Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
5764403f1c feat(po): user-defined T&C categories + dynamic PO terms editor (#11)
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>
2026-06-24 04:08:30 +05:30
23 changed files with 499 additions and 338 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) ### 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`) ### PO Numbering (`lib/po-number.ts`)

View file

@ -5,11 +5,12 @@ import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { TermsCategory } from "@prisma/client";
const schema = z.object({ 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"), text: z.string().trim().min(1, "Clause text is required"),
isDefault: z.boolean().default(false),
}); });
type Result = { ok: true } | { error: string }; type Result = { ok: true } | { error: string };
@ -22,14 +23,40 @@ async function guard(): Promise<{ ok: true } | { error: string }> {
return { ok: true }; 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> { export async function createTerm(formData: FormData): Promise<Result> {
const g = await guard(); const g = await guard();
if ("error" in g) return g; 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 }; 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"); revalidatePath("/admin/terms");
return { ok: true }; return { ok: true };
} }
@ -38,10 +65,14 @@ export async function updateTerm(id: string, formData: FormData): Promise<Result
const g = await guard(); const g = await guard();
if ("error" in g) return g; 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 }; 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"); revalidatePath("/admin/terms");
return { ok: true }; return { ok: true };
} }
@ -61,7 +92,7 @@ export async function deleteTerm(id: string): Promise<Result> {
const g = await guard(); const g = await guard();
if ("error" in g) return g; 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 } }); await db.termsCondition.delete({ where: { id } });
revalidatePath("/admin/terms"); revalidatePath("/admin/terms");
return { ok: true }; return { ok: true };

View file

@ -12,16 +12,22 @@ export default async function TermsPage() {
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_terms")) redirect("/dashboard"); if (!hasPermission(session.user.role, "manage_terms")) redirect("/dashboard");
const terms = await db.termsCondition.findMany({ const [terms, categories] = await Promise.all([
orderBy: [{ category: "asc" }, { isActive: "desc" }, { createdAt: "asc" }], 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 ( return (
<TermsTable <TermsTable
categoryNames={categories.map((c) => c.name)}
terms={terms.map((t) => ({ terms={terms.map((t) => ({
id: t.id, id: t.id,
category: t.category, categoryName: t.category.name,
text: t.text, text: t.text,
isDefault: t.isDefault,
isActive: t.isActive, isActive: t.isActive,
}))} }))}
/> />

View file

@ -2,41 +2,53 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { TermsCategory } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog"; import { AdminDialog } from "@/components/ui/admin-dialog";
import { TERMS_CATEGORIES, TERMS_CATEGORY_LABEL } from "@/lib/terms";
import { createTerm, updateTerm } from "./actions"; 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"; 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 = { export type TermRow = {
id: string; id: string;
category: TermsCategory; categoryName: string;
text: string; text: string;
isDefault: boolean;
isActive: boolean; isActive: boolean;
}; };
function Fields({ term }: { term?: TermRow }) { function Fields({ term, categoryNames }: { term?: TermRow; categoryNames: string[] }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Category *</label> <label className="block text-xs font-medium text-neutral-700 mb-1">Category *</label>
<select name="category" defaultValue={term?.category ?? ""} required className={INPUT}> <input
<option value="" disabled>Select a category</option> name="categoryName"
{TERMS_CATEGORIES.map((c) => ( list="tc-category-list"
<option key={c} value={c}>{TERMS_CATEGORY_LABEL[c]}</option> 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>
<div> <div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Clause text *</label> <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" /> <textarea name="text" defaultValue={term?.text ?? ""} rows={3} required className={INPUT} placeholder="e.g. Within 4 to 5 days" />
</div> </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> </div>
); );
} }
export function AddTermButton() { export function AddTermButton({ categoryNames }: { categoryNames: string[] }) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
@ -56,7 +68,7 @@ export function AddTermButton() {
</button> </button>
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add T&C Clause"> <AdminDialog open={open} onClose={() => setOpen(false)} title="Add T&C Clause">
<form onSubmit={handleSubmit} className="space-y-4"> <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>} {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"> <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> <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({ export function EditTermButton({
term, term,
categoryNames,
open: controlledOpen, open: controlledOpen,
onOpenChange, onOpenChange,
}: { }: {
term: TermRow; term: TermRow;
categoryNames: string[];
open?: boolean; open?: boolean;
onOpenChange?: (v: boolean) => void; onOpenChange?: (v: boolean) => void;
}) { }) {
@ -96,7 +110,7 @@ export function EditTermButton({
return ( return (
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit T&C Clause"> <AdminDialog open={open} onClose={() => setOpen(false)} title="Edit T&C Clause">
<form onSubmit={handleSubmit} className="space-y-4"> <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>} {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"> <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> <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 { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/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 { AddTermButton, EditTermButton, type TermRow } from "./terms-form";
import { deleteTerm, toggleTermActive } from "./actions"; import { deleteTerm, toggleTermActive } from "./actions";
const CHIPS = ["Active", "Inactive"]; const CHIPS = ["Active", "Inactive"];
function TermActionsMenu({ term }: { term: TermRow }) { function TermActionsMenu({ term, categoryNames }: { term: TermRow; categoryNames: string[] }) {
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false); const [toggleOpen, setToggleOpen] = useState(false);
@ -28,7 +27,7 @@ function TermActionsMenu({ term }: { term: TermRow }) {
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem> <RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu> </RowActionsMenu>
<EditTermButton term={term} open={editOpen} onOpenChange={setEditOpen} /> <EditTermButton term={term} categoryNames={categoryNames} open={editOpen} onOpenChange={setEditOpen} />
<DeleteConfirmDialog <DeleteConfirmDialog
open={deleteOpen} open={deleteOpen}
onOpenChange={setDeleteOpen} onOpenChange={setDeleteOpen}
@ -41,8 +40,8 @@ function TermActionsMenu({ term }: { term: TermRow }) {
title={term.isActive ? "Deactivate clause?" : "Activate clause?"} title={term.isActive ? "Deactivate clause?" : "Activate clause?"}
description={ description={
term.isActive term.isActive
? "It will no longer appear in the PO Terms & Conditions dropdowns." ? "It will no longer be suggested in the PO Terms & Conditions editor."
: "It will appear in the PO Terms & Conditions dropdowns again." : "It will be suggested in the PO Terms & Conditions editor again."
} }
confirmLabel={term.isActive ? "Deactivate" : "Activate"} confirmLabel={term.isActive ? "Deactivate" : "Activate"}
onConfirm={() => toggleTermActive(term.id)} 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 } = const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
useTableControls<TermRow>({ useTableControls<TermRow>({
rows: terms, rows: terms,
defaultSortKey: "category", defaultSortKey: "categoryName",
searchText: (t) => [TERMS_CATEGORY_LABEL[t.category], t.text, t.isActive ? "active" : "inactive"].join(" "), searchText: (t) => [t.categoryName, t.text, t.isActive ? "active" : "inactive"].join(" "),
chipMatch: (t, chip) => { chipMatch: (t, chip) => {
if (chip.toLowerCase() === "active") return t.isActive; if (chip.toLowerCase() === "active") return t.isActive;
if (chip.toLowerCase() === "inactive") return !t.isActive; if (chip.toLowerCase() === "inactive") return !t.isActive;
@ -64,7 +63,7 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
}, },
sortValue: (t, key) => { sortValue: (t, key) => {
if (key === "isActive") return t.isActive ? "Active" : "Inactive"; 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]; const val = t[key as keyof TermRow];
return typeof val === "string" || typeof val === "boolean" ? val : String(val ?? ""); 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 className="mb-6 flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold text-neutral-900">Terms &amp; Conditions</h1> <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> </div>
<AddTermButton /> <AddTermButton categoryNames={categoryNames} />
</div> </div>
<TableControls <TableControls
@ -93,8 +92,9 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200"> <thead className="bg-neutral-50 border-b border-neutral-200">
<tr> <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="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> <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> <th className="px-4 py-3 w-10"></th>
</tr> </tr>
@ -102,15 +102,18 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
<tbody className="divide-y divide-neutral-100"> <tbody className="divide-y divide-neutral-100">
{filtered.length === 0 && ( {filtered.length === 0 && (
<tr> <tr>
<td colSpan={4} className="px-4 py-8 text-center text-neutral-400"> <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 dropdowns. No clauses yet. Add one to populate the PO Terms &amp; Conditions editor.
</td> </td>
</tr> </tr>
)} )}
{filtered.map((term) => ( {filtered.map((term) => (
<tr key={term.id} className="hover:bg-neutral-50"> <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 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"> <td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${ <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" 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> </span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<TermActionsMenu term={term} /> <TermActionsMenu term={term} categoryNames={categoryNames} />
</td> </td>
</tr> </tr>
))} ))}

View file

@ -4,14 +4,13 @@ import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { managerEditPo } from "./manager-po-edit-actions"; import { managerEditPo } from "./manager-po-edit-actions";
import { LineItemsEditor } from "@/components/po/po-line-items-editor"; 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 { LineItemInput } from "@/lib/validations/po";
import type { Vendor, PurchaseOrder } from "@prisma/client"; import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form"; import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { TermsField } from "@/components/po/terms-field"; import { PoTermsEditor } from "@/components/po/po-terms-editor";
import { TC_FIELD_CATEGORY, type TermsByCategory } from "@/lib/terms"; import type { CatalogueCategory, PoTerm } from "@/lib/terms";
type SerializedLineItem = { type SerializedLineItem = {
id: string; id: string;
@ -43,7 +42,8 @@ interface Props {
vendors: Vendor[]; vendors: Vendor[];
companies: CompanyOption[]; companies: CompanyOption[];
deliveryOptions: string[]; deliveryOptions: string[];
termsByCategory: TermsByCategory; termsCatalogue: CatalogueCategory[];
initialTerms: PoTerm[];
} }
const INPUT = 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 />; 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 router = useRouter();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
const extPo = po as typeof po & { const extPo = po as typeof po & {
piQuotationNo?: string | null; piQuotationDate?: Date | null; 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}].unitPrice`, String(item.unitPrice));
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18)); data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18));
}); });
data.set("termsJson", JSON.stringify(terms));
const result = await managerEditPo(po.id, data); const result = await managerEditPo(po.id, data);
if ("error" in result) { if ("error" in result) {
@ -263,39 +265,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
{/* Terms & Conditions */} {/* Terms & Conditions */}
<section> <section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Terms &amp; Conditions</h3> <h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Terms &amp; Conditions</h3>
<div className="space-y-2.5"> <PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} accent="amber" />
<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>
</section> </section>
{error && ( {error && (

View file

@ -4,6 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { createPoSchema } from "@/lib/validations/po"; import { createPoSchema } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export async function managerEditPo( export async function managerEditPo(
@ -68,6 +69,10 @@ export async function managerEditPo(
} }
const data = parsed.data; 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( const newTotal = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate), (sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0 0
@ -130,6 +135,7 @@ export async function managerEditPo(
tcTransitInsurance: data.tcTransitInsurance ?? null, tcTransitInsurance: data.tcTransitInsurance ?? null,
tcPaymentTerms: data.tcPaymentTerms ?? null, tcPaymentTerms: data.tcPaymentTerms ?? null,
tcOthers: data.tcOthers ?? null, tcOthers: data.tcOthers ?? null,
terms,
totalAmount: newTotal, totalAmount: newTotal,
lineItems: { lineItems: {
deleteMany: {}, deleteMany: {},

View file

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

View file

@ -3,6 +3,7 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { createPoSchema } from "@/lib/validations/po"; import { createPoSchema } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { notify } from "@/lib/notifier"; import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
@ -71,6 +72,11 @@ export async function updatePo(
} }
const data = parsed.data; 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( const total = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate), (sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0 0
@ -156,6 +162,7 @@ export async function updatePo(
tcTransitInsurance: data.tcTransitInsurance ?? null, tcTransitInsurance: data.tcTransitInsurance ?? null,
tcPaymentTerms: data.tcPaymentTerms ?? null, tcPaymentTerms: data.tcPaymentTerms ?? null,
tcOthers: data.tcOthers ?? null, tcOthers: data.tcOthers ?? null,
terms,
totalAmount: total, totalAmount: total,
status: shouldSubmit ? "MGR_REVIEW" : "DRAFT", status: shouldSubmit ? "MGR_REVIEW" : "DRAFT",
submittedAt: shouldSubmit ? new Date() : po.submittedAt, 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 { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { TermsField } from "@/components/po/terms-field"; import { PoTermsEditor } from "@/components/po/po-terms-editor";
import { TC_FIELD_CATEGORY, type TermsByCategory } from "@/lib/terms"; import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
const INPUT_CLS = 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"; "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[]; vendors: Vendor[];
companies: CompanyOption[]; companies: CompanyOption[];
deliveryOptions: string[]; deliveryOptions: string[];
termsByCategory: TermsByCategory; termsCatalogue: CatalogueCategory[];
initialTerms: PoTerm[];
managerNoteAuthor?: string | null; 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 router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>( const [lineItems, setLineItems] = useState<LineItemInput[]>(
po.lineItems.map((li) => ({ 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 hasPerLineAccounts = po.lineItems.some((li) => li.accountId);
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts); const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? ""); const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
const canSubmit = po.status === "DRAFT"; const canSubmit = po.status === "DRAFT";
const canResubmit = po.status === "EDITS_REQUESTED"; 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 form = document.getElementById("edit-po-form") as HTMLFormElement;
const data = new FormData(form); const data = new FormData(form);
data.set("intent", intent); data.set("intent", intent);
data.set("termsJson", JSON.stringify(terms));
lineItems.forEach((item, i) => { lineItems.forEach((item, i) => {
data.set(`lineItems[${i}].name`, item.name); data.set(`lineItems[${i}].name`, item.name);
data.set(`lineItems[${i}].description`, item.description ?? ""); data.set(`lineItems[${i}].description`, item.description ?? "");
@ -265,43 +267,9 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
{/* Terms & Conditions */} {/* Terms & Conditions */}
<section className="rounded-lg border border-neutral-200 bg-white p-6"> <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> <h2 className="text-base font-semibold text-neutral-900 mb-1">Terms &amp; Conditions</h2>
<div className="space-y-3"> <p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause.</p>
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none"> <PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
<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>
</section> </section>
{error && ( {error && (

View file

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

View file

@ -4,6 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { requirePermission } from "@/lib/permissions"; import { requirePermission } from "@/lib/permissions";
import { createPoSchema } from "@/lib/validations/po"; import { createPoSchema } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { generatePoNumber } from "@/lib/po-number"; import { generatePoNumber } from "@/lib/po-number";
import { notify } from "@/lib/notifier"; import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
@ -77,6 +78,11 @@ export async function createPo(
} }
const data = parsed.data; 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 // totalAmount = grand total including GST
const total = data.lineItems.reduce( const total = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate), (sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
@ -108,6 +114,7 @@ export async function createPo(
tcTransitInsurance: data.tcTransitInsurance ?? null, tcTransitInsurance: data.tcTransitInsurance ?? null,
tcPaymentTerms: data.tcPaymentTerms ?? null, tcPaymentTerms: data.tcPaymentTerms ?? null,
tcOthers: data.tcOthers ?? null, tcOthers: data.tcOthers ?? null,
terms,
submitterId: session.user.id, submitterId: session.user.id,
submittedAt: intent === "submit" ? new Date() : null, submittedAt: intent === "submit" ? new Date() : null,
lineItems: { lineItems: {

View file

@ -8,11 +8,10 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { FileUploader } from "@/components/po/file-uploader"; import { FileUploader } from "@/components/po/file-uploader";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { TermsField } from "@/components/po/terms-field"; import { PoTermsEditor } from "@/components/po/po-terms-editor";
import { TC_FIELD_CATEGORY, type TermsByCategory } from "@/lib/terms"; import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import { uploadAndLinkFiles } from "@/lib/upload-files"; import { uploadAndLinkFiles } from "@/lib/upload-files";
import type { LineItemInput } from "@/lib/validations/po"; 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 VesselOption = { id: string; code: string; name: string };
export type AccountGroup = { group: string; items: { 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[]; vendors: Vendor[];
companies: CompanyOption[]; companies: CompanyOption[];
deliveryOptions: string[]; deliveryOptions: string[];
termsByCategory: TermsByCategory; termsCatalogue: CatalogueCategory[];
defaultTerms: PoTerm[];
initialLineItems?: LineItemInput[]; initialLineItems?: LineItemInput[];
initialVendorId?: string; initialVendorId?: string;
initialVesselId?: string; initialVesselId?: string;
initialCompanyId?: 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 router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>( const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE] initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
@ -47,6 +47,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
const [error, setError] = useState(""); const [error, setError] = useState("");
const [multiAccount, setMultiAccount] = useState(false); const [multiAccount, setMultiAccount] = useState(false);
const [defaultAccountId, setDefaultAccountId] = useState(""); const [defaultAccountId, setDefaultAccountId] = useState("");
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
async function handleSubmit(intent: "draft" | "submit") { async function handleSubmit(intent: "draft" | "submit") {
setSubmitting(intent); setSubmitting(intent);
@ -54,6 +55,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
const form = document.getElementById("po-form") as HTMLFormElement; const form = document.getElementById("po-form") as HTMLFormElement;
const data = new FormData(form); const data = new FormData(form);
data.set("intent", intent); data.set("intent", intent);
data.set("termsJson", JSON.stringify(terms));
lineItems.forEach((item, i) => { lineItems.forEach((item, i) => {
data.set(`lineItems[${i}].name`, item.name); data.set(`lineItems[${i}].name`, item.name);
data.set(`lineItems[${i}].description`, item.description ?? ""); data.set(`lineItems[${i}].description`, item.description ?? "");
@ -245,33 +247,9 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
{/* Terms & Conditions */} {/* Terms & Conditions */}
<section className="rounded-lg border border-neutral-200 bg-white p-6"> <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> <h2 className="text-base font-semibold text-neutral-900 mb-1">Terms &amp; Conditions</h2>
<div className="space-y-3"> <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>
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none"> <PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
<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>
</section> </section>
{/* Attachments */} {/* Attachments */}

View file

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

View file

@ -3,6 +3,7 @@ import { db } from "@/lib/db";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs"; import ExcelJS from "exceljs";
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po"; import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { downloadBuffer } from "@/lib/storage"; import { downloadBuffer } from "@/lib/storage";
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark"; import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
import { getImageSize, scaleToBox } from "@/lib/image-size"; import { getImageSize, scaleToBox } from "@/lib/image-size";
@ -182,7 +183,13 @@ export async function GET(request: NextRequest, { params }: Props) {
const reqDate = fmtDate(ext.requisitionDate); const reqDate = fmtDate(ext.requisitionDate);
const delivery = ext.placeOfDelivery ?? ""; const delivery = ext.placeOfDelivery ?? "";
const tcLines: [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], [1, "", TC_FIXED_LINE],
[2, "DELIVERY", ext.tcDelivery ?? TC_DEFAULTS.tcDelivery], [2, "DELIVERY", ext.tcDelivery ?? TC_DEFAULTS.tcDelivery],
[3, "DISPATCH INSTRUCTIONS", ext.tcDispatch ?? TC_DEFAULTS.tcDispatch], [3, "DISPATCH INSTRUCTIONS", ext.tcDispatch ?? TC_DEFAULTS.tcDispatch],

View file

@ -9,6 +9,7 @@ import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
import { generateDownloadUrl } from "@/lib/storage"; import { generateDownloadUrl } from "@/lib/storage";
import { groupAttachments } from "@/lib/attachments"; import { groupAttachments } from "@/lib/attachments";
import { TC_FIXED_LINE } from "@/lib/validations/po"; import { TC_FIXED_LINE } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
@ -38,6 +39,7 @@ type PoWithRelations = {
tcTransitInsurance?: string | null; tcTransitInsurance?: string | null;
tcPaymentTerms?: string | null; tcPaymentTerms?: string | null;
tcOthers?: string | null; tcOthers?: string | null;
terms?: import("@prisma/client").Prisma.JsonValue;
createdAt: Date; createdAt: Date;
submittedAt: Date | null; submittedAt: Date | null;
approvedAt: Date | null; approvedAt: Date | null;
@ -459,31 +461,41 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
/> />
</div> </div>
{/* Terms & Conditions */} {/* Terms & Conditions (issue #11): dynamic snapshot when present, else legacy tc* + fixed line. */}
{(po.tcDelivery || po.tcDispatch || po.tcInspection || po.tcTransitInsurance || po.tcPaymentTerms || po.tcOthers) && ( {(() => {
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"> <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> <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 }}> <ol className="space-y-1.5 text-sm text-neutral-700" style={{ listStyle: "none", padding: 0 }}>
<li className="flex gap-2"> {rows.map((r, i) => (
<span className="shrink-0 font-medium text-neutral-500">1.</span> <li key={i} className="flex gap-2">
<span>{TC_FIXED_LINE}</span> <span className="shrink-0 font-medium text-neutral-500">{i + 1}.</span>
</li> <span>{r.label ? <span className="font-medium">{r.label}: </span> : null}{r.text}</span>
{([
{ 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> </li>
))} ))}
</ol> </ol>
</div> </div>
)} );
})()}
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */} {/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
{attachmentGroups.length > 0 && ( {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 { 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). */ /** Active categories (ordered) each with their active clause texts — for the PO T&C editor (#11). */
export async function getActiveTermsByCategory(): Promise<TermsByCategory> { export async function getTermsCatalogue(): Promise<CatalogueCategory[]> {
const rows = await db.termsCondition.findMany({ const cats = await db.termsCategory.findMany({
where: { isActive: true }, where: { isActive: true },
orderBy: [{ category: "asc" }, { createdAt: "asc" }], orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
select: { category: true, text: true }, include: {
clauses: {
where: { isActive: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: { text: true },
},
},
}); });
const map: TermsByCategory = {}; return cats.map((c) => ({ id: c.id, name: c.name, clauses: c.clauses.map((x) => x.text) }));
for (const r of rows) (map[r.category] ??= []).push(r.text); }
return map;
/** 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 * Terms & Conditions catalogue (issue #11) admin-managed categories + clauses
* the PO's named T&C dropdowns. Each clause has a category matching one of the * that feed the PO's dynamic T&C editor. Categories are user-defined data.
* PO's tc* slots; the chosen clause is stored as a text snapshot in that column.
*/ */
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 // One chosen T&C row on a PO (stored as a JSON snapshot in PurchaseOrder.terms).
// boilerplate lines are not catalogued). Order = display order. export type PoTerm = { category: string; text: string };
export const TERMS_CATEGORIES: TermsCategory[] = [
"DELIVERY", // A catalogue category with its active clause texts — passed to the PO editor.
"DISPATCH", export type CatalogueCategory = { id: string; name: string; clauses: string[] };
"INSPECTION",
"TRANSIT_INSURANCE", // Legacy PO (no `terms` JSON yet) → editable rows, mapping the old tc* columns +
"PAYMENT_TERMS", // 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);
}
export const TERMS_CATEGORY_LABEL: Record<TermsCategory, string> = { /** Coerce an unknown (DB JSON / parsed form value) into a clean PoTerm[]. */
DELIVERY: "Delivery", export function parsePoTerms(value: unknown): PoTerm[] {
DISPATCH: "Dispatch Instructions", if (!Array.isArray(value)) return [];
INSPECTION: "Inspection", const out: PoTerm[] = [];
TRANSIT_INSURANCE: "Transit Insurance", for (const row of value) {
PAYMENT_TERMS: "Payment Terms", 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();
// PO tc* form field ⇄ catalogue category. // A row needs at least some text to be meaningful; category may be blank.
export const TC_FIELD_CATEGORY: Record<string, TermsCategory> = { if (text) out.push({ category, text });
tcDelivery: "DELIVERY", }
tcDispatch: "DISPATCH", return out;
tcInspection: "INSPECTION", }
tcTransitInsurance: "TRANSIT_INSURANCE",
tcPaymentTerms: "PAYMENT_TERMS",
};
// Server → client shape: active clause texts grouped by category.
export type TermsByCategory = Partial<Record<TermsCategory, string[]>>;

View file

@ -1,26 +1,52 @@
-- CreateEnum -- CreateTable: user-defined T&C categories
CREATE TYPE "TermsCategory" AS ENUM ('DELIVERY', 'DISPATCH', 'INSPECTION', 'TRANSIT_INSURANCE', 'PAYMENT_TERMS'); 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,
-- CreateTable CONSTRAINT "TermsCategory_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "TermsCategory_name_key" ON "TermsCategory"("name");
-- CreateTable: clauses belonging to a category
CREATE TABLE "TermsCondition" ( CREATE TABLE "TermsCondition" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"category" "TermsCategory" NOT NULL, "categoryId" TEXT NOT NULL,
"text" TEXT NOT NULL, "text" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true, "isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TermsCondition_pkey" PRIMARY KEY ("id") CONSTRAINT "TermsCondition_pkey" PRIMARY KEY ("id")
); );
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;
-- CreateIndex -- Dynamic T&C snapshot on the PO
CREATE INDEX "TermsCondition_category_idx" ON "TermsCondition"("category"); ALTER TABLE "PurchaseOrder" ADD COLUMN "terms" JSONB;
-- Seed the standard clauses (the prior TC_DEFAULTS) so the catalogue is usable -- Seed: every standard PO T&C line becomes a catalogued clause. "General" holds
-- immediately and existing default wording stays selectable. -- the previously-fixed boilerplate; the five named slots keep their default
INSERT INTO "TermsCondition" ("id", "category", "text", "updatedAt") VALUES -- wording; "Others" is an empty bucket admins fill. isDefault rows pre-fill new POs.
('tcseed_delivery', 'DELIVERY', 'Within 4 to 5 days', CURRENT_TIMESTAMP), INSERT INTO "TermsCategory" ("id", "name", "sortOrder", "updatedAt") VALUES
('tcseed_dispatch', 'DISPATCH', 'To be transported to site address as above. Freight Supplier''s A/C', CURRENT_TIMESTAMP), ('tcat_general', 'General', 0, CURRENT_TIMESTAMP),
('tcseed_inspect', 'INSPECTION', 'NA', CURRENT_TIMESTAMP), ('tcat_delivery', 'Delivery', 1, CURRENT_TIMESTAMP),
('tcseed_transit', 'TRANSIT_INSURANCE', 'NA', CURRENT_TIMESTAMP), ('tcat_dispatch', 'Dispatch Instructions',2, CURRENT_TIMESTAMP),
('tcseed_payment', 'PAYMENT_TERMS', 'Within 30 days from delivery.', 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);
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),
('tcc_delivery', 'tcat_delivery', 'Within 4 to 5 days', true, 0, CURRENT_TIMESTAMP),
('tcc_dispatch', 'tcat_dispatch', 'To be transported to site address as above. Freight Supplier''s A/C', true, 0, CURRENT_TIMESTAMP),
('tcc_inspect', 'tcat_inspect', 'NA', true, 0, CURRENT_TIMESTAMP),
('tcc_transit', 'tcat_transit', 'NA', true, 0, CURRENT_TIMESTAMP),
('tcc_payment', 'tcat_payment', 'Within 30 days from delivery.', true, 0, CURRENT_TIMESTAMP);

View file

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

View file

@ -1,7 +1,8 @@
/** /**
* Integration tests for the Terms & Conditions admin CRUD (issue #11). * Integration tests for the Terms & Conditions admin CRUD (issue #11).
* Covers create/update/toggle/delete + the manage_terms guard, and the * Categories are user-defined data: adding a clause under a new category name
* grouping helper used to feed the PO T&C dropdowns. * 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"; import { vi, describe, it, expect, afterAll } from "vitest";
@ -16,7 +17,7 @@ import {
toggleTermActive, toggleTermActive,
deleteTerm, deleteTerm,
} from "@/app/(portal)/admin/terms/actions"; } from "@/app/(portal)/admin/terms/actions";
import { getActiveTermsByCategory } from "@/lib/terms-data"; import { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data";
import { makeSession, fd } from "./helpers"; import { makeSession, fd } from "./helpers";
const mockedAuth = vi.mocked(auth); const mockedAuth = vi.mocked(auth);
@ -25,43 +26,51 @@ const asManager = () => mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAG
afterAll(async () => { afterAll(async () => {
await db.termsCondition.deleteMany({ where: { text: { startsWith: PREFIX } } }); await db.termsCondition.deleteMany({ where: { text: { startsWith: PREFIX } } });
await db.termsCategory.deleteMany({ where: { name: { startsWith: PREFIX } } });
}); });
describe("createTerm", () => { 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(); 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 }); expect(result).toEqual({ ok: true });
const t = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}Within 2 days` } }); const cat = await db.termsCategory.findFirstOrThrow({ where: { name: `${PREFIX}Warranty` }, include: { clauses: true } });
expect(t.category).toBe("DELIVERY"); expect(cat.clauses).toHaveLength(1);
expect(t.isActive).toBe(true); 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(); asManager();
expect("error" in (await createTerm(fd({ category: "DELIVERY", text: " " })))).toBe(true); await createTerm(fd({ categoryName: `${PREFIX}warranty`, text: `${PREFIX}24 months` }));
expect("error" in (await createTerm(fd({ category: "NOT_A_CATEGORY", text: "x" })))).toBe(true); 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 () => { it("refuses callers without manage_terms", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never); 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); 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", () => { describe("updateTerm / toggle / delete", () => {
it("edits, toggles active, then deletes a clause", async () => { it("edits, toggles active, then deletes a clause", async () => {
asManager(); asManager();
await createTerm(fd({ category: "PAYMENT_TERMS", text: `${PREFIX}old wording` })); await createTerm(fd({ categoryName: `${PREFIX}Edit`, text: `${PREFIX}old` }));
const t = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}old wording` } }); 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 }); 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 } }); const after = await db.termsCondition.findUniqueOrThrow({ where: { id: t.id }, include: { category: true } });
expect(after.text).toBe(`${PREFIX}new wording`); expect(after.text).toBe(`${PREFIX}new`);
expect(after.category).toBe("INSPECTION"); expect(after.category.name).toBe(`${PREFIX}Edit2`);
expect(await toggleTermActive(t.id)).toEqual({ ok: true }); expect(await toggleTermActive(t.id)).toEqual({ ok: true });
expect((await db.termsCondition.findUniqueOrThrow({ where: { id: t.id } })).isActive).toBe(false); 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 () => { it("guards update/toggle/delete behind the permission", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never); 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 toggleTermActive("x")).toEqual({ error: "Forbidden" });
expect(await deleteTerm("x")).toEqual({ error: "Forbidden" }); expect(await deleteTerm("x")).toEqual({ error: "Forbidden" });
}); });
}); });
describe("getActiveTermsByCategory", () => { describe("catalogue + default terms helpers", () => {
it("groups only active clauses by category", async () => { it("getTermsCatalogue exposes active categories with their active clauses", async () => {
asManager(); asManager();
await createTerm(fd({ category: "DISPATCH", text: `${PREFIX}active dispatch` })); await createTerm(fd({ categoryName: `${PREFIX}Cat`, text: `${PREFIX}active clause` }));
await createTerm(fd({ category: "DISPATCH", text: `${PREFIX}inactive dispatch` })); await createTerm(fd({ categoryName: `${PREFIX}Cat`, text: `${PREFIX}inactive clause` }));
const inactive = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}inactive dispatch` } }); const inactive = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}inactive clause` } });
await toggleTermActive(inactive.id); await toggleTermActive(inactive.id);
const map = await getActiveTermsByCategory(); const cat = (await getTermsCatalogue()).find((c) => c.name === `${PREFIX}Cat`);
expect(map.DISPATCH).toContain(`${PREFIX}active dispatch`); expect(cat?.clauses).toContain(`${PREFIX}active clause`);
expect(map.DISPATCH).not.toContain(`${PREFIX}inactive dispatch`); 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);
}); });
}); });