import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, EyeOff, FolderKanban, Pencil, Plus, RotateCcw, Save, Star, Trash2 } from "lucide-react"; import { FormEvent, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Alert } from "@/components/ui/alert"; 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 { TodoPanel } from "@/components/TodoPanel"; import { createSession, deleteProject, deleteSession, hideProject, addFavoriteTicket, getDashboardPreference, getTicketDetails, getTicketPeriod, removeFavoriteTicket, updateSessionBilling, updateSessionDetails, updateTicket, updateTicketDayBilling, updateProject, updateProjectStatus } from "../api"; import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format"; import { ticketHasRecurringBilling } from "../recurring-billings"; import type { BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types"; import { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta"; import { cn } from "@/lib/utils"; type TicketDetailPageProps = { periodType: TicketDetailPeriodType; period: string; ticketId: string; onNavigate: (to: string) => void; 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; 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; hourBudget: string; budgetSyncDisabled: boolean; manualConsumption: string; isDefault: boolean; }; function emptyTicketSessionForm(): TicketSessionFormState { return { organizationId: "", organizationName: null, projectTeamspaceEntryId: "", activity: "", workType: "support", day: currentDay(), startTime: "09:00", endTime: "09: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 projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] { if (project.teamspace_entries?.length) { return project.teamspace_entries; } return [ { id: `${project.id}-default`, teamspace_project: project.teamspace_project, 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 teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; is_default: boolean }) { const label = entry.teamspace_project || "Ohne Teamspace-ID"; return entry.is_default ? `${label} (Standard)` : label; } function projectEditForm(project: ProjectSummary): ProjectEditFormState { const entries = projectTeamspaceEntries(project).map((entry, index) => ({ clientId: entry.id || projectEntryId(), teamspaceProject: entry.teamspace_project ?? "", 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 combinedProjectDescription(project: ProjectSummary) { return [project.description, project.goals].map((part) => part.trim()).filter(Boolean).join("\n\n"); } function BillingButtons({ session, disabled, loading, onChange }: { session: SessionEntry; disabled: boolean; loading: boolean; onChange: (status: BillingStatus) => void; }) { const status = session.billing_status; 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 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: "", 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 hourBudgetMinutes = parseHoursInput(entry.hourBudget); const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption); if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) { return undefined; } return { id: /^\d+$/.test(entry.clientId) ? entry.clientId : null, teamspaceProject: entry.teamspaceProject.trim() || null, isDefault: entry.isDefault, hourBudgetMinutes, budgetSyncDisabled: entry.budgetSyncDisabled, manualConsumptionMinutes: manualConsumptionMinutes ?? 0 }; }); if (parsedEntries.some((entry) => entry === undefined)) { return undefined; } return parsedEntries as Array<{ id?: string | null; teamspaceProject: string | null; isDefault: boolean; hourBudgetMinutes: number | null; budgetSyncDisabled: boolean; manualConsumptionMinutes: number; }>; } function ProjectTeamspaceEntriesEditor({ entries, onChange }: { entries: ProjectTeamspaceEntryForm[]; onChange: (entries: ProjectTeamspaceEntryForm[]) => void; }) { 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, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })} />
updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })} />
))}

Der Standard-Eintrag erhält die gebuchten Teamspace-Tageswerte. Manuelle Stunden zählen nur im Projektverbrauch.

); } export function TicketDetailPage({ periodType, period, ticketId, onNavigate, 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 [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 [dayBillingInputs, setDayBillingInputs] = useState>({}); const [savingDayBillingKey, setSavingDayBillingKey] = useState(null); const [ticketSession, setTicketSession] = useState(() => emptyTicketSessionForm()); const [savingTicketSession, setSavingTicketSession] = useState(false); const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter); const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "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 [projectForm, setProjectForm] = useState({ title: "", organizationId: "", organizationName: null, description: "", startsOn: "", endsOn: "", teamspaceEntries: [ { clientId: projectEntryId(), teamspaceProject: "", 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); 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 nextInputs = Object.fromEntries((data?.dayBillings ?? []).map((billing) => [billing.day, formatHoursInput(billing.billed_minutes)])); setDayBillingInputs(nextInputs); }, [data?.dayBillings]); 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(() => { setProjectDescriptionExpanded(false); }, [data?.project?.id]); 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 hasMultipleProjectTeamspaceEntries = projectEntries.length > 1; const showBudgetedProjectEntries = budgetedProjectEntries.length > 1; const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries); 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 !== false; const canEditTicketData = !project || canEditProject; const isProject = Boolean(project); const isGlobalTicket = periodType === "all"; const isPeriodClosed = Boolean(data?.closed); 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 dayBillingMap = useMemo(() => new Map((data?.dayBillings ?? []).map((billing) => [billing.day, billing.billed_minutes])), [data?.dayBillings]); const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day)); const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day); const sessionGroups = useMemo(() => { const groups = new Map< string, { key: string; label: string; totalMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] } >(); for (const session of data?.sessions ?? []) { const key = sessionDayKey(session.started_at); const group = groups.get(key); const billedMinutes = session.billing_status === "billed" ? session.rounded_minutes : 0; const nonBillableMinutes = session.billing_status === "non_billable" ? session.rounded_minutes : 0; const openMinutes = session.billing_status === null ? session.rounded_minutes : 0; if (group) { group.sessions.push(session); group.totalMinutes += billedMinutes; group.nonBillableMinutes += nonBillableMinutes; group.openMinutes += openMinutes; continue; } groups.set(key, { key, label: formatDate(session.started_at), totalMinutes: billedMinutes, nonBillableMinutes, openMinutes, sessions: [session] }); } return Array.from(groups.values()); }, [data?.sessions]); 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 = Array.from(dayBillingMap.values()).reduce((sum, minutes) => sum + Number(minutes ?? 0), 0); const ticketDataDirty = Boolean( ticket && (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 || 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); } } function toggleSessionDetails(sessionId: string) { setExpandedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [...current, sessionId] ); } function toggleAllSessionDetails() { setExpandedSessionIds(allSessionsExpanded ? [] : sessionIds); } async function saveDayBilling(day: string) { 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[day] ?? ""); 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(day) ?? null; if (parsedMinutes === currentMinutes) { return; } setSavingDayBillingKey(day); try { await updateTicketDayBilling(ticket.id, day, parsedMinutes); 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("Projekt ist geteilt", { description: "Nur der Zammad-Owner kann Ticket- und Projektdaten ä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 (project.can_edit === false) { 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 (project.can_edit === false) { 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 (project.can_edit === false) { 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 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 (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 muss nach Beginn liegen"); return; } if (editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry && !editSessionProjectTeamspaceEntryId) { toast.error("Teamspace-Eintrag wählen", { description: "Dieses Projekt hat mehrere Teamspace-Einträge." }); return; } const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000); setSavingTicketSession(true); try { await createSession({ ticketNumber: ticket.ticket_number, organizationId: effectiveOrganizationId, activity: ticketSession.activity, workType: effectiveWorkType, startedAt: startedAt.toISOString(), endedAt: endedAt.toISOString(), durationSeconds, projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null }); toast.success("Session nachgetragen", { description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet." }); setTicketSession({ ...emptyTicketSessionForm(), organizationId: ticket.organization_id ?? "", organizationName: ticket.organization_name ?? null, projectTeamspaceEntryId: "", workType: ticket.work_type ?? "support", day: ticketSession.day }); 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); 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 muss nach Beginn liegen"); 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 { await updateSessionDetails(editingSession.id, { organizationId: editSessionOrganizationId, activity: editSessionActivity, 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} {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} {isGlobalTicket ? ( Filter Grenzt die Sessionliste dieses Tickets ein. Die Ticketdaten selbst bleiben global.
) : null}
Ticketdaten {canEditTicketData ? "Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen." : "Dieses Projekt ist geteilt. Nur der Zammad-Owner kann Ticket- und Projektdaten ä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} {(project.unassigned_teamspace_session_count ?? 0) > 0 ? {project.unassigned_teamspace_session_count} ohne TS : null} {!canEditProject && project.owner_display_name ? `Eigentümer: ${project.owner_display_name}` : showBudgetedProjectEntries ? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge` : budgetedProjectEntries[0]?.teamspace_project || project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? (
Nicht zugeordnete Sessions
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag. Bitte über die Session-Bearbeitung neu zuordnen.
) : null} {project.close_review_required ? (
Zeiten prüfen
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 ? `${formatMinutes(projectConsumptionMinutes)} / ${formatMinutes(projectBudgetMinutes)} · ${projectRemainingLabel(projectRemainingMinutes)}` : `${formatMinutes(projectConsumptionMinutes)} / offen`}
{budgetedProjectEntries.map((entry) => { const consumption = Number(entry.consumption_minutes ?? 0); const percent = projectConsumptionPercent(consumption, entry.hour_budget_minutes); const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID"); return (
{label} {entry.is_default ? " · Standard" : ""} {entry.budget_sync_disabled ? " · Budget geschützt" : ""} {formatMinutes(consumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
); })}
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 })} />