diff --git a/src/__tests__/planCategory.test.ts b/src/__tests__/planCategory.test.ts index 483e91c..64d69d3 100644 --- a/src/__tests__/planCategory.test.ts +++ b/src/__tests__/planCategory.test.ts @@ -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 } }); + } + }); }); diff --git a/src/admin/components/PlanCategoriesModal.tsx b/src/admin/components/PlanCategoriesModal.tsx index cb25d48..cc53da0 100644 --- a/src/admin/components/PlanCategoriesModal.tsx +++ b/src/admin/components/PlanCategoriesModal.tsx @@ -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(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 ( - + {confirmDeleteId === c.id ? ( + <> + + + + ) : ( + <> + + + + )} ))} diff --git a/src/admin/plan.css b/src/admin/plan.css index b7800b7..2294816 100644 --- a/src/admin/plan.css +++ b/src/admin/plan.css @@ -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); +} diff --git a/src/routes/admin/plan.ts b/src/routes/admin/plan.ts index 62c6359..923579f 100644 --- a/src/routes/admin/plan.ts +++ b/src/routes/admin/plan.ts @@ -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"); }, ); diff --git a/src/services/planCategory.service.ts b/src/services/planCategory.service.ts index 193ac7b..a321a19 100644 --- a/src/services/planCategory.service.ts +++ b/src/services/planCategory.service.ts @@ -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> { + 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 } }; +}