mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Date fields rendered as a native <input type="date">, which ignores the
form's styling and varies by browser. They now use Mantine's DateInput,
kept on the `YYYY-MM-DD` string the API's date columns take — no Date
round-trip, so none of the UTC-parsing shift that new Date("2026-01-01")
introduces east of Greenwich.
Labels were passed as an element, which put a flex box inside the
<label>: a line of dead space above every input, and Mantine's own
required asterisk pushed onto its own line. Passing the plain string and
withAsterisk lets Mantine render both.
762 lines
25 KiB
TypeScript
762 lines
25 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 = "";
|
|
}
|
|
// 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)}
|
|
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;
|