diff --git a/backend/hermes/app/schemas/repair_estimate.py b/backend/hermes/app/schemas/repair_estimate.py index f2c6f44..3410a04 100644 --- a/backend/hermes/app/schemas/repair_estimate.py +++ b/backend/hermes/app/schemas/repair_estimate.py @@ -31,6 +31,13 @@ class RepairEstimateItemPayload(BaseModel): return None return normalize_text(value) + @field_validator("quantity", mode="before") + @classmethod + def normalize_quantity(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().replace(",", ".") + return value + class RepairEstimatePayload(BaseModel): title: str = Field(min_length=1, max_length=255) @@ -53,6 +60,13 @@ class RepairEstimatePayload(BaseModel): def normalize_currency(cls, value: str) -> str: return value.upper() or "EUR" + @field_validator("tax_rate_percent", mode="before") + @classmethod + def normalize_tax_rate(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().replace(",", ".") + return value + @model_validator(mode="after") def validate_items(self): if not self.items: diff --git a/frontend/athena/components/repairs/RepairEstimatesSection.tsx b/frontend/athena/components/repairs/RepairEstimatesSection.tsx index 6e00342..b924bfa 100644 --- a/frontend/athena/components/repairs/RepairEstimatesSection.tsx +++ b/frontend/athena/components/repairs/RepairEstimatesSection.tsx @@ -19,7 +19,6 @@ import { Input } from "@/components/ui/input"; import { api } from "@/lib/api"; import type { RepairEstimate, - RepairEstimateItemPayload, RepairEstimateItemType, RepairEstimatePayload, } from "@/types/repair"; @@ -41,10 +40,40 @@ const statusLabels: Record = { cancelled: "Storniert", }; +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?: string; message?: string } } }).response; - return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden"; + 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"; } @@ -58,18 +87,46 @@ function dateTime(value: string | null) { return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); } -function emptyItem(): RepairEstimateItemPayload { +type EstimateFormItem = { + item_type: RepairEstimateItemType; + 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", title: "", description: "", quantity: "1.00", unit: "Std.", - unit_price_cents: 0, + unit_price_euros: "", }; } -function emptyPayload(): RepairEstimatePayload { +function emptyPayload(): EstimateFormPayload { return { title: "Kostenvoranschlag", customer_message: "Bitte prüfen Sie den Kostenvoranschlag und geben Sie uns über den Statuslink Rückmeldung.", @@ -106,7 +163,7 @@ export default function RepairEstimatesSection({ const [error, setError] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); const [editing, setEditing] = useState(null); - const [payload, setPayload] = useState(emptyPayload()); + const [payload, setPayload] = useState(emptyPayload()); const [saving, setSaving] = useState(false); const [pendingId, setPendingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -136,7 +193,8 @@ export default function RepairEstimatesSection({ const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => { const quantity = Number(item.quantity.replace(",", ".")) || 0; - return sum + Math.round(quantity * item.unit_price_cents); + 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); @@ -161,13 +219,13 @@ export default function RepairEstimatesSection({ description: item.description ?? "", quantity: String(item.quantity), unit: item.unit, - unit_price_cents: item.unit_price_cents, + unit_price_euros: centsToEuroInput(item.unit_price_cents), })), }); setDialogOpen(true); } - function updateItem(index: number, update: Partial) { + function updateItem(index: number, update: Partial) { setPayload((current) => ({ ...current, items: current.items.map((item, itemIndex) => itemIndex === index ? { ...item, ...update } : item), @@ -177,14 +235,30 @@ export default function RepairEstimatesSection({ 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 = { - ...payload, - internal_note: payload.internal_note || null, + 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, - description: item.description || null, + item_type: item.item_type, + 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) { @@ -346,7 +420,7 @@ export default function RepairEstimatesSection({ updateItem(index, { title: event.target.value })} /> updateItem(index, { quantity: event.target.value })} /> updateItem(index, { unit: event.target.value })} /> - updateItem(index, { unit_price_cents: Number(event.target.value) || 0 })} /> + updateItem(index, { unit_price_euros: event.target.value })} /> updateItem(index, { description: event.target.value })} />