import { ArrowLeft, Pencil, Save, 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 { 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 { createSession, deleteSession, getTicketDetails, getTicketPeriod, updateSessionBilling, updateSessionDetails, updateTicket, updateTicketDayBilling } from "../api"; import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format"; import type { BillingStatus, PeriodType, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types"; 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; dirty: boolean; }) => void; }; type TicketSessionFormState = { organizationId: string; organizationName: string | null; activity: string; workType: WorkType; day: string; startTime: string; endTime: string; }; function emptyTicketSessionForm(): TicketSessionFormState { return { organizationId: "", organizationName: null, activity: "", workType: "support", day: currentDay(), startTime: "09:00", endTime: "09:30" }; } 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")}`; } 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 isPartialHoursInput(value: string) { return /^\d*(?:[,.]\d*)?$/.test(value.trim()); } export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange }: 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 [editTicketNumber, setEditTicketNumber] = useState(""); const [editOrganizationId, setEditOrganizationId] = useState(""); const [editOrganizationName, setEditOrganizationName] = useState(null); const [editWorkType, setEditWorkType] = useState("support"); const [editingSession, setEditingSession] = useState(null); const [editSessionOrganizationId, setEditSessionOrganizationId] = useState(""); const [editSessionOrganizationName, setEditSessionOrganizationName] = useState(null); const [editSessionActivity, setEditSessionActivity] = useState(""); const [editSessionWorkType, setEditSessionWorkType] = useState("support"); 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"); 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); setEditOrganizationId(data.ticket.organization_id ?? ""); setEditOrganizationName(data.ticket.organization_name ?? null); setEditWorkType(data.ticket.work_type ?? "support"); setTicketSession((current) => ({ ...current, organizationId: data.ticket.organization_id ?? current.organizationId, organizationName: data.ticket.organization_name ?? current.organizationName, workType: data.ticket.work_type ?? current.workType })); }, [data?.ticket]); const ticket = data?.ticket; 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 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 || editOrganizationId !== (ticket.organization_id ?? "") || editWorkType !== (ticket.work_type ?? "support")) ); const hasUnsavedChanges = ticketDataDirty || sessionGroups.some((group) => isDayBillingDirty(group.key)); 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}`, dirty: hasUnsavedChanges }); }, [ticket, periodType, period, isGlobalTicket, globalMonthFilter, hasUnsavedChanges, onOpenTicketChange]); 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 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 (!editOrganizationId) { toast.error("Organisation wählen"); return; } setSavingTicket(true); try { await updateTicket(ticket.id, { ticketNumber: editTicketNumber, organizationId: editOrganizationId, workType: editWorkType }); toast.success("Ticketdaten gespeichert", { description: "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); } } async function submitTicketSession(event: FormEvent) { event.preventDefault(); if (!ticket) { return; } const effectiveOrganizationId = ticket.organization_id ?? ticketSession.organizationId; const effectiveWorkType = ticket.work_type ?? ticketSession.workType; if (!effectiveOrganizationId) { toast.error("Organisation wählen"); 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; } 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 }); 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, 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); setEditSessionDay(toDateLocalValue(session.started_at)); setEditSessionStartTime(toTimeLocalValue(session.started_at)); setEditSessionEndTime(toTimeLocalValue(session.ended_at)); } 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; } setSavingSessionDetails(true); try { await updateSessionDetails(editingSession.id, { organizationId: editSessionOrganizationId, activity: editSessionActivity, workType: editSessionWorkType, 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.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("/analysis"); 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); } } return (

{ticket?.ticket_number ?? "Ticket"}

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

Sessions {sessionsScopeLabel}

{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 Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen.
setEditTicketNumber(event.currentTarget.value)} required />
{ticket?.customer_name && !ticket.organization_id ? (

Bisheriger Freitext: {ticket.customer_name}

) : null} { setEditOrganizationId(organization.id); setEditOrganizationName(organization.name); }} required />
Session-Einträge Wähle für jede Session genau eine Bewertung aus oder lösche falsche Einträge.
Abgerechnet {formatMinutes(periodTotalMinutes)}
{teamspaceLabel} {formatMinutes(periodTeamspaceMinutes)}
{sessionGroups.map((group) => (

{group.label}

Tag abgerechnet {formatMinutes(group.totalMinutes)} {group.nonBillableMinutes > 0 ? Nicht {formatMinutes(group.nonBillableMinutes)} : null} {group.openMinutes > 0 ? Offen {formatMinutes(group.openMinutes)} : null}
{ const nextValue = event.currentTarget.value; if (!isPartialHoursInput(nextValue)) { return; } setDayBillingInputs((current) => ({ ...current, [group.key]: nextValue })); }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); } }} /> h
{group.sessions.map((session) => (
{formatTimeRange(session.started_at, session.ended_at)}

{session.activity}

{formatMinutes(session.rounded_minutes)} {session.recurring_billing_id ? fix : null}
setBilling(session.id, value)} />
))}
))}
{data?.sessions.length === 0 ? (

Keine Sessions für dieses Ticket im gewählten Filter.

) : null}
Session nachtragen Neue Zeit direkt diesem Ticket zuordnen.
{ticket?.organization_id && ticket.work_type ? (
{ticket.organization_name ?? ticket.customer_name} {ticket.work_type === "support" ? "Support" : "Consulting"}
) : (
{ticket?.customer_name && !ticket.organization_id ? (

Bisheriger Freitext: {ticket.customer_name}

) : null} setTicketSession({ ...ticketSession, organizationId: organization.id, organizationName: organization.name }) } required />
)}
setTicketSession({ ...ticketSession, day: event.currentTarget.value })} required />
setTicketSession({ ...ticketSession, startTime: event.currentTarget.value })} required />
setTicketSession({ ...ticketSession, endTime: event.currentTarget.value })} required />