Files
edr-platform/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx
2026-08-21 08:58:27 +00:00

774 lines
26 KiB
TypeScript

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<string, unknown>) => 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<string, unknown> => {
const values: Record<string, unknown> = {};
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, unknown>,
): 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<Record<string, unknown>>(() =>
buildInitialValues(fields, initialRecord),
);
const [position, setPosition] = useState(RULE_ENGINE_POSITION_END);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
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<string, unknown> = {};
// 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 (
<Group
key={field.name}
justify="space-between"
align="center"
wrap="nowrap"
gap="md"
px="md"
style={{
minHeight: 42,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Stack gap={2}>
<Text size="sm" fw={600}>
{field.label}
</Text>
{field.description ? (
<Text size="xs" c="dimmed">
{field.description}
</Text>
) : null}
</Stack>
<Switch
checked={Boolean(values[field.name])}
onChange={(e) => 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"
/>
</Group>
);
}
// A plain string, so Mantine renders the label and its required asterisk
// itself. Passing an element here put a flex box inside the <label>,
// which added a line of dead space above every input and bumped
// Mantine's own asterisk onto a line of its own.
const label = field.label;
if (field.type === "tierList") {
const rows = Array.isArray(values[field.name])
? (values[field.name] as TierRow[])
: [];
const setRows = (next: TierRow[]) => setField(field.name, next);
const setRow = (index: number, key: keyof TierRow, value: string) => {
if (value.trim().startsWith("-")) return;
setRows(rows.map((row, i) => (i === index ? { ...row, [key]: value } : row)));
};
return (
<Box key={field.name}>
<Text size="sm" fw={600} mb={2} c="var(--mantine-color-gray-8)">
{label}
</Text>
{field.description ? (
<Text size="xs" c="dimmed" mb={8}>
{field.description}
</Text>
) : null}
<Stack gap="xs">
{rows.map((row, index) => (
<Group key={index} gap="xs" wrap="nowrap" align="flex-end">
<TextInput
label={index === 0 ? "From km" : undefined}
type="number"
min={0}
step="any"
placeholder="0"
value={row.minKm}
onChange={(e) => setRow(index, "minKm", e.currentTarget.value)}
size="md"
radius="md"
styles={inputStyles}
style={{ flex: 1 }}
/>
<TextInput
label={index === 0 ? "To km" : undefined}
type="number"
min={0}
step="any"
placeholder="No limit"
value={row.maxKm}
onChange={(e) => setRow(index, "maxKm", e.currentTarget.value)}
size="md"
radius="md"
styles={inputStyles}
style={{ flex: 1 }}
/>
<TextInput
label={index === 0 ? "Rate value" : undefined}
type="number"
min={0}
step="any"
placeholder="Rate per km"
value={row.rateValue}
onChange={(e) => setRow(index, "rateValue", e.currentTarget.value)}
size="md"
radius="md"
styles={inputStyles}
style={{ flex: 1 }}
/>
<ActionIcon
variant="subtle"
color="red"
size="lg"
mb={2}
aria-label="Remove tier"
disabled={rows.length === 1}
onClick={() => setRows(rows.filter((_, i) => i !== index))}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
))}
<Group justify="flex-start">
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
// The next tier naturally starts where the previous one ends.
onClick={() => setRows([...rows, emptyTier(rows[rows.length - 1]?.maxKm ?? "")])}
>
Add tier
</Button>
</Group>
{fieldErrors[field.name] ? (
<Text size="xs" c="red">
{fieldErrors[field.name]}
</Text>
) : null}
</Stack>
</Box>
);
}
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 (
<MultiSelect
key={field.name}
label={label}
withAsterisk={field.required}
description={field.description}
placeholder={
selectOptionsLoading
? "Loading options..."
: (field.placeholder ?? "Select one or more")
}
value={selected}
onChange={(v) => 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 ?? []);
// A derived select shows (and submits) its computed value and is locked,
// matching the text-input branch — used by fields the shape decides on the
// admin's behalf, e.g. a shipping-line rate's import-only direction.
const computedSelect = field.computeValue
? String(field.computeValue(values) ?? "")
: undefined;
return (
<Select
key={field.name}
label={label}
description={field.description}
placeholder={
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
}
value={
computedSelect !== undefined
? computedSelect
: resolveSelectValue(field, values)
}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
disabled={
selectOptionsLoading ||
field.disabled ||
(field.disabledOnEdit && !!initialRecord) ||
computedSelect !== undefined
}
// Mantine's Select is not a native input, so `required` only marks it
// visually — handleSubmit is what actually blocks an empty one.
required={field.required}
error={fieldErrors[field.name] || undefined}
data={options
.filter((opt) => opt.value !== "")
.map((opt) => ({
label: opt.label,
value: opt.value,
}))}
searchable
clearable
size="md"
radius="md"
styles={inputStyles}
/>
);
}
if (field.type === "textarea") {
return (
<Textarea
key={field.name}
label={label}
value={String(values[field.name] ?? "")}
onChange={(e) => setField(field.name, e.currentTarget.value)}
placeholder={field.placeholder}
required={field.required}
minRows={4}
autosize
maxRows={8}
size="md"
radius="md"
styles={inputStyles}
/>
);
}
if (field.type === "date") {
const raw = String(values[field.name] ?? "");
return (
<DateInput
key={field.name}
label={label}
description={field.description}
placeholder="Select date"
// Mantine's DateValue accepts a `YYYY-MM-DD` string, which is exactly
// what the API's date columns take — so the value passes straight
// through with no Date round-trip, and none of the UTC-parsing shift
// that `new Date("2026-01-01")` would introduce east of Greenwich.
value={raw || null}
onChange={(v) => setField(field.name, v ?? "")}
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
required={field.required}
error={fieldErrors[field.name] || undefined}
clearable
size="md"
radius="md"
styles={inputStyles}
/>
);
}
const isNumber = field.type === "number";
const computed = field.computeValue ? field.computeValue(values) : undefined;
return (
<TextInput
key={field.name}
label={label}
description={field.description}
type={isNumber ? "number" : "text"}
// Every rule-engine number (sizes, capacities, counts, points, rates,
// display order) is a non-negative magnitude — reject negatives outright
// rather than letting a typed "-" reach the API.
min={isNumber ? 0 : undefined}
// Without an explicit step the browser assumes 1 and refuses to submit
// anything fractional — which blocked decimal rates, tonnages and
// distances. The API is the one that decides which of these are whole
// numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km),
// so let the field carry decimals and let a 400 catch the rest.
step={isNumber ? "any" : undefined}
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord) || computed !== undefined}
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
onChange={(e) => {
const next = e.currentTarget.value;
if (isNumber && next.trim().startsWith("-")) return;
setField(field.name, next);
}}
placeholder={field.placeholder}
required={field.required}
size="md"
radius="md"
styles={inputStyles}
rightSection={
field.suffix ? (
<Text size="sm" c="dimmed" fw={600} pr={4}>
{field.suffix}
</Text>
) : undefined
}
rightSectionWidth={field.suffix ? 52 : undefined}
/>
);
};
return (
<Modal
opened={open}
onClose={() => onOpenChange(false)}
title={
<Text size="lg" fw={700} lh={1.2}>
{title}
</Text>
}
centered
size={720}
radius="lg"
padding="xl"
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
styles={{
content: {
maxWidth: "min(720px, 95vw)",
},
body: {
paddingTop: 20,
},
}}
>
<form onSubmit={handleSubmit}>
<Stack gap="lg">
<Box style={{ maxHeight: "calc(65vh - 120px)", overflowY: "auto", paddingRight: 4 }}>
<Stack gap="md">
{!initialRecord && positionOptions ? (
<Select
label="Position"
description="New items are appended to the end by default."
value={position}
onChange={(value) => setPosition(value ?? RULE_ENGINE_POSITION_END)}
data={[
{ label: "At end (default)", value: RULE_ENGINE_POSITION_END },
...positionOptions,
]}
searchable
disabled={positionLoading}
size="md"
radius="md"
styles={inputStyles}
/>
) : null}
{formRows.map((row) =>
row.kind === "pair" ? (
<SimpleGrid key={`${row.fields[0].name}-${row.fields[1].name}`} cols={2} spacing="md">
<Box style={{ minWidth: 0 }}>{renderField(row.fields[0])}</Box>
<Box style={{ minWidth: 0 }}>{renderField(row.fields[1])}</Box>
</SimpleGrid>
) : (
<Box key={row.field.name}>{renderField(row.field)}</Box>
),
)}
</Stack>
</Box>
<Divider />
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
radius="md"
size="md"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
leftSection={
isSubmitting ? (
<Loader2 size={18} style={{ animation: "spin 1s linear infinite" }} />
) : undefined
}
radius="md"
color="edr-green"
variant="filled"
fw={600}
size="md"
>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
};
export default RuleEngineFormDialog;