pelagia-portal/App/components/po/po-terms-editor.tsx
Hardik 3babfe26ef
All checks were successful
PR checks / checks (pull_request) Successful in 45s
PR checks / integration (pull_request) Successful in 32s
feat(po): user-defined T&C categories + dynamic PO terms editor (#11)
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>
2026-06-24 04:43:24 +05:30

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>
);
}