diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 4213e2a..24ac5d3 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -106,7 +106,12 @@ The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) rende ### Terms & Conditions catalogue (issue #11) -Same admin-list-feeds-PO-dropdown pattern as Delivery Locations. `TermsCondition` (`category: TermsCategory` enum + `text` + `isActive`) is an admin-managed clause library, managed at `/admin/terms` (gated by **`manage_terms`** — Manager + SuperUser + Admin; CRUD mirrors `/admin/delivery-locations`). The migration **seeds** the prior `TC_DEFAULTS` wording as the starting clauses. The five **named** PO T&C slots (Delivery / Dispatch / Inspection / Transit Insurance / Payment Terms — the `tc*` columns, mapped via `lib/terms.ts` `TC_FIELD_CATEGORY`) become a shared `` **combobox** (native `` + ``) — type a one-off clause or pick a catalogued one — suggesting the active clauses of that category (`lib/terms-data.ts` `getActiveTermsByCategory`). **"Others" stays free text**, and the fixed boilerplate lines (`TC_FIXED_LINE` / `TC_FIXED_LINE_2`) are not catalogued. The `tc*` columns stay **free-text snapshots** (export/import unchanged); since the slot is a free-text combobox, any current/custom value is preserved as-is. No "work order" type — POs only (per the issue's steer). +Admin-managed T&C with **user-defined categories** (not a fixed set) feeding a **dynamic PO editor**. + +- **Models:** `TermsCategory` (`name` unique + `sortOrder` + `isActive`) and `TermsCondition` (`categoryId` FK + `text` + `isDefault` + `isActive` + `sortOrder`). Managed at `/admin/terms` (gated by **`manage_terms`** — Manager + SuperUser + Admin). The migration **seeds every standard PO T&C line** as a clause: the five named slots keep their wording, the previously-fixed boilerplate lines live under a **"General"** category, and an empty **"Others"** category is provided. `isDefault` clauses pre-fill new POs. +- **Admin** (`/admin/terms`): the Add/Edit clause form's category is a combobox — typing a new name **creates the category** ("add a new category along with the clause"). `isDefault` is a checkbox. +- **PO editor** (`components/po/po-terms-editor.tsx`, used by all three PO forms): a dynamic list — **"+ Add term"** appends a row; each row is a category combobox + a clause combobox (both `` so you can pick a catalogued value or type a one-off). New POs pre-fill from `getDefaultPoTerms()`; editing a PO loads `po.terms`, or (for pre-feature POs) `legacyPoTerms()` maps the old `tc*` columns + fixed lines onto rows. +- **Storage:** the chosen rows are a JSON **snapshot** on `PurchaseOrder.terms` (`[{ category, text }]`). It **supersedes** the legacy `tc*` columns for the export (`route.ts`) and PO detail; old POs with null `terms` still render from `tc*` + the fixed lines. `lib/terms.ts` `parsePoTerms` validates the JSON; `lib/terms-data.ts` exposes `getTermsCatalogue` / `getDefaultPoTerms`. No "work order" type — POs only (per the issue's steer). ### PO Numbering (`lib/po-number.ts`) diff --git a/App/app/(portal)/admin/terms/actions.ts b/App/app/(portal)/admin/terms/actions.ts index bf406ad..6fdce44 100644 --- a/App/app/(portal)/admin/terms/actions.ts +++ b/App/app/(portal)/admin/terms/actions.ts @@ -5,11 +5,12 @@ import { db } from "@/lib/db"; import { hasPermission } from "@/lib/permissions"; import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { TermsCategory } from "@prisma/client"; const schema = z.object({ - category: z.nativeEnum(TermsCategory), + // 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 }; @@ -22,14 +23,40 @@ async function guard(): Promise<{ ok: true } | { error: string }> { 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 { + 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 { const g = await guard(); if ("error" in g) return g; - const parsed = schema.safeParse(Object.fromEntries(formData)); + const parsed = parse(formData); if (!parsed.success) return { error: parsed.error.errors[0].message }; - await db.termsCondition.create({ data: { category: parsed.data.category, text: parsed.data.text } }); + 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 }; } @@ -38,10 +65,14 @@ export async function updateTerm(id: string, formData: FormData): Promise { const g = await guard(); if ("error" in g) return g; - // Safe to delete: POs keep their T&C as text snapshots, so no PO references this row. + // 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 }; diff --git a/App/app/(portal)/admin/terms/page.tsx b/App/app/(portal)/admin/terms/page.tsx index 5c73169..837effa 100644 --- a/App/app/(portal)/admin/terms/page.tsx +++ b/App/app/(portal)/admin/terms/page.tsx @@ -12,16 +12,22 @@ export default async function TermsPage() { if (!session?.user) redirect("/login"); if (!hasPermission(session.user.role, "manage_terms")) redirect("/dashboard"); - const terms = await db.termsCondition.findMany({ - orderBy: [{ category: "asc" }, { isActive: "desc" }, { createdAt: "asc" }], - }); + const [terms, categories] = await Promise.all([ + db.termsCondition.findMany({ + orderBy: [{ category: { sortOrder: "asc" } }, { isActive: "desc" }, { sortOrder: "asc" }, { createdAt: "asc" }], + include: { category: { select: { name: true } } }, + }), + db.termsCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { name: "asc" }], select: { name: true } }), + ]); return ( c.name)} terms={terms.map((t) => ({ id: t.id, - category: t.category, + categoryName: t.category.name, text: t.text, + isDefault: t.isDefault, isActive: t.isActive, }))} /> diff --git a/App/app/(portal)/admin/terms/terms-form.tsx b/App/app/(portal)/admin/terms/terms-form.tsx index aa73802..dccbece 100644 --- a/App/app/(portal)/admin/terms/terms-form.tsx +++ b/App/app/(portal)/admin/terms/terms-form.tsx @@ -2,41 +2,53 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import type { TermsCategory } from "@prisma/client"; import { AdminDialog } from "@/components/ui/admin-dialog"; -import { TERMS_CATEGORIES, TERMS_CATEGORY_LABEL } from "@/lib/terms"; import { createTerm, updateTerm } from "./actions"; const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"; export type TermRow = { id: string; - category: TermsCategory; + categoryName: string; text: string; + isDefault: boolean; isActive: boolean; }; -function Fields({ term }: { term?: TermRow }) { +function Fields({ term, categoryNames }: { term?: TermRow; categoryNames: string[] }) { return (
- + + {categoryNames.map((c) => ( + +

Type a new name to create a category.