From 2792051a16b3fe36f19ea74af5884b67f171b1fb Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Tue, 1 Sep 2026 11:18:47 +0200 Subject: [PATCH] Auto-fill manual sessions from existing tickets --- backend/src/index.ts | 4 +- frontend/src/views/TimerPage.tsx | 89 ++++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index c8544cf..10d16cd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2785,7 +2785,7 @@ app.post("/api/sessions", requireUser, async (req, res) => { [ticketNumber] ); const existingTicket = existingTicketResult.rows[0] ?? null; - const effectiveOrganizationId = requestedOrganizationId ?? (existingTicket?.organization_id ? Number(existingTicket.organization_id) : null); + const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId; if (!effectiveOrganizationId) { throw badRequest("organizationId is required"); @@ -2812,7 +2812,7 @@ app.post("/api/sessions", requireUser, async (req, res) => { ); const ticket = ticketResult.rows[0]; - const workType = requestedWorkType ?? ticket.work_type; + const workType = ticket.work_type ?? requestedWorkType; if (!workType) { throw badRequest("workType must be support or consulting"); diff --git a/frontend/src/views/TimerPage.tsx b/frontend/src/views/TimerPage.tsx index b29bacb..1e3f02a 100644 --- a/frontend/src/views/TimerPage.tsx +++ b/frontend/src/views/TimerPage.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useMemo, useState } from "react"; +import { FormEvent, useEffect, useMemo, useState } from "react"; import type { Dispatch, SetStateAction } from "react"; import { Pause, Play, Plus, RotateCcw, Square, Upload } from "lucide-react"; import { toast } from "sonner"; @@ -73,12 +73,74 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer 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 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 isManualTicketValid = ticketPattern.test(manualSession.ticketNumber.trim()); + const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type); const runningTimer = timers.find((timer) => timer.phase === "running") ?? null; + 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, + 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() { if (!isValidTicket) { toast.error("Ticketnummer prüfen", { @@ -231,7 +293,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer async function submitManualSession(event: FormEvent) { event.preventDefault(); - if (!ticketPattern.test(manualSession.ticketNumber)) { + if (!isManualTicketValid) { toast.error("Ticketnummer prüfen", { description: "Das Format muss Ticket#XXXXXX sein." }); @@ -262,7 +324,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer try { await createSession({ - ticketNumber: manualSession.ticketNumber, + ticketNumber: manualSession.ticketNumber.trim(), organizationId: manualSession.organizationId, activity: manualSession.activity, workType: manualSession.workType, @@ -403,15 +465,33 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer id="manual-ticket-number" placeholder="Ticket#123456" value={manualSession.ticketNumber} - onChange={(event) => setManualSession({ ...manualSession, ticketNumber: event.currentTarget.value })} + onChange={(event) => + setManualSession({ + ...manualSession, + ticketNumber: event.currentTarget.value, + organizationId: "", + organizationName: null, + workType: "support" + }) + } required /> + {manualSession.ticketNumber.length > 0 && !isManualTicketValid ?

Format: Ticket#XXXXXX

: null} + {manualLookupStatus === "loading" ?

Prüfe vorhandenes Ticket...

: null} + {manualLookupStatus === "found" && manualTicketDefaultsLocked ? ( +

Ticket gefunden. Organisation und Art wurden übernommen.

+ ) : null} + {manualLookupStatus === "found" && !manualTicketDefaultsLocked ? ( +

Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.

+ ) : null} + {manualLookupStatus === "not-found" ?

Neues Ticket. Organisation und Art bitte wählen.

: null}
setManualSession({ ...manualSession, @@ -428,6 +508,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer 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} + disabled={manualTicketDefaultsLocked} onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })} >