From 3babfe26ef91140851b4483c3a99d87fa0150019 Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 24 Jun 2026 04:08:30 +0530 Subject: [PATCH] feat(po): user-defined T&C categories + dynamic PO terms editor (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- App/CLAUDE.md | 7 +- App/app/(portal)/admin/terms/actions.ts | 45 ++++++-- App/app/(portal)/admin/terms/page.tsx | 14 ++- App/app/(portal)/admin/terms/terms-form.tsx | 38 ++++--- App/app/(portal)/admin/terms/terms-table.tsx | 35 +++--- .../approvals/[id]/manager-edit-po-form.tsx | 46 ++------ .../approvals/[id]/manager-po-edit-actions.ts | 6 ++ App/app/(portal)/approvals/[id]/page.tsx | 10 +- App/app/(portal)/po/[id]/edit/actions.ts | 7 ++ .../(portal)/po/[id]/edit/edit-po-form.tsx | 52 ++------- App/app/(portal)/po/[id]/edit/page.tsx | 10 +- App/app/(portal)/po/new/actions.ts | 7 ++ App/app/(portal)/po/new/new-po-form.tsx | 42 ++------ App/app/(portal)/po/new/page.tsx | 7 +- App/app/api/po/[id]/export/route.ts | 25 +++-- App/components/po/po-detail.tsx | 62 ++++++----- App/components/po/po-terms-editor.tsx | 100 ++++++++++++++++++ App/components/po/terms-field.tsx | 42 -------- App/lib/terms-data.ts | 32 ++++-- App/lib/terms.ts | 74 +++++++------ .../migration.sql | 66 ++++++++++++ App/prisma/schema.prisma | 50 +++++---- App/tests/integration/terms.test.ts | 72 ++++++++----- 23 files changed, 525 insertions(+), 324 deletions(-) create mode 100644 App/components/po/po-terms-editor.tsx delete mode 100644 App/components/po/terms-field.tsx create mode 100644 App/prisma/migrations/20260624150000_terms_categories_table/migration.sql 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.