feat(repairs): add repair estimates
This commit is contained in:
parent
4436fe5f73
commit
111d8d12df
2 changed files with 103 additions and 15 deletions
|
|
@ -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 })} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue