Follow-up to the merged #11 PR (which shipped the enum-based catalogue): make categories user-defined data and the PO T&C a dynamic editor. - categories are a TermsCategory TABLE (not an enum) — admins add new ones; - every PO T&C line is catalogued, incl. the previously-fixed boilerplate (seeded under a "General" category) and an "Others" bucket; - the PO form is a dynamic editor: "+ Add term", pick a category, type/pick a clause (components/po/po-terms-editor.tsx), used by new/edit/manager-edit. Migration: the already-released 20260624140000 migration is untouched; a new 20260624150000 FORWARD migration renames the enum, creates the table, migrates existing enum clauses onto category rows, adds isDefault/sortOrder + the two fixed lines under General, and adds PurchaseOrder.terms (JSON snapshot that supersedes the legacy tc* columns for export/detail; old POs fall back to tc*). Tests rewritten for category creation + catalogue/default helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
3.3 KiB
TypeScript
99 lines
3.3 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { hasPermission } from "@/lib/permissions";
|
|
import { revalidatePath } from "next/cache";
|
|
import { z } from "zod";
|
|
|
|
const schema = z.object({
|
|
// A category NAME — picked from the existing list or typed to create a new one.
|
|
categoryName: z.string().trim().min(1, "Category is required"),
|
|
text: z.string().trim().min(1, "Clause text is required"),
|
|
isDefault: z.boolean().default(false),
|
|
});
|
|
|
|
type Result = { ok: true } | { error: string };
|
|
|
|
async function guard(): Promise<{ ok: true } | { error: string }> {
|
|
const session = await auth();
|
|
if (!session?.user || !hasPermission(session.user.role, "manage_terms")) {
|
|
return { error: "Forbidden" };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
function parse(formData: FormData) {
|
|
return schema.safeParse({
|
|
categoryName: formData.get("categoryName"),
|
|
text: formData.get("text"),
|
|
isDefault: formData.get("isDefault") === "on" || formData.get("isDefault") === "true",
|
|
});
|
|
}
|
|
|
|
// Find a category by name (case-insensitive), creating it (appended to the end)
|
|
// if it doesn't exist — this is how new categories are added "along with clauses".
|
|
async function ensureCategory(name: string): Promise<string> {
|
|
const existing = await db.termsCategory.findFirst({
|
|
where: { name: { equals: name, mode: "insensitive" } },
|
|
select: { id: true },
|
|
});
|
|
if (existing) return existing.id;
|
|
const max = await db.termsCategory.aggregate({ _max: { sortOrder: true } });
|
|
const created = await db.termsCategory.create({
|
|
data: { name, sortOrder: (max._max.sortOrder ?? 0) + 1 },
|
|
});
|
|
return created.id;
|
|
}
|
|
|
|
export async function createTerm(formData: FormData): Promise<Result> {
|
|
const g = await guard();
|
|
if ("error" in g) return g;
|
|
|
|
const parsed = parse(formData);
|
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
|
|
|
const categoryId = await ensureCategory(parsed.data.categoryName);
|
|
await db.termsCondition.create({
|
|
data: { categoryId, text: parsed.data.text, isDefault: parsed.data.isDefault },
|
|
});
|
|
revalidatePath("/admin/terms");
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function updateTerm(id: string, formData: FormData): Promise<Result> {
|
|
const g = await guard();
|
|
if ("error" in g) return g;
|
|
|
|
const parsed = parse(formData);
|
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
|
|
|
const categoryId = await ensureCategory(parsed.data.categoryName);
|
|
await db.termsCondition.update({
|
|
where: { id },
|
|
data: { categoryId, text: parsed.data.text, isDefault: parsed.data.isDefault },
|
|
});
|
|
revalidatePath("/admin/terms");
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function toggleTermActive(id: string): Promise<Result> {
|
|
const g = await guard();
|
|
if ("error" in g) return g;
|
|
|
|
const term = await db.termsCondition.findUnique({ where: { id }, select: { isActive: true } });
|
|
if (!term) return { error: "Not found" };
|
|
await db.termsCondition.update({ where: { id }, data: { isActive: !term.isActive } });
|
|
revalidatePath("/admin/terms");
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function deleteTerm(id: string): Promise<Result> {
|
|
const g = await guard();
|
|
if ("error" in g) return g;
|
|
|
|
// Safe to delete: POs keep their T&C as a JSON snapshot, so no PO references this row.
|
|
await db.termsCondition.delete({ where: { id } });
|
|
revalidatePath("/admin/terms");
|
|
return { ok: true };
|
|
}
|