feat(repairs): add repair estimates

This commit is contained in:
Schubert Ferenc 2026-07-05 02:00:44 +02:00
parent 4436fe5f73
commit 111d8d12df
2 changed files with 103 additions and 15 deletions

View file

@ -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<string, string> = {
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<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",
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<RepairEstimate | null>(null);
const [payload, setPayload] = useState<RepairEstimatePayload>(emptyPayload());
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);
@ -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<RepairEstimateItemPayload>) {
function updateItem(index: number, update: Partial<EstimateFormItem>) {
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({
<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 })} />
<Input inputMode="decimal" placeholder="Einzelpreis €" value={item.unit_price_euros} 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 })} />