Files
app/src/admin/components/OrderConfirmationModal.tsx
BOHA 7398cad466 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>
2026-06-10 13:13:16 +02:00

403 lines
14 KiB
TypeScript

import { useState, useCallback, useEffect } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import { Modal, Button, TextField, Field } from "../ui";
import { useAlert } from "../context/AlertContext";
// Editable line-item. quantity/unit_price 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;
}
// 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;
unit_price: number;
is_included_in_total: boolean;
}
interface OrderConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onGenerate: (lang: string, items?: GeneratedItem[]) => Promise<void>;
initialItems: ConfirmationItem[];
orderNumber: string;
}
export default function OrderConfirmationModal({
isOpen,
onClose,
onGenerate,
initialItems,
orderNumber,
}: OrderConfirmationModalProps) {
const alert = useAlert();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [step, setStep] = useState<"choose" | "edit">("choose");
const [lang, setLang] = useState<string>("cs");
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
const [loading, setLoading] = useState(false);
// The modal is permanently mounted and merely toggled via `isOpen`, so its
// local state survives between opens and the `useState(initialItems)` snapshot
// goes stale. Re-seed everything from the current props each time the modal
// (re)opens so a fresh open always reflects the latest order data and starts
// on the "choose" step.
useEffect(() => {
if (!isOpen) return;
setStep("choose");
setLang("cs");
setItems(initialItems);
// initialItems are captured at open time; intentionally not in the dep
// array so an unrelated parent re-render doesn't clobber the user's edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const handleUseExisting = async () => {
setLoading(true);
try {
await onGenerate(lang, undefined);
// Only close on success — a generation error must keep the modal open.
onClose();
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally {
setLoading(false);
}
};
const handleEditGenerate = async () => {
setLoading(true);
try {
// 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,
}));
await onGenerate(lang, coercedItems);
// Only close on success — on error keep the user's edited items intact.
onClose();
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally {
setLoading(false);
}
};
const updateItem = useCallback(
(
index: number,
field: keyof ConfirmationItem,
value: string | number | boolean,
) => {
setItems((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
},
[],
);
const removeItem = useCallback((index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
}, []);
const addItem = useCallback(() => {
setItems((prev) => [
...prev,
{
description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
},
]);
}, []);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Potvrzení objednávky ${orderNumber}`}
maxWidth={step === "edit" ? "lg" : "md"}
loading={loading}
submitText={
step === "choose" ? "Použít položky z objednávky" : "Vygenerovat PDF"
}
submitDisabled={step === "edit" && items.length === 0}
onSubmit={step === "choose" ? handleUseExisting : handleEditGenerate}
>
{step === "choose" ? (
<Box>
<Field label="Jazyk dokumentu">
<Box sx={{ display: "flex", gap: 1 }}>
<Button
size="small"
variant={lang === "cs" ? "contained" : "outlined"}
color={lang === "cs" ? "primary" : "inherit"}
onClick={() => setLang("cs")}
>
Čeština
</Button>
<Button
size="small"
variant={lang === "en" ? "contained" : "outlined"}
color={lang === "en" ? "primary" : "inherit"}
onClick={() => setLang("en")}
>
English
</Button>
</Box>
</Field>
<Field label="Obsah potvrzení">
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Jak chcete připravit potvrzení objednávky?
</Typography>
<Button
fullWidth
onClick={() => {
setItems(initialItems.length > 0 ? initialItems : []);
setStep("edit");
}}
disabled={loading}
variant="outlined"
color="inherit"
>
Upravit položky
</Button>
</Field>
</Box>
) : (
<Box>
<Box sx={{ mb: 1.5 }}>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={() => setStep("choose")}
disabled={loading}
>
Zpět
</Button>
</Box>
{isMobile ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, i) => (
<Box
key={i}
sx={{
border: 1,
borderColor: "divider",
borderRadius: 2,
p: 1.5,
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography
variant="caption"
sx={{ color: "text.secondary", fontWeight: 700 }}
>
Položka {i + 1}
</Typography>
<Box sx={{ flex: 1 }} />
<IconButton
size="small"
color="error"
onClick={() => removeItem(i)}
title="Odstranit"
aria-label="Odstranit"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</IconButton>
</Box>
<TextField
label="Popis"
value={item.description}
onChange={(e) =>
updateItem(i, "description", e.target.value)
}
/>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 1,
}}
>
<TextField
label="Množství"
type="number"
value={item.quantity ?? ""}
onChange={(e) =>
updateItem(i, "quantity", e.target.value)
}
slotProps={{ htmlInput: { step: "0.001" } }}
/>
<TextField
label="Jednotka"
value={item.unit}
onChange={(e) => updateItem(i, "unit", e.target.value)}
/>
<TextField
label="Cena"
type="number"
value={item.unit_price ?? ""}
onChange={(e) =>
updateItem(i, "unit_price", e.target.value)
}
slotProps={{ htmlInput: { step: "0.01" } }}
/>
</Box>
</Box>
))}
</Box>
) : (
<Box sx={{ overflowX: "auto" }}>
<Box
component="table"
sx={{
width: "100%",
borderCollapse: "collapse",
"& th": {
textAlign: "left",
fontSize: ".7rem",
textTransform: "uppercase",
letterSpacing: ".05em",
fontWeight: 700,
color: "text.secondary",
pb: 1,
},
"& td": { py: 0.5, pr: 1, verticalAlign: "middle" },
}}
>
<thead>
<tr>
<th>Popis</th>
<th>Mn.</th>
<th>Jedn.</th>
<th>Cena</th>
<th style={{ width: "40px" }} />
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i}>
<td>
<TextField
value={item.description}
onChange={(e) =>
updateItem(i, "description", e.target.value)
}
sx={{ minWidth: 200 }}
/>
</td>
<td>
<TextField
type="number"
value={item.quantity ?? ""}
onChange={(e) =>
updateItem(i, "quantity", e.target.value)
}
sx={{ width: 112 }}
slotProps={{ htmlInput: { step: "0.001" } }}
/>
</td>
<td>
<TextField
value={item.unit}
onChange={(e) =>
updateItem(i, "unit", e.target.value)
}
sx={{ width: 70 }}
/>
</td>
<td>
<TextField
type="number"
value={item.unit_price ?? ""}
onChange={(e) =>
updateItem(i, "unit_price", e.target.value)
}
sx={{ width: 110 }}
slotProps={{ htmlInput: { step: "0.01" } }}
/>
</td>
<td>
<IconButton
size="small"
color="error"
onClick={() => removeItem(i)}
title="Odstranit"
aria-label="Odstranit"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</IconButton>
</td>
</tr>
))}
</tbody>
</Box>
</Box>
)}
<Box sx={{ mt: 1.5 }}>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={addItem}
>
+ Přidat položku
</Button>
</Box>
</Box>
)}
</Modal>
);
}