Merge pull request 'feat(po): user-defined T&C categories + dynamic PO terms editor (#11 follow-up)' (#107) from feat/terms-dynamic-editor into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s

Reviewed-on: #107
This commit is contained in:
shad0w 2026-06-23 23:27:47 +00:00
commit 85805754b5
23 changed files with 525 additions and 324 deletions

View file

@ -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 `<TermsField>` **combobox** (native `<input list>` + `<datalist>`) — 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 `<input list>` 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`)

View file

@ -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<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 = 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<Result
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.update({ where: { id }, data: { category: parsed.data.category, text: parsed.data.text } });
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 };
}
@ -61,7 +92,7 @@ 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 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 };

View file

@ -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 (
<TermsTable
categoryNames={categories.map((c) => 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,
}))}
/>

View file

@ -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 (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Category *</label>
<select name="category" defaultValue={term?.category ?? ""} required className={INPUT}>
<option value="" disabled>Select a category</option>
{TERMS_CATEGORIES.map((c) => (
<option key={c} value={c}>{TERMS_CATEGORY_LABEL[c]}</option>
<input
name="categoryName"
list="tc-category-list"
defaultValue={term?.categoryName ?? ""}
required
autoComplete="off"
className={INPUT}
placeholder="Pick a category or type a new one…"
/>
<datalist id="tc-category-list">
{categoryNames.map((c) => (
<option key={c} value={c} />
))}
</select>
</datalist>
<p className="mt-1 text-xs text-neutral-400">Type a new name to create a category.</p>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Clause text *</label>
<textarea name="text" defaultValue={term?.text ?? ""} rows={3} required className={INPUT} placeholder="e.g. Within 4 to 5 days" />
</div>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input type="checkbox" name="isDefault" defaultChecked={term?.isDefault ?? false} className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/30" />
Pre-add to new POs by default
</label>
</div>
);
}
export function AddTermButton() {
export function AddTermButton({ categoryNames }: { categoryNames: string[] }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
@ -56,7 +68,7 @@ export function AddTermButton() {
</button>
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add T&C Clause">
<form onSubmit={handleSubmit} className="space-y-4">
<Fields />
<Fields categoryNames={categoryNames} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
<div className="flex gap-3 justify-end">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
@ -70,10 +82,12 @@ export function AddTermButton() {
export function EditTermButton({
term,
categoryNames,
open: controlledOpen,
onOpenChange,
}: {
term: TermRow;
categoryNames: string[];
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) {
@ -96,7 +110,7 @@ export function EditTermButton({
return (
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit T&C Clause">
<form onSubmit={handleSubmit} className="space-y-4">
<Fields term={term} />
<Fields term={term} categoryNames={categoryNames} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>

View file

@ -6,13 +6,12 @@ import { TableControls, SortableTh } from "@/components/ui/table-controls";
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { TERMS_CATEGORY_LABEL } from "@/lib/terms";
import { AddTermButton, EditTermButton, type TermRow } from "./terms-form";
import { deleteTerm, toggleTermActive } from "./actions";
const CHIPS = ["Active", "Inactive"];
function TermActionsMenu({ term }: { term: TermRow }) {
function TermActionsMenu({ term, categoryNames }: { term: TermRow; categoryNames: string[] }) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false);
@ -28,7 +27,7 @@ function TermActionsMenu({ term }: { term: TermRow }) {
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu>
<EditTermButton term={term} open={editOpen} onOpenChange={setEditOpen} />
<EditTermButton term={term} categoryNames={categoryNames} open={editOpen} onOpenChange={setEditOpen} />
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
@ -41,8 +40,8 @@ function TermActionsMenu({ term }: { term: TermRow }) {
title={term.isActive ? "Deactivate clause?" : "Activate clause?"}
description={
term.isActive
? "It will no longer appear in the PO Terms & Conditions dropdowns."
: "It will appear in the PO Terms & Conditions dropdowns again."
? "It will no longer be suggested in the PO Terms & Conditions editor."
: "It will be suggested in the PO Terms & Conditions editor again."
}
confirmLabel={term.isActive ? "Deactivate" : "Activate"}
onConfirm={() => toggleTermActive(term.id)}
@ -51,12 +50,12 @@ function TermActionsMenu({ term }: { term: TermRow }) {
);
}
export function TermsTable({ terms }: { terms: TermRow[] }) {
export function TermsTable({ terms, categoryNames }: { terms: TermRow[]; categoryNames: string[] }) {
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
useTableControls<TermRow>({
rows: terms,
defaultSortKey: "category",
searchText: (t) => [TERMS_CATEGORY_LABEL[t.category], t.text, t.isActive ? "active" : "inactive"].join(" "),
defaultSortKey: "categoryName",
searchText: (t) => [t.categoryName, t.text, t.isActive ? "active" : "inactive"].join(" "),
chipMatch: (t, chip) => {
if (chip.toLowerCase() === "active") return t.isActive;
if (chip.toLowerCase() === "inactive") return !t.isActive;
@ -64,7 +63,7 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
},
sortValue: (t, key) => {
if (key === "isActive") return t.isActive ? "Active" : "Inactive";
if (key === "category") return TERMS_CATEGORY_LABEL[t.category];
if (key === "isDefault") return t.isDefault ? "Yes" : "No";
const val = t[key as keyof TermRow];
return typeof val === "string" || typeof val === "boolean" ? val : String(val ?? "");
},
@ -75,9 +74,9 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Terms &amp; Conditions</h1>
<p className="text-sm text-neutral-500 mt-0.5">Clauses that populate the PO Terms &amp; Conditions dropdowns</p>
<p className="text-sm text-neutral-500 mt-0.5">Categories &amp; clauses that populate the PO Terms &amp; Conditions editor</p>
</div>
<AddTermButton />
<AddTermButton categoryNames={categoryNames} />
</div>
<TableControls
@ -93,8 +92,9 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<SortableTh sortKey="category" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof TermRow)}>Category</SortableTh>
<SortableTh sortKey="categoryName" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof TermRow)}>Category</SortableTh>
<SortableTh sortKey="text" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof TermRow)}>Clause</SortableTh>
<SortableTh sortKey="isDefault" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof TermRow)}>Default</SortableTh>
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof TermRow)}>Status</SortableTh>
<th className="px-4 py-3 w-10"></th>
</tr>
@ -102,15 +102,18 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
<tbody className="divide-y divide-neutral-100">
{filtered.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-neutral-400">
No clauses yet. Add one to populate the PO Terms &amp; Conditions dropdowns.
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">
No clauses yet. Add one to populate the PO Terms &amp; Conditions editor.
</td>
</tr>
)}
{filtered.map((term) => (
<tr key={term.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900 whitespace-nowrap">{TERMS_CATEGORY_LABEL[term.category]}</td>
<td className="px-4 py-3 font-medium text-neutral-900 whitespace-nowrap">{term.categoryName}</td>
<td className="px-4 py-3 text-neutral-600 max-w-xl whitespace-pre-wrap">{term.text}</td>
<td className="px-4 py-3">
{term.isDefault ? <span className="text-xs font-medium text-primary-700">Default</span> : <span className="text-neutral-300"></span>}
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
term.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
@ -119,7 +122,7 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
</span>
</td>
<td className="px-4 py-3">
<TermActionsMenu term={term} />
<TermActionsMenu term={term} categoryNames={categoryNames} />
</td>
</tr>
))}

View file

@ -4,14 +4,13 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { managerEditPo } from "./manager-po-edit-actions";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
import type { LineItemInput } from "@/lib/validations/po";
import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { TermsField } from "@/components/po/terms-field";
import { TC_FIELD_CATEGORY, type TermsByCategory } from "@/lib/terms";
import { PoTermsEditor } from "@/components/po/po-terms-editor";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
type SerializedLineItem = {
id: string;
@ -43,7 +42,8 @@ interface Props {
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
termsByCategory: TermsByCategory;
termsCatalogue: CatalogueCategory[];
initialTerms: PoTerm[];
}
const INPUT =
@ -56,12 +56,13 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
}
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsByCategory }: Props) {
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, initialTerms }: Props) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
const extPo = po as typeof po & {
piQuotationNo?: string | null; piQuotationDate?: Date | null;
@ -103,6 +104,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice));
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18));
});
data.set("termsJson", JSON.stringify(terms));
const result = await managerEditPo(po.id, data);
if ("error" in result) {
@ -263,39 +265,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
{/* Terms & Conditions */}
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Terms &amp; Conditions</h3>
<div className="space-y-2.5">
<div className="rounded-lg bg-amber-100 border border-amber-200 px-3 py-2 text-xs text-amber-700 select-none">
<span className="font-semibold">1.</span> {TC_FIXED_LINE}
</div>
{([
{ n: 2, label: "Delivery", name: "tcDelivery", key: "tcDelivery" },
{ n: 3, label: "Dispatch Instructions", name: "tcDispatch", key: "tcDispatch" },
{ n: 4, label: "Inspection", name: "tcInspection", key: "tcInspection" },
{ n: 5, label: "Transit Insurance", name: "tcTransitInsurance", key: "tcTransitInsurance" },
{ n: 6, label: "Payment Terms", name: "tcPaymentTerms", key: "tcPaymentTerms" },
] as const).map(({ n, label, name, key }) => (
<div key={name} className="flex items-center gap-3">
<span className="w-5 shrink-0 text-xs font-semibold text-amber-700 text-right">{n}.</span>
<label className="w-44 shrink-0 text-xs font-semibold text-amber-800">{label}</label>
<TermsField
field={name}
options={termsByCategory[TC_FIELD_CATEGORY[name]] ?? []}
current={(extPo[key] ?? TC_DEFAULTS[key]) as string}
className={INPUT}
/>
</div>
))}
<div className="flex items-start gap-3">
<span className="w-5 shrink-0 text-xs font-semibold text-amber-700 text-right mt-2">7.</span>
<label className="w-44 shrink-0 text-xs font-semibold text-amber-800 mt-2">Others</label>
<textarea
name="tcOthers"
rows={2}
defaultValue={(extPo.tcOthers ?? TC_DEFAULTS.tcOthers) as string}
className={INPUT}
/>
</div>
</div>
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} accent="amber" />
</section>
{error && (

View file

@ -4,6 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { createPoSchema } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { revalidatePath } from "next/cache";
export async function managerEditPo(
@ -68,6 +69,10 @@ export async function managerEditPo(
}
const data = parsed.data;
let termsRaw: unknown = [];
try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ }
const terms = parsePoTerms(termsRaw);
const newTotal = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0
@ -130,6 +135,7 @@ export async function managerEditPo(
tcTransitInsurance: data.tcTransitInsurance ?? null,
tcPaymentTerms: data.tcPaymentTerms ?? null,
tcOthers: data.tcOthers ?? null,
terms,
totalAmount: newTotal,
lineItems: {
deleteMany: {},

View file

@ -7,7 +7,8 @@ import { PoDetail } from "@/components/po/po-detail";
import { ManagerEditPoForm } from "./manager-edit-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups";
import { formatDeliveryLocation } from "@/lib/delivery-location";
import { getActiveTermsByCategory } from "@/lib/terms-data";
import { getTermsCatalogue } from "@/lib/terms-data";
import { parsePoTerms, legacyPoTerms } from "@/lib/terms";
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import type { Metadata } from "next";
@ -62,7 +63,9 @@ export default async function ApprovalDetailPage({ params }: Props) {
const accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
const termsByCategory = await getActiveTermsByCategory();
const termsCatalogue = await getTermsCatalogue();
const savedTerms = parsePoTerms(po.terms);
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
const serializedPo = {
...po,
@ -104,7 +107,8 @@ export default async function ApprovalDetailPage({ params }: Props) {
vendors={vendors}
companies={companies}
deliveryOptions={deliveryOptions}
termsByCategory={termsByCategory}
termsCatalogue={termsCatalogue}
initialTerms={initialTerms}
/>
</div>

View file

@ -3,6 +3,7 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { createPoSchema } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
@ -71,6 +72,11 @@ export async function updatePo(
}
const data = parsed.data;
// Dynamic T&C rows (issue #11) — JSON snapshot superseding the tc* columns.
let termsRaw: unknown = [];
try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ }
const terms = parsePoTerms(termsRaw);
const total = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0
@ -156,6 +162,7 @@ export async function updatePo(
tcTransitInsurance: data.tcTransitInsurance ?? null,
tcPaymentTerms: data.tcPaymentTerms ?? null,
tcOthers: data.tcOthers ?? null,
terms,
totalAmount: total,
status: shouldSubmit ? "MGR_REVIEW" : "DRAFT",
submittedAt: shouldSubmit ? new Date() : po.submittedAt,

View file

@ -8,10 +8,9 @@ import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/p
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { TermsField } from "@/components/po/terms-field";
import { TC_FIELD_CATEGORY, type TermsByCategory } from "@/lib/terms";
import { PoTermsEditor } from "@/components/po/po-terms-editor";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
const INPUT_CLS =
"w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
@ -44,11 +43,12 @@ interface Props {
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
termsByCategory: TermsByCategory;
termsCatalogue: CatalogueCategory[];
initialTerms: PoTerm[];
managerNoteAuthor?: string | null;
}
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsByCategory, managerNoteAuthor }: Props) {
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, initialTerms, managerNoteAuthor }: Props) {
const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>(
po.lineItems.map((li) => ({
@ -67,6 +67,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
const hasPerLineAccounts = po.lineItems.some((li) => li.accountId);
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
const canSubmit = po.status === "DRAFT";
const canResubmit = po.status === "EDITS_REQUESTED";
@ -77,6 +78,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
const form = document.getElementById("edit-po-form") as HTMLFormElement;
const data = new FormData(form);
data.set("intent", intent);
data.set("termsJson", JSON.stringify(terms));
lineItems.forEach((item, i) => {
data.set(`lineItems[${i}].name`, item.name);
data.set(`lineItems[${i}].description`, item.description ?? "");
@ -265,43 +267,9 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
{/* Terms & Conditions */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Terms &amp; Conditions</h2>
<div className="space-y-3">
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
<span className="font-medium text-neutral-600">1.</span> {TC_FIXED_LINE}
</div>
{([
{ n: 2, label: "Delivery", name: "tcDelivery", key: "tcDelivery" },
{ n: 3, label: "Dispatch Instructions", name: "tcDispatch", key: "tcDispatch" },
{ n: 4, label: "Inspection", name: "tcInspection", key: "tcInspection" },
{ n: 5, label: "Transit Insurance", name: "tcTransitInsurance", key: "tcTransitInsurance" },
{ n: 6, label: "Payment Terms", name: "tcPaymentTerms", key: "tcPaymentTerms" },
] as const).map(({ n, label, name, key }) => (
<div key={name} className="flex items-center gap-3">
<span className="w-5 shrink-0 text-sm font-medium text-neutral-500 text-right">{n}.</span>
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700">{label}</label>
<TermsField
field={name}
options={termsByCategory[TC_FIELD_CATEGORY[name]] ?? []}
current={extPo[key] ?? TC_DEFAULTS[key]}
className={INPUT_CLS}
/>
</div>
))}
<div className="flex items-start gap-3">
<span className="w-5 shrink-0 text-sm font-medium text-neutral-500 text-right mt-2.5">7.</span>
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700 mt-2.5">Others</label>
<textarea
name="tcOthers"
rows={2}
defaultValue={extPo.tcOthers ?? ""}
className={INPUT_CLS}
/>
</div>
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
<span className="font-medium text-neutral-600">8.</span> {TC_FIXED_LINE_2}
</div>
</div>
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms &amp; Conditions</h2>
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause.</p>
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
</section>
{error && (

View file

@ -4,7 +4,8 @@ import { notFound, redirect } from "next/navigation";
import { EditPoForm } from "./edit-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups";
import { formatDeliveryLocation } from "@/lib/delivery-location";
import { getActiveTermsByCategory } from "@/lib/terms-data";
import { getTermsCatalogue } from "@/lib/terms-data";
import { parsePoTerms, legacyPoTerms } from "@/lib/terms";
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import type { Metadata } from "next";
@ -52,7 +53,9 @@ export default async function EditPoPage({ params }: Props) {
const accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
const termsByCategory = await getActiveTermsByCategory();
const termsCatalogue = await getTermsCatalogue();
const savedTerms = parsePoTerms(po.terms);
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
const serializedPo = {
...po,
@ -79,7 +82,8 @@ export default async function EditPoPage({ params }: Props) {
vendors={vendors}
companies={companies}
deliveryOptions={deliveryOptions}
termsByCategory={termsByCategory}
termsCatalogue={termsCatalogue}
initialTerms={initialTerms}
managerNoteAuthor={noteAction?.actor.name ?? null}
/>
</div>

View file

@ -4,6 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { requirePermission } from "@/lib/permissions";
import { createPoSchema } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { generatePoNumber } from "@/lib/po-number";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
@ -77,6 +78,11 @@ export async function createPo(
}
const data = parsed.data;
// Dynamic T&C rows (issue #11) — a JSON snapshot superseding the tc* columns.
let termsRaw: unknown = [];
try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ }
const terms = parsePoTerms(termsRaw);
// totalAmount = grand total including GST
const total = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
@ -108,6 +114,7 @@ export async function createPo(
tcTransitInsurance: data.tcTransitInsurance ?? null,
tcPaymentTerms: data.tcPaymentTerms ?? null,
tcOthers: data.tcOthers ?? null,
terms,
submitterId: session.user.id,
submittedAt: intent === "submit" ? new Date() : null,
lineItems: {

View file

@ -8,11 +8,10 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { FileUploader } from "@/components/po/file-uploader";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { TermsField } from "@/components/po/terms-field";
import { TC_FIELD_CATEGORY, type TermsByCategory } from "@/lib/terms";
import { PoTermsEditor } from "@/components/po/po-terms-editor";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import { uploadAndLinkFiles } from "@/lib/upload-files";
import type { LineItemInput } from "@/lib/validations/po";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
export type VesselOption = { id: string; code: string; name: string };
export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] };
@ -29,14 +28,15 @@ interface Props {
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
termsByCategory: TermsByCategory;
termsCatalogue: CatalogueCategory[];
defaultTerms: PoTerm[];
initialLineItems?: LineItemInput[];
initialVendorId?: string;
initialVesselId?: string;
initialCompanyId?: string;
}
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, termsByCategory, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
@ -47,6 +47,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
const [error, setError] = useState("");
const [multiAccount, setMultiAccount] = useState(false);
const [defaultAccountId, setDefaultAccountId] = useState("");
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
async function handleSubmit(intent: "draft" | "submit") {
setSubmitting(intent);
@ -54,6 +55,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
const form = document.getElementById("po-form") as HTMLFormElement;
const data = new FormData(form);
data.set("intent", intent);
data.set("termsJson", JSON.stringify(terms));
lineItems.forEach((item, i) => {
data.set(`lineItems[${i}].name`, item.name);
data.set(`lineItems[${i}].description`, item.description ?? "");
@ -245,33 +247,9 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
{/* Terms & Conditions */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Terms &amp; Conditions</h2>
<div className="space-y-3">
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
<span className="font-medium text-neutral-600">1.</span> {TC_FIXED_LINE}
</div>
{([
{ n: 2, label: "Delivery", name: "tcDelivery", key: "tcDelivery" },
{ n: 3, label: "Dispatch Instructions", name: "tcDispatch", key: "tcDispatch" },
{ n: 4, label: "Inspection", name: "tcInspection", key: "tcInspection" },
{ n: 5, label: "Transit Insurance", name: "tcTransitInsurance", key: "tcTransitInsurance" },
{ n: 6, label: "Payment Terms", name: "tcPaymentTerms", key: "tcPaymentTerms" },
] as const).map(({ n, label, name, key }) => (
<div key={name} className="flex items-center gap-3">
<span className="w-5 shrink-0 text-sm font-medium text-neutral-500 text-right">{n}.</span>
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700">{label}</label>
<TermsField field={name} options={termsByCategory[TC_FIELD_CATEGORY[name]] ?? []} current={TC_DEFAULTS[key]} className={INPUT_CLS} />
</div>
))}
<div className="flex items-start gap-3">
<span className="w-5 shrink-0 text-sm font-medium text-neutral-500 text-right mt-2.5">7.</span>
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700 mt-2.5">Others</label>
<textarea name="tcOthers" rows={2} defaultValue="" className={INPUT_CLS} />
</div>
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
<span className="font-medium text-neutral-600">8.</span> {TC_FIXED_LINE_2}
</div>
</div>
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms &amp; Conditions</h2>
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause. Manage the catalogue under Administration Terms &amp; Conditions.</p>
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
</section>
{/* Attachments */}

View file

@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
import { NewPoForm } from "./new-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups";
import { formatDeliveryLocation } from "@/lib/delivery-location";
import { getActiveTermsByCategory } from "@/lib/terms-data";
import { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data";
import type { Metadata } from "next";
import type { LineItemInput } from "@/lib/validations/po";
import type { CartItem } from "@/lib/cart";
@ -62,7 +62,7 @@ export default async function NewPoPage({ searchParams }: Props) {
const accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
const termsByCategory = await getActiveTermsByCategory();
const [termsCatalogue, defaultTerms] = await Promise.all([getTermsCatalogue(), getDefaultPoTerms()]);
return (
<div className="max-w-6xl">
@ -78,7 +78,8 @@ export default async function NewPoPage({ searchParams }: Props) {
vendors={vendors}
companies={companies}
deliveryOptions={deliveryOptions}
termsByCategory={termsByCategory}
termsCatalogue={termsCatalogue}
defaultTerms={defaultTerms}
initialLineItems={initialLineItems}
initialVendorId={initialVendorId}
initialVesselId={initialVesselId}

View file

@ -3,6 +3,7 @@ import { db } from "@/lib/db";
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { downloadBuffer } from "@/lib/storage";
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
import { getImageSize, scaleToBox } from "@/lib/image-size";
@ -182,15 +183,21 @@ export async function GET(request: NextRequest, { params }: Props) {
const reqDate = fmtDate(ext.requisitionDate);
const delivery = ext.placeOfDelivery ?? "";
const tcLines: [number, string, string][] = [
[1, "", TC_FIXED_LINE],
[2, "DELIVERY", ext.tcDelivery ?? TC_DEFAULTS.tcDelivery],
[3, "DISPATCH INSTRUCTIONS", ext.tcDispatch ?? TC_DEFAULTS.tcDispatch],
[4, "INSPECTION", ext.tcInspection ?? TC_DEFAULTS.tcInspection],
[5, "TRANSIT INSURANCE", ext.tcTransitInsurance ?? TC_DEFAULTS.tcTransitInsurance],
[6, "PAYMENT TERMS", ext.tcPaymentTerms ?? TC_DEFAULTS.tcPaymentTerms],
...(ext.tcOthers ? [[7, "OTHERS", ext.tcOthers] as [number, string, string]] : []),
];
// T&C (issue #11): prefer the dynamic snapshot (po.terms) when present; older
// POs fall back to the legacy tc* columns + the fixed boilerplate lines.
const dynamicTerms = parsePoTerms((po as { terms?: unknown }).terms);
const tcLines: [number, string, string][] =
dynamicTerms.length > 0
? dynamicTerms.map((t, i) => [i + 1, (t.category || "").toUpperCase(), t.text] as [number, string, string])
: [
[1, "", TC_FIXED_LINE],
[2, "DELIVERY", ext.tcDelivery ?? TC_DEFAULTS.tcDelivery],
[3, "DISPATCH INSTRUCTIONS", ext.tcDispatch ?? TC_DEFAULTS.tcDispatch],
[4, "INSPECTION", ext.tcInspection ?? TC_DEFAULTS.tcInspection],
[5, "TRANSIT INSURANCE", ext.tcTransitInsurance ?? TC_DEFAULTS.tcTransitInsurance],
[6, "PAYMENT TERMS", ext.tcPaymentTerms ?? TC_DEFAULTS.tcPaymentTerms],
...(ext.tcOthers ? [[7, "OTHERS", ext.tcOthers] as [number, string, string]] : []),
];
const vendorAddr = [
po.vendor?.address,

View file

@ -9,6 +9,7 @@ import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
import { generateDownloadUrl } from "@/lib/storage";
import { groupAttachments } from "@/lib/attachments";
import { TC_FIXED_LINE } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po";
import type { Role } from "@prisma/client";
@ -38,6 +39,7 @@ type PoWithRelations = {
tcTransitInsurance?: string | null;
tcPaymentTerms?: string | null;
tcOthers?: string | null;
terms?: import("@prisma/client").Prisma.JsonValue;
createdAt: Date;
submittedAt: Date | null;
approvedAt: Date | null;
@ -459,31 +461,41 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
/>
</div>
{/* Terms & Conditions */}
{(po.tcDelivery || po.tcDispatch || po.tcInspection || po.tcTransitInsurance || po.tcPaymentTerms || po.tcOthers) && (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Terms &amp; Conditions</h3>
<ol className="space-y-1.5 text-sm text-neutral-700" style={{ listStyle: "none", padding: 0 }}>
<li className="flex gap-2">
<span className="shrink-0 font-medium text-neutral-500">1.</span>
<span>{TC_FIXED_LINE}</span>
</li>
{([
{ n: 2, label: "DELIVERY", value: po.tcDelivery },
{ n: 3, label: "DISPATCH INSTRUCTIONS", value: po.tcDispatch },
{ n: 4, label: "INSPECTION", value: po.tcInspection },
{ n: 5, label: "TRANSIT INSURANCE", value: po.tcTransitInsurance },
{ n: 6, label: "PAYMENT TERMS", value: po.tcPaymentTerms },
{ n: 7, label: "OTHERS", value: po.tcOthers },
] as const).filter(({ value }) => value).map(({ n, label, value }) => (
<li key={n} className="flex gap-2">
<span className="shrink-0 font-medium text-neutral-500">{n}.</span>
<span><span className="font-medium">{label}:</span> {value}</span>
</li>
))}
</ol>
</div>
)}
{/* Terms & Conditions (issue #11): dynamic snapshot when present, else legacy tc* + fixed line. */}
{(() => {
const saved = parsePoTerms(po.terms);
const rows: { label: string; text: string }[] =
saved.length > 0
? saved.map((t) => ({ label: (t.category || "").toUpperCase(), text: t.text }))
: [
{ label: "", text: TC_FIXED_LINE },
...([
["DELIVERY", po.tcDelivery],
["DISPATCH INSTRUCTIONS", po.tcDispatch],
["INSPECTION", po.tcInspection],
["TRANSIT INSURANCE", po.tcTransitInsurance],
["PAYMENT TERMS", po.tcPaymentTerms],
["OTHERS", po.tcOthers],
] as const)
.filter(([, value]) => value)
.map(([label, value]) => ({ label, text: value as string })),
];
// Only the fixed line and nothing else → treat as "no T&C" (legacy empty PO).
if (saved.length === 0 && rows.length <= 1) return null;
return (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Terms &amp; Conditions</h3>
<ol className="space-y-1.5 text-sm text-neutral-700" style={{ listStyle: "none", padding: 0 }}>
{rows.map((r, i) => (
<li key={i} className="flex gap-2">
<span className="shrink-0 font-medium text-neutral-500">{i + 1}.</span>
<span>{r.label ? <span className="font-medium">{r.label}: </span> : null}{r.text}</span>
</li>
))}
</ol>
</div>
);
})()}
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
{attachmentGroups.length > 0 && (

View file

@ -0,0 +1,100 @@
"use client";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
/**
* Dynamic PO Terms & Conditions editor (issue #11). A list of rows, each a
* category + a clause; "+ Add term" appends a row. Both fields are comboboxes
* (native <input list>) so you can pick a catalogued category/clause or type a
* new one-off value. Controlled by the parent form, which serialises `value`
* into the submitted FormData (`termsJson`).
*/
export function PoTermsEditor({
value,
onChange,
catalogue,
accent = "neutral",
}: {
value: PoTerm[];
onChange: (v: PoTerm[]) => void;
catalogue: CatalogueCategory[];
// The manager-edit form uses an amber theme; everything else neutral.
accent?: "neutral" | "amber";
}) {
const input =
accent === "amber"
? "w-full rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-400/30"
: "w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
function update(i: number, patch: Partial<PoTerm>) {
onChange(value.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
}
function remove(i: number) {
onChange(value.filter((_, idx) => idx !== i));
}
function add() {
onChange([...value, { category: "", text: "" }]);
}
const clausesFor = (categoryName: string) =>
catalogue.find((c) => c.name.toLowerCase() === categoryName.trim().toLowerCase())?.clauses ?? [];
return (
<div className="space-y-2">
{/* Shared category suggestions */}
<datalist id="po-terms-categories">
{catalogue.map((c) => (
<option key={c.id} value={c.name} />
))}
</datalist>
{value.length === 0 && (
<p className="text-sm text-neutral-400">No terms added. Use + Add term below.</p>
)}
{value.map((row, i) => (
<div key={i} className="flex flex-col gap-2 sm:flex-row sm:items-start">
<input
aria-label="Category"
list="po-terms-categories"
value={row.category}
onChange={(e) => update(i, { category: e.target.value })}
placeholder="Category"
autoComplete="off"
className={`${input} sm:w-56`}
/>
<input
aria-label="Clause"
list={`po-terms-clauses-${i}`}
value={row.text}
onChange={(e) => update(i, { text: e.target.value })}
placeholder="Type a clause or pick one…"
autoComplete="off"
className={input}
/>
<datalist id={`po-terms-clauses-${i}`}>
{clausesFor(row.category).map((c) => (
<option key={c} value={c} />
))}
</datalist>
<button
type="button"
onClick={() => remove(i)}
aria-label="Remove term"
className="shrink-0 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-500 hover:bg-neutral-50"
>
</button>
</div>
))}
<button
type="button"
onClick={add}
className="mt-1 rounded-lg border border-dashed border-neutral-300 px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
>
+ Add term
</button>
</div>
);
}

View file

@ -1,42 +0,0 @@
"use client";
/**
* A single PO Terms & Conditions slot (issue #11) a combobox: type a one-off
* clause OR pick a catalogued one. Implemented as a native <input list> +
* <datalist> so it stays free-text (custom wording per PO) while suggesting the
* admin-managed clauses for this category, and submits via plain FormData.
*
* `options` are the active clause texts (suggestions). `current` is the PO's
* existing/default value for this slot; it's just the input's initial value, so
* a value not in the catalogue is preserved as-is.
*/
export function TermsField({
field,
options,
current,
className,
}: {
field: string;
options: string[];
current?: string | null;
className?: string;
}) {
const listId = `terms-list-${field}`;
return (
<>
<input
name={field}
list={listId}
defaultValue={current ?? ""}
autoComplete="off"
placeholder="Type a clause or pick one…"
className={className}
/>
<datalist id={listId}>
{options.map((o) => (
<option key={o} value={o} />
))}
</datalist>
</>
);
}

View file

@ -1,14 +1,28 @@
import { db } from "@/lib/db";
import type { TermsByCategory } from "@/lib/terms";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
/** Active T&C clause texts grouped by category, for the PO form dropdowns (#11). */
export async function getActiveTermsByCategory(): Promise<TermsByCategory> {
const rows = await db.termsCondition.findMany({
/** Active categories (ordered) each with their active clause texts — for the PO T&C editor (#11). */
export async function getTermsCatalogue(): Promise<CatalogueCategory[]> {
const cats = await db.termsCategory.findMany({
where: { isActive: true },
orderBy: [{ category: "asc" }, { createdAt: "asc" }],
select: { category: true, text: true },
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
include: {
clauses: {
where: { isActive: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: { text: true },
},
},
});
const map: TermsByCategory = {};
for (const r of rows) (map[r.category] ??= []).push(r.text);
return map;
return cats.map((c) => ({ id: c.id, name: c.name, clauses: c.clauses.map((x) => x.text) }));
}
/** The default T&C set pre-filled on a NEW PO — every active isDefault clause, ordered. */
export async function getDefaultPoTerms(): Promise<PoTerm[]> {
const rows = await db.termsCondition.findMany({
where: { isDefault: true, isActive: true, category: { isActive: true } },
orderBy: [{ category: { sortOrder: "asc" } }, { sortOrder: "asc" }],
select: { text: true, category: { select: { name: true } } },
});
return rows.map((r) => ({ category: r.category.name, text: r.text }));
}

View file

@ -1,36 +1,50 @@
/**
* Terms & Conditions catalogue (issue #11) admin-managed clauses that populate
* the PO's named T&C dropdowns. Each clause has a category matching one of the
* PO's tc* slots; the chosen clause is stored as a text snapshot in that column.
* Terms & Conditions catalogue (issue #11) admin-managed categories + clauses
* that feed the PO's dynamic T&C editor. Categories are user-defined data.
*/
import type { TermsCategory } from "@prisma/client";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
// The five catalogued slots (the PO's "Others" stays free text; the fixed
// boilerplate lines are not catalogued). Order = display order.
export const TERMS_CATEGORIES: TermsCategory[] = [
"DELIVERY",
"DISPATCH",
"INSPECTION",
"TRANSIT_INSURANCE",
"PAYMENT_TERMS",
];
// One chosen T&C row on a PO (stored as a JSON snapshot in PurchaseOrder.terms).
export type PoTerm = { category: string; text: string };
export const TERMS_CATEGORY_LABEL: Record<TermsCategory, string> = {
DELIVERY: "Delivery",
DISPATCH: "Dispatch Instructions",
INSPECTION: "Inspection",
TRANSIT_INSURANCE: "Transit Insurance",
PAYMENT_TERMS: "Payment Terms",
// A catalogue category with its active clause texts — passed to the PO editor.
export type CatalogueCategory = { id: string; name: string; clauses: string[] };
// Legacy PO (no `terms` JSON yet) → editable rows, mapping the old tc* columns +
// the previously-fixed boilerplate lines onto the new category model, in the
// original document order. Used to seed the editor when editing an old PO.
type LegacyTc = {
tcDelivery?: string | null;
tcDispatch?: string | null;
tcInspection?: string | null;
tcTransitInsurance?: string | null;
tcPaymentTerms?: string | null;
tcOthers?: string | null;
};
export function legacyPoTerms(po: LegacyTc): PoTerm[] {
const rows: PoTerm[] = [
{ category: "General", text: TC_FIXED_LINE },
{ category: "Delivery", text: po.tcDelivery ?? TC_DEFAULTS.tcDelivery },
{ category: "Dispatch Instructions", text: po.tcDispatch ?? TC_DEFAULTS.tcDispatch },
{ category: "Inspection", text: po.tcInspection ?? TC_DEFAULTS.tcInspection },
{ category: "Transit Insurance", text: po.tcTransitInsurance ?? TC_DEFAULTS.tcTransitInsurance },
{ category: "Payment Terms", text: po.tcPaymentTerms ?? TC_DEFAULTS.tcPaymentTerms },
{ category: "Others", text: po.tcOthers ?? "" },
{ category: "General", text: TC_FIXED_LINE_2 },
];
return rows.filter((r) => r.text.trim().length > 0);
}
// PO tc* form field ⇄ catalogue category.
export const TC_FIELD_CATEGORY: Record<string, TermsCategory> = {
tcDelivery: "DELIVERY",
tcDispatch: "DISPATCH",
tcInspection: "INSPECTION",
tcTransitInsurance: "TRANSIT_INSURANCE",
tcPaymentTerms: "PAYMENT_TERMS",
};
// Server → client shape: active clause texts grouped by category.
export type TermsByCategory = Partial<Record<TermsCategory, string[]>>;
/** Coerce an unknown (DB JSON / parsed form value) into a clean PoTerm[]. */
export function parsePoTerms(value: unknown): PoTerm[] {
if (!Array.isArray(value)) return [];
const out: PoTerm[] = [];
for (const row of value) {
if (!row || typeof row !== "object") continue;
const category = String((row as Record<string, unknown>).category ?? "").trim();
const text = String((row as Record<string, unknown>).text ?? "").trim();
// A row needs at least some text to be meaningful; category may be blank.
if (text) out.push({ category, text });
}
return out;
}

View file

@ -0,0 +1,66 @@
-- Rework Terms & Conditions (issue #11 follow-up): the fixed TermsCategory ENUM
-- becomes a user-defined TermsCategory TABLE; clauses gain isDefault/sortOrder;
-- PurchaseOrder gains a JSON `terms` snapshot. Existing enum-based clauses are
-- migrated onto the new category rows. Forward migration (the original
-- 20260624140000 migration is already released and stays untouched).
-- Free the "TermsCategory" name (a table and an enum type cannot coexist).
ALTER TYPE "TermsCategory" RENAME TO "TermsCategory_old";
-- New category table.
CREATE TABLE "TermsCategory" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TermsCategory_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "TermsCategory_name_key" ON "TermsCategory"("name");
INSERT INTO "TermsCategory" ("id", "name", "sortOrder", "updatedAt") VALUES
('tcat_general', 'General', 0, CURRENT_TIMESTAMP),
('tcat_delivery', 'Delivery', 1, CURRENT_TIMESTAMP),
('tcat_dispatch', 'Dispatch Instructions', 2, CURRENT_TIMESTAMP),
('tcat_inspect', 'Inspection', 3, CURRENT_TIMESTAMP),
('tcat_transit', 'Transit Insurance', 4, CURRENT_TIMESTAMP),
('tcat_payment', 'Payment Terms', 5, CURRENT_TIMESTAMP),
('tcat_others', 'Others', 6, CURRENT_TIMESTAMP);
-- New clause columns.
ALTER TABLE "TermsCondition" ADD COLUMN "categoryId" TEXT;
ALTER TABLE "TermsCondition" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "TermsCondition" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
-- Migrate existing clauses from the enum onto category rows.
UPDATE "TermsCondition" SET "categoryId" = CASE "category"::text
WHEN 'DELIVERY' THEN 'tcat_delivery'
WHEN 'DISPATCH' THEN 'tcat_dispatch'
WHEN 'INSPECTION' THEN 'tcat_inspect'
WHEN 'TRANSIT_INSURANCE' THEN 'tcat_transit'
WHEN 'PAYMENT_TERMS' THEN 'tcat_payment'
END;
-- The original seed clauses become the PO defaults.
UPDATE "TermsCondition" SET "isDefault" = true
WHERE "id" IN ('tcseed_delivery','tcseed_dispatch','tcseed_inspect','tcseed_transit','tcseed_payment');
-- Drop the old enum column + type now that data is migrated.
DROP INDEX "TermsCondition_category_idx";
ALTER TABLE "TermsCondition" DROP COLUMN "category";
DROP TYPE "TermsCategory_old";
-- Enforce the relation.
ALTER TABLE "TermsCondition" ALTER COLUMN "categoryId" SET NOT NULL;
CREATE INDEX "TermsCondition_categoryId_idx" ON "TermsCondition"("categoryId");
ALTER TABLE "TermsCondition" ADD CONSTRAINT "TermsCondition_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TermsCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Seed the previously-fixed boilerplate lines as default "General" clauses.
INSERT INTO "TermsCondition" ("id", "categoryId", "text", "isDefault", "sortOrder", "updatedAt") VALUES
('tcc_fixed1', 'tcat_general', 'Please quote this purchase order no. for further communications and invoices pertaining to this indent.', true, 0, CURRENT_TIMESTAMP),
('tcc_fixed2', 'tcat_general', 'We encourage bulk packaging and avoid plastic. No asbestos to be used in any product or packing material.', true, 1, CURRENT_TIMESTAMP);
-- Dynamic T&C snapshot on the PO.
ALTER TABLE "PurchaseOrder" ADD COLUMN "terms" JSONB;

View file

@ -410,30 +410,36 @@ model DeliveryLocation {
@@index([companyId])
}
// Admin-managed Terms & Conditions clauses (issue #11). Each clause belongs to a
// category matching one of the PO's named T&C slots; the PO form turns those slots
// into dropdowns sourced from the active clauses of that category. The PO keeps
// the chosen clause as a text snapshot in its tc* columns (point-in-time
// document), so editing/removing a clause never rewrites historical POs. Managed
// by manage_terms. ("Others" stays free text; the fixed boilerplate lines —
// TC_FIXED_LINE / TC_FIXED_LINE_2 — are not catalogued.)
enum TermsCategory {
DELIVERY
DISPATCH
INSPECTION
TRANSIT_INSURANCE
PAYMENT_TERMS
// Admin-managed Terms & Conditions catalogue (issue #11). Categories are
// user-defined data (not a fixed set) — admins add new ones — and every PO T&C
// line is a catalogued clause, including the standard "fixed" lines (seeded under
// a "General" category) and the "Others" bucket. The PO form is a dynamic editor:
// add rows, pick a category, type/pick a clause. The chosen rows are stored as a
// JSON snapshot on PurchaseOrder.terms, so editing/removing a clause never
// rewrites historical POs. Managed by manage_terms.
model TermsCategory {
id String @id @default(cuid())
name String @unique
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clauses TermsCondition[]
}
model TermsCondition {
id String @id @default(cuid())
category TermsCategory
text String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
categoryId String
category TermsCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
text String
// Pre-added to a new PO's default T&C set (reproduces the old standard wording).
isDefault Boolean @default(false)
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
@@index([categoryId])
}
model Account {
@ -578,6 +584,10 @@ model PurchaseOrder {
tcTransitInsurance String?
tcPaymentTerms String?
tcOthers String?
// Dynamic T&C snapshot (issue #11): [{ category, text }] chosen on the PO form.
// When present it supersedes the legacy tc* columns for display/export; null on
// pre-feature POs (which still render from tc* + the fixed boilerplate lines).
terms Json?
poDate DateTime?
submittedAt DateTime?
approvedAt DateTime?

View file

@ -1,7 +1,8 @@
/**
* Integration tests for the Terms & Conditions admin CRUD (issue #11).
* Covers create/update/toggle/delete + the manage_terms guard, and the
* grouping helper used to feed the PO T&C dropdowns.
* Categories are user-defined data: adding a clause under a new category name
* creates the category. Covers CRUD + the manage_terms guard + the catalogue /
* default-terms helpers that feed the PO editor.
*/
import { vi, describe, it, expect, afterAll } from "vitest";
@ -16,7 +17,7 @@ import {
toggleTermActive,
deleteTerm,
} from "@/app/(portal)/admin/terms/actions";
import { getActiveTermsByCategory } from "@/lib/terms-data";
import { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data";
import { makeSession, fd } from "./helpers";
const mockedAuth = vi.mocked(auth);
@ -25,43 +26,51 @@ const asManager = () => mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAG
afterAll(async () => {
await db.termsCondition.deleteMany({ where: { text: { startsWith: PREFIX } } });
await db.termsCategory.deleteMany({ where: { name: { startsWith: PREFIX } } });
});
describe("createTerm", () => {
it("persists a clause under its category", async () => {
it("creates a new category on the fly and the clause under it", async () => {
asManager();
const result = await createTerm(fd({ category: "DELIVERY", text: `${PREFIX}Within 2 days` }));
const result = await createTerm(fd({ categoryName: `${PREFIX}Warranty`, text: `${PREFIX}12 months` }));
expect(result).toEqual({ ok: true });
const t = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}Within 2 days` } });
expect(t.category).toBe("DELIVERY");
expect(t.isActive).toBe(true);
const cat = await db.termsCategory.findFirstOrThrow({ where: { name: `${PREFIX}Warranty` }, include: { clauses: true } });
expect(cat.clauses).toHaveLength(1);
expect(cat.clauses[0].text).toBe(`${PREFIX}12 months`);
});
it("requires text and a valid category", async () => {
it("reuses an existing category (case-insensitive) for a second clause", async () => {
asManager();
expect("error" in (await createTerm(fd({ category: "DELIVERY", text: " " })))).toBe(true);
expect("error" in (await createTerm(fd({ category: "NOT_A_CATEGORY", text: "x" })))).toBe(true);
await createTerm(fd({ categoryName: `${PREFIX}warranty`, text: `${PREFIX}24 months` }));
const cats = await db.termsCategory.findMany({ where: { name: { startsWith: PREFIX, mode: "insensitive" }, AND: { name: { equals: `${PREFIX}Warranty`, mode: "insensitive" } } } });
expect(cats).toHaveLength(1); // no duplicate category
});
it("requires a category and clause text", async () => {
asManager();
expect("error" in (await createTerm(fd({ categoryName: " ", text: "x" })))).toBe(true);
expect("error" in (await createTerm(fd({ categoryName: `${PREFIX}X`, text: " " })))).toBe(true);
});
it("refuses callers without manage_terms", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
expect(await createTerm(fd({ category: "DELIVERY", text: `${PREFIX}nope` }))).toEqual({ error: "Forbidden" });
expect(await createTerm(fd({ categoryName: `${PREFIX}X`, text: `${PREFIX}nope` }))).toEqual({ error: "Forbidden" });
mockedAuth.mockResolvedValue(makeSession("u-acc", "ACCOUNTS") as never);
expect(await createTerm(fd({ category: "DELIVERY", text: `${PREFIX}nope` }))).toEqual({ error: "Forbidden" });
expect(await createTerm(fd({ categoryName: `${PREFIX}X`, text: `${PREFIX}nope` }))).toEqual({ error: "Forbidden" });
});
});
describe("updateTerm / toggle / delete", () => {
it("edits, toggles active, then deletes a clause", async () => {
asManager();
await createTerm(fd({ category: "PAYMENT_TERMS", text: `${PREFIX}old wording` }));
const t = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}old wording` } });
await createTerm(fd({ categoryName: `${PREFIX}Edit`, text: `${PREFIX}old` }));
const t = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}old` } });
expect(await updateTerm(t.id, fd({ category: "INSPECTION", text: `${PREFIX}new wording` }))).toEqual({ ok: true });
const after = await db.termsCondition.findUniqueOrThrow({ where: { id: t.id } });
expect(after.text).toBe(`${PREFIX}new wording`);
expect(after.category).toBe("INSPECTION");
expect(await updateTerm(t.id, fd({ categoryName: `${PREFIX}Edit2`, text: `${PREFIX}new` }))).toEqual({ ok: true });
const after = await db.termsCondition.findUniqueOrThrow({ where: { id: t.id }, include: { category: true } });
expect(after.text).toBe(`${PREFIX}new`);
expect(after.category.name).toBe(`${PREFIX}Edit2`);
expect(await toggleTermActive(t.id)).toEqual({ ok: true });
expect((await db.termsCondition.findUniqueOrThrow({ where: { id: t.id } })).isActive).toBe(false);
@ -72,22 +81,29 @@ describe("updateTerm / toggle / delete", () => {
it("guards update/toggle/delete behind the permission", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
expect(await updateTerm("x", fd({ category: "DELIVERY", text: "y" }))).toEqual({ error: "Forbidden" });
expect(await updateTerm("x", fd({ categoryName: `${PREFIX}X`, text: "y" }))).toEqual({ error: "Forbidden" });
expect(await toggleTermActive("x")).toEqual({ error: "Forbidden" });
expect(await deleteTerm("x")).toEqual({ error: "Forbidden" });
});
});
describe("getActiveTermsByCategory", () => {
it("groups only active clauses by category", async () => {
describe("catalogue + default terms helpers", () => {
it("getTermsCatalogue exposes active categories with their active clauses", async () => {
asManager();
await createTerm(fd({ category: "DISPATCH", text: `${PREFIX}active dispatch` }));
await createTerm(fd({ category: "DISPATCH", text: `${PREFIX}inactive dispatch` }));
const inactive = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}inactive dispatch` } });
await createTerm(fd({ categoryName: `${PREFIX}Cat`, text: `${PREFIX}active clause` }));
await createTerm(fd({ categoryName: `${PREFIX}Cat`, text: `${PREFIX}inactive clause` }));
const inactive = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}inactive clause` } });
await toggleTermActive(inactive.id);
const map = await getActiveTermsByCategory();
expect(map.DISPATCH).toContain(`${PREFIX}active dispatch`);
expect(map.DISPATCH).not.toContain(`${PREFIX}inactive dispatch`);
const cat = (await getTermsCatalogue()).find((c) => c.name === `${PREFIX}Cat`);
expect(cat?.clauses).toContain(`${PREFIX}active clause`);
expect(cat?.clauses).not.toContain(`${PREFIX}inactive clause`);
});
it("getDefaultPoTerms returns isDefault clauses", async () => {
asManager();
await createTerm(fd({ categoryName: `${PREFIX}Def`, text: `${PREFIX}default clause`, isDefault: "true" }));
const defaults = await getDefaultPoTerms();
expect(defaults.some((d) => d.text === `${PREFIX}default clause` && d.category === `${PREFIX}Def`)).toBe(true);
});
});