Reworks the T&C feature per review:
- categories are user-defined DATA, not a fixed enum — admins add new ones;
- ALL PO T&Cs are 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.
- schema: TermsCategory (name/sortOrder/isActive) + TermsCondition (categoryId
FK + text + isDefault + isActive). PurchaseOrder.terms Json snapshot. Migration
seeds every standard line as a clause (named slots, the two fixed lines under
General, empty Others); isDefault rows pre-fill new POs.
- admin /admin/terms: Add/Edit clause form's category is a combobox — typing a
new name creates the category; isDefault checkbox.
- PO editor components/po/po-terms-editor.tsx: dynamic rows (category + clause
comboboxes), used by new/edit/manager-edit forms; new POs pre-fill from
getDefaultPoTerms, edits load po.terms or legacyPoTerms (old tc* + fixed lines).
- storage: PurchaseOrder.terms ([{category,text}]) supersedes tc* for export +
detail; null on old POs falls back to tc* + fixed lines. parsePoTerms validates.
- export route + po-detail render from terms when present.
- 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 };
|
|
}
|