import { FormEvent, useMemo, useState } from "react"; import type { Dispatch, SetStateAction } from "react"; import { Pause, Play, Plus, RotateCcw, 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 { 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, lookupTicket } from "../api"; import { formatTimer } from "../format"; import { activeElapsedMs, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers"; import type { TicketMeta, WorkType } from "../types"; type ManualSessionFormState = { ticketNumber: string; organizationId: string; organizationName: string | null; activity: string; 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, activity: "", workType: "support", day: currentDay(), startTime: "09:00", endTime: "09:30" }; } type FinishedSession = { timerId: string; ticketNumber: string; ticket: TicketMeta | null; startedAt: string; endedAt: string; durationSeconds: number; roundedMinutes: number; }; type TimerPageProps = { timers: TimerEntry[]; setTimers: Dispatch>; selectedTimerId: string | null; setSelectedTimerId: Dispatch>; tick: number; }; export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick }: 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 [activity, setActivity] = useState(""); const [workType, setWorkType] = useState("support"); const [saving, setSaving] = useState(false); const [manualSession, setManualSession] = useState(() => emptyManualSession()); const [savingManualSession, setSavingManualSession] = useState(false); 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); const runningTimer = timers.find((timer) => timer.phase === "running") ?? null; async function addTimer() { if (!isValidTicket) { toast.error("Ticketnummer prüfen", { description: "Das Format muss Ticket#XXXXXX sein." }); return; } if (timers.some((timer) => timer.ticketNumber === ticketNumber)) { toast.error("Timer existiert bereits", { description: "Für dieses Ticket läuft oder pausiert schon ein Timer." }); return; } const now = Date.now(); const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null })); const newTimer: TimerEntry = { id: crypto.randomUUID(), ticketNumber, organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null, workType: ticketResult.ticket?.work_type ?? null, ticketLookupDone: true, startedAt: now, pausedTotalMs: 0, pausedAt: null, phase: "running" }; setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]); setSelectedTimerId(newTimer.id); setTicketNumber(""); } 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)); } async function finishTimer(timerId: string) { const timer = timers.find((entry) => entry.id === timerId); if (!timer) { 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; setOrganizationId(ticket?.organization_id ?? ""); setOrganizationName(ticket?.organization_name ?? null); setWorkType(ticket?.work_type ?? "support"); setActivity(""); setFinished({ timerId: timer.id, ticketNumber: timer.ticketNumber, ticket, 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; } 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; if (!effectiveOrganizationId) { toast.error("Organisation wählen", { description: "Neue Sessions können nur mit einer synchronisierten Organisation gespeichert werden." }); return; } setSaving(true); try { await createSession({ ticketNumber: finished.ticketNumber, startedAt: finished.startedAt, endedAt: finished.endedAt, durationSeconds: finished.durationSeconds, organizationId: effectiveOrganizationId, activity, workType: effectiveWorkType }); 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); setActivity(""); 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 (!ticketPattern.test(manualSession.ticketNumber)) { toast.error("Ticketnummer prüfen", { description: "Das Format muss Ticket#XXXXXX sein." }); return; } if (!manualSession.organizationId) { toast.error("Organisation wählen"); 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, organizationId: manualSession.organizationId, activity: manualSession.activity, workType: manualSession.workType, startedAt: startedAt.toISOString(), endedAt: endedAt.toISOString(), durationSeconds }); toast.success("Session nachgetragen", { description: "Der Eintrag ist in deiner Auswertung 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.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}` : "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert." : "Starte einen Timer, um eine Session zu erfassen."}

Gemessene Arbeitszeit

{formatTimer(elapsedSeconds)}

{selectedTimer?.phase === "running" ? ( ) : ( )}
Session nachtragen Vergessene Zeiten für deinen eigenen Account manuell erfassen.
setManualSession({ ...manualSession, ticketNumber: event.currentTarget.value })} required />
setManualSession({ ...manualSession, organizationId: organization.id, organizationName: organization.name }) } required />
setManualSession({ ...manualSession, day: event.currentTarget.value })} required />
setManualSession({ ...manualSession, startTime: event.currentTarget.value })} required />
setManualSession({ ...manualSession, endTime: event.currentTarget.value })} required />