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) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 16:36:32 +02:00
parent 39565767ef
commit 20177e8f87
3 changed files with 86 additions and 19 deletions

View File

@@ -1211,15 +1211,19 @@ export default function InvoiceDetail() {
Zobrazit fakturu
</Button>
)}
{hasPermission("invoices.delete") && (
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}
{/* 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" && (
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}
</Box>
</Box>
</Box>
@@ -1778,15 +1782,20 @@ export default function InvoiceDetail() {
)}
</Button>
))}
{hasPermission("invoices.delete") && (
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}
{/* 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" && (
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}
</>
)}
</Box>

View File

@@ -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<void, unknown>({
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() {
)}
</Button>
))}
{/* 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" && (
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}
</Box>
</Box>
@@ -1280,6 +1318,21 @@ export default function IssuedOrderDetail() {
}
/>
)}
{/* Delete confirm */}
{isEdit && (
<ConfirmDialog
isOpen={deleteConfirm}
onClose={() => 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}
/>
)}
</PageEnter>
);
}

View File

@@ -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"}
</Button>
)}
{isEdit && hasPermission("offers.delete") && (
{canDelete && hasPermission("offers.delete") && (
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"