feat(vat)!: remove VAT entirely from offers, orders and issued orders
Accounting rule: nabidky, objednavky (incl. confirmation PDF) and objednavky
vydane are NOT tax documents - VAT belongs only on invoices. Per user
decision this is a FULL removal including DB columns.
- migration: DROP orders.vat_rate/apply_vat, issued_orders.vat_rate/apply_vat,
issued_order_items.vat_rate, quotations.vat_rate/apply_vat (applied to
dev + test DBs)
- services: net-only totals everywhere (computeIssuedOrderTotals(items) ->
{total}; enrichOrder/enrichQuotation net; per-currency list totals net)
- PDFs (offers, order confirmation, issued order): no VAT columns/summary,
single 'Celkem bez DPH' / 'Total excl. VAT' total, note under totals:
'Ceny jsou uvedeny bez DPH. DPH bude uctovano dle platnych predpisu.';
duplicate Cena/Celkem column merged (desc width 56%); orders-pdf render
extracted as exported renderOrderConfirmationHtml for testability
- frontend: Uplatnit DPH checkboxes, VAT selects and per-item VAT columns
removed from OfferDetail/OrderDetail/IssuedOrderDetail/
OrderConfirmationModal/ReceivedOrders manual-create; list footers read
'Celkem bez DPH'; invoice-from-order prefill now takes the company default
VAT (invoice decides its own VAT)
- tests: suites reworked to net math; new pdf-vat-note.test.ts pins the
exact cs+en note text and VAT-free layout on all three PDFs
Invoices and received invoices keep their VAT handling unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -57,7 +57,6 @@ import {
|
||||
DateField,
|
||||
Field,
|
||||
StatusChip,
|
||||
CheckboxField,
|
||||
ConfirmDialog,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
@@ -85,11 +84,6 @@ const TRANSITION_LABELS: Record<string, string> = {
|
||||
cancelled: "Stornovat",
|
||||
};
|
||||
|
||||
const VAT_OPTIONS = [0, 10, 12, 15, 21].map((v) => ({
|
||||
value: String(v),
|
||||
label: `${v}%`,
|
||||
}));
|
||||
|
||||
const CURRENCY_FALLBACK = ["CZK", "EUR", "USD", "GBP"];
|
||||
|
||||
const BackIcon = (
|
||||
@@ -151,20 +145,16 @@ interface OrderItem {
|
||||
item_description: string;
|
||||
// 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.
|
||||
// only where used (live totals + save payload).
|
||||
quantity: string | number;
|
||||
unit: string;
|
||||
unit_price: string | number;
|
||||
vat_rate: number;
|
||||
}
|
||||
|
||||
interface OrderForm {
|
||||
supplier_id: number | null;
|
||||
supplier_name: string;
|
||||
currency: string;
|
||||
apply_vat: boolean;
|
||||
vat_rate: number;
|
||||
order_date: string;
|
||||
delivery_date: string;
|
||||
language: string;
|
||||
@@ -182,7 +172,6 @@ function SortableOrderRow({
|
||||
item,
|
||||
index,
|
||||
currency,
|
||||
apply_vat,
|
||||
readOnly,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
@@ -191,7 +180,6 @@ function SortableOrderRow({
|
||||
item: OrderItem;
|
||||
index: number;
|
||||
currency: string;
|
||||
apply_vat: boolean;
|
||||
readOnly: boolean;
|
||||
onUpdate: (
|
||||
index: number,
|
||||
@@ -290,7 +278,7 @@ function SortableOrderRow({
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
@@ -317,21 +305,6 @@ function SortableOrderRow({
|
||||
slotProps={{ htmlInput: { step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
/>
|
||||
{apply_vat &&
|
||||
(readOnly ? (
|
||||
<TextField
|
||||
label="DPH"
|
||||
value={`${Number(item.vat_rate)}%`}
|
||||
InputProps={{ readOnly: true }}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
label="DPH"
|
||||
value={String(item.vat_rate)}
|
||||
onChange={(val) => onUpdate(index, "vat_rate", Number(val))}
|
||||
options={VAT_OPTIONS}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -436,20 +409,6 @@ function SortableOrderRow({
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
{apply_vat ? (
|
||||
<TableCell>
|
||||
{readOnly ? (
|
||||
<Box sx={{ textAlign: "center" }}>{Number(item.vat_rate)}%</Box>
|
||||
) : (
|
||||
<Select
|
||||
value={String(item.vat_rate)}
|
||||
onChange={(val) => onUpdate(index, "vat_rate", Number(val))}
|
||||
sx={{ minWidth: "4.5rem" }}
|
||||
options={VAT_OPTIONS}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell
|
||||
align="right"
|
||||
sx={{
|
||||
@@ -496,7 +455,6 @@ export default function IssuedOrderDetail() {
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
vat_rate: 21,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -513,8 +471,6 @@ export default function IssuedOrderDetail() {
|
||||
supplier_id: null,
|
||||
supplier_name: "",
|
||||
currency: "CZK",
|
||||
apply_vat: true,
|
||||
vat_rate: 21,
|
||||
order_date: todayLocalStr(),
|
||||
delivery_date: "",
|
||||
language: "cs",
|
||||
@@ -534,7 +490,6 @@ export default function IssuedOrderDetail() {
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
vat_rate: 21,
|
||||
},
|
||||
]);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
@@ -615,8 +570,6 @@ export default function IssuedOrderDetail() {
|
||||
supplier_id: d.supplier_id ?? null,
|
||||
supplier_name: d.supplier_name ?? "",
|
||||
currency: d.currency || "CZK",
|
||||
apply_vat: d.apply_vat !== false,
|
||||
vat_rate: numberOr(d.vat_rate, 21),
|
||||
order_date: normalizeDateStr(d.order_date),
|
||||
delivery_date: normalizeDateStr(d.delivery_date),
|
||||
language: d.language || "cs",
|
||||
@@ -640,7 +593,6 @@ export default function IssuedOrderDetail() {
|
||||
quantity: numberOr(it.quantity, 1),
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
vat_rate: numberOr(it.vat_rate, numberOr(d.vat_rate, 21)),
|
||||
}))
|
||||
: [];
|
||||
if (mapped.length > 0) setItems(mapped);
|
||||
@@ -683,21 +635,14 @@ export default function IssuedOrderDetail() {
|
||||
const editable = !isEdit || form.status === "draft" || form.status === "sent";
|
||||
const canExport = hasPermission("orders.view");
|
||||
|
||||
// ─── Totals (live) ───
|
||||
// ─── Totals (live, NET only — issued orders carry no VAT) ───
|
||||
const totals = useMemo(() => {
|
||||
let subtotal = 0;
|
||||
const vatByRate: Record<number, number> = {};
|
||||
let total = 0;
|
||||
items.forEach((it) => {
|
||||
const line = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
|
||||
subtotal += line;
|
||||
if (form.apply_vat) {
|
||||
const rate = Number(it.vat_rate) || 0;
|
||||
vatByRate[rate] = (vatByRate[rate] || 0) + (line * rate) / 100;
|
||||
}
|
||||
total += (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
|
||||
});
|
||||
const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0);
|
||||
return { subtotal, vatByRate, totalVat, total: subtotal + totalVat };
|
||||
}, [items, form.apply_vat]);
|
||||
return { total };
|
||||
}, [items]);
|
||||
|
||||
// ─── Mutations ───
|
||||
const saveMutation = useApiMutation<Record<string, unknown>, { id: number }>({
|
||||
@@ -774,8 +719,6 @@ export default function IssuedOrderDetail() {
|
||||
const payload: Record<string, unknown> = {
|
||||
supplier_id: form.supplier_id,
|
||||
currency: form.currency,
|
||||
vat_rate: form.vat_rate,
|
||||
apply_vat: form.apply_vat,
|
||||
order_date: form.order_date,
|
||||
delivery_date: form.delivery_date || null,
|
||||
language: form.language,
|
||||
@@ -795,7 +738,6 @@ export default function IssuedOrderDetail() {
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: it.unit,
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
vat_rate: it.vat_rate,
|
||||
position: i,
|
||||
})),
|
||||
};
|
||||
@@ -1175,7 +1117,7 @@ export default function IssuedOrderDetail() {
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr 1fr" },
|
||||
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
@@ -1202,31 +1144,6 @@ export default function IssuedOrderDetail() {
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Sazba DPH">
|
||||
<Select
|
||||
value={String(form.vat_rate)}
|
||||
disabled={!editable || !form.apply_vat}
|
||||
onChange={(val) =>
|
||||
setForm((prev) => ({ ...prev, vat_rate: Number(val) }))
|
||||
}
|
||||
options={VAT_OPTIONS}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DPH">
|
||||
<Box sx={{ display: "flex", alignItems: "center", height: 40 }}>
|
||||
<CheckboxField
|
||||
label="Uplatnit DPH"
|
||||
checked={form.apply_vat}
|
||||
disabled={!editable}
|
||||
onChange={(v) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
apply_vat: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Field label="Vystavil">
|
||||
@@ -1291,7 +1208,6 @@ export default function IssuedOrderDetail() {
|
||||
item={item}
|
||||
index={index}
|
||||
currency={form.currency}
|
||||
apply_vat={form.apply_vat}
|
||||
readOnly={!editable}
|
||||
onUpdate={updateItem}
|
||||
onRemove={removeItem}
|
||||
@@ -1324,11 +1240,6 @@ export default function IssuedOrderDetail() {
|
||||
<TableCell sx={{ width: "8rem" }} align="center">
|
||||
Jedn. cena
|
||||
</TableCell>
|
||||
{form.apply_vat ? (
|
||||
<TableCell sx={{ width: "5rem" }} align="center">
|
||||
DPH
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell sx={{ width: "8rem" }} align="right">
|
||||
Celkem
|
||||
</TableCell>
|
||||
@@ -1342,7 +1253,6 @@ export default function IssuedOrderDetail() {
|
||||
item={item}
|
||||
index={index}
|
||||
currency={form.currency}
|
||||
apply_vat={form.apply_vat}
|
||||
readOnly={!editable}
|
||||
onUpdate={updateItem}
|
||||
onRemove={removeItem}
|
||||
@@ -1356,7 +1266,7 @@ export default function IssuedOrderDetail() {
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Totals */}
|
||||
{/* Totals (NET only — issued orders are not tax documents) */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
@@ -1367,34 +1277,6 @@ export default function IssuedOrderDetail() {
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Mezisoučet:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{formatCurrency(totals.subtotal, form.currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{form.apply_vat &&
|
||||
Object.entries(totals.vatByRate).map(([rate, amount]) => (
|
||||
<Box
|
||||
key={rate}
|
||||
sx={{ display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
DPH {rate}%:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{formatCurrency(amount, form.currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -1406,7 +1288,7 @@ export default function IssuedOrderDetail() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
Celkem:
|
||||
Celkem bez DPH:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
|
||||
Reference in New Issue
Block a user