Initial TicketTracker release

This commit is contained in:
2026-08-05 10:55:30 +02:00
commit 125b1d22ff
128 changed files with 26208 additions and 0 deletions
+541
View File
@@ -0,0 +1,541 @@
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 { 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<SetStateAction<TimerEntry[]>>;
selectedTimerId: string | null;
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
tick: number;
};
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick }: TimerPageProps) {
const [ticketNumber, setTicketNumber] = useState("");
const [finished, setFinished] = useState<FinishedSession | null>(null);
const [finishingId, setFinishingId] = useState<string | null>(null);
const [organizationId, setOrganizationId] = useState("");
const [organizationName, setOrganizationName] = useState<string | null>(null);
const [activity, setActivity] = useState("");
const [workType, setWorkType] = useState<WorkType>("support");
const [saving, setSaving] = useState(false);
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => 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 (
<div className="space-y-4">
<section className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Timer</h2>
<p className="text-sm text-muted-foreground">Mehrere Tickets vorbereiten, aber immer nur eine Session aktiv messen.</p>
</div>
{runningTimer ? <Badge variant="success">Aktiv: {runningTimer.ticketNumber}</Badge> : null}
{!runningTimer && timers.length > 0 ? <Badge variant="warning">Alle pausiert</Badge> : null}
{timers.length === 0 ? <Badge variant="info">Kein Timer aktiv</Badge> : null}
</div>
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<CardTitle>Neuen Timer starten</CardTitle>
<CardDescription>Ein neuer Timer startet sofort und pausiert alle anderen Timer automatisch.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto] sm:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-number">
Ticketnummer
</label>
<Input
id="ticket-number"
placeholder="Ticket#123456"
value={ticketNumber}
onChange={(event) => setTicketNumber(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
void addTimer();
}
}}
/>
{ticketNumber.length > 0 && !isValidTicket ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
</div>
<Button onClick={() => void addTimer()}>
<Plus className="size-4" />
Timer starten
</Button>
</CardContent>
</Card>
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<CardTitle>{selectedTimer ? selectedTimer.ticketNumber : "Keine Session ausgewählt"}</CardTitle>
<CardDescription>
{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."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-4">
<div className="rounded-lg border bg-background p-4 sm:p-5">
<p className="mb-2 text-sm font-medium text-muted-foreground">Gemessene Arbeitszeit</p>
<p className="font-mono text-4xl font-bold leading-none tracking-normal sm:text-6xl">
{formatTimer(elapsedSeconds)}
</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
{selectedTimer?.phase === "running" ? (
<Button variant="secondary" disabled={!selectedTimer} onClick={() => selectedTimer && pauseTimer(selectedTimer.id)}>
<Pause className="size-4" />
Pausieren
</Button>
) : (
<Button disabled={!selectedTimer} onClick={() => selectedTimer && activateTimer(selectedTimer.id)}>
<Play className="size-4" />
Aktivieren
</Button>
)}
<Button variant="destructive" disabled={!selectedTimer || finishingId === selectedTimer?.id} onClick={() => selectedTimer && void finishTimer(selectedTimer.id)}>
<Square className="size-4" />
{finishingId === selectedTimer?.id ? "Prüft..." : "Beenden"}
</Button>
<Button variant="ghost" disabled={!selectedTimer} onClick={() => selectedTimer && resetTimer(selectedTimer.id)}>
<RotateCcw className="size-4" />
Entfernen
</Button>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<CardTitle>Session nachtragen</CardTitle>
<CardDescription>Vergessene Zeiten für deinen eigenen Account manuell erfassen.</CardDescription>
</CardHeader>
<CardContent className="p-4">
<form className="space-y-4" onSubmit={submitManualSession}>
<div className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_150px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label>
<Input
id="manual-ticket-number"
placeholder="Ticket#123456"
value={manualSession.ticketNumber}
onChange={(event) => setManualSession({ ...manualSession, ticketNumber: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
<OrganizationSelect
value={manualSession.organizationId}
selectedName={manualSession.organizationName}
onChange={(organization) =>
setManualSession({
...manualSession,
organizationId: organization.id,
organizationName: organization.name
})
}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-work-type">Art</label>
<select
id="manual-work-type"
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={manualSession.workType}
onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label>
<Input
id="manual-day"
type="date"
value={manualSession.day}
onChange={(event) => setManualSession({ ...manualSession, day: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-start">Von</label>
<Input
id="manual-start"
type="time"
value={manualSession.startTime}
onChange={(event) => setManualSession({ ...manualSession, startTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-end">Bis</label>
<Input
id="manual-end"
type="time"
value={manualSession.endTime}
onChange={(event) => setManualSession({ ...manualSession, endTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
<Textarea
id="manual-activity"
className="min-h-20"
value={manualSession.activity}
onChange={(event) => setManualSession({ ...manualSession, activity: event.currentTarget.value })}
required
/>
</div>
<Button type="submit" disabled={savingManualSession}>
<Upload className="size-4" />
{savingManualSession ? "Speichert..." : "Nachtragen"}
</Button>
</div>
</form>
</CardContent>
</Card>
</section>
<Dialog open={Boolean(finished)} onOpenChange={(open) => !open && setFinished(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Session abschließen</DialogTitle>
<DialogDescription>
{finished?.ticketNumber}: {finished?.roundedMinutes} Minute(n), Pausen bereits abgezogen.
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={submitSession}>
{finished?.ticket?.organization_id && finished.ticket.work_type ? (
<div className="rounded-lg border bg-muted/40 p-3 text-sm">
<p className="font-medium">{finished.ticket.organization_name ?? finished.ticket.customer_name}</p>
<p className="text-muted-foreground">{finished.ticket.work_type === "support" ? "Support" : "Consulting"} wurde vom bestehenden Ticket übernommen.</p>
</div>
) : (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="organization">
Organisation
</label>
{finished?.ticket?.customer_name && !finished.ticket.organization_id ? (
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {finished.ticket.customer_name}</p>
) : null}
<OrganizationSelect
value={organizationId}
selectedName={organizationName}
onChange={(organization) => {
setOrganizationId(organization.id);
setOrganizationName(organization.name);
}}
required
/>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="activity">
Tätigkeit
</label>
<Textarea id="activity" value={activity} onChange={(event) => setActivity(event.currentTarget.value)} required />
</div>
{finished?.ticket?.organization_id && finished.ticket.work_type ? null : (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="work-type">
Art
</label>
<select
id="work-type"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={workType}
onChange={(event) => setWorkType(event.currentTarget.value as WorkType)}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
</div>
)}
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
<Upload className="size-4" />
{saving ? "Speichern..." : "Eintrag speichern"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
}