| 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", |
| ]; |
|
|
| |
| 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"; |
|
|
| |
| 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; |
| |
| 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); |
| |
| 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> |
| ); |
| } |
|
|