Initial TicketTracker release
This commit is contained in:
commit
125b1d22ff
128 files changed
+26208
No files matched your search
@@ -0,0 +1,491 @@
|
||||
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 { 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 der Auswertung, 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-center gap-2">
|
||||
<Repeat className="size-5 text-muted-foreground" />
|
||||
<CardTitle>{editingBillingId ? "Regel bearbeiten" : "Neue Regel"}</CardTitle>
|
||||
</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">
|
||||
<CardTitle>Regeln</CardTitle>
|
||||
<CardDescription>Erzeugte Einträge können in der Auswertung verschoben oder gelöscht werden.</CardDescription>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user