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:
Hagernesh
2026-08-06 04:14:19 +00:00
parent ea9df4407e
commit 4716aa14c3
7 changed files with 304 additions and 6 deletions

View File

@@ -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)

View File

@@ -306,7 +306,34 @@ const RuleEngineResourcePage = () => {
const formFields = useMemo(() => {
if (!config) return [];
return config.formFields.map((field) => {
// Last-mile container bands: creating uses the multi-row tier list (one
// rate per tier); editing an existing band row keeps the single
// From/To/value fields (a rate row IS one band).
const bandFields = config.formFields.filter((field) => {
if (config.slug !== "rates") return true;
if (field.type === "tierList") return !editing;
if (editing) return true;
return field.name !== "minKm" && field.name !== "maxKm";
});
return bandFields.map((field) => {
// On create, the tier rows carry the per-band rate values — the single
// last-mile "Rate value" field then only applies to bulk mode.
if (
config.slug === "rates" &&
!editing &&
field.name === "rateValue" &&
field.showWhen?.field === "appliesTo" &&
field.showWhen.equals.includes("LAST_MILE")
) {
return {
...field,
showWhen: undefined,
showIf: (values: Record<string, unknown>) =>
values.appliesTo === "LAST_MILE" && values.lastMileMode === "BULK",
};
}
return field;
}).map((field) => {
if (isPriorityRules && field.name === "minWagonCount") {
return {
...field,
@@ -624,6 +651,31 @@ const RuleEngineResourcePage = () => {
);
return;
}
// Container-mode create: the tier list becomes one rate row per tier,
// created sequentially so an overlap/duplicate rejection stops the batch
// with its own toast instead of half-failing in parallel.
const tiers = (
payload as {
tiers?: Array<{ minKm: number; maxKm: number | null; rateValue: number }>;
}
).tiers;
if (!editing?.id && Array.isArray(tiers)) {
const { tiers: _omitted, ...base } = payload as Record<string, unknown>;
void _omitted;
void (async () => {
try {
for (const tier of tiers) {
await create.mutateAsync({ ...base, ...tier });
}
setFormOpen(false);
setEditing(null);
} catch {
// The create mutation already toasted the failure; keep the dialog
// open so the admin can fix the tier set and retry.
}
})();
return;
}
} else if (isPriorityRules) {
// Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) };

View File

@@ -17,7 +17,7 @@ export type ColumnFormat =
| "entityLabel"
| "rateLabel";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio" | "tierList";
export interface ResourceColumn {
id: string;
@@ -1022,6 +1022,19 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
getInitialValue: (record) => String(record.currency ?? "ETB"),
},
// ── Distance tiers (create only — the page swaps this for the single
// From/To/value fields when editing an existing band row). Each tier
// becomes its own rate row, so every band keeps edit/delete/approval. ──
{
name: "tiers",
label: "Distance tiers",
type: "tierList",
required: true,
description:
"One rate per distance range. To km is exclusive (030 then 30+); leave the last tier's To km empty for no upper limit.",
showIf: (v) =>
v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER",
},
// ── Container type — Container freight, container-kind intercity, and
// the empty-container return surcharge (20ft vs 40ft price differently) ─
{