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

@@ -5,6 +5,7 @@ import {
createPlanCategory,
updatePlanCategory,
deactivatePlanCategory,
deletePlanCategory,
isCategoryKeyActive,
slugifyCategory,
} from "../services/planCategory.service";
@@ -83,4 +84,46 @@ describe("plan category service", () => {
await deactivatePlanCategory(res.data.id);
expect(await isCategoryKeyActive("test_deact_zz")).toBe(false);
});
it("hard-deletes an unused category", async () => {
const res = await createPlanCategory({
label: "Test Del ZZ",
color: "#dddddd",
});
if ("error" in res) throw new Error(res.error);
const del = await deletePlanCategory(res.data.id);
expect("data" in del).toBe(true);
const stillThere = await prisma.plan_categories.findUnique({
where: { id: res.data.id },
});
expect(stillThere).toBeNull();
});
it("refuses to hard-delete a category in use", async () => {
const res = await createPlanCategory({
label: "Test InUse ZZ",
color: "#eeeeee",
});
if ("error" in res) throw new Error(res.error);
created.push(res.data.id);
const user = await prisma.users.findFirst({ select: { id: true } });
if (!user) throw new Error("no user in test DB");
const entry = await prisma.work_plan_entries.create({
data: {
user_id: user.id,
date_from: new Date("2026-06-10T00:00:00.000Z"),
date_to: new Date("2026-06-10T00:00:00.000Z"),
category: res.data.key,
note: "test in-use",
created_by: user.id,
},
});
try {
const del = await deletePlanCategory(res.data.id);
expect("error" in del).toBe(true);
if ("error" in del) expect(del.status).toBe(409);
} finally {
await prisma.work_plan_entries.delete({ where: { id: entry.id } });
}
});
});

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);
}

View File

@@ -35,7 +35,7 @@ import {
listPlanCategories,
createPlanCategory,
updatePlanCategory,
deactivatePlanCategory,
deletePlanCategory,
} from "../../services/planCategory.service";
const MAX_RANGE_DAYS = 92;
@@ -206,14 +206,14 @@ export default async function planRoutes(app: FastifyInstance) {
},
);
// --- DELETE /plan/categories/:id (deactivate) ---
// --- DELETE /plan/categories/:id (hard delete; blocked if in use) ---
app.delete(
"/categories/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as any).id, reply);
if (id === null) return;
const result = await deactivatePlanCategory(id);
const result = await deletePlanCategory(id);
if ("error" in result) return error(reply, result.error, result.status);
await logAudit({
request,
@@ -221,9 +221,9 @@ export default async function planRoutes(app: FastifyInstance) {
action: "delete",
entityType: "plan_category",
entityId: id,
description: "Kategorie deaktivována",
description: "Kategorie smazána",
});
return success(reply, { ok: true }, 200, "Kategorie deaktivována");
return success(reply, { ok: true }, 200, "Kategorie smazána");
},
);

View File

@@ -119,3 +119,47 @@ export async function deactivatePlanCategory(
if ("error" in res) return res;
return { data: { ok: true } };
}
/**
* Hard-delete a category. Blocked when the category is still used by any live
* (non-soft-deleted) plan entry or override — those rows would otherwise lose
* their label/color — in which case the caller should hide it instead. Also
* refuses to remove the last active category so the create form is never empty.
*/
export async function deletePlanCategory(
id: number,
): Promise<CatResult<{ ok: true }>> {
const existing = await prisma.plan_categories.findUnique({ where: { id } });
if (!existing) return { error: "Kategorie nenalezena", status: 404 };
const [entryCount, overrideCount] = await Promise.all([
prisma.work_plan_entries.count({
where: { category: existing.key, is_deleted: false },
}),
prisma.work_plan_overrides.count({
where: { category: existing.key, is_deleted: false },
}),
]);
if (entryCount + overrideCount > 0) {
return {
error:
"Kategorie je používána v plánu a nelze ji smazat. Můžete ji skrýt.",
status: 409,
};
}
if (existing.is_active) {
const activeCount = await prisma.plan_categories.count({
where: { is_active: true },
});
if (activeCount <= 1) {
return {
error: "Musí zůstat alespoň jedna aktivní kategorie",
status: 400,
};
}
}
await prisma.plan_categories.delete({ where: { id } });
return { data: { ok: true } };
}