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)
|
### 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`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 & Conditions</h1>
|
<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>
|
</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 & Conditions dropdowns.
|
No clauses yet. Add one to populate the PO Terms & 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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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 & Conditions</h3>
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Terms & 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 && (
|
||||||
|
|
|
||||||
|
|
@ -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: {},
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 & Conditions</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & 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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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 & Conditions</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & 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 & 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 */}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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 & Conditions</h3>
|
<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 }}>
|
<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 && (
|
||||||
|
|
|
||||||
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 { 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 }));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[]>>;
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue