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:
@@ -49,10 +49,31 @@ export interface DocumentItem {
|
||||
quantity: string | number;
|
||||
unit: string;
|
||||
unit_price: string | number;
|
||||
/** Per-item discount as a percentage (0–100). Offers only; 0 elsewhere. */
|
||||
discount: string | number;
|
||||
/** Offers only — undefined (issued orders) counts as included. */
|
||||
is_included_in_total?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discount input styling: centered, and with the 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 line after its per-item percentage discount. */
|
||||
const discountedLineNet = (item: DocumentItem): number =>
|
||||
(Number(item.quantity) || 0) *
|
||||
(Number(item.unit_price) || 0) *
|
||||
(1 - (Number(item.discount) || 0) / 100);
|
||||
|
||||
let documentItemKeyCounter = 0;
|
||||
|
||||
/** Unique client-side key for a (new or hydrated) document item row. */
|
||||
@@ -66,6 +87,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
discount: 0,
|
||||
is_included_in_total: true,
|
||||
});
|
||||
|
||||
@@ -73,9 +95,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
|
||||
export const documentItemsTotal = (items: DocumentItem[]): number =>
|
||||
items.reduce(
|
||||
(sum, it) =>
|
||||
it.is_included_in_total === false
|
||||
? sum
|
||||
: sum + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0),
|
||||
it.is_included_in_total === false ? sum : sum + discountedLineNet(it),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -111,6 +131,7 @@ function SortableItemRow({
|
||||
readOnly,
|
||||
canDelete,
|
||||
showIncludedInTotal,
|
||||
showDiscount,
|
||||
itemDescriptionMaxLength,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
@@ -121,6 +142,7 @@ function SortableItemRow({
|
||||
readOnly: boolean;
|
||||
canDelete: boolean;
|
||||
showIncludedInTotal: boolean;
|
||||
showDiscount: boolean;
|
||||
itemDescriptionMaxLength: number;
|
||||
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
|
||||
onRemove: () => void;
|
||||
@@ -143,8 +165,7 @@ function SortableItemRow({
|
||||
position: "relative" as const,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
};
|
||||
const lineTotal =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const lineTotal = discountedLineNet(item);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -253,6 +274,17 @@ function SortableItemRow({
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
{showDiscount && (
|
||||
<TextField
|
||||
label="Sleva %"
|
||||
type="number"
|
||||
value={item.discount ?? ""}
|
||||
onChange={(e) => onUpdate("discount", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={discountInputSx}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -396,6 +428,18 @@ function SortableItemRow({
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
{showDiscount && (
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.discount ?? ""}
|
||||
onChange={(e) => onUpdate("discount", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={discountInputSx}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{showIncludedInTotal && (
|
||||
<TableCell align="center">
|
||||
<Checkbox
|
||||
@@ -442,6 +486,8 @@ interface DocumentItemsEditorProps {
|
||||
error?: string;
|
||||
/** Render the offers-only "V ceně" (is_included_in_total) column. */
|
||||
showIncludedInTotal?: boolean;
|
||||
/** Render the per-item "Sleva %" discount column (offers only). */
|
||||
showDiscount?: boolean;
|
||||
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
|
||||
itemDescriptionMaxLength?: number;
|
||||
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
||||
@@ -460,6 +506,7 @@ export default function DocumentItemsEditor({
|
||||
readOnly,
|
||||
error,
|
||||
showIncludedInTotal = false,
|
||||
showDiscount = false,
|
||||
itemDescriptionMaxLength = 5000,
|
||||
templatesSlot,
|
||||
}: DocumentItemsEditorProps) {
|
||||
@@ -563,6 +610,7 @@ export default function DocumentItemsEditor({
|
||||
readOnly={readOnly}
|
||||
canDelete={items.length > 1}
|
||||
showIncludedInTotal={showIncludedInTotal}
|
||||
showDiscount={showDiscount}
|
||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||
onUpdate={(field, value) => updateItem(index, field, value)}
|
||||
onRemove={() => removeItem(index)}
|
||||
@@ -594,6 +642,11 @@ export default function DocumentItemsEditor({
|
||||
<TableCell sx={{ width: "8rem" }} align="center">
|
||||
Jedn. cena
|
||||
</TableCell>
|
||||
{showDiscount && (
|
||||
<TableCell sx={{ width: "7rem" }} align="center">
|
||||
Sleva %
|
||||
</TableCell>
|
||||
)}
|
||||
{showIncludedInTotal && (
|
||||
<TableCell sx={{ width: "4rem" }} align="center">
|
||||
V ceně
|
||||
@@ -615,6 +668,7 @@ export default function DocumentItemsEditor({
|
||||
readOnly={readOnly}
|
||||
canDelete={items.length > 1}
|
||||
showIncludedInTotal={showIncludedInTotal}
|
||||
showDiscount={showDiscount}
|
||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||
onUpdate={(field, value) =>
|
||||
updateItem(index, field, value)
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface InvoiceItem {
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
discount?: number;
|
||||
vat_rate?: number;
|
||||
is_included_in_total: boolean;
|
||||
position?: number;
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface OfferItemData {
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
discount?: number;
|
||||
is_included_in_total: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
@@ -185,9 +185,34 @@ interface InvoiceItem {
|
||||
quantity: string | number;
|
||||
unit: string;
|
||||
unit_price: string | number;
|
||||
/** Per-item discount as a percentage (0–100). */
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user