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>
122 lines
5 KiB
TypeScript
122 lines
5 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
|
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;
|
|
categoryName: string;
|
|
text: string;
|
|
isDefault: boolean;
|
|
isActive: boolean;
|
|
};
|
|
|
|
function Fields({ term, categoryNames }: { term?: TermRow; categoryNames: string[] }) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Category *</label>
|
|
<input
|
|
name="categoryName"
|
|
list="tc-category-list"
|
|
defaultValue={term?.categoryName ?? ""}
|
|
required
|
|
autoComplete="off"
|
|
className={INPUT}
|
|
placeholder="Pick a category or type a new one…"
|
|
/>
|
|
<datalist id="tc-category-list">
|
|
{categoryNames.map((c) => (
|
|
<option key={c} value={c} />
|
|
))}
|
|
</datalist>
|
|
<p className="mt-1 text-xs text-neutral-400">Type a new name to create a category.</p>
|
|
</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>
|
|
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
|
<input type="checkbox" name="isDefault" defaultChecked={term?.isDefault ?? false} className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/30" />
|
|
Pre-add to new POs by default
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AddTermButton({ categoryNames }: { categoryNames: string[] }) {
|
|
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 categoryNames={categoryNames} />
|
|
{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,
|
|
categoryNames,
|
|
open: controlledOpen,
|
|
onOpenChange,
|
|
}: {
|
|
term: TermRow;
|
|
categoryNames: string[];
|
|
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} categoryNames={categoryNames} />
|
|
{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>
|
|
);
|
|
}
|