From 20177e8f87b8d7d55961824cb30426ce6168fb1f Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 9 Jun 2026 16:36:32 +0200 Subject: [PATCH] fix(ui): guard delete on paid invoices / ordered-or-locked offers; add guarded delete to issued orders Co-Authored-By: Claude Opus 4.8 (1M context) --- src/admin/pages/InvoiceDetail.tsx | 45 ++++++++++++++--------- src/admin/pages/IssuedOrderDetail.tsx | 53 +++++++++++++++++++++++++++ src/admin/pages/OfferDetail.tsx | 7 +++- 3 files changed, 86 insertions(+), 19 deletions(-) diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index 0c7adfa..6bad19f 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -1211,15 +1211,19 @@ export default function InvoiceDetail() { Zobrazit fakturu )} - {hasPermission("invoices.delete") && ( - - )} + {/* A paid invoice is a settled financial record — never deletable + from the UI. (Backend rejection of deleting a paid invoice is a + separate hardening, out of scope here.) */} + {hasPermission("invoices.delete") && + invoice.status !== "paid" && ( + + )} @@ -1778,15 +1782,20 @@ export default function InvoiceDetail() { )} ))} - {hasPermission("invoices.delete") && ( - - )} + {/* A paid invoice is a settled financial record — never + deletable from the UI. A paid invoice never actually reaches + this branch (it renders the read-only view above), but the + guard documents the invariant explicitly. */} + {hasPermission("invoices.delete") && + invoice.status !== "paid" && ( + + )} )} diff --git a/src/admin/pages/IssuedOrderDetail.tsx b/src/admin/pages/IssuedOrderDetail.tsx index b2c86ed..4f13b68 100644 --- a/src/admin/pages/IssuedOrderDetail.tsx +++ b/src/admin/pages/IssuedOrderDetail.tsx @@ -540,6 +540,8 @@ export default function IssuedOrderDetail() { status: string | null; }>({ show: false, status: null }); const [pdfLoading, setPdfLoading] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(false); + const [deleting, setDeleting] = useState(false); // ─── Queries ─── const customersQuery = useQuery(offerCustomersOptions()); @@ -654,6 +656,12 @@ export default function IssuedOrderDetail() { invalidate: ["issued-orders"], }); + const deleteMutation = useApiMutation({ + url: () => `${API_BASE}/issued-orders/${id}`, + method: () => "DELETE", + invalidate: ["issued-orders"], + }); + // ─── Item handlers ─── const updateItem = ( index: number, @@ -768,6 +776,21 @@ export default function IssuedOrderDetail() { } }; + // ─── Delete ─── + const handleDelete = async () => { + setDeleting(true); + try { + await deleteMutation.mutateAsync(undefined); + alert.success("Objednávka byla smazána"); + navigate("/orders?tab=vydane"); + } catch (err) { + alert.error(err instanceof Error ? err.message : "Chyba připojení"); + } finally { + setDeleting(false); + setDeleteConfirm(false); + } + }; + // ─── PDF export ─── const handleViewPdf = async () => { const newWindow = window.open("", "_blank"); @@ -901,6 +924,21 @@ export default function IssuedOrderDetail() { )} ))} + {/* A completed order is terminal/finalized — never deletable from the + UI. draft/sent/confirmed/cancelled may be deleted. (Backend + rejection of deleting an order is a separate hardening, out of + scope here.) */} + {isEdit && + hasPermission("orders.delete") && + form.status !== "completed" && ( + + )} @@ -1280,6 +1318,21 @@ export default function IssuedOrderDetail() { } /> )} + + {/* Delete confirm */} + {isEdit && ( + setDeleteConfirm(false)} + onConfirm={handleDelete} + title="Smazat objednávku?" + message={`Opravdu chcete smazat objednávku "${poNumber}"? Tato akce je nevratná.`} + confirmText="Smazat" + cancelText="Zrušit" + confirmVariant="danger" + loading={deleting} + /> + )} ); } diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index 0e393ae..3dc36c7 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -663,6 +663,11 @@ export default function OfferDetail() { const isLockedByOther = !!lockedBy; const readOnly = isInvalidated || isLockedByOther || isCompleted; const canInvalidate = isEdit && !isInvalidated && !isCompleted && !orderInfo; + // An offer is deletable only when it has NOT spawned an order (orderInfo — + // deleting would orphan the linked order), is NOT invalidated, and is NOT + // locked by another user. (Backend rejection of deleting an ordered offer is + // a separate hardening, out of scope here.) + const canDelete = isEdit && !orderInfo && !isInvalidated && !isLockedByOther; // Sync offer detail data to form state on first load (edit mode) const formInitializedRef = useRef(false); @@ -1190,7 +1195,7 @@ export default function OfferDetail() { {saving ? "Ukládání..." : "Uložit"} )} - {isEdit && hasPermission("offers.delete") && ( + {canDelete && hasPermission("offers.delete") && (