Olympus/frontend/athena/components/repairs/RepairEstimatesSection.tsx
2026-07-05 00:57:02 +02:00

401 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}