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:
@@ -5,6 +5,7 @@ import {
|
|||||||
createPlanCategory,
|
createPlanCategory,
|
||||||
updatePlanCategory,
|
updatePlanCategory,
|
||||||
deactivatePlanCategory,
|
deactivatePlanCategory,
|
||||||
|
deletePlanCategory,
|
||||||
isCategoryKeyActive,
|
isCategoryKeyActive,
|
||||||
slugifyCategory,
|
slugifyCategory,
|
||||||
} from "../services/planCategory.service";
|
} from "../services/planCategory.service";
|
||||||
@@ -83,4 +84,46 @@ describe("plan category service", () => {
|
|||||||
await deactivatePlanCategory(res.data.id);
|
await deactivatePlanCategory(res.data.id);
|
||||||
expect(await isCategoryKeyActive("test_deact_zz")).toBe(false);
|
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 } });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export default function PlanCategoriesModal({
|
|||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const [newLabel, setNewLabel] = useState("");
|
const [newLabel, setNewLabel] = useState("");
|
||||||
const [newColor, setNewColor] = useState(DEFAULT_NEW_COLOR);
|
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<
|
const createCat = useApiMutation<
|
||||||
{ label: string; color: string },
|
{ label: string; color: string },
|
||||||
@@ -37,6 +39,11 @@ export default function PlanCategoriesModal({
|
|||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
invalidate: ["plan", "dashboard", "audit-log"],
|
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 handleAdd = () => {
|
||||||
const label = newLabel.trim();
|
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 (
|
return (
|
||||||
<FormModal
|
<FormModal
|
||||||
@@ -110,14 +133,47 @@ export default function PlanCategoriesModal({
|
|||||||
}}
|
}}
|
||||||
aria-label={`Název – ${c.label}`}
|
aria-label={`Název – ${c.label}`}
|
||||||
/>
|
/>
|
||||||
<button
|
{confirmDeleteId === c.id ? (
|
||||||
type="button"
|
<>
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
<button
|
||||||
disabled={busy}
|
type="button"
|
||||||
onClick={() => patch(c.id, { is_active: !c.is_active })}
|
className="admin-btn admin-btn-danger admin-btn-sm"
|
||||||
>
|
disabled={busy}
|
||||||
{c.is_active ? "Skrýt" : "Obnovit"}
|
onClick={() => handleDelete(c.id)}
|
||||||
</button>
|
>
|
||||||
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -1011,3 +1011,13 @@
|
|||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
border-top: 1px dashed var(--plan-paper-rule);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
listPlanCategories,
|
listPlanCategories,
|
||||||
createPlanCategory,
|
createPlanCategory,
|
||||||
updatePlanCategory,
|
updatePlanCategory,
|
||||||
deactivatePlanCategory,
|
deletePlanCategory,
|
||||||
} from "../../services/planCategory.service";
|
} from "../../services/planCategory.service";
|
||||||
|
|
||||||
const MAX_RANGE_DAYS = 92;
|
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(
|
app.delete(
|
||||||
"/categories/:id",
|
"/categories/:id",
|
||||||
{ preHandler: requirePermission("attendance.manage") },
|
{ preHandler: requirePermission("attendance.manage") },
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const id = parseId((request.params as any).id, reply);
|
const id = parseId((request.params as any).id, reply);
|
||||||
if (id === null) return;
|
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);
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
@@ -221,9 +221,9 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
action: "delete",
|
action: "delete",
|
||||||
entityType: "plan_category",
|
entityType: "plan_category",
|
||||||
entityId: id,
|
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");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -119,3 +119,47 @@ export async function deactivatePlanCategory(
|
|||||||
if ("error" in res) return res;
|
if ("error" in res) return res;
|
||||||
return { data: { ok: true } };
|
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 } };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user