3469 lines
150 KiB
TypeScript
3469 lines
150 KiB
TypeScript
import { AlertCircle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, EyeOff, FolderKanban, MailCheck, MailPlus, Pencil, Play, Plus, RefreshCw, RotateCcw, Save, Send, Star, Trash2 } from "lucide-react";
|
|
import { type ClipboardEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
|
import { HelpLink } from "@/components/HelpLink";
|
|
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
|
import { readRichActivityEditorValue, RichActivityEditor } from "@/components/RichActivityEditor";
|
|
import { TodoPanel } from "@/components/TodoPanel";
|
|
import {
|
|
acknowledgeProjectReview,
|
|
createSession,
|
|
deleteProject,
|
|
deleteSession,
|
|
hideProject,
|
|
addFavoriteTicket,
|
|
getDashboardPreference,
|
|
getTicketDetails,
|
|
getTicketPeriod,
|
|
importTicketZammadMailSessions,
|
|
previewTicketZammadMailSessions,
|
|
removeFavoriteTicket,
|
|
resendSessionZammadArticle,
|
|
syncProjectTeamspaceSubentries,
|
|
updateProjectTeamspaceSubentry,
|
|
updateSessionBilling,
|
|
updateSessionDetails,
|
|
updateTicket,
|
|
updateTicketDayBilling,
|
|
updateProject,
|
|
updateProjectStatus
|
|
} from "../api";
|
|
import type { CreateSessionResult, TicketZammadMailSessionItem, TicketZammadMailSessionResult } from "../api";
|
|
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
|
|
import { ticketHasRecurringBilling } from "../recurring-billings";
|
|
import type { AuthUser, BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, ProjectTeamspaceSubentry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
|
|
import { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta";
|
|
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
|
|
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type TicketDetailPageProps = {
|
|
periodType: TicketDetailPeriodType;
|
|
period: string;
|
|
ticketId: string;
|
|
onNavigate: (to: string) => void;
|
|
onStartTimer: (ticketNumber: string) => Promise<boolean>;
|
|
currentUser: AuthUser;
|
|
onOpenTicketChange?: (ticket: {
|
|
key: string;
|
|
ticketId: string;
|
|
ticketNumber: string;
|
|
periodType: TicketDetailPeriodType;
|
|
period: string;
|
|
path: string;
|
|
returnPath?: string;
|
|
dirty: boolean;
|
|
}) => void;
|
|
returnPath: string;
|
|
};
|
|
|
|
type TicketSessionFormState = {
|
|
organizationId: string;
|
|
organizationName: string | null;
|
|
projectTeamspaceEntryId: string;
|
|
activity: string;
|
|
zammadArticleBody: string;
|
|
billingStatus: BillingStatus;
|
|
createZammadArticle: boolean;
|
|
zammadArticleTypeId: ZammadArticleTypeId;
|
|
workType: WorkType;
|
|
day: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
};
|
|
|
|
type ProjectEditFormState = {
|
|
title: string;
|
|
organizationId: string;
|
|
organizationName: string | null;
|
|
description: string;
|
|
startsOn: string;
|
|
endsOn: string;
|
|
teamspaceEntries: ProjectTeamspaceEntryForm[];
|
|
};
|
|
|
|
type ProjectTeamspaceEntryForm = {
|
|
clientId: string;
|
|
teamspaceProject: string;
|
|
teamspaceDescription: string;
|
|
hourBudget: string;
|
|
budgetSyncDisabled: boolean;
|
|
manualConsumption: string;
|
|
isDefault: boolean;
|
|
};
|
|
|
|
type TeamspaceSubentryEditDraft = {
|
|
alias: string;
|
|
};
|
|
|
|
function emptyTicketSessionForm(): TicketSessionFormState {
|
|
return {
|
|
organizationId: "",
|
|
organizationName: null,
|
|
projectTeamspaceEntryId: "",
|
|
activity: "",
|
|
zammadArticleBody: "",
|
|
billingStatus: null,
|
|
createZammadArticle: false,
|
|
zammadArticleTypeId: 6,
|
|
workType: "support",
|
|
day: currentDay(),
|
|
startTime: "09:00",
|
|
endTime: "09:30"
|
|
};
|
|
}
|
|
|
|
function notifyZammadArticleResult(result: CreateSessionResult) {
|
|
if (result.zammadArticle?.status === "sent") {
|
|
toast.success("Zammad-Notiz übertragen");
|
|
return;
|
|
}
|
|
|
|
if (result.zammadArticle?.status === "failed") {
|
|
toast.error("Zammad-Notiz nicht übertragen", {
|
|
description: result.zammadArticle.error ?? "Die Session wurde gespeichert, aber Zammad hat die Notiz nicht angenommen."
|
|
});
|
|
}
|
|
}
|
|
|
|
function notifyTodosChanged() {
|
|
window.dispatchEvent(new Event("tickettracker:todos-changed"));
|
|
}
|
|
|
|
function zammadSyncStorageKey(userId: string) {
|
|
return `tickettracker.zammad-sync.${userId}`;
|
|
}
|
|
|
|
function teamspaceSubentryAccordionStorageKey(userId: string, projectId: string) {
|
|
return `tickettracker.teamspace-subentry-accordion.${userId}.${projectId}`;
|
|
}
|
|
|
|
function parseStoredTeamspaceSubentryGroups(value: string | null, validIds: Set<string>) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
|
|
if (!Array.isArray(parsed)) {
|
|
return null;
|
|
}
|
|
|
|
return Array.from(new Set(parsed.filter((id): id is string => typeof id === "string" && validIds.has(id))));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseMailDaysBack(value: unknown) {
|
|
const parsed = Number(value);
|
|
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 3650 ? parsed : 30;
|
|
}
|
|
|
|
function mailDaysBackFromZammadSyncSettings(userId: string) {
|
|
const raw = getLocalStorageValue(zammadSyncStorageKey(userId));
|
|
|
|
if (!raw) {
|
|
return 30;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw) as { mailDaysBack?: unknown };
|
|
return parseMailDaysBack(parsed.mailDaysBack);
|
|
} catch {
|
|
return 30;
|
|
}
|
|
}
|
|
|
|
function projectEntryId() {
|
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
}
|
|
|
|
function escapeHtml(value: string) {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function textToEditorHtml(value: string) {
|
|
return value
|
|
.split(/\r?\n/)
|
|
.map((line) => `<div>${line ? escapeHtml(line) : "<br>"}</div>`)
|
|
.join("");
|
|
}
|
|
|
|
function fileToDataUrl(file: File) {
|
|
return new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
|
|
reader.addEventListener("error", () => reject(reader.error ?? new Error("Bild konnte nicht gelesen werden")));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
function insertEditorHtml(html: string) {
|
|
document.execCommand("insertHTML", false, html);
|
|
}
|
|
|
|
function currentSelectionRange() {
|
|
const selection = window.getSelection();
|
|
return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
|
|
}
|
|
|
|
function restoreSelectionRange(range: Range | null) {
|
|
if (!range) {
|
|
return;
|
|
}
|
|
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
}
|
|
|
|
function editorPlainText(element: HTMLElement) {
|
|
return element.innerText.replace(/\u00a0/g, " ").trim();
|
|
}
|
|
|
|
function hasInlineImage(element: HTMLElement) {
|
|
return Boolean(element.querySelector("img[src^='data:image/']"));
|
|
}
|
|
|
|
function normalizedEditorHtml(element: HTMLElement) {
|
|
const html = element.innerHTML.trim();
|
|
return html && (editorPlainText(element) || hasInlineImage(element)) ? html : "";
|
|
}
|
|
|
|
function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] {
|
|
if (project.teamspace_entries?.length) {
|
|
return project.teamspace_entries;
|
|
}
|
|
|
|
return [
|
|
{
|
|
id: `${project.id}-default`,
|
|
teamspace_project: project.teamspace_project,
|
|
teamspace_description: "",
|
|
is_default: true,
|
|
hour_budget_minutes: project.hour_budget_minutes,
|
|
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
|
|
teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0,
|
|
consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0,
|
|
display_order: 0
|
|
}
|
|
];
|
|
}
|
|
|
|
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
|
|
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
|
|
}
|
|
|
|
function teamspaceEntryDisplayLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
|
|
return entry.teamspace_description?.trim() || entry.teamspace_project || "Ohne Teamspace-ID";
|
|
}
|
|
|
|
function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
|
|
const label = teamspaceEntryDisplayLabel(entry);
|
|
|
|
return entry.is_default ? `${label} (Standard)` : label;
|
|
}
|
|
|
|
function teamspaceEntryDescription(entry: { teamspace_project?: string | null; teamspace_description?: string | null } | null | undefined) {
|
|
return entry?.teamspace_description?.trim() ? entry.teamspace_project?.trim() || "" : "";
|
|
}
|
|
|
|
function teamspaceSubentryOptionLabel(subentry: ProjectTeamspaceSubentry) {
|
|
return subentry.alias?.trim() || subentry.name?.trim() || subentry.teamspace_project_id || `Position ${subentry.display_order + 1}`;
|
|
}
|
|
|
|
function teamspacePositionDisplayId(value: string | null | undefined) {
|
|
if (!value) {
|
|
return "";
|
|
}
|
|
|
|
return value
|
|
.split(".")
|
|
.map((part) => (/^\d+$/.test(part) ? String(Number(part)) : part))
|
|
.join(".");
|
|
}
|
|
|
|
function teamspaceSubentryDescription(subentry: ProjectTeamspaceSubentry | null | undefined) {
|
|
return subentry?.description?.trim() || "";
|
|
}
|
|
|
|
function decodeHtmlEntities(value: string) {
|
|
const namedEntities: Record<string, string> = {
|
|
amp: "&",
|
|
gt: ">",
|
|
lt: "<",
|
|
quot: "\"",
|
|
apos: "'",
|
|
nbsp: " ",
|
|
auml: "ä",
|
|
Auml: "Ä",
|
|
ouml: "ö",
|
|
Ouml: "Ö",
|
|
uuml: "ü",
|
|
Uuml: "Ü",
|
|
szlig: "ß",
|
|
euro: "€"
|
|
};
|
|
|
|
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/gi, (match, entity: string) => {
|
|
if (entity.startsWith("#x")) {
|
|
const codePoint = Number.parseInt(entity.slice(2), 16);
|
|
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : match;
|
|
}
|
|
|
|
if (entity.startsWith("#")) {
|
|
const codePoint = Number.parseInt(entity.slice(1), 10);
|
|
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : match;
|
|
}
|
|
|
|
return namedEntities[entity] ?? match;
|
|
});
|
|
}
|
|
|
|
function teamspaceFormattedText(value: string | null | undefined) {
|
|
if (!value) {
|
|
return "";
|
|
}
|
|
|
|
return decodeHtmlEntities(
|
|
value
|
|
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
|
|
.replace(/<\/\s*(p|div|li|ul|ol)\s*>/gi, "\n")
|
|
.replace(/<[^>]*>/g, "")
|
|
)
|
|
.replace(/\u00a0/g, " ")
|
|
.replace(/[ \t]+\n/g, "\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
function teamspaceProgressClass(progress: number | null | undefined) {
|
|
const value = Number(progress ?? 0);
|
|
|
|
if (value >= 100) {
|
|
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
|
}
|
|
|
|
if (value >= 75) {
|
|
return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
|
}
|
|
|
|
return "border-muted-foreground/20 bg-muted/40 text-muted-foreground";
|
|
}
|
|
|
|
function teamspaceBillableUsagePercent(sumMinutes: number | null | undefined, plannedMinutes: number | null | undefined) {
|
|
const planned = Number(plannedMinutes ?? 0);
|
|
|
|
if (!Number.isFinite(planned) || planned <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const used = Number(sumMinutes ?? 0);
|
|
return Math.round((used / planned) * 100);
|
|
}
|
|
|
|
function teamspaceFixedUnitCount(subentry: ProjectTeamspaceSubentry, kind: "sum" | "planned") {
|
|
return kind === "sum"
|
|
? Number(subentry.sum_minutes_billable ?? subentry.sum_minutes ?? 0)
|
|
: Number(subentry.planned_minutes_billable ?? subentry.planned_minutes ?? 0);
|
|
}
|
|
|
|
function teamspaceIncomeValue(value: number | string | null | undefined) {
|
|
const number = Number(value ?? 0);
|
|
return Number.isFinite(number) ? number : 0;
|
|
}
|
|
|
|
function isTeamspaceFixedPricePosition(billingType: string | null | undefined) {
|
|
return billingType === "INVOICEABLE_FIX";
|
|
}
|
|
|
|
function teamspaceFixedPriceFulfilled(subentry: ProjectTeamspaceSubentry) {
|
|
const planned = teamspaceIncomeValue(subentry.planned_income);
|
|
const used = teamspaceIncomeValue(subentry.sum_income);
|
|
|
|
return planned > 0 && used >= planned;
|
|
}
|
|
|
|
function teamspaceBillingKey(day: string, projectTeamspaceEntryId: string | null | undefined) {
|
|
return `${projectTeamspaceEntryId ?? "ticket"}::${day}`;
|
|
}
|
|
|
|
function dayBillingProjectEntryIdForView(
|
|
projectTeamspaceEntryId: string | null | undefined,
|
|
hasMultipleProjectTeamspaceEntries: boolean,
|
|
defaultProjectEntryId: string | null
|
|
) {
|
|
if (!hasMultipleProjectTeamspaceEntries) {
|
|
return null;
|
|
}
|
|
|
|
return projectTeamspaceEntryId ?? defaultProjectEntryId;
|
|
}
|
|
|
|
function projectEditForm(project: ProjectSummary): ProjectEditFormState {
|
|
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
|
|
clientId: entry.id || projectEntryId(),
|
|
teamspaceProject: entry.teamspace_project ?? "",
|
|
teamspaceDescription: entry.teamspace_description ?? "",
|
|
hourBudget: formatHoursInput(entry.hour_budget_minutes),
|
|
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
|
|
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
|
|
isDefault: entry.is_default || index === 0
|
|
}));
|
|
const hasDefault = entries.some((entry) => entry.isDefault);
|
|
|
|
return {
|
|
title: project.title ?? "",
|
|
organizationId: project.organization_id ?? "",
|
|
organizationName: project.organization_name ?? project.customer_name ?? null,
|
|
description: combinedProjectDescription(project),
|
|
startsOn: project.starts_on ?? "",
|
|
endsOn: project.ends_on ?? "",
|
|
teamspaceEntries: hasDefault ? entries : entries.map((entry, index) => ({ ...entry, isDefault: index === 0 }))
|
|
};
|
|
}
|
|
|
|
function teamspaceSubentryEditDrafts(project: ProjectSummary | null | undefined) {
|
|
const drafts: Record<string, TeamspaceSubentryEditDraft> = {};
|
|
|
|
for (const entry of project ? projectTeamspaceEntries(project) : []) {
|
|
for (const subentry of entry.subentries ?? []) {
|
|
drafts[subentry.id] = {
|
|
alias: subentry.alias ?? ""
|
|
};
|
|
}
|
|
}
|
|
|
|
return drafts;
|
|
}
|
|
|
|
function combinedProjectDescription(project: ProjectSummary) {
|
|
return [project.description, project.goals].map((part) => part.trim()).filter(Boolean).join("\n\n");
|
|
}
|
|
|
|
function InlineImageActivityEditor({
|
|
id,
|
|
value,
|
|
htmlValue,
|
|
onChange
|
|
}: {
|
|
id: string;
|
|
value: string;
|
|
htmlValue: string;
|
|
onChange: (value: string, htmlValue: string) => void;
|
|
}) {
|
|
const editorRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
const editor = editorRef.current;
|
|
|
|
if (!editor) {
|
|
return;
|
|
}
|
|
|
|
const nextHtml = htmlValue || (value ? textToEditorHtml(value) : "");
|
|
|
|
if (editor.innerHTML !== nextHtml) {
|
|
editor.innerHTML = nextHtml;
|
|
}
|
|
}, [htmlValue, value]);
|
|
|
|
function emitChange() {
|
|
const editor = editorRef.current;
|
|
|
|
if (!editor) {
|
|
return;
|
|
}
|
|
|
|
onChange(editorPlainText(editor), normalizedEditorHtml(editor));
|
|
}
|
|
|
|
async function handlePaste(event: ClipboardEvent<HTMLDivElement>) {
|
|
const imageFiles = Array.from(event.clipboardData.items)
|
|
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
|
.map((item) => item.getAsFile())
|
|
.filter((file): file is File => Boolean(file));
|
|
|
|
if (imageFiles.length === 0) {
|
|
const text = event.clipboardData.getData("text/plain");
|
|
|
|
if (text) {
|
|
event.preventDefault();
|
|
document.execCommand("insertText", false, text);
|
|
emitChange();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
let insertionRange = currentSelectionRange();
|
|
const text = event.clipboardData.getData("text/plain").trim();
|
|
if (text) {
|
|
document.execCommand("insertText", false, `${text}\n`);
|
|
insertionRange = currentSelectionRange();
|
|
}
|
|
|
|
try {
|
|
for (const file of imageFiles) {
|
|
const dataUrl = await fileToDataUrl(file);
|
|
restoreSelectionRange(insertionRange);
|
|
insertEditorHtml(`<img src="${dataUrl}" style="display:block;max-width:100%;height:auto;margin-top:0.5rem;border-radius:0.375rem;"><br>`);
|
|
insertionRange = currentSelectionRange();
|
|
}
|
|
emitChange();
|
|
} catch (error) {
|
|
toast.error("Screenshot konnte nicht eingefügt werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
id={id}
|
|
ref={editorRef}
|
|
role="textbox"
|
|
aria-multiline="true"
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
className="min-h-24 w-full overflow-hidden rounded-lg border border-input bg-transparent px-2.5 py-2 text-base outline-none transition-colors empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm [&_img]:my-2 [&_img]:max-h-[28rem] [&_img]:max-w-full [&_img]:rounded-md"
|
|
data-placeholder="Tätigkeit beschreiben, Screenshot mit Strg+V einfügen"
|
|
onInput={emitChange}
|
|
onBlur={emitChange}
|
|
onPaste={(event) => void handlePaste(event)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function BillingButtons({
|
|
session,
|
|
disabled,
|
|
loading,
|
|
onChange
|
|
}: {
|
|
session: SessionEntry;
|
|
disabled: boolean;
|
|
loading: boolean;
|
|
onChange: (status: BillingStatus) => void;
|
|
}) {
|
|
const status = session.billing_status;
|
|
|
|
return (
|
|
<div className="grid grid-cols-3 gap-2 sm:inline-grid lg:gap-3">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={status === "billable" ? "default" : "secondary"}
|
|
disabled={loading || (disabled && status !== "billable")}
|
|
onClick={() => onChange(status === "billable" ? null : "billable")}
|
|
className={cn("h-8", status === "billable" ? "" : "bg-secondary/70")}
|
|
>
|
|
Abrechenbar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={status === "billed" ? "default" : "secondary"}
|
|
disabled={loading || (disabled && status !== "billed")}
|
|
onClick={() => onChange(status === "billed" ? null : "billed")}
|
|
className={cn("h-8", status === "billed" ? "" : "bg-secondary/70")}
|
|
>
|
|
Abgerechnet
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={status === "non_billable" ? "default" : "secondary"}
|
|
disabled={loading || (disabled && status !== "non_billable")}
|
|
onClick={() => onChange(status === "non_billable" ? null : "non_billable")}
|
|
className={cn("h-8", status === "non_billable" ? "" : "bg-secondary/70")}
|
|
>
|
|
Nicht abrechenbar
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BillingCaptureSelect({
|
|
id,
|
|
value,
|
|
onChange
|
|
}: {
|
|
id: string;
|
|
value: BillingStatus;
|
|
onChange: (status: BillingStatus) => void;
|
|
}) {
|
|
return (
|
|
<select
|
|
id={id}
|
|
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
value={value ?? ""}
|
|
onChange={(event) => onChange(event.currentTarget.value === "" ? null : event.currentTarget.value as Exclude<BillingStatus, null>)}
|
|
>
|
|
<option value="">---</option>
|
|
<option value="billable">Abrechenbar</option>
|
|
<option value="non_billable">Nicht abrechenbar</option>
|
|
</select>
|
|
);
|
|
}
|
|
|
|
function sessionDayKey(value: string) {
|
|
const date = new Date(value);
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
async function writeTextToClipboard(value: string) {
|
|
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(value);
|
|
return;
|
|
}
|
|
|
|
if (typeof document === "undefined") {
|
|
throw new Error("Zwischenablage nicht verfügbar");
|
|
}
|
|
|
|
const textarea = document.createElement("textarea");
|
|
textarea.value = value;
|
|
textarea.style.position = "fixed";
|
|
textarea.style.left = "-9999px";
|
|
textarea.setAttribute("readonly", "");
|
|
document.body.appendChild(textarea);
|
|
textarea.focus();
|
|
textarea.select();
|
|
|
|
try {
|
|
if (!document.execCommand("copy")) {
|
|
throw new Error("Zwischenablage nicht verfügbar");
|
|
}
|
|
} finally {
|
|
document.body.removeChild(textarea);
|
|
}
|
|
}
|
|
|
|
function teamspacePreparationText(ticket: TicketPeriod["ticket"] | null | undefined, sessions: SessionEntry[]) {
|
|
const ticketNumber = ticket?.ticket_number ?? "Ticket#-";
|
|
const customerName = ticket?.organization_name ?? ticket?.customer_name ?? "-";
|
|
const activities = sessions
|
|
.slice()
|
|
.sort((left, right) => new Date(left.started_at).getTime() - new Date(right.started_at).getTime())
|
|
.map((session) => session.activity.trim())
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
return [`${ticketNumber} - ${customerName}`, "", activities, "", "Tickethandling und Dokumentation"].join("\n");
|
|
}
|
|
|
|
function SessionActivityBody({ session }: { session: SessionEntry }) {
|
|
if (session.zammad_article_body) {
|
|
return (
|
|
<div
|
|
className="break-words text-sm font-medium [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_br]:block [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:text-base [&_h3]:font-semibold [&_img]:my-2 [&_img]:max-h-80 [&_img]:max-w-full [&_img]:rounded-md [&_ol]:ml-5 [&_ol]:list-decimal [&_p]:my-1 [&_pre]:whitespace-pre-wrap [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-2 [&_strike]:line-through [&_ul]:ml-5 [&_ul]:list-disc"
|
|
dangerouslySetInnerHTML={{ __html: session.zammad_article_body }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity || "Keine Tätigkeit erfasst."}</p>;
|
|
}
|
|
|
|
function toDateLocalValue(value: string) {
|
|
const date = new Date(value);
|
|
const offsetMs = date.getTimezoneOffset() * 60_000;
|
|
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 10);
|
|
}
|
|
|
|
function toTimeLocalValue(value: string) {
|
|
const date = new Date(value);
|
|
const offsetMs = date.getTimezoneOffset() * 60_000;
|
|
return new Date(date.getTime() - offsetMs).toISOString().slice(11, 16);
|
|
}
|
|
|
|
function formatHoursInput(minutes?: number | null) {
|
|
if (minutes === null || minutes === undefined) {
|
|
return "";
|
|
}
|
|
|
|
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60);
|
|
}
|
|
|
|
function parseHoursInput(value: string) {
|
|
const trimmed = value.trim();
|
|
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (/[,.]$/.test(trimmed)) {
|
|
return undefined;
|
|
}
|
|
|
|
const hours = Number(trimmed.replace(",", "."));
|
|
|
|
if (!Number.isFinite(hours) || hours < 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return Math.round(hours * 60);
|
|
}
|
|
|
|
function projectConsumptionBarClass(percent: number) {
|
|
if (percent >= 100) {
|
|
return "bg-blue-500";
|
|
}
|
|
|
|
if (percent >= 90) {
|
|
return "bg-red-500";
|
|
}
|
|
|
|
return "bg-emerald-500";
|
|
}
|
|
|
|
function projectConsumptionPercent(consumptionMinutes: number, budgetMinutes?: number | null) {
|
|
if (!budgetMinutes || budgetMinutes <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return Math.round((consumptionMinutes / budgetMinutes) * 100);
|
|
}
|
|
|
|
function projectRemainingLabel(remainingMinutes: number | null) {
|
|
if (remainingMinutes === null) {
|
|
return "Rest offen";
|
|
}
|
|
|
|
if (remainingMinutes < 0) {
|
|
return `Überzogen ${formatMinutes(Math.abs(remainingMinutes))}`;
|
|
}
|
|
|
|
return `Rest ${formatMinutes(remainingMinutes)}`;
|
|
}
|
|
|
|
function isPartialHoursInput(value: string) {
|
|
return /^\d*(?:[,.]\d*)?$/.test(value.trim());
|
|
}
|
|
|
|
function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
|
const source = entries.length
|
|
? entries
|
|
: [
|
|
{
|
|
clientId: projectEntryId(),
|
|
teamspaceProject: "",
|
|
teamspaceDescription: "",
|
|
hourBudget: "",
|
|
budgetSyncDisabled: false,
|
|
manualConsumption: "",
|
|
isDefault: true
|
|
}
|
|
];
|
|
const defaultIndex = Math.max(0, source.findIndex((entry) => entry.isDefault));
|
|
|
|
return source.map((entry, index) => ({
|
|
...entry,
|
|
isDefault: index === defaultIndex
|
|
}));
|
|
}
|
|
|
|
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
|
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
|
|
const parsedEntries = normalizedEntries.map((entry) => {
|
|
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
|
|
|
|
if (manualConsumptionMinutes === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
|
|
teamspaceProject: entry.teamspaceProject.trim() || null,
|
|
teamspaceDescription: entry.teamspaceDescription.trim() || null,
|
|
isDefault: entry.isDefault,
|
|
hourBudgetMinutes: null,
|
|
budgetSyncDisabled: false,
|
|
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
|
|
};
|
|
});
|
|
|
|
if (parsedEntries.some((entry) => entry === undefined)) {
|
|
return undefined;
|
|
}
|
|
|
|
return parsedEntries as Array<{
|
|
id?: string | null;
|
|
teamspaceProject: string | null;
|
|
teamspaceDescription: string | null;
|
|
isDefault: boolean;
|
|
hourBudgetMinutes: number | null;
|
|
budgetSyncDisabled: boolean;
|
|
manualConsumptionMinutes: number;
|
|
}>;
|
|
}
|
|
|
|
function ProjectTeamspaceEntriesEditor({
|
|
entries,
|
|
onChange,
|
|
showManualConsumption = true
|
|
}: {
|
|
entries: ProjectTeamspaceEntryForm[];
|
|
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
|
|
showManualConsumption?: boolean;
|
|
}) {
|
|
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
|
|
|
|
function updateEntry(clientId: string, changes: Partial<ProjectTeamspaceEntryForm>) {
|
|
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.map((entry) => (entry.clientId === clientId ? { ...entry, ...changes } : entry))));
|
|
}
|
|
|
|
function removeEntry(clientId: string) {
|
|
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.filter((entry) => entry.clientId !== clientId)));
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<label className="text-sm font-medium">Teamspace-Einträge</label>
|
|
<Button type="button" size="sm" variant="secondary" onClick={() => onChange([...normalizedEntries, { clientId: projectEntryId(), teamspaceProject: "", teamspaceDescription: "", hourBudget: "", budgetSyncDisabled: false, manualConsumption: "", isDefault: false }])}>
|
|
<Plus className="size-3.5" />
|
|
Eintrag
|
|
</Button>
|
|
</div>
|
|
{normalizedEntries.map((entry, index) => (
|
|
<div
|
|
key={entry.clientId}
|
|
className={cn(
|
|
"grid gap-2 rounded-lg border bg-muted/10 p-3 sm:items-end",
|
|
showManualConsumption
|
|
? "sm:grid-cols-[minmax(180px,1fr)_minmax(160px,1fr)_110px_120px_36px]"
|
|
: "sm:grid-cols-[minmax(180px,1fr)_minmax(160px,1fr)_120px_36px]"
|
|
)}
|
|
>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-teamspace-${entry.clientId}`}>
|
|
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
|
|
</label>
|
|
<Input
|
|
id={`project-ticket-teamspace-${entry.clientId}`}
|
|
value={entry.teamspaceProject}
|
|
onChange={(event) => updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-teamspace-description-${entry.clientId}`}>Info</label>
|
|
<Input
|
|
id={`project-ticket-teamspace-description-${entry.clientId}`}
|
|
value={entry.teamspaceDescription}
|
|
placeholder="z.B. Cloud-Umzug"
|
|
onChange={(event) => updateEntry(entry.clientId, { teamspaceDescription: event.currentTarget.value })}
|
|
/>
|
|
</div>
|
|
{showManualConsumption ? (
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-teamspace-manual-${entry.clientId}`}>Manuell h</label>
|
|
<Input
|
|
id={`project-ticket-teamspace-manual-${entry.clientId}`}
|
|
inputMode="decimal"
|
|
value={entry.manualConsumption}
|
|
onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={entry.isDefault ? "default" : "secondary"}
|
|
className="sm:self-end"
|
|
onClick={() => onChange(normalizedEntries.map((candidate) => ({ ...candidate, isDefault: candidate.clientId === entry.clientId })))}
|
|
>
|
|
{entry.isDefault ? "Standard" : "Als Standard"}
|
|
</Button>
|
|
<Button type="button" size="icon" variant="ghost" className="sm:self-end sm:justify-self-end" disabled={normalizedEntries.length === 1} onClick={() => removeEntry(entry.clientId)}>
|
|
<Trash2 className="size-4 text-destructive" />
|
|
<span className="sr-only">Teamspace-Eintrag {index + 1} löschen</span>
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onStartTimer, currentUser, onOpenTicketChange, returnPath }: TicketDetailPageProps) {
|
|
const initialGlobalMonthFilter = periodType === "all" && period !== "all" ? period : currentMonth();
|
|
const [data, setData] = useState<TicketPeriod | null>(null);
|
|
const [loadingId, setLoadingId] = useState<string | null>(null);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [savingTicket, setSavingTicket] = useState(false);
|
|
const [savingFavorite, setSavingFavorite] = useState(false);
|
|
const [isFavorite, setIsFavorite] = useState(false);
|
|
const [editTicketNumber, setEditTicketNumber] = useState("");
|
|
const [editTicketTitle, setEditTicketTitle] = useState("");
|
|
const [editOrganizationId, setEditOrganizationId] = useState("");
|
|
const [editOrganizationName, setEditOrganizationName] = useState<string | null>(null);
|
|
const [editWorkType, setEditWorkType] = useState<WorkType>("support");
|
|
const [editingSession, setEditingSession] = useState<SessionEntry | null>(null);
|
|
const [pendingProjectDelete, setPendingProjectDelete] = useState<{ projectId: string } | null>(null);
|
|
const [editSessionOrganizationId, setEditSessionOrganizationId] = useState("");
|
|
const [editSessionOrganizationName, setEditSessionOrganizationName] = useState<string | null>(null);
|
|
const [editSessionActivity, setEditSessionActivity] = useState("");
|
|
const [editSessionZammadArticleBody, setEditSessionZammadArticleBody] = useState("");
|
|
const [editSessionWorkType, setEditSessionWorkType] = useState<WorkType>("support");
|
|
const [editSessionProjectTeamspaceEntryId, setEditSessionProjectTeamspaceEntryId] = useState("");
|
|
const [editSessionDay, setEditSessionDay] = useState("");
|
|
const [editSessionStartTime, setEditSessionStartTime] = useState("");
|
|
const [editSessionEndTime, setEditSessionEndTime] = useState("");
|
|
const [savingSessionDetails, setSavingSessionDetails] = useState(false);
|
|
const [sendingZammadSessionId, setSendingZammadSessionId] = useState<string | null>(null);
|
|
const [dayBillingInputs, setDayBillingInputs] = useState<Record<string, string>>({});
|
|
const [savingDayBillingKey, setSavingDayBillingKey] = useState<string | null>(null);
|
|
const [selectedTeamspaceEntryId, setSelectedTeamspaceEntryId] = useState<string | null>(null);
|
|
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
|
|
const [savingTicketSession, setSavingTicketSession] = useState(false);
|
|
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
|
|
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billable" | "billed" | "non_billable">("all");
|
|
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
|
|
const [editingProject, setEditingProject] = useState(false);
|
|
const [savingProject, setSavingProject] = useState(false);
|
|
const [deletingEmptyProject, setDeletingEmptyProject] = useState(false);
|
|
const [savingProjectStatus, setSavingProjectStatus] = useState(false);
|
|
const [syncingTeamspaceSubentries, setSyncingTeamspaceSubentries] = useState(false);
|
|
const [editingTeamspaceSubentries, setEditingTeamspaceSubentries] = useState(false);
|
|
const [openTeamspaceSubentryGroups, setOpenTeamspaceSubentryGroups] = useState<string[]>([]);
|
|
const [teamspaceSubentryDrafts, setTeamspaceSubentryDrafts] = useState<Record<string, TeamspaceSubentryEditDraft>>({});
|
|
const [savingTeamspaceSubentryId, setSavingTeamspaceSubentryId] = useState<string | null>(null);
|
|
const [projectForm, setProjectForm] = useState<ProjectEditFormState>({
|
|
title: "",
|
|
organizationId: "",
|
|
organizationName: null,
|
|
description: "",
|
|
startsOn: "",
|
|
endsOn: "",
|
|
teamspaceEntries: [
|
|
{
|
|
clientId: projectEntryId(),
|
|
teamspaceProject: "",
|
|
teamspaceDescription: "",
|
|
hourBudget: "",
|
|
budgetSyncDisabled: false,
|
|
manualConsumption: "",
|
|
isDefault: true
|
|
}
|
|
]
|
|
});
|
|
const [todoFilter, setTodoFilter] = useState<"open" | "all" | "done">("open");
|
|
const [expandedSessionIds, setExpandedSessionIds] = useState<string[]>([]);
|
|
const [projectDescriptionExpanded, setProjectDescriptionExpanded] = useState(false);
|
|
const [teamspacePreparation, setTeamspacePreparation] = useState<{ day: string; label: string; text: string } | null>(null);
|
|
const [mailPreview, setMailPreview] = useState<TicketZammadMailSessionResult | null>(null);
|
|
const [mailSelection, setMailSelection] = useState<Record<number, boolean>>({});
|
|
const [loadingMailPreview, setLoadingMailPreview] = useState(false);
|
|
const [importingMailSessions, setImportingMailSessions] = useState(false);
|
|
const [mailImportOpen, setMailImportOpen] = useState(false);
|
|
|
|
async function load() {
|
|
try {
|
|
setData(
|
|
periodType === "all"
|
|
? await getTicketDetails(ticketId, {
|
|
month: globalMonthFilter,
|
|
status: globalStatusFilter,
|
|
source: globalSourceFilter
|
|
})
|
|
: await getTicketPeriod(periodType, period, ticketId)
|
|
);
|
|
} catch (error) {
|
|
toast.error("Ticket konnte nicht geladen werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [periodType, period, ticketId, globalMonthFilter, globalStatusFilter, globalSourceFilter]);
|
|
|
|
useEffect(() => {
|
|
if (periodType !== "all") {
|
|
return;
|
|
}
|
|
|
|
setGlobalMonthFilter(period !== "all" ? period : currentMonth());
|
|
}, [periodType, period, ticketId]);
|
|
|
|
useEffect(() => {
|
|
const loadedProjectEntries = data?.project ? projectTeamspaceEntries(data.project) : [];
|
|
const hasMultipleEntries = loadedProjectEntries.length > 1;
|
|
const defaultEntryId = loadedProjectEntries.find((entry) => entry.is_default)?.id ?? null;
|
|
const nextInputMinutes = new Map<string, number>();
|
|
|
|
for (const billing of data?.dayBillings ?? []) {
|
|
const key = teamspaceBillingKey(
|
|
billing.day,
|
|
dayBillingProjectEntryIdForView(billing.project_teamspace_entry_id, hasMultipleEntries, defaultEntryId)
|
|
);
|
|
nextInputMinutes.set(key, (nextInputMinutes.get(key) ?? 0) + Number(billing.billed_minutes ?? 0));
|
|
}
|
|
|
|
const nextInputs = Object.fromEntries(
|
|
Array.from(nextInputMinutes.entries()).map(([key, minutes]) => [key, formatHoursInput(minutes)])
|
|
);
|
|
setDayBillingInputs(nextInputs);
|
|
}, [data?.dayBillings, data?.project]);
|
|
|
|
useEffect(() => {
|
|
if (!data?.ticket) {
|
|
return;
|
|
}
|
|
|
|
setEditTicketNumber(data.ticket.ticket_number);
|
|
setEditTicketTitle(data.ticket.title ?? "");
|
|
setEditOrganizationId(data.ticket.organization_id ?? "");
|
|
setEditOrganizationName(data.ticket.organization_name ?? null);
|
|
setEditWorkType(data.ticket.work_type ?? "support");
|
|
const loadedProjectEntries = data.project ? projectTeamspaceEntries(data.project) : [];
|
|
setTicketSession((current) => ({
|
|
...current,
|
|
organizationId: data.ticket.organization_id ?? current.organizationId,
|
|
organizationName: data.ticket.organization_name ?? current.organizationName,
|
|
projectTeamspaceEntryId: loadedProjectEntries.length === 1 ? loadedProjectEntries[0].id : current.projectTeamspaceEntryId,
|
|
workType: data.ticket.work_type ?? current.workType
|
|
}));
|
|
}, [data?.ticket, data?.project]);
|
|
|
|
useEffect(() => {
|
|
setTeamspaceSubentryDrafts(teamspaceSubentryEditDrafts(data?.project));
|
|
}, [data?.project]);
|
|
|
|
useEffect(() => {
|
|
setTicketSession((current) => {
|
|
if (
|
|
current.activity ||
|
|
current.billingStatus ||
|
|
(current.createZammadArticle === currentUser.zammad_session_note_default_enabled && current.zammadArticleTypeId === currentUser.zammad_article_type_id)
|
|
) {
|
|
return current;
|
|
}
|
|
|
|
return {
|
|
...current,
|
|
createZammadArticle: currentUser.zammad_session_note_default_enabled,
|
|
zammadArticleTypeId: currentUser.zammad_article_type_id
|
|
};
|
|
});
|
|
}, [currentUser.zammad_article_type_id, currentUser.zammad_session_note_default_enabled]);
|
|
|
|
useEffect(() => {
|
|
setProjectDescriptionExpanded(false);
|
|
}, [data?.project?.id]);
|
|
|
|
useEffect(() => {
|
|
setMailPreview(null);
|
|
setMailSelection({});
|
|
}, [ticketId]);
|
|
|
|
const ticket = data?.ticket;
|
|
const project = data?.project ?? null;
|
|
const isRecurringBillingTicket = ticketHasRecurringBilling(ticket);
|
|
const projectDescription = project ? combinedProjectDescription(project) : "";
|
|
const projectEntries = useMemo(() => (project ? projectTeamspaceEntries(project) : []), [project]);
|
|
const budgetedProjectEntries = useMemo(() => (project ? budgetedProjectTeamspaceEntries(project) : []), [project]);
|
|
const projectTeamspaceSubentryGroups = useMemo(
|
|
() => projectEntries
|
|
.map((entry) => ({
|
|
entry,
|
|
subentries: entry.subentries ?? []
|
|
}))
|
|
.filter((group) => group.subentries.length > 0),
|
|
[projectEntries]
|
|
);
|
|
const projectTeamspaceSubentryCount = projectTeamspaceSubentryGroups.reduce((sum, group) => sum + group.subentries.length, 0);
|
|
const teamspaceSubentryGroupIds = useMemo(() => projectTeamspaceSubentryGroups.map((group) => group.entry.id), [projectTeamspaceSubentryGroups]);
|
|
const teamspaceSubentryAccordionKey = project ? teamspaceSubentryAccordionStorageKey(currentUser.id, project.id) : null;
|
|
useEffect(() => {
|
|
const validIds = new Set(teamspaceSubentryGroupIds);
|
|
|
|
setOpenTeamspaceSubentryGroups((current) => {
|
|
const stored = parseStoredTeamspaceSubentryGroups(
|
|
teamspaceSubentryAccordionKey ? getLocalStorageValue(teamspaceSubentryAccordionKey) : null,
|
|
validIds
|
|
);
|
|
|
|
if (stored) {
|
|
return stored;
|
|
}
|
|
|
|
return current.filter((id) => validIds.has(id));
|
|
});
|
|
}, [teamspaceSubentryAccordionKey, teamspaceSubentryGroupIds]);
|
|
|
|
function updateOpenTeamspaceSubentryGroups(nextOpenGroups: string[]) {
|
|
const validIds = new Set(teamspaceSubentryGroupIds);
|
|
const next = Array.from(new Set(nextOpenGroups.filter((id) => validIds.has(id))));
|
|
|
|
setOpenTeamspaceSubentryGroups(next);
|
|
|
|
if (teamspaceSubentryAccordionKey) {
|
|
setLocalStorageValue(teamspaceSubentryAccordionKey, JSON.stringify(next));
|
|
}
|
|
}
|
|
|
|
const hasMultipleProjectTeamspaceEntries = projectEntries.length > 1;
|
|
const hasProjectTeamspaceApiIds = projectEntries.some((entry) => Boolean(entry.teamspace_api_project_id));
|
|
const showTeamspaceSubentryCard = Boolean(project && hasProjectTeamspaceApiIds);
|
|
const usesTeamspaceSubentries = false;
|
|
const showBudgetedProjectEntries = budgetedProjectEntries.length > 1;
|
|
const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries);
|
|
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
|
|
const projectConsumptionMinutes = project?.consumption_minutes ?? 0;
|
|
const projectBudgetMinutes = project?.hour_budget_minutes ?? null;
|
|
const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null;
|
|
const projectRemainingMinutes = projectBudgetMinutes && projectBudgetMinutes > 0 ? projectBudgetMinutes - projectConsumptionMinutes : null;
|
|
const canEditProject = project?.can_edit === true;
|
|
const unassignedTeamspaceEntryCount = teamspaceTimeTrackingEnabled ? Number(project?.unassigned_teamspace_session_count ?? 0) : 0;
|
|
const unassignedTeamspaceSubentryCount = 0;
|
|
const ticketManagedByZammad = Boolean(ticket?.zammad_ticket_id);
|
|
const canEditTicketData = ticket?.can_edit_ticket_data === true;
|
|
const zammadOwnerLabel = ticket?.zammad_owner_display_name
|
|
|| ticket?.zammad_owner_username
|
|
|| (ticket?.zammad_owner_id ? `Zammad-Owner ${ticket.zammad_owner_id}` : null);
|
|
const projectOwnerLabel = project?.owner_display_name
|
|
|| project?.owner_username
|
|
|| (project?.zammad_owner_id ? `Zammad-Owner ${project.zammad_owner_id}` : null)
|
|
|| zammadOwnerLabel;
|
|
const currentZammadUserLabel = currentUser.zammad_user_id ? `Zammad-User ${currentUser.zammad_user_id}` : "dein Zammad-User";
|
|
const isProject = Boolean(project);
|
|
const isGlobalTicket = periodType === "all";
|
|
const isPeriodClosed = Boolean(data?.closed);
|
|
const mailDaysBack = useMemo(() => mailDaysBackFromZammadSyncSettings(currentUser.id), [currentUser.id]);
|
|
const selectedMailArticleIds = useMemo(
|
|
() => (mailPreview?.items ?? []).filter((item) => mailSelection[item.articleId] !== false).map((item) => item.articleId),
|
|
[mailPreview?.items, mailSelection]
|
|
);
|
|
const allMailSelected = Boolean(mailPreview?.items.length) && selectedMailArticleIds.length === mailPreview?.items.length;
|
|
const canPreviewMailSessions = Boolean(ticket?.work_type && currentUser.has_zammad_api_token && currentUser.zammad_user_id);
|
|
const periodLabel = periodType === "month" ? "Monat" : periodType === "day" ? "Tag" : "Ticket";
|
|
const sessionsScopeLabel = isGlobalTicket
|
|
? globalMonthFilter === "all"
|
|
? "im gesamten Ticketverlauf"
|
|
: `im Monat ${globalMonthFilter}`
|
|
: `im ${periodLabel.toLowerCase()} ${period}`;
|
|
const teamspaceLabel = isGlobalTicket || periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag";
|
|
const availableMonths = data?.availableMonths ?? [];
|
|
const selectedMonthHasSessions = availableMonths.some((month) => month.month === globalMonthFilter);
|
|
const defaultProjectEntryId = projectEntries.find((entry) => entry.is_default)?.id ?? null;
|
|
const dayBillingMap = useMemo(() => {
|
|
const billingMap = new Map<string, number>();
|
|
|
|
for (const billing of data?.dayBillings ?? []) {
|
|
const key = teamspaceBillingKey(
|
|
billing.day,
|
|
dayBillingProjectEntryIdForView(billing.project_teamspace_entry_id, requiresProjectTeamspaceEntry, defaultProjectEntryId)
|
|
);
|
|
billingMap.set(key, (billingMap.get(key) ?? 0) + Number(billing.billed_minutes ?? 0));
|
|
}
|
|
|
|
return billingMap;
|
|
}, [data?.dayBillings, defaultProjectEntryId, requiresProjectTeamspaceEntry]);
|
|
const savedDayBillingInput = (key: string) => formatHoursInput(dayBillingMap.get(key));
|
|
const isDayBillingDirty = (key: string) => (dayBillingInputs[key] ?? "") !== savedDayBillingInput(key);
|
|
const unassignedTeamspaceSessions = useMemo(
|
|
() => teamspaceTimeTrackingEnabled
|
|
? (data?.sessions ?? []).filter((session) =>
|
|
session.work_type === "consulting"
|
|
&& (session.billing_status === "billable" || session.billing_status === "billed")
|
|
&& !session.project_teamspace_entry_id
|
|
)
|
|
: [],
|
|
[data?.sessions, teamspaceTimeTrackingEnabled]
|
|
);
|
|
const usesTeamspaceEntryTabs = Boolean(project && hasMultipleProjectTeamspaceEntries && (requiresProjectTeamspaceEntry || usesTeamspaceSubentries));
|
|
const teamspaceTabs = useMemo(() => {
|
|
if (!usesTeamspaceEntryTabs) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
...projectEntries.map((entry) => ({
|
|
id: entry.id,
|
|
label: teamspaceEntryDisplayLabel(entry),
|
|
description: teamspaceEntryDescription(entry),
|
|
entry
|
|
})),
|
|
...(unassignedTeamspaceSessions.length > 0
|
|
? [{ id: "unassigned", label: "Ohne Zuordnung", description: "", entry: null }]
|
|
: [])
|
|
];
|
|
}, [projectEntries, unassignedTeamspaceSessions.length, usesTeamspaceEntryTabs]);
|
|
const activeTeamspaceEntryId = usesTeamspaceEntryTabs
|
|
? selectedTeamspaceEntryId && teamspaceTabs.some((tab) => tab.id === selectedTeamspaceEntryId)
|
|
? selectedTeamspaceEntryId
|
|
: teamspaceTabs[0]?.id ?? null
|
|
: null;
|
|
const selectedTicketSessionTeamspaceEntry = projectEntries.find((entry) => entry.id === ticketSession.projectTeamspaceEntryId) ?? null;
|
|
const selectedEditSessionTeamspaceEntry = projectEntries.find((entry) => entry.id === editSessionProjectTeamspaceEntryId) ?? null;
|
|
const projectEntryConsumptionById = useMemo(() => {
|
|
const consumption = new Map<string, number>();
|
|
|
|
for (const entry of projectEntries) {
|
|
consumption.set(entry.id, Number(entry.consumption_minutes ?? 0));
|
|
}
|
|
|
|
return consumption;
|
|
}, [projectEntries]);
|
|
const sessionGroups = useMemo(() => {
|
|
const groups = new Map<
|
|
string,
|
|
{
|
|
key: string;
|
|
day: string;
|
|
projectTeamspaceEntryId: string | null;
|
|
label: string;
|
|
totalMinutes: number;
|
|
billableMinutes: number;
|
|
nonBillableMinutes: number;
|
|
openMinutes: number;
|
|
sessions: SessionEntry[];
|
|
}
|
|
>();
|
|
|
|
for (const session of data?.sessions ?? []) {
|
|
if (usesTeamspaceEntryTabs) {
|
|
if (activeTeamspaceEntryId === "unassigned") {
|
|
if (session.project_teamspace_entry_id) {
|
|
continue;
|
|
}
|
|
} else if (session.project_teamspace_entry_id !== activeTeamspaceEntryId) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const day = sessionDayKey(session.started_at);
|
|
const projectTeamspaceEntryId =
|
|
activeTeamspaceEntryId && activeTeamspaceEntryId !== "unassigned"
|
|
? activeTeamspaceEntryId
|
|
: usesTeamspaceSubentries && projectEntries.length === 1
|
|
? projectEntries[0].id
|
|
: null;
|
|
const key = teamspaceBillingKey(day, projectTeamspaceEntryId);
|
|
const group = groups.get(key);
|
|
const billedMinutes = session.billing_status === "billed" ? session.rounded_minutes : 0;
|
|
const billableMinutes = session.billing_status === "billable" ? session.rounded_minutes : 0;
|
|
const nonBillableMinutes = session.billing_status === "non_billable" ? session.rounded_minutes : 0;
|
|
const openMinutes = session.billing_status === null || session.billing_status === "billable" ? session.rounded_minutes : 0;
|
|
|
|
if (group) {
|
|
group.sessions.push(session);
|
|
group.totalMinutes += billedMinutes;
|
|
group.billableMinutes += billableMinutes;
|
|
group.nonBillableMinutes += nonBillableMinutes;
|
|
group.openMinutes += openMinutes;
|
|
continue;
|
|
}
|
|
|
|
groups.set(key, {
|
|
key,
|
|
day,
|
|
projectTeamspaceEntryId,
|
|
label: formatDate(session.started_at),
|
|
totalMinutes: billedMinutes,
|
|
billableMinutes,
|
|
nonBillableMinutes,
|
|
openMinutes,
|
|
sessions: [session]
|
|
});
|
|
}
|
|
|
|
return Array.from(groups.values())
|
|
.map((group) => ({
|
|
...group,
|
|
sessions: [...group.sessions].sort((left, right) => new Date(right.started_at).getTime() - new Date(left.started_at).getTime())
|
|
}))
|
|
.sort((left, right) => right.key.localeCompare(left.key));
|
|
}, [activeTeamspaceEntryId, data?.sessions, projectEntries, usesTeamspaceEntryTabs, usesTeamspaceSubentries]);
|
|
const sessionIds = useMemo(() => sessionGroups.flatMap((group) => group.sessions.map((session) => session.id)), [sessionGroups]);
|
|
const allSessionsExpanded = sessionIds.length > 0 && sessionIds.every((sessionId) => expandedSessionIds.includes(sessionId));
|
|
const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0);
|
|
const periodTeamspaceMinutes = (data?.dayBillings ?? []).reduce((sum, billing) => sum + Number(billing.billed_minutes ?? 0), 0);
|
|
const ticketDataDirty = Boolean(
|
|
ticket &&
|
|
canEditTicketData &&
|
|
(editTicketNumber !== ticket.ticket_number ||
|
|
editTicketTitle !== (ticket.title ?? "") ||
|
|
editOrganizationId !== (ticket.organization_id ?? "") ||
|
|
editWorkType !== (ticket.work_type ?? "support"))
|
|
);
|
|
const todos = data?.todos ?? [];
|
|
const visibleTodos = todoFilter === "all" ? todos : todos.filter((todo) => todo.status === todoFilter);
|
|
const todoCounts = {
|
|
open: todos.filter((todo) => todo.status === "open").length,
|
|
done: todos.filter((todo) => todo.status === "done").length,
|
|
all: todos.length
|
|
};
|
|
const hasUnsavedChanges = ticketDataDirty || (teamspaceTimeTrackingEnabled && sessionGroups.some((group) => isDayBillingDirty(group.key)));
|
|
|
|
useEffect(() => {
|
|
setExpandedSessionIds((current) => current.filter((sessionId) => sessionIds.includes(sessionId)));
|
|
}, [sessionIds]);
|
|
|
|
useEffect(() => {
|
|
if (!ticket) {
|
|
return;
|
|
}
|
|
|
|
const ticketMonth = isGlobalTicket ? globalMonthFilter : periodType === "day" ? period.slice(0, 7) : period;
|
|
|
|
onOpenTicketChange?.({
|
|
key: `ticket:${ticket.id}`,
|
|
ticketId: ticket.id,
|
|
ticketNumber: ticket.ticket_number,
|
|
periodType: "all",
|
|
period: ticketMonth,
|
|
path: `/tickets/${ticket.id}?month=${ticketMonth}`,
|
|
returnPath,
|
|
dirty: hasUnsavedChanges
|
|
});
|
|
}, [ticket, periodType, period, isGlobalTicket, globalMonthFilter, returnPath, hasUnsavedChanges, onOpenTicketChange]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
if (!ticket?.ticket_number) {
|
|
setIsFavorite(false);
|
|
return;
|
|
}
|
|
|
|
async function loadFavoriteStatus() {
|
|
try {
|
|
const result = await getDashboardPreference();
|
|
const favorites = Array.isArray(result.preference.favorites) ? result.preference.favorites : [];
|
|
|
|
if (!cancelled) {
|
|
setIsFavorite(favorites.some((favorite) => favorite.ticketNumber === ticket?.ticket_number));
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setIsFavorite(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void loadFavoriteStatus();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [ticket?.ticket_number]);
|
|
|
|
async function setBilling(sessionId: string, billingStatus: BillingStatus) {
|
|
if (isPeriodClosed && billingStatus !== null) {
|
|
toast.warning("Monat ist abgeschlossen", {
|
|
description: "Öffne den Monat zuerst wieder, um Bewertungen zu ändern."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setLoadingId(sessionId);
|
|
try {
|
|
await updateSessionBilling(sessionId, billingStatus);
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Status konnte nicht gespeichert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setLoadingId(null);
|
|
}
|
|
}
|
|
|
|
async function sendSessionToZammad(session: SessionEntry) {
|
|
if (!session.activity.trim() && !session.zammad_article_body) {
|
|
toast.warning("Keine Zammad-Notiz möglich", {
|
|
description: "Die Session hat keine Tätigkeit."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSendingZammadSessionId(session.id);
|
|
try {
|
|
const result = await resendSessionZammadArticle(session.id, {
|
|
zammadArticleTypeId: session.zammad_article_type_id ?? currentUser.zammad_article_type_id
|
|
});
|
|
notifyZammadArticleResult(result);
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Zammad-Notiz konnte nicht gesendet werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSendingZammadSessionId(null);
|
|
}
|
|
}
|
|
|
|
async function previewTicketMailSessions() {
|
|
if (!ticket) {
|
|
return;
|
|
}
|
|
|
|
if (!canPreviewMailSessions) {
|
|
toast.warning("Mail-Sync nicht bereit", {
|
|
description: "Dieses Ticket braucht eine Zammad-Ticket-ID und dein Profil einen gültigen Zammad-Token."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setLoadingMailPreview(true);
|
|
try {
|
|
const result = await previewTicketZammadMailSessions(ticket.id, mailDaysBack);
|
|
setMailPreview(result);
|
|
setMailSelection(Object.fromEntries(result.items.map((item) => [item.articleId, true])));
|
|
toast.success("Mail-Vorschau erstellt", {
|
|
description: `${result.items.length} Mail-Session(s) können importiert werden.`
|
|
});
|
|
} catch (error) {
|
|
toast.error("Mail-Vorschau fehlgeschlagen", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setLoadingMailPreview(false);
|
|
}
|
|
}
|
|
|
|
async function importTicketMailSessions() {
|
|
if (!ticket || selectedMailArticleIds.length === 0) {
|
|
toast.info("Keine Mail-Sessions ausgewählt");
|
|
return;
|
|
}
|
|
|
|
setImportingMailSessions(true);
|
|
try {
|
|
const result = await importTicketZammadMailSessions(ticket.id, mailDaysBack, selectedMailArticleIds);
|
|
setMailPreview(null);
|
|
setMailSelection({});
|
|
setMailImportOpen(false);
|
|
const firstError = result.errors[0];
|
|
const description = firstError
|
|
? `${result.created} angelegt, ${result.skipped} ohne Transfer. Article ${firstError.articleId}: ${firstError.error}`
|
|
: `${result.created} angelegt, ${result.skipped} ohne Transfer.`;
|
|
const notify = result.created === 0 && result.errors.length > 0 ? toast.error : toast.success;
|
|
notify("Mail-Sessions importiert", { description });
|
|
if (result.created > 0) {
|
|
notifyTodosChanged();
|
|
}
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Mail-Sessions konnten nicht importiert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setImportingMailSessions(false);
|
|
}
|
|
}
|
|
|
|
function toggleMailSelection(articleId: number, checked: boolean) {
|
|
setMailSelection((current) => ({ ...current, [articleId]: checked }));
|
|
}
|
|
|
|
function toggleAllMailSelection(checked: boolean) {
|
|
setMailSelection(Object.fromEntries((mailPreview?.items ?? []).map((item) => [item.articleId, checked])));
|
|
}
|
|
|
|
function toggleSessionDetails(sessionId: string) {
|
|
setExpandedSessionIds((current) =>
|
|
current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [...current, sessionId]
|
|
);
|
|
}
|
|
|
|
function toggleAllSessionDetails() {
|
|
setExpandedSessionIds(allSessionsExpanded ? [] : sessionIds);
|
|
}
|
|
|
|
async function saveDayBilling(group: (typeof sessionGroups)[number]) {
|
|
if (!ticket) {
|
|
return;
|
|
}
|
|
|
|
if (isPeriodClosed) {
|
|
toast.warning("Monat ist abgeschlossen", {
|
|
description: "Öffne den Monat zuerst wieder, um Teamspace-Zeiten zu ändern."
|
|
});
|
|
return;
|
|
}
|
|
|
|
const parsedMinutes = parseHoursInput(dayBillingInputs[group.key] ?? "");
|
|
|
|
if (parsedMinutes === undefined) {
|
|
toast.error("Teamspace-Zeit prüfen", {
|
|
description: "Bitte Stunden als positive Zahl eintragen, z.B. 1,5."
|
|
});
|
|
return;
|
|
}
|
|
|
|
const currentMinutes = dayBillingMap.get(group.key) ?? null;
|
|
|
|
if (parsedMinutes === currentMinutes) {
|
|
return;
|
|
}
|
|
|
|
setSavingDayBillingKey(group.key);
|
|
try {
|
|
await updateTicketDayBilling(ticket.id, group.day, parsedMinutes, group.projectTeamspaceEntryId);
|
|
toast.success("Zeit gespeichert");
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Zeit konnte nicht gespeichert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSavingDayBillingKey(null);
|
|
}
|
|
}
|
|
|
|
async function saveTicketData(event: FormEvent) {
|
|
event.preventDefault();
|
|
|
|
if (!ticket) {
|
|
return;
|
|
}
|
|
|
|
if (!canEditTicketData) {
|
|
toast.error(ticketManagedByZammad ? "Ticketdaten kommen aus Zammad" : "Ticket gehört einem anderen Owner", {
|
|
description: ticketManagedByZammad
|
|
? "Nummer, Art, Titel und Organisation werden durch den Zammad-Sync verwaltet."
|
|
: "Nur der Owner kann Ticketdaten ändern."
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!editOrganizationId) {
|
|
toast.error("Organisation wählen");
|
|
return;
|
|
}
|
|
|
|
const removeProjectData = Boolean(project && editWorkType === "support");
|
|
|
|
if (removeProjectData) {
|
|
const confirmed = window.confirm(
|
|
`Ticket "${ticket.title || ticket.ticket_number}" auf Support umstellen? Projektdaten, Kontingent und Teamspace-Zeiten werden entfernt. Sessions und ToDos bleiben erhalten.`
|
|
);
|
|
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
setSavingTicket(true);
|
|
try {
|
|
await updateTicket(ticket.id, {
|
|
ticketNumber: editTicketNumber,
|
|
title: editTicketTitle || null,
|
|
organizationId: editOrganizationId,
|
|
workType: editWorkType,
|
|
removeProjectData
|
|
});
|
|
toast.success("Ticketdaten gespeichert", {
|
|
description: editWorkType === "consulting" ? "Das Ticket ist jetzt als Projekt angelegt." : "Organisation und Art wurden auf die vorhandenen Sessions übernommen."
|
|
});
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Ticketdaten konnten nicht gespeichert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSavingTicket(false);
|
|
}
|
|
}
|
|
|
|
function openProjectEditor() {
|
|
if (!project) {
|
|
return;
|
|
}
|
|
|
|
if (!canEditProject) {
|
|
toast.error("Projekt ist geteilt", {
|
|
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setProjectForm(projectEditForm(project));
|
|
setEditingProject(true);
|
|
}
|
|
|
|
async function saveProjectData(event: FormEvent) {
|
|
event.preventDefault();
|
|
|
|
if (!project || !ticket) {
|
|
return;
|
|
}
|
|
|
|
if (!canEditProject) {
|
|
toast.error("Projekt ist geteilt", {
|
|
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!projectForm.organizationId) {
|
|
toast.error("Organisation wählen");
|
|
return;
|
|
}
|
|
|
|
const teamspaceEntries = parseProjectTeamspaceEntryForms(projectForm.teamspaceEntries);
|
|
|
|
if (!teamspaceEntries) {
|
|
toast.error("Teamspace-Einträge prüfen", {
|
|
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
|
|
});
|
|
return;
|
|
}
|
|
|
|
const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
|
|
|
|
setSavingProject(true);
|
|
try {
|
|
await updateProject(project.id, {
|
|
ticketNumber: ticket.ticket_number,
|
|
title: projectForm.title || null,
|
|
organizationId: projectForm.organizationId,
|
|
teamspaceProject: defaultTeamspaceEntry.teamspaceProject,
|
|
teamspaceEntries,
|
|
description: projectForm.description,
|
|
goals: "",
|
|
startsOn: projectForm.startsOn || null,
|
|
endsOn: projectForm.endsOn || null,
|
|
hourBudgetMinutes: defaultTeamspaceEntry.hourBudgetMinutes,
|
|
manualConsumptionMinutes: defaultTeamspaceEntry.manualConsumptionMinutes
|
|
});
|
|
toast.success("Projekt gespeichert");
|
|
setEditingProject(false);
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Projekt konnte nicht gespeichert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSavingProject(false);
|
|
}
|
|
}
|
|
|
|
async function setProjectClosed(closed: boolean) {
|
|
if (!project) {
|
|
return;
|
|
}
|
|
|
|
if (!canEditProject) {
|
|
toast.error("Projekt ist geteilt", {
|
|
description: "Nur der Zammad-Owner kann den Projektstatus ändern."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSavingProjectStatus(true);
|
|
try {
|
|
await updateProjectStatus(project.id, closed);
|
|
toast.success(closed ? "Projekt geschlossen" : "Projekt wieder geöffnet");
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Projektstatus konnte nicht gespeichert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSavingProjectStatus(false);
|
|
}
|
|
}
|
|
|
|
async function syncTeamspaceSubentries() {
|
|
if (!project || syncingTeamspaceSubentries) {
|
|
return;
|
|
}
|
|
|
|
setSyncingTeamspaceSubentries(true);
|
|
|
|
try {
|
|
const { result } = await syncProjectTeamspaceSubentries(project.id);
|
|
toast.success("Teamspace-Unterpositionen synchronisiert", {
|
|
description: `${result.subentriesSynced} Position(en) abgeglichen, ${result.subentriesRemoved} entfernt.`
|
|
});
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Teamspace-Unterpositionen konnten nicht synchronisiert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSyncingTeamspaceSubentries(false);
|
|
}
|
|
}
|
|
|
|
async function saveTeamspaceSubentry(subentry: ProjectTeamspaceSubentry) {
|
|
const draft = teamspaceSubentryDrafts[subentry.id];
|
|
|
|
if (!draft) {
|
|
return;
|
|
}
|
|
|
|
if (!canEditProject) {
|
|
toast.error("Projekt ist geteilt", {
|
|
description: "Nur der Zammad-Owner kann Teamspace-Unterpositionen bearbeiten."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSavingTeamspaceSubentryId(subentry.id);
|
|
|
|
try {
|
|
await updateProjectTeamspaceSubentry(subentry.id, {
|
|
alias: draft.alias
|
|
});
|
|
toast.success("Teamspace-Unterposition gespeichert");
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Teamspace-Unterposition konnte nicht gespeichert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSavingTeamspaceSubentryId(null);
|
|
}
|
|
}
|
|
|
|
async function acknowledgeProjectReviewCheck() {
|
|
if (!project) {
|
|
return;
|
|
}
|
|
|
|
if (!canEditProject) {
|
|
toast.error("Projekt ist geteilt", {
|
|
description: "Nur der Zammad-Owner kann die Prüfung bestätigen."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSavingProjectStatus(true);
|
|
try {
|
|
await acknowledgeProjectReview(project.id);
|
|
toast.success("Prüfung bestätigt");
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Prüfung konnte nicht bestätigt werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSavingProjectStatus(false);
|
|
}
|
|
}
|
|
|
|
async function submitTicketSession(event: FormEvent) {
|
|
event.preventDefault();
|
|
|
|
if (!ticket) {
|
|
return;
|
|
}
|
|
|
|
if (isRecurringBillingTicket) {
|
|
toast.error("Fixe Abrechnung", {
|
|
description: "Für fixe Abrechnungen dürfen keine Sessions nachgetragen werden. Bitte die Definition bearbeiten."
|
|
});
|
|
return;
|
|
}
|
|
|
|
const effectiveOrganizationId = ticket.organization_id ?? ticketSession.organizationId;
|
|
const effectiveWorkType = ticket.work_type ?? ticketSession.workType;
|
|
|
|
if (!effectiveOrganizationId) {
|
|
toast.error("Organisation wählen");
|
|
return;
|
|
}
|
|
|
|
if (!ticketSession.activity.trim() && !ticketSession.zammadArticleBody) {
|
|
toast.error("Tätigkeit erfassen");
|
|
return;
|
|
}
|
|
|
|
if (effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry && !ticketSession.projectTeamspaceEntryId) {
|
|
toast.error("Teamspace-Eintrag wählen", {
|
|
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
|
|
});
|
|
return;
|
|
}
|
|
|
|
const startedAt = new Date(`${ticketSession.day}T${ticketSession.startTime}:00`);
|
|
const endedAt = new Date(`${ticketSession.day}T${ticketSession.endTime}:00`);
|
|
|
|
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
|
|
toast.error("Datum oder Uhrzeit prüfen");
|
|
return;
|
|
}
|
|
|
|
if (endedAt < startedAt) {
|
|
toast.error("Ende darf nicht vor Beginn liegen");
|
|
return;
|
|
}
|
|
|
|
const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
|
|
|
setSavingTicketSession(true);
|
|
|
|
try {
|
|
const activityValue = readRichActivityEditorValue("ticket-session-activity") ?? {
|
|
activity: ticketSession.activity,
|
|
zammadArticleBody: ticketSession.zammadArticleBody
|
|
};
|
|
const result = await createSession({
|
|
ticketNumber: ticket.ticket_number,
|
|
organizationId: effectiveOrganizationId,
|
|
activity: activityValue.activity,
|
|
zammadArticleBody: activityValue.zammadArticleBody || null,
|
|
workType: effectiveWorkType,
|
|
startedAt: startedAt.toISOString(),
|
|
endedAt: endedAt.toISOString(),
|
|
durationSeconds,
|
|
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
|
|
billingStatus: ticketSession.billingStatus,
|
|
createZammadArticle: ticketSession.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
|
|
zammadArticleTypeId: ticketSession.zammadArticleTypeId
|
|
});
|
|
toast.success("Session nachgetragen", {
|
|
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
|
|
});
|
|
notifyZammadArticleResult(result);
|
|
notifyTodosChanged();
|
|
setTicketSession({
|
|
...emptyTicketSessionForm(),
|
|
organizationId: ticket.organization_id ?? "",
|
|
organizationName: ticket.organization_name ?? null,
|
|
projectTeamspaceEntryId: "",
|
|
zammadArticleBody: "",
|
|
workType: ticket.work_type ?? "support",
|
|
day: ticketSession.day,
|
|
billingStatus: null,
|
|
createZammadArticle: currentUser.zammad_session_note_default_enabled,
|
|
zammadArticleTypeId: currentUser.zammad_article_type_id
|
|
});
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Session konnte nicht nachgetragen werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSavingTicketSession(false);
|
|
}
|
|
}
|
|
|
|
function openSessionEditor(session: SessionEntry) {
|
|
setEditingSession(session);
|
|
setEditSessionOrganizationId(session.organization_id ?? "");
|
|
setEditSessionOrganizationName(session.organization_name ?? null);
|
|
setEditSessionActivity(session.activity);
|
|
setEditSessionZammadArticleBody(session.zammad_article_body ?? "");
|
|
setEditSessionWorkType(session.work_type);
|
|
setEditSessionProjectTeamspaceEntryId(session.project_teamspace_entry_id ?? "");
|
|
setEditSessionDay(toDateLocalValue(session.started_at));
|
|
setEditSessionStartTime(toTimeLocalValue(session.started_at));
|
|
setEditSessionEndTime(toTimeLocalValue(session.ended_at));
|
|
}
|
|
|
|
function openTeamspacePreparation(group: (typeof sessionGroups)[number]) {
|
|
setTeamspacePreparation({
|
|
day: group.key,
|
|
label: group.label,
|
|
text: teamspacePreparationText(ticket, group.sessions)
|
|
});
|
|
}
|
|
|
|
async function copyTeamspacePreparation() {
|
|
if (!teamspacePreparation) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await writeTextToClipboard(teamspacePreparation.text);
|
|
toast.success("TS Aufbereitung kopiert", {
|
|
description: teamspacePreparation.label
|
|
});
|
|
} catch (error) {
|
|
toast.error("TS Aufbereitung konnte nicht kopiert werden", {
|
|
description: error instanceof Error ? error.message : "Zwischenablage nicht verfügbar"
|
|
});
|
|
}
|
|
}
|
|
|
|
async function saveSessionData(event: FormEvent) {
|
|
event.preventDefault();
|
|
|
|
if (!editingSession) {
|
|
return;
|
|
}
|
|
|
|
if (!editSessionOrganizationId) {
|
|
toast.error("Organisation wählen");
|
|
return;
|
|
}
|
|
|
|
const startedAt = new Date(`${editSessionDay}T${editSessionStartTime}:00`);
|
|
const endedAt = new Date(`${editSessionDay}T${editSessionEndTime}:00`);
|
|
|
|
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
|
|
toast.error("Beginn und Ende prüfen");
|
|
return;
|
|
}
|
|
|
|
if (endedAt < startedAt) {
|
|
toast.error("Ende darf nicht vor Beginn liegen");
|
|
return;
|
|
}
|
|
|
|
if (editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry && !editSessionProjectTeamspaceEntryId) {
|
|
toast.error("Teamspace-Eintrag wählen", {
|
|
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
|
|
});
|
|
return;
|
|
}
|
|
|
|
const removeProjectData = Boolean(project && editingSession.work_type === "consulting" && editSessionWorkType === "support");
|
|
|
|
if (removeProjectData) {
|
|
const confirmed = window.confirm(
|
|
`Session auf Support umstellen? Falls dadurch keine Consulting-Sessions mehr für "${ticket?.title || ticket?.ticket_number || "dieses Ticket"}" bleiben, werden Projektdaten, Kontingent und Teamspace-Zeiten entfernt. Sessions und ToDos bleiben erhalten.`
|
|
);
|
|
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
setSavingSessionDetails(true);
|
|
try {
|
|
const activityValue = readRichActivityEditorValue("session-activity") ?? {
|
|
activity: editSessionActivity,
|
|
zammadArticleBody: editSessionZammadArticleBody
|
|
};
|
|
await updateSessionDetails(editingSession.id, {
|
|
organizationId: editSessionOrganizationId,
|
|
activity: activityValue.activity,
|
|
zammadArticleBody: activityValue.zammadArticleBody || null,
|
|
workType: editSessionWorkType,
|
|
removeProjectData,
|
|
projectTeamspaceEntryId: editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? editSessionProjectTeamspaceEntryId : null,
|
|
startedAt: startedAt.toISOString(),
|
|
endedAt: endedAt.toISOString()
|
|
});
|
|
toast.success("Session aktualisiert");
|
|
setEditingSession(null);
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Session konnte nicht gespeichert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSavingSessionDetails(false);
|
|
}
|
|
}
|
|
|
|
async function removeSession(session: SessionEntry) {
|
|
const confirmed = window.confirm(`Session vom ${formatDateTime(session.started_at)} wirklich löschen?`);
|
|
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
setDeletingId(session.id);
|
|
try {
|
|
const result = await deleteSession(session.id);
|
|
|
|
if (result.deleted.projectDeleteAvailable && result.deleted.project_id) {
|
|
setPendingProjectDelete({ projectId: result.deleted.project_id });
|
|
toast.success("Session gelöscht", {
|
|
description: "Für dieses Projekt sind keine Sessions mehr vorhanden."
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (result.deleted.ticketDeleted || result.deleted.userTicketEmpty) {
|
|
toast.success("Letzte Session gelöscht", {
|
|
description: result.deleted.ticketDeleted ? "Das leere Ticket wurde entfernt." : "Für dich gibt es zu diesem Ticket keine Einträge mehr."
|
|
});
|
|
onNavigate(returnPath);
|
|
return;
|
|
}
|
|
|
|
toast.success("Session gelöscht", {
|
|
description: "Betroffene Abschlüsse wurden wieder geöffnet."
|
|
});
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Session konnte nicht gelöscht werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setDeletingId(null);
|
|
}
|
|
}
|
|
|
|
async function keepEmptyProject() {
|
|
setPendingProjectDelete(null);
|
|
toast.success("Projekt behalten");
|
|
await load();
|
|
}
|
|
|
|
async function deleteEmptyProject() {
|
|
if (!pendingProjectDelete) {
|
|
return;
|
|
}
|
|
|
|
setDeletingEmptyProject(true);
|
|
try {
|
|
await deleteProject(pendingProjectDelete.projectId);
|
|
toast.success("Projekt gelöscht");
|
|
setPendingProjectDelete(null);
|
|
onNavigate(returnPath);
|
|
} catch (error) {
|
|
toast.error("Projekt konnte nicht gelöscht werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setDeletingEmptyProject(false);
|
|
}
|
|
}
|
|
|
|
async function toggleTicketFavorite() {
|
|
if (!ticket?.ticket_number) {
|
|
return;
|
|
}
|
|
|
|
setSavingFavorite(true);
|
|
try {
|
|
if (isFavorite) {
|
|
await removeFavoriteTicket(ticket.ticket_number);
|
|
setIsFavorite(false);
|
|
toast.success("Favorit entfernt", {
|
|
description: ticket.ticket_number
|
|
});
|
|
return;
|
|
}
|
|
|
|
await addFavoriteTicket(ticket.ticket_number);
|
|
setIsFavorite(true);
|
|
toast.success("Favorit gespeichert", {
|
|
description: ticket.ticket_number
|
|
});
|
|
} catch (error) {
|
|
toast.error(isFavorite ? "Favorit konnte nicht entfernt werden" : "Favorit konnte nicht gespeichert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSavingFavorite(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<Button variant="ghost" size="sm" className="-ml-2 mb-1 h-8" onClick={() => onNavigate(returnPath)}>
|
|
<ArrowLeft className="size-4" />
|
|
Zurück
|
|
</Button>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.title || ticket?.ticket_number || "Ticket"}</h2>
|
|
|
|
{isProject ? <Badge variant="outline">Projekt</Badge> : null}
|
|
<ZammadStateBadge stateId={ticket?.zammad_state_id} />
|
|
<ZammadPriorityBadge priorityId={ticket?.zammad_priority_id} />
|
|
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null}
|
|
{ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
{ticket?.title ? `${ticket.ticket_number} · ` : ""}
|
|
Sessions {sessionsScopeLabel}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 flex-nowrap items-center gap-2">
|
|
{ticket?.ticket_number ? (
|
|
<Button type="button" size="sm" onClick={() => void onStartTimer(ticket.ticket_number)}>
|
|
<Play className="size-4" />
|
|
Timer starten
|
|
</Button>
|
|
) : null}
|
|
{ticket?.ticket_number ? (
|
|
<Button type="button" size="sm" variant="secondary" onClick={() => setMailImportOpen(true)}>
|
|
<MailPlus className="size-4" />
|
|
Mails importieren
|
|
</Button>
|
|
) : null}
|
|
{ticket?.ticket_number ? (
|
|
<Button type="button" size="sm" variant={isFavorite ? "default" : "secondary"} disabled={savingFavorite} onClick={() => void toggleTicketFavorite()}>
|
|
<Star className={cn("size-4", isFavorite && "fill-current")} />
|
|
{isFavorite ? "Favorit entfernen" : "Favorit"}
|
|
</Button>
|
|
) : null}
|
|
<Badge variant={data?.openCount === 0 ? "success" : "warning"}>
|
|
{data?.openCount === 0 ? "Bewertet" : `${data?.openCount ?? 0} offen`}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? (
|
|
<Alert variant="success" className="py-3 text-sm">
|
|
Dieser Monat ist abgeschlossen. Bewertungen und Änderungen sind geschützt, bis du den Monat im Monatsabschluss wieder öffnest.
|
|
</Alert>
|
|
) : null}
|
|
|
|
{ticket?.is_foreign_zammad_owner ? (
|
|
<Alert className="py-3 text-sm">
|
|
<AlertCircle className="size-4" />
|
|
<AlertTitle>Anderer Zammad-Owner</AlertTitle>
|
|
<AlertDescription>
|
|
Dieses Ticket gehört in Zammad {zammadOwnerLabel ? `${zammadOwnerLabel}` : "einem anderen Owner"}; du bist {currentZammadUserLabel}. Du kannst deine Sessions sehen, Projekt- und Ticketdaten sind für dich geschützt.
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{ticketManagedByZammad ? (
|
|
<Alert className="py-3 text-sm">
|
|
<AlertCircle className="size-4" />
|
|
<AlertTitle>Ticketdaten werden von Zammad verwaltet</AlertTitle>
|
|
<AlertDescription>
|
|
Nummer, Art, Titel und Organisation wurden durch den Zammad-Sync übernommen und können in TicketTracker nicht manuell geändert werden.
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<div className="grid gap-4">
|
|
<Card>
|
|
<CardHeader className="p-4 pb-2">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle>Ticketdaten</CardTitle>
|
|
<CardDescription>
|
|
{canEditTicketData
|
|
? "Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen."
|
|
: ticketManagedByZammad
|
|
? "Nummer, Art, Titel und Organisation kommen aus Zammad und sind lokal gesperrt."
|
|
: "Nur der Owner kann Ticketdaten ändern."}
|
|
</CardDescription>
|
|
</div>
|
|
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-3">
|
|
<form className="grid gap-3 md:grid-cols-2 xl:grid-cols-[140px_145px_minmax(220px,1fr)_minmax(240px,1.2fr)_150px] xl:items-end" onSubmit={saveTicketData}>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="edit-ticket-number">Ticketnummer</label>
|
|
<Input id="edit-ticket-number" value={editTicketNumber} disabled={!canEditTicketData} onChange={(event) => setEditTicketNumber(event.currentTarget.value)} required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="edit-ticket-work-type">Art</label>
|
|
<select
|
|
id="edit-ticket-work-type"
|
|
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
value={editWorkType}
|
|
disabled={!canEditTicketData}
|
|
onChange={(event) => setEditWorkType(event.currentTarget.value as WorkType)}
|
|
>
|
|
<option value="support">Support</option>
|
|
<option value="consulting">Consulting</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="edit-ticket-title">Titel</label>
|
|
<Input id="edit-ticket-title" value={editTicketTitle} disabled={!canEditTicketData} onChange={(event) => setEditTicketTitle(event.currentTarget.value)} placeholder="optional" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="edit-ticket-organization">Organisation</label>
|
|
{ticket?.customer_name && !ticket.organization_id ? (
|
|
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {ticket.customer_name}</p>
|
|
) : null}
|
|
<OrganizationSelect
|
|
value={editOrganizationId}
|
|
selectedName={editOrganizationName}
|
|
disabled={!canEditTicketData}
|
|
onChange={(organization) => {
|
|
setEditOrganizationId(organization.id);
|
|
setEditOrganizationName(organization.name);
|
|
}}
|
|
required
|
|
/>
|
|
</div>
|
|
<Button type="submit" className="justify-self-end md:col-span-2 xl:col-span-1 xl:mb-0 xl:w-full" disabled={!canEditTicketData || savingTicket}>
|
|
<Save className="size-4" />
|
|
{savingTicket ? "Speichert..." : "Speichern"}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className={cn("grid gap-4", project && "xl:grid-cols-2")}>
|
|
{project ? (
|
|
<Card>
|
|
<CardHeader className="p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<FolderKanban className="size-5 text-muted-foreground" />
|
|
Projekt
|
|
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
|
|
<ZammadStateBadge stateId={project.zammad_state_id} />
|
|
<ZammadPriorityBadge priorityId={project.zammad_priority_id} />
|
|
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
|
|
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
|
|
{unassignedTeamspaceEntryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceEntryCount} ohne TS</Badge> : null}
|
|
{unassignedTeamspaceSubentryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceSubentryCount} ohne Pos.</Badge> : null}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{!canEditProject && projectOwnerLabel
|
|
? `Eigentümer: ${projectOwnerLabel}`
|
|
: showBudgetedProjectEntries
|
|
? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge`
|
|
: budgetedProjectEntries[0] ? teamspaceEntryDisplayLabel(budgetedProjectEntries[0]) : project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
disabled={!canEditProject || savingProjectStatus}
|
|
onClick={() => void setProjectClosed(!project.closed_at)}
|
|
>
|
|
{project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
{project.closed_at ? "Öffnen" : "Schließen"}
|
|
</Button>
|
|
<Button type="button" size="sm" variant="secondary" disabled={!canEditProject} onClick={openProjectEditor}>
|
|
<Pencil className="size-4" />
|
|
Bearbeiten
|
|
</Button>
|
|
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3 px-4 pb-4">
|
|
{unassignedTeamspaceEntryCount > 0 || unassignedTeamspaceSubentryCount > 0 ? (
|
|
<Alert variant="warning">
|
|
<div className="font-medium">Nicht zugeordnete Sessions</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{[
|
|
unassignedTeamspaceEntryCount > 0 ? `${unassignedTeamspaceEntryCount} Session(s) haben kein Teamspace-Projekt` : null,
|
|
unassignedTeamspaceSubentryCount > 0 ? `${unassignedTeamspaceSubentryCount} Session(s) haben keine Teamspace-Unterposition` : null
|
|
].filter(Boolean).join(". ")}. Bitte über die Session-Bearbeitung neu zuordnen.
|
|
</div>
|
|
</Alert>
|
|
) : null}
|
|
{project.close_review_required ? (
|
|
<Alert variant="warning">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div className="space-y-1">
|
|
<div className="font-medium">{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Zeiten prüfen"}</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
|
|
? project.zammad_merged_from_ticket_id
|
|
? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte und Zuordnungen bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.`
|
|
: "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte und Zuordnungen bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen."
|
|
: "Zammad meldet dieses Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="shrink-0"
|
|
disabled={savingProjectStatus || !canEditProject}
|
|
onClick={() => void acknowledgeProjectReviewCheck()}
|
|
>
|
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
|
Geprüft
|
|
</Button>
|
|
</div>
|
|
</Alert>
|
|
) : null}
|
|
<div className="flex flex-wrap gap-2 text-sm">
|
|
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
|
|
<Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>
|
|
<Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</Badge>
|
|
<Badge variant={projectRemainingMinutes !== null && projectRemainingMinutes < 0 ? "destructive" : "secondary"}>{projectRemainingLabel(projectRemainingMinutes)}</Badge>
|
|
{showBudgetedProjectEntries ? <Badge variant="outline">{budgetedProjectEntries.length} Budgets</Badge> : null}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
|
<span>Budgetverbrauch</span>
|
|
<span className="inline-flex shrink-0 items-center gap-1 text-right">
|
|
{projectBudgetMinutes && projectBudgetMinutes > 0 && projectConsumptionMinutes > projectBudgetMinutes ? (
|
|
<AlertCircle className="size-3.5 text-amber-500" aria-label="Budgetüberschreitung" />
|
|
) : null}
|
|
{projectBudgetMinutes && projectBudgetMinutes > 0
|
|
? `${formatMinutes(projectConsumptionMinutes)} / ${formatMinutes(projectBudgetMinutes)} · ${projectRemainingLabel(projectRemainingMinutes)}`
|
|
: `${formatMinutes(projectConsumptionMinutes)} / offen`}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className="h-2 overflow-hidden rounded-full bg-muted"
|
|
title={projectConsumptionUsagePercent !== null ? `${projectConsumptionUsagePercent}% Verbrauch` : "ohne Gesamtbudget"}
|
|
aria-label={projectConsumptionUsagePercent !== null ? `${projectConsumptionUsagePercent}% Verbrauch` : "ohne Gesamtbudget"}
|
|
>
|
|
<div
|
|
className={cn("h-full rounded-full", projectConsumptionUsagePercent === null ? "bg-muted-foreground/20" : projectConsumptionBarClass(projectConsumptionUsagePercent))}
|
|
style={{ width: `${projectConsumptionUsagePercent === null ? 0 : Math.min(100, projectConsumptionUsagePercent)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{showBudgetedProjectEntries ? (
|
|
<div className="grid gap-1.5 md:grid-cols-2">
|
|
{budgetedProjectEntries.map((entry) => {
|
|
const consumption = projectEntryConsumptionById.get(entry.id) ?? Number(entry.consumption_minutes ?? 0);
|
|
const percent = projectConsumptionPercent(consumption, entry.hour_budget_minutes);
|
|
const label = teamspaceEntryDisplayLabel(entry);
|
|
const budgetExceeded = Number(entry.hour_budget_minutes ?? 0) > 0 && consumption > Number(entry.hour_budget_minutes ?? 0);
|
|
|
|
return (
|
|
<div key={entry.id} className="rounded-md border bg-background/50 px-2 py-1">
|
|
<div className="mb-1 flex min-w-0 items-center justify-between gap-2 text-[11px]">
|
|
<span className="truncate text-muted-foreground">
|
|
{label}
|
|
{entry.is_default ? " · Standard" : ""}
|
|
</span>
|
|
<span className="inline-flex shrink-0 items-center gap-1 font-medium">
|
|
{budgetExceeded ? <AlertCircle className="size-3.5 text-amber-500" aria-label="Budgetüberschreitung" /> : null}
|
|
{formatMinutes(consumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
|
|
</span>
|
|
</div>
|
|
<div className="h-1 overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className={cn("h-full rounded-full", percent === null ? "bg-muted-foreground/20" : projectConsumptionBarClass(percent))}
|
|
style={{ width: `${percent === null ? 0 : Math.min(100, percent)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="text-sm">
|
|
<div className="min-w-0">
|
|
<div className="mb-1 flex items-center gap-2 font-medium">
|
|
<FolderKanban className="size-4 text-muted-foreground" />
|
|
Beschreibung/Ziele
|
|
</div>
|
|
<p className={cn("whitespace-pre-wrap text-muted-foreground", !projectDescriptionExpanded && "line-clamp-4")}>
|
|
{projectDescription || "Noch keine Beschreibung/Ziele."}
|
|
</p>
|
|
{projectDescription ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
className="mt-1 h-7 px-2 text-xs"
|
|
onClick={() => setProjectDescriptionExpanded((current) => !current)}
|
|
>
|
|
{projectDescriptionExpanded ? (
|
|
<>
|
|
<ChevronUp className="size-3.5" />
|
|
Einklappen
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronDown className="size-3.5" />
|
|
Vollständig anzeigen
|
|
</>
|
|
)}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
<Dialog open={editingProject} onOpenChange={setEditingProject}>
|
|
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-x-hidden overflow-y-auto sm:max-w-4xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Projekt bearbeiten</DialogTitle>
|
|
<DialogDescription>Projektinformationen zum geöffneten Ticket aktualisieren.</DialogDescription>
|
|
</DialogHeader>
|
|
<form className="space-y-4" onSubmit={saveProjectData}>
|
|
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)]">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-edit-title">Titel</label>
|
|
<Input
|
|
id="project-edit-title"
|
|
value={projectForm.title}
|
|
onChange={(event) => setProjectForm({ ...projectForm, title: event.currentTarget.value })}
|
|
placeholder="optional"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Organisation</label>
|
|
<OrganizationSelect
|
|
value={projectForm.organizationId}
|
|
selectedName={projectForm.organizationName}
|
|
onChange={(organization) =>
|
|
setProjectForm({
|
|
...projectForm,
|
|
organizationId: organization.id,
|
|
organizationName: organization.name
|
|
})
|
|
}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-edit-start">Von</label>
|
|
<Input id="project-edit-start" type="date" value={projectForm.startsOn} onChange={(event) => setProjectForm({ ...projectForm, startsOn: event.currentTarget.value })} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-edit-end">Bis</label>
|
|
<Input id="project-edit-end" type="date" value={projectForm.endsOn} onChange={(event) => setProjectForm({ ...projectForm, endsOn: event.currentTarget.value })} />
|
|
</div>
|
|
</div>
|
|
<ProjectTeamspaceEntriesEditor
|
|
entries={projectForm.teamspaceEntries}
|
|
showManualConsumption={teamspaceTimeTrackingEnabled}
|
|
onChange={(teamspaceEntries) => setProjectForm({ ...projectForm, teamspaceEntries })}
|
|
/>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-edit-description">Beschreibung/Ziele</label>
|
|
<Textarea
|
|
id="project-edit-description"
|
|
className="min-h-32"
|
|
value={projectForm.description}
|
|
onChange={(event) => setProjectForm({ ...projectForm, description: event.currentTarget.value })}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={savingProjectStatus}
|
|
onClick={() => void setProjectClosed(!project?.closed_at)}
|
|
>
|
|
{project?.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
{project?.closed_at ? "Wieder öffnen" : "Projekt schließen"}
|
|
</Button>
|
|
<Button type="submit" disabled={savingProject}>
|
|
<Save className="size-4" />
|
|
{savingProject ? "Speichert..." : "Projekt speichern"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Card>
|
|
<CardHeader className="p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<ClipboardList className="size-5 text-muted-foreground" />
|
|
ToDos
|
|
</CardTitle>
|
|
<CardDescription>Aufgaben zu diesem Ticket oder Projekt verwalten.</CardDescription>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<div className="flex rounded-md border bg-muted/20 p-0.5">
|
|
{(["open", "all", "done"] as const).map((value) => (
|
|
<Button
|
|
key={value}
|
|
type="button"
|
|
size="sm"
|
|
variant={todoFilter === value ? "default" : "ghost"}
|
|
className="h-7 px-2 text-xs"
|
|
onClick={() => setTodoFilter(value)}
|
|
>
|
|
{value === "open" ? `Offen ${todoCounts.open}` : value === "done" ? `Erledigt ${todoCounts.done}` : `Alle ${todoCounts.all}`}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4">
|
|
<TodoPanel ticketId={ticket?.id} todos={visibleTodos} onChanged={load} />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{showTeamspaceSubentryCard ? (
|
|
<Card>
|
|
<CardHeader className="p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<FolderKanban className="size-5 text-muted-foreground" />
|
|
Teamspace-Unterpositionen
|
|
</CardTitle>
|
|
<CardDescription>Positionen aus Teamspace für die granulare Zuordnung dieses Projekts.</CardDescription>
|
|
</div>
|
|
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
|
{canEditProject && projectTeamspaceSubentryCount > 0 ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={editingTeamspaceSubentries ? "outline" : "secondary"}
|
|
onClick={() => {
|
|
if (editingTeamspaceSubentries) {
|
|
setTeamspaceSubentryDrafts(teamspaceSubentryEditDrafts(project));
|
|
}
|
|
|
|
setEditingTeamspaceSubentries(!editingTeamspaceSubentries);
|
|
}}
|
|
>
|
|
<Pencil className="size-4" />
|
|
{editingTeamspaceSubentries ? "Abbrechen" : "Bearbeiten"}
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
disabled={!canEditProject || !hasProjectTeamspaceApiIds || syncingTeamspaceSubentries}
|
|
onClick={() => void syncTeamspaceSubentries()}
|
|
>
|
|
<RefreshCw className={cn("size-4", syncingTeamspaceSubentries && "animate-spin")} />
|
|
{syncingTeamspaceSubentries ? "Sync..." : "Sync"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3 px-4 pb-4">
|
|
{!hasProjectTeamspaceApiIds ? (
|
|
<Alert variant="warning">
|
|
<AlertCircle className="size-4" />
|
|
<AlertTitle>Teamspace-API-ID fehlt</AlertTitle>
|
|
<AlertDescription>Bitte zuerst auf der Zammad-Sync-Seite die Teamspace-IDs verknüpfen.</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
{projectTeamspaceSubentryCount > 0 ? (
|
|
<Accordion
|
|
type="multiple"
|
|
value={openTeamspaceSubentryGroups}
|
|
onValueChange={updateOpenTeamspaceSubentryGroups}
|
|
className="overflow-hidden rounded-md border bg-background/50"
|
|
>
|
|
{projectTeamspaceSubentryGroups.map(({ entry, subentries }) => (
|
|
<AccordionItem key={entry.id} value={entry.id} className="border-b last:border-b-0">
|
|
<AccordionTrigger className="rounded-none border-l-4 border-l-primary/50 bg-muted/40 px-3 py-2 hover:bg-muted/55 hover:no-underline">
|
|
<div className="grid min-w-0 flex-1 gap-1 pr-3 text-left sm:grid-cols-[minmax(220px,1fr)_auto] sm:items-center">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold">{teamspaceEntryDisplayLabel(entry)}</p>
|
|
<p className="truncate text-xs font-normal text-muted-foreground">
|
|
{entry.teamspace_project || "Ohne Teamspace-ID"}
|
|
{entry.teamspace_api_project_id ? ` · API ${entry.teamspace_api_project_id}` : ""}
|
|
</p>
|
|
</div>
|
|
<Badge variant="outline" className="h-6 w-fit shrink-0 px-2 text-xs">{subentries.length} Position(en)</Badge>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="p-0">
|
|
<div className="divide-y border-t bg-background">
|
|
{subentries.map((subentry, index) => {
|
|
const description = teamspaceFormattedText(subentry.description);
|
|
const title = teamspaceFormattedText(subentry.alias) || teamspaceFormattedText(subentry.name) || subentry.teamspace_project_id || "Teamspace-Position";
|
|
const positionDisplayId = teamspacePositionDisplayId(subentry.teamspace_project_id);
|
|
const fixedPrice = isTeamspaceFixedPricePosition(subentry.billing_type);
|
|
const fixedPriceFulfilled = fixedPrice && teamspaceFixedPriceFulfilled(subentry);
|
|
const assignedConsumptionMinutes = Number(subentry.sum_minutes_billable ?? subentry.sum_minutes ?? 0);
|
|
const fixedPricePlannedMinutes = teamspaceFixedUnitCount(subentry, "planned");
|
|
const plannedIncome = teamspaceIncomeValue(subentry.planned_income);
|
|
const sumIncome = teamspaceIncomeValue(subentry.sum_income);
|
|
const plannedBillableMinutes = Number(subentry.planned_minutes_billable ?? subentry.planned_minutes ?? 0);
|
|
const budgetExceeded = fixedPrice
|
|
? plannedIncome > 0 && sumIncome > plannedIncome
|
|
: plannedBillableMinutes > 0 && assignedConsumptionMinutes > plannedBillableMinutes;
|
|
const usagePercent = fixedPrice ? null : teamspaceBillableUsagePercent(assignedConsumptionMinutes, subentry.planned_minutes_billable);
|
|
const draft = teamspaceSubentryDrafts[subentry.id] ?? {
|
|
alias: subentry.alias ?? ""
|
|
};
|
|
const savingSubentry = savingTeamspaceSubentryId === subentry.id;
|
|
|
|
return (
|
|
<div
|
|
key={subentry.id}
|
|
className="grid gap-2 px-3 py-2 text-xs hover:bg-muted/20 lg:grid-cols-[3.5rem_minmax(190px,0.95fr)_minmax(240px,1.45fr)_105px_136px] lg:items-center"
|
|
>
|
|
<div className="hidden text-muted-foreground lg:block">{positionDisplayId || index + 1}</div>
|
|
<div className="min-w-0">
|
|
<p className="truncate font-medium text-foreground/90">{title}</p>
|
|
<p className="truncate text-muted-foreground">
|
|
{positionDisplayId ? `Position ${positionDisplayId}` : "Ohne Positions-ID"}
|
|
{subentry.teamspace_api_project_id ? ` · API ${subentry.teamspace_api_project_id}` : ""}
|
|
</p>
|
|
</div>
|
|
<p className="min-w-0 whitespace-pre-line text-muted-foreground lg:line-clamp-2">
|
|
{description || "-"}
|
|
</p>
|
|
<div className="flex items-center lg:justify-center">
|
|
{fixedPrice ? (
|
|
<span
|
|
className={cn(
|
|
"inline-flex h-6 items-center justify-center gap-1 rounded-full border px-2 text-[11px] font-semibold",
|
|
fixedPriceFulfilled
|
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
|
: "border-muted-foreground/20 bg-muted/40 text-muted-foreground"
|
|
)}
|
|
title={fixedPriceFulfilled ? "Teamspace-Soll erreicht" : "Teamspace-Soll noch offen"}
|
|
>
|
|
{fixedPriceFulfilled ? <CheckCircle2 className="size-3" /> : null}
|
|
{fixedPriceFulfilled ? "Abgerechnet" : "Offen"}
|
|
{budgetExceeded ? <AlertCircle className="size-3 text-amber-500" /> : null}
|
|
</span>
|
|
) : usagePercent !== null ? (
|
|
<span className={cn("inline-flex h-6 min-w-10 items-center justify-center gap-1 rounded-full border px-2 text-[11px] font-semibold", teamspaceProgressClass(usagePercent))}>
|
|
{usagePercent}%
|
|
{budgetExceeded ? <AlertCircle className="size-3 text-amber-500" /> : null}
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex h-6 min-w-10 items-center justify-center rounded-full border border-muted-foreground/20 bg-muted/40 px-2 text-[11px] font-semibold text-muted-foreground">
|
|
-
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-[auto_1fr] gap-x-3 text-muted-foreground lg:block lg:text-right">
|
|
<span className="lg:hidden">Billable</span>
|
|
<span className="block">
|
|
{fixedPrice
|
|
? "Stückposition"
|
|
: (
|
|
<>
|
|
{formatMinutes(assignedConsumptionMinutes)}
|
|
{subentry.planned_minutes_billable ? ` / ${formatMinutes(subentry.planned_minutes_billable)}` : ""}
|
|
</>
|
|
)}
|
|
</span>
|
|
{fixedPrice ? (
|
|
<span className="block text-[11px] text-muted-foreground/85">
|
|
TS {formatMinutes(assignedConsumptionMinutes)}
|
|
{fixedPricePlannedMinutes > 0 ? ` / ${formatMinutes(fixedPricePlannedMinutes)}` : ""}
|
|
</span>
|
|
) : (
|
|
<span className="block text-[11px] text-muted-foreground/85">
|
|
TS {formatMinutes(assignedConsumptionMinutes)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{canEditProject && editingTeamspaceSubentries ? (
|
|
<div className={cn(
|
|
"grid gap-2 rounded-md border border-dashed bg-muted/10 p-2 lg:col-span-5 lg:items-end",
|
|
"lg:grid-cols-[minmax(150px,1fr)_auto]"
|
|
)}>
|
|
<div className="space-y-1">
|
|
<label className="text-[11px] font-medium text-muted-foreground" htmlFor={`subentry-alias-${subentry.id}`}>Alias</label>
|
|
<Input
|
|
id={`subentry-alias-${subentry.id}`}
|
|
className="h-8 text-xs"
|
|
value={draft.alias}
|
|
onChange={(event) => {
|
|
const alias = event.currentTarget.value;
|
|
|
|
setTeamspaceSubentryDrafts((current) => ({
|
|
...current,
|
|
[subentry.id]: {
|
|
...(current[subentry.id] ?? draft),
|
|
alias
|
|
}
|
|
}));
|
|
}}
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="h-8"
|
|
disabled={savingSubentry}
|
|
onClick={() => void saveTeamspaceSubentry(subentry)}
|
|
>
|
|
<Save className="size-3.5" />
|
|
{savingSubentry ? "..." : "OK"}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
))}
|
|
</Accordion>
|
|
) : (
|
|
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
|
|
Noch keine Unterpositionen synchronisiert.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
<Card>
|
|
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
|
<div>
|
|
<CardTitle>Session-Einträge</CardTitle>
|
|
<CardDescription>Wähle für jede Session genau eine Bewertung aus oder lösche falsche Einträge.</CardDescription>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
|
<span className="text-muted-foreground">Abgerechnet</span>
|
|
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
|
<span className="text-muted-foreground">{teamspaceLabel}</span>
|
|
<span className="font-semibold">{formatMinutes(periodTeamspaceMinutes)}</span>
|
|
</div>
|
|
<HelpLink anchor="ticketdetail" label="Hilfe zur Ticketdetailansicht" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4">
|
|
{isGlobalTicket ? (
|
|
<div className="mb-3 grid gap-3 rounded-md border bg-muted/15 p-3 md:grid-cols-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-month-filter">Monat</label>
|
|
<select
|
|
id="ticket-month-filter"
|
|
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
value={globalMonthFilter}
|
|
onChange={(event) => setGlobalMonthFilter(event.currentTarget.value)}
|
|
>
|
|
<option value="all">Alle Monate</option>
|
|
{globalMonthFilter !== "all" && !selectedMonthHasSessions ? <option value={globalMonthFilter}>{globalMonthFilter} (0)</option> : null}
|
|
{availableMonths.map((month) => (
|
|
<option key={month.month} value={month.month}>
|
|
{month.month} ({month.session_count})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-status-filter">Bewertung</label>
|
|
<select
|
|
id="ticket-status-filter"
|
|
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
value={globalStatusFilter}
|
|
onChange={(event) => setGlobalStatusFilter(event.currentTarget.value as typeof globalStatusFilter)}
|
|
>
|
|
<option value="all">Alle Bewertungen</option>
|
|
<option value="open">Offen</option>
|
|
<option value="billable">Abrechenbar</option>
|
|
<option value="billed">Abgerechnet</option>
|
|
<option value="non_billable">Nicht abrechenbar</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-source-filter">Quelle</label>
|
|
<select
|
|
id="ticket-source-filter"
|
|
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
value={globalSourceFilter}
|
|
onChange={(event) => setGlobalSourceFilter(event.currentTarget.value as typeof globalSourceFilter)}
|
|
>
|
|
<option value="all">Alle Quellen</option>
|
|
<option value="manual">Manuell</option>
|
|
<option value="recurring">Fix</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{data?.sessions.length ? (
|
|
<div className="mb-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
{teamspaceTabs.length > 0 ? (
|
|
<div className="flex min-w-0 flex-wrap gap-1 rounded-md border bg-muted/20 p-1">
|
|
{teamspaceTabs.map((tab) => (
|
|
<Button
|
|
key={tab.id}
|
|
type="button"
|
|
size="sm"
|
|
variant={activeTeamspaceEntryId === tab.id ? "default" : "ghost"}
|
|
className="h-auto max-w-full min-w-36 flex-col items-start justify-center gap-0.5 py-1.5 text-left"
|
|
onClick={() => setSelectedTeamspaceEntryId(tab.id)}
|
|
>
|
|
<span className="truncate">{tab.label}</span>
|
|
{tab.description ? <span className="max-w-56 truncate text-[11px] font-normal opacity-80">{tab.description}</span> : null}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span />
|
|
)}
|
|
<Button type="button" size="sm" variant="secondary" onClick={toggleAllSessionDetails}>
|
|
{allSessionsExpanded ? (
|
|
<>
|
|
<ChevronUp className="size-4" />
|
|
Alle einklappen
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronDown className="size-4" />
|
|
Alle aufklappen
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-2">
|
|
{sessionGroups.map((group) => (
|
|
<div key={group.key} className="rounded-md border bg-background">
|
|
<div className="grid min-w-0 gap-2 border-b bg-muted/25 px-3 py-2 lg:grid-cols-[220px_minmax(0,150px)_minmax(260px,1fr)_220px] lg:items-center">
|
|
<div className="min-w-0 text-left">
|
|
<p className="truncate font-medium">{group.label}</p>
|
|
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
className="h-8 min-w-32 justify-center justify-self-start"
|
|
title="Teamspace-Aufbereitung"
|
|
onClick={() => openTeamspacePreparation(group)}
|
|
>
|
|
<ClipboardList className="size-3.5" />
|
|
TS
|
|
</Button>
|
|
<div className="grid min-w-0 grid-cols-3 gap-2 lg:gap-3">
|
|
<Badge variant="outline" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Abrechenbar">
|
|
<Play className="size-3.5 shrink-0" />
|
|
<span className="truncate">{formatMinutes(group.billableMinutes)}</span>
|
|
</Badge>
|
|
<Badge variant="outline" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Abgerechnet">
|
|
<CheckCircle2 className="size-3.5 shrink-0" />
|
|
<span className="truncate">{formatMinutes(group.totalMinutes)}</span>
|
|
</Badge>
|
|
<Badge variant="secondary" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Nicht abrechenbar">
|
|
<EyeOff className="size-3.5 shrink-0" />
|
|
<span className="truncate">{formatMinutes(group.nonBillableMinutes)}</span>
|
|
</Badge>
|
|
</div>
|
|
<div className="flex min-w-0 items-center justify-start gap-2 lg:justify-end">
|
|
{teamspaceTimeTrackingEnabled ? (
|
|
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
|
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
|
|
<Input
|
|
id={`crm-billing-${group.key}`}
|
|
type="text"
|
|
className="h-8 w-20 bg-background text-right"
|
|
autoComplete="off"
|
|
inputMode="decimal"
|
|
value={dayBillingInputs[group.key] ?? ""}
|
|
disabled={isPeriodClosed || savingDayBillingKey === group.key}
|
|
placeholder="0"
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
|
|
if (!isPartialHoursInput(nextValue)) {
|
|
return;
|
|
}
|
|
|
|
setDayBillingInputs((current) => ({
|
|
...current,
|
|
[group.key]: nextValue
|
|
}));
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
}
|
|
}}
|
|
/>
|
|
<span>h</span>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
className={cn("size-7 shrink-0", !isDayBillingDirty(group.key) && "invisible")}
|
|
disabled={isPeriodClosed || !isDayBillingDirty(group.key) || savingDayBillingKey === group.key}
|
|
title={savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() => void saveDayBilling(group)}
|
|
>
|
|
<Save className="size-3.5" />
|
|
<span className="sr-only">{savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}</span>
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">Teamspace-Erfassung aus</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5 p-2 sm:p-3 lg:px-0">
|
|
{group.sessions.map((session) => {
|
|
const expanded = expandedSessionIds.includes(session.id);
|
|
const zammadSent = session.zammad_article_status === "sent";
|
|
const zammadPending = session.zammad_article_status === "pending";
|
|
const zammadMailImported = Boolean(session.zammad_source_article_id);
|
|
const canSendZammad = !zammadMailImported && (session.activity.trim().length > 0 || Boolean(session.zammad_article_body));
|
|
const sendingZammad = sendingZammadSessionId === session.id;
|
|
const billedMissingTeamspaceEntry =
|
|
teamspaceTimeTrackingEnabled
|
|
&& session.work_type === "consulting"
|
|
&& (session.billing_status === "billable" || session.billing_status === "billed")
|
|
&& !session.project_teamspace_entry_id;
|
|
|
|
return (
|
|
<div key={session.id} className="rounded-md border bg-card/40">
|
|
<div className="grid gap-2 p-2 lg:grid-cols-[220px_minmax(0,150px)_minmax(260px,1fr)_220px] lg:items-center">
|
|
<button
|
|
type="button"
|
|
className="flex min-w-0 items-center gap-2 rounded-md text-left text-sm text-muted-foreground hover:cursor-pointer hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
onClick={() => toggleSessionDetails(session.id)}
|
|
aria-expanded={expanded}
|
|
>
|
|
{expanded ? <ChevronUp className="size-4 shrink-0" /> : <ChevronDown className="size-4 shrink-0" />}
|
|
<span className="truncate">
|
|
{formatTimeRange(session.started_at, session.ended_at)}
|
|
</span>
|
|
{zammadMailImported ? (
|
|
<Badge variant="info" className="shrink-0 gap-1">
|
|
<MailCheck className="size-3" />
|
|
Mail
|
|
</Badge>
|
|
) : null}
|
|
{billedMissingTeamspaceEntry ? (
|
|
<Badge variant="warning" className="shrink-0 gap-1" title="Abgerechnete Session ohne Teamspace-Projekt">
|
|
<AlertCircle className="size-3" />
|
|
ohne TS
|
|
</Badge>
|
|
) : null}
|
|
</button>
|
|
<div className="flex min-w-0 flex-col items-start gap-1">
|
|
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
|
|
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null}
|
|
</div>
|
|
<BillingButtons
|
|
session={session}
|
|
disabled={isPeriodClosed}
|
|
loading={loadingId === session.id}
|
|
onChange={(value) => setBilling(session.id, value)}
|
|
/>
|
|
<div className="grid grid-cols-[2rem_2rem_2rem] gap-1 lg:justify-end">
|
|
{zammadMailImported ? (
|
|
<span className="size-8" aria-hidden="true" />
|
|
) : zammadSent ? (
|
|
<span
|
|
className="inline-flex size-8 items-center justify-center rounded-md text-emerald-600"
|
|
title="An Zammad übertragen"
|
|
aria-label="An Zammad übertragen"
|
|
>
|
|
<Send className="size-4" />
|
|
</span>
|
|
) : (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="size-8"
|
|
disabled={!canSendZammad || sendingZammad}
|
|
title={canSendZammad ? (zammadPending ? "Geparkte Zammad-Notiz erneut senden" : "Zammad-Notiz senden") : "Keine Zammad-Notiz: Tätigkeit leer"}
|
|
onClick={() => void sendSessionToZammad(session)}
|
|
>
|
|
{zammadPending || sendingZammad ? <RotateCcw className="size-4" /> : <Send className="size-4" />}
|
|
<span className="sr-only">{zammadPending ? "Geparkte Zammad-Notiz erneut senden" : "Zammad-Notiz senden"}</span>
|
|
</Button>
|
|
)}
|
|
<Button size="icon" variant="ghost" className="size-8" onClick={() => openSessionEditor(session)}>
|
|
<Pencil className="size-4" />
|
|
<span className="sr-only">Session bearbeiten</span>
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="size-8 text-destructive hover:text-destructive"
|
|
disabled={deletingId === session.id}
|
|
onClick={() => removeSession(session)}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
<span className="sr-only">Session löschen</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{expanded ? (
|
|
<div className="border-t px-3 pb-3 pt-2">
|
|
<SessionActivityBody session={session} />
|
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
<span>{session.organization_name ?? session.customer_name}</span>
|
|
<span>{session.work_type === "support" ? "Support" : "Consulting"}</span>
|
|
{zammadMailImported ? (
|
|
<span className="inline-flex items-center gap-1">
|
|
<MailCheck className="size-3.5 text-sky-500" aria-hidden="true" />
|
|
<span>Zammad-Mail importiert · Article {session.zammad_source_article_id}</span>
|
|
</span>
|
|
) : null}
|
|
{!zammadMailImported ? (
|
|
<span className="inline-flex items-center gap-1">
|
|
{zammadSent ? <Send className="size-3.5 text-emerald-500" aria-hidden="true" /> : zammadPending ? <RotateCcw className="size-3.5 text-amber-500" aria-hidden="true" /> : <Send className="size-3.5 text-muted-foreground/60" aria-hidden="true" />}
|
|
<span>
|
|
{zammadSent
|
|
? `Zammad übertragen · ${zammadArticleTypeLabel(session.zammad_article_type_id)}`
|
|
: zammadPending
|
|
? `Zammad geparkt · ${zammadArticleTypeLabel(session.zammad_article_type_id)}`
|
|
: canSendZammad
|
|
? "Nicht an Zammad übertragen"
|
|
: "Keine Zammad-Notiz (Tätigkeit leer)"}
|
|
</span>
|
|
</span>
|
|
) : null}
|
|
<span>Erfasst {formatDateTime(session.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{data?.sessions.length === 0 ? (
|
|
<p className="py-8 text-center text-sm text-muted-foreground">Keine Sessions für dieses Ticket im gewählten Filter.</p>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
{isRecurringBillingTicket ? (
|
|
<Alert variant="warning">
|
|
<div className="font-medium">Fixe Abrechnung</div>
|
|
<div className="text-sm text-muted-foreground">Sessions für dieses Ticket werden ausschließlich aus der Definition erzeugt. Manuelles Nachtragen ist deaktiviert.</div>
|
|
</Alert>
|
|
) : (
|
|
<Card>
|
|
<CardHeader className="p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle>Session nachtragen</CardTitle>
|
|
<CardDescription>Neue Zeit direkt diesem Ticket zuordnen.</CardDescription>
|
|
</div>
|
|
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen von Sessions" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4">
|
|
<form className="grid gap-4 xl:grid-cols-2" onSubmit={submitTicketSession}>
|
|
<div className="space-y-4">
|
|
{ticket?.organization_id && ticket.work_type ? (
|
|
<div className="grid gap-2 rounded-md border bg-muted/30 p-3 text-sm sm:grid-cols-[1fr_auto] sm:items-center">
|
|
<span className="min-w-0 truncate font-medium">{ticket.organization_name ?? ticket.customer_name}</span>
|
|
<Badge variant="outline">{ticket.work_type === "support" ? "Support" : "Consulting"}</Badge>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_160px]">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Organisation</label>
|
|
{ticket?.customer_name && !ticket.organization_id ? (
|
|
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {ticket.customer_name}</p>
|
|
) : null}
|
|
<OrganizationSelect
|
|
value={ticketSession.organizationId}
|
|
selectedName={ticketSession.organizationName}
|
|
onChange={(organization) =>
|
|
setTicketSession({
|
|
...ticketSession,
|
|
organizationId: organization.id,
|
|
organizationName: organization.name
|
|
})
|
|
}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="ticket-session-work-type">
|
|
Art
|
|
</label>
|
|
<select
|
|
id="ticket-session-work-type"
|
|
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
value={ticketSession.workType}
|
|
onChange={(event) => setTicketSession({ ...ticketSession, workType: event.currentTarget.value as WorkType })}
|
|
>
|
|
<option value="support">Support</option>
|
|
<option value="consulting">Consulting</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{requiresProjectTeamspaceEntry ? (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="ticket-session-teamspace-entry">Teamspace-Eintrag</label>
|
|
<select
|
|
id="ticket-session-teamspace-entry"
|
|
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
value={ticketSession.projectTeamspaceEntryId}
|
|
onChange={(event) => setTicketSession({ ...ticketSession, projectTeamspaceEntryId: event.currentTarget.value })}
|
|
required
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{projectEntries.map((entry) => (
|
|
<option key={entry.id} value={entry.id}>
|
|
{teamspaceEntryOptionLabel(entry)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{teamspaceEntryDescription(selectedTicketSessionTeamspaceEntry) ? (
|
|
<p className="text-xs text-muted-foreground">{teamspaceEntryDescription(selectedTicketSessionTeamspaceEntry)}</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="ticket-session-start">
|
|
Von
|
|
</label>
|
|
<Input
|
|
id="ticket-session-start"
|
|
type="time"
|
|
value={ticketSession.startTime}
|
|
onChange={(event) => setTicketSession({ ...ticketSession, startTime: event.currentTarget.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="ticket-session-end">
|
|
Bis
|
|
</label>
|
|
<Input
|
|
id="ticket-session-end"
|
|
type="time"
|
|
value={ticketSession.endTime}
|
|
onChange={(event) => setTicketSession({ ...ticketSession, endTime: event.currentTarget.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="ticket-session-day">
|
|
Datum
|
|
</label>
|
|
<Input
|
|
id="ticket-session-day"
|
|
type="date"
|
|
value={ticketSession.day}
|
|
onChange={(event) => setTicketSession({ ...ticketSession, day: event.currentTarget.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="ticket-session-billing-status">
|
|
Bewertung
|
|
</label>
|
|
<BillingCaptureSelect
|
|
id="ticket-session-billing-status"
|
|
value={ticketSession.billingStatus}
|
|
onChange={(billingStatus) => setTicketSession({ ...ticketSession, billingStatus })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<label className="flex min-h-10 items-start gap-2 rounded-md border bg-background p-3 text-sm">
|
|
<Checkbox
|
|
checked={ticketSession.createZammadArticle}
|
|
onCheckedChange={(checked) => setTicketSession({ ...ticketSession, createZammadArticle: checked === true })}
|
|
/>
|
|
<span>
|
|
<span className="block font-medium">Interne Zammad-Notiz erstellen</span>
|
|
<span className="block text-xs text-muted-foreground">Überträgt die Tätigkeit inklusive time_unit nach Zammad oder parkt sie bei Fehlern.</span>
|
|
</span>
|
|
</label>
|
|
{ticketSession.createZammadArticle ? (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="ticket-session-zammad-article-type">
|
|
Zammad-Typ
|
|
</label>
|
|
<select
|
|
id="ticket-session-zammad-article-type"
|
|
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
value={zammadArticleTypeValue(ticketSession.zammadArticleTypeId)}
|
|
onChange={(event) => setTicketSession({ ...ticketSession, zammadArticleTypeId: zammadArticleTypeFromValue(event.currentTarget.value) })}
|
|
>
|
|
{zammadArticleTypeOptions.map((option) => (
|
|
<option key={option.value || "empty"} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-col space-y-2">
|
|
|
|
<div className="min-h-72 flex-1 [&>div]:flex [&>div]:h-full [&>div]:flex-col [&_[role=textbox]]:min-h-72 xl:[&_[role=textbox]]:flex-1">
|
|
<RichActivityEditor
|
|
key={`ticket-session-${ticketId}`}
|
|
id="ticket-session-activity"
|
|
value={ticketSession.activity}
|
|
htmlValue={ticketSession.zammadArticleBody}
|
|
onChange={(activity, zammadArticleBody) => setTicketSession((current) => ({ ...current, activity, zammadArticleBody }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end xl:col-start-2">
|
|
<Button type="submit" disabled={savingTicketSession}>
|
|
<Save className="size-4" />
|
|
{savingTicketSession ? "Speichert..." : "Nachtragen"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
|
|
{data && data.openCount > 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Noch {data.openCount} Session(s) ohne Auswahl. Erst wenn im Monat keine Sessions mehr offen sind, kann der Monat abgeschlossen werden.
|
|
</p>
|
|
) : null}
|
|
|
|
<Dialog open={Boolean(teamspacePreparation)} onOpenChange={(open) => !open && setTeamspacePreparation(null)}>
|
|
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] overflow-y-auto p-5 sm:max-w-2xl">
|
|
<DialogHeader className="pr-8">
|
|
<DialogTitle>TS Aufbereitung</DialogTitle>
|
|
<DialogDescription>{teamspacePreparation?.label ?? "Sessiontätigkeiten des Tages"}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<Textarea
|
|
className="min-h-80 resize-y font-mono text-sm"
|
|
value={teamspacePreparation?.text ?? ""}
|
|
onChange={(event) =>
|
|
setTeamspacePreparation((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
text: event.currentTarget.value
|
|
}
|
|
: current
|
|
)
|
|
}
|
|
/>
|
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
<Button type="button" variant="secondary" onClick={() => setTeamspacePreparation(null)}>
|
|
Schließen
|
|
</Button>
|
|
<Button type="button" disabled={!teamspacePreparation?.text.trim()} onClick={() => void copyTeamspacePreparation()}>
|
|
<Copy className="size-4" />
|
|
Kopieren
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={mailImportOpen} onOpenChange={setMailImportOpen}>
|
|
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] overflow-y-auto p-5 sm:max-w-3xl">
|
|
<DialogHeader className="pr-8">
|
|
<DialogTitle>Zammad-Mails importieren</DialogTitle>
|
|
<DialogDescription>Eigene eMail-Artikel übernehmen. Rückblick: {mailDaysBack} Tag(e).</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
{mailPreview?.items.length ? (
|
|
<Button type="button" size="sm" disabled={importingMailSessions || selectedMailArticleIds.length === 0} onClick={() => void importTicketMailSessions()}>
|
|
<CheckCircle2 className="size-3.5" />
|
|
{importingMailSessions ? "Importiert..." : `Auswahl übernehmen (${selectedMailArticleIds.length})`}
|
|
</Button>
|
|
) : null}
|
|
<Button type="button" size="sm" variant={mailPreview ? "outline" : "default"} disabled={!canPreviewMailSessions || loadingMailPreview || importingMailSessions} onClick={() => void previewTicketMailSessions()}>
|
|
<MailPlus className="size-3.5" />
|
|
{loadingMailPreview ? "Lädt..." : "Vorschau laden"}
|
|
</Button>
|
|
</div>
|
|
|
|
{!canPreviewMailSessions ? (
|
|
<Alert variant="warning">
|
|
<AlertCircle className="size-4" />
|
|
<AlertTitle>Mail-Sync nicht bereit</AlertTitle>
|
|
<AlertDescription>Dieses Ticket braucht eine Support/Consulting-Art und dein Profil einen gültigen Zammad-Token.</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{mailPreview ? (
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Checkbox checked={allMailSelected} disabled={mailPreview.items.length === 0} onCheckedChange={(checked) => toggleAllMailSelection(checked === true)} />
|
|
{selectedMailArticleIds.length} von {mailPreview.items.length} Mail-Session(s) ausgewählt.
|
|
</label>
|
|
{mailPreview.skipped > 0 || mailPreview.errors.length > 0 ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
{mailPreview.skipped} ohne Transfer, {mailPreview.errors.length} Fehler
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{mailPreview.items.length === 0 ? (
|
|
<p className="rounded-md border bg-muted/10 px-3 py-4 text-center text-sm text-muted-foreground">Keine importierbaren Mails für dieses Ticket gefunden.</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{mailPreview.items.map((item: TicketZammadMailSessionItem) => (
|
|
<label key={item.articleId} className="grid gap-2 rounded-md border bg-background p-2 sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center">
|
|
<Checkbox checked={mailSelection[item.articleId] !== false} onCheckedChange={(checked) => toggleMailSelection(item.articleId, checked === true)} />
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-sm font-medium">{item.subject || "eMail aus Zammad"}</span>
|
|
<span className="block text-xs text-muted-foreground">
|
|
Article {item.articleId} · {formatDateTime(item.createdAt)} · {formatTimeRange(item.createdAt, item.endedAt)}
|
|
</span>
|
|
</span>
|
|
<Badge variant="outline" className="w-fit justify-self-start sm:justify-self-end">{formatMinutes(Math.round(item.durationSeconds / 60))}</Badge>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : canPreviewMailSessions ? (
|
|
<p className="rounded-md border bg-muted/10 px-3 py-6 text-center text-sm text-muted-foreground">Lade zuerst die Vorschau, um importierbare Mails auszuwählen.</p>
|
|
) : null}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={Boolean(pendingProjectDelete)} onOpenChange={(open) => !open && void keepEmptyProject()}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Keine Sessions mehr vorhanden</DialogTitle>
|
|
<DialogDescription>Projekt löschen?</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
<Button type="button" variant="secondary" disabled={deletingEmptyProject} onClick={() => void keepEmptyProject()}>
|
|
Projekt behalten
|
|
</Button>
|
|
<Button type="button" variant="destructive" disabled={deletingEmptyProject} onClick={() => void deleteEmptyProject()}>
|
|
<Trash2 className="size-4" />
|
|
{deletingEmptyProject ? "Löscht..." : "Projekt löschen"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={Boolean(editingSession)} onOpenChange={(open) => !open && setEditingSession(null)}>
|
|
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] overflow-y-auto p-5 sm:max-w-2xl">
|
|
<DialogHeader className="pr-8">
|
|
<DialogTitle>Session bearbeiten</DialogTitle>
|
|
<DialogDescription>Beginn, Ende, Tätigkeit und Stammdaten dieses Eintrags nachträglich korrigieren.</DialogDescription>
|
|
</DialogHeader>
|
|
<form className="space-y-4 overflow-x-hidden" onSubmit={saveSessionData}>
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="session-day">Datum</label>
|
|
<Input
|
|
id="session-day"
|
|
type="date"
|
|
value={editSessionDay}
|
|
onChange={(event) => setEditSessionDay(event.currentTarget.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="session-start-time">Von</label>
|
|
<Input
|
|
id="session-start-time"
|
|
type="time"
|
|
value={editSessionStartTime}
|
|
onChange={(event) => setEditSessionStartTime(event.currentTarget.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="session-end-time">Bis</label>
|
|
<Input
|
|
id="session-end-time"
|
|
type="time"
|
|
value={editSessionEndTime}
|
|
onChange={(event) => setEditSessionEndTime(event.currentTarget.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="session-organization">Organisation</label>
|
|
{editingSession?.customer_name && !editingSession.organization_id ? (
|
|
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {editingSession.customer_name}</p>
|
|
) : null}
|
|
<OrganizationSelect
|
|
value={editSessionOrganizationId}
|
|
selectedName={editSessionOrganizationName}
|
|
onChange={(organization) => {
|
|
setEditSessionOrganizationId(organization.id);
|
|
setEditSessionOrganizationName(organization.name);
|
|
}}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<RichActivityEditor
|
|
key={editingSession?.id ?? "session-activity-empty"}
|
|
id="session-activity"
|
|
value={editSessionActivity}
|
|
htmlValue={editSessionZammadArticleBody}
|
|
onChange={(activity, zammadArticleBody) => {
|
|
setEditSessionActivity(activity);
|
|
setEditSessionZammadArticleBody(zammadArticleBody);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="session-work-type">Art</label>
|
|
<select
|
|
id="session-work-type"
|
|
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
value={editSessionWorkType}
|
|
disabled={Boolean(editingSession?.recurring_billing_id)}
|
|
onChange={(event) => setEditSessionWorkType(event.currentTarget.value as WorkType)}
|
|
>
|
|
<option value="support">Support</option>
|
|
<option value="consulting">Consulting</option>
|
|
</select>
|
|
</div>
|
|
{editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="session-teamspace-entry">Teamspace-Eintrag</label>
|
|
<select
|
|
id="session-teamspace-entry"
|
|
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
value={editSessionProjectTeamspaceEntryId}
|
|
onChange={(event) => setEditSessionProjectTeamspaceEntryId(event.currentTarget.value)}
|
|
required
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{projectEntries.map((entry) => (
|
|
<option key={entry.id} value={entry.id}>
|
|
{teamspaceEntryOptionLabel(entry)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{teamspaceEntryDescription(selectedEditSessionTeamspaceEntry) ? (
|
|
<p className="text-xs text-muted-foreground">{teamspaceEntryDescription(selectedEditSessionTeamspaceEntry)}</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<div className="flex justify-end">
|
|
<Button type="submit" disabled={savingSessionDetails}>
|
|
<Save className="size-4" />
|
|
{savingSessionDetails ? "Speichert..." : "Session speichern"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|