"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 = { 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", }; 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([]); 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 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 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) { 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(`/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(estimate: RepairEstimate) { setPendingId(estimate.id); try { await api.post(`/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

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}

{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}

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

))}
{canUpdate && ["draft", "sent"].includes(estimate.status) && } {canSend && ["draft", "sent"].includes(estimate.status) && } {canUpdate && ["draft", "sent"].includes(estimate.status) && } {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() }))} />