feat(documents): per-item Sleva (% discount) on offers + invoices

Adds an optional per-line-item percentage discount ("Sleva") to offers and
issued invoices, on both the detail-page editor and the PDF.

- DB: discount Decimal(5,2) DEFAULT 0 on quotation_items + invoice_items
  (migration 20260613195833_add_item_discount).
- Totals/VAT: new shared lineNet() (qty × price × (1 − discount/100)); offers'
  NET total and invoices' subtotal AND VAT now compute on the discounted net
  (every getBase/enrichQuotation/stats path). Backward compatible: discount 0
  is identical to before.
- Schemas: discount = numberInRange(0,100), default 0; persisted on
  create/update (+ offer duplicate).
- Detail editors: editable "Sleva %" column — offers via DocumentItemsEditor's
  new opt-in showDiscount prop (issued orders unaffected); invoices in their own
  editor. Spinners hidden + 7rem column so "100" is fully visible. Read-only
  invoice view shows the column when any line has a discount.
- PDFs (offers + invoices): conditional "Sleva"/"Discount" column rendered only
  when an item has a discount; line/total/VAT always use the discounted net.

Tests: +16 (money, totals incl. VAT-on-discounted-net, schema range, PDF
column present/absent). Full suite 665 pass, tsc -b clean, lint 0. Editor
verified live via Playwright. Design spec in docs/superpowers/specs/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-13 20:46:44 +02:00
parent 3787cc4dd0
commit 719d1f2659
21 changed files with 554 additions and 33 deletions

View File

@@ -185,9 +185,34 @@ interface InvoiceItem {
quantity: string | number;
unit: string;
unit_price: string | number;
/** Per-item discount as a percentage (0100). */
discount: string | number;
vat_rate: number;
}
/**
* Discount input styling: centered, native number spinners hidden so a 3-digit
* value ("100") isn't clipped under the up/down arrows.
*/
const discountInputSx = {
"& input": { textAlign: "center" },
"& input[type=number]": { MozAppearance: "textfield" as const },
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
WebkitAppearance: "none",
margin: 0,
},
};
/** NET of one invoice line after its per-item percentage discount. */
const invoiceLineNet = (item: {
quantity: string | number;
unit_price: string | number;
discount?: string | number | null;
}): number =>
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0) *
(1 - (Number(item.discount) || 0) / 100);
interface InvoiceForm {
customer_id: number | null;
customer_name: string;
@@ -253,8 +278,7 @@ function SortableInvoiceRow({
position: "relative" as const,
zIndex: isDragging ? 10 : undefined,
};
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const lineTotal = invoiceLineNet(item);
if (isMobile) {
return (
@@ -349,6 +373,14 @@ function SortableInvoiceRow({
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
slotProps={{ htmlInput: { step: "any" } }}
/>
<TextField
label="Sleva %"
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate(index, "discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
sx={discountInputSx}
/>
{apply_vat && (
<Select
label="DPH"
@@ -458,6 +490,15 @@ function SortableInvoiceRow({
sx={{ "& input": { textAlign: "right" } }}
/>
</TableCell>
<TableCell>
<TextField
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate(index, "discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
sx={discountInputSx}
/>
</TableCell>
{apply_vat ? (
<TableCell>
<Select
@@ -513,6 +554,7 @@ export default function InvoiceDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
discount: 0,
vat_rate: 21,
}),
[],
@@ -577,6 +619,7 @@ export default function InvoiceDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
discount: 0,
vat_rate: 21,
},
]);
@@ -628,6 +671,10 @@ export default function InvoiceDetail() {
const invoiceQuery = useQuery(invoiceDetailOptions(id));
const invoice = invoiceQuery.data ?? null;
// Read-only view: show the Sleva column only when some line carries a discount.
const hasItemDiscount = (invoice?.items ?? []).some(
(it) => Number(it.discount) > 0,
);
const nextNumberQuery = useQuery({
queryKey: ["invoices", "next-number"],
@@ -751,6 +798,7 @@ export default function InvoiceDetail() {
quantity: Number(item.quantity) || 1,
unit: item.unit || "",
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
// Per-line VAT: hydrate from the ITEM's own stored rate (a real DB
// column returned by the detail endpoint) — falling back to the
// invoice-level rate only when the line carries none. Hydrating
@@ -848,6 +896,7 @@ export default function InvoiceDetail() {
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
vat_rate: vatRate,
})),
);
@@ -967,8 +1016,7 @@ export default function InvoiceDetail() {
const vatByRate: Record<number, number> = {};
items.forEach((item) => {
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const lineTotal = invoiceLineNet(item);
subtotal += lineTotal;
if (form.apply_vat) {
@@ -1038,6 +1086,7 @@ export default function InvoiceDetail() {
// field never reaches the server as NaN (mirrors createTotals).
quantity: Number(item.quantity) || 0,
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
position: i,
})),
sections: sections.map((s, i) => ({
@@ -1361,9 +1410,7 @@ export default function InvoiceDetail() {
isMobile ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{invoice.items.map((item, index) => {
const lineSubtotal =
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0);
const lineSubtotal = invoiceLineNet(item);
const lineVat = Number(invoice.apply_vat)
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
: 0;
@@ -1494,6 +1541,11 @@ export default function InvoiceDetail() {
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
{hasItemDiscount && (
<TableCell sx={{ width: "4rem" }} align="center">
Sleva %
</TableCell>
)}
<TableCell sx={{ width: "4rem" }} align="center">
DPH
</TableCell>
@@ -1504,9 +1556,7 @@ export default function InvoiceDetail() {
</TableHead>
<TableBody>
{invoice.items.map((item, index) => {
const lineSubtotal =
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0);
const lineSubtotal = invoiceLineNet(item);
const lineVat = Number(invoice.apply_vat)
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
: 0;
@@ -1555,6 +1605,13 @@ export default function InvoiceDetail() {
>
{formatCurrency(item.unit_price, invoice.currency)}
</TableCell>
{hasItemDiscount && (
<TableCell align="center">
{Number(item.discount) > 0
? `${Number(item.discount)} %`
: "—"}
</TableCell>
)}
<TableCell align="center">
{Number(invoice.apply_vat)
? Number(item.vat_rate)
@@ -2238,6 +2295,9 @@ export default function InvoiceDetail() {
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
<TableCell sx={{ width: "7rem" }} align="center">
Sleva %
</TableCell>
{form.apply_vat ? (
<TableCell sx={{ width: "5rem" }} align="center">
DPH

View File

@@ -293,6 +293,8 @@ export default function IssuedOrderDetail() {
quantity: numberOr(it.quantity, 1),
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
// Issued orders have no Sleva column; keep the shared item shape happy.
discount: 0,
}))
: [emptyDocumentItem()];
setItems(mappedItems);

View File

@@ -93,6 +93,7 @@ interface OfferItemPayload {
quantity: number;
unit: string;
unit_price: number;
discount: number;
is_included_in_total: boolean;
position: number;
}
@@ -303,6 +304,7 @@ export default function OfferDetail() {
quantity: numberOr(it.quantity, 1),
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
discount: Number(it.discount) || 0,
is_included_in_total: it.is_included_in_total !== false,
}))
: [emptyDocumentItem()];
@@ -433,6 +435,7 @@ export default function OfferDetail() {
quantity: Number(item.quantity) || 0,
unit: item.unit,
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
is_included_in_total: item.is_included_in_total !== false,
position: i,
})),
@@ -936,6 +939,7 @@ export default function OfferDetail() {
readOnly={readOnly}
error={errors.items}
showIncludedInTotal
showDiscount
itemDescriptionMaxLength={8000}
templatesSlot={
itemTemplates && itemTemplates.length > 0 ? (
@@ -957,6 +961,7 @@ export default function OfferDetail() {
quantity: 1,
unit: "ks",
unit_price: template.default_price || 0,
discount: 0,
is_included_in_total: true,
},
]);