Follow-up to the merged #11 PR (which shipped the enum-based catalogue): make categories user-defined data and the PO T&C a dynamic editor. - categories are a TermsCategory TABLE (not an enum) — admins add new ones; - every PO T&C line is catalogued, incl. the previously-fixed boilerplate (seeded under a "General" category) and an "Others" bucket; - the PO form is a dynamic editor: "+ Add term", pick a category, type/pick a clause (components/po/po-terms-editor.tsx), used by new/edit/manager-edit. Migration: the already-released 20260624140000 migration is untouched; a new 20260624150000 FORWARD migration renames the enum, creates the table, migrates existing enum clauses onto category rows, adds isDefault/sortOrder + the two fixed lines under General, and adds PurchaseOrder.terms (JSON snapshot that supersedes the legacy tc* columns for export/detail; old POs fall back to tc*). Tests rewritten for category creation + catalogue/default helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
"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>
|
|
);
|
|
}
|