feat(plan): add hard-delete for categories (blocked when in use)

DELETE /plan/categories/:id now hard-deletes the row, guarded: refuses when any live entry/override uses the key (suggests hide instead) and keeps >=1 active category. Modal gains a per-row Smazat with inline confirm. Hiding stays via PATCH is_active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 01:12:40 +02:00
parent aef4ab92ec
commit 4d47897e27
5 changed files with 167 additions and 14 deletions

View File

@@ -20,6 +20,8 @@ export default function PlanCategoriesModal({
const alert = useAlert();
const [newLabel, setNewLabel] = useState("");
const [newColor, setNewColor] = useState(DEFAULT_NEW_COLOR);
// Id of the row showing the inline "really delete?" confirm, or null.
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const createCat = useApiMutation<
{ label: string; color: string },
@@ -37,6 +39,11 @@ export default function PlanCategoriesModal({
method: "PATCH",
invalidate: ["plan", "dashboard", "audit-log"],
});
const deleteCat = useApiMutation<{ id: number }, { ok: true }>({
url: (v) => `/api/admin/plan/categories/${v.id}`,
method: "DELETE",
invalidate: ["plan", "dashboard", "audit-log"],
});
const handleAdd = () => {
const label = newLabel.trim();
@@ -66,7 +73,23 @@ export default function PlanCategoriesModal({
);
};
const busy = createCat.isPending || updateCat.isPending;
const handleDelete = (id: number) => {
deleteCat.mutate(
{ id },
{
onSuccess: () => setConfirmDeleteId(null),
// Server blocks deleting a category that is still in use (409) — show
// the Czech message ("…můžete ji skrýt") and keep the row.
onError: (e) => {
alert.error(e.message);
setConfirmDeleteId(null);
},
},
);
};
const busy =
createCat.isPending || updateCat.isPending || deleteCat.isPending;
return (
<FormModal
@@ -110,14 +133,47 @@ export default function PlanCategoriesModal({
}}
aria-label={`Název ${c.label}`}
/>
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={busy}
onClick={() => patch(c.id, { is_active: !c.is_active })}
>
{c.is_active ? "Skrýt" : "Obnovit"}
</button>
{confirmDeleteId === c.id ? (
<>
<button
type="button"
className="admin-btn admin-btn-danger admin-btn-sm"
disabled={busy}
onClick={() => handleDelete(c.id)}
>
Opravdu smazat
</button>
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={busy}
onClick={() => setConfirmDeleteId(null)}
>
Zrušit
</button>
</>
) : (
<>
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={busy}
onClick={() => patch(c.id, { is_active: !c.is_active })}
>
{c.is_active ? "Skrýt" : "Obnovit"}
</button>
<button
type="button"
className="admin-btn admin-btn-danger admin-btn-sm plan-cat-delete"
disabled={busy}
onClick={() => setConfirmDeleteId(c.id)}
aria-label={`Smazat ${c.label}`}
title="Smazat kategorii"
>
Smazat
</button>
</>
)}
</div>
))}

View File

@@ -1011,3 +1011,13 @@
padding-top: 10px;
border-top: 1px dashed var(--plan-paper-rule);
}
/* The per-row delete trigger reads quieter than the solid danger confirm
button it turns into; keep it an outline until the user commits. */
.plan-cat-delete.admin-btn-danger {
background: transparent;
color: #dc2626;
border: 1px solid color-mix(in srgb, #dc2626 40%, transparent);
}
.plan-cat-delete.admin-btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, #dc2626 12%, transparent);
}