Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |