pelagia-portal/App/lib/terms.ts
Hardik 5764403f1c feat(po): user-defined T&C categories + dynamic PO terms editor (#11)
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>
2026-06-24 04:08:30 +05:30

50 lines
2.3 KiB
TypeScript

/**
* Terms & Conditions catalogue (issue #11) — admin-managed categories + clauses
* that feed the PO's dynamic T&C editor. Categories are user-defined data.
*/
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
// One chosen T&C row on a PO (stored as a JSON snapshot in PurchaseOrder.terms).
export type PoTerm = { category: string; text: string };
// A catalogue category with its active clause texts — passed to the PO editor.
export type CatalogueCategory = { id: string; name: string; clauses: string[] };
// Legacy PO (no `terms` JSON yet) → editable rows, mapping the old tc* columns +
// 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);
}
/** Coerce an unknown (DB JSON / parsed form value) into a clean PoTerm[]. */
export function parsePoTerms(value: unknown): PoTerm[] {
if (!Array.isArray(value)) return [];
const out: PoTerm[] = [];
for (const row of value) {
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();
// A row needs at least some text to be meaningful; category may be blank.
if (text) out.push({ category, text });
}
return out;
}