This commit is contained in:
michael committed 2026-09-08 16:48:28 +02:00
1 parent 3d9674bd5a
commit d1da86beab
20 files changed
+6109 -923

No files matched your search

+69 -6
View File
@@ -10,10 +10,11 @@ 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 { createSession, getRecurringBillings, lookupTicket } from "../api";
import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
import type { TicketMeta, WorkType } from "../types";
import type { RecurringBilling, TicketMeta, WorkType } from "../types";
type ManualSessionFormState = {
ticketNumber: string;
@@ -75,14 +76,41 @@ 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 [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 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();
@@ -104,7 +132,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const timeout = window.setTimeout(async () => {
try {
const result = await lookupTicket(normalizedTicketNumber);
const result = await lookupTicket(normalizedTicketNumber);
if (cancelled) {
return;
@@ -149,6 +177,16 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringBillings)) {
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
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)) {
toast.error("Timer existiert bereits", {
description: "Für dieses Ticket läuft oder pausiert schon ein Timer."
@@ -157,7 +195,6 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
}
const now = Date.now();
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
const newTimer: TimerEntry = {
id: crypto.randomUUID(),
ticketNumber,
@@ -221,6 +258,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
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);
setWorkType(ticket?.work_type ?? "support");
@@ -250,6 +295,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
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;
@@ -307,6 +360,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
if (manualTicketIsRecurringBilling) {
toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine Sessions nachgetragen werden. Bitte die Definition bearbeiten."
});
return;
}
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
@@ -335,7 +395,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
durationSeconds
});
toast.success("Session nachgetragen", {
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Auswertung offen."
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
});
setManualSession({
...emptyManualSession(),
@@ -488,6 +548,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
<p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p>
) : null}
{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}
</div>
<div className="space-y-2">
@@ -562,7 +625,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
required
/>
</div>
<Button type="submit" disabled={savingManualSession}>
<Button type="submit" disabled={savingManualSession || manualTicketIsRecurringBilling}>
<Upload className="size-4" />
{savingManualSession ? "Speichert..." : "Nachtragen"}
</Button>