fix(ui): line-item number inputs — allow clearing the 0 (store raw string, coerce at totals/save), unify value alignment, widen Množství column

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 17:38:28 +02:00
parent a3948c7da1
commit 28186397a0
4 changed files with 104 additions and 75 deletions

View File

@@ -7,7 +7,22 @@ import { useTheme } from "@mui/material/styles";
import { Modal, Button, TextField, Field } from "../ui"; import { Modal, Button, TextField, Field } from "../ui";
import { useAlert } from "../context/AlertContext"; 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 { 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; description: string;
quantity: number; quantity: number;
unit: string; unit: string;
@@ -22,7 +37,7 @@ interface OrderConfirmationModalProps {
onGenerate: ( onGenerate: (
lang: string, lang: string,
applyVat: boolean, applyVat: boolean,
items?: ConfirmationItem[], items?: GeneratedItem[],
) => Promise<void>; ) => Promise<void>;
initialItems: ConfirmationItem[]; initialItems: ConfirmationItem[];
orderNumber: string; orderNumber: string;
@@ -81,7 +96,17 @@ export default function OrderConfirmationModal({
const handleEditGenerate = async () => { const handleEditGenerate = async () => {
setLoading(true); setLoading(true);
try { 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. // Only close on success — on error keep the user's edited items intact.
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -273,9 +298,9 @@ export default function OrderConfirmationModal({
<TextField <TextField
label="Množství" label="Množství"
type="number" type="number"
value={item.quantity} value={item.quantity ?? ""}
onChange={(e) => onChange={(e) =>
updateItem(i, "quantity", Number(e.target.value) || 0) updateItem(i, "quantity", e.target.value)
} }
slotProps={{ htmlInput: { step: "0.001" } }} slotProps={{ htmlInput: { step: "0.001" } }}
/> />
@@ -287,18 +312,18 @@ export default function OrderConfirmationModal({
<TextField <TextField
label="Cena" label="Cena"
type="number" type="number"
value={item.unit_price} value={item.unit_price ?? ""}
onChange={(e) => onChange={(e) =>
updateItem(i, "unit_price", Number(e.target.value) || 0) updateItem(i, "unit_price", e.target.value)
} }
slotProps={{ htmlInput: { step: "0.01" } }} slotProps={{ htmlInput: { step: "0.01" } }}
/> />
<TextField <TextField
label="%DPH" label="%DPH"
type="number" type="number"
value={item.vat_rate} value={item.vat_rate ?? ""}
onChange={(e) => onChange={(e) =>
updateItem(i, "vat_rate", Number(e.target.value) || 0) updateItem(i, "vat_rate", e.target.value)
} }
slotProps={{ htmlInput: { step: "1" } }} slotProps={{ htmlInput: { step: "1" } }}
/> />
@@ -350,15 +375,11 @@ export default function OrderConfirmationModal({
<td> <td>
<TextField <TextField
type="number" type="number"
value={item.quantity} value={item.quantity ?? ""}
onChange={(e) => onChange={(e) =>
updateItem( updateItem(i, "quantity", e.target.value)
i,
"quantity",
Number(e.target.value) || 0,
)
} }
sx={{ width: 90 }} sx={{ width: 112 }}
slotProps={{ htmlInput: { step: "0.001" } }} slotProps={{ htmlInput: { step: "0.001" } }}
/> />
</td> </td>
@@ -374,13 +395,9 @@ export default function OrderConfirmationModal({
<td> <td>
<TextField <TextField
type="number" type="number"
value={item.unit_price} value={item.unit_price ?? ""}
onChange={(e) => onChange={(e) =>
updateItem( updateItem(i, "unit_price", e.target.value)
i,
"unit_price",
Number(e.target.value) || 0,
)
} }
sx={{ width: 110 }} sx={{ width: 110 }}
slotProps={{ htmlInput: { step: "0.01" } }} slotProps={{ htmlInput: { step: "0.01" } }}
@@ -389,13 +406,9 @@ export default function OrderConfirmationModal({
<td> <td>
<TextField <TextField
type="number" type="number"
value={item.vat_rate} value={item.vat_rate ?? ""}
onChange={(e) => onChange={(e) =>
updateItem( updateItem(i, "vat_rate", e.target.value)
i,
"vat_rate",
Number(e.target.value) || 0,
)
} }
sx={{ width: 80 }} sx={{ width: 80 }}
slotProps={{ htmlInput: { step: "1" } }} slotProps={{ htmlInput: { step: "1" } }}

View File

@@ -166,9 +166,13 @@ interface InvoiceItem {
_key: string; _key: string;
description: string; description: string;
item_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: string;
unit_price: number; unit_price: string | number;
vat_rate: number; vat_rate: number;
} }
@@ -332,10 +336,8 @@ function SortableInvoiceRow({
<TextField <TextField
label="Množství" label="Množství"
type="number" type="number"
value={item.quantity} value={item.quantity ?? ""}
onChange={(e) => onChange={(e) => onUpdate(index, "quantity", e.target.value)}
onUpdate(index, "quantity", Number(e.target.value))
}
slotProps={{ htmlInput: { min: "0", step: "any" } }} slotProps={{ htmlInput: { min: "0", step: "any" } }}
/> />
<TextField <TextField
@@ -347,10 +349,8 @@ function SortableInvoiceRow({
<TextField <TextField
label="Jedn. cena" label="Jedn. cena"
type="number" type="number"
value={item.unit_price} value={item.unit_price ?? ""}
onChange={(e) => onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
onUpdate(index, "unit_price", Number(e.target.value))
}
slotProps={{ htmlInput: { step: "any" } }} slotProps={{ htmlInput: { step: "any" } }}
/> />
{apply_vat && ( {apply_vat && (
@@ -438,8 +438,8 @@ function SortableInvoiceRow({
<TableCell> <TableCell>
<TextField <TextField
type="number" type="number"
value={item.quantity} value={item.quantity ?? ""}
onChange={(e) => onUpdate(index, "quantity", Number(e.target.value))} onChange={(e) => onUpdate(index, "quantity", e.target.value)}
slotProps={{ htmlInput: { min: "0", step: "any" } }} slotProps={{ htmlInput: { min: "0", step: "any" } }}
sx={{ "& input": { textAlign: "center" } }} sx={{ "& input": { textAlign: "center" } }}
/> />
@@ -455,10 +455,8 @@ function SortableInvoiceRow({
<TableCell> <TableCell>
<TextField <TextField
type="number" type="number"
value={item.unit_price} value={item.unit_price ?? ""}
onChange={(e) => onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
onUpdate(index, "unit_price", Number(e.target.value))
}
slotProps={{ htmlInput: { step: "any" } }} slotProps={{ htmlInput: { step: "any" } }}
sx={{ "& input": { textAlign: "right" } }} sx={{ "& input": { textAlign: "right" } }}
/> />
@@ -1036,6 +1034,10 @@ export default function InvoiceDetail() {
.filter((i) => i.description.trim()) .filter((i) => i.description.trim())
.map((item, i) => ({ .map((item, i) => ({
...item, ...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, position: i,
})), })),
}; };
@@ -2065,7 +2067,7 @@ export default function InvoiceDetail() {
# #
</TableCell> </TableCell>
<TableCell>Popis</TableCell> <TableCell>Popis</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center"> <TableCell sx={{ width: "7rem" }} align="center">
Množství Množství
</TableCell> </TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center"> <TableCell sx={{ width: "5.5rem" }} align="center">

View File

@@ -141,9 +141,13 @@ interface OrderItem {
_key: string; _key: string;
description: string; description: string;
item_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: string;
unit_price: number; unit_price: string | number;
vat_rate: number; vat_rate: number;
} }
@@ -284,10 +288,8 @@ function SortableOrderRow({
<TextField <TextField
label="Množství" label="Množství"
type="number" type="number"
value={item.quantity} value={item.quantity ?? ""}
onChange={(e) => onChange={(e) => onUpdate(index, "quantity", e.target.value)}
onUpdate(index, "quantity", Number(e.target.value))
}
slotProps={{ htmlInput: { min: "0", step: "any" } }} slotProps={{ htmlInput: { min: "0", step: "any" } }}
InputProps={{ readOnly }} InputProps={{ readOnly }}
/> />
@@ -301,10 +303,8 @@ function SortableOrderRow({
<TextField <TextField
label="Jedn. cena" label="Jedn. cena"
type="number" type="number"
value={item.unit_price} value={item.unit_price ?? ""}
onChange={(e) => onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
onUpdate(index, "unit_price", Number(e.target.value))
}
slotProps={{ htmlInput: { step: "any" } }} slotProps={{ htmlInput: { step: "any" } }}
InputProps={{ readOnly }} InputProps={{ readOnly }}
/> />
@@ -401,8 +401,8 @@ function SortableOrderRow({
<TableCell> <TableCell>
<TextField <TextField
type="number" type="number"
value={item.quantity} value={item.quantity ?? ""}
onChange={(e) => onUpdate(index, "quantity", Number(e.target.value))} onChange={(e) => onUpdate(index, "quantity", e.target.value)}
slotProps={{ htmlInput: { min: "0", step: "any" } }} slotProps={{ htmlInput: { min: "0", step: "any" } }}
InputProps={{ readOnly }} InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "center" } }} sx={{ "& input": { textAlign: "center" } }}
@@ -420,10 +420,8 @@ function SortableOrderRow({
<TableCell> <TableCell>
<TextField <TextField
type="number" type="number"
value={item.unit_price} value={item.unit_price ?? ""}
onChange={(e) => onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
onUpdate(index, "unit_price", Number(e.target.value))
}
slotProps={{ htmlInput: { step: "any" } }} slotProps={{ htmlInput: { step: "any" } }}
InputProps={{ readOnly }} InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "right" } }} sx={{ "& input": { textAlign: "right" } }}
@@ -740,9 +738,11 @@ export default function IssuedOrderDetail() {
.map((it, i) => ({ .map((it, i) => ({
description: it.description, description: it.description,
item_description: it.item_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: it.unit,
unit_price: it.unit_price, unit_price: Number(it.unit_price) || 0,
vat_rate: it.vat_rate, vat_rate: it.vat_rate,
position: i, position: i,
})), })),
@@ -1135,7 +1135,7 @@ export default function IssuedOrderDetail() {
# #
</TableCell> </TableCell>
<TableCell>Popis</TableCell> <TableCell>Popis</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center"> <TableCell sx={{ width: "7rem" }} align="center">
Množství Množství
</TableCell> </TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center"> <TableCell sx={{ width: "5.5rem" }} align="center">

View File

@@ -87,9 +87,12 @@ interface OfferItem {
id?: number; id?: number;
description: string; description: string;
item_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: string;
unit_price: number; unit_price: string | number;
is_included_in_total: boolean; is_included_in_total: boolean;
} }
@@ -323,10 +326,11 @@ function SortableItemRow({
<TextField <TextField
label="Množství" label="Množství"
type="number" type="number"
value={item.quantity} value={item.quantity ?? ""}
onChange={(e) => onUpdate("quantity", parseInt(e.target.value, 10))} onChange={(e) => onUpdate("quantity", e.target.value)}
slotProps={{ htmlInput: { step: "1" } }} slotProps={{ htmlInput: { step: "1" } }}
InputProps={{ readOnly }} InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "center" } }}
/> />
<TextField <TextField
label="Jednotka" label="Jednotka"
@@ -337,10 +341,11 @@ function SortableItemRow({
<TextField <TextField
label="Jedn. cena" label="Jedn. cena"
type="number" type="number"
value={item.unit_price} value={item.unit_price ?? ""}
onChange={(e) => onUpdate("unit_price", parseFloat(e.target.value))} onChange={(e) => onUpdate("unit_price", e.target.value)}
slotProps={{ htmlInput: { step: "0.01" } }} slotProps={{ htmlInput: { step: "0.01" } }}
InputProps={{ readOnly }} InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "right" } }}
/> />
</Box> </Box>
<Box <Box
@@ -437,10 +442,11 @@ function SortableItemRow({
<TableCell> <TableCell>
<TextField <TextField
type="number" type="number"
value={item.quantity} value={item.quantity ?? ""}
onChange={(e) => onUpdate("quantity", parseInt(e.target.value, 10))} onChange={(e) => onUpdate("quantity", e.target.value)}
slotProps={{ htmlInput: { step: "1" } }} slotProps={{ htmlInput: { step: "1" } }}
InputProps={{ readOnly }} InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "center" } }}
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -453,10 +459,11 @@ function SortableItemRow({
<TableCell> <TableCell>
<TextField <TextField
type="number" type="number"
value={item.unit_price} value={item.unit_price ?? ""}
onChange={(e) => onUpdate("unit_price", parseFloat(e.target.value))} onChange={(e) => onUpdate("unit_price", e.target.value)}
slotProps={{ htmlInput: { step: "0.01" } }} slotProps={{ htmlInput: { step: "0.01" } }}
InputProps={{ readOnly }} InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "right" } }}
/> />
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
@@ -882,7 +889,14 @@ export default function OfferDetail() {
const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`; const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`;
const payload: any = { const payload: any = {
...form, ...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 })), sections: sections.map((s, i) => ({ ...s, position: i })),
}; };
if (!isEdit) delete payload.quotation_number; if (!isEdit) delete payload.quotation_number;
@@ -1524,7 +1538,7 @@ export default function OfferDetail() {
# #
</TableCell> </TableCell>
<TableCell>Popis</TableCell> <TableCell>Popis</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center"> <TableCell sx={{ width: "7rem" }} align="center">
Množství Množství
</TableCell> </TableCell>
<TableCell sx={{ width: "5rem" }}>Jednotka</TableCell> <TableCell sx={{ width: "5rem" }}>Jednotka</TableCell>