diff --git a/apps/edr-freight-api/src/migrations/3290000000000-LastMileRequestContract.ts b/apps/edr-freight-api/src/migrations/3290000000000-LastMileRequestContract.ts new file mode 100644 index 000000000..d4971f903 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3290000000000-LastMileRequestContract.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts index b84595f7c..2978f8381 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts b/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts index 0077d7f0a..b85c2ad5c 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index 0ef297216..98b07c822 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -252,7 +252,12 @@ export class LastMileRequestsService { }); } - async submit(id: string, userId: string | null, containerNumbers: string[]): Promise { + async submit( + id: string, + userId: string | null, + containerNumbers: string[], + deliveryDate: string, + ): Promise { 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(), diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 49aeb17cd..d205aae26 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -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 = ; + 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 ( + + + {label} + + {field.description ? ( + + {field.description} + + ) : null} + + {rows.map((row, index) => ( + + setRow(index, "minKm", e.currentTarget.value)} + size="md" + radius="md" + styles={inputStyles} + style={{ flex: 1 }} + /> + setRow(index, "maxKm", e.currentTarget.value)} + size="md" + radius="md" + styles={inputStyles} + style={{ flex: 1 }} + /> + setRow(index, "rateValue", e.currentTarget.value)} + size="md" + radius="md" + styles={inputStyles} + style={{ flex: 1 }} + /> + setRows(rows.filter((_, i) => i !== index))} + > + + + + ))} + + + + {fieldErrors[field.name] ? ( + + {fieldErrors[field.name]} + + ) : null} + + + ); + } + if (field.type === "multiselect") { const options = field.optionsFromValues ? field.optionsFromValues(values) diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index d39892746..3a0a36ce4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -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) => + 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; + 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()) }; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 516a16046..a51b542c2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -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) ─ {