PR-AGENT / src /components /ProcurementFormBlock.tsx
Seth
Update
f3726ae
import { useEffect, useState, type ReactNode } from "react";
import type { DynamicField, PrRow } from "../api/form";
import type { PurchaseFormSnapshot } from "../types/purchasePayload";
import { buildPrLines, fetchFormSchema } from "../api/form";
import { Icon } from "./Icon";
const FALLBACK_INTERVALS = [
"Daily",
"Weekly",
"Monthly",
"Bi-Monthly",
"Quarterly",
"Bi-Annual",
"Annual",
];
/** Aligns label baselines for side-by-side fields; controls share one height. */
const CTRL =
"h-11 w-full box-border rounded border border-outline-variant bg-surface-container-low px-2.5 text-body-sm";
const LABEL_CLASS =
"min-h-[3rem] flex items-end text-[11px] font-bold text-secondary leading-snug";
/** Unit suffix for number fields when schema omits `unit` (cached / legacy schemas). */
function inferredNumberUnit(label: string): string | undefined {
const L = label.toLowerCase();
if (/\b(kilograms?|kg)\b/i.test(label)) return "kg";
if (/\b(weight|mass|load capacity|weight capacity)\b/.test(L)) return "kg";
if (/\b(pounds?|lbs?)\b/i.test(L)) return "lb";
if (
/\b(millimeters?|millimetres?|mm)\b/i.test(L) ||
/\b(seat depth|seat width|seat height)\b/.test(L)
)
return "mm";
if (/\b(centimeters?|centimetres?|cm)\b/i.test(L)) return "cm";
if (/\b(inches|inch)\b|\bin\.\b/i.test(L)) return "in";
if (/\b(meters?|metres?)\b/i.test(L)) return "m";
if (/\bpercent\b|%/i.test(L)) return "%";
return undefined;
}
function unitBadgeText(unit: string): string {
const u = unit.trim();
if (!u) return u;
// Short measurement tokens: show uppercase (kg → KG); leave longer symbols alone.
return u.length <= 6 ? u.toUpperCase() : u;
}
type Props = {
commodityCode: number;
intro: string;
catalogPath?: string;
codeSummary?: string;
onCommitPurchase: (payload: {
rows: PrRow[];
snapshot: PurchaseFormSnapshot;
}) => void;
onAddNew: () => void;
};
function FieldCell({
label,
children,
className = "col-span-12 md:col-span-6",
}: {
label: string;
children: ReactNode;
className?: string;
}) {
return (
<div
className={`flex flex-col gap-1.5 ${className}`}
>
<span className={LABEL_CLASS}>{label}</span>
{children}
</div>
);
}
export function ProcurementFormBlock({
commodityCode,
intro,
catalogPath,
codeSummary,
onCommitPurchase,
onAddNew,
}: Props) {
const [schemaLoading, setSchemaLoading] = useState(true);
const [schemaError, setSchemaError] = useState<string | null>(null);
const [fields, setFields] = useState<DynamicField[]>([]);
const [intervalChoices, setIntervalChoices] = useState<string[]>(FALLBACK_INTERVALS);
const [deliveries, setDeliveries] = useState(4);
const [interval, setInterval] = useState("Quarterly");
const [year, setYear] = useState(2026);
const [otherSpec, setOtherSpec] = useState("");
const [dynamicValues, setDynamicValues] = useState<Record<string, string>>({});
const [confirmBusy, setConfirmBusy] = useState(false);
const [buildError, setBuildError] = useState<string | null>(null);
const [justAdded, setJustAdded] = useState(false);
/** Enables "Add new" only after this block has successfully committed lines to the PR table. */
const [hasCommittedLines, setHasCommittedLines] = useState(false);
useEffect(() => {
if (!intervalChoices.length) return;
if (!intervalChoices.includes(interval)) {
setInterval(intervalChoices[0] ?? "Quarterly");
}
}, [intervalChoices, interval]);
useEffect(() => {
let cancelled = false;
setSchemaLoading(true);
setSchemaError(null);
fetchFormSchema(commodityCode)
.then((s) => {
if (cancelled) return;
if (s.error) setSchemaError(s.error);
setFields(s.fields ?? []);
if (s.interval_options?.length) setIntervalChoices(s.interval_options);
const init: Record<string, string> = {};
for (const f of s.fields ?? []) init[f.id] = "";
setDynamicValues(init);
})
.catch((e) =>
setSchemaError(e instanceof Error ? e.message : String(e))
)
.finally(() => {
if (!cancelled) setSchemaLoading(false);
});
return () => {
cancelled = true;
};
}, [commodityCode]);
const setDyn = (id: string, v: string) => {
setDynamicValues((prev) => ({ ...prev, [id]: v }));
};
const renderDynamicField = (f: DynamicField) => {
const v = dynamicValues[f.id] ?? "";
if (f.type === "select" && f.options?.length) {
return (
<FieldCell key={f.id} label={f.label}>
<select
className={`${CTRL} font-semibold focus:border-primary focus:ring-1 focus:ring-primary`}
value={v}
onChange={(e) => setDyn(f.id, e.target.value)}
>
<option value="">Select…</option>
{f.options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</FieldCell>
);
}
if (f.type === "chips" && f.options?.length) {
return (
<FieldCell key={f.id} label={f.label} className="col-span-12">
<div className="flex min-h-11 flex-wrap items-center gap-2">
{f.options.map((o) => (
<button
key={o}
type="button"
onClick={() => setDyn(f.id, o)}
className={`min-h-11 rounded px-3 py-2 text-xs font-semibold transition-colors ${
v === o
? "bg-primary text-on-primary"
: "border border-outline-variant bg-surface-container-low text-secondary hover:bg-surface-container-high"
}`}
>
{o}
</button>
))}
</div>
</FieldCell>
);
}
if (f.type === "number") {
const unit = (f.unit?.trim() || inferredNumberUnit(f.label)) ?? "";
return (
<FieldCell key={f.id} label={f.label}>
<div className="flex min-h-11 items-stretch gap-2">
<input
type="number"
className={`${CTRL} min-w-0 flex-1 font-bold`}
value={v}
onChange={(e) => setDyn(f.id, e.target.value)}
aria-describedby={unit ? `${f.id}-unit-hint` : undefined}
/>
{unit ? (
<>
<span id={`${f.id}-unit-hint`} className="sr-only">
Enter value in {unitBadgeText(unit)}
</span>
<span
className="flex shrink-0 items-center rounded border border-outline-variant bg-surface-container-high px-3 text-xs font-bold uppercase tracking-wide text-secondary"
aria-hidden
>
{unitBadgeText(unit)}
</span>
</>
) : null}
</div>
</FieldCell>
);
}
if (f.type === "textarea") {
return (
<FieldCell
key={f.id}
label={f.label}
className="col-span-12"
>
<textarea
className="min-h-[5.5rem] w-full box-border rounded border border-outline-variant bg-surface-container-low p-2.5 text-body-sm font-semibold"
value={v}
onChange={(e) => setDyn(f.id, e.target.value)}
/>
</FieldCell>
);
}
return (
<FieldCell key={f.id} label={f.label}>
<input
type="text"
className={`${CTRL} font-semibold`}
value={v}
onChange={(e) => setDyn(f.id, e.target.value)}
/>
</FieldCell>
);
};
const onConfirm = async () => {
setBuildError(null);
setJustAdded(false);
setConfirmBusy(true);
try {
const nums: Record<string, string | number> = { ...dynamicValues };
for (const f of fields) {
if (f.type === "number" && nums[f.id] !== "") {
const n = Number(nums[f.id]);
nums[f.id] = Number.isFinite(n) ? n : nums[f.id];
}
}
const { rows } = await buildPrLines({
commodity_code: commodityCode,
dynamic_values: nums,
deliveries,
interval,
other_spec: otherSpec,
year,
});
if (!rows?.length) {
setBuildError("No line items were generated. Check deliveries and try again.");
return;
}
const snapshot: PurchaseFormSnapshot = {
commodityCode,
intro,
catalogPath,
codeSummary,
schedule: {
numberOfDeliveries: deliveries,
intervalOfDeliveries: interval,
yearForDeliverySchedule: year,
},
fields: fields.map((f) => {
const raw = nums[f.id];
const answer: string | number =
raw === "" || raw === undefined
? ""
: typeof raw === "number"
? raw
: String(raw);
const u = f.type === "number" ? f.unit?.trim() || inferredNumberUnit(f.label) : undefined;
return {
id: f.id,
label: f.label,
type: f.type,
answer,
...(u ? { unit: u } : {}),
};
}),
otherSpecification: otherSpec,
};
onCommitPurchase({ rows, snapshot });
setHasCommittedLines(true);
setJustAdded(true);
window.setTimeout(() => setJustAdded(false), 5000);
} catch (e) {
setBuildError(e instanceof Error ? e.message : String(e));
} finally {
setConfirmBusy(false);
}
};
return (
<div className="flex justify-start">
<div className="flex items-start gap-3 w-full">
<div className="w-8 h-8 rounded-full bg-secondary-container flex items-center justify-center shrink-0">
<Icon
name="smart_toy"
className="text-on-secondary-container text-sm"
/>
</div>
<div className="min-w-0 flex-1">
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant">
<p className="font-body-md text-body-md text-primary mb-4">{intro}</p>
{(catalogPath || codeSummary) && (
<div className="mb-6 rounded-xl border border-outline-variant bg-surface-container-high/80 px-4 py-3">
{codeSummary ? (
<p className="font-data-mono text-data-mono text-xs font-semibold text-primary break-all">
{codeSummary}
</p>
) : null}
{catalogPath ? (
<p className="mt-2 font-body-sm text-body-sm text-secondary break-words">
{catalogPath}
</p>
) : null}
</div>
)}
{schemaLoading ? (
<p className="font-body-sm text-secondary mb-4">
Loading catalogue-specific questions…
</p>
) : null}
{schemaError ? (
<p className="font-body-sm text-error mb-4">{schemaError}</p>
) : null}
<div className="bg-white rounded-xl border border-outline-variant overflow-hidden mb-6">
<div className="bg-surface-container-high px-4 py-2 border-b border-outline-variant">
<span className="font-label-caps text-[10px] text-secondary uppercase font-bold">
Line Item Details
</span>
</div>
<div className="grid grid-cols-12 gap-x-6 gap-y-5 p-4">
<FieldCell label="Number of deliveries" className="col-span-12 md:col-span-4">
<input
type="number"
min={1}
max={52}
className={`${CTRL} font-bold`}
value={deliveries}
onChange={(e) =>
setDeliveries(Math.max(1, Number(e.target.value) || 1))
}
/>
</FieldCell>
<FieldCell label="Interval of deliveries" className="col-span-12 md:col-span-4">
<select
className={`${CTRL} font-semibold focus:border-primary focus:ring-1 focus:ring-primary`}
value={interval}
onChange={(e) => setInterval(e.target.value)}
>
{intervalChoices.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</FieldCell>
<FieldCell
label="Year (for delivery schedule)"
className="col-span-12 md:col-span-4"
>
<input
type="number"
className={`${CTRL} font-bold`}
value={year}
onChange={(e) =>
setYear(Number(e.target.value) || new Date().getFullYear())
}
/>
</FieldCell>
{fields.map((f) => renderDynamicField(f))}
<FieldCell label="Other specification (free text)" className="col-span-12">
<textarea
className="min-h-[5.5rem] w-full box-border rounded border border-outline-variant bg-surface-container-low p-2.5 text-body-sm font-semibold"
placeholder="e.g. Age 18+"
value={otherSpec}
onChange={(e) => setOtherSpec(e.target.value)}
/>
</FieldCell>
<div className="col-span-12 flex flex-col gap-2 rounded-lg border border-[#10B981]/20 bg-[#10B981]/10 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Icon name="check_circle" className="text-[#10B981]" />
<span className="text-body-sm font-semibold text-[#10B981]">
Budget Impact: Within Allocation
</span>
</div>
<span className="text-xs font-bold text-[#10B981]">
AVAILABLE
</span>
</div>
</div>
</div>
{buildError ? (
<p className="mb-3 text-body-sm text-error">{buildError}</p>
) : null}
{justAdded ? (
<p className="mb-3 text-body-sm font-semibold text-[#10B981]">
Line items added to the table on the right.
</p>
) : null}
<div className="border-t border-outline-variant pt-4">
<div className="flex flex-col gap-3 sm:flex-row">
<button
type="button"
disabled={confirmBusy || schemaLoading}
onClick={onConfirm}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-6 py-3 text-body-sm font-bold text-white shadow-sm transition-all hover:opacity-90 active:scale-[0.98] disabled:opacity-50"
>
{confirmBusy ? "Building…" : "Confirm Selection"}
<Icon name="task_alt" className="text-sm" />
</button>
<button
type="button"
disabled={!hasCommittedLines || confirmBusy}
title={
hasCommittedLines
? "Add another catalogue line to this request"
: "Confirm selection first to add another line"
}
onClick={onAddNew}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-outline-variant bg-white px-6 py-3 text-body-sm font-semibold text-secondary transition-colors hover:bg-surface-container-high disabled:cursor-not-allowed disabled:opacity-45"
>
Add new
<Icon name="add" className="text-sm" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}