diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index 0c6b1c727..79e350cc7 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -18,6 +18,7 @@
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
+ "seed:warehouse-layout": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-layout.ts",
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
"seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts",
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
diff --git a/apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts b/apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts
new file mode 100644
index 000000000..dbb68fe61
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts
@@ -0,0 +1,28 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Why a train schedule was cancelled, captured at cancel time. Staff pick a
+ * reason in the cancel dialog and every view of the cancelled schedule reads it
+ * back — a cancelled train on the board used to say nothing about why it died.
+ */
+export class ScheduleCancellationReason3780000000000 implements MigrationInterface {
+ name = 'ScheduleCancellationReason3780000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE "freight"."train_schedules"
+ ADD COLUMN IF NOT EXISTS "cancellation_reason" varchar(500),
+ ADD COLUMN IF NOT EXISTS "cancelled_at" timestamptz,
+ ADD COLUMN IF NOT EXISTS "cancelled_by_user_id" uuid
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE "freight"."train_schedules"
+ DROP COLUMN IF EXISTS "cancellation_reason",
+ DROP COLUMN IF EXISTS "cancelled_at",
+ DROP COLUMN IF EXISTS "cancelled_by_user_id"
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3790000000000-EmptyContainerReturnCompanyName.ts b/apps/edr-freight-api/src/migrations/3790000000000-EmptyContainerReturnCompanyName.ts
new file mode 100644
index 000000000..b0088da66
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3790000000000-EmptyContainerReturnCompanyName.ts
@@ -0,0 +1,24 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Empties backfilled into the yard belong to a company that may not be a
+ * registered customer yet, so `customer_id` cannot hold it. `company_name` is
+ * the typed fallback, and the display label when the customer IS registered.
+ */
+export class EmptyContainerReturnCompanyName3790000000000 implements MigrationInterface {
+ name = 'EmptyContainerReturnCompanyName3790000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.empty_container_returns
+ ADD COLUMN IF NOT EXISTS company_name varchar(200)
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.empty_container_returns
+ DROP COLUMN IF EXISTS company_name
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts b/apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts
new file mode 100644
index 000000000..ba4ba7cdb
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts
@@ -0,0 +1,34 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Backlog registration of full containers that were already sitting in a yard
+ * before the system knew about them. Such a row carries a true, backdated
+ * `arrived_at` for the record but accrues NO storage or demurrage — the
+ * operator decided these are not billable retroactively — so the flag exists
+ * to keep the fee engine off them.
+ *
+ * `company_id` / `company_name` carry the owner, since a backlog row has no
+ * booking to inherit one from. The name is free text for a company that is not
+ * a registered customer yet.
+ */
+export class WarehouseInventoryBacklogRegistration3800000000000 implements MigrationInterface {
+ name = 'WarehouseInventoryBacklogRegistration3800000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.warehouse_inventory
+ ADD COLUMN IF NOT EXISTS backlog_registration boolean NOT NULL DEFAULT false,
+ ADD COLUMN IF NOT EXISTS company_id uuid,
+ ADD COLUMN IF NOT EXISTS company_name varchar(200)
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.warehouse_inventory
+ DROP COLUMN IF EXISTS backlog_registration,
+ DROP COLUMN IF EXISTS company_id,
+ DROP COLUMN IF EXISTS company_name
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts b/apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts
new file mode 100644
index 000000000..fa2bcf7ec
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts
@@ -0,0 +1,55 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Seed `edr_freight_app:warehouse_zones:delete` — the zone counterpart of the
+ * warehouse and yard delete permissions, which already exist.
+ *
+ * `ROLE_PERMISSION_PRESETS` spreads `Object.values(FREIGHT_PERMS.warehouseZones)`
+ * into the warehouse positions, so the moment the key is added to the registry
+ * `FreightPositionsSeeder.loadPermissionIds` resolves it against `iam.permissions`
+ * at boot — and throws `missing_permissions:` if the row is absent. The
+ * catalog is otherwise written by `EdrOrgSeeder`, which skips itself unless
+ * `SEED_EDR_ORG` is set, so a migration is the only path that runs everywhere.
+ *
+ * Idempotent on `key`; keeps the registry's fixed uuid so every environment
+ * lands on the same id. Skips silently when the freight application row is
+ * absent, since there is nothing to attach to.
+ */
+export class WarehouseZoneDeletePermission3810000000000 implements MigrationInterface {
+ private static readonly KEY = 'edr_freight_app:warehouse_zones:delete';
+ private static readonly ID = 'f1c00001-0001-4000-8000-000000000004';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `INSERT INTO iam.permissions (id, key, name, application_id)
+ SELECT $2::uuid,
+ $1::varchar,
+ '{"am": "Delete warehouse zone", "en": "Delete warehouse zone"}'::jsonb,
+ a.id
+ FROM iam.application a
+ WHERE a.key = 'edr_freight_app'
+ AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
+ [WarehouseZoneDeletePermission3810000000000.KEY, WarehouseZoneDeletePermission3810000000000.ID],
+ );
+ }
+
+ /**
+ * Grants go first, or the delete trips the position/role permission foreign
+ * keys — a half-removed permission is worse than one left in place.
+ */
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `DELETE FROM iam.position_permissions
+ WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
+ [WarehouseZoneDeletePermission3810000000000.KEY],
+ );
+ await queryRunner.query(
+ `DELETE FROM iam.role_permissions
+ WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
+ [WarehouseZoneDeletePermission3810000000000.KEY],
+ );
+ await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
+ WarehouseZoneDeletePermission3810000000000.KEY,
+ ]);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3820000000000-WarehouseFreightType.ts b/apps/edr-freight-api/src/migrations/3820000000000-WarehouseFreightType.ts
new file mode 100644
index 000000000..563128411
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3820000000000-WarehouseFreightType.ts
@@ -0,0 +1,21 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * `freight.warehouses.freight_type` — CONTAINER or BULK, or null for a site
+ * that takes both.
+ *
+ * Nullable with no backfill on purpose: every existing warehouse predates the
+ * field and is unrestricted today, so writing a value would narrow live
+ * allocation behind the operator's back.
+ */
+export class WarehouseFreightType3820000000000 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE freight.warehouses ADD COLUMN IF NOT EXISTS freight_type varchar(16)`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE freight.warehouses DROP COLUMN IF EXISTS freight_type`);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts b/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts
new file mode 100644
index 000000000..38341a1f1
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts
@@ -0,0 +1,128 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Physical container positions below the zone: a stack is the ground footprint,
+ * a slot is one level in it. Adds `stack_id` / `slot_id` to warehouse inventory.
+ *
+ * Everything is additive and nullable. Existing inventory keeps warehouse /
+ * yard / zone as its only location and stays valid — nothing is backfilled,
+ * because no one can know where a box already in the yard is actually stacked.
+ *
+ * Occupancy is not stored on the slot. `uq_warehouse_inventory_active_slot`
+ * makes the inventory row the single source of truth: one live placement per
+ * slot, enforced by Postgres. Its status list must stay in step with
+ * `SLOT_OCCUPYING_STATUSES` in warehouse-inventory.entity.ts.
+ */
+export class WarehouseZoneStacksSlots3830000000000 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.warehouse_zone_stacks (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ zone_id uuid NOT NULL REFERENCES freight.warehouse_zones(id) ON DELETE CASCADE,
+ code varchar(40) NOT NULL,
+ name varchar(160),
+ "row" varchar(20),
+ bay varchar(20),
+ "position" varchar(20),
+ max_stack_height int NOT NULL DEFAULT 3,
+ status varchar(16) NOT NULL DEFAULT 'ACTIVE',
+ is_active boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz,
+ CONSTRAINT chk_warehouse_zone_stacks_height CHECK (max_stack_height >= 1)
+ )
+ `);
+
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_zone ON freight.warehouse_zone_stacks (zone_id)`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_status ON freight.warehouse_zone_stacks (status)`,
+ );
+ // Partial: a soft-deleted stack must not block reusing its code.
+ await queryRunner.query(
+ `CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_stacks_zone_code
+ ON freight.warehouse_zone_stacks (zone_id, code) WHERE deleted_at IS NULL`,
+ );
+
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.warehouse_zone_slots (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ stack_id uuid NOT NULL REFERENCES freight.warehouse_zone_stacks(id) ON DELETE CASCADE,
+ level int NOT NULL,
+ status varchar(16) NOT NULL DEFAULT 'AVAILABLE',
+ is_active boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz,
+ CONSTRAINT chk_warehouse_zone_slots_level CHECK (level >= 1)
+ )
+ `);
+
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS idx_warehouse_zone_slots_stack ON freight.warehouse_zone_slots (stack_id, level)`,
+ );
+ await queryRunner.query(
+ `CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_slots_stack_level
+ ON freight.warehouse_zone_slots (stack_id, level) WHERE deleted_at IS NULL`,
+ );
+
+ await queryRunner.query(
+ `ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS stack_id uuid`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS slot_id uuid`,
+ );
+
+ // Named FKs added defensively — ADD CONSTRAINT has no IF NOT EXISTS.
+ await queryRunner.query(`
+ DO $$ BEGIN
+ ALTER TABLE freight.warehouse_inventory
+ ADD CONSTRAINT fk_warehouse_inventory_stack
+ FOREIGN KEY (stack_id) REFERENCES freight.warehouse_zone_stacks(id);
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$
+ `);
+ await queryRunner.query(`
+ DO $$ BEGIN
+ ALTER TABLE freight.warehouse_inventory
+ ADD CONSTRAINT fk_warehouse_inventory_slot
+ FOREIGN KEY (slot_id) REFERENCES freight.warehouse_zone_slots(id);
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$
+ `);
+
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_stack ON freight.warehouse_inventory (stack_id)`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_slot ON freight.warehouse_inventory (slot_id)`,
+ );
+
+ // One live container per slot. Statuses past the yard gate (LOADED,
+ // DISPATCHED, DELIVERED, UNLOADED_AT_DJIBOUTI_PORT) free the position
+ // without any exit path having to clear the column.
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_inventory_active_slot
+ ON freight.warehouse_inventory (slot_id)
+ WHERE deleted_at IS NULL
+ AND slot_id IS NOT NULL
+ AND status IN ('UNLOADED','RECEIVED','STORED','RESERVED','READY_FOR_LOADING','READY_FOR_PICKUP')
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_warehouse_inventory_active_slot`);
+ await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_slot`);
+ await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_stack`);
+ await queryRunner.query(
+ `ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_slot`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_stack`,
+ );
+ await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS slot_id`);
+ await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS stack_id`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_slots`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_stacks`);
+ }
+}
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 cf41998ba..1dd4c5656 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts
@@ -116,6 +116,8 @@ export interface InvoiceListFilters {
status?: Freight.InvoiceStatus;
statuses?: Freight.InvoiceStatus[];
sources?: string[];
+ /** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */
+ types?: string[];
eimsStatuses?: string[];
/** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */
paymentMethods?: string[];
@@ -306,6 +308,9 @@ export class BillingService {
sources: filter.sources,
});
}
+ if (filter.types?.length) {
+ qb.andWhere("invoice.type IN (:...types)", { types: filter.types });
+ }
if (filter.eimsStatuses?.length) {
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
eimsStatuses: filter.eimsStatuses,
diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts
index 55e6b19d2..45c7acb51 100644
--- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts
+++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts
@@ -22,6 +22,7 @@ describe("FilterInvoiceDto", () => {
search: "INV-2026",
statuses: "PENDING,OVERDUE",
sources: "booking,warehouse",
+ types: "PREPAID,WAGON_CANCEL_FEE",
eimsStatuses: "NOT_SUBMITTED",
currency: "etb",
issuedFrom: "2026-08-01T00:00:00.000Z",
@@ -39,6 +40,7 @@ describe("FilterInvoiceDto", () => {
expect(errors).toEqual([]);
expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]);
expect(dto.sources).toEqual(["booking", "warehouse"]);
+ expect(dto.types).toEqual(["PREPAID", "WAGON_CANCEL_FEE"]);
expect(dto.currency).toBe("ETB");
expect(dto.minAmount).toBe(100);
expect(dto.hasBalance).toBe(true);
diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts
index a98ad06c1..fa00fb521 100644
--- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts
+++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts
@@ -89,6 +89,18 @@ export class FilterInvoiceDto {
@IsIn(Object.values(Freight.InvoiceSource), { each: true })
sources?: Freight.InvoiceSource[];
+ /**
+ * What the invoice bills for (`?types=PREPAID,WAGON_CANCEL_FEE`). Free-form
+ * like `paymentMethods`: every billing source mints its own `type` string, so
+ * an `IsIn` here would silently drop a real value.
+ */
+ @ApiPropertyOptional({ isArray: true, example: ["PREPAID"] })
+ @IsOptional()
+ @Transform(csv)
+ @IsArray()
+ @IsString({ each: true })
+ types?: string[];
+
/** MoR filing state — Finance's "what still needs registering" cut. */
@ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus })
@IsOptional()
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts
new file mode 100644
index 000000000..0668b9eba
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts
@@ -0,0 +1,146 @@
+import {
+ CARGO_TYPE_SUBTREE_SQL,
+ bookingContainerCountSql,
+ bookingContainerVgmSql,
+ bookingContentMatchSql,
+ bookingContentSql,
+ bookingHasContainerTypeSql,
+ bookingRequestedCargoSql,
+ bookingRequestedContainerCountSql,
+} from './booking-content.sql';
+
+describe('bookingContentSql', () => {
+ const sql = bookingContentSql('b');
+
+ it('prefers the container lines, since container bookings carry no description', () => {
+ expect(sql.indexOf('freight.booking_container')).toBeLessThan(
+ sql.indexOf('freight.cargo_types'),
+ );
+ expect(sql).toContain('freight.container_types');
+ expect(sql).toContain('bc.deleted_at IS NULL');
+ });
+
+ it('falls back to commodity, then to the free-text description', () => {
+ expect(sql.indexOf('cgt.cargo_type_name')).toBeLessThan(
+ sql.indexOf('b.cargo_free_text'),
+ );
+ });
+
+ // An empty string is not a missing value to COALESCE — without NULLIF a blank
+ // description would win over the commodity behind it.
+ it('treats an empty string as absent at every level', () => {
+ expect(sql.match(/NULLIF/g)).toHaveLength(3);
+ });
+
+ it('rewrites every reference when embedded under another alias', () => {
+ expect(bookingContentSql('bk')).not.toMatch(/\bb\.(cargo|id)/);
+ });
+});
+
+describe('CARGO_TYPE_SUBTREE_SQL', () => {
+ // The filter offers groups, not just leaves, so picking "Bulk" has to reach
+ // commodities at any depth beneath it — two levels today, more tomorrow.
+ it('walks the tree recursively rather than one level of children', () => {
+ expect(CARGO_TYPE_SUBTREE_SQL).toContain('WITH RECURSIVE');
+ expect(CARGO_TYPE_SUBTREE_SQL).toContain('c.parent_group_id = sub.id');
+ });
+
+ it('includes the picked node itself, so a leaf still matches exactly', () => {
+ expect(CARGO_TYPE_SUBTREE_SQL).toContain('WHERE id = :cargoTypeId');
+ });
+});
+
+describe('bookingContentMatchSql', () => {
+ const sql = bookingContentMatchSql('b');
+
+ it('searches all three places content can live', () => {
+ expect(sql).toContain('b.cargo_free_text ILIKE :cargoText');
+ expect(sql).toContain('cgt.cargo_type_name ILIKE :cargoText');
+ expect(sql).toContain('cnt.code ILIKE :cargoText');
+ });
+
+ // Anything but OR would make the text box match nothing for whole freight
+ // types — a container booking has no commodity, a bulk one has no container.
+ it('ORs them, and stays one parenthesised term for andWhere', () => {
+ expect(sql).not.toContain(' AND :cargoText');
+ expect(sql.startsWith('(')).toBe(true);
+ expect(sql.trimEnd().endsWith(')')).toBe(true);
+ });
+});
+
+describe('bookingContainerCountSql', () => {
+ // booking_container is one row per LINE carrying a quantity, so counting rows
+ // would report a 54-container booking as 1.
+ it('sums the line quantities rather than counting lines', () => {
+ expect(bookingContainerCountSql('b')).toContain('SUM(bc.quantity)');
+ expect(bookingContainerCountSql('b')).not.toContain('COUNT(');
+ });
+
+ it('counts every type by default and one type when scoped', () => {
+ expect(bookingContainerCountSql('b')).not.toContain('container_type_id');
+ expect(bookingContainerCountSql('b', true)).toContain(
+ 'bc.container_type_id = :containerTypeId',
+ );
+ });
+
+ it('is 0, never NULL, so a bound comparison still decides', () => {
+ expect(bookingContainerCountSql('b')).toContain('COALESCE(SUM(bc.quantity), 0)');
+ });
+
+ it('ignores soft-deleted lines', () => {
+ expect(bookingContainerCountSql('b')).toContain('bc.deleted_at IS NULL');
+ expect(bookingHasContainerTypeSql('b')).toContain('bc.deleted_at IS NULL');
+ });
+
+ it('rewrites the booking reference under another alias', () => {
+ expect(bookingContainerCountSql('bk')).toContain('bc.booking_id = bk.id');
+ expect(bookingHasContainerTypeSql('bk')).toContain('bc.booking_id = bk.id');
+ });
+});
+
+describe('bookingContainerVgmSql', () => {
+ // The whole point: b.cargo_total_weight_vgm is 0 for portal container
+ // bookings, so the weight has to come off the lines.
+ it('reads the lines, never the booking-level column', () => {
+ const sql = bookingContainerVgmSql('b');
+ expect(sql).toContain('SUM(bc.total_vgm_tons)');
+ expect(sql).not.toContain('cargo_total_weight_vgm');
+ expect(sql).toContain('bc.deleted_at IS NULL');
+ });
+});
+
+describe('requested (shipment-request) cargo', () => {
+ const cargo = bookingRequestedCargoSql('b');
+ const count = bookingRequestedContainerCountSql('b');
+
+ it('reads the request, never the booking or its container lines', () => {
+ for (const sql of [cargo, count]) {
+ expect(sql).toContain('freight.booking_requests br');
+ expect(sql).toContain('br.created_booking_id = b.id');
+ expect(sql).not.toContain('freight.booking_container');
+ }
+ });
+
+ // requested_lines is a free-form jsonb column; jsonb_array_elements throws on
+ // a non-array, which would 500 the whole list for one malformed row.
+ it('survives a requested_lines with no container array', () => {
+ for (const sql of [cargo, count]) {
+ expect(sql).toContain("jsonb_typeof(br.requested_lines->'containers') = 'array'");
+ expect(sql).toContain("ELSE '[]'::jsonb");
+ }
+ });
+
+ it('renders the bulk shape too, not only containers', () => {
+ expect(cargo).toContain("'bulk'->>'cargoWeightTons'");
+ expect(cargo).toContain("'bulk'->>'itemCount'");
+ });
+
+ it('counts 0 rather than NULL when no request exists', () => {
+ expect(count).toContain("COALESCE(SUM((l->>'quantity')::int), 0)");
+ });
+
+ it('ignores soft-deleted requests', () => {
+ expect(cargo).toContain('br.deleted_at IS NULL');
+ expect(count).toContain('br.deleted_at IS NULL');
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts
new file mode 100644
index 000000000..744a6bbd8
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts
@@ -0,0 +1,148 @@
+/**
+ * What the customer said is IN the booking, per freight type — the list
+ * filter, the summary and the export all read this one expression so the
+ * column, the pill and the sheet can never disagree.
+ *
+ * BULK the commodity picked from the cargo tree (`cargo_types`), falling
+ * back to the free-text description for a bare group or a legacy row
+ * that has no commodity.
+ * CONTAINER the wizard asks for no description at all — VGM and contents are
+ * captured later in operations — so the closest thing to the
+ * customer's own words is the container lines they entered:
+ * "2 × 40FT, 1 × 20FT".
+ *
+ * Containers are checked FIRST: a container booking has no `cargo_type_id`
+ * (the API rejects one), so the order only matters for a mixed legacy row,
+ * where the physical lines are the better answer.
+ */
+export function bookingContentSql(alias = 'b'): string {
+ return `COALESCE(
+ NULLIF((SELECT string_agg(bc.quantity || ' × ' || COALESCE(cnt.label, cnt.code), ', '
+ ORDER BY cnt.size_ft DESC NULLS LAST, cnt.code)
+ FROM freight.booking_container bc
+ JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
+ WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), ''),
+ NULLIF((SELECT cgt.cargo_type_name FROM freight.cargo_types cgt
+ WHERE cgt.id = ${alias}.cargo_type_id), ''),
+ NULLIF(${alias}.cargo_free_text, ''))`;
+}
+
+/**
+ * Cargo types at or under `:cargoTypeId`, so picking a GROUP in the filter
+ * matches every commodity beneath it — the same group→commodity drill-down the
+ * booking wizard offers, read back. Recursive because `cargo_types` is an
+ * arbitrary-depth tree (Bulk → Steel Billet → S1 → …), not two levels.
+ */
+export const CARGO_TYPE_SUBTREE_SQL = `(
+ WITH RECURSIVE sub AS (
+ SELECT id FROM freight.cargo_types WHERE id = :cargoTypeId
+ UNION ALL
+ SELECT c.id FROM freight.cargo_types c JOIN sub ON c.parent_group_id = sub.id
+ )
+ SELECT id FROM sub)`;
+
+/**
+ * Contains-match over every part of the content a customer can type or pick:
+ * their own description, the commodity's name, and the container types on the
+ * booking. Bind `:cargoText` already wrapped in `%`.
+ */
+export function bookingContentMatchSql(alias = 'b'): string {
+ return `(${alias}.cargo_free_text ILIKE :cargoText
+ OR EXISTS (SELECT 1 FROM freight.cargo_types cgt
+ WHERE cgt.id = ${alias}.cargo_type_id
+ AND cgt.cargo_type_name ILIKE :cargoText)
+ OR EXISTS (SELECT 1 FROM freight.booking_container bc
+ JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
+ WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL
+ AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`;
+}
+
+/**
+ * Containers on a booking, as a count of physical boxes — `booking_container`
+ * is one row PER LINE with a `quantity`, not one row per box, so this sums the
+ * quantity rather than counting rows.
+ *
+ * `scopedToType` narrows the sum to `:containerTypeId`, which is what makes one
+ * number filter answer both "10 containers in total" and "10 forty-footers":
+ * the count filter reads the container-type filter when one is set, and counts
+ * every type when it is not.
+ */
+export function bookingContainerCountSql(alias = 'b', scopedToType = false): string {
+ return `(SELECT COALESCE(SUM(bc.quantity), 0)
+ FROM freight.booking_container bc
+ WHERE bc.booking_id = ${alias}.id
+ AND bc.deleted_at IS NULL${
+ scopedToType ? '\n AND bc.container_type_id = :containerTypeId' : ''
+ })`;
+}
+
+/** Bookings carrying at least one line of `:containerTypeId`. */
+export function bookingHasContainerTypeSql(alias = 'b'): string {
+ return `EXISTS (SELECT 1 FROM freight.booking_container bc
+ WHERE bc.booking_id = ${alias}.id
+ AND bc.deleted_at IS NULL
+ AND bc.container_type_id = :containerTypeId)`;
+}
+
+/**
+ * Container VGM on a booking, in tons — the sum of the per-line totals.
+ *
+ * NOT `bookings.cargo_total_weight_vgm`: the portal wizard leaves that at 0 for
+ * container freight (VGM is captured per container, later, in operations), so
+ * reading the booking-level column showed every portal container booking as
+ * weighing nothing. Same reason `bookingTonsSql` falls through to these lines.
+ */
+export function bookingContainerVgmSql(alias = 'b'): string {
+ return `(SELECT COALESCE(SUM(bc.total_vgm_tons), 0)
+ FROM freight.booking_container bc
+ WHERE bc.booking_id = ${alias}.id
+ AND bc.deleted_at IS NULL)`;
+}
+
+/**
+ * Cargo the customer declared on the SHIPMENT REQUEST behind a booking, which
+ * is not the same fact as cargo on the booking itself.
+ *
+ * On a GENERAL + customs contract the customer cannot book directly: they
+ * submit a request (day + quantities), and `initiateForShipmentRequest` opens a
+ * BARE instance from it — "the request itself carries the quantities; the
+ * instance carries none". So between initiation and `completeUnderContract` the
+ * booking legitimately holds no cargo while the customer's declared quantities
+ * sit on `booking_requests.requested_lines`.
+ *
+ * Kept in its own column rather than folded into the real container count: a
+ * declared 2 × 20FT is a request, not two boxes on a booking, and merging the
+ * two would overstate operational totals.
+ */
+const REQUESTED_CONTAINER_LINES = `jsonb_array_elements(
+ CASE WHEN jsonb_typeof(br.requested_lines->'containers') = 'array'
+ THEN br.requested_lines->'containers'
+ ELSE '[]'::jsonb END)`;
+
+/** Human-readable declared cargo: "2 × 20FT", "12 t", "40 items". */
+export function bookingRequestedCargoSql(alias = 'b'): string {
+ return `(SELECT COALESCE(
+ (SELECT string_agg((l->>'quantity') || ' × ' || upper(l->>'containerSize'), ', '
+ ORDER BY l->>'containerSize')
+ FROM ${REQUESTED_CONTAINER_LINES} AS l),
+ NULLIF(br.requested_lines->'bulk'->>'cargoWeightTons', '') || ' t',
+ NULLIF(br.requested_lines->'bulk'->>'itemCount', '') || ' items')
+ FROM freight.booking_requests br
+ WHERE br.created_booking_id = ${alias}.id
+ AND br.deleted_at IS NULL
+ ORDER BY br.created_at DESC
+ LIMIT 1)`;
+}
+
+/**
+ * Boxes declared on the shipment request. Pairs with the real container count:
+ * `Containers = 0` AND `Requested containers >= 1` is exactly the set awaiting
+ * completion.
+ */
+export function bookingRequestedContainerCountSql(alias = 'b'): string {
+ return `(SELECT COALESCE(SUM((l->>'quantity')::int), 0)
+ FROM freight.booking_requests br
+ CROSS JOIN LATERAL ${REQUESTED_CONTAINER_LINES} AS l
+ WHERE br.created_booking_id = ${alias}.id
+ AND br.deleted_at IS NULL)`;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts
new file mode 100644
index 000000000..07067c3ed
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts
@@ -0,0 +1,30 @@
+import { bookingTonsSql } from './booking-tons.sql';
+
+describe('bookingTonsSql', () => {
+ const sql = bookingTonsSql('b');
+
+ // The regression this exists for: a plain COALESCE stops at the portal's
+ // literal 0 for container bookings and reports them as weighing nothing.
+ it('treats a stored 0 as "no figure" on both booking-level columns', () => {
+ expect(sql).toContain('NULLIF(b.bulk_total_weight_tons, 0)');
+ expect(sql).toContain('NULLIF(b.cargo_total_weight_vgm, 0)');
+ });
+
+ it('falls back to the per-line container VGM, excluding soft-deleted lines', () => {
+ expect(sql).toContain('SUM(bc.total_vgm_tons)');
+ expect(sql).toContain('freight.booking_container bc');
+ expect(sql).toContain('bc.booking_id = b.id');
+ expect(sql).toContain('bc.deleted_at IS NULL');
+ });
+
+ it('never returns NULL, so callers may SUM it directly', () => {
+ expect(sql.trimEnd().endsWith('0)')).toBe(true);
+ });
+
+ it('rewrites every reference when embedded under another alias', () => {
+ const aliased = bookingTonsSql('bk');
+ expect(aliased).not.toMatch(/\bb\./);
+ expect(aliased).toContain('bk.cargo_total_weight_vgm');
+ expect(aliased).toContain('bc.booking_id = bk.id');
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts
new file mode 100644
index 000000000..f3a8590a2
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts
@@ -0,0 +1,26 @@
+/**
+ * SQL mirror of `bookingCargoTons()` (train-scheduling/train-capacity.util.ts).
+ *
+ * Three storage conventions share `bookings.cargo_total_weight_vgm`:
+ * - BULK PER_TON — the column holds tons.
+ * - BULK PER_ITEM — the column holds an ITEM COUNT; the tons are in
+ * `bulk_total_weight_tons`.
+ * - CONTAINER — the portal wizard captures VGM per line, not per booking,
+ * and sends 0 (portal NewBookingPage: "containers carry NO weight at the
+ * wizard"). The tons live in `booking_container.total_vgm_tons`. The
+ * backoffice wizard does store a booking-level total, so both shapes exist
+ * in the same table.
+ *
+ * Hence NULLIF on both columns: a plain
+ * `COALESCE(bulk_total_weight_tons, cargo_total_weight_vgm)` stops at the
+ * portal's 0 — COALESCE falls through on NULL, never on 0 — and every
+ * portal-created container booking reads as 0 tons in exports and reports.
+ */
+export function bookingTonsSql(alias = 'b'): string {
+ return `COALESCE(
+ NULLIF(${alias}.bulk_total_weight_tons, 0),
+ NULLIF(${alias}.cargo_total_weight_vgm, 0),
+ (SELECT SUM(bc.total_vgm_tons) FROM freight.booking_container bc
+ WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL),
+ 0)`;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts
index ac6fb5aa3..86b9dedc6 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts
@@ -836,15 +836,15 @@ export class BookingWagonCancellationService {
)
.where('alloc.booking_id = :bookingId', { bookingId })
.getMany();
- const loaded = allocations.filter(
- (a) => a.status === 'LOADED' || a.status === 'DEPARTED',
- );
const remaining = allocations.filter(
(a) => a.status !== 'LOADED' && a.status !== 'DEPARTED',
);
- if (!loaded.length) {
+ // A booking whose cargo never showed up at all (0 loaded) is cancelled the
+ // same way — the gate that holds the train does not care whether loading
+ // started, only that nothing is left unresolved.
+ if (!allocations.length) {
throw new BadRequestException(
- 'Loading has not started for this booking — use the normal wagon cancellation flow.',
+ 'This booking has no wagons on this schedule — use the normal wagon cancellation flow.',
);
}
if (!remaining.length) {
@@ -1875,9 +1875,12 @@ export class BookingWagonCancellationService {
// the same cargo); number/seal/VGM come from the override when given.
units: sized.map((u, i) => ({
containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
+ // A credit snapshot taken before seals were mandatory can carry
+ // none; the booking service normalizes the blank back to null
+ // rather than blocking the rebook of already-paid cargo.
sealNumber: replacement
- ? (replacement[i]?.sealNumber ?? undefined)
- : (u.sealNumber ?? undefined),
+ ? (replacement[i]?.sealNumber ?? '')
+ : (u.sealNumber ?? ''),
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
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 885486031..5e0e3c994 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
@@ -21,6 +21,13 @@ import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-co
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
+import {
+ CARGO_TYPE_SUBTREE_SQL,
+ bookingContainerCountSql,
+ bookingContentMatchSql,
+ bookingHasContainerTypeSql,
+ bookingRequestedContainerCountSql,
+} from './booking-content.sql';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
BookingDocumentReview,
@@ -65,7 +72,17 @@ export interface BookingListFilterOptions {
contractId?: string;
contractType?: string;
serviceTypeId?: string;
+ /** Cargo type OR cargo group — a group matches every commodity beneath it. */
cargoTypeId?: string;
+ /** Contains-search over content: description, commodity name, container types. */
+ cargoText?: string;
+ /** Bookings carrying this container type; also scopes the container count. */
+ containerTypeId?: string;
+ containersMin?: number;
+ containersMax?: number;
+ /** Bounds on containers declared on the shipment request behind the booking. */
+ requestedContainersMin?: number;
+ requestedContainersMax?: number;
freightType?: string;
bookingType?: string;
tradeDirection?: string;
@@ -1176,11 +1193,55 @@ export class BookingsRepository extends BaseRepository {
serviceTypeId: options.serviceTypeId,
});
}
+ // A group is selectable in the filter, not just a leaf commodity, so this
+ // matches the whole subtree — picking "Bulk" must return every commodity
+ // under it, the same drill-down the booking wizard offers, read back.
if (options.cargoTypeId) {
- qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
+ qb.andWhere(`booking.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, {
cargoTypeId: options.cargoTypeId,
});
}
+ if (options.cargoText) {
+ qb.andWhere(bookingContentMatchSql('booking'), {
+ cargoText: `%${options.cargoText}%`,
+ });
+ }
+ if (options.containerTypeId) {
+ qb.andWhere(bookingHasContainerTypeSql('booking'), {
+ containerTypeId: options.containerTypeId,
+ });
+ }
+ // One count filter, two questions: with a container type picked it counts
+ // that type, without one it counts every box on the booking.
+ if (options.containersMin != null || options.containersMax != null) {
+ const count = bookingContainerCountSql(
+ 'booking',
+ Boolean(options.containerTypeId),
+ );
+ if (options.containersMin != null) {
+ qb.andWhere(`${count} >= :containersMin`, {
+ containersMin: options.containersMin,
+ });
+ }
+ if (options.containersMax != null) {
+ qb.andWhere(`${count} <= :containersMax`, {
+ containersMax: options.containersMax,
+ });
+ }
+ }
+ // Declared on the shipment request, not on the booking. Pairs with the
+ // count above: containers 0..0 AND requested >= 1 is the set awaiting
+ // completion after clearance.
+ if (options.requestedContainersMin != null) {
+ qb.andWhere(`${bookingRequestedContainerCountSql('booking')} >= :requestedContainersMin`, {
+ requestedContainersMin: options.requestedContainersMin,
+ });
+ }
+ if (options.requestedContainersMax != null) {
+ qb.andWhere(`${bookingRequestedContainerCountSql('booking')} <= :requestedContainersMax`, {
+ requestedContainersMax: options.requestedContainersMax,
+ });
+ }
if (omit !== 'freightType' && options.freightType) {
qb.andWhere('booking.freight_type = :freightType', {
freightType: options.freightType,
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index b6047633a..71107650f 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -1845,6 +1845,12 @@ export class BookingsService {
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
+ cargoText: filter.cargoText,
+ containerTypeId: filter.containerTypeId,
+ containersMin: filter.containersMin,
+ containersMax: filter.containersMax,
+ requestedContainersMin: filter.requestedContainersMin,
+ requestedContainersMax: filter.requestedContainersMax,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
@@ -2072,6 +2078,12 @@ export class BookingsService {
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
+ cargoText: filter.cargoText,
+ containerTypeId: filter.containerTypeId,
+ containersMin: filter.containersMin,
+ containersMax: filter.containersMax,
+ requestedContainersMin: filter.requestedContainersMin,
+ requestedContainersMax: filter.requestedContainersMax,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts
index a404c005e..a6138e77b 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts
@@ -62,11 +62,60 @@ export class FilterBookingDto {
@IsUUID()
serviceTypeId?: string;
- @ApiPropertyOptional({ format: 'uuid' })
+ @ApiPropertyOptional({
+ format: 'uuid',
+ description:
+ 'Cargo type OR cargo group — a group matches every commodity beneath it',
+ })
@IsOptional()
@IsUUID()
cargoTypeId?: string;
+ @ApiPropertyOptional({
+ description:
+ 'Contains-search over booking content: cargo description, commodity name, container types',
+ })
+ @IsOptional()
+ @Transform(({ value }) =>
+ typeof value === 'string' && value.trim() ? value.trim() : undefined,
+ )
+ cargoText?: string;
+
+ @ApiPropertyOptional({
+ format: 'uuid',
+ description:
+ 'Bookings carrying this container type. Also scopes containersMin/Max to it.',
+ })
+ @IsOptional()
+ @IsUUID()
+ containerTypeId?: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'Minimum container count — of containerTypeId when set, else of all types',
+ })
+ @IsOptional()
+ @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
+ containersMin?: number;
+
+ @ApiPropertyOptional({ description: 'Maximum container count — see containersMin' })
+ @IsOptional()
+ @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
+ containersMax?: number;
+
+ @ApiPropertyOptional({
+ description:
+ 'Minimum containers declared on the shipment request behind the booking',
+ })
+ @IsOptional()
+ @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
+ requestedContainersMin?: number;
+
+ @ApiPropertyOptional({ description: 'Maximum requested containers — see requestedContainersMin' })
+ @IsOptional()
+ @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
+ requestedContainersMax?: number;
+
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
@IsOptional()
@IsIn([...FREIGHT_TYPES])
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 f5e00f503..88a2175b9 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
@@ -2250,7 +2250,9 @@ export class ContractBookingService {
unitRepo.create({
bookingContainerId: containerRow.id,
containerNumber: unit.containerNumber,
- sealNumber: unit.sealNumber ?? null,
+ // Legacy units recovered by the remainder placement can still
+ // arrive sealless — keep those null rather than empty-string.
+ sealNumber: unit.sealNumber?.trim() || null,
vgmTons: unit.vgmTons,
isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false,
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
index 0592dafc4..b1dabfa16 100644
--- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
+++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
@@ -7,6 +7,7 @@ import {
IsEmail,
IsIn,
IsInt,
+ IsNotEmpty,
IsNumber,
IsOptional,
IsString,
@@ -32,10 +33,12 @@ export class CreateContainerUnitDto {
})
containerNumber!: string;
- @ApiPropertyOptional()
- @IsOptional()
+ @ApiProperty({ description: 'Seal number — required on every container, import and export alike.' })
@IsString()
- sealNumber?: string;
+ @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+ @IsNotEmpty({ message: 'sealNumber is required' })
+ @MaxLength(64)
+ sealNumber!: string;
@ApiProperty({ description: 'VGM in tons', minimum: 0 })
@IsNumber()
diff --git a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts
index c9f2a7d21..69ff22927 100644
--- a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts
+++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts
@@ -1,4 +1,17 @@
+import { DataSource } from 'typeorm';
+
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
+import {
+ CARGO_TYPE_SUBTREE_SQL,
+ bookingContainerCountSql,
+ bookingContentMatchSql,
+ bookingContainerVgmSql,
+ bookingContentSql,
+ bookingHasContainerTypeSql,
+ bookingRequestedCargoSql,
+ bookingRequestedContainerCountSql,
+} from '../../bookings/booking-content.sql';
+import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
@@ -10,23 +23,104 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { Train } from '../../trains/entities/train.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
-import { ExportDataset } from '../export.types';
+import { ExportFilterOption } from '../export-filter.util';
+import { ExportDataset, ExportField } from '../export.types';
/**
* Domain semantics that the retired `bookings-list` report used to share.
- * Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm`
- * holds an item COUNT, not tonnage, and `adjusted_total_amount` silently
- * overrides `total_amount`. Getting either wrong misreports money or weight.
+ * Tonnage is `bookingTonsSql` — the one resolver for the three ways a booking
+ * stores its weight. `adjusted_total_amount` silently overrides `total_amount`.
+ * Getting either wrong misreports money or weight.
*/
-const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
+const TONS = bookingTonsSql('b');
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
+/** What the customer described as the booking's contents — see the helper. */
+const CONTENT = bookingContentSql('b');
+const CONTAINER_COUNT = bookingContainerCountSql('b');
+const REQUESTED_COUNT = bookingRequestedContainerCountSql('b');
+
const STATUS_OPTIONS = [
'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED',
'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT',
'ARRIVED', 'DELIVERED', 'COMPLETED',
].map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
+/**
+ * Cargo tree flattened for a single select: groups and every commodity beneath
+ * them, each labelled by its full path ("Bulk → Wheat") the way the booking
+ * wizard shows a deep leaf. Picking a group row filters its whole subtree.
+ *
+ * Recursive because `cargo_types` is arbitrary-depth, not two levels.
+ */
+async function cargoTypeOptions(ds: DataSource): Promise {
+ return ds.query(`
+ WITH RECURSIVE t AS (
+ SELECT id, display_order, 0 AS depth,
+ ARRAY[display_order]::int[] AS ord,
+ ARRAY[cargo_type_name]::text[] AS path
+ FROM freight.cargo_types
+ WHERE parent_group_id IS NULL AND deleted_at IS NULL AND is_active
+ UNION ALL
+ SELECT c.id, c.display_order, t.depth + 1,
+ t.ord || c.display_order,
+ t.path || c.cargo_type_name
+ FROM freight.cargo_types c
+ JOIN t ON c.parent_group_id = t.id
+ WHERE c.deleted_at IS NULL AND c.is_active
+ )
+ SELECT id AS value, array_to_string(path, ' → ') AS label
+ FROM t ORDER BY ord, path
+ `) as Promise;
+}
+
+/** Container types are 2 rows that change about never. */
+async function containerTypeOptions(ds: DataSource): Promise {
+ return ds.query(`
+ SELECT id AS value, COALESCE(label, code) AS label
+ FROM freight.container_types
+ WHERE deleted_at IS NULL AND is_active
+ ORDER BY display_order, code
+ `) as Promise;
+}
+
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/**
+ * One column per container type ("20FT", "40FT", …), each the box count of
+ * that type on the booking. Resolved from `container_types` rather than
+ * hardcoded, so adding a 45ft adds its column without a deploy of this file.
+ *
+ * The type id is INTERPOLATED, not bound — `ExportField.select` is a raw SQL
+ * string with no parameter bag — so ids that are not uuids are dropped rather
+ * than spliced. They come from our own table; the guard is for the day someone
+ * changes that column's type.
+ */
+async function containerTypeFields(ds: DataSource): Promise {
+ const rows: Array<{ id: string; code: string; label: string | null }> = await ds.query(`
+ SELECT id, code, label
+ FROM freight.container_types
+ WHERE deleted_at IS NULL AND is_active
+ ORDER BY display_order, code
+ `);
+ return rows
+ .filter((r) => UUID_RE.test(r.id))
+ .map((r) => {
+ const name = r.label || r.code;
+ return {
+ key: `containers${r.code.replace(/[^A-Za-z0-9]/g, '')}`,
+ label: `${name} containers`,
+ type: 'number' as const,
+ group: 'cargo',
+ select: `(SELECT COALESCE(SUM(bc.quantity), 0)
+ FROM freight.booking_container bc
+ WHERE bc.booking_id = b.id
+ AND bc.deleted_at IS NULL
+ AND bc.container_type_id = '${r.id}')::int`,
+ };
+ });
+}
+
export const bookingsDataset: ExportDataset = {
key: 'bookings',
title: 'Bookings',
@@ -112,10 +206,21 @@ export const bookingsDataset: ExportDataset = {
{ key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' },
// ---- Cargo -----------------------------------------------------------
- { key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
+ // What the customer said is in the booking. `cargo` below is the narrower
+ // commodity-only view, kept for saved presets that already tick it.
+ { key: 'content', label: 'Content', type: 'string', group: 'cargo', default: true, select: CONTENT, sortExpr: CONTENT },
+ { key: 'cargo', label: 'Cargo (commodity)', type: 'string', group: 'cargo', requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
+ { key: 'cargoDescription', label: 'Cargo description', type: 'string', group: 'cargo', select: 'b.cargo_free_text' },
+ // Boxes, not lines: booking_container is one row per LINE with a quantity.
+ { key: 'containerCount', label: 'Containers', type: 'number', group: 'cargo', default: true, select: `${CONTAINER_COUNT}::int`, sortExpr: CONTAINER_COUNT },
+ // Declared on the shipment request, not yet on the booking — see the helper.
+ { key: 'requestedCargo', label: 'Requested cargo', type: 'string', group: 'cargo', select: bookingRequestedCargoSql('b') },
+ { key: 'requestedContainers', label: 'Requested containers', type: 'number', group: 'cargo', select: `${REQUESTED_COUNT}::int`, sortExpr: REQUESTED_COUNT },
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' },
{ key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS },
- { key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' },
+ // The per-line sum, NOT b.cargo_total_weight_vgm — the portal leaves that
+ // column at 0 for container freight, so it read 0 for every such booking.
+ { key: 'containerWeightVgm', label: 'Container VGM (t)', type: 'tons', group: 'cargo', select: `${bookingContainerVgmSql('b')}::float8`, sortExpr: bookingContainerVgmSql('b') },
{ key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' },
{ key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' },
{ key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' },
@@ -172,6 +277,8 @@ export const bookingsDataset: ExportDataset = {
{ key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' },
],
+ dynamicFields: containerTypeFields,
+
filters: [
{ key: 'created', label: 'Created', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
@@ -190,6 +297,13 @@ export const bookingsDataset: ExportDataset = {
{ value: 'PAID', label: 'Paid' },
{ value: 'FAILED', label: 'Failed' },
] },
+ { key: 'cargoTypeId', label: 'Content (cargo type)', type: 'select', optionsQuery: cargoTypeOptions },
+ { key: 'cargoText', label: 'Content contains', type: 'text' },
+ { key: 'containerTypeId', label: 'Container type', type: 'select', optionsQuery: containerTypeOptions },
+ { key: 'containersMin', label: 'Containers (min)', type: 'text' },
+ { key: 'containersMax', label: 'Containers (max)', type: 'text' },
+ { key: 'requestedContainersMin', label: 'Requested containers (min)', type: 'text' },
+ { key: 'requestedContainersMax', label: 'Requested containers (max)', type: 'text' },
{ key: 'companyId', label: 'Customer', type: 'text' },
{ key: 'search', label: 'Search reference or customer', type: 'text' },
],
@@ -210,6 +324,23 @@ export const bookingsDataset: ExportDataset = {
if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
+ // Group or leaf — a group matches its whole subtree (see CARGO_TYPE_SUBTREE_SQL).
+ if (params.cargoTypeId) qb.andWhere(`b.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: params.cargoTypeId });
+ if (params.cargoText) qb.andWhere(bookingContentMatchSql('b'), { cargoText: `%${params.cargoText as string}%` });
+ if (params.containerTypeId) qb.andWhere(bookingHasContainerTypeSql('b'), { containerTypeId: params.containerTypeId });
+ // With a container type picked the count is of THAT type, else of every box.
+ const containerCount = bookingContainerCountSql('b', Boolean(params.containerTypeId));
+ // coerceFilterParams yields null (not undefined) for an unset filter, and
+ // Number(null) is 0 — which would silently apply ">= 0" to every export.
+ const num = (v: unknown) => (v == null || v === '' ? NaN : Number(v));
+ const min = num(params.containersMin);
+ const max = num(params.containersMax);
+ if (Number.isFinite(min)) qb.andWhere(`${containerCount} >= :containersMin`, { containersMin: min });
+ if (Number.isFinite(max)) qb.andWhere(`${containerCount} <= :containersMax`, { containersMax: max });
+ const reqMin = num(params.requestedContainersMin);
+ const reqMax = num(params.requestedContainersMax);
+ if (Number.isFinite(reqMin)) qb.andWhere(`${REQUESTED_COUNT} >= :requestedContainersMin`, { requestedContainersMin: reqMin });
+ if (Number.isFinite(reqMax)) qb.andWhere(`${REQUESTED_COUNT} <= :requestedContainersMax`, { requestedContainersMax: reqMax });
if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus });
if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId });
if (params.search) {
diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts
index 56ab3b74e..d4d635f6f 100644
--- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts
+++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts
@@ -123,6 +123,7 @@ export const invoicesDataset: ExportDataset = {
// on-screen filter actually carries into the export.
{ key: 'status', label: 'Status (single)', type: 'text' },
{ key: 'sources', label: 'Source', type: 'multiselect' },
+ { key: 'types', label: 'Type', type: 'multiselect' },
{ key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' },
{ key: 'paymentMethods', label: 'Payment method', type: 'multiselect' },
{ key: 'currency', label: 'Currency', type: 'select', options: [
@@ -151,6 +152,8 @@ export const invoicesDataset: ExportDataset = {
if (params.status) qb.andWhere('i.status = :status', { status: params.status });
const sources = params.sources as string[] | null;
if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources });
+ const types = params.types as string[] | null;
+ if (types?.length) qb.andWhere('i.type IN (:...types)', { types });
const eimsStatuses = params.eimsStatuses as string[] | null;
if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses });
const paymentMethods = params.paymentMethods as string[] | null;
diff --git a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts
index bb31591da..d380470de 100644
--- a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts
+++ b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts
@@ -1,4 +1,5 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
+import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Route } from '../../routes/entities/route.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
@@ -86,7 +87,7 @@ export const trainSchedulesDataset: ExportDataset = {
},
{
key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true,
- select: `(SELECT ROUND(COALESCE(SUM(COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)), 0))::float8
+ select: `(SELECT ROUND(COALESCE(SUM(${bookingTonsSql('b')}), 0))::float8
FROM freight.bookings b
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
},
diff --git a/apps/edr-freight-api/src/modules/exports/export-filter.util.ts b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts
index c302d9d3b..40c8e6980 100644
--- a/apps/edr-freight-api/src/modules/exports/export-filter.util.ts
+++ b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts
@@ -1,5 +1,7 @@
import { DataSource } from 'typeorm';
+import type { ExportField } from './export.types';
+
const DAY_MS = 24 * 60 * 60 * 1000;
export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
@@ -64,6 +66,26 @@ export function coerceFilterParams(
*/
const optionsCache = new Map();
+/** Process-lifetime cache for `dynamicFields`, keyed by dataset. */
+const fieldsCache = new Map();
+
+/**
+ * A dataset's full field list: its static fields plus whatever `dynamicFields`
+ * resolves from the DB. Every read of `dataset.fields` goes through this, so
+ * the catalog and the download agree on which keys exist.
+ */
+export async function resolveDatasetFields(
+ dataset: { key: string; fields: ExportField[]; dynamicFields?: (ds: DataSource) => Promise },
+ ds: DataSource,
+): Promise {
+ if (!dataset.dynamicFields) return dataset.fields;
+ const cached = fieldsCache.get(dataset.key);
+ if (cached) return cached;
+ const resolved = [...dataset.fields, ...(await dataset.dynamicFields(ds))];
+ fieldsCache.set(dataset.key, resolved);
+ return resolved;
+}
+
export async function resolveFilterOptions(
filters: ExportFilterDef[],
ds: DataSource,
diff --git a/apps/edr-freight-api/src/modules/exports/export.types.ts b/apps/edr-freight-api/src/modules/exports/export.types.ts
index db12f0211..71abdad88 100644
--- a/apps/edr-freight-api/src/modules/exports/export.types.ts
+++ b/apps/edr-freight-api/src/modules/exports/export.types.ts
@@ -103,6 +103,15 @@ export interface ExportDataset {
alwaysJoin?: string[];
groups: ExportGroup[];
fields: ExportField[];
+ /**
+ * Extra fields resolved from reference data and appended to `fields` — one
+ * column per row of some small, rarely-changing table (a column per container
+ * type, say). Cached for the process, like `ExportFilterDef.optionsQuery`.
+ *
+ * The SQL these build is interpolated, not bound, so a resolver MUST validate
+ * anything it splices in; see `bookingsDataset` for the uuid guard.
+ */
+ dynamicFields?: (ds: DataSource) => Promise;
filters: ExportFilterDef[];
/** Must name a field whose `sortExpr` references only the base alias. */
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
diff --git a/apps/edr-freight-api/src/modules/exports/exports.controller.ts b/apps/edr-freight-api/src/modules/exports/exports.controller.ts
index aea35a9bc..46c6ebad2 100644
--- a/apps/edr-freight-api/src/modules/exports/exports.controller.ts
+++ b/apps/edr-freight-api/src/modules/exports/exports.controller.ts
@@ -9,7 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
-import { resolveFilterOptions } from './export-filter.util';
+import { resolveDatasetFields, resolveFilterOptions } from './export-filter.util';
import {
EXPORT_MIME,
formatRowCap,
@@ -31,13 +31,16 @@ const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP };
* Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of
* the schema — they never leave the server.
*/
-const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
+const toCatalogEntry = (
+ dataset: ExportDataset,
+ fields: ExportField[],
+): ExportCatalogEntry => ({
key: dataset.key,
title: dataset.title,
description: dataset.description,
group: dataset.group,
groups: dataset.groups,
- fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({
+ fields: fields.map(({ key, label, type, group, default: isDefault }) => ({
key,
label,
type,
@@ -72,7 +75,7 @@ export class ExportsController {
const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission));
return Promise.all(
allowed.map(async (d) => ({
- ...toCatalogEntry(d),
+ ...toCatalogEntry(d, await resolveDatasetFields(d, this.dataSource)),
filters: await resolveFilterOptions(d.filters, this.dataSource),
})),
);
@@ -102,7 +105,10 @@ export class ExportsController {
const dataset = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = resolveExportFormat(query.format);
- const fields = this.resolveFields(dataset, query.fields);
+ const fields = ExportsController.pickFields(
+ await resolveDatasetFields(dataset, this.dataSource),
+ query.fields,
+ );
const rows = await this.runner.run(dataset, fields, query, directions, {
cap: formatRowCap(format),
@@ -134,15 +140,15 @@ export class ExportsController {
* DEFAULT set, not everything — a booking export has ~70 fields and dumping
* all of them on an unparameterised call is nobody's intent.
*/
- private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] {
+ private static pickFields(all: ExportField[], raw: string | undefined): ExportField[] {
if (raw?.trim()) {
- const picked = pickByKey(dataset.fields, raw);
+ const picked = pickByKey(all, raw);
// pickByKey falls back to everything when nothing matched; for a dataset
// the safer read of "all keys unknown" is still the default set.
- if (picked.length !== dataset.fields.length) return picked;
+ if (picked.length !== all.length) return picked;
}
- const defaults = dataset.fields.filter((f) => f.default);
- return defaults.length ? defaults : dataset.fields;
+ const defaults = all.filter((f) => f.default);
+ return defaults.length ? defaults : all;
}
private resolve(key: string, user: TCurrentUser): ExportDataset {
diff --git a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts
index 9af97f1e8..92a5a611a 100644
--- a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts
+++ b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts
@@ -1,6 +1,8 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
+ ArrayMaxSize,
+ ArrayMinSize,
ArrayNotEmpty,
IsArray,
IsDateString,
@@ -9,6 +11,7 @@ import {
IsOptional,
IsString,
IsUUID,
+ MaxLength,
Min,
ValidateNested,
} from 'class-validator';
@@ -150,6 +153,14 @@ export class CreateEmptyContainerReturnDto {
@IsUUID()
customerId?: string;
+ @ApiPropertyOptional({
+ description: 'Owning company name — free text when the company is not a registered customer.',
+ })
+ @IsOptional()
+ @IsString()
+ @MaxLength(200)
+ companyName?: string;
+
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
@@ -196,6 +207,16 @@ export class CreateEmptyContainerReturnDto {
returnedBy?: 'EDR' | 'CUSTOMER';
}
+export class BulkCreateEmptyContainerReturnsDto {
+ @ApiProperty({ type: [CreateEmptyContainerReturnDto] })
+ @IsArray()
+ @ArrayMinSize(1)
+ @ArrayMaxSize(1000)
+ @ValidateNested({ each: true })
+ @Type(() => CreateEmptyContainerReturnDto)
+ returns!: CreateEmptyContainerReturnDto[];
+}
+
export class LoadEmptyContainerItemDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts
index 263dd30d2..230581dd4 100644
--- a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts
+++ b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts
@@ -27,6 +27,15 @@ export class EmptyContainerReturn extends BaseEntity {
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId?: string | null;
+ /**
+ * Owning company as text. Set when the box was backfilled for a company that
+ * is not (yet) a registered customer, so `customer_id` cannot carry it. When
+ * a registered company IS picked, both are set — the name is the label the
+ * list renders without a join.
+ */
+ @Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true })
+ companyName?: string | null;
+
@Column({ name: 'return_date', type: 'timestamptz' })
returnDate!: Date;
@@ -75,3 +84,14 @@ export class EmptyContainerReturn extends BaseEntity {
performedBy: string | null;
}>;
}
+
+/**
+ * A row of the returns list: the entity's own columns plus the booking
+ * reference and owning company joined in. Standalone returns leave
+ * `bookingId`/`bookingReference` null.
+ */
+export interface EmptyContainerReturnListItem
+ extends Omit {
+ bookingReference: string | null;
+ createdAt: Date;
+}
diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts
index ae80ea9c8..1116e9440 100644
--- a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts
+++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts
@@ -11,6 +11,7 @@ import { BookingsService } from '../bookings/bookings.service';
import {
AssignCustomsRiskDto,
CreateDjiboutiIncidentDto,
+ BulkCreateEmptyContainerReturnsDto,
CreateEmptyContainerReturnDto,
ImportOperationActionDto,
LoadEmptyContainersOnTrainDto,
@@ -125,6 +126,15 @@ export class ImportOperationsController {
return this.service.createEmptyReturn(dto);
}
+ @Post('empty-container-returns/bulk')
+ @BookingStaff(FREIGHT_PERMS.bookings.operations)
+ @ApiOperation({
+ summary: 'Bulk-record empties already in the yard but never entered in the system',
+ })
+ bulkCreateEmptyReturns(@Body() dto: BulkCreateEmptyContainerReturnsDto) {
+ return this.service.bulkCreateEmptyReturns(dto);
+ }
+
@Post('empty-container-returns/load-on-train')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts
index be94e3f32..9a84a0551 100644
--- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts
+++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts
@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { In, Repository } from 'typeorm';
+import { In, Not, Repository } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
@@ -10,6 +10,7 @@ import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
import {
+ BulkCreateEmptyContainerReturnsDto,
CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto,
ImportOperationActionDto,
@@ -24,7 +25,11 @@ import {
type DjiboutiIncidentType,
} from './entities/djibouti-incident.entity';
import { assertWagonLoad } from './empty-container-wagon.util';
-import { EmptyContainerReturn } from './entities/empty-container-return.entity';
+import {
+ EmptyContainerReturn,
+ type EmptyContainerReturnListItem,
+ type EmptyContainerReturnStatus,
+} from './entities/empty-container-return.entity';
import {
ImportCustomsFinalization,
type ImportCustomsDocumentType,
@@ -159,8 +164,43 @@ export class ImportOperationsService {
return this.getCustoms(bookingId);
}
- listEmptyReturns() {
- return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
+ /**
+ * Every empty return, booking-linked and standalone alike, in one list. The
+ * booking reference and the owning company are joined in so the table can
+ * show which booking a box came back on without a second round trip — a
+ * standalone row simply has neither, and falls back to the typed
+ * `company_name`.
+ */
+ listEmptyReturns(): Promise {
+ return this.emptyReturns.manager.query(`
+ SELECT
+ r.id,
+ r.container_number AS "containerNumber",
+ r.booking_id AS "bookingId",
+ b.reference AS "bookingReference",
+ r.customer_id AS "customerId",
+ COALESCE(r.company_name, c.name) AS "companyName",
+ r.return_date AS "returnDate",
+ r.facility,
+ r.yard,
+ r.zone,
+ r.condition,
+ r.handover_note AS "handoverNote",
+ r.status,
+ r.wagon_allocation_reference AS "wagonAllocationReference",
+ r.container_size AS "containerSize",
+ r.train_schedule_id AS "trainScheduleId",
+ r.wagon_sequence_no AS "wagonSequenceNo",
+ r.performed_by AS "performedBy",
+ r.returned_by AS "returnedBy",
+ r.status_history AS "statusHistory",
+ r.created_at AS "createdAt"
+ FROM freight.empty_container_returns r
+ LEFT JOIN freight.bookings b ON b.id = r.booking_id
+ LEFT JOIN freight.companies c ON c.id = b.company_id
+ WHERE r.deleted_at IS NULL
+ ORDER BY r.created_at DESC
+ `);
}
listEmptyReturnsForBooking(bookingId: string) {
@@ -174,6 +214,7 @@ export class ImportOperationsService {
containerNumber: dto.containerNumber,
bookingId: dto.bookingId ?? null,
customerId: dto.customerId ?? null,
+ companyName: dto.companyName ?? null,
returnDate,
containerSize: dto.containerSize ?? null,
facility: dto.facility ?? null,
@@ -199,6 +240,66 @@ export class ImportOperationsService {
return saved;
}
+ /**
+ * Bulk backfill of empties already sitting in a yard but never recorded.
+ * All-or-nothing: if any container number already has an open (not COMPLETED)
+ * return, nothing is written — re-uploading the same sheet must not duplicate
+ * boxes. No interchange notification is sent; these are historical rows, not
+ * a live handover.
+ */
+ async bulkCreateEmptyReturns(dto: BulkCreateEmptyContainerReturnsDto) {
+ const numbers = dto.returns.map((r) => r.containerNumber.trim().toUpperCase());
+
+ const seen = new Set();
+ const dupInFile = numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false)));
+ if (dupInFile.length > 0) {
+ throw new BadRequestException(
+ `Container number(s) repeated in the upload: ${[...new Set(dupInFile)].join(', ')}`,
+ );
+ }
+
+ const existing = await this.emptyReturns.find({
+ where: {
+ containerNumber: In(numbers),
+ status: Not('COMPLETED' as EmptyContainerReturnStatus),
+ },
+ select: { containerNumber: true },
+ });
+ if (existing.length > 0) {
+ throw new BadRequestException(
+ `Already recorded as returned: ${existing.map((r) => r.containerNumber).join(', ')}`,
+ );
+ }
+
+ const rows = dto.returns.map((r, i) => {
+ const returnDate = r.returnDate ? new Date(r.returnDate) : new Date();
+ return this.emptyReturns.create({
+ containerNumber: numbers[i],
+ bookingId: r.bookingId ?? null,
+ customerId: r.customerId ?? null,
+ companyName: r.companyName ?? null,
+ returnDate,
+ containerSize: r.containerSize ?? null,
+ facility: r.facility ?? null,
+ yard: r.yard ?? null,
+ zone: r.zone ?? null,
+ condition: r.condition ?? null,
+ handoverNote: r.handoverNote ?? null,
+ performedBy: r.performedBy ?? null,
+ returnedBy: r.returnedBy ?? null,
+ statusHistory: [
+ {
+ status: 'RETURNED' as const,
+ changedAt: returnDate.toISOString(),
+ performedBy: r.performedBy ?? null,
+ },
+ ],
+ });
+ });
+
+ return this.emptyReturns.save(rows);
+ }
+
/**
* Load returned empties onto an export departure. A wagon takes ONE 40ft or
* TWO 20ft — never a mix, never three. Empties already sitting on a wagon of
diff --git a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts
index 449c47181..c70fcb13f 100644
--- a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts
+++ b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts
@@ -1,6 +1,7 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { BookingStatus } from '@edr/types';
+import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Booking } from '../../bookings/entities/booking.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
@@ -9,7 +10,7 @@ import { ReportContext, ReportDefinition } from '../report.types';
// One resolver behind "Booking per status, per port/train/date/cargo/contract
// type" — the same breakdown Operation, Marketing, Global Logistics and the
// Operation Report each ask for verbatim. Embed once, reuse everywhere.
-const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
+const TONS = bookingTonsSql('b');
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({
diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts
index ee3063ee1..2df862f3c 100644
--- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts
+++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts
@@ -1,9 +1,10 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
+import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Booking } from '../../bookings/entities/booking.entity';
import { ReportContext, ReportDefinition } from '../report.types';
-const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
+const TONS = bookingTonsSql('b');
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts
index 747a14891..64598fa87 100644
--- a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts
+++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts
@@ -1,10 +1,11 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
+import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Company } from '../../companies/entities/company.entity';
import { Contract } from '../../contracts/entities/contract.entity';
import { ReportContext, ReportDefinition } from '../report.types';
-const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
+const TONS = bookingTonsSql('b');
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder {
diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts
index a1f927a77..5f24359a2 100644
--- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts
+++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts
@@ -273,6 +273,20 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'wagon_allocation_snapshot', type: 'jsonb', nullable: true })
wagonAllocationSnapshot?: WagonAllocationSnapshot | null;
+ /**
+ * Why this schedule was cancelled — required at cancel time and shown on every
+ * view of the cancelled train. NULL on live schedules and on rows cancelled
+ * before the reason was captured.
+ */
+ @Column({ name: 'cancellation_reason', type: 'varchar', length: 500, nullable: true })
+ cancellationReason?: string | null;
+
+ @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
+ cancelledAt?: Date | null;
+
+ @Column({ name: 'cancelled_by_user_id', type: 'uuid', nullable: true })
+ cancelledByUserId?: string | null;
+
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
scheduleBookings?: TrainScheduleBooking[];
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
index 9051bb816..8599a96bc 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
@@ -1379,6 +1379,8 @@ describe('BookingBatchService — built-train wagon capacity', () => {
maxWagons?: number;
routeStops?: string[];
yardCountries?: Record;
+ maxPullWeightTons?: number;
+ maxTrainLengthMeters?: number;
}) => {
const schedule = {
id: scheduleId,
@@ -1390,8 +1392,11 @@ describe('BookingBatchService — built-train wagon capacity', () => {
scheduleBookings: [],
trainSet: {
locomotive: {
- maxPullWeightTons: 1,
- maxTrainLengthMeters: 1,
+ // Roomy on purpose: these cases exercise the SLOT axis, so the pull
+ // budget must not be what closes the train. Weight-bound behaviour
+ // has its own cases below.
+ maxPullWeightTons: opts.maxPullWeightTons ?? 100000,
+ maxTrainLengthMeters: opts.maxTrainLengthMeters ?? 100000,
overageToleranceTons: 0,
overageToleranceMeters: 0,
},
@@ -1459,15 +1464,28 @@ describe('BookingBatchService — built-train wagon capacity', () => {
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});
- it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => {
+ it('is NOT full while physical wagons remain and the loco can still haul them', async () => {
const { service } = buildService({
physicalWagons: 3,
reserved: [reservedBooking('b1'), reservedBooking('b2')],
});
- // 1T pull cap would have been exhausted long ago under the old math.
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
+ it('is FULL when the locomotive cannot pull another wagon, though slots are free', async () => {
+ // The consist has a spare slot, but every wagon spends its TARE out of the
+ // same pull limit the cargo needs — so a slot-free train can still be
+ // weight-full. This is what let a 44-wagon booking plan 4065T gross onto a
+ // 3500T train while the board advertised free wagons.
+ const { service } = buildService({
+ physicalWagons: 3,
+ reserved: [reservedBooking('b1'), reservedBooking('b2')],
+ maxPullWeightTons: 1,
+ maxTrainLengthMeters: 1,
+ });
+ await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
+ });
+
it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => {
// Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real
// capacity on the edges they don't ride: a domestic corridor with cargo
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 d787f737d..bf9c0aa5f 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
@@ -658,8 +658,36 @@ export class BookingBatchService implements OnModuleInit {
}
}
- const linked =
+ let linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
+ // A link row can outlive the booking's own pointer (cleared on one path
+ // while the row survives on another). The booking then looks "linked" here,
+ // so the branch below calls tryAutoWagonAllocation(null) and the paid
+ // booking silently never gets wagons — no error, just no allocation.
+ if (linked && !booking.trainScheduleId) {
+ // The link row still names the train it belongs to — restore the pointer
+ // from it rather than dropping the link, so the booking keeps the train
+ // it was placed on and the allocation below has a schedule to run against.
+ const [link] = await this.trainScheduleBookingsRepository.findByBookingIds([
+ bookingId,
+ ]);
+ if (link?.trainScheduleId) {
+ await this.dataSource
+ .getRepository(Booking)
+ .update(bookingId, { trainScheduleId: link.trainScheduleId } as never);
+ booking.trainScheduleId = link.trainScheduleId;
+ this.logger.warn(
+ `[BATCH] ${booking.reference ?? bookingId} was linked to schedule ${link.trainScheduleId} ` +
+ `with no train_schedule_id of its own — pointer restored so it can allocate`,
+ );
+ } else {
+ await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
+ link?.trainScheduleId ?? '',
+ bookingId,
+ );
+ linked = false;
+ }
+ }
// Intercity is allocated MANUALLY: payment secures the ride, staff then
// place it on whichever same-route train suits (intercity panel). Unpin
// from the train it reserved against — that train may be the wrong one by
@@ -5376,10 +5404,13 @@ export class BookingBatchService implements OnModuleInit {
* Dire→Djibouti leaves the Addis→Dire edges untouched.
*
* Two capacity regimes, decided by the schedule's train:
- * - Built train (Train Builder consist with physical wagons): the consist IS
- * the capacity. Wagon slots = physical wagon count; weight and length are
- * NOT re-checked here — the builder and adjust-consist already enforced the
- * locomotive's pull/length limits when the consist was assembled.
+ * - Built train (Train Builder consist with physical wagons): wagon slots =
+ * physical wagon count, but the locomotive's weight/length budgets STILL
+ * apply. The builder only proves the EMPTY consist can be pulled; every
+ * wagon then spends its tare out of the same pull limit the cargo needs, so
+ * a 54-wagon consist can be slot-free and still weight-full. Treating the
+ * consist as unlimited tonnage is what let a 44-wagon booking plan 4065T
+ * gross onto a 3500T train.
* - No built train (legacy schedules): the locomotive's length-derived slot
* count plus its weight/length budgets, as before — yard staff attach the
* missing wagons manually before wagon assignment.
@@ -5392,13 +5423,16 @@ export class BookingBatchService implements OnModuleInit {
): Promise {
const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) {
+ // The consist fixes the SLOT count (never the locomotive's length-derived
+ // estimate), but weight and length stay on the locomotive's real budget —
+ // including its overage tolerance, which `fits` may spend on a whole unit.
limits = {
base: {
wagons: physicalWagons,
- weightTons: Number.POSITIVE_INFINITY,
- lengthMeters: Number.POSITIVE_INFINITY,
+ weightTons: limits.base.weightTons,
+ lengthMeters: limits.base.lengthMeters,
},
- tolerance: { weightTons: 0, lengthMeters: 0 },
+ tolerance: limits.tolerance,
};
}
// Built trains keep the leg-aware multi-edge corridor too: the wagon
@@ -5631,19 +5665,23 @@ export class BookingBatchService implements OnModuleInit {
const wagonDims = await this.loadWagonDims();
const physicalWagons = await this.builtTrainWagonCount(schedule);
let limits: TrainLimits;
+ const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
if (physicalWagons != null) {
- // The consist is the capacity; weight/length were settled at build time.
- // remainingBudget swaps in the physical wagon count per edge itself.
- limits = {
- base: {
- wagons: physicalWagons,
- weightTons: Number.POSITIVE_INFINITY,
- lengthMeters: Number.POSITIVE_INFINITY,
- },
- tolerance: { weightTons: 0, lengthMeters: 0 },
- };
+ // The consist fixes the slot count, but the locomotive's pull/length
+ // budget still binds: 54 empty slots are worthless once the tare of the
+ // wagons already loaded has spent the pull limit. Without a locomotive
+ // there is nothing to weigh against, so the slot axis is all that is left.
+ limits = locomotive
+ ? await this.capacityLimits(locomotive)
+ : {
+ base: {
+ wagons: physicalWagons,
+ weightTons: Number.POSITIVE_INFINITY,
+ lengthMeters: Number.POSITIVE_INFINITY,
+ },
+ tolerance: { weightTons: 0, lengthMeters: 0 },
+ };
} else {
- const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
// No loco, no built train: only the slot axis exists to bind against.
if (!locomotive) return (await this.remainingWagons(schedule)) <= 0;
limits = await this.capacityLimits(locomotive);
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts
index fa32b601b..4fc921ccb 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts
@@ -32,6 +32,7 @@ import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto";
import { StationWorkDto } from "../dto/station-work.dto";
import { AssignBookingsDto } from "../dto/assign-bookings.dto";
+import { CancelTrainScheduleDto } from "../dto/cancel-train-schedule.dto";
import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto";
import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto";
import { CreateContainerTrainScheduleDto } from "../dto/create-container-train-schedule.dto";
@@ -894,6 +895,28 @@ export class TrainSchedulingController {
return res.send(buffer);
}
+ @Get("schedules/:id/marshalling/stops")
+ @TrainSchedulingView()
+ @ApiOperation({ summary: "Corridor stops with a logged consist change, in order (Marshalling 2, 3, 4…)" })
+ marshallingStops(@Param("id", ParseUUIDPipe) id: string) {
+ return this.trainSchedulingService.marshallingStops(id);
+ }
+
+ @Get("schedules/:id/marshalling/document/:stopIndex")
+ @TrainSchedulingView()
+ @ApiOperation({ summary: "Download the numbered marshalling PDF for one corridor stop" })
+ async marshallingDocumentAt(
+ @Param("id", ParseUUIDPipe) id: string,
+ @Param("stopIndex", ParseIntPipe) stopIndex: number,
+ @Res() res: Response,
+ ) {
+ const { filename, buffer } = await this.trainSchedulingService.marshallingDocumentAt(id, stopIndex);
+ res.setHeader("Content-Type", "application/pdf");
+ res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
+ res.setHeader("Content-Length", buffer.length);
+ return res.send(buffer);
+ }
+
// ---- batch / booking-window staff actions ----
@Post("schedules/:id/run-batch")
@@ -1184,14 +1207,22 @@ export class TrainSchedulingController {
@Post("container/schedules/:id/cancel")
@TrainSchedulingCancel()
@ApiOperation({ summary: "Cancel container train schedule" })
- cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
- return this.trainSchedulingService.cancelTrainSchedule(id);
+ cancelTrainSchedule(
+ @Param("id", ParseUUIDPipe) id: string,
+ @Body() dto: CancelTrainScheduleDto,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ return this.trainSchedulingService.cancelTrainSchedule(id, dto, user?.id);
}
@Post('bulk/schedules/:id/cancel')
@TrainSchedulingCancel()
@ApiOperation({ summary: "Cancel bulk train schedule" })
- cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
- return this.trainSchedulingService.cancelTrainSchedule(id);
+ cancelBulkTrainSchedule(
+ @Param("id", ParseUUIDPipe) id: string,
+ @Body() dto: CancelTrainScheduleDto,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ return this.trainSchedulingService.cancelTrainSchedule(id, dto, user?.id);
}
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts
new file mode 100644
index 000000000..ef98e0887
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts
@@ -0,0 +1,145 @@
+import { BadRequestException } from '@nestjs/common';
+
+import { TrainSchedulingService } from './services/train-scheduling.service';
+
+/**
+ * Per-wagon loading dispatch gate. A booking half-loaded at the DEPARTURE yard
+ * blocks the train; a booking that boards further down the corridor
+ * (A→B→C→D carrying a B→C load) never does — its wagons are not due until its
+ * own yard, so the SQL is scoped by `b.origin_yard_id = `.
+ * The scoping lives in the query, so this checks the parameters that carry it
+ * plus the throw/pass decision on the rows it returns.
+ */
+describe('TrainSchedulingService.assertNoPartiallyLoadedBookings', () => {
+ const ORIGIN = 'yard-a';
+ const SET = 'set-1';
+
+ const makeService = (rows: Array<{ reference: string; loaded: string; total: string }>) => {
+ const calls: Array<{ sql: string; params: unknown[] }> = [];
+ const svc = Object.create(TrainSchedulingService.prototype) as {
+ dataSource: { query: (sql: string, params: unknown[]) => Promise };
+ assertNoPartiallyLoadedBookings(
+ schedule: unknown,
+ boardingYardId: string,
+ context: { action: string; yardLabel?: string },
+ ): Promise;
+ assertPassedYardsFullyLoaded(
+ schedule: unknown,
+ stations: Array<{ sequenceNo: number; yardId: string; label: string }>,
+ sequenceNo: number,
+ ): Promise;
+ };
+ svc.dataSource = {
+ query: async (sql: string, params: unknown[]) => {
+ calls.push({ sql, params });
+ return rows;
+ },
+ };
+ return { svc, calls };
+ };
+ const schedule = { trainSetId: SET, originStationId: ORIGIN };
+
+ it('scopes the scan to bookings boarding at this departure yard', async () => {
+ const { svc, calls } = makeService([]);
+ await svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' });
+ expect(calls).toHaveLength(1);
+ // The origin filter is what keeps a mid-corridor booking from holding the
+ // train — without it, one early-loaded B→C wagon blocks dispatch at A.
+ expect(calls[0].sql).toContain('b.origin_yard_id = $2');
+ expect(calls[0].params).toEqual([SET, ORIGIN]);
+ });
+
+ it('lets the train go when nothing at this yard is half-loaded', async () => {
+ const { svc } = makeService([]);
+ await expect(
+ svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('blocks a booking half-loaded at this yard, naming its progress', async () => {
+ const { svc } = makeService([{ reference: 'BK-2026-000220', loaded: '4', total: '5' }]);
+ await expect(
+ svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }),
+ ).rejects.toThrow(/BK-2026-000220 \(4\/5 wagons loaded\)/);
+ await expect(
+ svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('skips the scan entirely for a schedule with no train set', async () => {
+ const { svc, calls } = makeService([{ reference: 'X', loaded: '1', total: '2' }]);
+ await expect(
+ svc.assertNoPartiallyLoadedBookings({ trainSetId: null }, ORIGIN, {
+ action: 'dispatch',
+ }),
+ ).resolves.toBeUndefined();
+ expect(calls).toHaveLength(0);
+ });
+});
+
+/**
+ * Mid-corridor twin: logging a checkpoint at station N means the train left
+ * every earlier stop, so each of those yards is checked for its OWN
+ * half-loaded bookings. The origin is excluded (dispatch gated it) and the
+ * yard being arrived at is excluded (its loading has not happened yet).
+ */
+describe('TrainSchedulingService.assertPassedYardsFullyLoaded', () => {
+ const STATIONS = [
+ { sequenceNo: 0, yardId: 'mojo', label: 'Mojo' },
+ { sequenceNo: 1, yardId: 'adama', label: 'Adama' },
+ { sequenceNo: 2, yardId: 'dire', label: 'Dire Dawa' },
+ { sequenceNo: 3, yardId: 'djibouti', label: 'Djibouti' },
+ ];
+
+ const makeService = (rowsByYard: Record>>) => {
+ const scanned: string[] = [];
+ const svc = Object.create(TrainSchedulingService.prototype) as {
+ dataSource: { query: (sql: string, params: unknown[]) => Promise };
+ assertPassedYardsFullyLoaded(
+ schedule: unknown,
+ stations: typeof STATIONS,
+ sequenceNo: number,
+ ): Promise;
+ };
+ svc.dataSource = {
+ query: async (_sql: string, params: unknown[]) => {
+ const yardId = params[1] as string;
+ scanned.push(yardId);
+ return rowsByYard[yardId] ?? [];
+ },
+ };
+ return { svc, scanned };
+ };
+ const schedule = { trainSetId: 'set-1', originStationId: 'mojo' };
+
+ it('checks the stops already departed, never the origin or the yard being reached', async () => {
+ const { svc, scanned } = makeService({});
+ await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 3);
+ // Mojo is dispatch's job; Djibouti has not been loaded at yet.
+ expect(scanned).toEqual(['adama', 'dire']);
+ });
+
+ it('blocks the checkpoint when a passed yard left a booking half-loaded', async () => {
+ const { svc } = makeService({
+ adama: [{ reference: 'BK-200', loaded: '5', total: '8' }],
+ });
+ await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow(
+ /Adama.*BK-200 \(5\/8 wagons loaded\)/s,
+ );
+ });
+
+ it('names the resolution the operator has: load the rest, or cancel it', async () => {
+ const { svc } = makeService({
+ adama: [{ reference: 'BK-200', loaded: '5', total: '8' }],
+ });
+ await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow(
+ /customer fault: cancellation fee; EDR fault: no fee, rebookable/,
+ );
+ });
+
+ it('scans nothing at the first checkpoint after the origin', async () => {
+ const { svc, scanned } = makeService({});
+ await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 1);
+ expect(scanned).toEqual([]);
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts
new file mode 100644
index 000000000..b3e2c7678
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts
@@ -0,0 +1,14 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
+
+export class CancelTrainScheduleDto {
+ @ApiProperty({
+ description:
+ 'Why this train is being cancelled. Shown on the schedule from then on, and to the staff who have to re-place its bookings.',
+ maxLength: 500,
+ })
+ @IsString()
+ @IsNotEmpty()
+ @MaxLength(500)
+ reason!: string;
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts
index 4e2724f49..9dc75335f 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts
@@ -7,6 +7,7 @@ import {
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
+import { bookingTonsSql } from '../bookings/booking-tons.sql';
import { Booking } from '../bookings/entities/booking.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -58,7 +59,7 @@ export class IntercityService {
b.reference AS "reference",
b.status AS "status",
b.freight_type AS "freightType",
- b.cargo_total_weight_vgm AS "weightTons",
+ ${bookingTonsSql('b')} AS "weightTons",
b.loaded_at AS "loadedAt",
b.arrived_at AS "arrivedAt",
company.name AS "customer",
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts
index 85b6d0878..e95cb4fe8 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts
@@ -333,7 +333,9 @@ export class RemainderPlacementService {
return deferred.map((u) => ({
containerNumber: u.containerNumber,
- sealNumber: u.sealNumber ?? undefined,
+ // Deferred units predate the seal requirement; the booking service
+ // normalizes the blank back to null rather than rejecting the re-book.
+ sealNumber: u.sealNumber ?? '',
vgmTons: Number(u.vgmTons),
isHazardous: u.isHazardous,
isReefer: u.isReefer,
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts
index 2b8971e81..d6e778216 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts
@@ -1209,7 +1209,7 @@ describe('TrainSchedulingService', () => {
expect(html).toContain('To load en route1 containers');
});
- it('prints coupled/switched wagons logged at this stop, and omits the box when there are none', () => {
+ it('prints the consist-changes table for this stop, and omits it when there are none', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
@@ -1223,16 +1223,21 @@ describe('TrainSchedulingService', () => {
const withChanges = build(schedule, {
consistChangesAtStop: [
- { action: 'ADD', wagonNumber: 'W-1002' },
- { action: 'SWITCH', wagonNumber: 'W-0501 → W-1003' },
+ { wagonNumber: 'W-1002', event: 'Coupled', containerNumbers: 'EMPTY WAGON' },
+ { wagonNumber: 'W-1005', event: 'Coupled', containerNumbers: 'CONT-004, CONT-005' },
+ { wagonNumber: 'W-0501 → W-1003', event: 'Switched', containerNumbers: 'CONT-011' },
],
});
- expect(withChanges).toContain('Consist changed at this stop');
- expect(withChanges).toContain('Coupled: W-1002');
- expect(withChanges).toContain('Uncoupled — replaced: W-0501 → W-1003');
+ expect(withChanges).toContain('Consist Changed At This Stop');
+ expect(withChanges).toContain('
W-1002
');
+ expect(withChanges).toContain('
Coupled
');
+ expect(withChanges).toContain('
EMPTY WAGON
');
+ expect(withChanges).toContain('
CONT-004, CONT-005
');
+ expect(withChanges).toContain('
W-0501 → W-1003
');
+ expect(withChanges).toContain('
Switched
');
const withoutChanges = build(schedule, {});
- expect(withoutChanges).not.toContain('Consist changed at this stop');
+ expect(withoutChanges).not.toContain('Consist Changed At This Stop');
});
it('lists loaded empty containers by number and states they are empty', () => {
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
index 858d14c90..dbf0c7f7c 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
@@ -2949,7 +2949,9 @@ export class TrainSchedulingService {
// Per-wagon loading: a booking mid-load is neither ridable nor removable —
// every wagon must be LOADED, or the never-loaded remainder cancelled
// (at-loading cancellation), before the train departs.
- await this.assertNoPartiallyLoadedBookings(schedule);
+ await this.assertNoPartiallyLoadedBookings(schedule, schedule.originStationId, {
+ action: 'dispatch',
+ });
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
@@ -3149,13 +3151,21 @@ export class TrainSchedulingService {
* (their charge sits on the credit ledger) yet ride from accept.
*/
/**
- * Per-wagon loading dispatch gate: a booking with SOME wagons LOADED and
- * SOME still PLANNED/RESERVED must resolve before departure — load the rest
- * or cancel it (which shrinks the booking to its loaded wagons). Blocking
- * here beats silently unassigning: unassign would delete LOADED allocations
- * and strand cargo that is physically on the train.
+ * Per-wagon loading gate: a booking with SOME wagons LOADED and SOME still
+ * PLANNED/RESERVED must resolve before the train leaves the yard it boards
+ * at — load the rest, or cancel the remainder (which shrinks the booking to
+ * its loaded wagons). Blocking beats silently unassigning: unassign would
+ * delete LOADED allocations and strand cargo physically on the train.
+ *
+ * Scoped to bookings BOARDING AT `boardingYardId`, so each yard answers only
+ * for its own cargo: a mid-corridor booking (A→B→C→D carrying a B→C load) is
+ * not due at A and must never hold the train there.
*/
- private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise {
+ private async assertNoPartiallyLoadedBookings(
+ schedule: TrainSchedule,
+ boardingYardId: string,
+ context: { action: string; yardLabel?: string },
+ ): Promise {
if (!schedule.trainSetId) return;
const rows: Array<{ reference: string; loaded: string; total: string }> =
await this.dataSource.query(
@@ -3166,24 +3176,51 @@ export class TrainSchedulingService {
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
JOIN freight.bookings b ON b.id = a.booking_id
WHERE tsw.train_set_id = $1
+ AND b.origin_yard_id = $2
AND a.deleted_at IS NULL
AND tsw.deleted_at IS NULL
AND b.deleted_at IS NULL
GROUP BY b.id, b.reference
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
- [schedule.trainSetId],
+ [schedule.trainSetId, boardingYardId],
);
if (rows.length) {
const detail = rows
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
.join(', ');
+ const where = context.yardLabel ? ` at ${context.yardLabel}` : '';
throw new BadRequestException(
- `Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`,
+ `Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` +
+ `or cancel the remainder (customer fault: cancellation fee; EDR fault: no fee, ` +
+ `rebookable) first: ${detail}`,
);
}
}
+ /**
+ * Mid-corridor twin of the dispatch gate. Logging a checkpoint at station N
+ * asserts the train has left every earlier stop, so each of those yards must
+ * have no half-loaded booking of its own left behind. The origin (seq 0) is
+ * skipped — dispatch already gated it — and the final station is included:
+ * arriving there still means the train left the stop before it.
+ */
+ private async assertPassedYardsFullyLoaded(
+ schedule: TrainSchedule,
+ stations: Array<{ sequenceNo: number; yardId: string; label: string }>,
+ sequenceNo: number,
+ ): Promise {
+ const departed = stations.filter(
+ (st) => st.sequenceNo > 0 && st.sequenceNo < sequenceNo,
+ );
+ for (const st of departed) {
+ await this.assertNoPartiallyLoadedBookings(schedule, st.yardId, {
+ action: 'record this checkpoint',
+ yardLabel: st.label,
+ });
+ }
+ }
+
private async unloadedOriginBoarderIds(
scheduleId: string,
originYardId: string,
@@ -3580,7 +3617,83 @@ export class TrainSchedulingService {
return { wagons, unassignedBookings };
}
- async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
+ /**
+ * Every corridor stop where the consist actually changed for this schedule
+ * (coupled, uncoupled, or switched — any flavor), in the order the train
+ * reached them. Origin is never in this list — it's always its own doc (the
+ * plain import/export load list), so numbering here starts at 2. A stop with
+ * only a routine checkpoint and no consist change never gets a row, which is
+ * the point: "Marshalling 2, 3, 4…" tracks events, not raw stop count.
+ */
+ async marshallingStops(
+ scheduleId: string,
+ ): Promise> {
+ const rows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
+ where: { trainScheduleId: scheduleId },
+ order: { occurredAt: 'ASC' },
+ });
+ const firstSeenAt = new Map();
+ for (const row of rows) {
+ if (!row.yardId || firstSeenAt.has(row.yardId)) continue;
+ firstSeenAt.set(row.yardId, row.occurredAt);
+ }
+ const orderedYardIds = [...firstSeenAt.entries()]
+ .sort((a, b) => a[1].getTime() - b[1].getTime())
+ .map(([yardId]) => yardId);
+ const labels = await this.yardLabelsById(orderedYardIds);
+ return orderedYardIds.map((yardId, i) => ({
+ stopIndex: i + 2,
+ yardId,
+ yardLabel: labels.get(yardId) ?? yardId,
+ firstOccurredAt: firstSeenAt.get(yardId)!.toISOString(),
+ }));
+ }
+
+ /**
+ * The coupled/uncoupled/switched rows for one stop, in the locked table
+ * shape (wagon, event, containers). "EMPTY WAGON" replaces the container
+ * list rather than a blank cell — the column always exists so a loaded and
+ * an empty coupling read as the same table, not two different layouts.
+ * Cargo for ADD/REMOVE rows is read off the schedule's OWN slot allocations
+ * for that physical wagon: an ADD is a leg slot boarding already loaded (see
+ * stampSlotLegs) or an empty couple (plannedWagonCouples) with none; a
+ * REMOVE is a slot alighting with its cargo, or an empty trim. A SWITCH row
+ * carries the incoming wagon's id — the slot's cargo already rides it.
+ */
+ private consistChangesAt(
+ schedule: TrainSchedule,
+ logRows: ScheduleWagonAdjustmentLog[],
+ ): Array<{ wagonNumber: string; event: 'Coupled' | 'Uncoupled' | 'Switched'; containerNumbers: string }> {
+ const slotByPhysicalWagonId = new Map(
+ (schedule.trainSet?.wagons ?? [])
+ .filter((wagon) => wagon.physicalWagonId)
+ .map((wagon) => [wagon.physicalWagonId as string, wagon]),
+ );
+ return logRows.map((row) => {
+ const slot = slotByPhysicalWagonId.get(row.wagonId);
+ const containerNumbers = (slot?.allocations ?? [])
+ .flatMap((allocation) => allocation.containerItems ?? [])
+ .map((item) => item.containerNumber)
+ .filter(Boolean)
+ .join(', ');
+ return {
+ wagonNumber: row.wagonNumber,
+ event: row.action === 'ADD' ? 'Coupled' : row.action === 'REMOVE' ? 'Uncoupled' : 'Switched',
+ containerNumbers: containerNumbers || 'EMPTY WAGON',
+ };
+ });
+ }
+
+ /**
+ * The numbered marshalling document for one corridor stop (see
+ * marshallingStops — stopIndex 2+, origin is its own separate doc).
+ * ponytail: the wagon table always shows the CURRENT on-board state, not a
+ * point-in-time reconstruction of what stood on the train at that past
+ * stop — a full historical snapshot is a much bigger feature nobody has
+ * asked for. What's stop-specific is the consist-changes table below it,
+ * which IS scoped to that stop's own logged events.
+ */
+ async marshallingDocumentAt(scheduleId: string, stopIndex: number): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -3590,24 +3703,66 @@ export class TrainSchedulingService {
'Intercity marshalling document applies only to dispatched or arrived trains',
);
}
+ const stops = await this.marshallingStops(scheduleId);
+ const stop = stops.find((s) => s.stopIndex === stopIndex);
+ if (!stop) {
+ throw new NotFoundException(
+ `No marshalling document at stop ${stopIndex} for this schedule — nothing coupled/uncoupled there, or the stop doesn't exist`,
+ );
+ }
+ const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
+ const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
+ where: { trainScheduleId: scheduleId, yardId: stop.yardId },
+ order: { occurredAt: 'ASC' },
+ });
+ const html = this.buildExportLoadListHtml(schedule, {
+ title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`,
+ positionLabel: `At ${stop.yardLabel}`,
+ wagons,
+ unassignedBookings,
+ emptyContainers: await this.loadedEmptyContainers(scheduleId),
+ logoImageUrl: await this.logoSettings.getLogoImageUrl(),
+ consistChangesAtStop: this.consistChangesAt(schedule, logRows),
+ });
+ // Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
+ const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`);
+ const reference = schedule.trainNumber ?? schedule.id;
+ return {
+ filename: `marshalling-${stopIndex}-${this.safeDocumentName(reference)}.pdf`,
+ buffer,
+ };
+ }
+
+ /**
+ * Back-compat alias: the single "current" intercity doc (Marshalling 2) the
+ * old one-document-per-schedule UI calls. Resolves to the LATEST stop with
+ * a logged consist change; falls back to the current-position doc with no
+ * changes table when nothing has coupled/uncoupled yet (e.g. right after
+ * dispatch, before any mid-corridor stop).
+ */
+ async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
+ const stops = await this.marshallingStops(scheduleId);
+ const latest = stops[stops.length - 1];
+ if (latest) {
+ return this.marshallingDocumentAt(scheduleId, latest.stopIndex);
+ }
+
+ const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
+ if (!schedule) {
+ throw new NotFoundException(`Train schedule ${scheduleId} not found`);
+ }
+ if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') {
+ throw new BadRequestException(
+ 'Intercity marshalling document applies only to dispatched or arrived trains',
+ );
+ }
const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
const last = checkpoints[checkpoints.length - 1];
const positionLabel = last
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
-
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
- // Couples/switches logged AT THIS STOP — what staff standing here actually
- // just did to the consist. Bare trims (REMOVE, no replacement) are left
- // out: nothing new to point staff at for those. Origin adjustments (a
- // different yard) don't show up on this stop's document.
- const consistChangesAtStop = last
- ? await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
- where: { trainScheduleId: scheduleId, yardId: last.yardId, action: In(['ADD', 'SWITCH']) },
- order: { occurredAt: 'DESC' },
- })
- : [];
const html = this.buildExportLoadListHtml(schedule, {
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
positionLabel,
@@ -3615,7 +3770,6 @@ export class TrainSchedulingService {
unassignedBookings,
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
- consistChangesAtStop,
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
@@ -3685,10 +3839,14 @@ export class TrainSchedulingService {
// Slots that couple to the train downstream (slot id → board yard label).
// Their cargo renders as TO LOAD AT and stays out of the loaded tallies.
pendingBoardYardLabelBySlot?: Map;
- // Intercity (Marshalling 2) only: couples/switches logged at the stop
- // this document is printed at (see ScheduleWagonAdjustmentLog). Origin
- // import/export docs never pass this, so they render no such box.
- consistChangesAtStop?: ScheduleWagonAdjustmentLog[];
+ // Numbered marshalling docs only (see marshallingDocumentAt /
+ // consistChangesAt) — couples/uncouples/switches logged at THIS stop.
+ // Origin import/export docs never pass this, so they render no such box.
+ consistChangesAtStop?: Array<{
+ wagonNumber: string;
+ event: 'Coupled' | 'Uncoupled' | 'Switched';
+ containerNumbers: string;
+ }>;
},
): string {
const esc = (value: unknown) =>
@@ -3853,6 +4011,7 @@ export class TrainSchedulingService {
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
${logoImageCss()}
+ h2 { margin: 16px 0 6px; font-size: 12px; color: #0f766e; text-transform: uppercase; letter-spacing: .05em; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
@@ -3901,19 +4060,27 @@ export class TrainSchedulingService {
${
opts?.consistChangesAtStop?.length
- ? `
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx
index 8c8560442..ca199e0cd 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx
@@ -13,8 +13,19 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
-import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
-import { extractErrorMessage, lettersOnly, statusOptions, warehouseTypeOptions } from './options';
+import type {
+ SaveWarehousePayload,
+ Warehouse,
+ WarehouseFreightType,
+ WarehouseType,
+} from '@/types/warehouse';
+import {
+ extractErrorMessage,
+ lettersOnly,
+ statusOptions,
+ warehouseFreightTypeOptions,
+ warehouseTypeOptions,
+} from './options';
interface CreateWarehouseModalProps {
opened: boolean;
@@ -26,6 +37,7 @@ interface FormState {
name: string;
code: string;
type: WarehouseType;
+ freightType: WarehouseFreightType | null;
stationId: string | null;
locationName: string;
capacityWeight: number | '';
@@ -38,6 +50,7 @@ const emptyForm = (): FormState => ({
name: '',
code: '',
type: 'OPEN_WAREHOUSE',
+ freightType: null,
stationId: null,
locationName: '',
capacityWeight: '',
@@ -66,6 +79,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: warehouse.name,
code: warehouse.code,
type: warehouse.type,
+ freightType: warehouse.freightType ?? null,
stationId: warehouse.stationId ?? null,
locationName: warehouse.locationName ?? '',
capacityWeight: warehouse.capacityWeight ?? '',
@@ -90,6 +104,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: form.name.trim(),
code: form.code.trim(),
type: form.type,
+ freightType: form.freightType,
stationId: form.stationId ?? undefined,
locationName: form.locationName.trim() || undefined,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
@@ -142,6 +157,14 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
/>
+
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/SlotPicker.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/SlotPicker.tsx
new file mode 100644
index 000000000..e30444277
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/SlotPicker.tsx
@@ -0,0 +1,87 @@
+import { useMemo } from 'react';
+import { Select, Text } from '@mantine/core';
+import { useQuery } from '@tanstack/react-query';
+
+import { api } from '@/services/api';
+import type { ZoneLayout } from '@/types/warehouse';
+
+interface SlotPickerProps {
+ zoneId: string;
+ value: string;
+ onChange: (slotId: string) => void;
+ label?: string;
+ disabled?: boolean;
+}
+
+/**
+ * The stack levels a container may actually be put on right now.
+ *
+ * Only the next fillable level of each stack is offered: a box cannot stand on
+ * level 2 while level 1 is empty, so listing level 3 of an empty stack would
+ * only produce a rejected request. The server enforces the same rule — this
+ * mirrors it so the operator never sees a 400 for a position the form offered.
+ */
+export function fillableLevels(layout: ZoneLayout | undefined) {
+ if (!layout) return [];
+
+ return layout.stacks
+ .filter((stack) => stack.isActive && stack.status === 'ACTIVE')
+ .flatMap((stack) => {
+ const occupied = stack.slots
+ .filter((slot) => slot.effectiveStatus === 'OCCUPIED')
+ .map((slot) => slot.level);
+ const top = occupied.length > 0 ? Math.max(...occupied) : 0;
+ if (top >= stack.maxStackHeight) return [];
+
+ const next = stack.slots.find(
+ (slot) => slot.level === top + 1 && slot.effectiveStatus === 'AVAILABLE',
+ );
+ if (!next) return [];
+
+ return [
+ {
+ value: next.slotId,
+ label: `${stack.code} — level ${next.level}${top > 0 ? ` (on ${top} container${top > 1 ? 's' : ''})` : ' (ground)'}`,
+ },
+ ];
+ });
+}
+
+export function SlotPicker({ zoneId, value, onChange, label = 'Stack position', disabled }: SlotPickerProps) {
+ const { data, isLoading } = useQuery(
+ api.warehouses.zoneLayout.queryOptions({
+ input: { zoneId },
+ enabled: Boolean(zoneId),
+ }),
+ );
+
+ const options = useMemo(() => fillableLevels(data), [data]);
+
+ // A zone with no stacks configured keeps plain zone-level placement — showing
+ // an empty picker there would imply a choice that does not exist.
+ if (!zoneId || (!isLoading && (data?.stacks.length ?? 0) === 0)) return null;
+
+ return (
+
+ Every stack in this zone is full or blocked — the item will be stored at zone level.
+
+ ) : (
+ 'Leave blank to take the lowest free level automatically.'
+ )
+ }
+ placeholder={isLoading ? 'Loading positions…' : 'Automatic (lowest free level)'}
+ searchable
+ clearable
+ disabled={disabled || isLoading || options.length === 0}
+ data={options}
+ value={value || null}
+ onChange={(v) => onChange(v ?? '')}
+ />
+ );
+}
+
+export default SlotPicker;
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx
index f1a5aa4ad..c9051dba6 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx
@@ -8,6 +8,7 @@ import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
+import { SlotPicker } from './SlotPicker';
interface StoreInventoryModalProps {
opened: boolean;
@@ -25,12 +26,14 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
const [warehouseId, setWarehouseId] = useState('');
const [yardId, setYardId] = useState('');
const [zoneId, setZoneId] = useState('');
+ const [slotId, setSlotId] = useState('');
useEffect(() => {
if (opened) {
setWarehouseId('');
setYardId('');
setZoneId('');
+ setSlotId('');
}
}, [opened]);
@@ -75,7 +78,7 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
try {
await storeMutation.mutateAsync({
id: item.id,
- payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
+ payload: manualComplete ? { warehouseId, yardId, zoneId, slotId: slotId || undefined } : undefined,
});
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
onClose();
@@ -127,8 +130,13 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
disabled={!yardId}
data={zoneOptions}
value={zoneId || null}
- onChange={(v) => setZoneId(v ?? '')}
+ onChange={(v) => {
+ setZoneId(v ?? '');
+ setSlotId('');
+ }}
/>
+ {/* Container yards only — the picker hides itself where no stacks exist. */}
+
Cancel
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx
index c6baed7ca..ceb616e45 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core';
-import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react';
+import { Building2, Eye, MapPin, Package, Pencil, Trash2, Weight } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -13,9 +13,11 @@ interface WarehouseCardViewProps {
warehouses: Warehouse[];
onView: (warehouse: Warehouse) => void;
onEdit: (warehouse: Warehouse) => void;
+ /** Omitted when the user lacks the delete permission. */
+ onDelete?: (warehouse: Warehouse) => void;
}
-export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) {
+export function WarehouseCardView({ warehouses, onView, onEdit, onDelete }: WarehouseCardViewProps) {
const { data: stations } = useQuery(
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
);
@@ -130,6 +132,18 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
+ {onDelete ? (
+
+ onDelete(warehouse)}
+ aria-label="Delete warehouse"
+ >
+
+
+
+ ) : null}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx
index 82a44f557..7dac2d8a1 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx
@@ -8,6 +8,7 @@ import {
Package,
Pencil,
Scale,
+ Trash2,
Warehouse as WarehouseIcon,
} from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
@@ -24,6 +25,8 @@ interface WarehouseTableProps {
warehouses: Warehouse[];
onView: (warehouse: Warehouse) => void;
onEdit: (warehouse: Warehouse) => void;
+ /** Omitted when the user lacks the delete permission. */
+ onDelete?: (warehouse: Warehouse) => void;
}
const HEADER = bookingTable.headerCell;
@@ -63,7 +66,7 @@ function CapacityCell({
);
}
-export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
+export function WarehouseTable({ warehouses, onView, onEdit, onDelete }: WarehouseTableProps) {
const { data: stations } = useQuery(
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
);
@@ -179,6 +182,11 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
onEdit(row.original)} title="Edit">
+ {onDelete ? (
+ onDelete(row.original)} title="Delete">
+
+
+ ) : null}
),
},
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneContentsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneContentsModal.tsx
new file mode 100644
index 000000000..2c0672078
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneContentsModal.tsx
@@ -0,0 +1,119 @@
+import { Badge, Group, Loader, Modal, Text } from '@mantine/core';
+import { useQuery } from '@tanstack/react-query';
+import { DataTable, type ColumnDef } from '@edr/ui-common';
+
+import { formatDateTime, humanize } from '@/lib/format';
+import { api } from '@/services/api';
+import type { ZoneContentItem } from '@/types/warehouse';
+
+/** Enough to name the zone and fetch it — satisfied by WarehouseZone and ZoneOccupancy alike. */
+export interface ZoneRef {
+ id: string;
+ name: string;
+ code: string;
+}
+
+interface ZoneContentsModalProps {
+ opened: boolean;
+ onClose: () => void;
+ zone: ZoneRef | null;
+}
+
+const columns: ColumnDef[] = [
+ {
+ id: 'containerNumber',
+ header: 'Container No.',
+ // Bulk cargo has no container of its own — it still occupies the zone.
+ cell: ({ row }) => row.original.containerNumber ?? 'Bulk cargo',
+ },
+ {
+ id: 'unloadedAt',
+ header: 'Unloaded',
+ cell: ({ row }) => formatDateTime(row.original.unloadedAt),
+ },
+ {
+ id: 'containerType',
+ header: 'Type',
+ cell: ({ row }) => row.original.containerType ?? '—',
+ },
+ {
+ id: 'direction',
+ header: 'Import / Export',
+ cell: ({ row }) =>
+ row.original.direction ? (
+
+ {humanize(row.original.direction)}
+
+ ) : (
+ '—'
+ ),
+ },
+ {
+ id: 'loadState',
+ header: 'Full / Empty',
+ cell: ({ row }) =>
+ row.original.loadState ? (
+
+ {humanize(row.original.loadState)}
+
+ ) : (
+ '—'
+ ),
+ },
+ {
+ id: 'bookingReference',
+ header: 'Booking',
+ cell: ({ row }) => row.original.bookingReference ?? '—',
+ },
+ {
+ id: 'status',
+ header: 'Status',
+ cell: ({ row }) => humanize(row.original.status),
+ },
+];
+
+export function ZoneContentsModal({ opened, onClose, zone }: ZoneContentsModalProps) {
+ const { data, isLoading, isError } = useQuery(
+ api.warehouses.zoneContents.queryOptions({
+ input: { zoneId: zone?.id ?? '' },
+ enabled: opened && Boolean(zone?.id),
+ }),
+ );
+
+ const items = data ?? [];
+
+ return (
+
+ {zone ? `${zone.name} (${zone.code})` : 'Zone'}
+
+ {items.length} item(s) in this zone
+
+
+ }
+ >
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+
+ Failed to load zone contents.
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default ZoneContentsModal;
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneLayoutModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneLayoutModal.tsx
new file mode 100644
index 000000000..7821ee1b3
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneLayoutModal.tsx
@@ -0,0 +1,407 @@
+import { useMemo, useState } from 'react';
+import {
+ ActionIcon,
+ Badge,
+ Button,
+ Card,
+ Group,
+ Loader,
+ Menu,
+ Modal,
+ NumberInput,
+ Paper,
+ SimpleGrid,
+ Stack,
+ Text,
+ TextInput,
+ Tooltip,
+} from '@mantine/core';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { Ban, CircleSlash, Layers, MoreVertical, Plus, Trash2, Unlock } from 'lucide-react';
+
+import { useAuth } from '@/auth/useAuth';
+import { useToast } from '@/hooks/use-toast';
+import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
+import { api } from '@/services/api';
+import type { SlotEffectiveStatus, ZoneLayoutSlot, ZoneLayoutStack } from '@/types/warehouse';
+import { extractErrorMessage } from './options';
+import type { ZoneRef } from './ZoneContentsModal';
+
+interface ZoneLayoutModalProps {
+ opened: boolean;
+ onClose: () => void;
+ zone: ZoneRef | null;
+}
+
+/** One colour per slot state, used by both the cell and the legend. */
+const SLOT_TONE: Record = {
+ OCCUPIED: { color: 'blue', label: 'Occupied' },
+ AVAILABLE: { color: 'teal', label: 'Free' },
+ RESERVED: { color: 'orange', label: 'Reserved' },
+ BLOCKED: { color: 'red', label: 'Blocked' },
+ INACTIVE: { color: 'gray', label: 'Inactive' },
+};
+
+/**
+ * A container stack seen from the side: level 3 on top, level 1 on the ground —
+ * the order the API already returns and the order the yard actually looks.
+ */
+function SlotCell({
+ slot,
+ onSetStatus,
+ canEdit,
+}: {
+ slot: ZoneLayoutSlot;
+ canEdit: boolean;
+ onSetStatus: (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => void;
+}) {
+ const tone = SLOT_TONE[slot.effectiveStatus];
+ const occupied = slot.effectiveStatus === 'OCCUPIED';
+
+ return (
+
+
+
+
+ L{slot.level}
+
+
+ {occupied ? (slot.containerNumber ?? 'Container') : tone.label}
+
+
+
+ {/* An occupied level has no status to set — empty it by moving the box. */}
+ {canEdit && !occupied ? (
+
+ ) : null}
+
+
+ );
+}
+
+function StackCard({
+ stack,
+ canEdit,
+ canDelete,
+ onSetSlotStatus,
+ onDelete,
+}: {
+ stack: ZoneLayoutStack;
+ canEdit: boolean;
+ canDelete: boolean;
+ onSetSlotStatus: (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => void;
+ onDelete: (stack: ZoneLayoutStack) => void;
+}) {
+ const filled = stack.slots.filter((s) => s.effectiveStatus === 'OCCUPIED').length;
+
+ return (
+
+
+
+
+
+ {stack.code}
+
+ {stack.status !== 'ACTIVE' || !stack.isActive ? (
+
+ Inactive
+
+ ) : null}
+
+
+
+ {filled}/{stack.maxStackHeight}
+
+ {canDelete ? (
+
+ onDelete(stack)}
+ aria-label={`Delete stack ${stack.code}`}
+ >
+
+
+
+ ) : null}
+
+
+
+
+ {stack.slots.map((slot) => (
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * Physical layout of one zone — every ground stack with its levels, what stands
+ * on each, and the capacity numbers that are routinely confused (configured vs
+ * built vs full). Stacks are created and retired from here, since there is
+ * nowhere else the yard layout is visible.
+ */
+export function ZoneLayoutModal({ opened, onClose, zone }: ZoneLayoutModalProps) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+ const canCreate = hasPermission(user, FREIGHT_PERMS.warehouseZones.create);
+ const canEdit = hasPermission(user, FREIGHT_PERMS.warehouseZones.update);
+ const canDelete = hasPermission(user, FREIGHT_PERMS.warehouseZones.delete);
+
+ const zoneId = zone?.id ?? '';
+ const { data, isLoading, isError, refetch } = useQuery(
+ api.warehouses.zoneLayout.queryOptions({
+ input: { zoneId },
+ enabled: opened && Boolean(zoneId),
+ }),
+ );
+
+ const [creating, setCreating] = useState(false);
+ const [code, setCode] = useState('');
+ const [height, setHeight] = useState(3);
+
+ const createStack = useMutation(api.warehouses.createStack.mutationOptions());
+ const deleteStack = useMutation(api.warehouses.deleteStack.mutationOptions());
+ const updateSlot = useMutation(api.warehouses.updateSlot.mutationOptions());
+
+ const stacks = data?.stacks ?? [];
+ const summary = data?.summary;
+
+ const nextCode = useMemo(() => {
+ // Suggest the next number in the zone's own series (ZA-001 → ZA-002) so
+ // codes stay sortable, which is the order the placement engine walks.
+ const numbered = stacks
+ .map((s) => /^(.*?)(\d+)$/.exec(s.code))
+ .filter((m): m is RegExpExecArray => Boolean(m));
+ if (numbered.length === 0) return '';
+ const last = numbered[numbered.length - 1];
+ const width = last[2].length;
+ const next = Math.max(...numbered.map((m) => Number(m[2]))) + 1;
+ return `${last[1]}${String(next).padStart(width, '0')}`;
+ }, [stacks]);
+
+ const submitStack = () => {
+ const trimmed = code.trim();
+ if (!trimmed) {
+ toast({ variant: 'destructive', title: 'Stack code is required' });
+ return;
+ }
+ createStack.mutate(
+ { zoneId, payload: { code: trimmed, maxStackHeight: height } },
+ {
+ onSuccess: () => {
+ toast({ title: `Stack ${trimmed} created with ${height} level(s)` });
+ setCode('');
+ setCreating(false);
+ },
+ onError: (error) =>
+ toast({
+ variant: 'destructive',
+ title: 'Could not create the stack',
+ description: extractErrorMessage(error),
+ }),
+ },
+ );
+ };
+
+ const removeStack = (stack: ZoneLayoutStack) => {
+ if (!window.confirm(`Delete stack ${stack.code}? It must be empty first.`)) return;
+ deleteStack.mutate(
+ { id: stack.stackId, zoneId },
+ {
+ onSuccess: () => toast({ title: `Stack ${stack.code} deleted` }),
+ onError: (error) =>
+ toast({
+ variant: 'destructive',
+ title: 'Could not delete the stack',
+ description: extractErrorMessage(error),
+ }),
+ },
+ );
+ };
+
+ const setSlotStatus = (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => {
+ updateSlot.mutate(
+ { slotId: slot.slotId, zoneId, payload: { status, isActive: true } },
+ {
+ onSuccess: () => toast({ title: `Level ${slot.level} set to ${SLOT_TONE[status].label}` }),
+ onError: (error) =>
+ toast({
+ variant: 'destructive',
+ title: 'Could not update the level',
+ description: extractErrorMessage(error),
+ }),
+ },
+ );
+ };
+
+ return (
+
+
+ {zone ? `${zone.name} (${zone.code}) layout` : 'Zone layout'}
+
+ }
+ >
+
+ {summary ? (
+
+
+
+
+
+
+
+
+
+ {summary.inconsistent ? (
+
+ {summary.physicalSlotCount} slots are built but the zone is configured for{' '}
+ {summary.configuredCapacity}. Raise the zone capacity or remove stacks — the
+ configured figure was left as it is.
+
+ ) : null}
+
+ ) : null}
+
+
+
+ {(Object.keys(SLOT_TONE) as SlotEffectiveStatus[]).map((key) => (
+
+ {SLOT_TONE[key].label}
+
+ ))}
+
+ {canCreate ? (
+ }
+ variant={creating ? 'default' : 'filled'}
+ onClick={() => {
+ setCreating((open) => !open);
+ if (!creating && !code) setCode(nextCode);
+ }}
+ >
+ {creating ? 'Cancel' : 'Add stack'}
+
+ ) : null}
+
+
+ {creating ? (
+
+
+ setCode(e.currentTarget.value)}
+ style={{ flex: 1 }}
+ />
+ setHeight(Number(v) || 1)}
+ w={140}
+ />
+
+ Create
+
+
+
+ ) : null}
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+
+ Failed to load the zone layout.
+ void refetch()}>
+ Retry
+
+
+ ) : stacks.length === 0 ? (
+
+ No ground stacks configured in this zone yet. Containers stored here keep zone-level
+ placement until stacks exist.
+
+ ) : (
+
+ {stacks.map((stack) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function Stat({ label, value, color }: { label: string; value: number | string; color?: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+export default ZoneLayoutModal;
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneOccupancyHeatmap.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneOccupancyHeatmap.tsx
index 9fcbd88ed..6ceb3e11d 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneOccupancyHeatmap.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneOccupancyHeatmap.tsx
@@ -25,13 +25,15 @@ function capacityLabel(z: ZoneOccupancy): string {
interface ZoneOccupancyHeatmapProps {
/** Scope to one yard; omit for all zones. */
yardId?: string;
+ /** Pass to make each tile open that zone; omitted leaves the tiles inert. */
+ onZoneClick?: (zone: ZoneOccupancy) => void;
}
/**
* Occupancy heatmap: one tile per zone, coloured by how full it is. Occupancy is
* container-count based (unit-consistent); weight is shown as context only.
*/
-export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
+export function ZoneOccupancyHeatmap({ yardId, onZoneClick }: ZoneOccupancyHeatmapProps) {
const { data: zones = [], isLoading } = useZoneOccupancy(yardId);
if (isLoading) {
@@ -67,7 +69,15 @@ export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
const t = tone(z.occupancyPct);
const pct = z.occupancyPct ?? 0;
return (
-
+ onZoneClick(z) : undefined}
+ style={onZoneClick ? { cursor: 'pointer' } : undefined}
+ title={onZoneClick ? `View what is stored in ${z.name}` : undefined}
+ >
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/container-return-excel.test.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/container-return-excel.test.ts
new file mode 100644
index 000000000..48b23485d
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/container-return-excel.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from "vitest";
+import * as XLSX from "xlsx";
+
+import { parseContainerReturnExcel } from "./container-return-excel";
+
+/** Build an in-memory .xlsx and hand it back as a File, like the dropzone would. */
+function sheetFile(aoa: unknown[][]): File {
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Sheet1");
+ const buf = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
+ return new File([buf], "returns.xlsx");
+}
+
+const HEADERS = [
+ "Container Number",
+ "Container Size",
+ "Company",
+ "Returned By",
+ "Returned Date",
+ "Facility",
+ "Yard",
+ "Zone",
+ "Condition",
+ "Handover Note",
+];
+
+describe("parseContainerReturnExcel", () => {
+ it("parses a good sheet, normalizing size and returned-by", async () => {
+ const result = await parseContainerReturnExcel(
+ sheetFile([
+ ["Yard tally — August"], // title row above the header is ignored
+ HEADERS,
+ ["temu1234567", "40ft", "Acme PLC", "EDR last mile", "2026-08-14", "Gelan", "A", "1", "", ""],
+ ["MSCU7654321", "20", "Other Trading", "Self haul", "2026-08-15", "Gelan", "", "", "Dented", "n"],
+ ]),
+ );
+
+ expect(result.errors).toEqual([]);
+ expect(result.rows).toHaveLength(2);
+ expect(result.rows[0].containerNumber).toBe("TEMU1234567");
+ expect(result.rows[0].containerSize).toBe("40");
+ expect(result.rows[0].returnedBy).toBe("EDR");
+ expect(result.rows[0].companyName).toBe("Acme PLC");
+ expect(result.rows[0].returnDate?.startsWith("2026-08-14")).toBe(true);
+ expect(result.rows[1].containerSize).toBe("20");
+ expect(result.rows[1].returnedBy).toBe("CUSTOMER");
+ });
+
+ it("rejects the whole file when a container number is invalid", async () => {
+ const result = await parseContainerReturnExcel(
+ sheetFile([HEADERS, ["NOTACONTAINER", "40", "Acme", "EDR", "", "", "", "", "", ""]]),
+ );
+
+ expect(result.rows).toEqual([]);
+ expect(result.errors[0]).toContain("Row 2");
+ });
+
+ it("rejects duplicate container numbers", async () => {
+ const result = await parseContainerReturnExcel(
+ sheetFile([
+ HEADERS,
+ ["TEMU1234567", "40", "Acme", "EDR", "", "", "", "", "", ""],
+ ["temu1234567", "20", "Acme", "EDR", "", "", "", "", "", ""],
+ ]),
+ );
+
+ expect(result.rows).toEqual([]);
+ expect(result.errors.some((e) => e.includes("appears 2 times"))).toBe(true);
+ });
+
+ it("errors when there is no container-number column", async () => {
+ const result = await parseContainerReturnExcel(sheetFile([["Company", "Yard"], ["Acme", "A"]]));
+ expect(result.errors[0]).toContain("Container Number");
+ });
+});
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/container-return-excel.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/container-return-excel.ts
new file mode 100644
index 000000000..bdc29a13d
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/container-return-excel.ts
@@ -0,0 +1,231 @@
+import * as XLSX from "xlsx";
+
+// Excel import for empties already sitting in an EDR yard that were never
+// entered in the system. One spreadsheet row per container. All-or-nothing —
+// any bad row rejects the whole file with row-numbered errors, so a partial
+// backfill can never silently drop boxes.
+
+// ISO 6346: 4-letter prefix (owner code + category id) + 7 digits.
+const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
+
+export interface ParsedReturnRow {
+ containerNumber: string;
+ containerSize: "20" | "40" | null;
+ companyName: string;
+ returnedBy: "EDR" | "CUSTOMER" | null;
+ returnDate: string | null;
+ facility: string;
+ yard: string;
+ zone: string;
+ condition: string;
+ handoverNote: string;
+}
+
+export interface ContainerReturnExcelResult {
+ rows: ParsedReturnRow[];
+ errors: string[];
+}
+
+type ColumnKey =
+ | "containerNumber"
+ | "containerSize"
+ | "companyName"
+ | "returnedBy"
+ | "returnDate"
+ | "facility"
+ | "yard"
+ | "zone"
+ | "condition"
+ | "handoverNote";
+
+/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */
+function headerKey(raw: string): ColumnKey | null {
+ const h = raw.toLowerCase().replace(/[^a-z]/g, "");
+ if (!h) return null;
+ if (h.includes("size") || h.includes("type")) return "containerSize";
+ if (h.includes("company") || h.includes("customer") || h.includes("consignee")) return "companyName";
+ if (h.includes("returnedby") || h.includes("haul") || h.includes("truck")) return "returnedBy";
+ if (h.includes("date")) return "returnDate";
+ if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility";
+ if (h.includes("yard")) return "yard";
+ if (h.includes("zone")) return "zone";
+ if (h.includes("condition") || h.includes("damage")) return "condition";
+ if (h.includes("note") || h.includes("remark")) return "handoverNote";
+ // Least specific last, so "Container Size" is not eaten by "container".
+ if (h.includes("container") || h.includes("number")) return "containerNumber";
+ return null;
+}
+
+/** "20", "20ft", "40 HC" … → '20' | '40' | null. */
+function normalizeSize(raw: string): "20" | "40" | null {
+ const digits = raw.replace(/[^0-9]/g, "");
+ if (digits.startsWith("20")) return "20";
+ if (digits.startsWith("40") || digits.startsWith("45")) return "40";
+ return null;
+}
+
+/** "EDR", "EDR last mile", "customer", "self haul" … */
+function normalizeReturnedBy(raw: string): "EDR" | "CUSTOMER" | null {
+ const v = raw.toLowerCase();
+ if (!v.trim()) return null;
+ if (v.includes("edr")) return "EDR";
+ if (v.includes("customer") || v.includes("self")) return "CUSTOMER";
+ return null;
+}
+
+/**
+ * Excel dates arrive either as a serial number (raw cells) or as text. Returns
+ * an ISO instant, or null when the cell is empty/unparseable.
+ */
+function normalizeDate(raw: string): string | null {
+ const v = raw.trim();
+ if (!v) return null;
+ // Excel serial: days since 1899-12-30.
+ if (/^\d{1,6}(\.\d+)?$/.test(v)) {
+ const serial = Number(v);
+ if (serial > 20000 && serial < 80000) {
+ return new Date(Math.round((serial - 25569) * 86400000)).toISOString();
+ }
+ }
+ const parsed = new Date(v);
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
+}
+
+/**
+ * Parse an uploaded workbook into one row per empty container. Returns either
+ * the full row set or the list of row-numbered problems — never both.
+ */
+export async function parseContainerReturnExcel(file: File): Promise {
+ let sheet: XLSX.WorkSheet | undefined;
+ try {
+ const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
+ sheet = workbook.Sheets[workbook.SheetNames[0]];
+ } catch {
+ return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
+ }
+ if (!sheet) return { rows: [], errors: ["The file has no sheets."] };
+
+ const grid = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" });
+
+ // First row carrying a container-number column is the header; titles and
+ // blank rows above it are ignored.
+ let headerRowIdx = -1;
+ let columns: Array = [];
+ for (let i = 0; i < grid.length; i++) {
+ const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
+ if (mapped.includes("containerNumber")) {
+ headerRowIdx = i;
+ columns = mapped;
+ break;
+ }
+ }
+ if (headerRowIdx < 0) {
+ return {
+ rows: [],
+ errors: [
+ 'Could not find a "Container Number" column — download the template to see the expected format.',
+ ],
+ };
+ }
+
+ const rows: ParsedReturnRow[] = [];
+ const errors: string[] = [];
+ const numberCounts = new Map();
+
+ for (let i = headerRowIdx + 1; i < grid.length; i++) {
+ const cells = grid[i] ?? [];
+ if (cells.every((c) => String(c ?? "").trim() === "")) continue;
+ const rowNo = i + 1; // 1-based, as shown in Excel
+
+ const cell = (key: ColumnKey) => {
+ const idx = columns.indexOf(key);
+ return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
+ };
+
+ const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, "");
+ if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
+ errors.push(
+ `Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`,
+ );
+ } else {
+ numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
+ }
+
+ const sizeRaw = cell("containerSize");
+ const containerSize = sizeRaw ? normalizeSize(sizeRaw) : null;
+ if (sizeRaw && !containerSize) {
+ errors.push(`Row ${rowNo}: container size "${sizeRaw}" is not 20 or 40.`);
+ }
+
+ const returnedByRaw = cell("returnedBy");
+ const returnedBy = normalizeReturnedBy(returnedByRaw);
+ if (returnedByRaw && !returnedBy) {
+ errors.push(`Row ${rowNo}: returned by "${returnedByRaw}" must be EDR or CUSTOMER.`);
+ }
+
+ const dateRaw = cell("returnDate");
+ const returnDate = normalizeDate(dateRaw);
+ if (dateRaw && !returnDate) {
+ errors.push(`Row ${rowNo}: returned date "${dateRaw}" is not a date.`);
+ }
+
+ rows.push({
+ containerNumber,
+ containerSize,
+ companyName: cell("companyName"),
+ returnedBy,
+ returnDate,
+ facility: cell("facility"),
+ yard: cell("yard"),
+ zone: cell("zone"),
+ condition: cell("condition"),
+ handoverNote: cell("handoverNote"),
+ });
+ }
+
+ numberCounts.forEach((count, num) => {
+ if (count > 1) {
+ errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
+ }
+ });
+
+ if (rows.length === 0 && errors.length === 0) {
+ errors.push("The sheet has no container rows below the header.");
+ }
+
+ return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
+}
+
+/** Download the import template with one filled sample row. */
+export function downloadContainerReturnTemplate() {
+ const headers = [
+ "Container Number",
+ "Container Size",
+ "Company",
+ "Returned By",
+ "Returned Date",
+ "Facility",
+ "Yard",
+ "Zone",
+ "Condition",
+ "Handover Note",
+ ];
+ const sample = [
+ "TEMU1234567",
+ "40",
+ "Acme Import PLC",
+ "CUSTOMER",
+ new Date().toISOString().split("T")[0],
+ "Gelan Multipurpose port",
+ "Yard A",
+ "Zone 1",
+ "Sound",
+ "Backfilled from yard tally sheet",
+ ];
+
+ const sheet = XLSX.utils.aoa_to_sheet([headers, sample]);
+ sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
+ const workbook = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(workbook, sheet, "Container Returns");
+ XLSX.writeFile(workbook, "container-return-import-template.xlsx");
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.test.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.test.ts
new file mode 100644
index 000000000..107bf0f75
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from "vitest";
+import * as XLSX from "xlsx";
+
+import { parseFullContainerExcel } from "./full-container-excel";
+
+function sheetFile(aoa: unknown[][]): File {
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Sheet1");
+ const buf = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
+ return new File([buf], "backlog.xlsx");
+}
+
+const HEADERS = [
+ "Container Number",
+ "Container Size",
+ "Company",
+ "Arrival Date",
+ "Facility",
+ "Yard",
+ "Zone",
+ "Seal Number",
+ "Weight (Tons)",
+ "Notes",
+];
+
+const row = (num: string, arrived: string, weight: string | number = 24.5) => [
+ num,
+ "40",
+ "Acme PLC",
+ arrived,
+ "Gelan",
+ "A",
+ "1",
+ "SL1",
+ weight,
+ "",
+];
+
+describe("parseFullContainerExcel", () => {
+ it("parses a backlog sheet with past arrival dates", async () => {
+ const result = await parseFullContainerExcel(
+ sheetFile([HEADERS, row("temu1234567", "2026-03-14"), row("MSCU7654321", "2025-11-02")]),
+ );
+
+ expect(result.errors).toEqual([]);
+ expect(result.rows).toHaveLength(2);
+ expect(result.rows[0].containerNumber).toBe("TEMU1234567");
+ expect(result.rows[0].arrivedAt?.startsWith("2026-03-14")).toBe(true);
+ expect(result.rows[0].companyName).toBe("Acme PLC");
+ });
+
+ it("rejects a future arrival date — a backlog box arrived in the past", async () => {
+ const future = new Date();
+ future.setFullYear(future.getFullYear() + 1);
+ const result = await parseFullContainerExcel(
+ sheetFile([HEADERS, row("TEMU1234567", future.toISOString().slice(0, 10))]),
+ );
+
+ expect(result.rows).toEqual([]);
+ expect(result.errors.some((e) => e.includes("in the future"))).toBe(true);
+ });
+
+ it("accepts an arrival date of today", async () => {
+ const today = new Date().toISOString().slice(0, 10);
+ const result = await parseFullContainerExcel(sheetFile([HEADERS, row("TEMU1234567", today)]));
+ expect(result.errors).toEqual([]);
+ });
+
+ it("rejects an invalid container number and a negative weight", async () => {
+ const result = await parseFullContainerExcel(
+ sheetFile([HEADERS, row("NOPE", "2026-03-14"), row("MSCU7654321", "2026-03-14", -3)]),
+ );
+
+ expect(result.rows).toEqual([]);
+ expect(result.errors.some((e) => e.includes("ISO container number"))).toBe(true);
+ expect(result.errors.some((e) => e.includes("0 or more"))).toBe(true);
+ });
+
+ it("rejects duplicate container numbers", async () => {
+ const result = await parseFullContainerExcel(
+ sheetFile([HEADERS, row("TEMU1234567", "2026-03-14"), row("temu1234567", "2026-03-15")]),
+ );
+
+ expect(result.rows).toEqual([]);
+ expect(result.errors.some((e) => e.includes("appears 2 times"))).toBe(true);
+ });
+});
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.ts
new file mode 100644
index 000000000..f94057383
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.ts
@@ -0,0 +1,206 @@
+import * as XLSX from "xlsx";
+
+// Excel import for loaded containers already sitting in a yard but never
+// entered in the system. One row per container. All-or-nothing — any bad row
+// rejects the file with row-numbered errors, so a half-registered yard cannot
+// happen.
+
+const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
+
+export interface ParsedFullContainerRow {
+ containerNumber: string;
+ containerSize: string;
+ companyName: string;
+ /** ISO instant; null when the cell was empty or unreadable. */
+ arrivedAt: string | null;
+ facility: string;
+ yard: string;
+ zone: string;
+ sealNumber: string;
+ weight: string;
+ notes: string;
+}
+
+export interface FullContainerExcelResult {
+ rows: ParsedFullContainerRow[];
+ errors: string[];
+}
+
+type ColumnKey =
+ | "containerNumber"
+ | "containerSize"
+ | "companyName"
+ | "arrivedAt"
+ | "facility"
+ | "yard"
+ | "zone"
+ | "sealNumber"
+ | "weight"
+ | "notes";
+
+/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */
+function headerKey(raw: string): ColumnKey | null {
+ const h = raw.toLowerCase().replace(/[^a-z]/g, "");
+ if (!h) return null;
+ if (h.includes("seal")) return "sealNumber";
+ if (h.includes("size") || h.includes("type")) return "containerSize";
+ if (h.includes("company") || h.includes("owner") || h.includes("consignee")) return "companyName";
+ if (h.includes("arriv") || h.includes("date")) return "arrivedAt";
+ if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility";
+ if (h.includes("yard")) return "yard";
+ if (h.includes("zone")) return "zone";
+ if (h.includes("weight") || h.includes("vgm")) return "weight";
+ if (h.includes("note") || h.includes("remark")) return "notes";
+ if (h.includes("container") || h.includes("number")) return "containerNumber";
+ return null;
+}
+
+/**
+ * Excel dates arrive either as a serial number (raw cells) or as text.
+ * Returns an ISO instant, or null when the cell is empty/unparseable.
+ */
+function normalizeDate(raw: string): string | null {
+ const v = raw.trim();
+ if (!v) return null;
+ if (/^\d{1,6}(\.\d+)?$/.test(v)) {
+ const serial = Number(v);
+ if (serial > 20000 && serial < 80000) {
+ return new Date(Math.round((serial - 25569) * 86400000)).toISOString();
+ }
+ }
+ const parsed = new Date(v);
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
+}
+
+/** Parse an uploaded workbook into one row per loaded container. */
+export async function parseFullContainerExcel(file: File): Promise {
+ let sheet: XLSX.WorkSheet | undefined;
+ try {
+ const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
+ sheet = workbook.Sheets[workbook.SheetNames[0]];
+ } catch {
+ return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
+ }
+ if (!sheet) return { rows: [], errors: ["The file has no sheets."] };
+
+ const grid = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" });
+
+ let headerRowIdx = -1;
+ let columns: Array = [];
+ for (let i = 0; i < grid.length; i++) {
+ const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
+ if (mapped.includes("containerNumber")) {
+ headerRowIdx = i;
+ columns = mapped;
+ break;
+ }
+ }
+ if (headerRowIdx < 0) {
+ return {
+ rows: [],
+ errors: [
+ 'Could not find a "Container Number" column — download the template to see the expected format.',
+ ],
+ };
+ }
+
+ const rows: ParsedFullContainerRow[] = [];
+ const errors: string[] = [];
+ const numberCounts = new Map();
+ const startOfTomorrow = new Date();
+ startOfTomorrow.setHours(24, 0, 0, 0);
+
+ for (let i = headerRowIdx + 1; i < grid.length; i++) {
+ const cells = grid[i] ?? [];
+ if (cells.every((c) => String(c ?? "").trim() === "")) continue;
+ const rowNo = i + 1; // 1-based, as shown in Excel
+
+ const cell = (key: ColumnKey) => {
+ const idx = columns.indexOf(key);
+ return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
+ };
+
+ const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, "");
+ if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
+ errors.push(
+ `Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`,
+ );
+ } else {
+ numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
+ }
+
+ const arrivedRaw = cell("arrivedAt");
+ const arrivedAt = normalizeDate(arrivedRaw);
+ if (arrivedRaw && !arrivedAt) {
+ errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is not a date.`);
+ }
+ // The whole point of a backlog is that it arrived in the past.
+ if (arrivedAt && new Date(arrivedAt).getTime() >= startOfTomorrow.getTime()) {
+ errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is in the future.`);
+ }
+
+ const weightRaw = cell("weight");
+ if (weightRaw && (Number.isNaN(Number(weightRaw)) || Number(weightRaw) < 0)) {
+ errors.push(`Row ${rowNo}: weight "${weightRaw}" must be a number of 0 or more.`);
+ }
+
+ rows.push({
+ containerNumber,
+ containerSize: cell("containerSize"),
+ companyName: cell("companyName"),
+ arrivedAt,
+ facility: cell("facility"),
+ yard: cell("yard"),
+ zone: cell("zone"),
+ sealNumber: cell("sealNumber"),
+ weight: weightRaw,
+ notes: cell("notes"),
+ });
+ }
+
+ numberCounts.forEach((count, num) => {
+ if (count > 1) {
+ errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
+ }
+ });
+
+ if (rows.length === 0 && errors.length === 0) {
+ errors.push("The sheet has no container rows below the header.");
+ }
+
+ return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
+}
+
+/** Download the import template with one filled sample row. */
+export function downloadFullContainerTemplate() {
+ const headers = [
+ "Container Number",
+ "Container Size",
+ "Company",
+ "Arrival Date",
+ "Facility",
+ "Yard",
+ "Zone",
+ "Seal Number",
+ "Weight (Tons)",
+ "Notes",
+ ];
+ const sample = [
+ "TEMU1234567",
+ "40",
+ "Acme Import PLC",
+ "2026-03-14",
+ "Gelan Multipurpose port",
+ "Yard A",
+ "Zone 1",
+ "SL482910",
+ 24.5,
+ "Backlog — registered from yard tally sheet",
+ ];
+
+ const sheet = XLSX.utils.aoa_to_sheet([headers, sample]);
+ sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
+ const workbook = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(workbook, sheet, "Full Containers");
+ XLSX.writeFile(workbook, "full-container-backlog-template.xlsx");
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
index dae0e1d7f..4e0ebd7f8 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
@@ -9,6 +9,9 @@ export { WarehouseInquiryTable } from './WarehouseInquiryTable';
export { CreateWarehouseModal } from './CreateWarehouseModal';
export { CreateYardModal } from './CreateYardModal';
export { CreateZoneModal } from './CreateZoneModal';
+export { ZoneContentsModal, type ZoneRef } from './ZoneContentsModal';
+export { ZoneLayoutModal } from './ZoneLayoutModal';
+export { SlotPicker } from './SlotPicker';
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
export { WarehouseInfoCard } from './WarehouseInfoCard';
export { MoveInventoryModal } from './MoveInventoryModal';
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts
index 5fd393268..eecf5809f 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts
@@ -1,4 +1,5 @@
import {
+ WAREHOUSE_FREIGHT_TYPES,
WAREHOUSE_TYPES,
WAREHOUSE_YARD_TYPES,
WAREHOUSE_ZONE_TYPES,
@@ -73,6 +74,7 @@ export const yardsForBooking = (
};
export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES);
+export const warehouseFreightTypeOptions = toOptions(WAREHOUSE_FREIGHT_TYPES);
export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES);
export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES);
export const statusOptions = toOptions(WAREHOUSE_STATUSES);
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/slot-picker.test.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/slot-picker.test.ts
new file mode 100644
index 000000000..ea2cbd984
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/slot-picker.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest';
+
+import { fillableLevels } from './SlotPicker';
+import type { SlotEffectiveStatus, ZoneLayout } from '@/types/warehouse';
+
+/**
+ * The picker must offer only positions the API will accept, or the operator
+ * gets a 400 for a level the form itself suggested.
+ */
+function layout(
+ stacks: Array<{
+ code: string;
+ height?: number;
+ isActive?: boolean;
+ levels: SlotEffectiveStatus[];
+ }>,
+): ZoneLayout {
+ return {
+ zoneId: 'zone-1',
+ zoneCode: 'L1-O-A-ZA',
+ zoneName: 'Zone A',
+ summary: {
+ configuredCapacity: 60,
+ physicalSlotCount: 60,
+ occupiedSlotCount: 0,
+ reservedSlotCount: 0,
+ blockedSlotCount: 0,
+ inactiveSlotCount: 0,
+ availableSlotCount: 60,
+ inconsistent: false,
+ },
+ stacks: stacks.map((stack) => ({
+ stackId: `id-${stack.code}`,
+ code: stack.code,
+ name: null,
+ maxStackHeight: stack.height ?? 3,
+ status: stack.isActive === false ? 'INACTIVE' : 'ACTIVE',
+ isActive: stack.isActive !== false,
+ // The API returns the highest level first — mirror that here.
+ slots: stack.levels
+ .map((effectiveStatus, index) => ({
+ slotId: `${stack.code}-L${index + 1}`,
+ level: index + 1,
+ effectiveStatus,
+ inventoryId: effectiveStatus === 'OCCUPIED' ? `inv-${stack.code}-${index + 1}` : null,
+ containerNumber: null,
+ }))
+ .reverse(),
+ })),
+ };
+}
+
+describe('SlotPicker.fillableLevels', () => {
+ it('offers the ground level of an empty stack', () => {
+ const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] }]));
+ expect(options).toEqual([{ value: 'ZA-001-L1', label: 'ZA-001 — level 1 (ground)' }]);
+ });
+
+ it('offers only the level directly above the top container', () => {
+ const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'AVAILABLE', 'AVAILABLE'] }]));
+ expect(options).toEqual([{ value: 'ZA-001-L2', label: 'ZA-001 — level 2 (on 1 container)' }]);
+ });
+
+ it('never offers a level that would float over an empty one', () => {
+ const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'OCCUPIED', 'AVAILABLE'] }]));
+ expect(options.map((o) => o.value)).toEqual(['ZA-001-L3']);
+ });
+
+ it('drops a full stack', () => {
+ expect(fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'OCCUPIED', 'OCCUPIED'] }]))).toEqual([]);
+ });
+
+ it('drops a stack whose next level is blocked or reserved', () => {
+ expect(fillableLevels(layout([{ code: 'ZA-001', levels: ['BLOCKED', 'AVAILABLE', 'AVAILABLE'] }]))).toEqual([]);
+ expect(fillableLevels(layout([{ code: 'ZA-002', levels: ['RESERVED', 'AVAILABLE', 'AVAILABLE'] }]))).toEqual([]);
+ });
+
+ it('drops an inactive stack', () => {
+ expect(
+ fillableLevels(layout([{ code: 'ZA-001', isActive: false, levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] }])),
+ ).toEqual([]);
+ });
+
+ it('lists one position per stack across the zone', () => {
+ const options = fillableLevels(
+ layout([
+ { code: 'ZA-001', levels: ['OCCUPIED', 'AVAILABLE', 'AVAILABLE'] },
+ { code: 'ZA-002', levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] },
+ ]),
+ );
+ expect(options.map((o) => o.value)).toEqual(['ZA-001-L2', 'ZA-002-L1']);
+ });
+
+ it('returns nothing before the layout has loaded', () => {
+ expect(fillableLevels(undefined)).toEqual([]);
+ });
+});
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/useCompanyOptions.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/useCompanyOptions.ts
new file mode 100644
index 000000000..2c80535b7
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/useCompanyOptions.ts
@@ -0,0 +1,37 @@
+import { useQuery } from "@tanstack/react-query";
+
+import { customersService } from "@/services/customers.service";
+
+/**
+ * Registered customer companies, as Autocomplete options. The picker is an
+ * Autocomplete rather than a Select on purpose: a company that is not on the
+ * system yet is typed in, and only the name is kept.
+ */
+export function useCompanyOptions() {
+ const { data, isLoading } = useQuery({
+ queryKey: ["companies-autocomplete"],
+ queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const companies = data?.items ?? [];
+
+ // Company names are not unique — Mantine throws on duplicate option values,
+ // so the list is deduped by the trimmed name.
+ const names = [...new Set(companies.map((c) => c.name.trim()).filter(Boolean))];
+
+ return {
+ loading: isLoading,
+ names,
+ /**
+ * Name → company id, only when exactly one company carries that name. An
+ * ambiguous name resolves to nothing rather than to an arbitrary company:
+ * the container keeps the typed name and no wrong customer is attached.
+ */
+ resolveId: (name: string): string | undefined => {
+ const key = name.trim().toLowerCase();
+ const matches = companies.filter((c) => c.name.trim().toLowerCase() === key);
+ return matches.length === 1 ? matches[0].id : undefined;
+ },
+ };
+}
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 66a55b555..0ee03a626 100644
--- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
@@ -161,6 +161,8 @@ export const QUERY_KEYS = {
["train-scheduling", "schedules", filters ?? {}] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
track: (id: string) => ["train-scheduling", "track", id] as const,
+ marshallingStops: (id: string) =>
+ ["train-scheduling", "marshalling-stops", id] as const,
batchBoard: (filters?: unknown) =>
["train-scheduling", "batch-board", "list", filters ?? {}] as const,
batchBoardDetail: (scheduleId: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 3fc6e53c0..32eb6b798 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -537,6 +537,10 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/export/load-list/document`,
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
+ MARSHALLING_STOPS: (id: string) =>
+ `/train-scheduling/schedules/${id}/marshalling/stops`,
+ MARSHALLING_DOCUMENT_AT: (id: string, stopIndex: number) =>
+ `/train-scheduling/schedules/${id}/marshalling/document/${stopIndex}`,
CHECKPOINTS: (id: string) =>
`/train-scheduling/schedules/${id}/checkpoints`,
CHECKPOINT: (id: string, sequenceNo: number) =>
@@ -648,6 +652,16 @@ export const URL_CONSTANTS = {
WAREHOUSE_ZONES: {
BASE: "/warehouse-zones",
BY_ID: (id: string) => `/warehouse-zones/${id}`,
+ LAYOUT: (id: string) => `/warehouse-zones/${id}/layout`,
+ SLOT_SUMMARY: (id: string) => `/warehouse-zones/${id}/slot-summary`,
+ },
+
+ WAREHOUSE_ZONE_STACKS: {
+ BASE: "/warehouse-zone-stacks",
+ BY_ZONE: (zoneId: string) => `/warehouse-zone-stacks?zoneId=${zoneId}`,
+ BY_ID: (id: string) => `/warehouse-zone-stacks/${id}`,
+ OCCUPANCY: (id: string) => `/warehouse-zone-stacks/${id}/occupancy`,
+ SLOT: (slotId: string) => `/warehouse-zone-stacks/slots/${slotId}`,
},
WAREHOUSE_INVENTORY: {
@@ -662,6 +676,10 @@ export const URL_CONSTANTS = {
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
+ FIND_SLOT: "/warehouse-inventory/placement/find-slot",
+ ASSIGN_SLOT: (id: string) => `/warehouse-inventory/${id}/assign-slot`,
+ RELEASE_SLOT: (id: string) => `/warehouse-inventory/${id}/release-slot`,
+ ACCESSIBILITY: (id: string) => `/warehouse-inventory/${id}/accessibility`,
RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
OPS_STATS: "/warehouse-inventory/ops-stats",
@@ -703,6 +721,8 @@ export const URL_CONSTANTS = {
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
: `/warehouse-inventory/eligible-bookings`,
RECEIVE_BULK: "/warehouse-inventory/receive-bulk",
+ REGISTER_BACKLOG: "/warehouse-inventory/register-backlog",
+ REGISTER_BACKLOG_BULK: "/warehouse-inventory/register-backlog-bulk",
LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export",
BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
RECEIVED_EXPORT: "/warehouse-inventory/received-export",
@@ -791,6 +811,7 @@ export const URL_CONSTANTS = {
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
`/import-operations/customs/${bookingId}/release-permitted`,
EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns",
+ EMPTY_CONTAINER_RETURNS_BULK: "/import-operations/empty-container-returns/bulk",
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
`/import-operations/empty-container-returns/${id}/status`,
EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN:
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
index bf19542b4..6ed13636a 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
@@ -82,6 +82,14 @@ export function useUpdateWarehouse() {
});
}
+export function useDeleteWarehouse() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => warehouseService.remove(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
+ });
+}
+
// ── Yards ────────────────────────────────────────────────────────────────
export function useWarehouseYards(warehouseId?: string) {
diff --git a/apps/edr-freight-web/backoffice/src/lib/format.ts b/apps/edr-freight-web/backoffice/src/lib/format.ts
index 4c5c6b639..1c8e66bb3 100644
--- a/apps/edr-freight-web/backoffice/src/lib/format.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/format.ts
@@ -60,3 +60,10 @@ export function formatBytes(bytes: number): string {
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
+
+/** `YYYY-MM-DDTHH:mm` for now, in local time — what `datetime-local` expects. */
+export function localNowForInput(): string {
+ const d = new Date();
+ d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
+ return d.toISOString().slice(0, 16);
+}
diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
index b3d721eb5..38af63785 100644
--- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
@@ -307,6 +307,7 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:warehouse_zones:view",
create: "edr_freight_app:warehouse_zones:create",
update: "edr_freight_app:warehouse_zones:update",
+ delete: "edr_freight_app:warehouse_zones:delete",
},
warehouseAllocationRules: {
view: "edr_freight_app:warehouse_allocation_rules:view",
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
index 758f8a7bb..a277d0570 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
@@ -140,6 +140,25 @@ export default function BookingRequestsPage() {
[refData],
);
+ // Content options mirror the booking wizard's cargo picker: the group itself
+ // — which the server expands to every commodity beneath it — then each
+ // commodity, labelled by its full path so a generically-named leaf still
+ // reads unambiguously. A group with no descendants is emitted by the
+ // reference-data tree as its own single child; drop that duplicate.
+ const cargoTypeOptions = useMemo(
+ () =>
+ (refData?.cargo_type ?? []).flatMap((group) => [
+ { value: group.id, label: group.name },
+ ...(group.children ?? [])
+ .filter((child) => child.id !== group.id)
+ .map((child) => ({
+ value: child.id,
+ label: `${group.name} → ${child.name}`,
+ })),
+ ]),
+ [refData],
+ );
+
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
// the header's document-review alarm opens exactly the undecided requests
// it is counting down for. No sync effect needed any more: controls.values
@@ -147,6 +166,15 @@ export default function BookingRequestsPage() {
// already mounted just works, and every filter — direction included —
// auto-pins its own pill the moment it has a value (FilterBar's `secondary`
// split), so a deep link can never land behind "More filters" unseen.
+ // Container types, flattened out of the reference data's size groups.
+ const containerTypeOptions = useMemo(
+ () =>
+ (refData?.containers ?? []).flatMap((group) =>
+ group.types.map((t) => ({ value: t.id, label: t.name || t.code })),
+ ),
+ [refData],
+ );
+
const bookingFilterDefs: FilterDef[] = useMemo(
() => [
{
@@ -183,6 +211,65 @@ export default function BookingRequestsPage() {
multiple: false,
options: FREIGHT_TYPE_OPTIONS,
},
+ {
+ // Cargo group or commodity. The group row matches its whole subtree
+ // server-side, so "Bulk" returns every bulk commodity under it.
+ key: "cargoTypeId",
+ label: "Content",
+ type: "enum",
+ multiple: false,
+ options: cargoTypeOptions,
+ },
+ {
+ // Containers carry no customer-written description, so this is also how
+ // they are reached: it matches container types ("40FT") as well as the
+ // commodity name and the bulk cargo description.
+ key: "cargoText",
+ label: "Content contains",
+ type: "text",
+ secondary: true,
+ placeholder: "Commodity, description or container type",
+ },
+ {
+ key: "containerTypeId",
+ label: "Container type",
+ type: "enum",
+ multiple: false,
+ options: containerTypeOptions,
+ secondary: true,
+ },
+ {
+ // Counts boxes. Scoped to the container-type filter when one is set, so
+ // this one control answers "10 containers" and "10 forty-footers" both.
+ key: "containers",
+ label: "Containers",
+ type: "number",
+ secondary: true,
+ operators: ["is", "between"],
+ toParams: (v) =>
+ v.op === "between"
+ ? { containersMin: v.v[0], containersMax: v.v[1] }
+ : { containersMin: v.v[0], containersMax: v.v[0] },
+ },
+ {
+ // Declared on the shipment request, not yet on the booking. Pair it
+ // with Containers = 0 to find the set awaiting completion.
+ key: "requestedContainers",
+ label: "Requested containers",
+ type: "number",
+ secondary: true,
+ operators: ["is", "between"],
+ toParams: (v) =>
+ v.op === "between"
+ ? {
+ requestedContainersMin: v.v[0],
+ requestedContainersMax: v.v[1],
+ }
+ : {
+ requestedContainersMin: v.v[0],
+ requestedContainersMax: v.v[0],
+ },
+ },
{
key: "serviceTypeId",
label: "Service",
@@ -244,7 +331,13 @@ export default function BookingRequestsPage() {
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
},
],
- [filterOptions, yardOptions, serviceTypeOptions],
+ [
+ filterOptions,
+ yardOptions,
+ serviceTypeOptions,
+ cargoTypeOptions,
+ containerTypeOptions,
+ ],
);
const controls = useFilters(bookingFilterDefs, {
diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx
index 8b28fd71a..2b319de30 100644
--- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx
@@ -19,8 +19,10 @@ import { ExportButton } from "@/components/export/ExportButton";
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
import { api } from "@/services/api";
import {
+ INVOICE_TYPE_OPTIONS,
PAYMENT_METHOD_OPTIONS,
invoicePaymentMethod,
+ invoiceTypeLabel,
paymentMethodLabel,
type Invoice,
type InvoiceListFilter,
@@ -55,6 +57,7 @@ const EIMS_STATUS_OPTIONS = [
const INVOICE_FILTER_DEFS: FilterDef[] = [
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{ key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS },
+ { key: "types", label: "Type", type: "enum", options: INVOICE_TYPE_OPTIONS },
{
key: "currency",
label: "Currency",
@@ -261,6 +264,15 @@ export default function InvoicesPanel() {
size: 220,
cell: ({ row }) => ,
},
+ {
+ id: "type",
+ header: "Type",
+ cell: ({ row }) => (
+
+ {invoiceTypeLabel(row.original.type)}
+
+ ),
+ },
{
id: "status",
header: "Status",
@@ -389,7 +401,7 @@ export default function InvoicesPanel() {
-
+ (
+
+ {invoiceTypeLabel(row.original.type)}
+
+ ),
+ },
{
id: "status",
header: "Status",
@@ -523,7 +541,7 @@ export default function UsdPaymentsPanel({
-
+
- trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
+ mutationFn: (stopIndex?: number) =>
+ stopIndex != null
+ ? trainSchedulingService.downloadMarshallingDocumentAt(scheduleId ?? "", stopIndex)
+ : trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
});
- const openIntercityMarshalling = async () => {
+ const openIntercityMarshalling = async (stopIndex?: number) => {
const pdfWindow = window.open("", "_blank");
try {
- const blob = await intercityMarshalling.mutateAsync();
- const opened = openPdfBlob(blob, `intercity-marshalling-${scheduleId}.pdf`, pdfWindow);
+ const blob = await intercityMarshalling.mutateAsync(stopIndex);
+ const filename =
+ stopIndex != null ? `marshalling-${stopIndex}-${scheduleId}.pdf` : `intercity-marshalling-${scheduleId}.pdf`;
+ const opened = openPdfBlob(blob, filename, pdfWindow);
toast({
- title: "Intercity marshalling ready",
+ title: stopIndex != null ? `Marshalling ${stopIndex} ready` : "Intercity marshalling ready",
description: opened
? "The PDF opened in a browser tab for printing or saving."
: "The browser blocked the preview tab, so the PDF was downloaded.",
@@ -313,7 +329,7 @@ export default function TrainScheduleTrackPage() {
- {inTransit || arrived ? (
+ {(inTransit || arrived) && marshallingStops.length === 0 ? (
) : null}
+ {(inTransit || arrived) && marshallingStops.length > 0 ? (
+
+
+
+ {marshallingStops.map((stop) => (
+ void openIntercityMarshalling(stop.stopIndex)}
+ >
+ {`Marshalling ${stop.stopIndex} — ${stop.yardLabel}`}
+
+ ))}
+
+
+ ) : null}
{/* ── Two-column work surface ── */}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
index 94da4837e..26d9d7f88 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
@@ -257,13 +257,34 @@ export default function TrainScheduleV2DetailPage() {
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
const downloadMarshalling = useMutation({
- mutationFn: ({ id, direction, variant }: { id: string; direction?: string | null; variant?: "INTERCITY" }) =>
- variant === "INTERCITY"
- ? trainSchedulingService.downloadIntercityMarshallingDocument(id)
- : direction === "EXPORT"
- ? trainSchedulingService.downloadExportLoadListDocument(id)
- : trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
+ mutationFn: ({
+ id,
+ direction,
+ variant,
+ stopIndex,
+ }: {
+ id: string;
+ direction?: string | null;
+ variant?: "INTERCITY";
+ stopIndex?: number;
+ }) =>
+ stopIndex != null
+ ? trainSchedulingService.downloadMarshallingDocumentAt(id, stopIndex)
+ : variant === "INTERCITY"
+ ? trainSchedulingService.downloadIntercityMarshallingDocument(id)
+ : direction === "EXPORT"
+ ? trainSchedulingService.downloadExportLoadListDocument(id)
+ : trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
});
+ // Numbered marshalling docs (Marshalling 2, 3, 4…) — one per corridor stop
+ // that actually coupled/uncoupled something. Empty when nothing has yet.
+ const marshallingStopsQuery = useQuery(
+ api.trainScheduling.marshallingStops.queryOptions({
+ input: { id: scheduleId ?? "" },
+ enabled: Boolean(scheduleId) && ["DISPATCHED", "ARRIVED"].includes(schedule?.status ?? ""),
+ }),
+ );
+ const marshallingStops = marshallingStopsQuery.data ?? [];
useEffect(() => {
const operation = gatepassQuery.data;
@@ -506,6 +527,7 @@ export default function TrainScheduleV2DetailPage() {
successDescription?: string;
errorTitle?: string;
variant?: "INTERCITY";
+ stopIndex?: number;
}) => {
const pdfWindow = window.open("", "_blank");
try {
@@ -513,13 +535,16 @@ export default function TrainScheduleV2DetailPage() {
id: scheduleId,
direction: schedule.direction,
variant: options?.variant,
+ stopIndex: options?.stopIndex,
});
const prefix =
- options?.variant === "INTERCITY"
- ? "intercity-marshalling"
- : schedule.direction === "EXPORT"
- ? "export-marshalling"
- : "import-marshalling";
+ options?.stopIndex != null
+ ? `marshalling-${options.stopIndex}`
+ : options?.variant === "INTERCITY"
+ ? "intercity-marshalling"
+ : schedule.direction === "EXPORT"
+ ? "export-marshalling"
+ : "import-marshalling";
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
toast({
@@ -1108,7 +1133,7 @@ export default function TrainScheduleV2DetailPage() {
Marshalling PDF
) : null}
- {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
+ {["DISPATCHED", "ARRIVED"].includes(schedule.status) && marshallingStops.length === 0 ? (
}
disabled={downloadMarshalling.isPending}
@@ -1122,6 +1147,25 @@ export default function TrainScheduleV2DetailPage() {
Intercity Marshalling
) : null}
+ {/* One item per corridor stop that actually coupled/uncoupled
+ something (Marshalling 2, 3, 4…) — replaces the single
+ "current position" item once anything has happened. */}
+ {marshallingStops.map((stop) => (
+ }
+ disabled={downloadMarshalling.isPending}
+ onClick={() =>
+ void openMarshallingDocument({
+ title: `Marshalling ${stop.stopIndex} ready`,
+ successDescription: `${stop.yardLabel} — coupled/uncoupled wagons included.`,
+ stopIndex: stop.stopIndex,
+ })
+ }
+ >
+ {`Marshalling ${stop.stopIndex} — ${stop.yardLabel}`}
+
+ ))}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
+ {schedule.status === "CANCELLED" && schedule.cancellationReason ? (
+ }
+ title="This schedule was cancelled"
+ >
+ {schedule.cancellationReason}
+
+ ) : null}
+
{/* Ops signage: Train No. / Voyage No. / Direction read at a glance from
across the room, so these stay large rather than folding into the
numeric KpiStrip below. */}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx
index 8041ad3ad..f9857fa5b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx
@@ -17,6 +17,7 @@ import {
Stack,
Switch,
Text,
+ Textarea,
TextInput,
ThemeIcon,
} from "@mantine/core";
@@ -150,6 +151,9 @@ export default function TrainScheduleV2ListPage() {
const [dispatchAt, setDispatchAt] = useState(null);
// Cancelling is likewise irreversible — confirmed before the mutation fires.
const [cancelTarget, setCancelTarget] = useState(null);
+ // Required: the reason is stored on the schedule and shown wherever the
+ // cancelled train appears, so staff downstream know why it died.
+ const [cancelReason, setCancelReason] = useState("");
const [editDateSchedule, setEditDateSchedule] = useState(null);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
@@ -823,7 +827,10 @@ export default function TrainScheduleV2ListPage() {
confirmed here rather than firing straight from the row menu. */}
setCancelTarget(null)}
+ onClose={() => {
+ setCancelTarget(null);
+ setCancelReason("");
+ }}
title="Cancel this schedule?"
centered
radius="md"
@@ -842,23 +849,43 @@ export default function TrainScheduleV2ListPage() {
another schedule.
) : null}
+
);
}
@@ -1041,6 +1073,15 @@ function ScheduleCard({
+ {schedule.status === "CANCELLED" && schedule.cancellationReason ? (
+
+
+ Cancelled:
+ {" "}
+ {schedule.cancellationReason}
+
+ ) : null}
+
("all");
const [returnModalOpen, setReturnModalOpen] = useState(false);
const [standaloneModalOpen, setStandaloneModalOpen] = useState(false);
+ const [bulkModalOpen, setBulkModalOpen] = useState(false);
const [activeKey, setActiveKey] = useState(null);
const [historyRow, setHistoryRow] = useState(null);
const [allocateRow, setAllocateRow] = useState(null);
@@ -287,11 +293,14 @@ export default function ContainerReturnsPage() {
bookingId: string;
customerId: string | null;
returnType: "EDR" | "CUSTOMER";
+ companyName?: string;
containers: Array<{
containerNumber: string;
containerSize?: EmptyContainerSize;
returnDate: string;
warehouse: string;
+ yard?: string;
+ zone?: string;
condition?: string;
handoverNote?: string;
}>;
@@ -306,7 +315,10 @@ export default function ContainerReturnsPage() {
returnDate: new Date(container.returnDate).toISOString(),
bookingId: truck.bookingId,
customerId: truck.customerId ?? undefined,
+ companyName: truck.companyName,
facility: container.warehouse,
+ yard: container.yard,
+ zone: container.zone,
condition: container.condition,
handoverNote: container.handoverNote,
returnedBy: truck.returnType,
@@ -319,7 +331,9 @@ export default function ContainerReturnsPage() {
onSuccess: () => {
toast({ title: "Container returns recorded" });
qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] });
+ qc.invalidateQueries({ queryKey: ["empty-container-returns"] });
setReturnModalOpen(false);
+ setStandaloneModalOpen(false);
setActiveKey(null);
},
onError: (error: any) => {
@@ -372,9 +386,15 @@ export default function ContainerReturnsPage() {
),
},
{
- id: "bookingRef",
- header: "Booking Ref",
- cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"),
+ id: "company",
+ header: "Company",
+ // One identity column: who the box belongs to, and the booking it came
+ // back on. A standalone return has no booking, so only the name shows.
+ cell: ({ row }) => {
+ const { companyName, bookingReference } = row.original;
+ if (!companyName) return bookingReference || "—";
+ return bookingReference ? `${companyName} (${bookingReference})` : companyName;
+ },
},
{
id: "returnedBy",
@@ -390,9 +410,8 @@ export default function ContainerReturnsPage() {
},
{
id: "returnDate",
- header: "Returned Date",
- cell: ({ row }) =>
- row.original.returnDate ? new Date(row.original.returnDate).toLocaleDateString() : "—",
+ header: "Returned Date & Time",
+ cell: ({ row }) => formatDateTime(row.original.returnDate),
},
{
id: "facility",
@@ -510,9 +529,23 @@ export default function ContainerReturnsPage() {
{ label: "Customer Self-Haul", value: "customer" },
]}
/>
- setStandaloneModalOpen(true)}>
- Record Return
-
+
+ }
+ onClick={() => downloadContainerReturnTemplate()}
+ >
+ Download Template
+
+ }
+ onClick={() => setBulkModalOpen(true)}
+ >
+ Bulk Upload
+
+ setStandaloneModalOpen(true)}>Record Return
+
{returnedContainers.length > 0 && (
@@ -688,6 +721,12 @@ export default function ContainerReturnsPage() {
loading={createReturnsMutation.isPending}
/>
+ setBulkModalOpen(false)}
+ onUploaded={() => qc.invalidateQueries({ queryKey: ["empty-container-returns"] })}
+ />
+
setAllocateRow(null)}
@@ -848,7 +887,7 @@ interface ContainerReturnModalProps {
function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: ContainerReturnModalProps) {
const [selectedContainers, setSelectedContainers] = useState([]);
- const [returnDate, setReturnDate] = useState(new Date().toISOString().split("T")[0]);
+ const [returnDate, setReturnDate] = useState(localNowForInput());
const [warehouse, setWarehouse] = useState(null);
const [condition, setCondition] = useState("");
const [handoverNote, setHandoverNote] = useState("");
@@ -940,13 +979,20 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
searchable
/>
- setReturnDate(e.target.value)}
- style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }}
- required
- />
+
+ setReturnDate(e.target.value)}
+ style={{
+ padding: "8px",
+ borderRadius: "4px",
+ border: "1px solid #ced4da",
+ width: "100%",
+ }}
+ required
+ />
+
) : (
- renderCoachSeats(selectedCoachData, isBedCoach)
+
)}
diff --git a/apps/edr-passenger-web/portal/src/components/SeatMap.tsx b/apps/edr-passenger-web/portal/src/components/SeatMap.tsx
new file mode 100644
index 000000000..94fbe7c98
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/components/SeatMap.tsx
@@ -0,0 +1,706 @@
+"use client";
+
+import { memo } from "react";
+import Image from "next/image";
+import { Armchair, Bed } from "lucide-react";
+
+/**
+ * The seat map shared by the booking flow (`/booking/seats`) and the reschedule flow
+ * (`/booking/reschedule`). Everything here is presentational: it takes a coach from
+ * `GET /seats/seatmap/:scheduleId` and three callbacks, and knows nothing about bookings,
+ * passengers, holds or fares. Both pages must render seats identically, so this is the one
+ * copy — extend it rather than forking a second layout.
+ */
+
+export const BED_POSITION_SUFFIX: Record = {
+ lower: "L",
+ middle: "M",
+ upper: "U",
+};
+
+export const buildSeatLabel = (seat: any): string => {
+ const base: string = seat.number || seat.label || seat.seatNumber || "";
+ if (!base) return "";
+ const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? "") : "";
+ return suffix ? `${base}${suffix}` : base;
+};
+
+/** "Economy Bed - Upper" → "upper". Null when the class names no berth level. */
+export const getBedPosition = (selectedClass: string): string | null => {
+ const lowerClass = selectedClass.toLowerCase();
+ if (lowerClass.includes("upper")) return "upper";
+ if (lowerClass.includes("middle")) return "middle";
+ if (lowerClass.includes("lower")) return "lower";
+ return null;
+};
+
+export const isBedCoachData = (coachData: any): boolean =>
+ coachData?.isBedCoach === true ||
+ coachData?.rooms?.length > 0 ||
+ (coachData?.seats || []).some((s: any) => s.bedPosition) ||
+ coachData?.seatClass?.toLowerCase().includes("bed") ||
+ coachData?.mode?.toLowerCase().includes("bed");
+
+/**
+ * Flattens a coach into the seats that are actually selectable: beds out of `rooms` when the
+ * coach has them, otherwise `seats`. Placeholder rows (labels starting "-") are dropped, and
+ * on a bed coach a berth-specific fare class narrows the list to that level.
+ */
+export const getValidSeatsForCoach = (
+ coachData: any,
+ selectedSeatClass?: string | null,
+): any[] => {
+ if (!coachData) return [];
+
+ if (coachData.rooms?.length > 0) {
+ const allBeds: any[] = [];
+ coachData.rooms.forEach((room: any) => {
+ if (room.beds) allBeds.push(...room.beds);
+ });
+
+ let beds = allBeds.filter((s: any) => {
+ const seatLabel = s.label || s.number || s.seatNumber || "";
+ return seatLabel && !seatLabel.startsWith("-");
+ });
+
+ if (isBedCoachData(coachData) && selectedSeatClass) {
+ const selectedBedPosition = getBedPosition(selectedSeatClass);
+ if (selectedBedPosition) {
+ beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition);
+ }
+ }
+ return beds;
+ }
+
+ let seats = (coachData.seats || []).filter((s: any) => {
+ const seatLabel = s.label || s.number || s.seatNumber || "";
+ return seatLabel && !seatLabel.startsWith("-");
+ });
+
+ if (isBedCoachData(coachData) && selectedSeatClass) {
+ const selectedBedPosition = getBedPosition(selectedSeatClass);
+ if (selectedBedPosition) {
+ seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
+ }
+ }
+ return seats;
+};
+
+/** "3+2" → [3, 2] so the aisle gap lands between the groups. Bed coaches collapse to one column. */
+export const parseSeatArrangement = (
+ arrangement: string | null,
+ seatClasses?: (string | undefined)[],
+): number[] => {
+ if (!arrangement) return [2, 2];
+
+ const isBedCoach = seatClasses?.some((sc) => sc?.toLowerCase().includes("bed"));
+
+ if (isBedCoach) {
+ // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically —
+ // render them as a single column.
+ const parts = arrangement
+ .split("+")
+ .map((p) => parseInt(p.trim()))
+ .filter((n) => !isNaN(n) && n > 0);
+ return parts.length > 0 ? [Math.max(...parts)] : [3];
+ }
+
+ const parts = arrangement
+ .split("+")
+ .map((p) => parseInt(p.trim()))
+ .filter((n) => !isNaN(n) && n > 0);
+ return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2];
+};
+
+export const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => {
+ const seatLabel = bed.label || bed.seatNumber || bed.number || "?";
+ const bedPosition = bed.bedPosition || "";
+ const bedType =
+ bedPosition === "upper" ? "Upper" : bedPosition === "middle" ? "Middle" : "Lower";
+ const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther;
+
+ return (
+ onToggle(bed.id)}
+ disabled={isDisabled}
+ title={
+ isAssignedToOther
+ ? `Bed ${seatLabel} - already assigned to another passenger`
+ : `${bedType} Berth ${seatLabel} - ${bed.status}`
+ }
+ className={`relative flex flex-col items-center justify-center gap-0.5 w-16 sm:w-[4.5rem] py-2.5 rounded-xl border shadow-sm transition-all duration-150 ${
+ isDisabled ? "" : "hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 active:scale-95"
+ } ${
+ isSelected
+ ? "bg-blue-50 border-2 border-blue-500 shadow-blue-200/60 dark:bg-blue-900/30 dark:border-blue-400 dark:shadow-none scale-[1.03]"
+ : isAssignedToOther
+ ? "bg-purple-50 border-purple-300 cursor-not-allowed dark:bg-purple-900/20 dark:border-purple-700"
+ : bed.status === "AVAILABLE"
+ ? "bg-green-50 border-green-300 hover:bg-green-100 hover:border-green-400 dark:bg-green-900/20 dark:border-green-700"
+ : bed.status === "BOOKED" || bed.status === "BLOCKED"
+ ? "bg-red-50 border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700"
+ : "bg-gray-100 border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
+ }`}
+ >
+ {/* bed.png is a portrait (headboard-to-footboard) silhouette; rotate it so the
+ berth lies horizontally, matching the direction beds actually run in the coach. */}
+
+
+
+
{seatLabel}
+
{bedType}
+
+ );
+});
+
+BedCard.displayName = "BedCard";
+
+// A real berth ladder is a single fixed rail mounted at the end of the bay that a
+// passenger climbs to reach every level — not a separate rung floating between each
+// pair of beds. So this renders once per bay, right after the last berth card, with
+// solid rounded rails/rungs (like a real metal ladder) rather than thin decorative lines.
+export const LadderConnector = memo(() => (
+
+ );
+
+ // Two-side compartment: the left bay and right bay each get their own row (berths
+ // still laid out horizontally within a row), stacked one above the other and split
+ // by a dashed aisle divider — instead of squeezing both sides into a single row.
+ const renderCompartment = (leftBay: any[], rightBay: any[], key: string) => (
+