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

905 lines
43 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 { AlertTriangle, CheckCircle2, Copy, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, 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 {
InventoryCategory,
InventoryItem,
} from "@/types/inventory";
import type { LexwareInvoicePreparation } from "@/types/lexware";
import type {
RepairEstimate,
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",
revoked: "Zurückgenommen",
};
const accountingStatusLabels: Record<string, string> = {
prepared: "Vorbereitet",
transferred: "Übertragen",
booked: "Gebucht",
cancelled: "Storniert",
};
const accountingStatusClasses: Record<string, string> = {
prepared: "bg-blue-50 text-blue-700 ring-blue-600/20",
transferred: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
booked: "bg-slate-900 text-white ring-slate-900/20",
cancelled: "bg-red-50 text-red-700 ring-red-600/20",
};
function humanizeValidationDetail(detail: unknown): string | null {
if (!Array.isArray(detail)) {
return null;
}
const messages = detail
.map((item) => {
if (typeof item !== "object" || item === null) {
return null;
}
const record = item as { loc?: unknown[]; msg?: unknown };
const field = Array.isArray(record.loc) ? record.loc.filter((part) => part !== "body").join(".") : "";
const message = typeof record.msg === "string" ? record.msg : "Ungültiger Wert";
return field ? `${field}: ${message}` : message;
})
.filter((message): message is string => Boolean(message));
return messages.length ? messages.join(" · ") : null;
}
function getErrorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = (error as { response?: { data?: { detail?: unknown; message?: unknown } } }).response;
const detailMessage = humanizeValidationDetail(response?.data?.detail);
if (detailMessage) {
return detailMessage;
}
if (typeof response?.data?.detail === "string") {
return response.data.detail;
}
if (typeof response?.data?.message === "string") {
return response.data.message;
}
return "Bitte prüfe die Eingaben. Mindestens eine gültige Position mit Titel, Menge und Preis ist erforderlich.";
}
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));
}
type EstimateFormItem = {
item_type: RepairEstimateItemType;
inventory_item_id: number | null;
inventory_snapshot_name: string;
inventory_snapshot_sku: string;
inventory_snapshot_manufacturer: string | null;
inventory_snapshot_part_number: string | null;
inventory_price_overridden: boolean;
title: string;
description: string | null;
quantity: string;
unit: string;
unit_price_euros: string;
};
type EstimateFormPayload = Omit<RepairEstimatePayload, "items"> & {
items: EstimateFormItem[];
};
function parseEuroToCents(value: string): number | null {
const normalized = value.trim().replace(/\s/g, "").replace(",", ".");
if (!normalized) {
return null;
}
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) {
return null;
}
return Math.round(Number(normalized) * 100);
}
function centsToEuroInput(cents: number): string {
return (cents / 100).toFixed(2).replace(".", ",");
}
function emptyItem(): EstimateFormItem {
return {
item_type: "labor",
inventory_item_id: null,
inventory_snapshot_name: "",
inventory_snapshot_sku: "",
inventory_snapshot_manufacturer: null,
inventory_snapshot_part_number: null,
inventory_price_overridden: false,
title: "",
description: "",
quantity: "1.00",
unit: "Std.",
unit_price_euros: "",
};
}
function emptyPayload(): EstimateFormPayload {
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;
canRevoke: boolean;
canLexwareExport: boolean;
};
export default function RepairEstimatesSection({
repairId,
customerEmail,
canRead,
canCreate,
canUpdate,
canDelete,
canSend,
canRevoke,
canLexwareExport,
}: 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<EstimateFormPayload>(emptyPayload());
const [saving, setSaving] = useState(false);
const [pendingId, setPendingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
const [cancelTarget, setCancelTarget] = useState<RepairEstimate | null>(null);
const [revokeTarget, setRevokeTarget] = useState<RepairEstimate | null>(null);
const [lexwareResult, setLexwareResult] = useState<LexwareInvoicePreparation | null>(null);
const [lexwareDialogOpen, setLexwareDialogOpen] = useState(false);
const [accountingTarget, setAccountingTarget] = useState<RepairEstimate | null>(null);
const [accountingNote, setAccountingNote] = useState("");
const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false);
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
const [inventoryCategories, setInventoryCategories] = useState<InventoryCategory[]>([]);
const [inventorySearch, setInventorySearch] = useState("");
const [inventoryCategory, setInventoryCategory] = useState("all");
const [inventoryManufacturer, setInventoryManufacturer] = useState("");
const [inventoryLoading, setInventoryLoading] = useState(false);
const [inventoryError, setInventoryError] = useState("");
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 searchInventoryItems = useCallback(async () => {
setInventoryLoading(true);
setInventoryError("");
try {
const params = new URLSearchParams();
params.set("limit", "20");
if (inventorySearch.trim()) params.set("q", inventorySearch.trim());
if (inventoryCategory !== "all") params.set("category", inventoryCategory);
if (inventoryManufacturer.trim()) params.set("manufacturer", inventoryManufacturer.trim());
const [itemsResponse, categoriesResponse] = await Promise.all([
api.get<InventoryItem[]>(`/inventory/items/search?${params.toString()}`),
api.get<InventoryCategory[]>("/inventory/categories"),
]);
setInventoryItems(itemsResponse.data);
setInventoryCategories(categoriesResponse.data);
} catch (err) {
setInventoryError(getErrorMessage(err));
} finally {
setInventoryLoading(false);
}
}, [inventoryCategory, inventoryManufacturer, inventorySearch]);
useEffect(() => {
if (!inventoryDialogOpen) {
return;
}
const id = window.setTimeout(() => {
void searchInventoryItems();
}, 200);
return () => window.clearTimeout(id);
}, [inventoryDialogOpen, searchInventoryItems]);
const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => {
const quantity = Number(item.quantity.replace(",", ".")) || 0;
const unitPriceCents = parseEuroToCents(item.unit_price_euros) ?? 0;
return sum + Math.round(quantity * unitPriceCents);
}, 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,
inventory_item_id: item.inventory_item_id ?? null,
inventory_snapshot_name: item.inventory_snapshot_name,
inventory_snapshot_sku: item.inventory_snapshot_sku,
inventory_snapshot_manufacturer: item.inventory_snapshot_manufacturer,
inventory_snapshot_part_number: item.inventory_snapshot_part_number,
inventory_price_overridden: false,
title: item.title,
description: item.description ?? "",
quantity: String(item.quantity),
unit: item.unit,
unit_price_euros: centsToEuroInput(item.unit_price_cents),
})),
});
setDialogOpen(true);
}
function updateItem(index: number, update: Partial<EstimateFormItem>) {
setPayload((current) => ({
...current,
items: current.items.map((item, itemIndex) => itemIndex === index ? { ...item, ...update } : item),
}));
}
function addInventoryItem(item: InventoryItem) {
const formItem: EstimateFormItem = {
item_type: "part",
inventory_item_id: item.id,
inventory_snapshot_name: item.name,
inventory_snapshot_sku: item.sku,
inventory_snapshot_manufacturer: item.manufacturer,
inventory_snapshot_part_number: item.manufacturer_part_number,
inventory_price_overridden: false,
title: item.name,
description: item.description ?? "",
quantity: "1",
unit: item.unit,
unit_price_euros: centsToEuroInput(item.selling_price_cents ?? 0),
};
setPayload((current) => ({ ...current, items: [...current.items, formItem] }));
setInventoryDialogOpen(false);
}
async function saveEstimate() {
setSaving(true);
try {
const invalidPriceIndex = payload.items.findIndex((item) => parseEuroToCents(item.unit_price_euros) === null);
if (invalidPriceIndex >= 0) {
showToast({
type: "error",
title: "Preisangabe ungültig",
description: `Bitte gib den Einzelpreis in Position ${invalidPriceIndex + 1} als Eurobetrag ein, z. B. 100,00.`,
});
return;
}
const normalizedPayload = {
title: payload.title.trim(),
customer_message: payload.customer_message.trim(),
internal_note: payload.internal_note?.trim() || null,
tax_rate_percent: payload.tax_rate_percent.replace(",", "."),
currency: payload.currency.trim().toUpperCase() || "EUR",
valid_until: payload.valid_until || null,
items: payload.items.map((item) => ({
item_type: item.item_type,
inventory_item_id: item.inventory_item_id,
inventory_price_overridden: item.inventory_price_overridden,
title: item.title.trim(),
description: item.description?.trim() || null,
quantity: item.quantity.replace(",", "."),
unit: item.unit.trim() || "Stk.",
unit_price_cents: parseEuroToCents(item.unit_price_euros) ?? 0,
})),
};
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() {
if (!cancelTarget) return;
setPendingId(cancelTarget.id);
try {
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${cancelTarget.id}/cancel`);
await loadEstimates();
setCancelTarget(null);
showToast({ type: "success", title: "Kostenvoranschlag storniert" });
} catch (err) {
showToast({ type: "error", title: "Stornierung fehlgeschlagen", description: getErrorMessage(err) });
} finally {
setPendingId(null);
}
}
async function revokeEstimate() {
if (!revokeTarget) return;
setPendingId(revokeTarget.id);
try {
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${revokeTarget.id}/revoke`);
await loadEstimates();
setRevokeTarget(null);
showToast({ type: "success", title: "Freigabe zurückgenommen", description: "Die Reservierungen wurden freigegeben." });
} catch (err) {
showToast({ type: "error", title: "Freigabe konnte nicht zurückgenommen werden", description: getErrorMessage(err) });
} finally {
setPendingId(null);
}
}
async function prepareLexwareInvoice(estimate: RepairEstimate) {
setPendingId(estimate.id);
try {
const response = await api.post<LexwareInvoicePreparation>(`/repairs/${repairId}/estimates/${estimate.id}/lexware/prepare-invoice`);
setLexwareResult(response.data);
setAccountingTarget(estimate);
setAccountingNote(response.data.accounting_note || estimate.accounting_note || "");
setLexwareDialogOpen(true);
showToast({
type: response.data.ready_for_export ? "success" : "error",
title: response.data.ready_for_export ? "Rechnungsvorbereitung erstellt" : "Rechnungsvorbereitung mit Hinweisen",
description: response.data.ready_for_export ? "Die Daten stehen für die Übernahme in die Buchhaltung bereit." : "Bitte prüfe die Hinweise vor der Übernahme.",
});
} catch (err) {
showToast({ type: "error", title: "Rechnungsvorbereitung fehlgeschlagen", description: getErrorMessage(err) });
} finally {
setPendingId(null);
}
}
async function markAccountingTransferred() {
if (!accountingTarget || !lexwareResult) return;
setPendingId(accountingTarget.id);
try {
const response = await api.post<LexwareInvoicePreparation>(
`/repairs/${repairId}/estimates/${accountingTarget.id}/accounting/mark-transferred`,
{ accounting_note: accountingNote },
);
setLexwareResult(response.data);
await loadEstimates();
showToast({ type: "success", title: "Als übertragen markiert", description: "Die Übergabe an die Buchhaltung wurde dokumentiert." });
} catch (err) {
showToast({ type: "error", title: "Status konnte nicht aktualisiert werden", description: getErrorMessage(err) });
} finally {
setPendingId(null);
}
}
async function copyText(label: string, text: string) {
try {
await navigator.clipboard.writeText(text);
showToast({ type: "success", title: `${label} kopiert` });
} catch {
showToast({ type: "error", title: "Kopieren fehlgeschlagen", description: "Bitte markiere den Text manuell." });
}
}
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>
{estimate.accounting_export_status && (
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg border bg-slate-50 p-3 text-sm">
<ReceiptText className="h-4 w-4 text-slate-500" />
<span className="font-medium text-slate-700">Buchhaltung</span>
<AccountingBadge status={estimate.accounting_export_status} />
{estimate.accounting_transferred_at && <span className="text-xs text-slate-500">{dateTime(estimate.accounting_transferred_at)}</span>}
{estimate.accounting_note && <span className="break-words text-xs text-slate-500">{estimate.accounting_note}</span>}
</div>
)}
<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">
{item.inventory_item_id ? `Lagerartikel · ${item.inventory_snapshot_sku} · ` : ""}
{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}
</p>
{item.inventory_item_id && (
<p className="mt-1 text-xs text-slate-500">
{item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"} · {item.inventory_snapshot_name}
</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 || canSend) && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => setCancelTarget(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</Button>}
{canRevoke && estimate.status === "approved" && <Button type="button" variant="destructive" size="sm" onClick={() => setRevokeTarget(estimate)} disabled={pendingId === estimate.id}><Undo2 />Freigabe zurücknehmen</Button>}
{canLexwareExport && estimate.status === "approved" && <Button type="button" variant="outline" size="sm" onClick={() => void prepareLexwareInvoice(estimate)} disabled={pendingId === estimate.id}><ReceiptText />In Buchhaltung übernehmen</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>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setInventoryDialogOpen(true)}><PackageSearch />Lagerartikel auswählen</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setPayload((current) => ({ ...current, items: [...current.items, emptyItem()] }))}><Plus />Position</Button>
</div>
</div>
{payload.items.map((item, index) => (
<div key={index} className="rounded-lg border bg-slate-50 p-3">
{item.inventory_item_id && (
<div className="mb-3 flex flex-col gap-2 rounded-lg border bg-white p-3 text-sm text-slate-600 sm:flex-row sm:items-center sm:justify-between">
<div>
<span className="font-semibold text-slate-950">Lagerartikel</span> · {item.inventory_snapshot_sku} · {item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"}
</div>
<label className="flex items-center gap-2 text-xs font-medium text-slate-600">
<input type="checkbox" checked={item.inventory_price_overridden} onChange={(event) => updateItem(index, { inventory_price_overridden: event.target.checked })} />
Preis manuell überschreiben
</label>
</div>
)}
<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} disabled={Boolean(item.inventory_item_id)} 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} readOnly={Boolean(item.inventory_item_id)} 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} readOnly={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { unit: event.target.value })} />
<Input inputMode="decimal" placeholder="Einzelpreis €" value={item.unit_price_euros} readOnly={Boolean(item.inventory_item_id && !item.inventory_price_overridden)} onChange={(event) => updateItem(index, { unit_price_euros: event.target.value })} />
<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>
<Dialog open={inventoryDialogOpen} onOpenChange={setInventoryDialogOpen}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Lagerartikel auswählen</DialogTitle>
<DialogDescription>Aktive Artikel werden als KV-Position übernommen. Preis und Stammdaten werden beim Speichern serverseitig geprüft.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-3 md:grid-cols-[1fr_220px_180px]">
<Input placeholder="SKU, Name, Hersteller oder Gerät suchen" value={inventorySearch} onChange={(event) => setInventorySearch(event.target.value)} />
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={inventoryCategory} onChange={(event) => setInventoryCategory(event.target.value)}>
<option value="all">Alle Kategorien</option>
{inventoryCategories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
</select>
<Input placeholder="Hersteller" value={inventoryManufacturer} onChange={(event) => setInventoryManufacturer(event.target.value)} />
</div>
{inventoryError ? (
<p className="rounded-lg border bg-red-50 p-3 text-sm text-red-700">{inventoryError}</p>
) : inventoryLoading ? (
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">Lagerartikel werden geladen...</p>
) : inventoryItems.length === 0 ? (
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">Keine aktiven Lagerartikel gefunden.</p>
) : (
<div className="max-h-[48vh] divide-y overflow-y-auto rounded-lg border">
{inventoryItems.map((item) => {
const lowAfterSelection = item.quantity_available - 1 <= item.reorder_level;
return (
<button
key={item.id}
type="button"
className="grid w-full gap-2 p-3 text-left hover:bg-slate-50 md:grid-cols-[1fr_auto]"
onClick={() => addInventoryItem(item)}
>
<div>
<p className="font-medium text-slate-950">{item.sku} · {item.name}</p>
<p className="text-sm text-slate-500">
{item.manufacturer || "Hersteller nicht angegeben"} · Verfügbar {item.quantity_available} {item.unit} · {item.location?.name ?? "Kein Lagerort"}
</p>
{lowAfterSelection && (
<p className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-amber-700">
<AlertTriangle size={14} /> Auswahl erreicht oder unterschreitet den Mindestbestand.
</p>
)}
</div>
<span className="font-semibold text-slate-950">{money(item.selling_price_cents ?? 0, item.currency)}</span>
</button>
);
})}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setInventoryDialogOpen(false)}>Schließen</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={lexwareDialogOpen} onOpenChange={setLexwareDialogOpen}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>In Buchhaltung übernehmen</DialogTitle>
<DialogDescription>Die Rechnung wird in der externen Buchhaltungssoftware erstellt. Olympus dokumentiert die Übergabe.</DialogDescription>
</DialogHeader>
{lexwareResult && (
<div className="grid max-h-[70vh] gap-4 overflow-y-auto pr-1 text-sm">
<div className="rounded-lg border bg-slate-50 p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-slate-950">Exportstatus</span>
<AccountingBadge status={lexwareResult.export_status} />
<span className="text-slate-400"></span>
<AccountingBadge status="transferred" muted={lexwareResult.export_status !== "transferred" && lexwareResult.export_status !== "booked"} />
<span className="text-slate-400"></span>
<AccountingBadge status="booked" muted={lexwareResult.export_status !== "booked"} />
</div>
<p className="mt-2 text-xs text-slate-500">Rechnungsvorbereitung #{lexwareResult.sync_record_id}</p>
{lexwareResult.transferred_at && <p className="mt-1 text-xs text-slate-500">Übertragen am {dateTime(lexwareResult.transferred_at)}</p>}
</div>
{lexwareResult.warnings.length > 0 && (
<div className="rounded-lg border bg-amber-50 p-4 text-amber-950">
<p className="font-semibold">Warnungen</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
{lexwareResult.warnings.map((warning) => <li key={warning}>{warning}</li>)}
</ul>
</div>
)}
<div className="rounded-lg border">
<StepHeader step="1" title="Kundendaten kopieren" />
<CopyBlock
text={[
`Kunde: ${lexwareResult.customer_mapping.name}`,
`E-Mail: ${lexwareResult.customer_mapping.email || "-"}`,
`Telefon: ${lexwareResult.customer_mapping.phone || "-"}`,
].join("\n")}
onCopy={(text) => void copyText("Kundendaten", text)}
/>
<StepHeader step="2" title="In Buchhaltungssoftware einfügen" muted />
</div>
<div className="rounded-lg border">
<StepHeader step="3" title="Positionen kopieren" />
<div className="grid grid-cols-[1fr_auto_auto] gap-3 border-b bg-slate-50 p-3 text-xs font-semibold uppercase text-slate-500">
<span>Position</span>
<span>Steuer</span>
<span>Summe</span>
</div>
{lexwareResult.line_item_mapping.map((item, index) => (
<div key={`${item.title}-${index}`} className="grid grid-cols-[1fr_auto_auto] gap-3 border-b p-3 last:border-b-0">
<div>
<p className="font-medium text-slate-950">{item.title}</p>
<p className="text-xs text-slate-500">{item.quantity} {item.unit} × {item.unit_price} </p>
</div>
<span>{item.tax_rate} %</span>
<span className="font-medium text-slate-950">{item.total} </span>
</div>
))}
<CopyBlock
text={lexwareResult.line_item_mapping.map((item) => (
`${item.title}; ${item.description || "-"}; ${item.quantity} ${item.unit}; ${item.unit_price} EUR; ${item.tax_rate}%; ${item.total} EUR`
)).join("\n")}
onCopy={(text) => void copyText("Positionen", text)}
/>
<StepHeader step="4" title="In Buchhaltungssoftware einfügen" muted />
</div>
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 md:grid-cols-3">
<Meta label="Netto" value={`${String(lexwareResult.payload_summary.subtotal ?? "-")}`} />
<Meta label="MwSt." value={`${String(lexwareResult.payload_summary.tax ?? "-")}`} />
<Meta label="Gesamt" value={`${String(lexwareResult.payload_summary.total ?? "-")}`} />
</div>
<div className="rounded-lg border bg-white">
<StepHeader step="5" title="Rechnung speichern" muted />
<StepHeader step="6" title="Zurück zu Olympus" muted />
<div className="border-t p-4">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">Buchhaltungsnotiz</span>
<textarea
className="min-h-20 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none"
placeholder="z. B. Lexware RG-2026-154 oder Rechnung in sevdesk erstellt"
value={accountingNote}
onChange={(event) => setAccountingNote(event.target.value)}
/>
</label>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
type="button"
onClick={() => void markAccountingTransferred()}
disabled={!accountingTarget || pendingId === accountingTarget.id || lexwareResult.export_status === "transferred"}
>
<CheckCircle2 />
Als übertragen markieren
</Button>
{lexwareResult.export_status === "transferred" && <span className="text-sm font-medium text-emerald-700">Bereits übertragen</span>}
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setLexwareDialogOpen(false)}>Schließen</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(cancelTarget)}
title="Kostenvoranschlag stornieren?"
description="Der Kostenvoranschlag wird storniert. Reservierte Lagerartikel werden freigegeben."
confirmLabel="Stornieren"
pending={pendingId === cancelTarget?.id}
onOpenChange={(open) => !open && setCancelTarget(null)}
onConfirm={() => void cancelEstimate()}
>
{cancelTarget && <p className="text-sm text-slate-600">{cancelTarget.estimate_number} · {cancelTarget.title}</p>}
</ConfirmDialog>
<ConfirmDialog
open={Boolean(revokeTarget)}
title="Freigabe zurücknehmen?"
description="Möchten Sie den bereits freigegebenen Kostenvoranschlag wirklich zurücknehmen? Die Lagerreservierungen werden freigegeben und der Kunde erhält eine Benachrichtigung."
confirmLabel="Freigabe zurücknehmen"
pending={pendingId === revokeTarget?.id}
onOpenChange={(open) => !open && setRevokeTarget(null)}
onConfirm={() => void revokeEstimate()}
>
{revokeTarget && <p className="text-sm text-slate-600">{revokeTarget.estimate_number} · {revokeTarget.title}</p>}
</ConfirmDialog>
<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 AccountingBadge({ status, muted = false }: { status: string; muted?: boolean }) {
const label = accountingStatusLabels[status] ?? status;
const activeClass = accountingStatusClasses[status] ?? "bg-slate-100 text-slate-700 ring-slate-600/20";
return (
<span className={`w-fit rounded-full px-2.5 py-1 text-xs font-medium ring-1 ${muted ? "bg-slate-100 text-slate-400 ring-slate-200" : activeClass}`}>
{label}
</span>
);
}
function StepHeader({ step, title, muted = false }: { step: string; title: string; muted?: boolean }) {
return (
<div className={`flex items-center gap-3 border-b p-3 ${muted ? "bg-slate-50 text-slate-500" : "bg-white text-slate-950"}`}>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{step}</span>
<p className="font-medium">{title}</p>
</div>
);
}
function CopyBlock({ text, onCopy }: { text: string; onCopy: (text: string) => void }) {
return (
<div className="grid gap-3 p-3">
<pre className="max-h-40 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-white whitespace-pre-wrap">{text}</pre>
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={() => onCopy(text)}>
<Copy />
Kopieren
</Button>
</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>
);
}