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>
166 lines
4.4 KiB
TypeScript
166 lines
4.4 KiB
TypeScript
import prisma from "../config/database";
|
|
|
|
export type CatResult<T> = { data: T } | { error: string; status: number };
|
|
|
|
/** ASCII slug: strip diacritics, lowercase, non-alphanumerics → underscore. */
|
|
export function slugifyCategory(label: string): string {
|
|
return label
|
|
.normalize("NFD")
|
|
.replace(/[̀-ͯ]/g, "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "_")
|
|
.replace(/^_+|_+$/g, "");
|
|
}
|
|
|
|
export async function listPlanCategories(includeInactive = true) {
|
|
return prisma.plan_categories.findMany({
|
|
where: includeInactive ? {} : { is_active: true },
|
|
orderBy: [{ sort_order: "asc" }, { id: "asc" }],
|
|
});
|
|
}
|
|
|
|
export async function isCategoryKeyActive(key: string): Promise<boolean> {
|
|
const cat = await prisma.plan_categories.findFirst({
|
|
where: { key, is_active: true },
|
|
select: { id: true },
|
|
});
|
|
return cat !== null;
|
|
}
|
|
|
|
async function uniqueKey(base: string): Promise<string> {
|
|
const root = base || "kategorie";
|
|
let candidate = root;
|
|
let n = 2;
|
|
while (
|
|
await prisma.plan_categories.findUnique({
|
|
where: { key: candidate },
|
|
select: { id: true },
|
|
})
|
|
) {
|
|
candidate = `${root}_${n++}`;
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
export async function createPlanCategory(input: {
|
|
label: string;
|
|
color: string;
|
|
}): Promise<
|
|
CatResult<{
|
|
id: number;
|
|
key: string;
|
|
label: string;
|
|
color: string;
|
|
sort_order: number;
|
|
is_active: boolean;
|
|
}>
|
|
> {
|
|
const key = await uniqueKey(slugifyCategory(input.label));
|
|
const max = await prisma.plan_categories.aggregate({
|
|
_max: { sort_order: true },
|
|
});
|
|
const sort_order = (max._max.sort_order ?? 0) + 1;
|
|
const data = await prisma.plan_categories.create({
|
|
data: {
|
|
key,
|
|
label: input.label,
|
|
color: input.color,
|
|
sort_order,
|
|
is_active: true,
|
|
},
|
|
});
|
|
return { data };
|
|
}
|
|
|
|
export async function updatePlanCategory(
|
|
id: number,
|
|
input: { label?: string; color?: string; is_active?: boolean },
|
|
): Promise<
|
|
CatResult<{
|
|
id: number;
|
|
key: string;
|
|
label: string;
|
|
color: string;
|
|
sort_order: number;
|
|
is_active: boolean;
|
|
}>
|
|
> {
|
|
const existing = await prisma.plan_categories.findUnique({ where: { id } });
|
|
if (!existing) return { error: "Kategorie nenalezena", status: 404 };
|
|
|
|
// Guard: never deactivate the last active category.
|
|
if (input.is_active === false && 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,
|
|
};
|
|
}
|
|
}
|
|
|
|
const data = await prisma.plan_categories.update({
|
|
where: { id },
|
|
data: {
|
|
label: input.label ?? undefined,
|
|
color: input.color ?? undefined,
|
|
is_active: input.is_active ?? undefined,
|
|
},
|
|
});
|
|
return { data };
|
|
}
|
|
|
|
export async function deactivatePlanCategory(
|
|
id: number,
|
|
): Promise<CatResult<{ ok: true }>> {
|
|
const res = await updatePlanCategory(id, { is_active: false });
|
|
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 } };
|
|
}
|