Files
app/src/admin/pages/OrderDetail.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

811 lines
24 KiB
TypeScript

import { useState, useEffect, useMemo, useRef } from "react";
import DOMPurify from "dompurify";
import { useQuery } from "@tanstack/react-query";
import {
orderDetailOptions,
type OrderData,
type OrderItem,
type OrderSection,
type OrderInvoice,
type OrderProject,
} from "../lib/queries/orders";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import OrderConfirmationModal from "../components/OrderConfirmationModal";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
import { ORDER_STATUS, statusLabel, statusColor } from "../lib/documentStatus";
import {
Button,
Card,
Field,
TextField,
DataTable,
StatusChip,
CheckboxField,
ConfirmDialog,
EmptyState,
LoadingState,
PageEnter,
RichTextView,
headerActionsSx,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
type ItemRow = OrderItem & { _index: number };
const TRANSITION_LABELS: Record<string, string> = {
v_realizaci: "Zahájit realizaci",
dokoncena: "Dokončit",
};
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
const FileIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
export default function OrderDetail() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const orderQuery = useQuery(orderDetailOptions(id));
const order = orderQuery.data;
const loading = orderQuery.isPending;
const [notes, setNotes] = useState("");
const [saving, setSaving] = useState(false);
const [statusChanging, setStatusChanging] = useState<string | null>(null);
const [statusConfirm, setStatusConfirm] = useState<{
show: boolean;
status: string | null;
}>({ show: false, status: null });
const [attachmentLoading, setAttachmentLoading] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false);
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
const [confirmationLoading, setConfirmationLoading] = useState(false);
const initialNotesRef = useRef<string | null>(null);
const formInitializedRef = useRef(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
// Reset form sync when navigating to a different order
useEffect(() => {
formInitializedRef.current = false;
}, [id]);
// Sync order data to local form state on first load
useEffect(() => {
if (orderQuery.data && !formInitializedRef.current) {
const orderData = orderQuery.data!;
setNotes(orderData.notes || "");
initialNotesRef.current = orderData.notes || "";
formInitializedRef.current = true;
}
}, [orderQuery.data]);
// Navigate away on fetch error
useEffect(() => {
if (orderQuery.error) {
alert.error("Nepodařilo se načíst objednávku");
navigate("/orders");
}
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
const isDirty = useMemo(() => {
if (!initialNotesRef.current) return false;
return notes !== initialNotesRef.current;
}, [notes]);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
// NET only — received orders are not tax documents (no VAT on them).
const totals = useMemo(() => {
if (!order?.items) return { total: 0 };
const total = order.items.reduce((sum, item) => {
if (Number(item.is_included_in_total)) {
return (
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
);
}
return sum;
}, 0);
return { total };
}, [order]);
const statusMutation = useApiMutation<{ status: string }, unknown>({
url: () => `${API_BASE}/orders/${id}`,
method: () => "PUT",
invalidate: ["orders", "invoices"],
});
const notesMutation = useApiMutation<{ notes: string }, unknown>({
url: () => `${API_BASE}/orders/${id}`,
method: () => "PUT",
invalidate: ["orders", "invoices"],
});
const orderDeleteMutation = useApiMutation<
{ delete_files: boolean },
unknown
>({
url: () => `${API_BASE}/orders/${id}`,
method: () => "DELETE",
invalidate: ["orders", "invoices"],
});
if (!hasPermission("orders.view")) return <Forbidden />;
const handleStatusChange = async () => {
if (!statusConfirm.status) return;
const newStatus = statusConfirm.status;
setStatusChanging(newStatus);
setStatusConfirm({ show: false, status: null });
try {
await statusMutation.mutateAsync({ status: newStatus });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setStatusChanging(null);
}
};
const handleSaveNotes = async () => {
setSaving(true);
try {
await notesMutation.mutateAsync({ notes });
alert.success("Poznámky byly uloženy");
initialNotesRef.current = notes;
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
};
const handleViewAttachment = async () => {
const newWindow = window.open("", "_blank");
setAttachmentLoading(true);
try {
const response = await apiFetch(`${API_BASE}/orders/${id}/attachment`);
if (!response.ok) {
newWindow?.close();
alert.error("Nepodařilo se stáhnout přílohu");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
newWindow?.close();
alert.error("Chyba připojení");
} finally {
setAttachmentLoading(false);
}
};
const handleGenerateConfirmation = async (
lang: string,
customItems?: Array<{
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
}>,
) => {
setConfirmationLoading(true);
try {
const response = await apiFetch(
`${API_BASE}/orders-pdf/${id}/confirmation`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lang, items: customItems }),
},
);
if (!response.ok) {
const result = await response.json().catch(() => ({}));
alert.error(result.error || "Nepodařilo se vygenerovat PDF");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Potvrzeni-${order?.order_number || String(id)}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
alert.error("Chyba připojení");
} finally {
setConfirmationLoading(false);
}
};
const handleDelete = async () => {
setDeleting(true);
try {
await orderDeleteMutation.mutateAsync({ delete_files: deleteFiles });
alert.success("Objednávka byla smazána");
navigate("/orders");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
setDeleteConfirm(false);
}
};
if (loading) {
return <LoadingState />;
}
if (!order) return null;
const itemRows: ItemRow[] = (order.items ?? []).map((item, index) => ({
...item,
_index: index,
}));
const itemColumns: DataColumn<ItemRow>[] = [
{
key: "index",
header: "#",
width: "2.5rem",
render: (item) => (
<Box component="span" sx={{ color: "text.secondary", fontWeight: 500 }}>
{item._index + 1}
</Box>
),
},
{
key: "description",
header: "Popis",
render: (item) => (
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.description || "—"}
</Typography>
{item.item_description && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 0.25 }}
>
{item.item_description}
</Typography>
)}
</Box>
),
},
{
key: "quantity",
header: "Množství",
width: "5.5rem",
render: (item) => String(item.quantity),
},
{
key: "unit",
header: "Jednotka",
width: "5.5rem",
render: (item) => item.unit || "—",
},
{
key: "unit_price",
header: "Jedn. cena",
width: "8rem",
align: "right",
mono: true,
render: (item) => formatCurrency(item.unit_price, order.currency),
},
{
key: "is_included_in_total",
header: "V ceně",
width: "4rem",
render: (item) => (Number(item.is_included_in_total) ? "Ano" : "Ne"),
},
{
key: "total",
header: "Celkem",
width: "9rem",
align: "right",
mono: true,
bold: true,
render: (item) =>
formatCurrency(
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0),
order.currency,
),
},
];
return (
<PageEnter>
{/* Header */}
<Box>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Button
component={RouterLink}
to="/orders"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Typography variant="h4">
Objednávka {order.order_number}
</Typography>
<StatusChip
label={statusLabel(ORDER_STATUS, order.status)}
color={statusColor(ORDER_STATUS, order.status)}
/>
</Box>
</Box>
<Box sx={headerActionsSx}>
{order.invoice ? (
<Button
component={RouterLink}
to={`/invoices/${order.invoice.id}`}
variant="outlined"
color="inherit"
startIcon={FileIcon}
>
Faktura {order.invoice.invoice_number}
</Button>
) : (
hasPermission("invoices.create") &&
order.status === "dokoncena" && (
<Button
component={RouterLink}
to={`/invoices/new?fromOrder=${order.id}`}
variant="outlined"
color="inherit"
startIcon={FileIcon}
>
Vytvořit fakturu
</Button>
)
)}
{hasPermission("orders.view") && (
<Button
variant="outlined"
color="inherit"
startIcon={FileIcon}
onClick={() => setShowConfirmationModal(true)}
disabled={confirmationLoading}
>
Potvrzení objednávky
</Button>
)}
{hasPermission("orders.edit") &&
(order.valid_transitions?.filter((s) => s !== "stornovana")
.length ?? 0) > 0 &&
order
.valid_transitions!.filter((s) => s !== "stornovana")
.map((status) => (
<Button
key={status}
onClick={() => setStatusConfirm({ show: true, status })}
disabled={statusChanging === status}
>
{statusChanging === status
? "Zpracovávám…"
: TRANSITION_LABELS[status] || status}
</Button>
))}
{hasPermission("orders.delete") && (
<Button
variant="outlined"
color="error"
onClick={() => setDeleteConfirm(true)}
>
Smazat
</Button>
)}
</Box>
</Box>
</Box>
{/* Info card */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Informace
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Nabídka
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
<Box
component={RouterLink}
to={`/offers/${order.quotation_id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{order.quotation_number}
</Box>
{order.project_code && (
<Box component="span" sx={{ color: "text.secondary", ml: 1 }}>
({order.project_code})
</Box>
)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Projekt
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{order.project ? (
<Box
component={RouterLink}
to={`/projects/${order.project.id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{order.project.project_number} {order.project.name}
</Box>
) : (
"—"
)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Zákazník
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{order.customer_name || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo obj. zákazníka
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{order.customer_order_number || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Měna
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{order.currency}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Datum vytvoření
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(order.created_at)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Příloha
</Typography>
<Box sx={{ mt: 0.5 }}>
{order.attachment_name ? (
<Button
variant="outlined"
color="inherit"
startIcon={FileIcon}
onClick={handleViewAttachment}
disabled={attachmentLoading}
>
{order.attachment_name}
</Button>
) : (
<Typography variant="body2"></Typography>
)}
</Box>
</Box>
</Box>
</Card>
{/* Items (read-only) */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
<DataTable<ItemRow>
columns={itemColumns}
rows={itemRows}
rowKey={(item) => item.id ?? item._index}
empty={<EmptyState title="Žádné položky." />}
/>
{/* Totals (NET only — orders are not tax documents) */}
<Box
sx={{
mt: 2,
ml: "auto",
maxWidth: 360,
display: "flex",
flexDirection: "column",
gap: 0.5,
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
pt: 0.5,
mt: 0.5,
borderTop: 1,
borderColor: "divider",
}}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Celkem bez DPH:
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 700,
}}
>
{formatCurrency(totals.total, order.currency)}
</Typography>
</Box>
</Box>
</Card>
{/* Sections (read-only) */}
{order.sections?.length > 0 && (
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Rozsah projektu
</Typography>
{order.scope_title && (
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}>
{order.scope_title}
</Typography>
)}
{order.scope_description && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{order.scope_description}
</Typography>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{order.sections.map((section, index) => (
<Box
key={section.id || index}
sx={{
border: 1,
borderColor: "divider",
borderRadius: 2,
overflow: "hidden",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 2,
py: 1,
bgcolor: "action.hover",
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontWeight: 600 }}
>
{index + 1}.
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{(order.language === "CZ"
? section.title_cz || section.title
: section.title || section.title_cz) ||
`Sekce ${index + 1}`}
</Typography>
</Box>
{section.content && (
<RichTextView
sx={{ p: 2 }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(section.content),
}}
/>
)}
</Box>
))}
</Box>
</Card>
)}
{/* Notes (editable) */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Poznámky
</Typography>
<Field label="Poznámky">
<TextField
value={notes}
onChange={(e) => setNotes(e.target.value)}
multiline
minRows={4}
placeholder="Interní poznámky k objednávce..."
fullWidth
disabled={!hasPermission("orders.edit")}
/>
</Field>
{hasPermission("orders.edit") && (
<Box sx={{ mt: 1 }}>
<Button
variant="outlined"
color="inherit"
onClick={handleSaveNotes}
disabled={saving}
>
{saving ? "Ukládání..." : "Uložit poznámky"}
</Button>
</Box>
)}
</Card>
{/* Status change confirmation */}
<ConfirmDialog
isOpen={statusConfirm.show}
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav objednávky"
message={`Opravdu chcete změnit stav objednávky "${order.order_number}" na "${statusLabel(ORDER_STATUS, statusConfirm.status)}"?${statusConfirm.status === "dokoncena" ? " Projekt bude automaticky dokončen." : ""}`}
confirmText={
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
}
cancelText="Zrušit"
/>
{/* Delete confirmation */}
<ConfirmDialog
isOpen={deleteConfirm}
onClose={() => {
setDeleteConfirm(false);
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat objednávku"
message={`Opravdu chcete smazat objednávku „${order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`}
confirmText="Smazat"
confirmVariant="danger"
loading={deleting}
>
{order.project?.has_nas_folder && (
<CheckboxField
label="Smazat i soubory projektu na disku"
checked={deleteFiles}
onChange={setDeleteFiles}
/>
)}
</ConfirmDialog>
{/* Order confirmation PDF modal */}
{order && (
<OrderConfirmationModal
isOpen={showConfirmationModal}
onClose={() => setShowConfirmationModal(false)}
onGenerate={handleGenerateConfirmation}
initialItems={order.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: Number(it.is_included_in_total) !== 0,
}))}
orderNumber={order.order_number}
/>
)}
</PageEnter>
);
}