Files
tickettracker/frontend/src/views/RecurringBillingsPage.tsx
T
2026-09-08 16:48:28 +02:00

501 lines
21 KiB
TypeScript

import { FormEvent, useEffect, useState } from "react";
import { Pencil, Plus, Repeat, Trash2, X } 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 { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { createRecurringBilling, deleteRecurringBilling, getRecurringBillings, updateRecurringBilling } from "../api";
import { currentDay, formatMinutes } from "../format";
import { ticketPattern } from "../timers";
import type { RecurringBilling, WorkType } from "../types";
type RecurringSlotFormState = {
id?: string;
weekday: number | null;
startTime: string;
durationHours: string;
};
type RecurringFormState = {
ticketNumber: string;
organizationId: string;
organizationName: string | null;
activity: string;
workType: WorkType;
recurrenceType: "weekly" | "every_n_weeks";
intervalValue: number;
validFrom: string;
validUntil: string;
slots: RecurringSlotFormState[];
};
const weekdays = [
{ value: 1, label: "Mo" },
{ value: 2, label: "Di" },
{ value: 3, label: "Mi" },
{ value: 4, label: "Do" },
{ value: 5, label: "Fr" },
{ value: 6, label: "Sa" },
{ value: 0, label: "So" }
];
function emptyRecurringForm(): RecurringFormState {
return {
ticketNumber: "",
organizationId: "",
organizationName: null,
activity: "",
workType: "support",
recurrenceType: "weekly",
intervalValue: 1,
validFrom: currentDay(),
validUntil: "",
slots: [{ weekday: 4, startTime: "09:00", durationHours: "4" }]
};
}
function formatDateLabel(value: string) {
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric"
}).format(new Date(`${value}T00:00:00`));
}
export function RecurringBillingsPage() {
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
const [recurringForm, setRecurringForm] = useState<RecurringFormState>(() => emptyRecurringForm());
const [loadingRecurring, setLoadingRecurring] = useState(false);
const [creatingRecurring, setCreatingRecurring] = useState(false);
const [updatingRecurringId, setUpdatingRecurringId] = useState<string | null>(null);
const [editingBillingId, setEditingBillingId] = useState<string | null>(null);
useEffect(() => {
void loadRecurringBillings();
}, []);
async function loadRecurringBillings() {
setLoadingRecurring(true);
try {
const result = await getRecurringBillings();
setRecurringBillings(result.recurringBillings);
} catch (error) {
toast.error("Fixe Abrechnungen konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setLoadingRecurring(false);
}
}
function changeRecurringType(nextType: "weekly" | "every_n_weeks") {
setRecurringForm((current) => ({
...current,
recurrenceType: nextType,
intervalValue: nextType === "weekly" ? 1 : current.intervalValue || 1,
slots:
nextType === "weekly"
? [{ weekday: 4, startTime: current.slots[0]?.startTime ?? "09:00", durationHours: current.slots[0]?.durationHours ?? "4" }]
: [{ weekday: null, startTime: current.slots[0]?.startTime ?? "09:00", durationHours: current.slots[0]?.durationHours ?? "4" }]
}));
}
function durationHoursLabel(minutes: number) {
const hours = minutes / 60;
return Number.isInteger(hours) ? String(hours) : String(hours).replace(".", ",");
}
function startEditing(billing: RecurringBilling) {
setEditingBillingId(billing.id);
setRecurringForm({
ticketNumber: billing.configured_ticket_number ?? "",
organizationId: billing.organization_id,
organizationName: billing.organization_name,
activity: billing.activity,
workType: billing.work_type,
recurrenceType: billing.recurrence_type,
intervalValue: billing.recurrence_type === "weekly" ? 1 : billing.interval_value,
validFrom: billing.valid_from,
validUntil: billing.valid_until ?? "",
slots: billing.slots.map((slot) => ({
id: slot.id,
weekday: billing.recurrence_type === "weekly" ? slot.weekday : null,
startTime: slot.start_time?.slice(0, 5) ?? "09:00",
durationHours: durationHoursLabel(slot.duration_minutes)
}))
});
window.scrollTo({ top: 0, behavior: "smooth" });
}
function cancelEditing() {
setEditingBillingId(null);
setRecurringForm(emptyRecurringForm());
}
function updateRecurringSlot(index: number, patch: Partial<RecurringSlotFormState>) {
setRecurringForm((current) => ({
...current,
slots: current.slots.map((slot, slotIndex) => (slotIndex === index ? { ...slot, ...patch } : slot))
}));
}
async function submitRecurringBilling(event: FormEvent) {
event.preventDefault();
if (!recurringForm.organizationId) {
toast.error("Organisation wählen");
return;
}
const trimmedTicketNumber = recurringForm.ticketNumber.trim();
if (trimmedTicketNumber && !ticketPattern.test(trimmedTicketNumber)) {
toast.error("Ticketnummer prüfen", {
description: "Leer lassen für Fix#ID oder Ticket#XXXXXX eintragen."
});
return;
}
let slots: Array<{ id?: string; weekday: number | null; startTime: string; durationMinutes: number }>;
try {
slots = recurringForm.slots.map((slot) => {
const hours = Number(slot.durationHours.replace(",", "."));
if (!Number.isFinite(hours) || hours <= 0) {
throw new Error("Dauer prüfen");
}
return {
id: slot.id,
weekday: recurringForm.recurrenceType === "weekly" ? slot.weekday : null,
startTime: slot.startTime,
durationMinutes: Math.round(hours * 60)
};
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Dauer prüfen");
return;
}
setCreatingRecurring(true);
try {
const payload = {
ticketNumber: trimmedTicketNumber || null,
organizationId: recurringForm.organizationId,
activity: recurringForm.activity,
workType: recurringForm.workType,
recurrenceType: recurringForm.recurrenceType,
intervalValue: recurringForm.recurrenceType === "weekly" ? 1 : recurringForm.intervalValue,
validFrom: recurringForm.validFrom,
validUntil: recurringForm.validUntil || null,
slots
};
const result = editingBillingId
? await updateRecurringBilling(editingBillingId, payload)
: await createRecurringBilling(payload);
setRecurringBillings(result.recurringBillings);
setRecurringForm(emptyRecurringForm());
setEditingBillingId(null);
toast.success(editingBillingId ? "Fixe Abrechnung gespeichert" : "Fixe Abrechnung angelegt", {
description: "Die Sessions erscheinen automatisch in Tickets, sobald du den Zeitraum öffnest. Nicht mehr gültige erzeugte Sessions werden entfernt."
});
} catch (error) {
toast.error("Fixe Abrechnung konnte nicht angelegt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setCreatingRecurring(false);
}
}
async function removeRecurring(billing: RecurringBilling) {
if (!window.confirm(`Fixe Abrechnung ${billing.ticket_number} wirklich löschen? Dadurch werden auch alle daraus erzeugten Sessions entfernt.`)) {
return;
}
setUpdatingRecurringId(billing.id);
try {
const result = await deleteRecurringBilling(billing.id);
setRecurringBillings(result.recurringBillings);
toast.success("Fixe Abrechnung gelöscht", {
description: "Die daraus erzeugten Sessions wurden entfernt."
});
} catch (error) {
toast.error("Fixe Abrechnung konnte nicht gelöscht werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setUpdatingRecurringId(null);
}
}
return (
<div 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">Fixe Abrechnungen</h2>
<p className="text-sm text-muted-foreground">Wiederkehrende Zeiten für deinen Account verwalten.</p>
</div>
<Badge variant="outline">{recurringBillings.length} Regel(n)</Badge>
</div>
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Repeat className="size-5 text-muted-foreground" />
<CardTitle>{editingBillingId ? "Regel bearbeiten" : "Neue Regel"}</CardTitle>
</div>
<HelpLink anchor="fixe-abrechnungen" label="Hilfe zu fixen Abrechnungen" />
</div>
<CardDescription>Ticket ist optional. Leer bedeutet automatische Gruppierung als Fix#ID.</CardDescription>
</CardHeader>
<CardContent className="p-4">
<form className="space-y-4" onSubmit={submitRecurringBilling}>
<div className="grid gap-3 lg:grid-cols-[170px_minmax(220px,1fr)_150px_180px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-ticket-number">
Ticket
</label>
<Input
id="recurring-ticket-number"
placeholder="leer = Fix#ID"
value={recurringForm.ticketNumber}
onChange={(event) => setRecurringForm({ ...recurringForm, ticketNumber: event.currentTarget.value })}
/>
{recurringForm.ticketNumber.trim() && !ticketPattern.test(recurringForm.ticketNumber.trim()) ? (
<p className="text-xs text-destructive">Leer lassen oder Ticket#XXXXXX</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
<OrganizationSelect
value={recurringForm.organizationId}
selectedName={recurringForm.organizationName}
onChange={(organization) => setRecurringForm({ ...recurringForm, organizationId: organization.id, organizationName: organization.name })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-work-type">
Art
</label>
<select
id="recurring-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={recurringForm.workType}
onChange={(event) => setRecurringForm({ ...recurringForm, workType: event.currentTarget.value as WorkType })}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-type">
Muster
</label>
<select
id="recurring-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={recurringForm.recurrenceType}
onChange={(event) => changeRecurringType(event.currentTarget.value as "weekly" | "every_n_weeks")}
>
<option value="weekly">Wochentage</option>
<option value="every_n_weeks">Alle X Wochen</option>
</select>
</div>
</div>
<div className={recurringForm.recurrenceType === "every_n_weeks" ? "grid gap-3 sm:grid-cols-[180px_180px_180px]" : "grid gap-3 sm:grid-cols-[180px_180px]"}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-valid-from">
Gültig von
</label>
<Input
id="recurring-valid-from"
type="date"
value={recurringForm.validFrom}
onChange={(event) => setRecurringForm({ ...recurringForm, validFrom: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-valid-until">
Gültig bis
</label>
<Input
id="recurring-valid-until"
type="date"
value={recurringForm.validUntil}
onChange={(event) => setRecurringForm({ ...recurringForm, validUntil: event.currentTarget.value })}
/>
</div>
{recurringForm.recurrenceType === "every_n_weeks" ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-interval">
Alle X Wochen
</label>
<Input
id="recurring-interval"
type="number"
min="1"
value={recurringForm.intervalValue}
onChange={(event) => setRecurringForm({ ...recurringForm, intervalValue: Number(event.currentTarget.value) || 1 })}
required
/>
</div>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-activity">
Tätigkeit
</label>
<Textarea
id="recurring-activity"
value={recurringForm.activity}
onChange={(event) => setRecurringForm({ ...recurringForm, activity: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{recurringForm.recurrenceType === "weekly" ? "Wochentage und Dauer" : "Startzeit und Dauer"}</label>
<div className="space-y-2">
{recurringForm.slots.map((slot, index) => (
<div
key={index}
className={
recurringForm.recurrenceType === "weekly"
? "grid gap-2 rounded-md border p-2 sm:grid-cols-[90px_120px_120px_auto] sm:items-center"
: "grid gap-2 rounded-md border p-2 sm:grid-cols-[120px_120px_auto] sm:items-center"
}
>
{recurringForm.recurrenceType === "weekly" ? (
<select
className="h-9 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={slot.weekday ?? 1}
onChange={(event) => updateRecurringSlot(index, { weekday: Number(event.currentTarget.value) })}
>
{weekdays.map((weekday) => (
<option key={weekday.value} value={weekday.value}>
{weekday.label}
</option>
))}
</select>
) : null}
<Input type="time" value={slot.startTime} onChange={(event) => updateRecurringSlot(index, { startTime: event.currentTarget.value })} required />
<Input
inputMode="decimal"
value={slot.durationHours}
onChange={(event) => updateRecurringSlot(index, { durationHours: event.currentTarget.value })}
placeholder="2,5"
required
/>
<Button
type="button"
size="icon"
variant="ghost"
className="size-8 text-destructive hover:text-destructive"
disabled={recurringForm.slots.length === 1}
onClick={() => setRecurringForm({ ...recurringForm, slots: recurringForm.slots.filter((_, slotIndex) => slotIndex !== index) })}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
{recurringForm.recurrenceType === "weekly" ? (
<Button
type="button"
variant="secondary"
size="sm"
onClick={() =>
setRecurringForm({
...recurringForm,
slots: [...recurringForm.slots, { weekday: 1, startTime: "09:00", durationHours: "2,5" }]
})
}
>
<Plus className="size-4" />
Slot hinzufügen
</Button>
) : null}
</div>
<div className="flex justify-end">
<div className="flex gap-2">
{editingBillingId ? (
<Button type="button" variant="secondary" disabled={creatingRecurring} onClick={cancelEditing}>
<X className="size-4" />
Abbrechen
</Button>
) : null}
<Button type="submit" disabled={creatingRecurring}>
<Repeat className="size-4" />
{creatingRecurring ? "Speichert..." : editingBillingId ? "Regel speichern" : "Fixe Abrechnung anlegen"}
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Regeln</CardTitle>
<CardDescription>Erzeugte Einträge können in Tickets verschoben oder gelöscht werden.</CardDescription>
</div>
<HelpLink anchor="fixe-abrechnungen" label="Hilfe zu erzeugten fixen Sessions" />
</div>
</CardHeader>
<CardContent className="space-y-2 p-4">
{recurringBillings.map((billing) => (
<div key={billing.id} className="grid gap-2 rounded-md border bg-background p-3 lg:grid-cols-[1fr_auto] lg:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{billing.ticket_number}</span>
{billing.configured_ticket_number ? <Badge variant="outline">Ticket</Badge> : <Badge variant="outline">Fix</Badge>}
</div>
<p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{billing.organization_name} · {billing.activity}</p>
<p className="text-xs text-muted-foreground">
{billing.recurrence_type === "weekly" ? "Wochentage" : `Alle ${billing.interval_value} Woche(n)`} · {formatDateLabel(billing.valid_from)}
{billing.valid_until ? ` bis ${formatDateLabel(billing.valid_until)}` : ""}
</p>
<div className="mt-1 flex flex-wrap gap-1">
{billing.slots.map((slot) => (
<Badge key={slot.id} variant="outline">
{slot.weekday === null ? "Starttag" : weekdays.find((weekday) => weekday.value === slot.weekday)?.label} · {slot.start_time?.slice(0, 5)} · {formatMinutes(slot.duration_minutes)}
</Badge>
))}
</div>
</div>
<div className="flex gap-2 lg:justify-end">
<Button size="sm" variant="secondary" disabled={updatingRecurringId === billing.id} onClick={() => startEditing(billing)}>
<Pencil className="size-4" />
Bearbeiten
</Button>
<Button size="icon" variant="ghost" className="size-8 text-destructive hover:text-destructive" disabled={updatingRecurringId === billing.id} onClick={() => void removeRecurring(billing)}>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
{loadingRecurring ? <p className="py-2 text-center text-sm text-muted-foreground">Fixe Abrechnungen werden geladen...</p> : null}
{!loadingRecurring && recurringBillings.length === 0 ? <p className="py-2 text-center text-sm text-muted-foreground">Noch keine fixen Abrechnungen vorhanden.</p> : null}
</CardContent>
</Card>
</div>
);
}