Merge pull request 'feat(po): admin-managed Terms & Conditions catalogue + PO dropdowns (#11)' (#106) from feat/terms-conditions-admin into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Deploy release to production / deploy (push) Successful in 1m34s

Reviewed-on: #106
This commit is contained in:
shad0w 2026-06-23 22:14:05 +00:00
commit fced7cc307
19 changed files with 614 additions and 11 deletions

View file

@ -104,6 +104,10 @@ A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId
The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) render a shared `<DeliveryLocationField>` — a native `<select name="placeOfDelivery">` populated from the **active** locations, each formatted by `lib/delivery-location.ts` `formatDeliveryLocation(company, address)``"Company — address"`. **`PurchaseOrder.placeOfDelivery` stays a free-text snapshot** (no FK): the dropdown only changes how the value is picked, so the export, import, and historical/imported POs are unchanged. On edit, a current value not in the active list is preserved as a leading "(current)" option so it's never silently dropped. Deleting a location is therefore always safe (no PO references it).
### 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).
### PO Numbering (`lib/po-number.ts`)
Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (AprMar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import.

View file

@ -0,0 +1,68 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { TermsCategory } from "@prisma/client";
const schema = z.object({
category: z.nativeEnum(TermsCategory),
text: z.string().trim().min(1, "Clause text is required"),
});
type Result = { ok: true } | { error: string };
async function guard(): Promise<{ ok: true } | { error: string }> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_terms")) {
return { error: "Forbidden" };
}
return { ok: true };
}
export async function createTerm(formData: FormData): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0].message };
await db.termsCondition.create({ data: { category: parsed.data.category, text: parsed.data.text } });
revalidatePath("/admin/terms");
return { ok: true };
}
export async function updateTerm(id: string, formData: FormData): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
const parsed = schema.safeParse(Object.fromEntries(formData));
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 } });
revalidatePath("/admin/terms");
return { ok: true };
}
export async function toggleTermActive(id: string): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
const term = await db.termsCondition.findUnique({ where: { id }, select: { isActive: true } });
if (!term) return { error: "Not found" };
await db.termsCondition.update({ where: { id }, data: { isActive: !term.isActive } });
revalidatePath("/admin/terms");
return { ok: true };
}
export async function deleteTerm(id: string): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
// Safe to delete: POs keep their T&C as text snapshots, so no PO references this row.
await db.termsCondition.delete({ where: { id } });
revalidatePath("/admin/terms");
return { ok: true };
}

View file

@ -0,0 +1,29 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { TermsTable } from "./terms-table";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Terms & Conditions" };
export default async function TermsPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_terms")) redirect("/dashboard");
const terms = await db.termsCondition.findMany({
orderBy: [{ category: "asc" }, { isActive: "desc" }, { createdAt: "asc" }],
});
return (
<TermsTable
terms={terms.map((t) => ({
id: t.id,
category: t.category,
text: t.text,
isActive: t.isActive,
}))}
/>
);
}

View file

@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { TermsCategory } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { TERMS_CATEGORIES, TERMS_CATEGORY_LABEL } from "@/lib/terms";
import { createTerm, updateTerm } from "./actions";
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
export type TermRow = {
id: string;
category: TermsCategory;
text: string;
isActive: boolean;
};
function Fields({ term }: { term?: TermRow }) {
return (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Category *</label>
<select name="category" defaultValue={term?.category ?? ""} required className={INPUT}>
<option value="" disabled>Select a category</option>
{TERMS_CATEGORIES.map((c) => (
<option key={c} value={c}>{TERMS_CATEGORY_LABEL[c]}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Clause text *</label>
<textarea name="text" defaultValue={term?.text ?? ""} rows={3} required className={INPUT} placeholder="e.g. Within 4 to 5 days" />
</div>
</div>
);
}
export function AddTermButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const result = await createTerm(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<>
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
+ Add Clause
</button>
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add T&C Clause">
<form onSubmit={handleSubmit} className="space-y-4">
<Fields />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
<div className="flex gap-3 justify-end">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Create"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditTermButton({
term,
open: controlledOpen,
onOpenChange,
}: {
term: TermRow;
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) {
const router = useRouter();
const [internalOpen, setInternalOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const result = await updateTerm(term.id, new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit T&C Clause">
<form onSubmit={handleSubmit} className="space-y-4">
<Fields term={term} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
</div>
</form>
</AdminDialog>
);
}

View file

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

View file

@ -10,6 +10,8 @@ import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { TermsField } from "@/components/po/terms-field";
import { TC_FIELD_CATEGORY, type TermsByCategory } from "@/lib/terms";
type SerializedLineItem = {
id: string;
@ -41,6 +43,7 @@ interface Props {
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
termsByCategory: TermsByCategory;
}
const INPUT =
@ -53,7 +56,7 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
}
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions }: Props) {
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsByCategory }: Props) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [pending, setPending] = useState(false);
@ -274,9 +277,10 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
<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>
<input
name={name}
defaultValue={(extPo[key] ?? TC_DEFAULTS[key]) as string}
<TermsField
field={name}
options={termsByCategory[TC_FIELD_CATEGORY[name]] ?? []}
current={(extPo[key] ?? TC_DEFAULTS[key]) as string}
className={INPUT}
/>
</div>

View file

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

View file

@ -8,6 +8,8 @@ import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/p
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { TermsField } from "@/components/po/terms-field";
import { TC_FIELD_CATEGORY, type TermsByCategory } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
@ -42,10 +44,11 @@ interface Props {
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
termsByCategory: TermsByCategory;
managerNoteAuthor?: string | null;
}
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, managerNoteAuthor }: Props) {
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsByCategory, managerNoteAuthor }: Props) {
const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>(
po.lineItems.map((li) => ({
@ -277,9 +280,10 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
<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>
<input
name={name}
defaultValue={extPo[key] ?? TC_DEFAULTS[key]}
<TermsField
field={name}
options={termsByCategory[TC_FIELD_CATEGORY[name]] ?? []}
current={extPo[key] ?? TC_DEFAULTS[key]}
className={INPUT_CLS}
/>
</div>

View file

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

View file

@ -8,6 +8,8 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { FileUploader } from "@/components/po/file-uploader";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { TermsField } from "@/components/po/terms-field";
import { TC_FIELD_CATEGORY, type TermsByCategory } from "@/lib/terms";
import { uploadAndLinkFiles } from "@/lib/upload-files";
import type { LineItemInput } from "@/lib/validations/po";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
@ -27,13 +29,14 @@ interface Props {
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
termsByCategory: TermsByCategory;
initialLineItems?: LineItemInput[];
initialVendorId?: string;
initialVesselId?: string;
initialCompanyId?: string;
}
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, termsByCategory, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
@ -257,7 +260,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<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>
<input name={name} defaultValue={TC_DEFAULTS[key]} className={INPUT_CLS} />
<TermsField field={name} options={termsByCategory[TC_FIELD_CATEGORY[name]] ?? []} current={TC_DEFAULTS[key]} className={INPUT_CLS} />
</div>
))}
<div className="flex items-start gap-3">

View file

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

View file

@ -35,6 +35,7 @@ import {
Gauge,
BadgeCheck,
Truck,
ScrollText,
ChevronRight,
} from "lucide-react";
import type { Role } from "@prisma/client";
@ -107,7 +108,8 @@ const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
const MANAGER_ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
{ href: "/admin/delivery-locations", label: "Delivery Locations", icon: Truck, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
{ href: "/admin/delivery-locations", label: "Delivery Locations", icon: Truck, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
{ href: "/admin/terms", label: "Terms & Conditions", icon: ScrollText, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
...(CREWING_ENABLED
? [

View file

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

View file

@ -23,6 +23,7 @@ export type Permission =
| "manage_products"
| "manage_sites"
| "manage_delivery_locations"
| "manage_terms"
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
| "raise_requisition"
| "request_relief_cover"
@ -83,6 +84,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"manage_products",
"manage_sites",
"manage_delivery_locations",
"manage_terms",
"confirm_receipt",
"process_payment"
],
@ -103,6 +105,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"export_reports",
"create_vendor",
"manage_delivery_locations",
"manage_terms",
],
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
ADMIN: [
@ -117,6 +120,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"manage_products",
"manage_sites",
"manage_delivery_locations",
"manage_terms",
],
SITE_STAFF: [],
};

14
App/lib/terms-data.ts Normal file
View file

@ -0,0 +1,14 @@
import { db } from "@/lib/db";
import type { TermsByCategory } from "@/lib/terms";
/** Active T&C clause texts grouped by category, for the PO form dropdowns (#11). */
export async function getActiveTermsByCategory(): Promise<TermsByCategory> {
const rows = await db.termsCondition.findMany({
where: { isActive: true },
orderBy: [{ category: "asc" }, { createdAt: "asc" }],
select: { category: true, text: true },
});
const map: TermsByCategory = {};
for (const r of rows) (map[r.category] ??= []).push(r.text);
return map;
}

36
App/lib/terms.ts Normal file
View file

@ -0,0 +1,36 @@
/**
* Terms & Conditions catalogue (issue #11) admin-managed clauses that populate
* the PO's named T&C dropdowns. Each clause has a category matching one of the
* PO's tc* slots; the chosen clause is stored as a text snapshot in that column.
*/
import type { TermsCategory } from "@prisma/client";
// The five catalogued slots (the PO's "Others" stays free text; the fixed
// boilerplate lines are not catalogued). Order = display order.
export const TERMS_CATEGORIES: TermsCategory[] = [
"DELIVERY",
"DISPATCH",
"INSPECTION",
"TRANSIT_INSURANCE",
"PAYMENT_TERMS",
];
export const TERMS_CATEGORY_LABEL: Record<TermsCategory, string> = {
DELIVERY: "Delivery",
DISPATCH: "Dispatch Instructions",
INSPECTION: "Inspection",
TRANSIT_INSURANCE: "Transit Insurance",
PAYMENT_TERMS: "Payment Terms",
};
// PO tc* form field ⇄ catalogue category.
export const TC_FIELD_CATEGORY: Record<string, TermsCategory> = {
tcDelivery: "DELIVERY",
tcDispatch: "DISPATCH",
tcInspection: "INSPECTION",
tcTransitInsurance: "TRANSIT_INSURANCE",
tcPaymentTerms: "PAYMENT_TERMS",
};
// Server → client shape: active clause texts grouped by category.
export type TermsByCategory = Partial<Record<TermsCategory, string[]>>;

View file

@ -0,0 +1,26 @@
-- CreateEnum
CREATE TYPE "TermsCategory" AS ENUM ('DELIVERY', 'DISPATCH', 'INSPECTION', 'TRANSIT_INSURANCE', 'PAYMENT_TERMS');
-- CreateTable
CREATE TABLE "TermsCondition" (
"id" TEXT NOT NULL,
"category" "TermsCategory" NOT NULL,
"text" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TermsCondition_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TermsCondition_category_idx" ON "TermsCondition"("category");
-- Seed the standard clauses (the prior TC_DEFAULTS) so the catalogue is usable
-- immediately and existing default wording stays selectable.
INSERT INTO "TermsCondition" ("id", "category", "text", "updatedAt") VALUES
('tcseed_delivery', 'DELIVERY', 'Within 4 to 5 days', CURRENT_TIMESTAMP),
('tcseed_dispatch', 'DISPATCH', 'To be transported to site address as above. Freight Supplier''s A/C', CURRENT_TIMESTAMP),
('tcseed_inspect', 'INSPECTION', 'NA', CURRENT_TIMESTAMP),
('tcseed_transit', 'TRANSIT_INSURANCE', 'NA', CURRENT_TIMESTAMP),
('tcseed_payment', 'PAYMENT_TERMS', 'Within 30 days from delivery.', CURRENT_TIMESTAMP);

View file

@ -410,6 +410,32 @@ model DeliveryLocation {
@@index([companyId])
}
// Admin-managed Terms & Conditions clauses (issue #11). Each clause belongs to a
// category matching one of the PO's named T&C slots; the PO form turns those slots
// into dropdowns sourced from the active clauses of that category. The PO keeps
// the chosen clause as a text snapshot in its tc* columns (point-in-time
// document), so editing/removing a clause never rewrites historical POs. Managed
// by manage_terms. ("Others" stays free text; the fixed boilerplate lines —
// TC_FIXED_LINE / TC_FIXED_LINE_2 — are not catalogued.)
enum TermsCategory {
DELIVERY
DISPATCH
INSPECTION
TRANSIT_INSURANCE
PAYMENT_TERMS
}
model TermsCondition {
id String @id @default(cuid())
category TermsCategory
text String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
}
model Account {
id String @id @default(cuid())
code String @unique

View file

@ -0,0 +1,93 @@
/**
* Integration tests for the Terms & Conditions admin CRUD (issue #11).
* Covers create/update/toggle/delete + the manage_terms guard, and the
* grouping helper used to feed the PO T&C dropdowns.
*/
import { vi, describe, it, expect, afterAll } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import {
createTerm,
updateTerm,
toggleTermActive,
deleteTerm,
} from "@/app/(portal)/admin/terms/actions";
import { getActiveTermsByCategory } from "@/lib/terms-data";
import { makeSession, fd } from "./helpers";
const mockedAuth = vi.mocked(auth);
const PREFIX = "INTTEST_TERMS_";
const asManager = () => mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
afterAll(async () => {
await db.termsCondition.deleteMany({ where: { text: { startsWith: PREFIX } } });
});
describe("createTerm", () => {
it("persists a clause under its category", async () => {
asManager();
const result = await createTerm(fd({ category: "DELIVERY", text: `${PREFIX}Within 2 days` }));
expect(result).toEqual({ ok: true });
const t = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}Within 2 days` } });
expect(t.category).toBe("DELIVERY");
expect(t.isActive).toBe(true);
});
it("requires text and a valid category", async () => {
asManager();
expect("error" in (await createTerm(fd({ category: "DELIVERY", text: " " })))).toBe(true);
expect("error" in (await createTerm(fd({ category: "NOT_A_CATEGORY", text: "x" })))).toBe(true);
});
it("refuses callers without manage_terms", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
expect(await createTerm(fd({ category: "DELIVERY", text: `${PREFIX}nope` }))).toEqual({ error: "Forbidden" });
mockedAuth.mockResolvedValue(makeSession("u-acc", "ACCOUNTS") as never);
expect(await createTerm(fd({ category: "DELIVERY", text: `${PREFIX}nope` }))).toEqual({ error: "Forbidden" });
});
});
describe("updateTerm / toggle / delete", () => {
it("edits, toggles active, then deletes a clause", async () => {
asManager();
await createTerm(fd({ category: "PAYMENT_TERMS", text: `${PREFIX}old wording` }));
const t = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}old wording` } });
expect(await updateTerm(t.id, fd({ category: "INSPECTION", text: `${PREFIX}new wording` }))).toEqual({ ok: true });
const after = await db.termsCondition.findUniqueOrThrow({ where: { id: t.id } });
expect(after.text).toBe(`${PREFIX}new wording`);
expect(after.category).toBe("INSPECTION");
expect(await toggleTermActive(t.id)).toEqual({ ok: true });
expect((await db.termsCondition.findUniqueOrThrow({ where: { id: t.id } })).isActive).toBe(false);
expect(await deleteTerm(t.id)).toEqual({ ok: true });
expect(await db.termsCondition.findUnique({ where: { id: t.id } })).toBeNull();
});
it("guards update/toggle/delete behind the permission", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
expect(await updateTerm("x", fd({ category: "DELIVERY", text: "y" }))).toEqual({ error: "Forbidden" });
expect(await toggleTermActive("x")).toEqual({ error: "Forbidden" });
expect(await deleteTerm("x")).toEqual({ error: "Forbidden" });
});
});
describe("getActiveTermsByCategory", () => {
it("groups only active clauses by category", async () => {
asManager();
await createTerm(fd({ category: "DISPATCH", text: `${PREFIX}active dispatch` }));
await createTerm(fd({ category: "DISPATCH", text: `${PREFIX}inactive dispatch` }));
const inactive = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}inactive dispatch` } });
await toggleTermActive(inactive.id);
const map = await getActiveTermsByCategory();
expect(map.DISPATCH).toContain(`${PREFIX}active dispatch`);
expect(map.DISPATCH).not.toContain(`${PREFIX}inactive dispatch`);
});
});