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; 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) { 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, """); } function textToEditorHtml(value: string) { return value .split(/\r?\n/) .map((line) => `
${line ? escapeHtml(line) : "
"}
`) .join(""); } function fileToDataUrl(file: File) { return new Promise((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 = { 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 = {}; 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(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) { 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(`
`); insertionRange = currentSelectionRange(); } emitChange(); } catch (error) { toast.error("Screenshot konnte nicht eingefügt werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } } return (
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 (
); } function BillingCaptureSelect({ id, value, onChange }: { id: string; value: BillingStatus; onChange: (status: BillingStatus) => void; }) { return ( ); } 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 (
); } return

{session.activity || "Keine Tätigkeit erfasst."}

; } 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) { onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.map((entry) => (entry.clientId === clientId ? { ...entry, ...changes } : entry)))); } function removeEntry(clientId: string) { onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.filter((entry) => entry.clientId !== clientId))); } return (
{normalizedEntries.map((entry, index) => (
updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })} />
updateEntry(entry.clientId, { teamspaceDescription: event.currentTarget.value })} />
{showManualConsumption ? (
updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })} />
) : null}
))}
); } export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onStartTimer, currentUser, onOpenTicketChange, returnPath }: TicketDetailPageProps) { const initialGlobalMonthFilter = periodType === "all" && period !== "all" ? period : currentMonth(); const [data, setData] = useState(null); const [loadingId, setLoadingId] = useState(null); const [deletingId, setDeletingId] = useState(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(null); const [editWorkType, setEditWorkType] = useState("support"); const [editingSession, setEditingSession] = useState(null); const [pendingProjectDelete, setPendingProjectDelete] = useState<{ projectId: string } | null>(null); const [editSessionOrganizationId, setEditSessionOrganizationId] = useState(""); const [editSessionOrganizationName, setEditSessionOrganizationName] = useState(null); const [editSessionActivity, setEditSessionActivity] = useState(""); const [editSessionZammadArticleBody, setEditSessionZammadArticleBody] = useState(""); const [editSessionWorkType, setEditSessionWorkType] = useState("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(null); const [dayBillingInputs, setDayBillingInputs] = useState>({}); const [savingDayBillingKey, setSavingDayBillingKey] = useState(null); const [selectedTeamspaceEntryId, setSelectedTeamspaceEntryId] = useState(null); const [ticketSession, setTicketSession] = useState(() => 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([]); const [teamspaceSubentryDrafts, setTeamspaceSubentryDrafts] = useState>({}); const [savingTeamspaceSubentryId, setSavingTeamspaceSubentryId] = useState(null); const [projectForm, setProjectForm] = useState({ 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([]); const [projectDescriptionExpanded, setProjectDescriptionExpanded] = useState(false); const [teamspacePreparation, setTeamspacePreparation] = useState<{ day: string; label: string; text: string } | null>(null); const [mailPreview, setMailPreview] = useState(null); const [mailSelection, setMailSelection] = useState>({}); 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(); 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(); 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(); 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 (

{ticket?.title || ticket?.ticket_number || "Ticket"}

{isProject ? Projekt : null} {(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? Monat geschlossen : null} {ticket?.ticket_number ? : null}

{ticket?.title ? `${ticket.ticket_number} · ` : ""} Sessions {sessionsScopeLabel}

{ticket?.ticket_number ? ( ) : null} {ticket?.ticket_number ? ( ) : null} {ticket?.ticket_number ? ( ) : null} {data?.openCount === 0 ? "Bewertet" : `${data?.openCount ?? 0} offen`}
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? ( Dieser Monat ist abgeschlossen. Bewertungen und Änderungen sind geschützt, bis du den Monat im Monatsabschluss wieder öffnest. ) : null} {ticket?.is_foreign_zammad_owner ? ( Anderer Zammad-Owner 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. ) : null} {ticketManagedByZammad ? ( Ticketdaten werden von Zammad verwaltet Nummer, Art, Titel und Organisation wurden durch den Zammad-Sync übernommen und können in TicketTracker nicht manuell geändert werden. ) : null}
Ticketdaten {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."}
setEditTicketNumber(event.currentTarget.value)} required />
setEditTicketTitle(event.currentTarget.value)} placeholder="optional" />
{ticket?.customer_name && !ticket.organization_id ? (

Bisheriger Freitext: {ticket.customer_name}

) : null} { setEditOrganizationId(organization.id); setEditOrganizationName(organization.name); }} required />
{project ? (
Projekt {project.closed_at ? Geschlossen : Offen} {!canEditProject ? geteilt : null} {project.close_review_required ? TS/TT prüfen : null} {unassignedTeamspaceEntryCount > 0 ? {unassignedTeamspaceEntryCount} ohne TS : null} {unassignedTeamspaceSubentryCount > 0 ? {unassignedTeamspaceSubentryCount} ohne Pos. : null} {!canEditProject && projectOwnerLabel ? `Eigentümer: ${projectOwnerLabel}` : showBudgetedProjectEntries ? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge` : budgetedProjectEntries[0] ? teamspaceEntryDisplayLabel(budgetedProjectEntries[0]) : project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
{unassignedTeamspaceEntryCount > 0 || unassignedTeamspaceSubentryCount > 0 ? (
Nicht zugeordnete Sessions
{[ 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.
) : null} {project.close_review_required ? (
{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Zeiten prüfen"}
{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."}
) : null}
Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"} Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"} Verbrauch {formatMinutes(projectConsumptionMinutes)} {projectRemainingLabel(projectRemainingMinutes)} {showBudgetedProjectEntries ? {budgetedProjectEntries.length} Budgets : null}
Budgetverbrauch {projectBudgetMinutes && projectBudgetMinutes > 0 && projectConsumptionMinutes > projectBudgetMinutes ? ( ) : null} {projectBudgetMinutes && projectBudgetMinutes > 0 ? `${formatMinutes(projectConsumptionMinutes)} / ${formatMinutes(projectBudgetMinutes)} · ${projectRemainingLabel(projectRemainingMinutes)}` : `${formatMinutes(projectConsumptionMinutes)} / offen`}
{showBudgetedProjectEntries ? (
{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 (
{label} {entry.is_default ? " · Standard" : ""} {budgetExceeded ? : null} {formatMinutes(consumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
); })}
) : null}
Beschreibung/Ziele

{projectDescription || "Noch keine Beschreibung/Ziele."}

{projectDescription ? ( ) : null}
) : null} Projekt bearbeiten Projektinformationen zum geöffneten Ticket aktualisieren.
setProjectForm({ ...projectForm, title: event.currentTarget.value })} placeholder="optional" />
setProjectForm({ ...projectForm, organizationId: organization.id, organizationName: organization.name }) } required />
setProjectForm({ ...projectForm, startsOn: event.currentTarget.value })} />
setProjectForm({ ...projectForm, endsOn: event.currentTarget.value })} />
setProjectForm({ ...projectForm, teamspaceEntries })} />