mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
511 lines
15 KiB
TypeScript
511 lines
15 KiB
TypeScript
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<string, unknown>) => 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<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 (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, 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 FieldLabel = ({ label, required }: { label: string; required?: boolean }) => (
|
|
<Group gap={4} wrap="nowrap">
|
|
<span>{label}</span>
|
|
{required ? (
|
|
<Text component="span" c="red" size="sm">
|
|
*
|
|
</Text>
|
|
) : null}
|
|
</Group>
|
|
);
|
|
|
|
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);
|
|
|
|
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<string, unknown> = {};
|
|
|
|
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 (
|
|
<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)",
|
|
}}
|
|
>
|
|
<Text size="sm" fw={600}>
|
|
{field.label}
|
|
</Text>
|
|
<Switch
|
|
checked={Boolean(values[field.name])}
|
|
onChange={(e) => setField(field.name, e.currentTarget.checked)}
|
|
size="md"
|
|
color="edr-green"
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
const label = <FieldLabel label={field.label} required={field.required} />;
|
|
|
|
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}
|
|
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 ?? []);
|
|
return (
|
|
<Select
|
|
key={field.name}
|
|
label={label}
|
|
description={field.description}
|
|
placeholder={
|
|
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
|
}
|
|
value={resolveSelectValue(field, values)}
|
|
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
|
disabled={selectOptionsLoading}
|
|
// Native required blocks submit while a mandatory select is empty —
|
|
// without it the form posts and the API 400s (e.g. a container
|
|
// customs/lashing rate with no container type picked).
|
|
required={field.required}
|
|
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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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" : field.type === "date" ? "date" : "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}
|
|
disabled={field.disabled || 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;
|