Weiter
This commit is contained in:
1 parent
c2063fe1ed
commit
07ef8ef025
10 files changed
+1624
-93
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Pause, Play, Plus, RotateCcw, Square, Upload } from "lucide-react";
|
||||
import { 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";
|
||||
@@ -20,6 +20,7 @@ type ManualSessionFormState = {
|
||||
ticketNumber: string;
|
||||
organizationId: string;
|
||||
organizationName: string | null;
|
||||
projectTeamspaceEntryId: string;
|
||||
activity: string;
|
||||
workType: WorkType;
|
||||
day: string;
|
||||
@@ -37,6 +38,7 @@ function emptyManualSession(): ManualSessionFormState {
|
||||
ticketNumber: "",
|
||||
organizationId: "",
|
||||
organizationName: null,
|
||||
projectTeamspaceEntryId: "",
|
||||
activity: "",
|
||||
workType: "support",
|
||||
day: currentDay(),
|
||||
@@ -55,6 +57,14 @@ type FinishedSession = {
|
||||
roundedMinutes: number;
|
||||
};
|
||||
|
||||
function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
|
||||
return ticket?.project_teamspace_entries ?? [];
|
||||
}
|
||||
|
||||
function requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) {
|
||||
return Boolean(ticket?.is_project && ticketTeamspaceEntries(ticket).length > 1);
|
||||
}
|
||||
|
||||
type TimerPageProps = {
|
||||
timers: TimerEntry[];
|
||||
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
|
||||
@@ -69,6 +79,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
const [finishingId, setFinishingId] = useState<string | null>(null);
|
||||
const [organizationId, setOrganizationId] = useState("");
|
||||
const [organizationName, setOrganizationName] = useState<string | null>(null);
|
||||
const [projectTeamspaceEntryId, setProjectTeamspaceEntryId] = useState("");
|
||||
const [activity, setActivity] = useState("");
|
||||
const [workType, setWorkType] = useState<WorkType>("support");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -76,11 +87,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
const [savingManualSession, setSavingManualSession] = useState(false);
|
||||
const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
||||
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null);
|
||||
const [zammadLookupBusy, setZammadLookupBusy] = useState<string | null>(null);
|
||||
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
|
||||
|
||||
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 isValidTicket = ticketPattern.test(ticketNumber.trim());
|
||||
const isManualTicketValid = ticketPattern.test(manualSession.ticketNumber.trim());
|
||||
const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type);
|
||||
const manualTicketIsRecurringBilling =
|
||||
@@ -151,6 +163,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
...current,
|
||||
organizationId: result.ticket?.organization_id ?? "",
|
||||
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
|
||||
projectTeamspaceEntryId: "",
|
||||
workType: result.ticket?.work_type ?? current.workType
|
||||
};
|
||||
});
|
||||
@@ -169,7 +182,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
};
|
||||
}, [manualSession.ticketNumber]);
|
||||
|
||||
async function addTimer() {
|
||||
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."
|
||||
@@ -177,17 +192,40 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
return;
|
||||
}
|
||||
|
||||
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
|
||||
if (source === "zammad") {
|
||||
setZammadLookupBusy("timer");
|
||||
}
|
||||
|
||||
if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringBillings)) {
|
||||
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
|
||||
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 === ticketNumber)) {
|
||||
if (timers.some((timer) => timer.ticketNumber === normalizedTicketNumber)) {
|
||||
toast.error("Timer existiert bereits", {
|
||||
description: "Für dieses Ticket läuft oder pausiert schon ein Timer."
|
||||
});
|
||||
@@ -197,7 +235,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
const now = Date.now();
|
||||
const newTimer: TimerEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
ticketNumber,
|
||||
ticketNumber: normalizedTicketNumber,
|
||||
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
|
||||
workType: ticketResult.ticket?.work_type ?? null,
|
||||
isProject: Boolean(ticketResult.ticket?.is_project),
|
||||
@@ -214,6 +252,80 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
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) =>
|
||||
@@ -268,6 +380,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
|
||||
setOrganizationId(ticket?.organization_id ?? "");
|
||||
setOrganizationName(ticket?.organization_name ?? null);
|
||||
setProjectTeamspaceEntryId("");
|
||||
setWorkType(ticket?.work_type ?? "support");
|
||||
setActivity("");
|
||||
setFinished({
|
||||
@@ -306,6 +419,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
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", {
|
||||
@@ -314,6 +428,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
return;
|
||||
}
|
||||
|
||||
if (finishedRequiresTeamspaceEntry && !projectTeamspaceEntryId) {
|
||||
toast.error("Teamspace-Eintrag wählen", {
|
||||
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
@@ -324,7 +445,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
durationSeconds: finished.durationSeconds,
|
||||
organizationId: effectiveOrganizationId,
|
||||
activity,
|
||||
workType: effectiveWorkType
|
||||
workType: effectiveWorkType,
|
||||
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
|
||||
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null
|
||||
});
|
||||
|
||||
toast.success("Session gespeichert", {
|
||||
@@ -334,6 +457,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
setFinished(null);
|
||||
setOrganizationId("");
|
||||
setOrganizationName(null);
|
||||
setProjectTeamspaceEntryId("");
|
||||
setActivity("");
|
||||
setWorkType("support");
|
||||
} catch (error) {
|
||||
@@ -367,6 +491,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
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`);
|
||||
|
||||
@@ -392,7 +523,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
workType: manualSession.workType,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
durationSeconds
|
||||
durationSeconds,
|
||||
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
|
||||
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null
|
||||
});
|
||||
toast.success("Session nachgetragen", {
|
||||
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
|
||||
@@ -433,7 +566,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
<HelpLink anchor="timer" label="Hilfe zu Timern" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto] sm:items-end">
|
||||
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto_auto] sm:items-end">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="ticket-number">
|
||||
Ticketnummer
|
||||
@@ -456,6 +589,10 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
<Plus className="size-4" />
|
||||
Timer starten
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={!isValidTicket || zammadLookupBusy === "timer"} onClick={() => void addTimer("zammad")}>
|
||||
<Search className="size-4" />
|
||||
{zammadLookupBusy === "timer" ? "Sucht..." : "In Zammad suchen"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -533,6 +670,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
ticketNumber: event.currentTarget.value,
|
||||
organizationId: "",
|
||||
organizationName: null,
|
||||
projectTeamspaceEntryId: "",
|
||||
workType: "support"
|
||||
})
|
||||
}
|
||||
@@ -551,7 +689,15 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
{manualTicketIsRecurringBilling ? (
|
||||
<p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p>
|
||||
) : null}
|
||||
{manualLookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen.</p> : null}
|
||||
{manualLookupStatus === "not-found" ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen oder aus Zammad übernehmen.</p>
|
||||
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "manual"} onClick={() => void lookupManualTicketInZammad()}>
|
||||
<Search className="size-3.5" />
|
||||
{zammadLookupBusy === "manual" ? "Sucht..." : "In Zammad suchen"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Organisation</label>
|
||||
@@ -582,6 +728,25 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
<option value="consulting">Consulting</option>
|
||||
</select>
|
||||
</div>
|
||||
{requiresTeamspaceEntry(manualLookupTicket) ? (
|
||||
<div className="space-y-2 lg:col-span-3">
|
||||
<label className="text-sm font-medium" htmlFor="manual-teamspace-entry">Teamspace-Eintrag</label>
|
||||
<select
|
||||
id="manual-teamspace-entry"
|
||||
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.projectTeamspaceEntryId}
|
||||
onChange={(event) => setManualSession({ ...manualSession, projectTeamspaceEntryId: event.currentTarget.value })}
|
||||
required
|
||||
>
|
||||
<option value="">Bitte wählen</option>
|
||||
{ticketTeamspaceEntries(manualLookupTicket).map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
|
||||
@@ -646,6 +811,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={submitSession}>
|
||||
{finished && !finished.ticket?.organization_id ? (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "finished"} onClick={() => void lookupFinishedTicketInZammad()}>
|
||||
<Search className="size-3.5" />
|
||||
{zammadLookupBusy === "finished" ? "Sucht..." : "In Zammad suchen"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{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>
|
||||
@@ -694,6 +867,25 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{requiresTeamspaceEntry(finished?.ticket) ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="finished-teamspace-entry">Teamspace-Eintrag</label>
|
||||
<select
|
||||
id="finished-teamspace-entry"
|
||||
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={projectTeamspaceEntryId}
|
||||
onChange={(event) => setProjectTeamspaceEntryId(event.currentTarget.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Bitte wählen</option>
|
||||
{ticketTeamspaceEntries(finished?.ticket).map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Upload className="size-4" />
|
||||
|
||||
Reference in new issue
Block a user