import { useEffect, useMemo, useState } from "react"; import { Loader2 } from "lucide-react"; import { Modal, Button, TextInput, Textarea, MultiSelect, Select, Switch, Stack, Group, Text, Box, SimpleGrid, Divider, } from "@mantine/core"; import { RULE_ENGINE_SELECT_NONE, type FormFieldDef, } from "@/pages/ruleEngine/config/resources"; import type { RuleEngineRecord } from "@/types/rule-engine"; import { RULE_ENGINE_POSITION_END } from "./ruleEngineOrder.utils"; export interface RuleEngineFormDialogProps { open: boolean; onOpenChange: (open: boolean) => void; title: string; description: string; fields: FormFieldDef[]; initialRecord?: RuleEngineRecord | null; isSubmitting: boolean; selectOptionsLoading?: boolean; positionOptions?: { label: string; value: string }[]; positionLoading?: boolean; onSubmit: (values: Record) => void; } type FormRow = | { kind: "pair"; fields: [FormFieldDef, FormFieldDef] } | { kind: "single"; field: FormFieldDef }; const isShortField = (field: FormFieldDef) => field.type === "text" || field.type === "number" || field.type === "select" || field.type === "date"; const buildFormRows = (fields: FormFieldDef[]): FormRow[] => { const rows: FormRow[] = []; let index = 0; while (index < fields.length) { const field = fields[index]; if (field.type === "textarea" || field.type === "boolean") { rows.push({ kind: "single", field }); index += 1; continue; } const next = fields[index + 1]; if (next && isShortField(next)) { rows.push({ kind: "pair", fields: [field, next] }); index += 2; continue; } rows.push({ kind: "single", field }); index += 1; } return rows; }; const buildInitialValues = ( fields: FormFieldDef[], record?: RuleEngineRecord | null, ): Record => { const values: Record = {}; for (const field of fields) { const raw = field.getInitialValue && record ? field.getInitialValue(record) : record?.[field.name]; if (field.type === "multiselect") { values[field.name] = Array.isArray(raw) ? raw.map(String) : []; } else if (raw !== undefined && raw !== null) { if (field.type === "date" && typeof raw === "string") { values[field.name] = raw.slice(0, 10); } else if (Array.isArray(raw)) { values[field.name] = raw.join(", "); } else { values[field.name] = raw; } } else if (field.type === "boolean") { values[field.name] = false; } else if (field.type === "number") { values[field.name] = ""; } else { values[field.name] = ""; } } return values; }; const resolveSelectValue = ( field: FormFieldDef, values: Record, ): string | undefined => { const raw = values[field.name]; const isEmpty = raw === "" || raw === null || raw === undefined; if (field.optional && isEmpty) { return RULE_ENGINE_SELECT_NONE; } if (isEmpty) { return undefined; } return String(raw); }; const inputStyles = { label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" }, } as const; const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => ( {label} {required ? ( * ) : null} ); const RuleEngineFormDialog = ({ open, onOpenChange, title, fields, initialRecord, isSubmitting, selectOptionsLoading = false, positionOptions, positionLoading = false, onSubmit, }: RuleEngineFormDialogProps) => { const [values, setValues] = useState>(() => buildInitialValues(fields, initialRecord), ); const [position, setPosition] = useState(RULE_ENGINE_POSITION_END); useEffect(() => { if (open) { setValues(buildInitialValues(fields, initialRecord)); setPosition(RULE_ENGINE_POSITION_END); } }, [open, fields, initialRecord]); const visibleFields = useMemo( () => fields.filter((field) => { if ( field.hideWhen && field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")) ) { return false; } if ( field.showWhen && !field.showWhen.equals.includes(String(values[field.showWhen.field] ?? "")) ) { return false; } if (field.showIf && !field.showIf(values)) return false; return true; }), [fields, values], ); const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]); const setField = (name: string, value: unknown) => { setValues((current) => { const next = { ...current, [name]: value }; // Changing what a rate applies to (or its surcharge trigger) can invalidate // the previously-chosen unit — reset it so the admin re-picks from the new // allowed set instead of submitting a stale, rejected unit. if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) { next.rateUnit = ""; } // The legal yards depend on what the rate is for and which way it runs, so // a leg picked under the old answer is no longer valid — clear it instead // of submitting a pair the API will reject. if ( (name === "appliesTo" || name === "tradeDirection") && "originYardId" in current ) { next.originYardId = ""; next.destinationYardId = ""; } // Intercity asks for a container type or a bulk cargo type, never both — // switching kind drops whichever the other kind had filled in. if (name === "intercityKind") { next.containerTypeId = ""; next.cargoTypeId = ""; } // Cargo kind (customs / lashing) decides both the container-type scope // and the legal units (container → per box/wagon, bulk → per ton/wagon). if (name === "cargoKind") { next.containerTypeId = ""; next.cargoTypeId = ""; next.rateUnit = ""; } return next; }); }; const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); const payload: Record = {}; for (const field of visibleFields) { // Derived fields always submit their computed value — never stale state. const raw = field.computeValue ? (field.computeValue(values) ?? "") : values[field.name]; if (field.type === "multiselect") { // Always the full replacement list — the API syncs the relation to it. payload[field.name] = Array.isArray(raw) ? raw : []; } else if (field.type === "number") { if (raw === "" || raw === undefined) continue; payload[field.name] = Number(raw); } else if (field.type === "boolean") { payload[field.name] = Boolean(raw); } else if ( field.type === "select" && (raw === "" || raw === RULE_ENGINE_SELECT_NONE) ) { if (!field.required) continue; } else if (raw === "" || raw === undefined) { if (!field.required) continue; payload[field.name] = raw; } else { payload[field.name] = raw; } } if (fields.some((f) => f.name === "code" && typeof payload.code === "string")) { payload.code = String(payload.code).toUpperCase(); } if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) { payload.insertAfterId = position; } onSubmit(payload); }; const renderField = (field: FormFieldDef) => { if (field.type === "boolean") { return ( {field.label} setField(field.name, e.currentTarget.checked)} size="md" color="edr-green" /> ); } const label = ; if (field.type === "multiselect") { const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []); const selected = Array.isArray(values[field.name]) ? (values[field.name] as string[]) : []; return ( setField(field.name, v)} disabled={selectOptionsLoading} data={options .filter((opt) => opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE) .map((opt) => ({ label: opt.label, value: opt.value }))} searchable clearable size="md" radius="md" styles={inputStyles} /> ); } if (field.type === "select") { // Dynamic options (e.g. rate unit) resolve from the live form values so // the choices track the other fields the admin has picked. const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []); return (