feat(repairs): add repair estimates

This commit is contained in:
Schubert Ferenc 2026-07-05 00:57:02 +02:00
parent 6e7e75f864
commit 4436fe5f73
25 changed files with 1802 additions and 9 deletions

View file

@ -0,0 +1,401 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { FileCheck2, Plus, Send, Trash2, XCircle } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import { useToast } from "@/components/common/ToastProvider";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/api";
import type {
RepairEstimate,
RepairEstimateItemPayload,
RepairEstimateItemType,
RepairEstimatePayload,
} from "@/types/repair";
const itemTypeLabels: Record<RepairEstimateItemType, string> = {
labor: "Arbeitszeit",
part: "Ersatzteil",
flat_rate: "Pauschale",
shipping: "Versand",
other: "Sonstiges",
};
const statusLabels: Record<string, string> = {
draft: "Entwurf",
sent: "Gesendet",
approved: "Freigegeben",
declined: "Abgelehnt",
expired: "Abgelaufen",
cancelled: "Storniert",
};
function getErrorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden";
}
return "Aktion konnte nicht abgeschlossen werden";
}
function money(cents: number, currency = "EUR") {
return new Intl.NumberFormat("de-DE", { style: "currency", currency }).format(cents / 100);
}
function dateTime(value: string | null) {
if (!value) return "-";
return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
}
function emptyItem(): RepairEstimateItemPayload {
return {
item_type: "labor",
title: "",
description: "",
quantity: "1.00",
unit: "Std.",
unit_price_cents: 0,
};
}
function emptyPayload(): RepairEstimatePayload {
return {
title: "Kostenvoranschlag",
customer_message: "Bitte prüfen Sie den Kostenvoranschlag und geben Sie uns über den Statuslink Rückmeldung.",
internal_note: "",
tax_rate_percent: "19.00",
currency: "EUR",
valid_until: null,
items: [emptyItem()],
};
}
type Props = {
repairId: number;
customerEmail: string;
canRead: boolean;
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
canSend: boolean;
};
export default function RepairEstimatesSection({
repairId,
customerEmail,
canRead,
canCreate,
canUpdate,
canDelete,
canSend,
}: Props) {
const { showToast } = useToast();
const [estimates, setEstimates] = useState<RepairEstimate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<RepairEstimate | null>(null);
const [payload, setPayload] = useState<RepairEstimatePayload>(emptyPayload());
const [saving, setSaving] = useState(false);
const [pendingId, setPendingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
const loadEstimates = useCallback(async () => {
if (!canRead) {
setLoading(false);
return;
}
setLoading(true);
setError("");
try {
const response = await api.get<RepairEstimate[]>(`/repairs/${repairId}/estimates`);
setEstimates(response.data);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
}, [canRead, repairId]);
useEffect(() => {
queueMicrotask(() => {
void loadEstimates();
});
}, [loadEstimates]);
const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => {
const quantity = Number(item.quantity.replace(",", ".")) || 0;
return sum + Math.round(quantity * item.unit_price_cents);
}, 0), [payload.items]);
const clientTax = Math.round(clientSubtotal * (Number(payload.tax_rate_percent.replace(",", ".")) || 0) / 100);
function openCreateDialog() {
setEditing(null);
setPayload(emptyPayload());
setDialogOpen(true);
}
function openEditDialog(estimate: RepairEstimate) {
setEditing(estimate);
setPayload({
title: estimate.title,
customer_message: estimate.customer_message,
internal_note: estimate.internal_note ?? "",
tax_rate_percent: String(estimate.tax_rate_percent),
currency: estimate.currency,
valid_until: estimate.valid_until,
items: estimate.items.map((item) => ({
item_type: item.item_type,
title: item.title,
description: item.description ?? "",
quantity: String(item.quantity),
unit: item.unit,
unit_price_cents: item.unit_price_cents,
})),
});
setDialogOpen(true);
}
function updateItem(index: number, update: Partial<RepairEstimateItemPayload>) {
setPayload((current) => ({
...current,
items: current.items.map((item, itemIndex) => itemIndex === index ? { ...item, ...update } : item),
}));
}
async function saveEstimate() {
setSaving(true);
try {
const normalizedPayload = {
...payload,
internal_note: payload.internal_note || null,
valid_until: payload.valid_until || null,
items: payload.items.map((item) => ({
...item,
description: item.description || null,
quantity: item.quantity.replace(",", "."),
})),
};
if (editing) {
await api.put<RepairEstimate>(`/repairs/${repairId}/estimates/${editing.id}`, normalizedPayload);
} else {
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates`, normalizedPayload);
}
await loadEstimates();
setDialogOpen(false);
showToast({ type: "success", title: editing ? "Kostenvoranschlag gespeichert" : "Kostenvoranschlag erstellt" });
} catch (err) {
showToast({ type: "error", title: "Kostenvoranschlag konnte nicht gespeichert werden", description: getErrorMessage(err) });
} finally {
setSaving(false);
}
}
async function sendEstimate(estimate: RepairEstimate) {
setPendingId(estimate.id);
try {
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${estimate.id}/send`);
await loadEstimates();
showToast({ type: "success", title: "Kostenvoranschlag gesendet", description: "Der Versandversuch wurde dokumentiert." });
} catch (err) {
showToast({ type: "error", title: "Kostenvoranschlag konnte nicht gesendet werden", description: getErrorMessage(err) });
} finally {
setPendingId(null);
}
}
async function cancelEstimate(estimate: RepairEstimate) {
setPendingId(estimate.id);
try {
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${estimate.id}/cancel`);
await loadEstimates();
showToast({ type: "success", title: "Kostenvoranschlag storniert" });
} catch (err) {
showToast({ type: "error", title: "Stornierung fehlgeschlagen", description: getErrorMessage(err) });
} finally {
setPendingId(null);
}
}
async function deleteEstimate() {
if (!deleteTarget) return;
setPendingId(deleteTarget.id);
try {
await api.delete(`/repairs/${repairId}/estimates/${deleteTarget.id}`);
await loadEstimates();
setDeleteTarget(null);
showToast({ type: "success", title: "Kostenvoranschlag gelöscht" });
} catch (err) {
showToast({ type: "error", title: "Löschen fehlgeschlagen", description: getErrorMessage(err) });
} finally {
setPendingId(null);
}
}
if (!canRead) {
return <p className="text-sm text-slate-500">Keine Berechtigung für Kostenvoranschläge.</p>;
}
if (loading) {
return <div className="rounded-lg border bg-slate-50 p-5 text-sm text-slate-500">Kostenvoranschläge werden geladen...</div>;
}
if (error) {
return <div className="rounded-lg border bg-red-50 p-5 text-sm text-red-700">{error}</div>;
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-slate-500">{estimates.length} Kostenvoranschlag{estimates.length === 1 ? "" : "e"}</p>
{canCreate && <Button type="button" onClick={openCreateDialog}><Plus />KV anlegen</Button>}
</div>
{estimates.length === 0 ? (
<div className="rounded-lg border border-dashed bg-slate-50 p-8 text-center">
<h3 className="text-lg font-semibold text-slate-950">Noch kein Kostenvoranschlag vorhanden.</h3>
<p className="mx-auto mt-2 max-w-xl text-sm text-slate-500">Erstelle Positionen, lasse Olympus serverseitig summieren und sende den KV per Statuslink an den Kunden.</p>
{canCreate && <div className="mt-5"><Button type="button" onClick={openCreateDialog}><Plus />KV anlegen</Button></div>}
</div>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{estimates.map((estimate) => (
<article key={estimate.id} className="rounded-lg border bg-white p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<FileCheck2 className="h-5 w-5 text-slate-500" />
<h3 className="font-semibold text-slate-950">{estimate.estimate_number}</h3>
</div>
<p className="mt-1 text-sm text-slate-600">{estimate.title}</p>
</div>
<span className="w-fit rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700">{statusLabels[estimate.status]}</span>
</div>
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
<Meta label="Summe netto" value={money(estimate.subtotal_cents, estimate.currency)} />
<Meta label="MwSt." value={money(estimate.tax_cents, estimate.currency)} />
<Meta label="Gesamt" value={money(estimate.total_cents, estimate.currency)} />
<Meta label="Gültig bis" value={estimate.valid_until ?? "-"} />
<Meta label="Gesendet" value={dateTime(estimate.sent_at)} />
<Meta label="Antwort" value={estimate.customer_response_message || "-"} />
</dl>
<div className="mt-4 divide-y rounded-lg border">
{estimate.items.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-3 p-3 text-sm">
<div>
<p className="font-medium text-slate-950">{item.position}. {item.title}</p>
<p className="text-xs text-slate-500">{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}</p>
</div>
<p className="font-medium text-slate-950">{money(item.total_cents, estimate.currency)}</p>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{canUpdate && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => openEditDialog(estimate)}>Bearbeiten</Button>}
{canSend && ["draft", "sent"].includes(estimate.status) && <Button type="button" size="sm" onClick={() => void sendEstimate(estimate)} disabled={pendingId === estimate.id || !customerEmail}><Send />Senden</Button>}
{canUpdate && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => void cancelEstimate(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</Button>}
{canDelete && ["draft", "cancelled"].includes(estimate.status) && <Button type="button" variant="destructive" size="sm" onClick={() => setDeleteTarget(estimate)} disabled={pendingId === estimate.id}><Trash2 />Löschen</Button>}
</div>
</article>
))}
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{editing ? "Kostenvoranschlag bearbeiten" : "Kostenvoranschlag anlegen"}</DialogTitle>
<DialogDescription>Summen werden nach dem Speichern serverseitig berechnet.</DialogDescription>
</DialogHeader>
<div className="grid max-h-[70vh] gap-5 overflow-y-auto pr-1">
<div className="grid gap-4 md:grid-cols-2">
<Field label="Titel"><Input value={payload.title} onChange={(event) => setPayload((current) => ({ ...current, title: event.target.value }))} /></Field>
<Field label="Gültig bis"><Input type="date" value={payload.valid_until ?? ""} onChange={(event) => setPayload((current) => ({ ...current, valid_until: event.target.value || null }))} /></Field>
<Field label="MwSt. %"><Input value={payload.tax_rate_percent} onChange={(event) => setPayload((current) => ({ ...current, tax_rate_percent: event.target.value }))} /></Field>
<Field label="Währung"><Input value={payload.currency} onChange={(event) => setPayload((current) => ({ ...current, currency: event.target.value.toUpperCase() }))} /></Field>
</div>
<Field label="Kundennachricht"><textarea className="min-h-20 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none" value={payload.customer_message} onChange={(event) => setPayload((current) => ({ ...current, customer_message: event.target.value }))} /></Field>
<Field label="Interne Notiz"><textarea className="min-h-20 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none" value={payload.internal_note ?? ""} onChange={(event) => setPayload((current) => ({ ...current, internal_note: event.target.value }))} /></Field>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-slate-950">Positionen</h4>
<Button type="button" variant="outline" size="sm" onClick={() => setPayload((current) => ({ ...current, items: [...current.items, emptyItem()] }))}><Plus />Position</Button>
</div>
{payload.items.map((item, index) => (
<div key={index} className="rounded-lg border bg-slate-50 p-3">
<div className="grid gap-3 md:grid-cols-[1fr_1fr_0.7fr_0.7fr_0.8fr_auto]">
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={item.item_type} onChange={(event) => updateItem(index, { item_type: event.target.value as RepairEstimateItemType })}>
{Object.entries(itemTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<Input placeholder="Titel" value={item.title} onChange={(event) => updateItem(index, { title: event.target.value })} />
<Input placeholder="Menge" value={item.quantity} onChange={(event) => updateItem(index, { quantity: event.target.value })} />
<Input placeholder="Einheit" value={item.unit} onChange={(event) => updateItem(index, { unit: event.target.value })} />
<Input type="number" min={0} step={1} placeholder="Cent" value={item.unit_price_cents} onChange={(event) => updateItem(index, { unit_price_cents: Number(event.target.value) || 0 })} />
<Button type="button" variant="ghost" size="icon" disabled={payload.items.length === 1} onClick={() => setPayload((current) => ({ ...current, items: current.items.filter((_, itemIndex) => itemIndex !== index) }))} aria-label="Position entfernen"><Trash2 /></Button>
</div>
<Input className="mt-3" placeholder="Beschreibung optional" value={item.description ?? ""} onChange={(event) => updateItem(index, { description: event.target.value })} />
</div>
))}
</div>
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 text-sm sm:grid-cols-3">
<Meta label="Netto Vorschau" value={money(clientSubtotal, payload.currency || "EUR")} />
<Meta label="MwSt. Vorschau" value={money(clientTax, payload.currency || "EUR")} />
<Meta label="Gesamt Vorschau" value={money(clientSubtotal + clientTax, payload.currency || "EUR")} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>Abbrechen</Button>
<Button type="button" onClick={() => void saveEstimate()} disabled={saving}>{saving ? "Speichert..." : "Speichern"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(deleteTarget)}
title="Kostenvoranschlag löschen?"
description="Der Entwurf wird dauerhaft entfernt."
pending={pendingId === deleteTarget?.id}
onOpenChange={(open) => !open && setDeleteTarget(null)}
onConfirm={() => void deleteEstimate()}
>
{deleteTarget && <p className="text-sm text-slate-600">{deleteTarget.estimate_number} · {deleteTarget.title}</p>}
</ConfirmDialog>
</div>
);
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</dt>
<dd className="mt-1 break-words font-medium text-slate-950">{value}</dd>
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
{children}
</label>
);
}