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 (
{label}
{children}
);
}
export function ProcurementFormBlock({
commodityCode,
intro,
catalogPath,
codeSummary,
onCommitPurchase,
onAddNew,
}: Props) {
const [schemaLoading, setSchemaLoading] = useState(true);
const [schemaError, setSchemaError] = useState(null);
const [fields, setFields] = useState([]);
const [intervalChoices, setIntervalChoices] = useState(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>({});
const [confirmBusy, setConfirmBusy] = useState(false);
const [buildError, setBuildError] = useState(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 = {};
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 (
);
}
if (f.type === "chips" && f.options?.length) {
return (
{f.options.map((o) => (
))}
);
}
if (f.type === "number") {
const unit = (f.unit?.trim() || inferredNumberUnit(f.label)) ?? "";
return (
setDyn(f.id, e.target.value)}
aria-describedby={unit ? `${f.id}-unit-hint` : undefined}
/>
{unit ? (
<>
Enter value in {unitBadgeText(unit)}
{unitBadgeText(unit)}
>
) : null}
);
}
if (f.type === "textarea") {
return (
);
}
return (
setDyn(f.id, e.target.value)}
/>
);
};
const onConfirm = async () => {
setBuildError(null);
setJustAdded(false);
setConfirmBusy(true);
try {
const nums: Record = { ...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 (
{intro}
{(catalogPath || codeSummary) && (
{codeSummary ? (
{codeSummary}
) : null}
{catalogPath ? (
{catalogPath}
) : null}
)}
{schemaLoading ? (
Loading catalogue-specific questions…
) : null}
{schemaError ? (
{schemaError}
) : null}
{buildError ? (
{buildError}
) : null}
{justAdded ? (
Line items added to the table on the right.
) : null}
);
}