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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user