import { FormEvent, useEffect, useMemo, useState } from "react"; import type { Dispatch, SetStateAction } from "react"; import { FolderGit, Pause, Play, Plus, RotateCcw, Search, Square, Upload } from "lucide-react"; import { toast } from "sonner"; 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 { HelpLink } from "@/components/HelpLink"; import { OrganizationSelect } from "@/components/OrganizationSelect"; import { createSession, getRecurringBillings, lookupTicket } from "../api"; import { formatTimer } from "../format"; import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings"; import { activeElapsedMs, createClientId, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers"; import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types"; type ManualSessionFormState = { ticketNumber: string; organizationId: string; organizationName: string | null; projectTeamspaceEntryId: string; activity: string; billingStatus: BillingStatus; workType: WorkType; day: string; startTime: string; endTime: string; }; function currentDay() { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; } function emptyManualSession(): ManualSessionFormState { return { ticketNumber: "", organizationId: "", organizationName: null, projectTeamspaceEntryId: "", activity: "", billingStatus: null, workType: "support", day: currentDay(), startTime: "09:00", endTime: "09:30" }; } type FinishedSession = { timerId: string; ticketNumber: string; ticket: TicketMeta | null; activity: string; billingStatus: BillingStatus; promptBillingStatus: boolean; createZammadArticle: boolean; startedAt: string; endedAt: string; durationSeconds: number; roundedMinutes: number; }; function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) { return ticket?.project_teamspace_entries ?? []; } 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 requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) { return Boolean(ticket?.is_project && ticketTeamspaceEntries(ticket).length > 1); } type TimerPageProps = { timers: TimerEntry[]; setTimers: Dispatch>; selectedTimerId: string | null; setSelectedTimerId: Dispatch>; tick: number; currentUser: AuthUser; }; function BillingStatusSelect({ id, value, onChange, disabled = false }: { id: string; value: BillingStatus; onChange: (status: BillingStatus) => void; disabled?: boolean; }) { return ( ); } export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser }: TimerPageProps) { const [ticketNumber, setTicketNumber] = useState(""); const [finished, setFinished] = useState(null); const [finishingId, setFinishingId] = useState(null); const [organizationId, setOrganizationId] = useState(""); const [organizationName, setOrganizationName] = useState(null); const [projectTeamspaceEntryId, setProjectTeamspaceEntryId] = useState(""); const [workType, setWorkType] = useState("support"); const [saving, setSaving] = useState(false); const [manualSession, setManualSession] = useState(() => emptyManualSession()); const [savingManualSession, setSavingManualSession] = useState(false); const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle"); const [manualLookupTicket, setManualLookupTicket] = useState(null); const [zammadLookupBusy, setZammadLookupBusy] = useState(null); const [recurringBillings, setRecurringBillings] = useState([]); const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null; const elapsedSeconds = useMemo(() => Math.floor(activeElapsedMs(selectedTimer, tick) / 1000), [selectedTimer, tick]); const isValidTicket = ticketPattern.test(ticketNumber.trim()); const isManualTicketValid = ticketPattern.test(manualSession.ticketNumber.trim()); const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type); const manualTicketIsRecurringBilling = ticketHasRecurringBilling(manualLookupTicket, recurringBillings) || isRecurringBillingTicketNumber(manualSession.ticketNumber.trim(), recurringBillings); const runningTimer = timers.find((timer) => timer.phase === "running") ?? null; useEffect(() => { let cancelled = false; async function loadRecurringBillings() { try { const result = await getRecurringBillings(); if (!cancelled) { setRecurringBillings(result.recurringBillings); } } catch { if (!cancelled) { setRecurringBillings([]); } } } void loadRecurringBillings(); return () => { cancelled = true; }; }, []); useEffect(() => { const normalizedTicketNumber = manualSession.ticketNumber.trim(); if (!normalizedTicketNumber) { setManualLookupStatus("idle"); setManualLookupTicket(null); return; } if (!ticketPattern.test(normalizedTicketNumber)) { setManualLookupStatus("idle"); setManualLookupTicket(null); return; } let cancelled = false; setManualLookupStatus("loading"); setManualLookupTicket(null); const timeout = window.setTimeout(async () => { try { const result = await lookupTicket(normalizedTicketNumber); if (cancelled) { return; } setManualLookupTicket(result.ticket); setManualLookupStatus(result.ticket ? "found" : "not-found"); if (result.ticket) { setManualSession((current) => { if (current.ticketNumber.trim() !== normalizedTicketNumber) { return current; } return { ...current, organizationId: result.ticket?.organization_id ?? "", organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null, projectTeamspaceEntryId: "", workType: result.ticket?.work_type ?? current.workType }; }); } } catch { if (!cancelled) { setManualLookupStatus("idle"); setManualLookupTicket(null); } } }, 250); return () => { cancelled = true; window.clearTimeout(timeout); }; }, [manualSession.ticketNumber]); async function addTimer(source: "local" | "zammad" = "local") { const normalizedTicketNumber = ticketNumber.trim(); if (!isValidTicket) { toast.error("Ticketnummer prüfen", { description: "Das Format muss Ticket#XXXXXX sein." }); return; } if (source === "zammad") { setZammadLookupBusy("timer"); } const ticketResult = await lookupTicket(normalizedTicketNumber, source).catch((error) => { if (source === "zammad") { toast.error("Zammad-Suche fehlgeschlagen", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } return { ticket: null }; }); if (source === "zammad") { setZammadLookupBusy(null); if (!ticketResult.ticket) { toast.error("Ticket nicht gefunden", { description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert." }); return; } } if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(normalizedTicketNumber, recurringBillings)) { setTimers((current) => current.filter((timer) => timer.ticketNumber !== normalizedTicketNumber)); toast.error("Fixe Abrechnung", { description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten." }); return; } if (timers.some((timer) => timer.ticketNumber === normalizedTicketNumber)) { toast.error("Timer existiert bereits", { description: "Für dieses Ticket läuft oder pausiert schon ein Timer." }); return; } const now = Date.now(); const newTimer: TimerEntry = { id: createClientId("timer"), ticketNumber: normalizedTicketNumber, organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null, workType: ticketResult.ticket?.work_type ?? null, isProject: Boolean(ticketResult.ticket?.is_project), projectName: ticketResult.ticket?.project_name ?? null, ticketLookupDone: true, startedAt: now, pausedTotalMs: 0, pausedAt: null, activity: "", billingStatus: null, phase: "running" }; setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]); setSelectedTimerId(newTimer.id); setTicketNumber(""); } async function lookupManualTicketInZammad() { const normalizedTicketNumber = manualSession.ticketNumber.trim(); if (!ticketPattern.test(normalizedTicketNumber)) { toast.error("Ticketnummer prüfen", { description: "Das Format muss Ticket#XXXXXX sein." }); return; } setZammadLookupBusy("manual"); try { const result = await lookupTicket(normalizedTicketNumber, "zammad"); if (!result.ticket) { setManualLookupStatus("not-found"); toast.error("Ticket nicht gefunden", { description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert." }); return; } setManualLookupTicket(result.ticket); setManualLookupStatus("found"); setManualSession((current) => ({ ...current, organizationId: result.ticket?.organization_id ?? "", organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null, projectTeamspaceEntryId: "", workType: result.ticket?.work_type ?? current.workType })); toast.success("Zammad-Daten übernommen"); } catch (error) { toast.error("Zammad-Suche fehlgeschlagen", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { setZammadLookupBusy(null); } } async function lookupFinishedTicketInZammad() { if (!finished) { return; } setZammadLookupBusy("finished"); try { const result = await lookupTicket(finished.ticketNumber, "zammad"); if (!result.ticket) { toast.error("Ticket nicht gefunden", { description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert." }); return; } setFinished({ ...finished, ticket: result.ticket }); setOrganizationId(result.ticket.organization_id ?? ""); setOrganizationName(result.ticket.organization_name ?? result.ticket.customer_name ?? null); setProjectTeamspaceEntryId(""); setWorkType(result.ticket.work_type ?? workType); toast.success("Zammad-Daten übernommen"); } catch (error) { toast.error("Zammad-Suche fehlgeschlagen", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { setZammadLookupBusy(null); } } function activateTimer(timerId: string) { const now = Date.now(); setTimers((current) => current.map((timer) => { if (timer.id === timerId) { return resumeEntry(timer, now); } return pauseEntry(timer, now); }) ); setSelectedTimerId(timerId); } function pauseTimer(timerId: string) { const now = Date.now(); setTimers((current) => current.map((timer) => (timer.id === timerId ? pauseEntry(timer, now) : timer))); setSelectedTimerId(timerId); } function resetTimer(timerId: string) { setTimers((current) => current.filter((timer) => timer.id !== timerId)); } function updateTimerActivity(timerId: string, nextActivity: string) { setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, activity: nextActivity } : timer))); } function updateTimerBillingStatus(timerId: string, billingStatus: BillingStatus) { setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, billingStatus } : timer))); } async function finishTimer(timerId: string) { const timer = timers.find((entry) => entry.id === timerId); if (!timer) { return; } const timerActivity = timer.activity; if (!timerActivity.trim()) { setSelectedTimerId(timer.id); toast.error("Tätigkeit erfassen", { description: "Bitte dokumentiere die Tätigkeit vor dem Beenden der Session." }); return; } const now = Date.now(); const stoppedTimer = pauseEntry(timer, now); const durationSeconds = Math.floor(activeElapsedMs(stoppedTimer, now) / 1000); const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60)); setTimers((current) => current.map((entry) => (entry.id === timerId ? pauseEntry(entry, now) : entry))); setSelectedTimerId(timer.id); setFinishingId(timer.id); try { const result = await lookupTicket(timer.ticketNumber); const ticket = result.ticket; if (ticketHasRecurringBilling(ticket, recurringBillings) || isRecurringBillingTicketNumber(timer.ticketNumber, recurringBillings)) { setTimers((current) => current.filter((entry) => entry.id !== timer.id)); toast.error("Fixe Abrechnung", { description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten." }); return; } setOrganizationId(ticket?.organization_id ?? ""); setOrganizationName(ticket?.organization_name ?? null); setProjectTeamspaceEntryId(""); setWorkType(ticket?.work_type ?? "support"); setFinished({ timerId: timer.id, ticketNumber: timer.ticketNumber, ticket, activity: timerActivity, billingStatus: timer.billingStatus, promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null, createZammadArticle: currentUser.zammad_session_note_default_enabled, startedAt: new Date(stoppedTimer.startedAt).toISOString(), endedAt: new Date(now).toISOString(), durationSeconds, roundedMinutes }); } catch (error) { toast.error("Ticket konnte nicht geprüft werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { setFinishingId(null); } } async function submitSession(event: FormEvent) { event.preventDefault(); if (!finished) { return; } if (!finished.activity.trim()) { toast.error("Tätigkeit erfassen", { description: "Bitte dokumentiere die Tätigkeit vor dem Speichern der Session." }); return; } if (ticketHasRecurringBilling(finished.ticket, recurringBillings) || isRecurringBillingTicketNumber(finished.ticketNumber, recurringBillings)) { toast.error("Fixe Abrechnung", { description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten." }); setFinished(null); return; } const existingTicketHasDefaults = Boolean(finished.ticket?.organization_id && finished.ticket?.work_type); const effectiveOrganizationId = existingTicketHasDefaults ? finished.ticket!.organization_id! : organizationId; const effectiveWorkType = existingTicketHasDefaults ? finished.ticket!.work_type! : workType; const finishedRequiresTeamspaceEntry = requiresTeamspaceEntry(finished.ticket); if (!effectiveOrganizationId) { toast.error("Organisation wählen", { description: "Neue Sessions können nur mit einer synchronisierten Organisation gespeichert werden." }); return; } if (finishedRequiresTeamspaceEntry && !projectTeamspaceEntryId) { toast.error("Teamspace-Eintrag wählen", { description: "Dieses Projekt hat mehrere Teamspace-Einträge." }); return; } setSaving(true); try { await createSession({ ticketNumber: finished.ticketNumber, startedAt: finished.startedAt, endedAt: finished.endedAt, durationSeconds: finished.durationSeconds, organizationId: effectiveOrganizationId, activity: finished.activity, workType: effectiveWorkType, zammadLookup: Boolean(finished.ticket?.zammad_ticket_id), projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null, billingStatus: finished.billingStatus, createZammadArticle: finished.createZammadArticle }); toast.success("Session gespeichert", { description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.` }); setTimers((current) => current.filter((timer) => timer.id !== finished.timerId)); setFinished(null); setOrganizationId(""); setOrganizationName(null); setProjectTeamspaceEntryId(""); setWorkType("support"); } catch (error) { toast.error("Speichern fehlgeschlagen", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { setSaving(false); } } async function submitManualSession(event: FormEvent) { event.preventDefault(); if (!isManualTicketValid) { toast.error("Ticketnummer prüfen", { description: "Das Format muss Ticket#XXXXXX sein." }); return; } if (!manualSession.organizationId) { toast.error("Organisation wählen"); return; } if (manualTicketIsRecurringBilling) { toast.error("Fixe Abrechnung", { description: "Für fixe Abrechnungen dürfen keine Sessions nachgetragen werden. Bitte die Definition bearbeiten." }); return; } if (requiresTeamspaceEntry(manualLookupTicket) && !manualSession.projectTeamspaceEntryId) { toast.error("Teamspace-Eintrag wählen", { description: "Dieses Projekt hat mehrere Teamspace-Einträge." }); return; } const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`); const endedAt = new Date(`${manualSession.day}T${manualSession.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); setSavingManualSession(true); try { await createSession({ ticketNumber: manualSession.ticketNumber.trim(), organizationId: manualSession.organizationId, activity: manualSession.activity, workType: manualSession.workType, startedAt: startedAt.toISOString(), endedAt: endedAt.toISOString(), durationSeconds, zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id), projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null, billingStatus: manualSession.billingStatus }); toast.success("Session nachgetragen", { description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen." }); setManualSession({ ...emptyManualSession(), day: manualSession.day }); } catch (error) { toast.error("Session konnte nicht nachgetragen werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { setSavingManualSession(false); } } return (

Timer

Mehrere Tickets vorbereiten, aber immer nur eine Session aktiv messen.

{runningTimer ? Aktiv: {runningTimer.ticketNumber} : null} {!runningTimer && timers.length > 0 ? Alle pausiert : null} {timers.length === 0 ? Kein Timer aktiv : null}
Neuen Timer starten Ein neuer Timer startet sofort und pausiert alle anderen Timer automatisch.
setTicketNumber(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter") { void addTimer(); } }} /> {ticketNumber.length > 0 && !isValidTicket ?

Format: Ticket#XXXXXX

: null}
{selectedTimer ? selectedTimer.ticketNumber : "Keine Session ausgewählt"} {selectedTimer ? selectedTimer.organizationName ? ( {selectedTimer.isProject ? : null} {selectedTimer.organizationName} {selectedTimer.workType ? · {selectedTimer.workType === "support" ? "Support" : "Consulting"} : null} ) : "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert." : "Starte einen Timer, um eine Session zu erfassen."}

Gemessene Arbeitszeit

{formatTimer(elapsedSeconds)}