From 3e94aa08f15b0b9da95a194a4ef4b1653c6d5f04 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Sat, 4 Jul 2026 04:22:03 +0000
Subject: [PATCH 01/73] update joins in repository services to use entity
classes and enhance booking form with hazardous/reefer toggles
---
.../new-booking-form/step5-cargo-details.tsx | 142 ++++++++++++------
1 file changed, 98 insertions(+), 44 deletions(-)
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
index a02138373..ac336c47c 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
@@ -26,6 +26,70 @@ type BookingForm = UseFormReturn<
BookingFormValues
>;
+/**
+ * One numbered toggle per container unit in the line — tap units to mark how
+ * many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection
+ * fills from unit 1: tapping unit N selects 1..N, tapping a selected unit N
+ * keeps 1..N-1 — the count is always derived, never free-typed, so it can't
+ * exceed the line quantity.
+ */
+function UnitCountToggles({
+ total,
+ value,
+ onChange,
+ label,
+ activeBg,
+ activeBorder,
+ activeColor,
+}: {
+ total: number;
+ value: string;
+ onChange: (v: string) => void;
+ label: string;
+ activeBg: string;
+ activeBorder: string;
+ activeColor: string;
+}) {
+ const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0)));
+
+ return (
+
+
+ {label} · {count}/{total} selected
+
+
+ {Array.from({ length: total }, (_, i) => {
+ const selected = i < count;
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
export function Step5CargoDetails({
form,
referenceData,
@@ -144,16 +208,6 @@ export function Step5CargoDetails({
const lineQtyOf = (index: number) =>
Math.max(1, Number(form.getValues(`containers.${index}.qty`) ?? 1) || 1);
const lineMax = (index: number) => lineQtyOf(index);
- // When a flag is switched on, default its count to the whole line.
- const defaultLineQty = (index: number) => lineQtyOf(index).toString();
- // Clamp a typed value into 1..lineQty (empty stays empty so the field can be
- // cleared; the schema flags an empty value as required while the switch is on).
- const clampToLine = (raw: string, index: number) => {
- if (raw === "") return "";
- const n = Number(raw);
- if (Number.isNaN(n)) return raw;
- return Math.min(lineQtyOf(index), Math.max(1, Math.floor(n))).toString();
- };
// After the line quantity changes, pull any active count back within bounds.
const clampDependentQty = (index: number, newLineQty: number) => {
const max = Math.max(1, newLineQty);
@@ -636,7 +690,7 @@ export function Step5CargoDetails({
hazField.onChange(v);
form.setValue(
`containers.${index}.hazardousQty`,
- v ? defaultLineQty(index) : "0",
+ v ? "1" : "0",
{ shouldDirty: true, shouldValidate: true },
);
}}
@@ -645,22 +699,22 @@ export function Step5CargoDetails({
name={`containers.${index}.hazardousQty`}
control={form.control}
render={({ field: hq, fieldState }) => (
-
- hq.onChange(
- clampToLine(e.currentTarget.value, index),
- )
- }
- onBlur={hq.onBlur}
- error={fieldState.error?.message}
- radius="md"
- />
+
+
+ {fieldState.error?.message ? (
+
+ {fieldState.error.message}
+
+ ) : null}
+
)}
/>
@@ -681,7 +735,7 @@ export function Step5CargoDetails({
reeField.onChange(v);
form.setValue(
`containers.${index}.reeferQty`,
- v ? defaultLineQty(index) : "0",
+ v ? "1" : "0",
{ shouldDirty: true, shouldValidate: true },
);
}}
@@ -690,22 +744,22 @@ export function Step5CargoDetails({
name={`containers.${index}.reeferQty`}
control={form.control}
render={({ field: rq, fieldState }) => (
-
- rq.onChange(
- clampToLine(e.currentTarget.value, index),
- )
- }
- onBlur={rq.onBlur}
- error={fieldState.error?.message}
- radius="md"
- />
+
+
+ {fieldState.error?.message ? (
+
+ {fieldState.error.message}
+
+ ) : null}
+
)}
/>
From 2c0e115c14f03a906b383826838ab20cfe2c1a84 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Sat, 4 Jul 2026 04:23:10 +0000
Subject: [PATCH 02/73] update joins in repository services to use entity
classes and enhance booking form with hazardous/reefer toggles
---
.../modules/bookings/bookings.repository.ts | 7 +-
.../contracts/contract-booking.service.ts | 34 +++++
.../contracts/GlCreateBookingForm.tsx | 121 ++++++++++++------
3 files changed, 120 insertions(+), 42 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
index 4803f988c..580150eb0 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
+import { Contract } from '../contracts/entities/contract.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
@@ -588,8 +589,10 @@ export class BookingsRepository extends BaseRepository {
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
// Contract reference for the list column + search (no entity relation on
- // Booking → contract, so join by id and select just the reference).
- .leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id')
+ // Booking → contract, so join the entity by id and select just the
+ // reference — a schema-qualified table string is parsed as alias.relation
+ // by TypeORM and crashes).
+ .leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
.addSelect('contract.reference', 'contract_reference')
.where('booking.deleted_at IS NULL');
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
index c1eb4b4f8..125cf5f3d 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
@@ -138,6 +138,11 @@ export class ContractBookingService {
// no override. Checked before any row is written.
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
+ // 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ
+ // ≤ the cap, and drawdown bookings never pass through submit — so this is
+ // their only chance to hard-block an unbalanceable set. Entry order is
+ // irrelevant (the check sorts by weight before pairing).
+ await this.assert20ftPairableAtCreate(dto);
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
@@ -761,6 +766,35 @@ export class ContractBookingService {
}
}
+ /**
+ * Hard-block booking creation when the 20ft container weights cannot be
+ * balanced onto wagons (pair diff over the global cap). Same rule the
+ * shipment-form preview reports as `pairingErrors`, enforced server-side.
+ */
+ private async assert20ftPairableAtCreate(
+ dto: CreateBookingUnderContractDto,
+ ): Promise {
+ const twentyFtUnits = (dto.containers ?? [])
+ .filter((line) => (line.containerSize ?? '').includes('20'))
+ .flatMap((line, lineIdx) =>
+ (line.units ?? []).map((u, idx) => ({
+ label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`,
+ grossWeightTons: Number(u.vgmTons ?? 0),
+ })),
+ );
+ if (twentyFtUnits.length < 2) return;
+
+ const maxDiff = await this.max20ftPairDiffTons();
+ const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff);
+ if (violations.length) {
+ throw new BadRequestException(
+ `Cannot create booking — 20ft containers cannot be paired on wagons: ${violations
+ .map((v) => v.message)
+ .join(' ')}`,
+ );
+ }
+ }
+
private async max20ftPairDiffTons(): Promise {
const row = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
index d967ffc3f..6f52d287f 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
@@ -7,6 +7,7 @@ import {
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
+ Badge,
Box,
Button,
Center,
@@ -18,6 +19,7 @@ import {
Paper,
Select,
Stack,
+ Switch,
Text,
Textarea,
TextInput,
@@ -82,12 +84,13 @@ interface UnitDraft {
containerNumber: string;
sealNumber: string;
vgmTons: number | string;
+ /** Per-unit flags — the line's hazardous/reefer counts are derived from these. */
+ hazardous: boolean;
+ reefer: boolean;
}
interface ContainerLineDraft {
containerSize: string;
- hazardousQuantity: number | string;
- reeferQuantity: number | string;
units: UnitDraft[];
}
@@ -100,7 +103,13 @@ interface BulkLineDraft {
}
function emptyUnit(): UnitDraft {
- return { containerNumber: "", sealNumber: "", vgmTons: "" };
+ return {
+ containerNumber: "",
+ sealNumber: "",
+ vgmTons: "",
+ hazardous: false,
+ reefer: false,
+ };
}
function bulkUnitOfMeasure(
@@ -199,11 +208,13 @@ export default function GlCreateBookingForm() {
setContainerLines(
lines.containers.map((c) => ({
containerSize: c.containerSize,
- hazardousQuantity: c.hazardousQuantity ?? "0",
- reeferQuantity: c.reeferQuantity ?? "",
- units: Array.from({ length: Math.max(1, c.quantity) }, () =>
- emptyUnit(),
- ),
+ // The request carries counts; pre-toggle the first N units so GL sees
+ // the customer's declared hazardous/reefer split and can adjust it.
+ units: Array.from({ length: Math.max(1, c.quantity) }, (_, i) => ({
+ ...emptyUnit(),
+ hazardous: i < Number(c.hazardousQuantity ?? 0),
+ reefer: i < Number(c.reeferQuantity ?? 0),
+ })),
})),
);
} else if (lines.bulk) {
@@ -229,8 +240,6 @@ export default function GlCreateBookingForm() {
setContainerLines(
containerSizes.map((size) => ({
containerSize: size,
- hazardousQuantity: "0",
- reeferQuantity: "0",
units: [emptyUnit()],
})),
);
@@ -261,8 +270,8 @@ export default function GlCreateBookingForm() {
containers: containerLines.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
- hazardousQuantity: Number(l.hazardousQuantity || 0),
- reeferQuantity: Number(l.reeferQuantity || 0),
+ hazardousQuantity: l.units.filter((u) => u.hazardous).length,
+ reeferQuantity: l.units.filter((u) => u.reefer).length,
})),
bulkQuantity: bulkLines.reduce(
(s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0),
@@ -384,12 +393,10 @@ export default function GlCreateBookingForm() {
.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
- ...(l.hazardousQuantity !== ""
- ? { hazardousQuantity: Number(l.hazardousQuantity) }
- : {}),
- ...(l.reeferQuantity !== ""
- ? { reeferQuantity: Number(l.reeferQuantity) }
- : {}),
+ // Counts are derived from the per-unit toggles — they can never
+ // exceed the line quantity.
+ hazardousQuantity: l.units.filter((u) => u.hazardous).length,
+ reeferQuantity: l.units.filter((u) => u.reefer).length,
units: l.units.map((u) => ({
containerNumber: u.containerNumber,
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
@@ -652,7 +659,7 @@ export default function GlCreateBookingForm() {
{line.containerSize} containers
-
+
syncUnits(lineIdx, Number(v) || 0)}
radius={10}
styles={fieldStyles}
+ w={160}
/>
{contract.isHazardous ? (
-
- patchLine(lineIdx, { hazardousQuantity: v })
- }
- radius={10}
- styles={fieldStyles}
- />
+
+ {line.units.filter((u) => u.hazardous).length} hazardous
+
) : null}
{contract.isReefer ? (
-
- patchLine(lineIdx, { reeferQuantity: v })
- }
- radius={10}
- styles={fieldStyles}
- />
+
+ {line.units.filter((u) => u.reefer).length} refrigerated
+
) : null}
Per-container details
{line.units.map((unit, unitIdx) => (
-
+
+ {/* Per-unit flags: toggle exactly the containers that are
+ hazardous / refrigerated; line counts derive from these. */}
+ {contract.isHazardous ? (
+
+ {unitIdx === 0 ? (
+
+ Hazardous
+
+ ) : null}
+
+ patchUnit(lineIdx, unitIdx, {
+ hazardous: e.currentTarget.checked,
+ })
+ }
+ />
+
+ ) : null}
+ {contract.isReefer ? (
+
+ {unitIdx === 0 ? (
+
+ Reefer
+
+ ) : null}
+
+ patchUnit(lineIdx, unitIdx, {
+ reefer: e.currentTarget.checked,
+ })
+ }
+ />
+
+ ) : null}
))}
From c650a2dcbd18563b36d8ddb679e400466526f752 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Sat, 4 Jul 2026 04:26:14 +0000
Subject: [PATCH 03/73] update joins in repository services to use entity
classes and enhance booking form with hazardous/reefer toggles
---
.../contracts/ContractRequestDetailPage.tsx | 67 ++++++++++++++-----
packages/types/src/freight/contracts.ts | 7 ++
2 files changed, 59 insertions(+), 15 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx
index 6a451ba56..c81f5b919 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx
@@ -596,21 +596,58 @@ export default function ContractRequestDetailPage() {
No cargo scope lines.
) : (
-
- {(contract.cargoScope ?? []).map((s) => (
-
-
-
- {s.containerSize ??
- s.cargoFreeText ??
- s.cargoTypeId ??
- "Cargo"}
-
-
- ))}
+
+ {(contract.cargoScope ?? []).map((s) => {
+ const isContainer = Boolean(s.containerSize);
+ // Bulk lines carry their commodity detail (name + unit);
+ // container lines carry the size (20ft / 40ft).
+ const title = isContainer
+ ? `${s.containerSize} container`
+ : (s.cargoType?.cargoTypeName ??
+ s.cargoFreeText ??
+ s.cargoType?.code ??
+ "Bulk cargo");
+ // quantityCap unit: containers for a size line, else the
+ // cargo type's unit of measure (tons / items / …), default tons.
+ const capUnit = isContainer
+ ? "containers"
+ : (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons");
+ return (
+
+
+
+
+ {title}
+
+
+
+ {isContainer ? "Container" : "Bulk"}
+
+ {s.cargoType?.code ? (
+
+ Code: {s.cargoType.code}
+
+ ) : null}
+
+ {s.quantityCap != null
+ ? `Cap: ${s.quantityCap} ${capUnit}`
+ : "Cap: uncapped"}
+
+
+
+
+ );
+ })}
)}
diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts
index e62b410ba..fe653573d 100644
--- a/packages/types/src/freight/contracts.ts
+++ b/packages/types/src/freight/contracts.ts
@@ -132,6 +132,13 @@ export interface IContractCargoScope {
/** "20ft" | "40ft"; null for bulk. */
containerSize?: string | null;
cargoTypeId?: string | null;
+ /** Bulk cargo type detail (name + unit), loaded on the contract detail. */
+ cargoType?: {
+ id: string;
+ code?: string | null;
+ cargoTypeName?: string | null;
+ unitOfMeasure?: string | null;
+ } | null;
cargoFreeText?: string | null;
/**
* GENERAL contracts: total quantity bookable across all shipments on this line
From f37078d51d294805d0583aeb7145daf470428a22 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Sat, 4 Jul 2026 04:59:51 +0000
Subject: [PATCH 04/73] update joins in repository services to use entity
classes and enhance booking form with hazardous/reefer toggles
---
...-AddWagonTypeFkToCargoAndContainerTypes.ts | 133 ++++++++++++++++++
.../modules/bookings/bookings.repository.ts | 6 +-
.../rule-engine/dto/create-cargo-type.dto.ts | 8 ++
.../dto/create-container-type.dto.ts | 8 ++
.../rule-engine/entities/cargo-type.entity.ts | 15 ++
.../entities/container-type.entity.ts | 17 ++-
.../repositories/cargo-types.repository.ts | 2 +-
.../container-types.repository.ts | 2 +-
.../repositories/rates.repository.ts | 2 +-
.../weight-limit-rules.repository.ts | 2 +-
.../services/cargo-types.service.ts | 1 +
.../services/container-types.service.ts | 1 +
.../train-scheduling.service.ts | 117 +++++++++++----
.../wagon-type-resolver.util.ts | 49 -------
.../backoffice/src/auth/types.ts | 2 +
.../src/hooks/rule-engine/useRuleEngine.ts | 17 +++
.../backoffice/src/lib/permissions.ts | 10 +-
.../src/pages/ruleEngine/CargoTypesPage.tsx | 35 ++++-
.../ruleEngine/RuleEngineResourcePage.tsx | 18 ++-
.../src/pages/ruleEngine/config/resources.ts | 8 ++
.../pages/contracts/ContractDetailPage.tsx | 111 +++++++++++++++
21 files changed, 478 insertions(+), 86 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts
delete mode 100644 apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts
diff --git a/apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts b/apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts
new file mode 100644
index 000000000..c7ab60577
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts
@@ -0,0 +1,133 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Replace load-type string matching with a real wagon-type foreign key.
+ *
+ * Before this migration, train scheduling picked a wagon type by matching
+ * strings — a hardcoded cargo-code → wagon-code map for bulk (COFFEE→KW2, …)
+ * and a fixed NW5 default for every container. This adds `wagon_type_id` FKs on
+ * `cargo_types` and `container_types` so scheduling resolves the wagon type
+ * through the relation instead.
+ *
+ * The columns are NULLABLE: cargo grouping rows and container/legacy cargo that
+ * never ship in bulk have no wagon type, and forcing one onto them is
+ * meaningless. Scheduling enforces the requirement at run time (it throws when a
+ * scheduled bulk cargo type or a container type in the batch has no wagon type).
+ *
+ * Backfill reproduces the old hardcoded resolution one final time so existing
+ * bulk cargo + container rows are not left unset. After this, the runtime map is
+ * removed — the FK is the single source of truth.
+ */
+export class AddWagonTypeFkToCargoAndContainerTypes1940000000000
+ implements MigrationInterface
+{
+ name = "AddWagonTypeFkToCargoAndContainerTypes1940000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // ── Columns + FKs ────────────────────────────────────────────────────────
+ await queryRunner.query(`
+ ALTER TABLE freight.cargo_types
+ ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.container_types
+ ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.cargo_types
+ ADD CONSTRAINT fk_cargo_types_wagon_type
+ FOREIGN KEY (wagon_type_id)
+ REFERENCES freight.wagon_types(id)
+ ON DELETE RESTRICT;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.container_types
+ ADD CONSTRAINT fk_container_types_wagon_type
+ FOREIGN KEY (wagon_type_id)
+ REFERENCES freight.wagon_types(id)
+ ON DELETE RESTRICT;
+ `);
+
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_cargo_types_wagon_type_id
+ ON freight.cargo_types (wagon_type_id);
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_container_types_wagon_type_id
+ ON freight.container_types (wagon_type_id);
+ `);
+
+ // ── Backfill: old cargo-code → wagon-code map (one last time) ─────────────
+ // COFFEE/GRAIN/WHEAT/SORGHUM/CORN → KW2, FERTILIZER/SUGAR → PW2,
+ // COAL → KW3, STEEL/ORE → CW3. Unmapped bulk cargo → CW3 (old default).
+ const cargoCodeToWagon: Record = {
+ COFFEE: "KW2",
+ GRAIN: "KW2",
+ WHEAT: "KW2",
+ SORGHUM: "KW2",
+ CORN: "KW2",
+ FERTILIZER: "PW2",
+ SUGAR: "PW2",
+ COAL: "KW3",
+ STEEL: "CW3",
+ ORE: "CW3",
+ };
+
+ for (const [cargoCode, wagonCode] of Object.entries(cargoCodeToWagon)) {
+ await queryRunner.query(
+ `
+ UPDATE freight.cargo_types ct
+ SET wagon_type_id = wt.id
+ FROM freight.wagon_types wt
+ WHERE wt.code = $1
+ AND UPPER(TRIM(ct.code)) = $2
+ AND ct.wagon_type_id IS NULL;
+ `,
+ [wagonCode, cargoCode],
+ );
+ }
+
+ // Remaining bulk cargo (PER_TON) without a mapped code → default bulk wagon CW3.
+ await queryRunner.query(`
+ UPDATE freight.cargo_types ct
+ SET wagon_type_id = wt.id
+ FROM freight.wagon_types wt
+ WHERE wt.code = 'CW3'
+ AND ct.wagon_type_id IS NULL
+ AND ct.unit_of_measure = 'PER_TON';
+ `);
+
+ // All container types → the old container default wagon NW5.
+ await queryRunner.query(`
+ UPDATE freight.container_types ct
+ SET wagon_type_id = wt.id
+ FROM freight.wagon_types wt
+ WHERE wt.code = 'NW5'
+ AND ct.wagon_type_id IS NULL;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ DROP INDEX IF EXISTS freight.idx_container_types_wagon_type_id;
+ `);
+ await queryRunner.query(`
+ DROP INDEX IF EXISTS freight.idx_cargo_types_wagon_type_id;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.container_types
+ DROP CONSTRAINT IF EXISTS fk_container_types_wagon_type;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.cargo_types
+ DROP CONSTRAINT IF EXISTS fk_cargo_types_wagon_type;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
index 580150eb0..6dfe2b8b9 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
@@ -1082,8 +1082,10 @@ export class BookingsRepository extends BaseRepository {
destinationYard: true,
// units carry the real per-container numbers entered at booking time —
// the wagon plan shows those instead of generated placeholders.
- bookingContainers: { containerType: true, units: true },
- cargoType: true,
+ // containerType.wagonType + cargoType.wagonType drive wagon-type
+ // resolution during scheduling (FK, not the old load-type string map).
+ bookingContainers: { containerType: { wagonType: true }, units: true },
+ cargoType: { wagonType: true },
},
order: { priorityScore: 'DESC', createdAt: 'ASC' },
});
diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts
index f30ebd501..76db7fa78 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts
@@ -21,6 +21,14 @@ export class CreateCargoTypeDto {
@IsUUID()
parentGroupId?: string;
+ @ApiPropertyOptional({
+ description:
+ 'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.',
+ })
+ @IsOptional()
+ @IsUUID('4')
+ wagonTypeId?: string | null;
+
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts
index 52cfe274b..e0baf7251 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts
@@ -30,6 +30,14 @@ export class CreateContainerTypeDto {
@IsBoolean()
isOpenTop?: boolean;
+ @ApiPropertyOptional({
+ description:
+ 'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.',
+ })
+ @IsOptional()
+ @IsUUID('4')
+ wagonTypeId?: string | null;
+
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts
index c8ac35f25..ac8a2ea24 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts
@@ -1,11 +1,13 @@
import { BaseEntity } from '@edr/api-common';
import { CargoUnitOfMeasure } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
+import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
@Entity({ schema: 'freight', name: 'cargo_types' })
@Index(['isActive'])
@Index(['displayOrder'])
@Index(['parentGroupId'])
+@Index(['wagonTypeId'])
@Index(['code'])
export class CargoType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
@@ -25,6 +27,19 @@ export class CargoType extends BaseEntity {
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
unitOfMeasure?: CargoUnitOfMeasure | null;
+ /**
+ * Wagon type that carries this (bulk) cargo. Replaces the former hardcoded
+ * cargo-code → wagon-code map: train scheduling resolves the bulk wagon type
+ * through this FK. Nullable — grouping rows and container/legacy cargo never
+ * carry it; scheduling throws if a scheduled bulk cargo type leaves it unset.
+ */
+ @Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
+ wagonTypeId?: string | null;
+
+ @ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
+ @JoinColumn({ name: 'wagon_type_id' })
+ wagonType?: WagonType | null;
+
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts
index e03078c19..f7cbeed99 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts
@@ -1,10 +1,12 @@
import { BaseEntity } from '@edr/api-common';
-import { Column, Entity, Index, OneToMany } from 'typeorm';
+import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { WeightLimitRule } from './weight-limit-rule.entity';
+import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
@Entity({ schema: 'freight', name: 'container_types' })
@Index(['code'])
@Index(['isActive'])
+@Index(['wagonTypeId'])
export class ContainerType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
code!: string;
@@ -24,6 +26,19 @@ export class ContainerType extends BaseEntity {
@Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true })
isOpenTop!: boolean;
+ /**
+ * Wagon type that carries this container. Replaces the former hardcoded
+ * container wagon-code default (NW5): train scheduling resolves the container
+ * wagon type through this FK. Nullable; scheduling throws if a scheduled
+ * container type leaves it unset.
+ */
+ @Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
+ wagonTypeId?: string | null;
+
+ @ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
+ @JoinColumn({ name: 'wagon_type_id' })
+ wagonType?: WagonType | null;
+
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts
index 496c2ce7b..5fba70fe1 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts
@@ -33,7 +33,7 @@ export class CargoTypesRepository implements ICargoTypesRepository {
}
async update(id: string, data: Partial): Promise {
- await this.repo.update(id, data);
+ await this.repo.update(id, data as never);
return this.findById(id);
}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts
index fe0a8f41e..0e4fb2716 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts
@@ -33,7 +33,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
}
async update(id: string, data: Partial): Promise {
- await this.repo.update(id, data);
+ await this.repo.update(id, data as never);
return this.findById(id);
}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts
index a7260f7f1..a7b8e69ab 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts
@@ -74,7 +74,7 @@ export class RatesRepository implements IRatesRepository {
}
async update(id: string, data: Partial): Promise {
- await this.repo.update(id, data);
+ await this.repo.update(id, data as never);
return this.findById(id);
}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts
index 87d2febba..7432dfc34 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts
@@ -65,7 +65,7 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
}
async update(id: string, data: Partial): Promise {
- await this.repo.update(id, data);
+ await this.repo.update(id, data as never);
return this.findById(id);
}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts
index f80ada585..5470094a5 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts
@@ -82,6 +82,7 @@ export class CargoTypesService {
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
unitOfMeasure: dto.unitOfMeasure ?? null,
+ wagonTypeId: dto.wagonTypeId ?? null,
displayOrder,
});
}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts
index 38407f36a..629bf3023 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts
@@ -64,6 +64,7 @@ export class ContainerTypesService {
isReefer: dto.isReefer ?? false,
isOpenTop: dto.isOpenTop ?? false,
isActive: dto.isActive ?? true,
+ wagonTypeId: dto.wagonTypeId ?? null,
displayOrder,
});
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
index a10847c17..32a046356 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
@@ -15,7 +15,7 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
-import { DataSource, EntityManager, In, Not } from 'typeorm';
+import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
@@ -38,6 +38,8 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
+import { CargoType } from '../rule-engine/entities/cargo-type.entity';
+import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
@@ -87,10 +89,6 @@ import {
type ContainerPlacementInput,
type WagonPlanSlot,
} from './wagon-plan.util';
-import {
- getDefaultContainerWagonTypeCode,
- pickBulkWagonType,
-} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
@@ -2725,28 +2723,98 @@ export class TrainSchedulingService {
return violations;
}
+ /**
+ * Resolve the wagon type for a batch through the cargo-type / container-type
+ * `wagon_type_id` FK (replaces the former load-type string matching). Throws
+ * when the relevant type has no wagon type configured — scheduling is blocked
+ * until an admin assigns one on the cargo-type / container-type config screen.
+ */
private async resolveWagonType(
freightType: 'CONTAINER' | 'BULK',
bookingIds: string[],
): Promise {
+ const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
+
if (freightType === 'CONTAINER') {
- const [wagonType] = await this.wagonTypesRepository.findAll({
- where: { code: getDefaultContainerWagonTypeCode(), isActive: true },
- });
- if (!wagonType) {
- throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`);
+ // First container type present on the batch drives the container wagon
+ // type (matches the prior single-wagon-type-per-consist behavior).
+ const containerType = bookings
+ .flatMap((b) => b.bookingContainers ?? [])
+ .map((line) => line.containerType)
+ .find((ct): ct is NonNullable => Boolean(ct));
+ if (!containerType) {
+ throw new BadRequestException('No container type found on the container booking(s)');
}
+ const wagonType = await this.loadWagonTypeForType(
+ containerType.wagonTypeId ?? null,
+ `Container type "${containerType.label ?? containerType.code}"`,
+ );
return wagonType;
}
- const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
- const cargoCode = bookings[0]?.cargoType?.code ?? null;
- const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } });
- const picked = pickBulkWagonType(wagonTypes, cargoCode);
- if (!picked) {
- throw new NotFoundException('No suitable bulk wagon type found');
+ const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct));
+ if (!cargoType) {
+ throw new BadRequestException('No cargo type found on the bulk booking(s)');
}
- return picked;
+ return this.loadWagonTypeForType(
+ cargoType.wagonTypeId ?? null,
+ `Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`,
+ );
+ }
+
+ /**
+ * Load an active wagon type by FK id, throwing a clear error when the id is
+ * unset (type not configured) or points at a missing/inactive wagon type.
+ */
+ private async loadWagonTypeForType(
+ wagonTypeId: string | null,
+ typeLabel: string,
+ ): Promise {
+ if (!wagonTypeId) {
+ throw new BadRequestException(
+ `${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`,
+ );
+ }
+ const [wagonType] = await this.wagonTypesRepository.findAll({
+ where: { id: wagonTypeId, isActive: true },
+ });
+ if (!wagonType) {
+ throw new NotFoundException(
+ `${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`,
+ );
+ }
+ return wagonType;
+ }
+
+ /**
+ * Soft wagon-type resolution for the customer-facing availability preview
+ * (getAvailableDaysForCargo). Reads the configured FK by cargo/container type;
+ * returns null (→ "no days") instead of throwing when nothing is configured,
+ * since this only estimates which days have wagons and creates no booking.
+ */
+ private async resolveWagonTypeForPreview(
+ freightType: 'CONTAINER' | 'BULK',
+ cargoTypeCode: string | null,
+ ): Promise {
+ if (freightType === 'BULK') {
+ if (!cargoTypeCode) return null;
+ const cargoType = await this.dataSource.getRepository(CargoType).findOne({
+ where: { code: cargoTypeCode },
+ relations: { wagonType: true },
+ });
+ return cargoType?.wagonType?.isActive ? cargoType.wagonType : null;
+ }
+
+ // Container preview: the input carries no specific container type, so use the
+ // wagon type of the first configured (active) container type.
+ const containerType = await this.dataSource
+ .getRepository(ContainerType)
+ .findOne({
+ where: { isActive: true, wagonTypeId: Not(IsNull()) },
+ relations: { wagonType: true },
+ order: { displayOrder: 'ASC' },
+ });
+ return containerType?.wagonType?.isActive ? containerType.wagonType : null;
}
private async persistTrainSetWagons(
@@ -3419,15 +3487,12 @@ export class TrainSchedulingService {
);
if (schedules.length === 0) return { days: [] };
- const wagonTypes = await this.dataSource.getRepository(WagonType).find();
-
- // Resolve the wagon type this cargo needs.
- const requiredType =
- input.freightType === 'BULK'
- ? pickBulkWagonType(wagonTypes, input.cargoTypeCode)
- : wagonTypes.find(
- (wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive,
- );
+ // Resolve the wagon type this cargo needs via the cargo/container-type FK.
+ // Soft (customer availability preview): no days if unresolved, never throws.
+ const requiredType = await this.resolveWagonTypeForPreview(
+ input.freightType,
+ input.cargoTypeCode ?? null,
+ );
if (!requiredType) return { days: [] };
// How many wagons of that type the cargo needs.
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts
deleted file mode 100644
index bac0330f2..000000000
--- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { WagonType } from '../wagon-types/entities/wagon-type.entity';
-
-const CARGO_CODE_TO_WAGON_TYPE: Record = {
- COFFEE: 'KW2',
- GRAIN: 'KW2',
- WHEAT: 'KW2',
- SORGHUM: 'KW2',
- CORN: 'KW2',
- FERTILIZER: 'PW2',
- SUGAR: 'PW2',
- COAL: 'KW3',
- STEEL: 'CW3',
- ORE: 'CW3',
-};
-
-const DEFAULT_BULK_WAGON_TYPE = 'CW3';
-const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5';
-
-/**
- * Resolve wagon type code from cargo type code for bulk freight.
- */
-export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string {
- if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE;
- const normalized = cargoTypeCode.trim().toUpperCase();
- return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE;
-}
-
-/**
- * Pick the best matching wagon type entity for bulk cargo.
- */
-export function pickBulkWagonType(
- wagonTypes: WagonType[],
- cargoTypeCode?: string | null,
-): WagonType | undefined {
- const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode);
- const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive);
- if (direct) return direct;
-
- return wagonTypes.find(
- (wt) =>
- wt.isActive &&
- !wt.supportsContainer &&
- wt.code !== DEFAULT_CONTAINER_WAGON_TYPE,
- );
-}
-
-export function getDefaultContainerWagonTypeCode(): string {
- return DEFAULT_CONTAINER_WAGON_TYPE;
-}
diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts
index 41742039c..6279c8491 100644
--- a/apps/edr-freight-web/backoffice/src/auth/types.ts
+++ b/apps/edr-freight-web/backoffice/src/auth/types.ts
@@ -21,6 +21,8 @@ interface AuthEmployeePosition {
isDelegate?: boolean;
parentPositionId?: string | null;
permissions?: AuthPermission[];
+ /** Some IAM payloads nest the position record instead of flattening its key. */
+ position?: { id?: string; key?: string; name?: LocaleText };
}
interface AuthEmployeeRecord {
diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
index ad16feac3..dcc8b21ed 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
@@ -174,6 +174,23 @@ export const useContainerTypeOptions = (
buildContainerTypeSelectOptions(result.data ?? [], includeNone),
});
+/**
+ * Active wagon-type options for the cargo-type / container-type "Wagon type"
+ * picker. The FK the selection sets drives train-scheduling wagon resolution.
+ */
+export const useWagonTypeOptions = (enabled = true) =>
+ useQuery({
+ ...api.wagonTypes.list.queryOptions(),
+ enabled,
+ select: (rows: { id: string; code: string; name: string; isActive?: boolean }[]) =>
+ rows
+ .filter((wt) => wt.isActive !== false)
+ .map((wt) => ({
+ label: wt.name ? `${wt.name} (${wt.code})` : wt.code,
+ value: wt.id,
+ })),
+ });
+
const LIVE_RATE_PAGE_SIZE = 500;
export const useLiveRateOptions = (enabled = true) =>
diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
index 9fe564312..0abb91e99 100644
--- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
@@ -73,15 +73,23 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
return [...keys];
}
-/** Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl"). */
+/**
+ * Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl").
+ * Tolerates IAM payload shape variants: the key flat on the employee position,
+ * nested under `position.key`, or the GL modeled as a role instead.
+ */
export function getPositionKeys(user: AuthUser | null | undefined): string[] {
if (!user) return [];
const keys = new Set();
for (const emp of user.employee ?? []) {
for (const pos of emp.positions ?? []) {
if (pos.key) keys.add(pos.key);
+ if (pos.position?.key) keys.add(pos.position.key);
}
}
+ for (const role of user.roles ?? []) {
+ if (role.key) keys.add(role.key);
+ }
return [...keys];
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx
index 2568c76f7..c511c214e 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx
@@ -41,6 +41,7 @@ import {
import {
useRuleEngineList,
useRuleEngineMutations,
+ useWagonTypeOptions,
} from "@/hooks/rule-engine/useRuleEngine";
import type { RuleEngineRecord } from "@/types/rule-engine";
@@ -54,6 +55,8 @@ interface CargoNode extends RuleEngineRecord {
requiresDirectorApproval?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
unitOfMeasure?: string | null;
+ /** Wagon type FK used to carry this bulk cargo during scheduling; null if unset. */
+ wagonTypeId?: string | null;
isActive?: boolean;
displayOrder?: number;
}
@@ -79,6 +82,18 @@ const FORM_FIELDS: FormFieldDef[] = [
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
],
},
+ {
+ // Wagon type that carries this (bulk) commodity — drives train-scheduling
+ // wagon resolution. Optional: leave "None" for grouping categories and
+ // container/legacy cargo; set it on scheduled bulk commodities.
+ // Options injected at render from useWagonTypeOptions.
+ name: "wagonTypeId",
+ label: "Wagon type",
+ type: "select",
+ optional: true,
+ placeholder: "Select wagon type (bulk cargo)",
+ options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }],
+ },
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
];
@@ -105,6 +120,24 @@ const CargoTypesPage = () => {
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
+ // Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK).
+ const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
+ const formFields = useMemo(
+ () =>
+ FORM_FIELDS.map((field) =>
+ field.name === "wagonTypeId"
+ ? {
+ ...field,
+ options: [
+ { label: "None", value: RULE_ENGINE_SELECT_NONE },
+ ...(wagonTypeOptions ?? []),
+ ],
+ }
+ : field,
+ ),
+ [wagonTypeOptions],
+ );
+
const [search, setSearch] = useState("");
const [formMode, setFormMode] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
@@ -350,7 +383,7 @@ const CargoTypesPage = () => {
? "Create a top-level cargo category."
: "Create a cargo type inside this category. It's attached here automatically."
}
- fields={FORM_FIELDS}
+ fields={formFields}
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
isSubmitting={create.isPending || update.isPending}
onSubmit={handleSubmit}
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 297d67050..253cdffcc 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
@@ -33,6 +33,7 @@ import {
useCargoTypeParentOptions,
useContainerTypeOptions,
useLiveRateOptions,
+ useWagonTypeOptions,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
@@ -151,6 +152,9 @@ const RuleEngineResourcePage = () => {
const usesLiveRateField = Boolean(
config?.formFields.some((f) => f.name === "rateId"),
);
+ const usesWagonTypeField = Boolean(
+ config?.formFields.some((f) => f.name === "wagonTypeId"),
+ );
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
@@ -160,6 +164,8 @@ const RuleEngineResourcePage = () => {
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
useLiveRateOptions(usesLiveRateField);
+ const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
+ useWagonTypeOptions(usesWagonTypeField);
const formFields = useMemo(() => {
if (!config) return [];
@@ -193,9 +199,16 @@ const RuleEngineResourcePage = () => {
options: liveRateOptions ?? [],
};
}
+ if (field.name === "wagonTypeId") {
+ return {
+ ...field,
+ type: "select" as const,
+ options: wagonTypeOptions ?? [],
+ };
+ }
return field;
});
- }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]);
+ }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
const rows = data?.data ?? [];
const meta = data?.meta;
@@ -502,7 +515,8 @@ const RuleEngineResourcePage = () => {
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
(usesContainerTypeField && containerTypeOptionsLoading) ||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
- (usesLiveRateField && liveRateOptionsLoading)
+ (usesLiveRateField && liveRateOptionsLoading) ||
+ (usesWagonTypeField && wagonTypeOptionsLoading)
}
positionOptions={!editing ? createPositionOptions : undefined}
positionLoading={createPositionLoading}
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 faa26036b..2014f9c65 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
@@ -248,6 +248,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
+ // Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
+ {
+ name: "wagonTypeId",
+ label: "Wagon type",
+ type: "select",
+ required: true,
+ description: "Wagon type used to carry this container during train scheduling.",
+ },
{ name: "isOpenTop", label: "Open top", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
],
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
index 97b7afb8b..268e8627a 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
@@ -16,6 +16,8 @@ import {
Group,
Loader,
Paper,
+ Progress,
+ RingProgress,
SimpleGrid,
Stack,
Tabs,
@@ -211,6 +213,17 @@ export default function ContractDetailPage() {
});
const bookingWindowOpen = hasOpenWindow(bookingWindows);
+ // Draw-down capacity per cargo line (GENERAL contracts only). The backend
+ // excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships
+ // releases its share and the tracker fills back up. Refetched on window focus so
+ // it reflects newly created / cancelled shipments.
+ const { data: capacityLines = [] } = useQuery({
+ queryKey: ["contract-capacity", id],
+ queryFn: () => contractsService.getCapacity(id!),
+ enabled: !!id && contract?.contractKind === "GENERAL",
+ refetchOnWindowFocus: true,
+ });
+
const contractBookings = useMemo(
() =>
(bookingsPage?.items ?? []).filter(
@@ -882,6 +895,88 @@ export default function ContractDetailPage() {
+ {/* Draw-down capacity — GENERAL contracts with a per-line quantity cap.
+ Fills as shipments consume capacity; empties again when a shipment is
+ cancelled/rejected/expired (backend releases it). */}
+ {isGeneral && capacityLines.length > 0 && (
+
+ Contract capacity
+
+ {capacityLines.map((line, i) => {
+ const cap = line.cap ?? 0;
+ const booked = line.booked ?? 0;
+ const remaining = line.remaining ?? Math.max(0, cap - booked);
+ const usedPct = cap > 0 ? Math.min(100, (booked / cap) * 100) : 0;
+ const remainingPct = cap > 0 ? Math.round((remaining / cap) * 100) : 0;
+ const unit = capacityUnitLabel(contract, line);
+ const label = isContainer
+ ? `${line.containerSize ?? "Containers"}`
+ : (contract.cargoScope ?? []).find(
+ (s) => s.cargoTypeId === line.cargoTypeId,
+ )?.cargoType?.cargoTypeName ??
+ (contract.cargoScope ?? [])[0]?.cargoFreeText ??
+ "Bulk commodity";
+ return (
+
+
+ {remainingPct}%
+
+ }
+ />
+
+
+
+ {isContainer ? (
+
+ ) : (
+
+ )}
+
+ {label}
+
+
+
+ {booked} / {cap} {unit} booked
+
+
+
+
+ {remaining} {unit} remaining
+
+
+
+ );
+ })}
+
+
+ )}
+
{/* Signatures */}
{(contract.signatures ?? []).length > 0 && (
s.cargoTypeId === line.cargoTypeId,
+ ) ?? (contract.cargoScope ?? [])[0];
+ return scope?.cargoType?.unitOfMeasure === "PER_ITEM" ? "items" : "tons";
+}
+
/**
* One document row in the Documents tab: the file's kind (passport, business
* license, contract, …) derived from its `code` as the primary label, the
From 73ed58955e0bb2467c3d72ae7876f882f7b77eea Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 14:00:54 +0000
Subject: [PATCH 05/73] fix: refetch on focus
---
apps/edr-freight-web/backoffice/src/lib/queryClient.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
index 32b94f325..781ffba74 100644
--- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
@@ -27,7 +27,6 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
- refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
From 70589229878bc888430459f69fb6fc107b280c41 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Sat, 4 Jul 2026 06:19:35 +0000
Subject: [PATCH 06/73] changes
---
.../src/modules/billing/billing.service.ts | 15 +++++++++++++++
.../src/modules/payment/payment.service.ts | 5 ++---
2 files changed, 17 insertions(+), 3 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts
index 536129122..71398896f 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts
@@ -953,6 +953,21 @@ export class BillingService {
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
+ // DEMO: manually fire the gateway `payment.succeeded` callback here, without
+ // waiting for real gateway settlement. Runs AFTER the paymentId link above so
+ // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
+ // remove — real settlement flips this via the `${source}.invoice.paid` handler.
+ if (!result.immediateSuccess) {
+ await this.payment.handlePaymentEvent({
+ eventType: "payment.succeeded",
+ eventId: `demo-${result.intentId}`,
+ referenceId: invoice.sourceId,
+ intentId: result.intentId,
+ providerTxnId: result.providerTxnId,
+ paidAt: (result.paidAt ?? new Date()).toISOString(),
+ });
+ }
+
if (result.immediateSuccess) {
await this.settleByPaymentId(
result.intentId,
diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts
index d773ebe1f..5b8a3ddca 100644
--- a/apps/edr-freight-api/src/modules/payment/payment.service.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts
@@ -479,7 +479,7 @@ export class PaymentService {
alreadyFinalized?: boolean;
reason?: string;
}> {
- console.log(`Received payment event: ${JSON.stringify(event)}`);
+ this.logger.log(`Received payment event: ${JSON.stringify(event)}`);
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({
refId: event.referenceId,
@@ -490,13 +490,12 @@ export class PaymentService {
reason: `No local intent for reference ${event.referenceId}`,
};
}
- console.log(`Processing payment succeeded event for intent: }`, intent);
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
notify: true,
});
- console.log(
+ this.logger.log(
`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`,
);
From 18f47481d70a3ece7fc6dbc40f57506e350c6b60 Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Sat, 4 Jul 2026 06:31:23 +0000
Subject: [PATCH 07/73] Handover customer sign
---
apps/edr-freight-api/package.json | 1 +
apps/edr-freight-api/src/app.module.ts | 2 +
.../seed-paid-import-export-mile-demo.ts | 28 ++
.../paid-import-export-mile-demo.seeder.ts | 299 ++++++++++++++++++
.../portal/src/constants/URLS.ts | 1 +
.../components/WarehousePaymentsSection.tsx | 82 ++++-
.../services/warehouse-invoices.service.ts | 28 ++
7 files changed, 434 insertions(+), 7 deletions(-)
create mode 100644 apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts
create mode 100644 apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts
diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index a0a119861..ef0b211d5 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -22,6 +22,7 @@
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
+ "seed:paid-import-export-mile-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-paid-import-export-mile-demo.ts",
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index f20184e59..b44a942ea 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -63,6 +63,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
+import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from "./modules/wagons/wagons.module";
@@ -165,6 +166,7 @@ import { LoggerMiddleware } from "./logger.middleware";
ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
+ PaidImportExportMileDemoSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {
diff --git a/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts b/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts
new file mode 100644
index 000000000..6b2a422e8
--- /dev/null
+++ b/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts
@@ -0,0 +1,28 @@
+import 'reflect-metadata';
+import { config } from 'dotenv';
+import { resolve } from 'path';
+
+config({ path: resolve(__dirname, '../../.env') });
+
+import { NestFactory } from '@nestjs/core';
+import { AppModule } from '../app.module';
+import { PaidImportExportMileDemoSeeder } from '../seed/paid-import-export-mile-demo.seeder';
+
+async function main() {
+ const app = await NestFactory.createApplicationContext(AppModule, {
+ logger: ['error', 'warn', 'log'],
+ });
+
+ try {
+ const seeder = app.get(PaidImportExportMileDemoSeeder);
+ await seeder.run();
+ console.log('Paid import/export mile demo bookings seeded.');
+ } finally {
+ await app.close();
+ }
+}
+
+main().catch((err) => {
+ console.error('Paid import/export mile demo booking seed failed:', err);
+ process.exit(1);
+});
diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts
new file mode 100644
index 000000000..708afb86b
--- /dev/null
+++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts
@@ -0,0 +1,299 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { randomUUID } from 'crypto';
+import { DataSource } from 'typeorm';
+
+import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
+import { Booking } from '../modules/bookings/entities/booking.entity';
+import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
+import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
+import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
+import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
+import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
+import { Yard } from '../modules/rule-engine/entities/yard.entity';
+
+const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE';
+const COMPANY_TIN = 'PAIDMILE001';
+const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
+
+const YARDS = [
+ { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
+ { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
+];
+
+const CONTAINER_TYPES = [
+ { code: '20FT', label: '20FT', sizeFt: 20 },
+ { code: '40FT', label: '40FT', sizeFt: 40 },
+];
+
+/**
+ * Six paid, approved container bookings that mirror the real trucking legs:
+ * - EXPORT (Ethiopia -> Djibouti) carries a FIRST-MILE leg (factory -> rail terminal).
+ * - IMPORT (Djibouti -> Ethiopia) carries a LAST-MILE leg (dry port -> final delivery).
+ * Each booking is paymentStatus PAID and its single mile leg is marked paid + ready to transit.
+ */
+const DEMO_BOOKINGS = [
+ // ── IMPORT: last mile only ─────────────────────────────────────────────
+ {
+ reference: 'PAID-IMP-001',
+ tradeDirection: 'IMPORT',
+ containerCode: '40FT',
+ quantity: 8,
+ totalWeightTons: 224,
+ originCode: 'DJIBOUTI',
+ destinationCode: 'ADDIS_ABABA',
+ scheduledDate: '2026-07-01T08:00:00.000Z',
+ lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa',
+ lastMileDeliveryLat: 8.8808,
+ lastMileDeliveryLng: 38.7876,
+ },
+ {
+ reference: 'PAID-IMP-002',
+ tradeDirection: 'IMPORT',
+ containerCode: '20FT',
+ quantity: 12,
+ totalWeightTons: 240,
+ originCode: 'DJIBOUTI',
+ destinationCode: 'ADDIS_ABABA',
+ scheduledDate: '2026-07-02T08:00:00.000Z',
+ lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa',
+ lastMileDeliveryLat: 8.9137,
+ lastMileDeliveryLng: 38.7815,
+ },
+ {
+ reference: 'PAID-IMP-003',
+ tradeDirection: 'IMPORT',
+ containerCode: '40FT',
+ quantity: 6,
+ totalWeightTons: 180,
+ originCode: 'DJIBOUTI',
+ destinationCode: 'ADDIS_ABABA',
+ scheduledDate: '2026-07-03T08:00:00.000Z',
+ lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa',
+ lastMileDeliveryLat: 8.9806,
+ lastMileDeliveryLng: 38.8736,
+ },
+ // ── EXPORT: first mile only ────────────────────────────────────────────
+ {
+ reference: 'PAID-EXP-001',
+ tradeDirection: 'EXPORT',
+ containerCode: '40FT',
+ quantity: 7,
+ totalWeightTons: 196,
+ originCode: 'ADDIS_ABABA',
+ destinationCode: 'DJIBOUTI',
+ scheduledDate: '2026-07-01T10:00:00.000Z',
+ firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa',
+ firstMilePickupLat: 8.9806,
+ firstMilePickupLng: 38.8736,
+ },
+ {
+ reference: 'PAID-EXP-002',
+ tradeDirection: 'EXPORT',
+ containerCode: '20FT',
+ quantity: 11,
+ totalWeightTons: 220,
+ originCode: 'ADDIS_ABABA',
+ destinationCode: 'DJIBOUTI',
+ scheduledDate: '2026-07-02T10:00:00.000Z',
+ firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa',
+ firstMilePickupLat: 8.8808,
+ firstMilePickupLng: 38.7876,
+ },
+ {
+ reference: 'PAID-EXP-003',
+ tradeDirection: 'EXPORT',
+ containerCode: '40FT',
+ quantity: 4,
+ totalWeightTons: 128,
+ originCode: 'ADDIS_ABABA',
+ destinationCode: 'DJIBOUTI',
+ scheduledDate: '2026-07-03T10:00:00.000Z',
+ firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa',
+ firstMilePickupLat: 8.9137,
+ firstMilePickupLng: 38.7815,
+ },
+] as const;
+
+@Injectable()
+export class PaidImportExportMileDemoSeeder {
+ private readonly logger = new Logger(PaidImportExportMileDemoSeeder.name);
+
+ constructor(private readonly dataSource: DataSource) {}
+
+ async run() {
+ await this.dataSource.transaction(async (manager) => {
+ await manager.getRepository(Yard).upsert(
+ YARDS.map((yard) => ({ ...yard, isActive: true })),
+ { conflictPaths: { code: true } },
+ );
+
+ await manager.getRepository(ServiceType).upsert(
+ {
+ code: SERVICE_TYPE_CODE,
+ serviceName: 'Rail Container with Paid First/Last Mile',
+ description: 'Demo service type for paid import/export bookings with a single mile leg',
+ canBeBookedAlone: true,
+ includesFirstMile: true,
+ includesLastMile: true,
+ includesCustoms: false,
+ priorityBonusPoints: 0,
+ isActive: true,
+ displayOrder: 11,
+ },
+ { conflictPaths: { code: true } },
+ );
+
+ await manager.getRepository(ContainerType).upsert(
+ CONTAINER_TYPES.map((containerType, index) => ({
+ ...containerType,
+ wagonsPerUnit: 1,
+ isReefer: false,
+ isOpenTop: false,
+ isActive: true,
+ displayOrder: index + 1,
+ })),
+ { conflictPaths: { code: true } },
+ );
+
+ await manager.getRepository(Company).upsert(
+ {
+ name: 'Paid Import/Export Mile Demo Customer',
+ type: CompanyType.Customer,
+ status: CompanyStatus.Active,
+ tin: COMPANY_TIN,
+ vatNumber: COMPANY_TIN,
+ fanNumber: 'PMD0000000000001',
+ country: 'Ethiopia',
+ address: 'Addis Ababa',
+ phone: '251900000202',
+ email: COMPANY_EMAIL,
+ website: null,
+ contactPersonName: 'Paid Mile Demo',
+ contactPersonPhone: '251900000202',
+ generalManagerName: 'Demo Manager',
+ generalManagerEmail: COMPANY_EMAIL,
+ generalManagerPhone: '251900000202',
+ },
+ { conflictPaths: { tin: true } },
+ );
+
+ const [serviceType, company, yards, containerTypes] = await Promise.all([
+ manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
+ manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }),
+ manager.getRepository(Yard).find(),
+ manager.getRepository(ContainerType).find(),
+ ]);
+
+ const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
+ const containerTypeByCode = new Map(
+ containerTypes.map((containerType) => [containerType.code, containerType]),
+ );
+
+ for (const demoBooking of DEMO_BOOKINGS) {
+ const origin = yardByCode.get(demoBooking.originCode);
+ const destination = yardByCode.get(demoBooking.destinationCode);
+ const containerType = containerTypeByCode.get(demoBooking.containerCode);
+
+ if (!origin || !destination || !containerType) {
+ throw new Error(`paid_import_export_mile_demo_dependency_missing:${demoBooking.reference}`);
+ }
+
+ const isImport = demoBooking.tradeDirection === 'IMPORT';
+ const wagonsRequired =
+ Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
+ const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
+
+ await manager.getRepository(Booking).upsert(
+ {
+ reference: demoBooking.reference,
+ companyId: company.id,
+ status: 'APPROVED',
+ scheduledDate: new Date(demoBooking.scheduledDate),
+ estimatedShipmentDate: new Date(demoBooking.scheduledDate),
+ totalAmount: demoBooking.totalWeightTons * 25,
+ paymentStatus: 'PAID',
+ contractType: 'NEW',
+ serviceTypeId: serviceType.id,
+ // Only the leg that matches the trade direction carries an address.
+ firstMilePickupAddress: isImport ? null : demoBooking.firstMilePickupAddress,
+ firstMilePickupLat: isImport ? null : demoBooking.firstMilePickupLat,
+ firstMilePickupLng: isImport ? null : demoBooking.firstMilePickupLng,
+ lastMileDeliveryAddress: isImport ? demoBooking.lastMileDeliveryAddress : null,
+ lastMileDeliveryLat: isImport ? demoBooking.lastMileDeliveryLat : null,
+ lastMileDeliveryLng: isImport ? demoBooking.lastMileDeliveryLng : null,
+ equipmentReturn: 'WITHOUT_RETURN',
+ originYardId: origin.id,
+ destinationYardId: destination.id,
+ tradeDirection: demoBooking.tradeDirection,
+ freightType: 'CONTAINER',
+ cargoTypeId: null,
+ cargoFreeText: 'Demo container cargo',
+ shippingLineId: null,
+ cargoTotalWeightVgm: demoBooking.totalWeightTons,
+ isHazardous: false,
+ isReefer: false,
+ paymentCurrency: 'ETB',
+ approvedByStaffAt: new Date(),
+ priorityScore: 20,
+ wagonsRequired,
+ schedulingStatus: 'NOT_SCHEDULED',
+ versionNumber: 1,
+ },
+ { conflictPaths: { reference: true } },
+ );
+
+ const booking = await manager.getRepository(Booking).findOneByOrFail({
+ reference: demoBooking.reference,
+ });
+
+ await manager.getRepository(BookingContainer).delete({ bookingId: booking.id });
+ await manager.getRepository(BookingContainer).insert({
+ id: randomUUID(),
+ bookingId: booking.id,
+ containerTypeId: containerType.id,
+ quantity: demoBooking.quantity,
+ vgmPerUnitTons,
+ totalVgmTons: demoBooking.totalWeightTons,
+ wagonsRequired,
+ weightLimitRuleId: null,
+ isOverweight: vgmPerUnitTons > 35,
+ overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null,
+ });
+
+ // Reset any existing legs for idempotency, then create the single paid leg.
+ await manager.getRepository(FirstMile).delete({ bookingId: booking.id });
+ await manager.getRepository(LastMile).delete({ bookingId: booking.id });
+
+ const paidAmount = demoBooking.totalWeightTons * 25;
+
+ if (isImport) {
+ await manager.getRepository(LastMile).insert({
+ bookingId: booking.id,
+ status: 'READY_TO_TRANSIT',
+ advancedPayment: paidAmount,
+ remainingPayment: 0,
+ paid: true,
+ estimatedKm: 22,
+ exactKm: null,
+ vehicleId: null,
+ });
+ } else {
+ await manager.getRepository(FirstMile).insert({
+ bookingId: booking.id,
+ status: 'READY_TO_TRANSIT',
+ advancedPayment: paidAmount,
+ remainingPayment: 0,
+ paid: true,
+ estimatedKm: 18,
+ exactKm: null,
+ vehicleId: null,
+ });
+ }
+ }
+ });
+
+ this.logger.log(
+ 'Seeded 6 paid bookings: 3 import (last-mile) + 3 export (first-mile).',
+ );
+ }
+}
diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts
index ce2c35c1a..4e20106a6 100644
--- a/apps/edr-freight-web/portal/src/constants/URLS.ts
+++ b/apps/edr-freight-web/portal/src/constants/URLS.ts
@@ -170,5 +170,6 @@ export const URL_CONSTANTS = {
BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`,
DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`,
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
+ PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`,
},
};
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx
index 146e31c1e..acd4dbb25 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx
@@ -1,16 +1,24 @@
-import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
-import { useQuery } from "@tanstack/react-query";
-import { Download, Receipt } from "lucide-react";
+import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { CreditCard, Download, Receipt } from "lucide-react";
+import { useState } from "react";
import toast from "react-hot-toast";
+import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import {
warehouseInvoicesService,
type PortalWarehouseInvoice,
} from "@/services/warehouse-invoices.service";
import { saveBlob } from "@/utils/download";
+import { PaymentMethodModal } from "./PaymentMethodModal";
import { CardTitle, SectionCard } from "./layout";
+/** Warehouse fee invoices the customer can still settle online. */
+const PAYABLE_STATUSES = new Set(["ISSUED", "PARTIALLY_PAID"]);
+const isPayable = (inv: PortalWarehouseInvoice) =>
+ PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0;
+
const money = (amount: number | string | null | undefined, currency: string) =>
`${Number(amount ?? 0).toLocaleString()} ${currency}`;
@@ -44,10 +52,12 @@ function StatusPill({ status }: { status: string }) {
}
/**
- * Warehouse fee invoices linked to this booking — display + PDF download only.
- * Paying them online is tracked separately (in-system demurrage/storage
- * payment). Renders nothing when the booking has no warehouse fees. Carries
- * `id="warehouse-payments"` so the invoice detail page can deep-link here.
+ * Warehouse fee invoices linked to this booking. Customers can pay outstanding
+ * demurrage/storage invoices online (Telebirr/Waafi) so they can then sign the
+ * delivery handover; paid invoices expose the receipt PDF. The backoffice cash
+ * `/pay` (record-a-payment) path is unaffected. Renders nothing when the booking
+ * has no warehouse fees. Carries `id="warehouse-payments"` so the invoice detail
+ * page can deep-link here.
*/
export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
const { data: invoices = [] } = useQuery({
@@ -55,6 +65,41 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
queryFn: () => warehouseInvoicesService.listForBooking(bookingId),
});
+ const [payInvoice, setPayInvoice] = useState(null);
+
+ const payMutation = useMutation({
+ mutationFn: (method: PaymentMethod) => {
+ if (!payInvoice) throw new Error("No invoice selected for payment.");
+ return warehouseInvoicesService.payOnline(payInvoice.id, {
+ method,
+ platform: "web",
+ });
+ },
+ onSuccess: (data, method) => {
+ if (!payInvoice) return;
+ // Redirect to the provider (or the fallback checkout page) — same as the
+ // booking "Pay now" flow, so behaviour is identical everywhere.
+ const redirectUrl =
+ data?.clientAction?.type === "REDIRECT" && data.clientAction.url
+ ? data.clientAction.url
+ : paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method });
+ window.location.href = redirectUrl;
+ },
+ });
+
+ const payError = payMutation.isError
+ ? payMutation.error instanceof Error
+ ? payMutation.error.message
+ : "Could not start payment. Please try again."
+ : null;
+
+ const closePayModal = () => {
+ if (!payMutation.isPending) {
+ setPayInvoice(null);
+ payMutation.reset();
+ }
+ };
+
if (invoices.length === 0) return null;
const download = async (inv: PortalWarehouseInvoice) => {
@@ -128,6 +173,17 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
+ {isPayable(inv) && (
+ }
+ onClick={() => setPayInvoice(inv)}
+ >
+ Pay
+
+ )}
+
+ payMutation.mutate(method)}
+ processing={payMutation.isPending}
+ error={payError}
+ />
);
}
diff --git a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts
index f1fa4e841..91cd6a0d2 100644
--- a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts
+++ b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts
@@ -1,5 +1,10 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
+import type {
+ InitiateResponse,
+ PaymentMethod,
+ PaymentPlatform,
+} from "./payments.service";
const W = URL_CONSTANTS.WAREHOUSE_INVOICES;
@@ -51,4 +56,27 @@ export const warehouseInvoicesService = {
const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" });
return data;
},
+
+ /**
+ * Initiate a Telebirr/Waafi online payment for a warehouse demurrage/storage
+ * invoice. Returns the payment intent + `clientAction` to redirect the browser
+ * to the provider (mirrors the booking `/pay` flow). The backoffice cash
+ * `/pay` (record-a-payment) path is unaffected.
+ */
+ payOnline: async (
+ id: string,
+ payload: {
+ method: PaymentMethod;
+ platform?: PaymentPlatform;
+ payerAccount?: string;
+ returnUrl?: string;
+ failureUrl?: string;
+ },
+ ): Promise => {
+ const { data } = await client.post(W.PAY_ONLINE(id), {
+ platform: "web",
+ ...payload,
+ });
+ return data.data ?? data;
+ },
};
From 2f6f06b7c4870d168ddea43b7017a24224558c10 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Sat, 4 Jul 2026 06:47:20 +0000
Subject: [PATCH 08/73] feat: add check validity to the user signup
---
.../auth/check-availability.controller.ts | 22 +++++++++
.../auth/check-availability.service.ts | 47 +++++++++++++++++++
.../src/modules/auth/freight-auth.module.ts | 10 +++-
.../portal/src/constants/URLS.ts | 1 +
.../portal/src/pages/accounts/SignupPage.tsx | 22 ++++++++-
.../portal/src/services/api.ts | 7 +++
.../portal/src/services/auth.service.ts | 34 +++++++++-----
apps/edr-freight-web/portal/src/types/auth.ts | 10 ++++
8 files changed, 136 insertions(+), 17 deletions(-)
create mode 100644 apps/edr-freight-api/src/modules/auth/check-availability.controller.ts
create mode 100644 apps/edr-freight-api/src/modules/auth/check-availability.service.ts
diff --git a/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts b/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts
new file mode 100644
index 000000000..13084d1c8
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts
@@ -0,0 +1,22 @@
+import { Controller, Get, Query } from "@nestjs/common";
+import { ApiOperation, ApiTags } from "@nestjs/swagger";
+import { Public } from "@edr/api-common";
+
+import { CheckAvailabilityService } from "./check-availability.service";
+
+@ApiTags("auth")
+@Controller("auth")
+@Public()
+export class CheckAvailabilityController {
+ constructor(
+ private readonly checkAvailabilityService: CheckAvailabilityService,
+ ) {}
+
+ @Get("check-availability")
+ @ApiOperation({
+ summary: "Check whether an email and/or phone number is already registered",
+ })
+ check(@Query("email") email?: string, @Query("phone") phone?: string) {
+ return this.checkAvailabilityService.check({ email, phone });
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/auth/check-availability.service.ts b/apps/edr-freight-api/src/modules/auth/check-availability.service.ts
new file mode 100644
index 000000000..c9ce84b72
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/auth/check-availability.service.ts
@@ -0,0 +1,47 @@
+import { BadRequestException, Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+
+import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
+
+export interface CheckAvailabilityQuery {
+ email?: string;
+ phone?: string;
+}
+
+export interface CheckAvailabilityResult {
+ emailTaken: boolean;
+ phoneTaken: boolean;
+}
+
+@Injectable()
+export class CheckAvailabilityService {
+ constructor(
+ @InjectRepository(User)
+ private readonly userRepository: Repository,
+ ) {}
+
+ async check({
+ email,
+ phone,
+ }: CheckAvailabilityQuery): Promise {
+ if (!email && !phone) {
+ throw new BadRequestException("email or phone is required");
+ }
+
+ const matches = await this.userRepository.find({
+ where: [
+ ...(email ? [{ email }] : []),
+ ...(phone ? [{ phoneNumber: phone }] : []),
+ ],
+ select: { id: true, email: true, phoneNumber: true },
+ });
+
+ return {
+ emailTaken: email ? matches.some((user) => user.email === email) : false,
+ phoneTaken: phone
+ ? matches.some((user) => user.phoneNumber === phone)
+ : false,
+ };
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts
index a689ba24e..16fbeffda 100644
--- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts
+++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts
@@ -1,10 +1,16 @@
import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
+
+import { CheckAvailabilityController } from './check-availability.controller';
+import { CheckAvailabilityService } from './check-availability.service';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
@Module({
- controllers: [FreightMeController],
- providers: [FreightMeService],
+ imports: [TypeOrmModule.forFeature([User])],
+ controllers: [FreightMeController, CheckAvailabilityController],
+ providers: [FreightMeService, CheckAvailabilityService],
})
export class FreightAuthModule {}
diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts
index a4e20b029..a8cafc0d1 100644
--- a/apps/edr-freight-web/portal/src/constants/URLS.ts
+++ b/apps/edr-freight-web/portal/src/constants/URLS.ts
@@ -14,6 +14,7 @@ export const URL_CONSTANTS = {
SET_PASSWORD: "/api/auth/set-password",
ME: "/api/auth/me",
GENERATE_VERIFICATION_CODE: "/users/generate-verification-code",
+ CHECK_AVAILABILITY: "/api/auth/check-availability",
},
OTP: {
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
index 50320f699..05fa411b5 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
@@ -132,12 +132,30 @@ export default function SignupPage() {
const passwordValue = watch("password") ?? "";
- // Step 1 — form is valid: send a fresh code to the chosen channel, then
- // move to the OTP challenge.
+ // Step 1 — form is valid: make sure the email/phone aren't already
+ // registered, then send a fresh code to the chosen channel and move to
+ // the OTP challenge.
const requestOtp = async (data: FormData) => {
setError(null);
setSending(true);
try {
+ const availability = await api.auth.checkAvailability.call({
+ email: data.email,
+ phone: data.phone,
+ });
+ if (availability.emailTaken && availability.phoneTaken) {
+ setError("An account with this email and phone number already exists.");
+ return;
+ }
+ if (availability.emailTaken) {
+ setError("An account with this email already exists.");
+ return;
+ }
+ if (availability.phoneTaken) {
+ setError("An account with this phone number already exists.");
+ return;
+ }
+
await api.auth.sendOTP.call(
channel === "email" ? { email: data.email } : { phone: data.phone },
);
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index 81a2488a9..932f6fc46 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -66,6 +66,8 @@ import type {
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type {
AuthUser,
+ CheckAvailabilityPayload,
+ CheckAvailabilityResponse,
GenerateVerificationCodePayload,
LoginPayload,
LoginResponse,
@@ -107,6 +109,11 @@ export const api = {
"setPassword",
authService.setPassword,
),
+ checkAvailability: endpoint(
+ "auth",
+ "checkAvailability",
+ authService.checkAvailability,
+ ),
sendOTP: endpoint(
"auth",
"sendOTP",
diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts
index e1de1889a..58b81c5ba 100644
--- a/apps/edr-freight-web/portal/src/services/auth.service.ts
+++ b/apps/edr-freight-web/portal/src/services/auth.service.ts
@@ -1,14 +1,16 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
- AuthUser,
- GenerateVerificationCodePayload,
- LoginPayload,
- LoginResponse,
- OtpPayload,
- OtpResponse,
- SetPasswordPayload,
- SignupPayload,
- SignupResponse,
+ AuthUser,
+ CheckAvailabilityPayload,
+ CheckAvailabilityResponse,
+ GenerateVerificationCodePayload,
+ LoginPayload,
+ LoginResponse,
+ OtpPayload,
+ OtpResponse,
+ SetPasswordPayload,
+ SignupPayload,
+ SignupResponse,
} from "@/types/auth";
import { client } from "@/utils/api";
import { ApiResponse } from "@edr/types";
@@ -23,7 +25,7 @@ export const authService = {
},
createUser: async (body: SignupPayload) => {
- const res = await client.post> (
+ const res = await client.post>(
URL_CONSTANTS.USERS.SIGN_UP,
body,
);
@@ -31,9 +33,7 @@ export const authService = {
},
getMyInfo: async () => {
- const res = await client.get(
- URL_CONSTANTS.USERS.ME,
- );
+ const res = await client.get(URL_CONSTANTS.USERS.ME);
return res.data;
},
@@ -53,6 +53,14 @@ export const authService = {
return res.data.data;
},
+ checkAvailability: async (params: CheckAvailabilityPayload) => {
+ const res = await client.get>(
+ URL_CONSTANTS.USERS.CHECK_AVAILABILITY,
+ { params },
+ );
+ return res.data;
+ },
+
sendOTP: async (body: OtpPayload) => {
const res = await client.post>(
URL_CONSTANTS.OTP.SEND,
diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts
index 7b58f573b..04357a9ca 100644
--- a/apps/edr-freight-web/portal/src/types/auth.ts
+++ b/apps/edr-freight-web/portal/src/types/auth.ts
@@ -45,6 +45,16 @@ export interface OtpResponse {
message: string;
}
+export interface CheckAvailabilityPayload {
+ email?: string;
+ phone?: string;
+}
+
+export interface CheckAvailabilityResponse {
+ emailTaken: boolean;
+ phoneTaken: boolean;
+}
+
export interface SetPasswordPayload {
newPassword: string;
confirmPassword: string;
From 1c625cfb827aaf4d6894c50cc54a45935691e0a8 Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Sat, 4 Jul 2026 10:10:26 +0300
Subject: [PATCH 09/73] feat: ( iam ) OTP-gate registration via IAM signup +
set-password
---
.../src/modules/auth/auth.controller.ts | 20 +-
.../src/modules/auth/auth.dto.ts | 17 +-
.../modules/auth/passenger-auth.service.ts | 101 +++++++-
.../modules/bookings/guest-booking.service.ts | 5 +-
.../portal/src/app/register/page.tsx | 60 ++---
.../portal/src/app/verify-account/page.tsx | 216 ++++++++++++++++++
.../portal/src/lib/api/auth.ts | 4 +
.../portal/src/lib/auth-store.ts | 31 +--
8 files changed, 370 insertions(+), 84 deletions(-)
create mode 100644 apps/edr-passenger-web/portal/src/app/verify-account/page.tsx
diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts
index d53ef40d7..a6f596bf6 100644
--- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts
+++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts
@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nes
import { Throttle, SkipThrottle } from '@nestjs/throttler';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PassengerAuthService } from './passenger-auth.service';
-import { RegisterDto, LoginDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto';
+import { RegisterDto, LoginDto, ResendRegistrationCodeDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Passenger Auth')
@@ -14,14 +14,28 @@ export class AuthController {
@Post('register')
@IsPublic()
- @ApiOperation({ summary: 'Register new passenger account' })
- @ApiResponse({ status: 201, description: 'Account created. Returns token + user.' })
+ @ApiOperation({ summary: 'Register new passenger account (sends SMS verification code)' })
+ @ApiResponse({
+ status: 201,
+ description:
+ 'Account created as pending. A verification code is sent via SMS — complete signup via PATCH /v1/auth/set-password.',
+ })
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
@ApiBody({ type: RegisterDto })
register(@Request() req: any, @Body() dto: RegisterDto) {
return this.passengerAuthService.register(dto, req);
}
+ @Post('register/resend-code')
+ @IsPublic()
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: 'Resend the registration verification code for a pending account' })
+ @ApiResponse({ status: 200, description: 'Verification code re-sent if the account is pending.' })
+ @ApiBody({ type: ResendRegistrationCodeDto })
+ resendRegistrationCode(@Request() req: any, @Body() dto: ResendRegistrationCodeDto) {
+ return this.passengerAuthService.resendRegistrationCode(dto, req);
+ }
+
@Post('login')
@IsPublic()
@HttpCode(HttpStatus.OK)
diff --git a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts
index 6f43e7212..d0a691b0f 100644
--- a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts
+++ b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts
@@ -1,6 +1,6 @@
-import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
+import { IsEmail, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
-import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { ApiProperty } from '@nestjs/swagger';
export class NameDto {
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
@@ -29,15 +29,16 @@ export class RegisterDto {
@ValidateNested()
@Type(() => NameDto)
name: NameDto;
+}
- @ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' })
- @IsString()
- @MinLength(8)
- password: string;
+export class ResendRegistrationCodeDto {
+ @ApiProperty({ example: 'kelemu@email.com' })
+ @IsEmail()
+ email: string;
- @ApiProperty({ example: 'SecurePass123', format: 'password' })
+ @ApiProperty({ example: '+251912345678' })
@IsString()
- confirmPassword: string;
+ phoneNumber: string;
}
export class LoginDto {
diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts
index 0213bb8f6..1784261e5 100644
--- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts
+++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts
@@ -50,14 +50,17 @@ export class PassengerAuthService {
const iamAuthService = await this.resolveIamAuthService(req);
- const { token, refreshToken } = await iamAuthService.signupWithPassword({
+ // IAM `signup` creates the user as PENDING/isActive=false with NO credential and
+ // SMS-sends a 6-digit verification code. The account cannot log in until the code is
+ // redeemed via PATCH /v1/auth/set-password. We intentionally discard the session
+ // token `signup` returns — the account is not verified yet, so it must never reach
+ // the client.
+ await iamAuthService.signup({
email: dto.email,
username: dto.username,
phoneNumber: dto.phoneNumber,
userType: EUserType.INDIVIDUAL,
name: dto.name,
- password: dto.password,
- confirmPassword: dto.confirmPassword,
});
const iamRows = await this.dataSource.query(
@@ -70,20 +73,98 @@ export class PassengerAuthService {
}
const iamUserId = iamRows[0].id;
- let passengerId: string;
+ // The Prisma "passenger satellite" (Passenger + wallet + loyalty) is NOT provisioned
+ // here — `login()` lazy-provisions it on first successful login, so satellites exist
+ // only for verified users who complete set-password and sign in.
+ return {
+ iamUserId,
+ email: dto.email,
+ phoneNumber: dto.phoneNumber,
+ requiresPasswordSetup: true,
+ };
+ }
+
+ /**
+ * Immediate-activation account creation used by the payment-gated guest-checkout
+ * "create account" path only. Unlike the public `register()` (OTP-gated), this creates a
+ * ready-to-use account from the password entered at checkout and provisions the passenger
+ * satellite synchronously so the booking can attach to it. Do NOT wire this to the public
+ * registration form — that flow must stay behind SMS verification.
+ */
+ async registerWithPassword(
+ dto: {
+ email: string;
+ username: string;
+ phoneNumber: string;
+ name: { en: string; am: string };
+ password: string;
+ },
+ req: any,
+ ): Promise<{ iamUserId: string; passengerId: string }> {
+ const existing = await this.dataSource.query<{ id: string }[]>(
+ `SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`,
+ [dto.email, dto.phoneNumber],
+ );
+ if (existing.length) throw new ConflictException('Email or phone already registered');
+
+ const iamAuthService = await this.resolveIamAuthService(req);
+ await iamAuthService.signupWithPassword({
+ email: dto.email,
+ username: dto.username,
+ phoneNumber: dto.phoneNumber,
+ userType: EUserType.INDIVIDUAL,
+ name: dto.name,
+ password: dto.password,
+ confirmPassword: dto.password,
+ });
+
+ const iamRows = await this.dataSource.query(
+ `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`,
+ [dto.email],
+ );
+ if (!iamRows.length) {
+ await this.compensateIamSignup(dto.email);
+ throw new InternalServerErrorException('Account creation failed. Please try again.');
+ }
+ const iamUserId = iamRows[0].id;
+
try {
const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' });
- passengerId = result.passengerId;
+ return { iamUserId, passengerId: result.passengerId };
} catch {
await this.compensateIamSignup(dto.email);
throw new InternalServerErrorException('Account creation failed. Please try again.');
}
+ }
- return {
- token,
- refreshToken,
- user: { id: iamUserId, iamUserId, email: dto.email, fullName: dto.name.en, passengerId },
- };
+ async resendRegistrationCode(
+ dto: { email: string; phoneNumber: string },
+ req: any,
+ ): Promise<{ sent: boolean }> {
+ // Only regenerate for accounts still pending password setup. A fully-registered user
+ // should use forgot-password instead. Always return { sent: true } to avoid leaking
+ // whether the email/phone maps to a pending account (enumeration guard).
+ const users = await this.dataSource.query<{ email: string; phone_number: string }[]>(
+ `SELECT email, phone_number FROM iam.users
+ WHERE email = $1 AND phone_number = $2 AND has_set_password = false LIMIT 1`,
+ [dto.email, dto.phoneNumber],
+ );
+ if (!users.length) return { sent: true };
+
+ const iamAuthService = await this.resolveIamAuthService(req);
+ try {
+ await iamAuthService.generateVerificationCode({
+ email: users[0].email,
+ phoneNumber: users[0].phone_number,
+ type: EOtpType.VERIFY_PHONE_NUMBER,
+ });
+ } catch (err) {
+ this.logger.error(
+ `[PassengerAuthService] resend registration code failed for ${dto.email}`,
+ (err as Error).message,
+ );
+ }
+ return { sent: true };
}
async login(dto: LoginDto, req: any) {
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
index 6907d14a3..d2c65db42 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
@@ -886,18 +886,17 @@ export class GuestBookingService {
): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> {
if (dto.createAccount && firstPassenger.email && dto.password) {
const guestName = firstPassenger.passengerName ?? 'Guest';
- const result = await this.passengerAuthService.register(
+ const result = await this.passengerAuthService.registerWithPassword(
{
email: firstPassenger.email,
username: firstPassenger.email,
phoneNumber: firstPassenger.phone || `+251900000000`,
name: { en: guestName, am: guestName },
password: dto.password,
- confirmPassword: dto.password,
},
req,
);
- return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true };
+ return { guestPassengerId: result.passengerId, iamUserId: result.iamUserId, createdAccount: true };
}
// Create guest passenger with basic profile
diff --git a/apps/edr-passenger-web/portal/src/app/register/page.tsx b/apps/edr-passenger-web/portal/src/app/register/page.tsx
index c335d9e98..a39810be1 100644
--- a/apps/edr-passenger-web/portal/src/app/register/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/register/page.tsx
@@ -9,18 +9,11 @@ import { useAuthStore } from '@/lib/auth-store';
import { useState } from 'react';
import { Train, ShieldCheck } from 'lucide-react';
-const registerSchema = z
- .object({
- fullName: z.string().min(2, 'Full name is required'),
- email: z.string().email('Invalid email address'),
- phone: z.string().min(9, 'Phone number is required'),
- password: z.string().min(8, 'Password must be at least 8 characters'),
- confirmPassword: z.string(),
- })
- .refine((data) => data.password === data.confirmPassword, {
- message: 'Passwords do not match',
- path: ['confirmPassword'],
- });
+const registerSchema = z.object({
+ fullName: z.string().min(2, 'Full name is required'),
+ email: z.string().email('Invalid email address'),
+ phone: z.string().min(9, 'Phone number is required'),
+});
type RegisterForm = z.infer;
@@ -38,14 +31,17 @@ export default function RegisterPage() {
setLoading(true);
setError('');
try {
- await registerUser({
+ const result = await registerUser({
fullName: data.fullName,
email: data.email,
phone: data.phone,
- password: data.password,
- confirmPassword: data.confirmPassword,
});
- router.push('/booking/search');
+ const params = new URLSearchParams({
+ email: result.email,
+ userId: result.iamUserId,
+ phone: result.phoneNumber,
+ });
+ router.push(`/verify-account?${params.toString()}`);
} catch (err: any) {
if (err.response?.status === 409) {
setError('An account with this email or phone number already exists.');
@@ -67,7 +63,7 @@ export default function RegisterPage() {
Create account
- Book faster and manage your trips
+ We'll text you a code to verify your phone
@@ -120,36 +116,8 @@ export default function RegisterPage() {
)}
-
-
-
- {errors.password && (
-
{errors.password.message}
- )}
-
-
-
-
-
- {errors.confirmPassword && (
-
{errors.confirmPassword.message}
- )}
-
-
diff --git a/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx b/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx
new file mode 100644
index 000000000..f9f0b61c9
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx
@@ -0,0 +1,216 @@
+'use client';
+
+import { Suspense, useState } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import Link from 'next/link';
+import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
+import { iamAuthApi } from '@/lib/api/auth';
+import { useAuthStore } from '@/lib/auth-store';
+
+// Mirrors the IAM set-password requirement (class-validator @IsStrongPassword defaults):
+// min length 8, with lower- and upper-case letters, a number, and a symbol.
+function isStrongPassword(pw: string): boolean {
+ return (
+ pw.length >= 8 &&
+ /[a-z]/.test(pw) &&
+ /[A-Z]/.test(pw) &&
+ /[0-9]/.test(pw) &&
+ /[^A-Za-z0-9]/.test(pw)
+ );
+}
+
+function VerifyAccountContent() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const login = useAuthStore((s) => s.login);
+
+ const email = searchParams.get('email') || '';
+ const userId = searchParams.get('userId') || '';
+ const phone = searchParams.get('phone') || '';
+ const linkValid = Boolean(email && userId);
+
+ const [verificationCode, setVerificationCode] = useState('');
+ const [newPassword, setNewPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [resending, setResending] = useState(false);
+ const [resent, setResent] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ if (!verificationCode.trim()) {
+ setError('Enter the verification code sent to your phone.');
+ return;
+ }
+ if (!isStrongPassword(newPassword)) {
+ setError('Password must be at least 8 characters and include upper- and lower-case letters, a number, and a symbol.');
+ return;
+ }
+ if (newPassword !== confirmPassword) {
+ setError('Passwords do not match.');
+ return;
+ }
+ setLoading(true);
+ try {
+ // Completes signup: PATCH /v1/auth/set-password with the SMS code, which activates
+ // the account and sets the password.
+ await iamAuthApi.resetPassword({
+ userId,
+ email,
+ verificationCode: verificationCode.trim(),
+ newPassword,
+ confirmPassword,
+ });
+ // Auto-login with the freshly-set password; login lazy-provisions the passenger record.
+ await login(email, newPassword);
+ router.push('/booking/search');
+ } catch (err: any) {
+ const msg = err.response?.data?.message || err.message || '';
+ setError(msg || 'Could not verify your account. Check the code and try again, or resend it.');
+ setLoading(false);
+ }
+ };
+
+ const handleResend = async () => {
+ setError('');
+ setResent(false);
+ setResending(true);
+ try {
+ await iamAuthApi.resendRegistrationCode({ email, phoneNumber: phone });
+ setResent(true);
+ } catch {
+ setError('Could not resend the code. Please try again in a moment.');
+ } finally {
+ setResending(false);
+ }
+ };
+
+ return (
+
+
+
+
+
Verify your account
+ {linkValid && (
+
+ Enter the code we sent to your phone and choose a password for{' '}
+ {email}.
+
+ )}
+
+
+
+ {!linkValid ? (
+
+
+ This verification link is invalid or incomplete. Please start registration again.
+
+
+ Back to registration
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+export default function VerifyAccountPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/edr-passenger-web/portal/src/lib/api/auth.ts b/apps/edr-passenger-web/portal/src/lib/api/auth.ts
index aa5272173..14e9a16a1 100644
--- a/apps/edr-passenger-web/portal/src/lib/api/auth.ts
+++ b/apps/edr-passenger-web/portal/src/lib/api/auth.ts
@@ -11,6 +11,10 @@ export const iamAuthApi = {
forgotPassword: (email: string) =>
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
+ // Re-sends the registration verification code for a still-pending account.
+ resendRegistrationCode: (data: { email: string; phoneNumber: string }) =>
+ axios.post(`${API_URL}/auth/register/resend-code`, data),
+
// Completes the forgot-password flow using the link sent via SMS:
// ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=..
resetPassword: (data: {
diff --git a/apps/edr-passenger-web/portal/src/lib/auth-store.ts b/apps/edr-passenger-web/portal/src/lib/auth-store.ts
index 2157cfc0a..0d6e46fd9 100644
--- a/apps/edr-passenger-web/portal/src/lib/auth-store.ts
+++ b/apps/edr-passenger-web/portal/src/lib/auth-store.ts
@@ -31,7 +31,7 @@ interface AuthState {
isAuthenticated: boolean;
isInitialized: boolean;
login: (email: string, password: string) => Promise;
- register: (data: RegisterData) => Promise;
+ register: (data: RegisterData) => Promise;
logout: () => Promise;
setUser: (user: User, token: string) => void;
updateUser: (userData: Partial) => void;
@@ -43,8 +43,12 @@ interface RegisterData {
fullName: string;
email: string;
phone: string;
- password: string;
- confirmPassword: string;
+}
+
+interface RegisterResult {
+ iamUserId: string;
+ email: string;
+ phoneNumber: string;
}
export const useAuthStore = create((set, get) => ({
@@ -118,25 +122,24 @@ export const useAuthStore = create((set, get) => ({
set({ user, token, isAuthenticated: true });
},
- register: async (data: RegisterData) => {
+ register: async (data: RegisterData): Promise => {
// Shape required by the passenger-api RegisterDto; username = email by convention.
+ // Registration no longer takes a password — the account is created as pending and
+ // an SMS verification code is sent. The user completes signup on the verify-account
+ // page (set-password). No token is issued here; the user is NOT logged in yet.
const payload = {
email: data.email,
username: data.email,
phoneNumber: data.phone,
name: { en: data.fullName, am: data.fullName },
- password: data.password,
- confirmPassword: data.confirmPassword,
};
const response: any = await apiClient.post('/auth/register', payload);
- const { token, user } = response.data || response;
-
- if (typeof window !== 'undefined') {
- localStorage.setItem('auth_token', token);
- localStorage.setItem('auth_user', JSON.stringify(user));
- }
-
- set({ user, token, isAuthenticated: true });
+ const result = response.data || response;
+ return {
+ iamUserId: result.iamUserId,
+ email: result.email,
+ phoneNumber: result.phoneNumber,
+ };
},
logout: async () => {
From 74118165c6530cb3f9c58f8221e376e325dd4e76 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Sat, 4 Jul 2026 07:16:23 +0000
Subject: [PATCH 10/73] chages
---
.../src/modules/train-scheduling/booking-batch.service.ts | 5 ++++-
.../src/modules/train-scheduling/train-scheduling.service.ts | 4 +++-
.../src/components/contracts/GlUpcomingWindowsSection.tsx | 2 ++
3 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
index 46175c7ef..0b239990b 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
@@ -310,7 +310,10 @@ export class BookingBatchService implements OnModuleInit {
private async openRouteDayGroups(): Promise {
const open = (
await this.trainSchedulesRepository.findAll({
- where: { bookingWindowStatus: "OPEN" },
+ where: [
+ { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft },
+ { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled },
+ ],
})
).filter((s) => s.windowPhase == null);
const groups = new Map();
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
index 32a046356..3b36fdc9a 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
@@ -2049,7 +2049,9 @@ export class TrainSchedulingService {
await this.trainSchedulesRepository.updateStatus(
id,
TrainScheduleStatusEnum.Cancelled,
- {},
+ // Retire the booking window so a canceled schedule never lingers as an
+ // "open window" in booking-window lists or the legacy batch fill.
+ { bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' },
manager,
);
if (schedule.trainSetId) {
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx
index e5e998721..dc95729c1 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx
@@ -235,6 +235,8 @@ export function GlUpcomingWindowsSection() {
const rows = (data ?? []).filter(
(w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w),
);
+ // Canceled schedules are retired to windowPhase='DONE' server-side, so the
+ // guard above already excludes them; they never reach the upcoming list.
// Open lanes first, then by opening time.
return rows.sort((a, b) => {
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
From 619ffa5419de62addd46a3f9c5b439a2c61787d1 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Sat, 4 Jul 2026 07:17:05 +0000
Subject: [PATCH 11/73] style(WIP): auth ui clean up.
---
.../src/components/auth/AuthShell.tsx | 139 +++++++
.../backoffice/src/pages/auth/LoginPage.tsx | 392 +++++-------------
.../portal/src/pages/accounts/LoginPage.tsx | 81 ++--
3 files changed, 274 insertions(+), 338 deletions(-)
create mode 100644 apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx
diff --git a/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx
new file mode 100644
index 000000000..ecf06c7c9
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx
@@ -0,0 +1,139 @@
+import type { ReactNode } from "react";
+import { ArrowUpRight, ChevronDown, Globe } from "lucide-react";
+
+const LOGIN_IMAGE = "/assets/login.png";
+const EDR_LOGO = "/assets/logo.svg";
+
+const LeftPanelDecor = () => (
+
+
+
+
+);
+
+const RightPanelDecor = () => (
+
+);
+
+export interface AuthShellProps {
+ children: ReactNode;
+ /** Tagline shown in the highlighted card over the left image panel. */
+ tagline?: string;
+ taglineBody?: string;
+}
+
+const LeftPanel = ({
+ tagline,
+ taglineBody,
+}: Pick) => (
+
+

+
+
+
+
+

+
+
+
+
+
+
+
+ {tagline ?? "Empower Your Freight Operations"}
+
+
+
+ {taglineBody ??
+ "Sign in to manage bookings, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."}
+
+
+
+
+);
+
+const LanguageSelector = () => (
+
+
+ Eng
+
+
+);
+
+export default function AuthShell({
+ children,
+ tagline,
+ taglineBody,
+}: AuthShellProps) {
+ return (
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx
index 89e8557c3..849049bc2 100644
--- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx
@@ -1,162 +1,40 @@
import { type FormEvent, useState } from "react";
import {
- Eye,
- EyeOff,
- ArrowUpRight,
- Globe,
- ChevronDown,
-} from "lucide-react";
+ Alert,
+ Box,
+ Button,
+ Center,
+ Group,
+ Image,
+ PasswordInput,
+ PinInput,
+ Stack,
+ Text,
+ TextInput,
+ Title,
+} from "@mantine/core";
+import { AlertCircle, ArrowLeft } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
+import AuthShell from "@/components/auth/AuthShell";
+import { extractApiError } from "@/utils/result";
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
const normaliseIdentifier = (raw: string): string => {
const v = raw.trim();
const digits = v.replace(/\D/g, "");
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
- const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
+ const local = digits.startsWith("251")
+ ? digits.slice(3)
+ : digits.replace(/^0/, "");
return `+251${local}`;
}
return v.toLowerCase();
};
-const LOGIN_IMAGE = "/assets/login.png";
const EDR_LOGO = "/assets/logo.svg";
-const fieldClass =
- "h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
-
-const primaryButtonClass =
- "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
-
-const LeftPanelDecor = () => (
-
-
-
-
-);
-
-const RightPanelDecor = () => (
-
-);
-
-const LeftPanel = () => (
-
-

-
-
-
-
-
-
-
-
-
-
- Empower Your Freight Operations
-
-
-
- Sign in to manage bookings, track cargo, and run logistics operations
- on the Ethio Djibouti Railway freight platform.
-
-
-
-
-);
-
-const LanguageSelector = () => (
-
-
- Eng
-
-
-);
-
-const FormFooter = () => (
-
-);
-
const LoginPage = () => {
const navigate = useNavigate();
const { login, verifyMfa } = useAuth();
@@ -165,7 +43,6 @@ const LoginPage = () => {
const [otp, setOtp] = useState("");
const [needsMfa, setNeedsMfa] = useState(false);
const [submitting, setSubmitting] = useState(false);
- const [showPassword, setShowPassword] = useState(false);
const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
const [error, setError] = useState(null);
@@ -179,15 +56,14 @@ const LoginPage = () => {
setNormalizedIdentifier(normalized);
const result = await login({ email: normalized, password });
- console.log(result);
if (result.mfaRequired) {
setNeedsMfa(true);
return;
}
// navigate("/dashboard/overview", { replace: true });
- } catch {
- setError("Unable to sign in with those credentials.");
+ } catch (err) {
+ setError(extractApiError(err).message);
} finally {
setSubmitting(false);
}
@@ -201,194 +77,132 @@ const LoginPage = () => {
try {
await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() });
navigate("/dashboard/overview", { replace: true });
- } catch {
- setError("Unable to verify the one-time code.");
+ } catch (err) {
+ setError(extractApiError(err).message);
} finally {
setSubmitting(false);
}
};
const loginForm = (
-
-
-
-
- setIdentifier(event.target.value)}
- placeholder="name@company.com or 09XXXXXXXX"
- autoComplete="username"
- className={fieldClass}
- />
-
+
+ setIdentifier(event.target.value)}
+ />
-
-
-
- setPassword(event.target.value)}
- placeholder="Enter your password"
- className={`${fieldClass} pr-11`}
- />
-
-
-
+ setPassword(event.target.value)}
+ />
{error ? (
-
+
) : null}
-
-
-
- Need an account?{" "}
-
- Contact your admin
-
-
-
-
+
+
+
);
const mfaForm = (
-
+ Verify
+
+
+
+
);
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!needsMfa ? loginForm : mfaForm}
-
-
-
-
-
-
-
-
- >
- );
+ return {!needsMfa ? loginForm : mfaForm};
};
export default LoginPage;
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx
index bdd10871b..323b5d5a8 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx
@@ -1,9 +1,11 @@
import { type FormEvent, useState } from "react";
-import { Eye, EyeOff } from "lucide-react";
+import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
+import { AlertCircle } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
-import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
+import AuthShell from "@/components/auth/AuthShell";
+import { extractApiError } from "@/utils/result";
const EDR_LOGO = "/assets/edr-logo.png";
@@ -24,7 +26,6 @@ export default function LoginPage() {
const { login } = useAuth();
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
- const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
@@ -41,8 +42,8 @@ export default function LoginPage() {
} else {
setError(result.error.message);
}
- } catch {
- setError("An unexpected error occurred");
+ } catch (err) {
+ setError(extractApiError(err).message);
} finally {
setLoading(false);
}
@@ -64,60 +65,42 @@ export default function LoginPage() {
-
-
-
- setIdentifier(event.target.value)}
- placeholder="name@company.com or 09XXXXXXXX"
- disabled={loading}
- autoComplete="username"
- className={fieldClass}
- />
-
+
+ setIdentifier(event.target.value)}
+ />
-
-
-
+
+
-
- setPassword(event.target.value)}
- placeholder="Enter your password"
- disabled={loading}
- className={`${fieldClass} pr-11`}
- />
-
-
+
setPassword(event.target.value)}
+ />
{error ? (
-
+
) : null}
-
+
Don't have an account?{" "}
@@ -129,7 +112,7 @@ export default function LoginPage() {
Create an account
-
+
);
From e8e3e01f312398088f9d473395867957577be05c Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Sat, 4 Jul 2026 07:36:53 +0000
Subject: [PATCH 12/73] release order document fix
---
apps/edr-freight-api/Dockerfile | 11 ++++++++++-
.../train-scheduling/train-scheduling.service.ts | 7 +++++--
.../warehouses/warehouse-release-document.service.ts | 12 ++++++++++++
3 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile
index f9107ed23..b781fb4c0 100644
--- a/apps/edr-freight-api/Dockerfile
+++ b/apps/edr-freight-api/Dockerfile
@@ -7,6 +7,9 @@ RUN apk add --no-cache libc6-compat
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
+# Puppeteer uses the system Chromium installed in the runner stage — skip the
+# ~150MB bundled-Chromium download during pnpm install.
+ENV PUPPETEER_SKIP_DOWNLOAD=true
RUN corepack enable
WORKDIR /app
@@ -32,8 +35,14 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
FROM node:24.15.0-alpine AS runner
-RUN apk add --no-cache libc6-compat
+# Chromium + fonts for headless PDF rendering (puppeteer). Alpine ships the
+# binary at /usr/bin/chromium-browser, which the PDF renderer auto-detects
+# (also pinned via PUPPETEER_EXECUTABLE_PATH). Without this, PDF generation
+# falls back to a degraded hand-built layout.
+RUN apk add --no-cache libc6-compat \
+ chromium nss freetype harfbuzz ca-certificates ttf-freefont
ENV NODE_ENV=production
+ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nestjs
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
index 32a046356..86e37451e 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
@@ -1265,7 +1265,9 @@ export class TrainSchedulingService {
performedBy: 'DOCUMENT_GENERATION',
});
const html = this.buildImportLoadListHtml(loadList);
- const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
+ // Generic render — NOT the release-order fallback (would mislabel this as a
+ // gate-clearance / release order when Chromium is unavailable).
+ const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list');
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
return {
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
@@ -1283,7 +1285,8 @@ export class TrainSchedulingService {
}
const html = this.buildExportLoadListHtml(schedule);
- const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
+ // Generic render — NOT the release-order fallback (see importLoadListDocument).
+ const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts
index f8c0dd355..68e630e0b 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts
@@ -20,6 +20,18 @@ export class WarehouseReleaseDocumentService {
});
}
+ /**
+ * Render arbitrary document HTML to PDF via the shared renderer WITHOUT the
+ * release-order fallback. Non-release documents (e.g. the import/export
+ * marshalling load list) must use this so a Chromium-less fallback degrades to
+ * a plain-text dump of *their own* content — instead of masquerading as a
+ * "Warehouse Gate Clearance / Release Order", which the release-specific
+ * fallback would otherwise draw regardless of the input HTML.
+ */
+ renderDocumentHtml(html: string, label = 'Document'): Promise {
+ return this.pdf.htmlToPdfBuffer(html, { label });
+ }
+
private htmlToBasicPdfBuffer(html: string): Buffer {
const doc = this.extractReleaseDocument(html);
const body: string[] = [
From 145240d3bded71b8da3c36ded965f89c27e6d93d Mon Sep 17 00:00:00 2001
From: Marshal
Date: Sat, 4 Jul 2026 07:43:03 +0000
Subject: [PATCH 13/73] refactor: remove gate pass granting logic from
clearance services and UI
- Removed the gate pass granting functionality from the BookingClearanceService and ContractClearanceService, replacing it with a new method to retrieve gate pass status from train schedules.
- Updated the ContractsController to eliminate endpoints related to gate pass granting.
- Refactored the UI components (ExportClearanceStepper and PhasedClearanceActionPanel) to reflect the new gate pass securing process, linking to the train scheduling interface instead.
- Cleaned up related constants and query hooks, removing unused code and references to the gate pass functionality.
- Adjusted types in the contracts to accommodate changes in the gate pass handling logic.
---
.../contracts/booking-clearance.service.ts | 12 +-
.../contracts/contract-clearance.service.ts | 14 +-
.../modules/contracts/contracts.controller.ts | 40 --
.../contracts/dto/phased-clearance.dto.ts | 8 -
.../contracts/gl-operations.service.ts | 222 +++--------
.../contracts/ExportClearanceStepper.tsx | 102 ++---
.../contracts/PhasedClearanceActionPanel.tsx | 103 +-----
.../backoffice/src/constants/QUERY_KEYS.ts | 1 -
.../backoffice/src/constants/URLS.ts | 5 -
.../src/hooks/contracts/useContracts.ts | 9 -
.../contracts/GlDjiboutiClearanceListPage.tsx | 349 +++---------------
.../src/services/contracts.service.ts | 29 --
packages/types/src/freight/contracts.ts | 26 +-
13 files changed, 136 insertions(+), 784 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts
index 3f169c49c..61ac93925 100644
--- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts
@@ -205,7 +205,7 @@ export class BookingClearanceService {
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
const bookingMilestone = (code: string) =>
milestones.find((m) => m.milestoneCode === code);
- const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
+ const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
@@ -242,14 +242,8 @@ export class BookingClearanceService {
workflowFiles,
t1,
train,
- gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
- gatepassAt:
- gatepassMilestone?.status === 'COMPLETED'
- ? (gatepassMilestone.metadata?.gatepassAt ??
- (gatepassMilestone.triggeredAt
- ? gatepassMilestone.triggeredAt.toISOString()
- : null))
- : null,
+ gatepassGranted: gatepass.granted,
+ gatepassAt: gatepass.grantedAt,
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
t1ClosedAt:
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
index 532d26359..2c79ba42f 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
@@ -278,7 +278,9 @@ export class ContractClearanceService {
}
const bookingMilestone = (code: string) =>
bookingMilestones.find((m) => m.milestoneCode === code);
- const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
+ const gatepass = cycle?.bookingId
+ ? await this.glOperationsService.gatepassForBooking(cycle.bookingId)
+ : { granted: false, grantedAt: null };
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(
@@ -344,14 +346,8 @@ export class ContractClearanceService {
workflowFiles,
t1,
train,
- gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
- gatepassAt:
- gatepassMilestone?.status === 'COMPLETED'
- ? (gatepassMilestone.metadata?.gatepassAt ??
- (gatepassMilestone.triggeredAt
- ? gatepassMilestone.triggeredAt.toISOString()
- : null))
- : null,
+ gatepassGranted: gatepass.granted,
+ gatepassAt: gatepass.grantedAt,
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
t1ClosedAt:
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
index 06ac31d68..a22c7cad4 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
@@ -77,7 +77,6 @@ import {
} from './dto/gl-operations.dto';
import {
AdviseContractDutyDto,
- GatepassDto,
RoAmendmentDto,
} from './dto/phased-clearance.dto';
@@ -688,30 +687,6 @@ export class ContractsController {
return this.clearanceService.djQueue(filter);
}
- @Get('clearance/dj-schedules')
- @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
- @ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' })
- djClearanceSchedules() {
- return this.glOperationsService.djSchedules();
- }
-
- @Post('clearance/schedules/:scheduleId/gatepass')
- @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
- @ApiOperation({
- summary: 'GL DJ grants the gate pass for every customs booking on a train schedule',
- })
- grantScheduleGatepass(
- @Param('scheduleId', ParseUUIDPipe) scheduleId: string,
- @Body() dto: GatepassDto,
- @CurrentUser() user: AuthUserPayload,
- ) {
- return this.glOperationsService.grantScheduleGatepass(
- scheduleId,
- dto?.gatepassAt,
- resolveAuthUserId(user),
- );
- }
-
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
@Get('clearance/ops-queue')
@@ -947,21 +922,6 @@ export class ContractsController {
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
}
- @Post('bookings/:bookingId/gatepass')
- @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
- @ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' })
- grantGatepass(
- @Param('bookingId', ParseUUIDPipe) bookingId: string,
- @Body() dto: GatepassDto,
- @CurrentUser() user: AuthUserPayload,
- ) {
- return this.glOperationsService.grantGatepass(
- bookingId,
- dto?.gatepassAt,
- resolveAuthUserId(user),
- );
- }
-
@Post('bookings/:bookingId/final-invoice')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts
index 6b784073b..34a903427 100644
--- a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts
+++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts
@@ -36,11 +36,3 @@ export class RoAmendmentDto {
note?: string;
}
-export class GatepassDto {
- @ApiPropertyOptional({
- description: 'When the gate pass was granted (ISO datetime; defaults to now)',
- })
- @IsOptional()
- @IsString()
- gatepassAt?: string;
-}
diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts
index 8fed3a8ff..e639f0867 100644
--- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts
@@ -4,7 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
-import { DataSource, In, IsNull } from 'typeorm';
+import { DataSource, IsNull } from 'typeorm';
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
import { BillingService } from '../billing/billing.service';
@@ -17,7 +17,6 @@ import {
ClearanceIncident,
IncidentType,
} from './entities/clearance-incident.entity';
-import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import {
@@ -198,6 +197,7 @@ export class GlOperationsService {
}
return {
+ scheduleId: schedule?.id ?? null,
wagonAllocated,
departedAt: schedule?.actualDepartureAt
? new Date(schedule.actualDepartureAt).toISOString()
@@ -208,6 +208,41 @@ export class GlOperationsService {
};
}
+ /**
+ * Gate pass status for a booking, sourced from the train schedule's Djibouti
+ * gate-pass operation (secured via the train-scheduling "Save as Secured"
+ * action) rather than a clearance milestone. For EXPORT bookings this also
+ * backfills the arrival-chain milestones once secured, same as the retired
+ * clearance-side grant action used to.
+ */
+ async gatepassForBooking(
+ bookingId: string,
+ ): Promise<{ granted: boolean; grantedAt: string | null }> {
+ const train = await this.trainState(bookingId);
+ if (!train.scheduleId) return { granted: false, grantedAt: null };
+ const operation = await this.dataSource
+ .getRepository(ImportDjiboutiOperation)
+ .findOne({ where: { trainScheduleId: train.scheduleId } });
+ const grantedAt = operation?.gatepassGrantedAt
+ ? new Date(operation.gatepassGrantedAt).toISOString()
+ : null;
+
+ if (grantedAt) {
+ const booking = await this.getBooking(bookingId);
+ if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') {
+ const milestones = await this.milestoneService.listForBooking(bookingId);
+ const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
+ for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
+ if (byCode.get(code)?.status === 'PENDING') {
+ await this.milestoneService.completeForBooking(bookingId, code);
+ }
+ }
+ }
+ }
+
+ return { granted: Boolean(grantedAt), grantedAt };
+ }
+
/**
* T1 transit-document lifecycle state for an import shipment booking. Wagon
* allocation opens the upload window; train departure locks it; train arrival
@@ -302,8 +337,11 @@ export class GlOperationsService {
'The transport document must be uploaded before T1 can be closed.',
);
}
- if (!done('GATEPASS_GRANTED')) {
- throw new BadRequestException('Grant the gate pass before closing T1.');
+ const gatepass = await this.gatepassForBooking(bookingId);
+ if (!gatepass.granted) {
+ throw new BadRequestException(
+ 'Secure the Djibouti gate pass on the train schedule before closing T1.',
+ );
}
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
@@ -322,182 +360,6 @@ export class GlOperationsService {
'ARRIVED_AT_DJIBOUTI',
];
- /**
- * GL Djibouti grants the gate pass for a customs booking, capturing the time.
- * Export: requires the train to have arrived at Djibouti; back-fills the
- * arrival-chain milestones. Import: requires wagon allocation (pre-loading).
- */
- async grantGatepass(
- bookingId: string,
- gatepassAt?: string,
- userId?: string,
- ): Promise<{ bookingId: string; gatepassAt: string }> {
- const booking = await this.getBooking(bookingId);
- if (!booking.customsClearingEnabled) {
- throw new BadRequestException('Gate pass applies to customs bookings only.');
- }
- const tradeDirection = booking.tradeDirection ?? 'IMPORT';
- const milestones = await this.milestoneService.listForBooking(bookingId);
- const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
-
- const existing = byCode.get('GATEPASS_GRANTED');
- if (existing?.status === 'COMPLETED') {
- return {
- bookingId,
- gatepassAt:
- existing.metadata?.gatepassAt ??
- (existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''),
- };
- }
-
- const train = await this.trainState(bookingId);
- if (tradeDirection === 'EXPORT') {
- if (!train.arrivedAt) {
- throw new BadRequestException(
- 'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.',
- );
- }
- for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
- if (byCode.get(code)?.status === 'PENDING') {
- await this.milestoneService.completeForBooking(bookingId, code, userId);
- }
- }
- } else if (!train.wagonAllocated) {
- throw new BadRequestException(
- 'Wagons must be allocated before the gate pass can be granted.',
- );
- }
-
- const at = gatepassAt?.trim() || new Date().toISOString();
- await this.milestoneService.completeWithMetadataForBooking(
- bookingId,
- 'GATEPASS_GRANTED',
- { gatepassAt: at },
- userId,
- );
- return { bookingId, gatepassAt: at };
- }
-
- /** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */
- async djSchedules(): Promise {
- const schedules = await this.dataSource.getRepository(TrainSchedule).find({
- relations: {
- scheduleBookings: { booking: true },
- originStation: true,
- destinationStation: true,
- },
- order: { scheduledDepartureDate: 'DESC' },
- });
-
- const withCustoms = schedules
- .filter((s) => s.status !== 'CANCELLED')
- .map((s) => ({
- schedule: s,
- customs: (s.scheduleBookings ?? [])
- .map((sb) => sb.booking)
- .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)),
- }))
- .filter((s) => s.customs.length > 0);
-
- const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id));
- const gatepassRows = bookingIds.length
- ? await this.dataSource.getRepository(ClearanceMilestone).find({
- where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' },
- })
- : [];
- const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m]));
-
- return withCustoms.map(({ schedule, customs }) => {
- const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))];
- return {
- id: schedule.id,
- trainNumber: schedule.trainNumber ?? null,
- routeName: null,
- origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
- destination:
- schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
- status: schedule.status,
- scheduledDepartureDate: schedule.scheduledDepartureDate
- ? new Date(schedule.scheduledDepartureDate).toISOString()
- : null,
- actualDepartureAt: schedule.actualDepartureAt
- ? new Date(schedule.actualDepartureAt).toISOString()
- : null,
- actualArrivalAt: schedule.actualArrivalAt
- ? new Date(schedule.actualArrivalAt).toISOString()
- : null,
- freightType:
- freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null,
- customsBookings: customs.map((b) => {
- const m = gatepassByBooking.get(b.id);
- const granted = m?.status === 'COMPLETED';
- return {
- bookingId: b.id,
- reference: b.reference ?? b.id,
- tradeDirection: b.tradeDirection ?? 'IMPORT',
- contractId: b.contractId ?? null,
- gatepassGranted: granted,
- gatepassAt: granted
- ? (m?.metadata?.gatepassAt ??
- (m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null))
- : null,
- };
- }),
- };
- });
- }
-
- /**
- * One-click gate pass for every customs booking on a train schedule. Per-booking
- * guard failures are collected, not fatal. Import schedules also get the
- * schedule-level ImportDjiboutiOperation gate pass so loading unblocks.
- */
- async grantScheduleGatepass(
- scheduleId: string,
- gatepassAt?: string,
- userId?: string,
- ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> {
- const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
- where: { id: scheduleId },
- relations: { scheduleBookings: { booking: true } },
- });
- if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
-
- const customs = (schedule.scheduleBookings ?? [])
- .map((sb) => sb.booking)
- .filter((b): b is Booking => Boolean(b?.customsClearingEnabled));
- if (customs.length === 0) {
- throw new BadRequestException('No customs bookings ride this schedule.');
- }
-
- let granted = 0;
- const skipped: Array<{ bookingId: string; error: string }> = [];
- for (const booking of customs) {
- try {
- await this.grantGatepass(booking.id, gatepassAt, userId);
- granted += 1;
- } catch (e) {
- skipped.push({
- bookingId: booking.id,
- error: e instanceof Error ? e.message : 'Failed',
- });
- }
- }
-
- if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) {
- const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation);
- let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } });
- if (!operation) {
- operation = opRepo.create({ trainScheduleId: scheduleId });
- }
- if (!operation.gatepassGrantedAt) {
- operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date();
- await opRepo.save(operation);
- }
- }
-
- return { granted, skipped };
- }
/**
* GL Djibouti raises the post-offload final invoice (export): manual amount +
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx
index d7cd9207b..b13e38961 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx
@@ -14,7 +14,7 @@ import {
Text,
Textarea,
} from "@mantine/core";
-import { DateInput, DateTimePicker } from "@mantine/dates";
+import { DateInput } from "@mantine/dates";
import {
AlertTriangle,
CheckCircle2,
@@ -397,15 +397,10 @@ export function ExportClearanceStepper({
: }
>
-
+
void;
-}) {
- const [opened, setOpened] = useState(false);
- const [at, setAt] = useState(new Date());
- const [loading, setLoading] = useState(false);
+/**
+ * Gate pass status, read-only. Secured on the train schedule's "Save as
+ * Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
+ */
+function GatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
+ const scheduleId = clearance.train?.scheduleId ?? null;
if (clearance.gatepassGranted) {
return (
@@ -526,68 +513,21 @@ function GatepassStep({
done={false}
pendingLabel={
arrived
- ? "Train arrived — GL Djibouti can grant the gate pass."
+ ? "Train arrived — secure the gate pass on the train schedule."
: "Available once the train arrives at Djibouti."
}
doneLabel=""
/>
- {canAct && bookingId ? (
- <>
- }
- disabled={!arrived}
- onClick={() => {
- setAt(new Date());
- setOpened(true);
- }}
- >
- Grant gate pass
-
- setOpened(false)}
- title={Grant gate pass}
- radius="md"
- size="sm"
- >
-
- setAt(v ? new Date(v) : null)}
- required
- />
-
-
-
-
-
-
- >
+ {scheduleId ? (
+ }
+ >
+ Secure gate pass on train schedule
+
) : null}
);
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx
index 82ac61382..d0b7f2c87 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx
@@ -4,7 +4,6 @@ import {
Badge,
Button,
Group,
- Modal,
NumberInput,
Paper,
SegmentedControl,
@@ -15,7 +14,6 @@ import {
Text,
TextInput,
} from "@mantine/core";
-import { DateTimePicker } from "@mantine/dates";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import {
TransitPermitMultiUpload,
@@ -536,17 +534,12 @@ export function PhasedClearanceActionPanel({
:
}
>
-
+
void;
-}) {
- const [opened, setOpened] = useState(false);
- const [at, setAt] = useState(new Date());
- const [loading, setLoading] = useState(false);
+/**
+ * Gate pass status, read-only. Secured on the train schedule's "Save as
+ * Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
+ */
+function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
+ const scheduleId = clearance.train?.scheduleId ?? null;
if (clearance.gatepassGranted) {
return (
@@ -852,68 +836,21 @@ function ImportGatepassStep({
done={false}
pendingLabel={
wagonAllocated
- ? "Wagons allocated — GL Djibouti can grant the gate pass."
+ ? "Wagons allocated — secure the gate pass on the train schedule."
: "Available once wagons are allocated."
}
doneLabel=""
/>
- {canAct && bookingId ? (
- <>
- }
- disabled={!wagonAllocated}
- onClick={() => {
- setAt(new Date());
- setOpened(true);
- }}
- >
- Grant gate pass
-
- setOpened(false)}
- title={Grant gate pass}
- radius="md"
- size="sm"
- >
-
- setAt(v ? new Date(v) : null)}
- required
- />
-
-
-
-
-
-
- >
+ {scheduleId ? (
+ }
+ >
+ Secure gate pass on train schedule
+
) : null}
);
diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
index 3275ef662..bd1c51e47 100644
--- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
@@ -70,7 +70,6 @@ export const QUERY_KEYS = {
["contracts", "clearance-queue", region ?? "ET"] as const,
clearanceHistory: (region?: string) =>
["contracts", "clearance-history", region ?? "ET"] as const,
- djSchedules: ["contracts", "clearance-dj-schedules"] as const,
milestones: (id: string) => ["contracts", "milestones", id] as const,
capacity: (id: string) => ["contracts", "capacity", id] as const,
bookingMilestones: (bookingId: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index d0f09f508..c22427881 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -232,11 +232,6 @@ export const URL_CONSTANTS = {
`/contracts/bookings/${bookingId}/t1-documents`,
BOOKING_T1_CLOSE: (bookingId: string) =>
`/contracts/bookings/${bookingId}/t1-close`,
- CLEARANCE_DJ_SCHEDULES: "/contracts/clearance/dj-schedules",
- CLEARANCE_SCHEDULE_GATEPASS: (scheduleId: string) =>
- `/contracts/clearance/schedules/${scheduleId}/gatepass`,
- BOOKING_GATEPASS: (bookingId: string) =>
- `/contracts/bookings/${bookingId}/gatepass`,
BOOKING_FINAL_INVOICE: (bookingId: string) =>
`/contracts/bookings/${bookingId}/final-invoice`,
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts
index 56229415f..04a4720aa 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts
@@ -68,15 +68,6 @@ export function useDjClearanceQueue(enabled = true) {
});
}
-/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
-export function useDjClearanceSchedules(enabled = true) {
- return useQuery({
- queryKey: QUERY_KEYS.CONTRACTS.djSchedules,
- queryFn: () => contractsService.getDjClearanceSchedules(),
- enabled,
- });
-}
-
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
export function useOpsClearanceQueue(enabled = true) {
return useQuery({
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx
index 3957fb7a5..0b7456851 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx
@@ -1,326 +1,65 @@
-import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
-import {
- Badge,
- Button,
- Card,
- Group,
- Loader,
- Modal,
- Stack,
- Tabs,
- Text,
-} from "@mantine/core";
-import { DateTimePicker } from "@mantine/dates";
-import { ChevronRight, Ship, Train, Truck } from "lucide-react";
-import { DataTable, type ColumnDef } from "@edr/ui-common";
-import type { Freight } from "@edr/types";
-import toast from "react-hot-toast";
+import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
+import { ChevronRight, Ship } from "lucide-react";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
-import {
- useDjClearanceQueue,
- useDjClearanceSchedules,
-} from "@/hooks/contracts/useContracts";
-import { contractsService } from "@/services/contracts.service";
+import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate();
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
- const schedulesQuery = useDjClearanceSchedules();
const contractItems = contractQueue?.items ?? [];
- const scheduleItems = schedulesQuery.data ?? [];
-
- const [gatepassTarget, setGatepassTarget] =
- useState(null);
- const [gatepassAt, setGatepassAt] = useState(new Date());
- const [granting, setGranting] = useState(false);
-
- const columns = useMemo[]>(
- () => [
- {
- header: "Train",
- accessorKey: "trainNumber",
- cell: ({ row }) => (
-
- {row.original.trainNumber ?? "—"}
-
- ),
- },
- {
- header: "Route",
- id: "route",
- cell: ({ row }) => (
-
- {row.original.origin ?? "—"} → {row.original.destination ?? "—"}
-
- ),
- },
- {
- header: "Scheduled departure",
- id: "scheduled",
- cell: ({ row }) => (
-
- {row.original.scheduledDepartureDate
- ? new Date(row.original.scheduledDepartureDate).toLocaleDateString()
- : "—"}
-
- ),
- },
- {
- header: "Departed",
- id: "departed",
- cell: ({ row }) => (
-
- {row.original.actualDepartureAt
- ? new Date(row.original.actualDepartureAt).toLocaleString()
- : "—"}
-
- ),
- },
- {
- header: "Arrived",
- id: "arrived",
- cell: ({ row }) => (
-
- {row.original.actualArrivalAt
- ? new Date(row.original.actualArrivalAt).toLocaleString()
- : "—"}
-
- ),
- },
- {
- header: "Status",
- accessorKey: "status",
- cell: ({ row }) => (
-
- {row.original.status}
-
- ),
- },
- {
- header: "Customs bookings",
- id: "customs",
- cell: ({ row }) => {
- const bookings = row.original.customsBookings;
- const directions = [...new Set(bookings.map((b) => b.tradeDirection))];
- return (
-
-
- {bookings.length}
-
- {directions.map((d) => (
-
- {d}
-
- ))}
-
- );
- },
- },
- {
- header: "Gate pass",
- id: "gatepass",
- cell: ({ row }) => {
- const bookings = row.original.customsBookings;
- const allGranted =
- bookings.length > 0 && bookings.every((b) => b.gatepassGranted);
- const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null;
- if (allGranted) {
- return (
-
- Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""}
-
- );
- }
- return (
- }
- onClick={(e) => {
- e.stopPropagation();
- setGatepassAt(new Date());
- setGatepassTarget(row.original);
- }}
- >
- Gate pass
-
- );
- },
- },
- ],
- [],
- );
return (
-
-
- Contracts ({contractItems.length})
- }>
- Schedules ({scheduleItems.length})
-
-
-
-
- {contractsLoading ? (
-
-
-
- ) : (
-
- {contractItems.length === 0 ? (
-
- No Djibouti customs contracts yet.
-
- ) : (
- contractItems.map((c) => (
- navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
- >
-
-
-
-
- {c.reference}
-
- {c.tradeDirection} · {c.status}
-
-
-
-
-
- Contract
-
-
-
-
-
- ))
- )}
-
- )}
-
-
-
- void schedulesQuery.refetch(),
- }
- : undefined
- }
- emptyMessage="No train schedules carry customs bookings yet."
- />
-
-
-
- setGatepassTarget(null)}
- title={
-
-
-
- Gate pass — train {gatepassTarget?.trainNumber ?? ""}
+ {contractsLoading ? (
+
+
+
+ ) : (
+
+ {contractItems.length === 0 ? (
+
+ No Djibouti customs contracts yet.
-
- }
- radius="md"
- size="sm"
- >
-
-
- Grants the gate pass for all{" "}
- {gatepassTarget?.customsBookings.length ?? 0} customs booking
- {(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this
- train.
-
- setGatepassAt(v ? new Date(v) : null)}
- required
- />
-
-
- }
- onClick={async () => {
- if (!gatepassTarget) return;
- setGranting(true);
- try {
- const result = await contractsService.grantScheduleGatepass(
- gatepassTarget.id,
- (gatepassAt ?? new Date()).toISOString(),
- );
- if (result.skipped.length > 0) {
- toast.error(
- `${result.granted} granted, ${result.skipped.length} skipped: ${result.skipped[0]?.error ?? ""}`,
- );
- } else {
- toast.success(
- `Gate pass granted for ${result.granted} booking${result.granted === 1 ? "" : "s"}`,
- );
- }
- setGatepassTarget(null);
- void schedulesQuery.refetch();
- } catch (e) {
- toast.error(e instanceof Error ? e.message : "Failed");
- } finally {
- setGranting(false);
- }
- }}
- >
- Grant gate pass
-
-
+ ) : (
+ contractItems.map((c) => (
+ navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
+ >
+
+
+
+
+ {c.reference}
+
+ {c.tradeDirection} · {c.status}
+
+
+
+
+
+ Contract
+
+
+
+
+
+ ))
+ )}
-
+ )}
);
}
-
-function statusColor(status: string): string {
- switch (status) {
- case "SCHEDULED":
- return "blue";
- case "DISPATCHED":
- return "yellow";
- case "ARRIVED":
- return "edr-green";
- default:
- return "gray";
- }
-}
diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
index 8860df62a..7ad051ce7 100644
--- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
@@ -391,35 +391,6 @@ export const contractsService = {
return unwrap(response.data) as Freight.ClearanceT1State;
},
- /** Train schedules carrying customs bookings — GL DJ gate-pass table. */
- getDjClearanceSchedules: async (): Promise => {
- const response = await client.get(C.CLEARANCE_DJ_SCHEDULES);
- return unwrap(response.data) as Freight.DjClearanceSchedule[];
- },
-
- /** Gate pass for every customs booking on a train schedule (captures time). */
- grantScheduleGatepass: async (
- scheduleId: string,
- gatepassAt?: string,
- ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> => {
- const response = await client.post(C.CLEARANCE_SCHEDULE_GATEPASS(scheduleId), {
- gatepassAt,
- });
- return unwrap(response.data) as {
- granted: number;
- skipped: Array<{ bookingId: string; error: string }>;
- };
- },
-
- /** Gate pass for a single customs booking (captures time). */
- grantGatepass: async (
- bookingId: string,
- gatepassAt?: string,
- ): Promise<{ bookingId: string; gatepassAt: string }> => {
- const response = await client.post(C.BOOKING_GATEPASS(bookingId), { gatepassAt });
- return unwrap(response.data) as { bookingId: string; gatepassAt: string };
- },
-
/** GL DJ raises the post-offload final invoice (amount + invoice document). */
sendFinalInvoice: async (
bookingId: string,
diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts
index fe653573d..829ae3217 100644
--- a/packages/types/src/freight/contracts.ts
+++ b/packages/types/src/freight/contracts.ts
@@ -265,6 +265,7 @@ export interface ClearanceT1State {
/** Train link state for the booking tied to a customs clearance flow. */
export interface ClearanceTrainState {
+ scheduleId: string | null;
wagonAllocated: boolean;
departedAt: string | null;
arrivedAt: string | null;
@@ -305,31 +306,6 @@ export interface ClearanceSecondDuty {
paid: boolean;
}
-/** A customs booking riding a train schedule, as shown on the GL DJ schedules tab. */
-export interface DjClearanceScheduleBooking {
- bookingId: string;
- reference: string;
- tradeDirection: string;
- contractId: string | null;
- gatepassGranted: boolean;
- gatepassAt: string | null;
-}
-
-/** Train schedule row for the GL Djibouti gate-pass table. */
-export interface DjClearanceSchedule {
- id: string;
- trainNumber: string | null;
- routeName: string | null;
- origin: string | null;
- destination: string | null;
- status: string;
- scheduledDepartureDate: string | null;
- actualDepartureAt: string | null;
- actualArrivalAt: string | null;
- freightType: string | null;
- customsBookings: DjClearanceScheduleBooking[];
-}
-
export interface ContractClearanceView {
contractId: string;
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
From 8916182a6248018d7058e76c6223f15db3517946 Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Sat, 4 Jul 2026 07:56:23 +0000
Subject: [PATCH 14/73] Truck assign containers
---
.../bookings/booking-transition.service.ts | 11 +++++++
.../CustomerTruckAssignmentCard.tsx | 33 +++++++++++++++----
packages/types/src/freight/index.ts | 4 +++
3 files changed, 41 insertions(+), 7 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
index 35fc7fd54..06edbf04e 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -1086,6 +1086,9 @@ export class BookingTransitionService {
offeredAmount: number;
paymentDeadline: Date;
} | null;
+ /** Flat list of physical container numbers on this booking (for the
+ * customer truck-assignment container picker). */
+ containerNumbers: string[];
}
> {
// This enrichment runs AFTER the transition has committed. A failure here
@@ -1141,12 +1144,20 @@ export class BookingTransitionService {
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
+ // Physical container numbers entered at booking time (booking_container
+ // units), flattened for the customer truck-assignment container picker.
+ const containerNumbers = (booking.bookingContainers ?? [])
+ .flatMap((bc) => bc.units ?? [])
+ .map((unit) => unit.containerNumber)
+ .filter((n): n is string => Boolean(n));
+
return {
...booking,
latestChangeRequestNote: note?.note ?? null,
contractSummary: summary,
nextStep,
activeBatchOffer,
+ containerNumbers,
};
}
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx
index 37e65ab2b..c6ec6bf5d 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx
@@ -38,6 +38,11 @@ export function CustomerTruckAssignmentCard({
);
const [error, setError] = useState(null);
+ // Physical container numbers on this booking — the customer picks which one to
+ // load onto the truck instead of typing it. Falls back to free entry when the
+ // booking has no container numbers recorded.
+ const containerOptions = booking.containerNumbers ?? [];
+
const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions());
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
@@ -120,13 +125,27 @@ export function CustomerTruckAssignmentCard({
onChange={(value) => setTruckType(value ?? "")}
disabled={assigned}
/>
- setContainerNumberToLoad(e.currentTarget.value.toUpperCase())}
- readOnly={assigned}
- />
+ {containerOptions.length > 0 ? (
+