mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
774 lines
26 KiB
TypeScript
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;
|