mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +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:
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Last-mile contract fields on freight.last_mile_requests: the customer-chosen
|
||||
* delivery date, the chief-approved advance amount (held until the invoice is
|
||||
* generated at signing time), the rate snapshot rendered into the contract,
|
||||
* and the customer signature bookkeeping. The signed PDF and signature image
|
||||
* live in freight.files (resource 'last_mile_requests').
|
||||
*/
|
||||
export class LastMileRequestContract3290000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_requests
|
||||
ADD COLUMN IF NOT EXISTS requested_delivery_date date,
|
||||
ADD COLUMN IF NOT EXISTS approved_advance_amount numeric(14,2),
|
||||
ADD COLUMN IF NOT EXISTS contract_summary jsonb,
|
||||
ADD COLUMN IF NOT EXISTS contract_generated_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS customer_signed_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS signer_display_name varchar(160),
|
||||
ADD COLUMN IF NOT EXISTS consent_text text
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_requests
|
||||
DROP COLUMN IF EXISTS requested_delivery_date,
|
||||
DROP COLUMN IF EXISTS approved_advance_amount,
|
||||
DROP COLUMN IF EXISTS contract_summary,
|
||||
DROP COLUMN IF EXISTS contract_generated_at,
|
||||
DROP COLUMN IF EXISTS customer_signed_at,
|
||||
DROP COLUMN IF EXISTS signer_display_name,
|
||||
DROP COLUMN IF EXISTS consent_text
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayNotEmpty, ArrayUnique, IsArray, IsString } from 'class-validator';
|
||||
import { ArrayNotEmpty, ArrayUnique, IsArray, IsDateString, IsString } from 'class-validator';
|
||||
|
||||
export class SubmitLastMileRequestDto {
|
||||
@ApiProperty({
|
||||
@@ -12,4 +12,12 @@ export class SubmitLastMileRequestDto {
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
containerNumbers!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Requested last-mile delivery date (ISO date), chosen by the customer against the train departure from Djibouti.',
|
||||
example: '2026-08-15',
|
||||
})
|
||||
@IsDateString()
|
||||
deliveryDate!: string;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,47 @@ export class LastMileRequest extends BaseEntity {
|
||||
@Column({ name: 'resulting_last_mile_id', type: 'uuid', nullable: true })
|
||||
resultingLastMileId?: string | null;
|
||||
|
||||
/** Customer-chosen last-mile delivery date (guided by the train's Djibouti departure). */
|
||||
@Column({ name: 'requested_delivery_date', type: 'date', nullable: true })
|
||||
requestedDeliveryDate?: string | null;
|
||||
|
||||
/** Chief-approved advance — invoiced only after the customer signs the LM contract. */
|
||||
@Column({
|
||||
name: 'approved_advance_amount',
|
||||
type: 'numeric',
|
||||
precision: 14,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
transformer: {
|
||||
to: (v?: number | null) => v,
|
||||
from: (v?: string | null) => (v == null ? null : Number(v)),
|
||||
},
|
||||
})
|
||||
approvedAdvanceAmount?: number | null;
|
||||
|
||||
/** Rate snapshot taken at approval, rendered into the contract document. */
|
||||
@Column({ name: 'contract_summary', type: 'jsonb', nullable: true })
|
||||
contractSummary?: {
|
||||
estimatedKm: number | null;
|
||||
mode: string | null;
|
||||
currency: string | null;
|
||||
total: number | null;
|
||||
lines: Array<{ description: string; amount: number }>;
|
||||
advanceAmount: number;
|
||||
} | null;
|
||||
|
||||
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
|
||||
contractGeneratedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true })
|
||||
customerSignedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'signer_display_name', type: 'varchar', length: 160, nullable: true })
|
||||
signerDisplayName?: string | null;
|
||||
|
||||
@Column({ name: 'consent_text', type: 'text', nullable: true })
|
||||
consentText?: string | null;
|
||||
|
||||
@ManyToOne(() => LastMile, { nullable: true, eager: false })
|
||||
@JoinColumn({ name: 'resulting_last_mile_id' })
|
||||
resultingLastMile?: LastMile | null;
|
||||
|
||||
@@ -252,7 +252,12 @@ export class LastMileRequestsService {
|
||||
});
|
||||
}
|
||||
|
||||
async submit(id: string, userId: string | null, containerNumbers: string[]): Promise<LastMileRequest> {
|
||||
async submit(
|
||||
id: string,
|
||||
userId: string | null,
|
||||
containerNumbers: string[],
|
||||
deliveryDate: string,
|
||||
): Promise<LastMileRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== LastMileRequestStatus.AwaitingConfirmation) {
|
||||
throw new BadRequestException(`Request is already ${request.status.toLowerCase()}`);
|
||||
@@ -274,6 +279,7 @@ export class LastMileRequestsService {
|
||||
|
||||
await this.requestsRepository.update(id, {
|
||||
requestedContainerNumbers: selected,
|
||||
requestedDeliveryDate: deliveryDate,
|
||||
status: LastMileRequestStatus.Submitted,
|
||||
submittedByUserId: userId,
|
||||
submittedAt: new Date(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()) };
|
||||
|
||||
@@ -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 (0–30 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) ─
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user