import { useEffect, useMemo, useState } from "react"; import { DateInput } from "@mantine/dates"; import { Loader2, Plus, Trash2 } from "lucide-react"; import { ActionIcon, 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 }; /** One editable distance tier of a tierList field (raw input strings). */ type TierRow = { minKm: string; maxKm: string; rateValue: string }; const emptyTier = (fromKm = ""): TierRow => ({ minKm: fromKm, maxKm: "", rateValue: "" }); /** * Validate a tier set before submit: every tier complete, ranges sane, no * overlaps, and only the last tier open-ended. Returns the error message, or * null when the set is valid. */ const validateTiers = (rows: TierRow[]): string | null => { if (!rows.length) return "Add at least one tier."; for (const row of rows) { if (row.minKm === "" || row.rateValue === "") { return "Every tier needs a From km and a Rate value."; } if (row.maxKm !== "" && Number(row.maxKm) <= Number(row.minKm)) { return "Each tier's To km must be greater than its From km."; } } const sorted = [...rows].sort((a, b) => Number(a.minKm) - Number(b.minKm)); for (let i = 1; i < sorted.length; i += 1) { const prev = sorted[i - 1]; if (prev.maxKm === "") return "Only the last tier can leave To km empty."; if (Number(sorted[i].minKm) < Number(prev.maxKm)) { return `Tiers overlap around ${sorted[i].minKm} km — each distance must fall in exactly one tier.`; } } return null; }; 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" || field.type === "tierList") { 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 (field.type === "tierList") { values[field.name] = [emptyTier("0")]; } 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.defaultValue !== undefined) { values[field.name] = field.defaultValue; } 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 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); const [fieldErrors, setFieldErrors] = useState>({}); useEffect(() => { if (open) { setValues(buildInitialValues(fields, initialRecord)); setPosition(RULE_ENGINE_POSITION_END); setFieldErrors({}); } }, [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) => { setFieldErrors((current) => current[name] ? { ...current, [name]: "" } : current, ); setValues((current) => { // Mantine fires onChange even when the same option is re-picked, and the // cascades below clear dependent answers (yards, unit, scope). Re-picking // an unchanged value must be a no-op, or an untouched direction silently // wipes the yard pair and the submit fails with "origin/destination // missing" data the admin did fill in. if (current[name] === value) return 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 / cancellation) 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 = ""; } // Full customs and Ethiopian-only customs are alternatives on a service // type — switching one on drops the other so the API never sees both. if (name === "includesCustoms" && value === true) { next.includesEthiopianCustomsOnly = false; } if (name === "includesEthiopianCustomsOnly" && value === true) { next.includesCustoms = false; } // Turning the shipping-line toggle on or off swaps the entire form, so // nothing answered under the other shape may survive into the payload. if (name === "isShippingLineRate") { next.shippingLineCompanyId = ""; next.shippingLineRateKind = ""; next.shippingLineCargoKind = ""; next.appliesTo = ""; next.trigger = ""; next.containerTypeId = ""; next.cargoTypeId = ""; next.originYardId = ""; next.destinationYardId = ""; next.rateUnit = ""; } // Base-vs-surcharge and container-vs-bulk each decide the scope field and // the legal units for a shipping-line rate, exactly as appliesTo and // cargoKind do on the customer form. if (name === "shippingLineRateKind" || name === "shippingLineCargoKind") { next.containerTypeId = ""; next.cargoTypeId = ""; next.rateUnit = ""; if (name === "shippingLineRateKind") { next.shippingLineCargoKind = ""; next.trigger = ""; next.originYardId = ""; next.destinationYardId = ""; } } return next; }); }; const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); const payload: Record = {}; // Required selects that are empty block the submit and mark themselves, // rather than posting an incomplete payload for the API to reject. setFieldErrors({}); let blocked = false; 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 === "tierList") { const rows = (Array.isArray(raw) ? raw : []) as TierRow[]; const error = validateTiers(rows); if (error) { setFieldErrors((current) => ({ ...current, [field.name]: error })); blocked = true; } else { payload[field.name] = rows.map((row) => ({ minKm: Number(row.minKm), maxKm: row.maxKm === "" ? null : Number(row.maxKm), rateValue: Number(row.rateValue), })); } } 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) ) { // A required select left empty must not silently submit nothing — the // API rejects the payload with a message that reads as if the admin // skipped a field they never saw cleared (e.g. yards reset by a trade // direction change). Surface it on the field instead. if (!field.required) continue; setFieldErrors((current) => ({ ...current, [field.name]: `${field.label} is required.`, })); blocked = true; } 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 (blocked) return; if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) { payload.insertAfterId = position; } onSubmit(payload); }; const renderField = (field: FormFieldDef) => { if (field.type === "boolean") { return ( {field.label} {field.description ? ( {field.description} ) : null} setField(field.name, e.currentTarget.checked)} // A toggle that re-targets what an existing record means (e.g. who // a rate is priced for) is create-only — flipping it on a saved row // would silently change every booking that prices off it. disabled={ field.disabled || (field.disabledOnEdit && !!initialRecord) || field.disabledIf?.(values) === true } size="md" color="edr-green" /> ); } // A plain string, so Mantine renders the label and its required asterisk // itself. Passing an element here put a flex box inside the