mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
refactor pricing data seeder to fold surcharge types into rates update route meta subtitle to remove surcharge types enhance RuleEngineFormDialog to support conditional field visibility remove surcharge types from URL constants and related services add cargo leaf options query for bulk cargo type selection update RuleEngineResourcePage to utilize cargo leaf options modify resources configuration to remove surcharge types implement migration to fold surcharge types into rates create utility to derive legacy rate types from new rate structure
407 lines
11 KiB
TypeScript
407 lines
11 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { Loader2 } from "lucide-react";
|
|
import {
|
|
Modal,
|
|
Button,
|
|
TextInput,
|
|
Textarea,
|
|
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 = record?.[field.name];
|
|
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;
|
|
}
|
|
return true;
|
|
}),
|
|
[fields, values],
|
|
);
|
|
|
|
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
|
|
|
|
const setField = (name: string, value: unknown) => {
|
|
setValues((current) => ({ ...current, [name]: value }));
|
|
};
|
|
|
|
const handleSubmit = (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
const payload: Record<string, unknown> = {};
|
|
|
|
for (const field of visibleFields) {
|
|
const raw = values[field.name];
|
|
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 === "select") {
|
|
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}
|
|
data={(field.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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TextInput
|
|
key={field.name}
|
|
label={label}
|
|
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
|
|
value={String(values[field.name] ?? "")}
|
|
onChange={(e) => setField(field.name, e.currentTarget.value)}
|
|
placeholder={field.placeholder}
|
|
required={field.required}
|
|
size="md"
|
|
radius="md"
|
|
styles={inputStyles}
|
|
/>
|
|
);
|
|
};
|
|
|
|
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;
|