feat(repairs): add repair estimates
This commit is contained in:
parent
4436fe5f73
commit
111d8d12df
2 changed files with 103 additions and 15 deletions
|
|
@ -31,6 +31,13 @@ class RepairEstimateItemPayload(BaseModel):
|
||||||
return None
|
return None
|
||||||
return normalize_text(value)
|
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):
|
class RepairEstimatePayload(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=255)
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
|
@ -53,6 +60,13 @@ class RepairEstimatePayload(BaseModel):
|
||||||
def normalize_currency(cls, value: str) -> str:
|
def normalize_currency(cls, value: str) -> str:
|
||||||
return value.upper() or "EUR"
|
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")
|
@model_validator(mode="after")
|
||||||
def validate_items(self):
|
def validate_items(self):
|
||||||
if not self.items:
|
if not self.items:
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
RepairEstimate,
|
RepairEstimate,
|
||||||
RepairEstimateItemPayload,
|
|
||||||
RepairEstimateItemType,
|
RepairEstimateItemType,
|
||||||
RepairEstimatePayload,
|
RepairEstimatePayload,
|
||||||
} from "@/types/repair";
|
} from "@/types/repair";
|
||||||
|
|
@ -41,10 +40,40 @@ const statusLabels: Record<string, string> = {
|
||||||
cancelled: "Storniert",
|
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) {
|
function getErrorMessage(error: unknown) {
|
||||||
if (typeof error === "object" && error !== null && "response" in error) {
|
if (typeof error === "object" && error !== null && "response" in error) {
|
||||||
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
const response = (error as { response?: { data?: { detail?: unknown; message?: unknown } } }).response;
|
||||||
return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden";
|
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";
|
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));
|
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 {
|
return {
|
||||||
item_type: "labor",
|
item_type: "labor",
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
quantity: "1.00",
|
quantity: "1.00",
|
||||||
unit: "Std.",
|
unit: "Std.",
|
||||||
unit_price_cents: 0,
|
unit_price_euros: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyPayload(): RepairEstimatePayload {
|
function emptyPayload(): EstimateFormPayload {
|
||||||
return {
|
return {
|
||||||
title: "Kostenvoranschlag",
|
title: "Kostenvoranschlag",
|
||||||
customer_message: "Bitte prüfen Sie den Kostenvoranschlag und geben Sie uns über den Statuslink Rückmeldung.",
|
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 [error, setError] = useState("");
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<RepairEstimate | null>(null);
|
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 [saving, setSaving] = useState(false);
|
||||||
const [pendingId, setPendingId] = useState<number | null>(null);
|
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | 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 clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => {
|
||||||
const quantity = Number(item.quantity.replace(",", ".")) || 0;
|
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]);
|
}, 0), [payload.items]);
|
||||||
const clientTax = Math.round(clientSubtotal * (Number(payload.tax_rate_percent.replace(",", ".")) || 0) / 100);
|
const clientTax = Math.round(clientSubtotal * (Number(payload.tax_rate_percent.replace(",", ".")) || 0) / 100);
|
||||||
|
|
||||||
|
|
@ -161,13 +219,13 @@ export default function RepairEstimatesSection({
|
||||||
description: item.description ?? "",
|
description: item.description ?? "",
|
||||||
quantity: String(item.quantity),
|
quantity: String(item.quantity),
|
||||||
unit: item.unit,
|
unit: item.unit,
|
||||||
unit_price_cents: item.unit_price_cents,
|
unit_price_euros: centsToEuroInput(item.unit_price_cents),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateItem(index: number, update: Partial<RepairEstimateItemPayload>) {
|
function updateItem(index: number, update: Partial<EstimateFormItem>) {
|
||||||
setPayload((current) => ({
|
setPayload((current) => ({
|
||||||
...current,
|
...current,
|
||||||
items: current.items.map((item, itemIndex) => itemIndex === index ? { ...item, ...update } : item),
|
items: current.items.map((item, itemIndex) => itemIndex === index ? { ...item, ...update } : item),
|
||||||
|
|
@ -177,14 +235,30 @@ export default function RepairEstimatesSection({
|
||||||
async function saveEstimate() {
|
async function saveEstimate() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
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 = {
|
const normalizedPayload = {
|
||||||
...payload,
|
title: payload.title.trim(),
|
||||||
internal_note: payload.internal_note || null,
|
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,
|
valid_until: payload.valid_until || null,
|
||||||
items: payload.items.map((item) => ({
|
items: payload.items.map((item) => ({
|
||||||
...item,
|
item_type: item.item_type,
|
||||||
description: item.description || null,
|
title: item.title.trim(),
|
||||||
|
description: item.description?.trim() || null,
|
||||||
quantity: item.quantity.replace(",", "."),
|
quantity: item.quantity.replace(",", "."),
|
||||||
|
unit: item.unit.trim() || "Stk.",
|
||||||
|
unit_price_cents: parseEuroToCents(item.unit_price_euros) ?? 0,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
if (editing) {
|
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="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="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 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>
|
<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>
|
</div>
|
||||||
<Input className="mt-3" placeholder="Beschreibung optional" value={item.description ?? ""} onChange={(event) => updateItem(index, { description: event.target.value })} />
|
<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