Compare commits
1 commit
master
...
feat/terms
| Author | SHA1 | Date | |
|---|---|---|---|
| 5764403f1c |
23 changed files with 499 additions and 338 deletions
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & Conditions</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">Clauses that populate the PO Terms & Conditions dropdowns</p>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">Categories & clauses that populate the PO Terms & 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 & 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 & 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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 & 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 && (
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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 & Conditions.</p>
|
||||
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
|
||||
</section>
|
||||
|
||||
{/* Attachments */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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 && (
|
||||
|
|
|
|||
100
App/components/po/po-terms-editor.tsx
Normal file
100
App/components/po/po-terms-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,52 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "TermsCategory" AS ENUM ('DELIVERY', 'DISPATCH', 'INSPECTION', 'TRANSIT_INSURANCE', 'PAYMENT_TERMS');
|
||||
-- CreateTable: user-defined T&C categories
|
||||
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" (
|
||||
"id" TEXT NOT NULL,
|
||||
"category" "TermsCategory" NOT NULL,
|
||||
"categoryId" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
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
|
||||
CREATE INDEX "TermsCondition_category_idx" ON "TermsCondition"("category");
|
||||
-- Dynamic T&C snapshot on the PO
|
||||
ALTER TABLE "PurchaseOrder" ADD COLUMN "terms" JSONB;
|
||||
|
||||
-- Seed the standard clauses (the prior TC_DEFAULTS) so the catalogue is usable
|
||||
-- immediately and existing default wording stays selectable.
|
||||
INSERT INTO "TermsCondition" ("id", "category", "text", "updatedAt") VALUES
|
||||
('tcseed_delivery', 'DELIVERY', 'Within 4 to 5 days', CURRENT_TIMESTAMP),
|
||||
('tcseed_dispatch', 'DISPATCH', 'To be transported to site address as above. Freight Supplier''s A/C', CURRENT_TIMESTAMP),
|
||||
('tcseed_inspect', 'INSPECTION', 'NA', CURRENT_TIMESTAMP),
|
||||
('tcseed_transit', 'TRANSIT_INSURANCE', 'NA', CURRENT_TIMESTAMP),
|
||||
('tcseed_payment', 'PAYMENT_TERMS', 'Within 30 days from delivery.', CURRENT_TIMESTAMP);
|
||||
-- Seed: every standard PO T&C line becomes a catalogued clause. "General" holds
|
||||
-- the previously-fixed boilerplate; the five named slots keep their default
|
||||
-- wording; "Others" is an empty bucket admins fill. isDefault rows pre-fill new POs.
|
||||
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);
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue