"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { AlertTriangle, 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 = { labor: "Arbeitszeit", part: "Ersatzteil", flat_rate: "Pauschale", shipping: "Versand", other: "Sonstiges", }; const statusLabels: Record = { draft: "Entwurf", sent: "Gesendet", approved: "Freigegeben", declined: "Abgelehnt", expired: "Abgelaufen", cancelled: "Storniert", revoked: "Zurückgenommen", }; 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 & { 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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); const [editing, setEditing] = useState(null); const [payload, setPayload] = useState(emptyPayload()); const [saving, setSaving] = useState(false); const [pendingId, setPendingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [cancelTarget, setCancelTarget] = useState(null); const [revokeTarget, setRevokeTarget] = useState(null); const [lexwareResult, setLexwareResult] = useState(null); const [lexwareDialogOpen, setLexwareDialogOpen] = useState(false); const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false); const [inventoryItems, setInventoryItems] = useState([]); const [inventoryCategories, setInventoryCategories] = useState([]); 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(`/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(`/inventory/items/search?${params.toString()}`), api.get("/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) { 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(`/repairs/${repairId}/estimates/${editing.id}`, normalizedPayload); } else { await api.post(`/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(`/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(`/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(`/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(`/repairs/${repairId}/estimates/${estimate.id}/lexware/prepare-invoice`); setLexwareResult(response.data); setLexwareDialogOpen(true); showToast({ type: response.data.ready_for_export ? "success" : "error", title: response.data.ready_for_export ? "Lexware-Rechnung vorbereitet" : "Lexware-Vorbereitung mit Hinweisen", description: response.data.ready_for_export ? "Die Daten wurden geprüft und für den späteren Export vorgemerkt." : "Bitte prüfe die Hinweise vor dem Export.", }); } catch (err) { showToast({ type: "error", title: "Lexware-Rechnung konnte nicht vorbereitet werden", 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

Keine Berechtigung für Kostenvoranschläge.

; } if (loading) { return
Kostenvoranschläge werden geladen...
; } if (error) { return
{error}
; } return (

{estimates.length} Kostenvoranschlag{estimates.length === 1 ? "" : "e"}

{canCreate && }
{estimates.length === 0 ? (

Noch kein Kostenvoranschlag vorhanden.

Erstelle Positionen, lasse Olympus serverseitig summieren und sende den KV per Statuslink an den Kunden.

{canCreate &&
}
) : (
{estimates.map((estimate) => (

{estimate.estimate_number}

{estimate.title}

{statusLabels[estimate.status]}
{estimate.items.map((item) => (

{item.position}. {item.title}

{item.inventory_item_id ? `Lagerartikel · ${item.inventory_snapshot_sku} · ` : ""} {itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}

{item.inventory_item_id && (

{item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"} · {item.inventory_snapshot_name}

)}

{money(item.total_cents, estimate.currency)}

))}
{canUpdate && ["draft", "sent"].includes(estimate.status) && } {canSend && ["draft", "sent"].includes(estimate.status) && } {(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && } {canRevoke && estimate.status === "approved" && } {canLexwareExport && estimate.status === "approved" && } {canDelete && ["draft", "cancelled"].includes(estimate.status) && }
))}
)} {editing ? "Kostenvoranschlag bearbeiten" : "Kostenvoranschlag anlegen"} Summen werden nach dem Speichern serverseitig berechnet.
setPayload((current) => ({ ...current, title: event.target.value }))} /> setPayload((current) => ({ ...current, valid_until: event.target.value || null }))} /> setPayload((current) => ({ ...current, tax_rate_percent: event.target.value }))} /> setPayload((current) => ({ ...current, currency: event.target.value.toUpperCase() }))} />