Reworks the T&C feature per review:
- categories are user-defined DATA, not a fixed enum — admins add new ones;
- ALL PO T&Cs are catalogued, incl. the previously-fixed boilerplate (seeded
under a "General" category) and an "Others" bucket;
- the PO form is a dynamic editor: "+ Add term", pick a category, type/pick a
clause.
- schema: TermsCategory (name/sortOrder/isActive) + TermsCondition (categoryId
FK + text + isDefault + isActive). PurchaseOrder.terms Json snapshot. Migration
seeds every standard line as a clause (named slots, the two fixed lines under
General, empty Others); isDefault rows pre-fill new POs.
- admin /admin/terms: Add/Edit clause form's category is a combobox — typing a
new name creates the category; isDefault checkbox.
- PO editor components/po/po-terms-editor.tsx: dynamic rows (category + clause
comboboxes), used by new/edit/manager-edit forms; new POs pre-fill from
getDefaultPoTerms, edits load po.terms or legacyPoTerms (old tc* + fixed lines).
- storage: PurchaseOrder.terms ([{category,text}]) supersedes tc* for export +
detail; null on old POs falls back to tc* + fixed lines. parsePoTerms validates.
- export route + po-detail render from terms when present.
- tests rewritten for category creation + catalogue/default helpers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
|
|
);
|
|
}
|