mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat(rates): multi-tier distance band entry in last-mile rate form
- tierList field type in rule-engine form dialog (add/remove rows, overlap + open-ended validation, From km auto-continues) - create submits one rate row per tier sequentially - editing a band row keeps the single From/To/value form
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Button,
|
||||
TextInput,
|
||||
@@ -41,6 +42,37 @@ 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" ||
|
||||
@@ -54,7 +86,7 @@ const buildFormRows = (fields: FormFieldDef[]): FormRow[] => {
|
||||
while (index < fields.length) {
|
||||
const field = fields[index];
|
||||
|
||||
if (field.type === "textarea" || field.type === "boolean") {
|
||||
if (field.type === "textarea" || field.type === "boolean" || field.type === "tierList") {
|
||||
rows.push({ kind: "single", field });
|
||||
index += 1;
|
||||
continue;
|
||||
@@ -85,6 +117,8 @@ const buildInitialValues = (
|
||||
: 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);
|
||||
@@ -241,6 +275,19 @@ const RuleEngineFormDialog = ({
|
||||
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);
|
||||
@@ -313,6 +360,101 @@ const RuleEngineFormDialog = ({
|
||||
|
||||
const label = <FieldLabel label={field.label} required={field.required} />;
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user