import prisma from "../config/database"; export type CatResult = { 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 { const cat = await prisma.plan_categories.findFirst({ where: { key, is_active: true }, select: { id: true }, }); return cat !== null; } async function uniqueKey(base: string): Promise { 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> { 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> { 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 } }; }