diff --git a/src/admin/components/OrderConfirmationModal.tsx b/src/admin/components/OrderConfirmationModal.tsx index 3cb2570..1a13eac 100644 --- a/src/admin/components/OrderConfirmationModal.tsx +++ b/src/admin/components/OrderConfirmationModal.tsx @@ -7,7 +7,22 @@ import { useTheme } from "@mui/material/styles"; import { Modal, Button, TextField, Field } from "../ui"; import { useAlert } from "../context/AlertContext"; +// Editable line-item. quantity/unit_price/vat_rate are held as the raw typed +// string while editing (so a field can be cleared — empty renders fine in a +// type="number" input) and are coerced to numbers in handleEditGenerate before +// being handed to onGenerate (the parent posts them verbatim). interface ConfirmationItem { + description: string; + quantity: string | number; + unit: string; + unit_price: string | number; + is_included_in_total: boolean; + vat_rate: string | number; +} + +// Numeric shape handed to the parent (the raw editing strings are coerced in +// handleEditGenerate). Distinct from the editable ConfirmationItem. +interface GeneratedItem { description: string; quantity: number; unit: string; @@ -22,7 +37,7 @@ interface OrderConfirmationModalProps { onGenerate: ( lang: string, applyVat: boolean, - items?: ConfirmationItem[], + items?: GeneratedItem[], ) => Promise; initialItems: ConfirmationItem[]; orderNumber: string; @@ -81,7 +96,17 @@ export default function OrderConfirmationModal({ const handleEditGenerate = async () => { setLoading(true); try { - await onGenerate(lang, applyVatState, items); + // Coerce the raw typed strings back to numbers so an empty/partial field + // never reaches the server as NaN (the parent posts these verbatim). + const coercedItems: GeneratedItem[] = items.map((it) => ({ + description: it.description, + quantity: Number(it.quantity) || 0, + unit: it.unit, + unit_price: Number(it.unit_price) || 0, + is_included_in_total: it.is_included_in_total, + vat_rate: Number(it.vat_rate) || 0, + })); + await onGenerate(lang, applyVatState, coercedItems); // Only close on success — on error keep the user's edited items intact. onClose(); } catch (err) { @@ -273,9 +298,9 @@ export default function OrderConfirmationModal({ - updateItem(i, "quantity", Number(e.target.value) || 0) + updateItem(i, "quantity", e.target.value) } slotProps={{ htmlInput: { step: "0.001" } }} /> @@ -287,18 +312,18 @@ export default function OrderConfirmationModal({ - updateItem(i, "unit_price", Number(e.target.value) || 0) + updateItem(i, "unit_price", e.target.value) } slotProps={{ htmlInput: { step: "0.01" } }} /> - updateItem(i, "vat_rate", Number(e.target.value) || 0) + updateItem(i, "vat_rate", e.target.value) } slotProps={{ htmlInput: { step: "1" } }} /> @@ -350,15 +375,11 @@ export default function OrderConfirmationModal({ - updateItem( - i, - "quantity", - Number(e.target.value) || 0, - ) + updateItem(i, "quantity", e.target.value) } - sx={{ width: 90 }} + sx={{ width: 112 }} slotProps={{ htmlInput: { step: "0.001" } }} /> @@ -374,13 +395,9 @@ export default function OrderConfirmationModal({ - updateItem( - i, - "unit_price", - Number(e.target.value) || 0, - ) + updateItem(i, "unit_price", e.target.value) } sx={{ width: 110 }} slotProps={{ htmlInput: { step: "0.01" } }} @@ -389,13 +406,9 @@ export default function OrderConfirmationModal({ - updateItem( - i, - "vat_rate", - Number(e.target.value) || 0, - ) + updateItem(i, "vat_rate", e.target.value) } sx={{ width: 80 }} slotProps={{ htmlInput: { step: "1" } }} diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index 24b2e5b..e30c5b8 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -166,9 +166,13 @@ interface InvoiceItem { _key: string; description: string; item_description: string; - quantity: number; + // Held as the raw typed string while editing so the field can be cleared + // (empty renders fine in a type="number" input). Coerced via Number(x) || 0 + // only where used (live totals + save payload). vat_rate stays numeric: it + // is edited via a Select, never a free-text number input. + quantity: string | number; unit: string; - unit_price: number; + unit_price: string | number; vat_rate: number; } @@ -332,10 +336,8 @@ function SortableInvoiceRow({ - onUpdate(index, "quantity", Number(e.target.value)) - } + value={item.quantity ?? ""} + onChange={(e) => onUpdate(index, "quantity", e.target.value)} slotProps={{ htmlInput: { min: "0", step: "any" } }} /> - onUpdate(index, "unit_price", Number(e.target.value)) - } + value={item.unit_price ?? ""} + onChange={(e) => onUpdate(index, "unit_price", e.target.value)} slotProps={{ htmlInput: { step: "any" } }} /> {apply_vat && ( @@ -438,8 +438,8 @@ function SortableInvoiceRow({ onUpdate(index, "quantity", Number(e.target.value))} + value={item.quantity ?? ""} + onChange={(e) => onUpdate(index, "quantity", e.target.value)} slotProps={{ htmlInput: { min: "0", step: "any" } }} sx={{ "& input": { textAlign: "center" } }} /> @@ -455,10 +455,8 @@ function SortableInvoiceRow({ - onUpdate(index, "unit_price", Number(e.target.value)) - } + value={item.unit_price ?? ""} + onChange={(e) => onUpdate(index, "unit_price", e.target.value)} slotProps={{ htmlInput: { step: "any" } }} sx={{ "& input": { textAlign: "right" } }} /> @@ -1036,6 +1034,10 @@ export default function InvoiceDetail() { .filter((i) => i.description.trim()) .map((item, i) => ({ ...item, + // Coerce the raw typed strings back to numbers so an empty/partial + // field never reaches the server as NaN (mirrors createTotals). + quantity: Number(item.quantity) || 0, + unit_price: Number(item.unit_price) || 0, position: i, })), }; @@ -2065,7 +2067,7 @@ export default function InvoiceDetail() { # Popis - + Množství diff --git a/src/admin/pages/IssuedOrderDetail.tsx b/src/admin/pages/IssuedOrderDetail.tsx index 03fbea7..f75e967 100644 --- a/src/admin/pages/IssuedOrderDetail.tsx +++ b/src/admin/pages/IssuedOrderDetail.tsx @@ -141,9 +141,13 @@ interface OrderItem { _key: string; description: string; item_description: string; - quantity: number; + // Held as the raw typed string while editing so the field can be cleared + // (empty renders fine in a type="number" input). Coerced via Number(x) || 0 + // only where used (live totals + save payload). vat_rate stays numeric: it + // is edited via a Select, never a free-text number input. + quantity: string | number; unit: string; - unit_price: number; + unit_price: string | number; vat_rate: number; } @@ -284,10 +288,8 @@ function SortableOrderRow({ - onUpdate(index, "quantity", Number(e.target.value)) - } + value={item.quantity ?? ""} + onChange={(e) => onUpdate(index, "quantity", e.target.value)} slotProps={{ htmlInput: { min: "0", step: "any" } }} InputProps={{ readOnly }} /> @@ -301,10 +303,8 @@ function SortableOrderRow({ - onUpdate(index, "unit_price", Number(e.target.value)) - } + value={item.unit_price ?? ""} + onChange={(e) => onUpdate(index, "unit_price", e.target.value)} slotProps={{ htmlInput: { step: "any" } }} InputProps={{ readOnly }} /> @@ -401,8 +401,8 @@ function SortableOrderRow({ onUpdate(index, "quantity", Number(e.target.value))} + value={item.quantity ?? ""} + onChange={(e) => onUpdate(index, "quantity", e.target.value)} slotProps={{ htmlInput: { min: "0", step: "any" } }} InputProps={{ readOnly }} sx={{ "& input": { textAlign: "center" } }} @@ -420,10 +420,8 @@ function SortableOrderRow({ - onUpdate(index, "unit_price", Number(e.target.value)) - } + value={item.unit_price ?? ""} + onChange={(e) => onUpdate(index, "unit_price", e.target.value)} slotProps={{ htmlInput: { step: "any" } }} InputProps={{ readOnly }} sx={{ "& input": { textAlign: "right" } }} @@ -740,9 +738,11 @@ export default function IssuedOrderDetail() { .map((it, i) => ({ description: it.description, item_description: it.item_description, - quantity: it.quantity, + // Coerce the raw typed strings back to numbers so an empty/partial + // field never reaches the server as NaN (mirrors the totals math). + quantity: Number(it.quantity) || 0, unit: it.unit, - unit_price: it.unit_price, + unit_price: Number(it.unit_price) || 0, vat_rate: it.vat_rate, position: i, })), @@ -1135,7 +1135,7 @@ export default function IssuedOrderDetail() { # Popis - + Množství diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index a5993fd..3c44df0 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -87,9 +87,12 @@ interface OfferItem { id?: number; description: string; item_description: string; - quantity: number; + // Held as the raw typed string while editing so the field can be cleared + // (an empty string renders fine in a type="number" input). Coerced via + // Number(x) || 0 only where used (live totals + save payload). + quantity: string | number; unit: string; - unit_price: number; + unit_price: string | number; is_included_in_total: boolean; } @@ -323,10 +326,11 @@ function SortableItemRow({ onUpdate("quantity", parseInt(e.target.value, 10))} + value={item.quantity ?? ""} + onChange={(e) => onUpdate("quantity", e.target.value)} slotProps={{ htmlInput: { step: "1" } }} InputProps={{ readOnly }} + sx={{ "& input": { textAlign: "center" } }} /> onUpdate("unit_price", parseFloat(e.target.value))} + value={item.unit_price ?? ""} + onChange={(e) => onUpdate("unit_price", e.target.value)} slotProps={{ htmlInput: { step: "0.01" } }} InputProps={{ readOnly }} + sx={{ "& input": { textAlign: "right" } }} /> onUpdate("quantity", parseInt(e.target.value, 10))} + value={item.quantity ?? ""} + onChange={(e) => onUpdate("quantity", e.target.value)} slotProps={{ htmlInput: { step: "1" } }} InputProps={{ readOnly }} + sx={{ "& input": { textAlign: "center" } }} /> @@ -453,10 +459,11 @@ function SortableItemRow({ onUpdate("unit_price", parseFloat(e.target.value))} + value={item.unit_price ?? ""} + onChange={(e) => onUpdate("unit_price", e.target.value)} slotProps={{ htmlInput: { step: "0.01" } }} InputProps={{ readOnly }} + sx={{ "& input": { textAlign: "right" } }} /> @@ -882,7 +889,14 @@ export default function OfferDetail() { const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`; const payload: any = { ...form, - items: items.map((item, i) => ({ ...item, position: i })), + items: items.map((item, i) => ({ + ...item, + // Coerce the raw typed strings back to numbers so an empty/partial + // field never reaches the server as NaN (mirrors the totals math). + quantity: Number(item.quantity) || 0, + unit_price: Number(item.unit_price) || 0, + position: i, + })), sections: sections.map((s, i) => ({ ...s, position: i })), }; if (!isEdit) delete payload.quotation_number; @@ -1524,7 +1538,7 @@ export default function OfferDetail() { # Popis - + Množství Jednotka