diff --git a/.gitmodules b/.gitmodules index 80eabfb57..e69de29bb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +0,0 @@ -[submodule "user-management"] - path = user-management - url = git@github.com:Tria-plc/iamui.git -[submodule "apps/edr-freight-web/backoffice/user-management"] - path = apps/edr-freight-web/backoffice/user-management - url = git@github.com:Tria-plc/iamui.git diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 115d3624a..897a7b764 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -36,8 +36,8 @@ "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", - "@tria-plc/api-common": "^1.4.3", - "@tria-plc/iamapi-common": "^0.6.6", + "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", "axios": "^1.16.1", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index eca814b6d..3a96e7c77 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -48,6 +48,10 @@ import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; +import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; +import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; +import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; +import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; //New Trains, Wagons, Container and Cargo management modules @@ -131,6 +135,10 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module'; DemoFreightDataSeeder, IndodeFacilitySeeder, Batch14TestDataSeeder, + Batch5TestDataSeeder, + Batch7TestDataSeeder, + Batch8TestDataSeeder, + WarehouseDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -139,6 +147,14 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, + private readonly pricingDataSeeder: PricingDataSeeder, + private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + private readonly indodeFacilitySeeder: IndodeFacilitySeeder, + private readonly batch14TestDataSeeder: Batch14TestDataSeeder, + private readonly batch5TestDataSeeder: Batch5TestDataSeeder, + private readonly batch7TestDataSeeder: Batch7TestDataSeeder, + private readonly batch8TestDataSeeder: Batch8TestDataSeeder, + private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, ) { } @@ -149,6 +165,16 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.demoUsersSeeder.run(); await this.freightStaffUsersSeeder.run(); + await this.pricingDataSeeder.run(); + await this.fileUploadSettingsSeeder.run(); + await this.indodeFacilitySeeder.run(); + await this.batch14TestDataSeeder.run(); + await this.batch5TestDataSeeder.run(); + await this.batch7TestDataSeeder.run(); + await this.batch8TestDataSeeder.run(); + await this.warehouseDemoSeeder.run(); + // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. + // Each block self-guards on an empty-table check, so this is safe every boot. // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, // FileUploadSettingsSeeder) are intentionally disabled — they stay // registered as providers but are not run. Re-inject + call .run() to enable. diff --git a/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts similarity index 97% rename from apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts rename to apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts index 05b1259f4..fa6087faa 100644 --- a/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts +++ b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts @@ -2,9 +2,9 @@ import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm'; /** * Batch 5 — warehouse allocation rules, storage/demurrage fee rules, - * and demurrage lifecycle timestamps on inventory. + * and demurrage lifecycle timestamps on inventory. Idempotent. */ -export class AddWarehouseAllocationAndFeeRules1790000000000 implements MigrationInterface { +export class AddWarehouseAllocationAndFeeRules1791000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ diff --git a/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts b/apps/edr-freight-api/src/migrations/1791000000001-AddWarehouseFeeInvoices.ts similarity index 96% rename from apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts rename to apps/edr-freight-api/src/migrations/1791000000001-AddWarehouseFeeInvoices.ts index c34eb240a..662a35739 100644 --- a/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1791000000001-AddWarehouseFeeInvoices.ts @@ -1,7 +1,7 @@ import { MigrationInterface, QueryRunner, Table } from 'typeorm'; -/** Batch 6 — warehouse fee invoices + invoice items. */ -export class AddWarehouseFeeInvoices1790000000001 implements MigrationInterface { +/** Batch 6 — warehouse fee invoices + invoice items. Idempotent (createTable ifNotExists). */ +export class AddWarehouseFeeInvoices1791000000001 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ diff --git a/apps/edr-freight-api/src/migrations/1791000000002-AddImportPickupDeliveryColumns.ts b/apps/edr-freight-api/src/migrations/1791000000002-AddImportPickupDeliveryColumns.ts new file mode 100644 index 000000000..3e272c859 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000002-AddImportPickupDeliveryColumns.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Import pickup branch on warehouse_inventory: + * - release_order_reference: DO / release order number sent to the customer + * - delivered_at: when the goods were handed over (proof of delivery) + * + * Idempotent: the shared dev DB may already carry some of these columns + * (added by another checkout), so only add what is missing. + */ +export class AddImportPickupDeliveryColumns1791000000002 implements MigrationInterface { + private readonly table = 'freight.warehouse_inventory'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn(this.table, 'release_order_reference'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ name: 'release_order_reference', type: 'varchar', length: '100', isNullable: true }), + ); + } + if (!(await queryRunner.hasColumn(this.table, 'delivered_at'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ name: 'delivered_at', type: 'timestamptz', isNullable: true }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasColumn(this.table, 'release_order_reference')) { + await queryRunner.dropColumn(this.table, 'release_order_reference'); + } + if (await queryRunner.hasColumn(this.table, 'delivered_at')) { + await queryRunner.dropColumn(this.table, 'delivered_at'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000003-AddInventoryUnloadedAt.ts b/apps/edr-freight-api/src/migrations/1791000000003-AddInventoryUnloadedAt.ts new file mode 100644 index 000000000..6009d9ea4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000003-AddInventoryUnloadedAt.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Batch 8 — train-arrival unload landing state on warehouse_inventory: + * - unloaded_at: when the goods were unloaded off the arrived train (before storage/inspection) + * + * The `status` column is a free varchar, so the new 'UNLOADED' value needs no schema change. + * Idempotent: the shared dev DB may already carry this column (added by another checkout). + */ +export class AddInventoryUnloadedAt1791000000003 implements MigrationInterface { + private readonly table = 'freight.warehouse_inventory'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn(this.table, 'unloaded_at'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ name: 'unloaded_at', type: 'timestamptz', isNullable: true }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasColumn(this.table, 'unloaded_at')) { + await queryRunner.dropColumn(this.table, 'unloaded_at'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000004-AddFacilityIdToWarehousesFix.ts b/apps/edr-freight-api/src/migrations/1791000000004-AddFacilityIdToWarehousesFix.ts new file mode 100644 index 000000000..df239a375 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000004-AddFacilityIdToWarehousesFix.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from 'typeorm'; + +/** + * Fix: migration 1750000000001 (AddFacilityIdToWarehouses) silently skipped because + * the freight.warehouses table didn't exist yet at that timestamp. The column was + * never added. Add it now with idempotent guards. + */ +export class AddFacilityIdToWarehousesFix1791000000004 implements MigrationInterface { + private readonly table = 'freight.warehouses'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn(this.table, 'facility_id'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ + name: 'facility_id', + type: 'uuid', + isNullable: true, + }), + ); + } + + const table = await queryRunner.getTable(this.table); + const hasFk = table?.foreignKeys.some((fk) => fk.columnNames.includes('facility_id')); + if (!hasFk) { + await queryRunner.createForeignKey( + this.table, + new TableForeignKey({ + columnNames: ['facility_id'], + referencedColumnNames: ['id'], + referencedTableName: 'facilities', + referencedSchema: 'freight', + onDelete: 'SET NULL', + }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable(this.table); + if (!table) return; + + const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.includes('facility_id')); + if (foreignKey) { + await queryRunner.dropForeignKey(this.table, foreignKey); + } + + if (await queryRunner.hasColumn(this.table, 'facility_id')) { + await queryRunner.dropColumn(this.table, 'facility_id'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts new file mode 100644 index 000000000..bfdc813f0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsOptional, IsString, IsUUID } from 'class-validator'; + +/** Bulk-mark received inventory items as inspection PASSED. */ +export class BulkInspectDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('all', { each: true }) + inventoryIds!: string[]; + + @ApiPropertyOptional({ description: 'Inspection type label (e.g. ORIGIN_INSPECTION).' }) + @IsOptional() + @IsString() + inspectionType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + inspectedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts new file mode 100644 index 000000000..9bb734512 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; + +/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */ +export class BulkReceiveDto { + @ApiProperty({ enum: ['IMPORT', 'EXPORT'] }) + @IsIn(['IMPORT', 'EXPORT']) + direction!: 'IMPORT' | 'EXPORT'; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + warehouseId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + zoneId!: string; + + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('all', { each: true }) + bookingIds!: string[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/deliver-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/deliver-inventory.dto.ts new file mode 100644 index 000000000..b2491472d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/deliver-inventory.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString } from 'class-validator'; + +/** Proof of delivery captured when import goods are handed over to the customer. */ +export class DeliverInventoryDto { + @ApiProperty({ description: 'Name of the person who received the goods' }) + @IsString() + receiverName!: string; + + @ApiPropertyOptional({ description: 'When the goods were delivered (defaults to now)' }) + @IsOptional() + @IsDateString() + deliveredAt?: string; + + @ApiPropertyOptional({ description: 'Delivery remarks / notes' }) + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts new file mode 100644 index 000000000..9d4e3eb4f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts @@ -0,0 +1,20 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString } from 'class-validator'; + +/** Records a DO / release order being sent to the customer for import pickup. */ +export class ReleaseOrderDto { + @ApiPropertyOptional({ description: 'DO / release order reference number' }) + @IsOptional() + @IsString() + reference?: string; + + @ApiPropertyOptional({ description: 'Release date (defaults to now)' }) + @IsOptional() + @IsDateString() + releaseDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts index 4521bb808..8df9c0dd8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts @@ -3,12 +3,16 @@ import { Column, Entity, Index } from 'typeorm'; export const WAREHOUSE_ACTIVITY_TYPES = [ 'INVENTORY_RECEIVED', + 'INVENTORY_UNLOADED', 'INVENTORY_STORED', 'INVENTORY_MOVED', 'INVENTORY_RESERVED', 'READY_FOR_LOADING', 'INVENTORY_LOADED', 'INVENTORY_DISPATCHED', + 'READY_FOR_PICKUP', + 'INVENTORY_RELEASED', + 'INVENTORY_DELIVERED', ] as const; export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 6c9270987..a5d4e1eeb 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -8,26 +8,40 @@ import { Warehouse } from './warehouse.entity'; import { WarehouseYard } from './warehouse-yard.entity'; import { WarehouseZone } from './warehouse-zone.entity'; -// Batch 2 lifecycle. Supersedes the Batch 1 set +// Lifecycle. Supersedes the Batch 1 set // (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place. +// After RECEIVED + inspection (PASSED), the flow branches by booking trade direction: +// EXPORT/DOMESTIC: STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED +// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery) export const WAREHOUSE_INVENTORY_STATUSES = [ + 'UNLOADED', 'RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED', + 'READY_FOR_PICKUP', + 'DELIVERED', ] as const; export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number]; /** Allowed forward transitions for the inventory lifecycle. */ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record = { - RECEIVED: ['STORED'], + // UNLOADED = train-arrival landing state (Batch 8). Not yet stored/inspected. + // Mirrors RECEIVED so the import flow can store or go straight to pickup after inspection. + UNLOADED: ['STORED', 'READY_FOR_PICKUP'], + RECEIVED: ['STORED', 'READY_FOR_PICKUP'], STORED: ['RESERVED'], RESERVED: ['READY_FOR_LOADING'], READY_FOR_LOADING: ['LOADED'], LOADED: ['DISPATCHED'], DISPATCHED: [], + // Batch 10: an inspected import item can leave by customer pickup (DELIVERED) or be dispatched + // out by EDR (DISPATCHED) — kept separate — or be put into storage (STORED) if no one collects + // it / customs or inspection hold / operator chooses to store. + READY_FOR_PICKUP: ['DELIVERED', 'STORED', 'DISPATCHED'], + DELIVERED: [], }; @Entity({ schema: 'freight', name: 'warehouse_inventory' }) @@ -104,6 +118,10 @@ export class WarehouseInventory extends BaseEntity { @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) arrivedAt?: Date | null; + // Batch 8: when the goods were unloaded off the arrived train (before storage/inspection). + @Column({ name: 'unloaded_at', type: 'timestamptz', nullable: true }) + unloadedAt?: Date | null; + @Column({ name: 'stored_at', type: 'timestamptz', nullable: true }) storedAt?: Date | null; @@ -135,6 +153,14 @@ export class WarehouseInventory extends BaseEntity { @Column({ name: 'release_date', type: 'timestamptz', nullable: true }) releaseDate?: Date | null; + // Import branch: reference of the DO / release order sent to the customer. + @Column({ name: 'release_order_reference', type: 'varchar', length: 100, nullable: true }) + releaseOrderReference?: string | null; + + // Import branch: when the goods were handed over to the customer (proof of delivery). + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt?: Date | null; + @Column({ name: 'gate_cleared_at', type: 'timestamptz', nullable: true }) gateClearedAt?: Date | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts index 9d27ed64a..267fcc8af 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts @@ -1,5 +1,5 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Facility } from '../../facilities/entities/facility.entity'; import { WarehouseYard } from './warehouse-yard.entity'; @@ -63,6 +63,7 @@ export class Warehouse extends BaseEntity { facilityId?: string | null; @ManyToOne(() => Facility, (facility) => facility.warehouses, { nullable: true }) + @JoinColumn({ name: 'facility_id' }) facility?: Facility | null; @OneToMany(() => WarehouseYard, (yard) => yard.warehouse) diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index de5a791c1..76e1fe892 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; + /** * READ-ONLY view into the train-scheduling / wagons domain for the warehouse module. * @@ -9,6 +11,32 @@ import { DataSource } from 'typeorm'; * It is intentionally decoupled (raw SQL) so it does not import the scheduling * services/entities and cannot accidentally write to them. */ +export interface ImportTrainRow { + scheduleId: string; + trainNumber: string | null; + route: string | null; + origin: string | null; + destination: string | null; + arrivalTime: string | null; + totalBookings: number; + totalContainers: number; + totalCargoes: number; + status: string; +} + +export interface ImportTrainItemRow { + bookingId: string; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + arrivalTime: string | null; + currentStatus: string | null; + lastMileRequested: boolean; + pickupOption: string; +} export interface WagonView { id: string; wagonNumber: string; @@ -115,4 +143,81 @@ export class SchedulingReadFacade { departureStatus: schedule?.status ?? null, }; } + + /** + * ARRIVED train schedules whose route is IMPORT (origin country = Djibouti), with per-train + * booking/container/cargo counts. Direction is derived from the origin/destination station + * countries (route-based), so EXPORT/DOMESTIC trains never appear. Read-only. + */ + async importArriveQueue(): Promise { + const rows: Array< + ImportTrainRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT ts.id AS "scheduleId", + ts.train_number AS "trainNumber", + oy.code AS "origin", + dy.code AS "destination", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", + ts.status, + (SELECT count(*) FROM freight.train_schedule_bookings tsb + WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL) AS "totalBookings", + (SELECT count(*) FROM freight.containers c + JOIN freight.train_schedule_bookings tsbc ON tsbc.booking_id = c.booking_id AND tsbc.deleted_at IS NULL + WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers", + (SELECT count(*) FROM freight.cargoes cg + JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL + WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status = 'ARRIVED' + ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`, + ); + + return rows + .filter( + (r) => + deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT', + ) + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({ + ...rest, + totalBookings: Number(rest.totalBookings) || 0, + totalContainers: Number(rest.totalContainers) || 0, + totalCargoes: Number(rest.totalCargoes) || 0, + route: rest.origin || rest.destination ? `${rest.origin ?? '?'} → ${rest.destination ?? '?'}` : null, + })); + } + + /** Assigned bookings/items for an arrived import train (one row per booking). Read-only. */ + async importTrainDetail(scheduleId: string): Promise { + const rows: ImportTrainItemRow[] = await this.dataSource.query( + `SELECT b.id AS "bookingId", + b.reference AS "bookingReference", + b.company_id AS "customerId", + company.name AS "customerName", + (SELECT c.container_number FROM freight.containers c + WHERE c.booking_id = b.id AND c.deleted_at IS NULL + ORDER BY c.container_number LIMIT 1) AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + b.cargo_total_weight_vgm AS "weight", + COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", + COALESCE(inv.status, b.status) AS "currentStatus", + (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + CASE WHEN b.last_mile_delivery_address IS NOT NULL + THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption" + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + ORDER BY b.reference ASC NULLS LAST`, + [scheduleId], + ); + return rows; + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts index 1bb5b1289..fcc09f668 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, IsNull } from 'typeorm'; import { Warehouse } from './entities/warehouse.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; @@ -8,11 +8,18 @@ export interface WarehouseDashboard { totalWarehouses: number; totalInventory: number; receivedToday: number; + // Inspection gate + awaitingInspection: number; + inspected: number; + // Export branch stored: number; reserved: number; readyForLoading: number; loaded: number; dispatched: number; + // Import branch + readyForPickup: number; + delivered: number; } @Injectable() @@ -26,30 +33,50 @@ export class WarehouseDashboardService { const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0); - const [totalWarehouses, totalInventory, stored, reserved, readyForLoading, loaded, dispatched, receivedToday] = - await Promise.all([ - warehouseRepo.count(), - inventoryRepo.count(), - inventoryRepo.count({ where: { status: 'STORED' } }), - inventoryRepo.count({ where: { status: 'RESERVED' } }), - inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }), - inventoryRepo.count({ where: { status: 'LOADED' } }), - inventoryRepo.count({ where: { status: 'DISPATCHED' } }), - inventoryRepo - .createQueryBuilder('inv') - .where('inv.arrived_at >= :start', { start: startOfToday }) - .getCount(), - ]); - - return { + const [ totalWarehouses, totalInventory, - receivedToday, + awaitingInspection, + inspected, stored, reserved, readyForLoading, loaded, dispatched, + readyForPickup, + delivered, + receivedToday, + ] = await Promise.all([ + warehouseRepo.count(), + inventoryRepo.count(), + inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }), + inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }), + inventoryRepo.count({ where: { status: 'STORED' } }), + inventoryRepo.count({ where: { status: 'RESERVED' } }), + inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }), + inventoryRepo.count({ where: { status: 'LOADED' } }), + inventoryRepo.count({ where: { status: 'DISPATCHED' } }), + inventoryRepo.count({ where: { status: 'READY_FOR_PICKUP' } }), + inventoryRepo.count({ where: { status: 'DELIVERED' } }), + inventoryRepo + .createQueryBuilder('inv') + .where('inv.arrived_at >= :start', { start: startOfToday }) + .getCount(), + ]); + + return { + totalWarehouses, + totalInventory, + receivedToday, + awaitingInspection, + inspected, + stored, + reserved, + readyForLoading, + loaded, + dispatched, + readyForPickup, + delivered, }; } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index ce8a2c188..2699adbef 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -1,11 +1,15 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BulkReceiveDto } from './dto/bulk-receive.dto'; +import { BulkInspectDto } from './dto/bulk-inspect.dto'; +import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; import { SchedulingReadFacade } from './scheduling-read.facade'; @@ -56,6 +60,49 @@ export class WarehouseInventoryController { return this.inventoryService.autoLoadReady(); } + @Get('eligible-bookings') + @ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' }) + eligibleBookings(@Query('direction') direction?: string) { + const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined; + return this.inventoryService.eligibleBookings(dir); + } + + @Post('receive-bulk') + @ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' }) + receiveBulk(@Body() dto: BulkReceiveDto) { + return this.inventoryService.bulkReceive(dto); + } + + @Post('load-passed-export') + @ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' }) + loadPassedExport(@Body('performedBy') performedBy?: string) { + return this.inventoryService.loadPassedExport(performedBy); + } + + @Get('ready-to-load-export') + @ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' }) + readyToLoadExport() { + return this.inventoryService.readyToLoadExport(); + } + + @Get('loaded-export') + @ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' }) + loadedExport() { + return this.inventoryService.loadedExport(); + } + + @Post('bulk-dispatch-export') + @ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' }) + bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) { + return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy); + } + + @Post('bulk-mark-inspected') + @ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' }) + bulkMarkInspected(@Body() dto: BulkInspectDto) { + return this.inventoryService.bulkMarkInspected(dto); + } + @Post('bookings/:bookingId/unload') @ApiOperation({ summary: 'Unload a single arrived booking into a location' }) unloadBooking( @@ -71,6 +118,36 @@ export class WarehouseInventoryController { return this.inventoryService.gateClearance(id, performedBy); } + @Get('import/arrive-queue') + @ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' }) + importArriveQueue() { + return this.scheduling.importArriveQueue(); + } + + @Get('import/trains/:scheduleId/items') + @ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' }) + importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { + return this.scheduling.importTrainDetail(scheduleId); + } + + @Post('import/auto-unload-arrived-bookings') + @ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' }) + autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) { + return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy); + } + + @Get('import/unloaded-queue') + @ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' }) + importUnloadedQueue() { + return this.inventoryService.importUnloadedQueue(); + } + + @Get('import/pickup-ready-queue') + @ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' }) + importPickupReadyQueue() { + return this.inventoryService.importPickupReadyQueue(); + } + @Get('loadable-wagons') @ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' }) loadableWagons() { @@ -137,6 +214,24 @@ export class WarehouseInventoryController { return this.inventoryService.load(id, dto); } + @Post(':id/ready-for-pickup') + @ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' }) + readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.readyForPickup(id, performedBy); + } + + @Post(':id/release') + @ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' }) + release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) { + return this.inventoryService.release(id, dto); + } + + @Post(':id/deliver') + @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) + deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { + return this.inventoryService.deliver(id, dto); + } + @Patch(':id/dispatch') @ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' }) dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 652be600c..31975b089 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1,14 +1,21 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { BulkInspectDto } from './dto/bulk-inspect.dto'; +import { BulkReceiveDto } from './dto/bulk-receive.dto'; +import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; import { WarehouseAllocationService } from './warehouse-allocation.service'; +import { WarehouseInspectionService } from './warehouse-inspection.service'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; @@ -113,6 +120,85 @@ export interface AutoLoadResult { results: { inventoryId: string; status: string; reason?: string }[]; } +// ── Receive (Import/Export bulk) shapes ────────────────────────────────────── +export interface EligibleBookingRow { + id: string; + reference: string; + customerId: string | null; + customer: string | null; + direction: string; + origin: string | null; + destination: string | null; + freightType: string | null; + cargo: string | null; + weight: string | null; + paymentStatus: string; + status: string; +} + +export interface BulkReceiveResult { + receivedCount: number; + skippedCount: number; + results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[]; +} + +export interface LoadPassedExportResult { + loadedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + +export interface BulkInspectResult { + inspectedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + +export interface ReadyToLoadRow { + id: string; + bookingId: string | null; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + origin: string | null; + destination: string | null; + inspectionStatus: string | null; + status: string; +} + +export interface BulkDispatchResult { + dispatchedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + +export interface AutoUnloadArrivedResult { + unloadedCount: number; + skippedCount: number; + failedCount: number; + results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; +} + +export interface ImportUnloadedRow { + id: string; + bookingId: string | null; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + arrivalTime: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + trainSchedule: string | null; + inspectionStatus: string | null; + pickupOption: string; + lastMileRequested: boolean; + currentStatus: string; +} + @Injectable() export class WarehouseInventoryService { constructor( @@ -123,6 +209,7 @@ export class WarehouseInventoryService { private readonly scheduling: SchedulingReadFacade, private readonly allocation: WarehouseAllocationService, private readonly invoices: WarehouseInvoiceService, + private readonly inspectionService: WarehouseInspectionService, ) {} /** @@ -403,6 +490,542 @@ export class WarehouseInventoryService { return result; } + // ── Receive (Import/Export bulk) ─────────────────────────────────────────── + + /** + * Eligible PAID bookings that have NOT been received yet, classified IMPORT/EXPORT by route + * (origin/destination yard countries). Pass a direction to filter to one; omit it to return + * all import + export bookings in a single call (DOMESTIC routes are excluded either way). + */ + async eligibleBookings(direction?: 'IMPORT' | 'EXPORT'): Promise { + const rows: Array< + EligibleBookingRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT b.id, + b.reference AS "reference", + b.company_id AS "customerId", + company.name AS "customer", + oy.code AS "origin", + dy.code AS "destination", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + b.freight_type AS "freightType", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo", + b.cargo_total_weight_vgm AS "weight", + b.payment_status AS "paymentStatus", + b.status AS "status" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE b.deleted_at IS NULL + AND b.payment_status = 'PAID' + AND inv.id IS NULL + ORDER BY b.scheduled_date DESC NULLS LAST`, + ); + + // Direction is derived from the route (origin/destination yard countries), reusing deriveTradeDirection. + return rows + .map((r) => ({ + ...r, + direction: deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }), + })) + .filter((r) => + direction ? r.direction === direction : r.direction === 'IMPORT' || r.direction === 'EXPORT', + ); + } + + /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ + async bulkReceive(dto: BulkReceiveDto): Promise { + const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; + + await this.dataSource.transaction(async (manager) => { + await this.validateLocation(manager, { + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + }); + + for (const bookingId of dto.bookingIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ bookingId, status: 'SKIPPED', reason }); + }; + + const [booking] = await manager.query( + `SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight", + oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.bookings b + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, + [bookingId], + ); + if (!booking) { skip('Booking not found'); continue; } + if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; } + // Direction is derived from the route (yard countries), not the stored field. + const bookingDirection = deriveTradeDirection( + { country: booking.originCountry }, + { country: booking.destinationCountry }, + ); + if (bookingDirection !== dto.direction) { + skip(`Booking route is ${bookingDirection}, not ${dto.direction}`); + continue; + } + + const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); + if (existing) { skip('Already received'); continue; } + + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + quantity: 1, + weight: Number(booking.weight) || 0, + status: 'RECEIVED', + arrivedAt: new Date(), + notes: `Bulk received (${dto.direction})`, + }), + ); + + await this.activityLog.record( + { + activityType: 'INVENTORY_RECEIVED', + inventoryId: saved.id, + warehouseId: dto.warehouseId, + description: `Bulk received ${dto.direction} booking`, + performedBy: dto.performedBy, + }, + manager, + ); + + result.receivedCount += 1; + result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id }); + } + }); + + return result; + } + + /** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */ + async loadPassedExport(performedBy?: string): Promise { + const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); + const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] }; + + for (const item of ready) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason }); + }; + + if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; } + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; } + const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; + if (bookingStatus !== 'PAID') { skip('Booking not PAID'); continue; } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(item.id, { + status: 'LOADED', + loadedAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_LOADED', + inventoryId: item.id, + warehouseId: item.warehouseId, + description: 'Bulk loaded (passed export)', + performedBy, + }, + manager, + ); + }); + + result.loadedCount += 1; + result.results.push({ inventoryId: item.id, status: 'LOADED' }); + } + + return result; + } + + /** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */ + private async exportInventoryByStatus( + status: WarehouseInventoryStatus, + requireInspectionPassed = false, + ): Promise { + const rows: Array< + ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT inv.id, + inv.booking_id AS "bookingId", + b.reference AS "bookingReference", + b.company_id AS "customerId", + company.name AS "customerName", + ct.container_number AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + inv.weight AS "weight", + oy.code AS "origin", + dy.code AS "destination", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + inv.inspection_status AS "inspectionStatus", + inv.status + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.containers ct ON ct.id = inv.container_id + WHERE inv.deleted_at IS NULL + AND inv.status = $1 + ${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''} + ORDER BY inv.created_at DESC`, + [status], + ); + + return rows + .filter((r) => { + const dir = deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }); + return dir === 'EXPORT'; + }) + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest); + } + + /** EXPORT inventory that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */ + async readyToLoadExport(): Promise { + return this.exportInventoryByStatus('READY_FOR_LOADING', true); + } + + /** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */ + async loadedExport(): Promise { + return this.exportInventoryByStatus('LOADED'); + } + + /** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */ + private async importQueueByStatuses(statuses: string[]): Promise { + const rows: Array< + ImportUnloadedRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT inv.id, + inv.booking_id AS "bookingId", + b.reference AS "bookingReference", + b.company_id AS "customerId", + company.name AS "customerName", + COALESCE(inv.unloaded_at, inv.arrived_at) AS "arrivalTime", + (SELECT c.container_number FROM freight.containers c + WHERE c.booking_id = b.id AND c.deleted_at IS NULL + ORDER BY c.container_number LIMIT 1) AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + inv.weight AS "weight", + ts.train_number AS "trainSchedule", + inv.inspection_status AS "inspectionStatus", + CASE WHEN b.last_mile_delivery_address IS NOT NULL + THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", + (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + inv.status AS "currentStatus", + oy.country AS "originCountry", + dy.country AS "destinationCountry" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL + LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + WHERE inv.deleted_at IS NULL + AND inv.status = ANY($1) + ORDER BY inv.created_at DESC`, + [statuses], + ); + + return rows + .filter( + (r) => + deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT', + ) + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest); + } + + /** + * Batch 9 — IMPORT inventory sitting in the Unloaded Queue (UNLOADED / destination-inspection + * states), with the columns the inspection screen needs. Read-only. + */ + importUnloadedQueue(): Promise { + return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']); + } + + /** + * Batch 10 — IMPORT inventory that passed inspection and is PICKUP_READY (READY_FOR_PICKUP), + * awaiting customer pickup / last mile / store / dispatch. Read-only. + */ + importPickupReadyQueue(): Promise { + return this.importQueueByStatuses(['READY_FOR_PICKUP']); + } + + /** + * Bulk-dispatch loaded EXPORT inventory. Reuses the single-item dispatch transition + * (status LOADED → DISPATCHED, capacity freed, movement/activity logged). Items not + * LOADED or not EXPORT are skipped. The train/schedule flow later moves DISPATCHED → IN_TRANSIT. + */ + async bulkDispatchExport(inventoryIds: string[], performedBy?: string): Promise { + const result: BulkDispatchResult = { dispatchedCount: 0, skippedCount: 0, results: [] }; + + for (const inventoryId of inventoryIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId, status: 'SKIPPED', reason }); + }; + + const item = await this.inventoryRepository.findById(inventoryId); + if (!item) { skip('Inventory not found'); continue; } + if (item.status !== 'LOADED') { skip(`Status is ${item.status}, not LOADED`); continue; } + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; } + + try { + await this.dispatch(inventoryId, performedBy); + result.dispatchedCount += 1; + result.results.push({ inventoryId, status: 'DISPATCHED' }); + } catch (error) { + skip(error instanceof Error ? error.message : String(error)); + } + } + + return result; + } + + /** Booking statuses eligible to be unloaded off an arrived import train (Batch 8). */ + private readonly IMPORT_UNLOAD_ELIGIBLE_STATUSES = [ + 'IN_TRANSIT', + 'ARRIVED_AT_INDODE', + 'ARRIVED_AT_DESTINATION', + 'ARRIVED_AT_FACILITY', + ]; + + /** + * Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state. + * Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect — + * items land in UNLOADED so the operator drives store/inspect/reserve/dispatch from the queue. + */ + async autoUnloadArrivedBookings( + scheduleId: string, + performedBy?: string, + ): Promise { + const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] }; + + // 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries). + const [schedule] = await this.dataSource.query( + `SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL + LIMIT 1`, + [scheduleId], + ); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== 'ARRIVED') { + throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`); + } + const direction = deriveTradeDirection( + { country: schedule.originCountry }, + { country: schedule.destinationCountry }, + ); + if (direction !== 'IMPORT') { + throw new BadRequestException(`Train schedule route is ${direction}, not IMPORT`); + } + + // 2. Assigned bookings on this train. + const bookings: { + id: string; + status: string; + weight: string | null; + freightType: string | null; + tradeDirection: string | null; + cargoTypeCode: string | null; + }[] = await this.dataSource.query( + `SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight, + b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", + cgt.code AS "cargoTypeCode" + FROM freight.train_schedule_bookings tsb + JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`, + [scheduleId], + ); + + const fallback = await this.pickDefaultLocation(); + const now = new Date(); + + for (const booking of bookings) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ bookingId: booking.id, status: 'SKIPPED', reason }); + }; + const fail = (reason: string) => { + result.failedCount += 1; + result.results.push({ bookingId: booking.id, status: 'FAILED', reason }); + }; + + if (!this.IMPORT_UNLOAD_ELIGIBLE_STATUSES.includes(booking.status)) { + skip(`Booking status ${booking.status} is not unload-eligible`); + continue; + } + + try { + const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0]; + + // Already unloaded or further along — leave it (do not regress the lifecycle). + if (existing && existing.status !== 'RECEIVED') { + skip(`Inventory already ${existing.status}`); + continue; + } + + if (existing) { + await this.inventoryRepository.update(existing.id, { + status: 'UNLOADED', + unloadedAt: now, + arrivedAt: existing.arrivedAt ?? now, + }); + await this.activityLog.record({ + activityType: 'INVENTORY_UNLOADED', + inventoryId: existing.id, + warehouseId: existing.warehouseId, + description: 'Unloaded from arrived import train', + performedBy, + }); + result.unloadedCount += 1; + result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' }); + continue; + } + + // No inventory yet — create it at the allocated (or default) location, in UNLOADED state. + const allocated = await this.allocation.resolveLocation({ + freightType: booking.freightType, + tradeDirection: booking.tradeDirection, + cargoTypeCode: booking.cargoTypeCode, + }); + const location = allocated ?? fallback; + if (!location) { + fail('No warehouse/yard/zone configured'); + continue; + } + + const saved = await this.inventoryRepository.create({ + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + bookingId: booking.id, + quantity: 1, + weight: Number(booking.weight) || 0, + status: 'UNLOADED', + arrivedAt: now, + unloadedAt: now, + notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', + }); + await this.activityLog.record({ + activityType: 'INVENTORY_UNLOADED', + inventoryId: saved.id, + warehouseId: saved.warehouseId, + description: 'Unloaded from arrived import train', + performedBy, + }); + result.unloadedCount += 1; + result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' }); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + } + + return result; + } + + /** + * Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal + * report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING. + * For damage / weight-loss / images, use the per-item Inspect / Report action instead. + */ + async bulkMarkInspected(dto: BulkInspectDto): Promise { + const result: BulkInspectResult = { inspectedCount: 0, skippedCount: 0, results: [] }; + // UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state). + const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED']; + + for (const inventoryId of dto.inventoryIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId, status: 'SKIPPED', reason }); + }; + + const item = await this.inventoryRepository.findById(inventoryId); + if (!item) { skip('Inventory not found'); continue; } + if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; } + if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; } + + // Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt. + await this.inspectionService.create(inventoryId, { + reportType: 'INSPECTION', + inspectionStatus: 'PASSED', + remarks: dto.remarks ?? 'Bulk marked inspected (PASSED).', + inspectedById: dto.inspectedBy, + }); + + // A passed item advances by trade direction: + // EXPORT → Ready To Load (READY_FOR_LOADING) + // IMPORT → Pickup Ready (READY_FOR_PICKUP) — NOT ready-for-loading. + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction === 'EXPORT') { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(inventoryId, { + status: 'READY_FOR_LOADING', + readyForLoadingAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'READY_FOR_LOADING', + inventoryId, + warehouseId: item.warehouseId, + description: 'Inspection passed → ready for loading', + performedBy: dto.inspectedBy, + }, + manager, + ); + }); + result.results.push({ inventoryId, status: 'READY_FOR_LOADING' }); + } else if (direction === 'IMPORT') { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(inventoryId, { + status: 'READY_FOR_PICKUP', + readyForPickupAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'READY_FOR_PICKUP', + inventoryId, + warehouseId: item.warehouseId, + description: 'Destination inspection passed → pickup ready', + performedBy: dto.inspectedBy, + }, + manager, + ); + }); + result.results.push({ inventoryId, status: 'READY_FOR_PICKUP' }); + } else { + result.results.push({ inventoryId, status: 'INSPECTED' }); + } + result.inspectedCount += 1; + } + + return result; + } + // ── Receive ────────────────────────────────────────────────────────────── async receive(dto: ReceiveWarehouseInventoryDto): Promise { @@ -511,6 +1134,9 @@ export class WarehouseInventoryService { if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) { throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep'); } + if (item.inspectionStatus !== 'PASSED') { + throw new BadRequestException('Inventory must pass inspection before it can be marked ready for loading'); + } return this.transition(id, 'READY_FOR_LOADING', { timestampField: 'readyForLoadingAt', activityType: 'READY_FOR_LOADING', @@ -520,6 +1146,112 @@ export class WarehouseInventoryService { }); } + // ── Import branch (READY_FOR_PICKUP → DELIVERED) ─────────────────────────── + + /** Mark inspected IMPORT inventory ready for customer pickup (RECEIVED → READY_FOR_PICKUP). */ + async readyForPickup(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + + if (item.inspectionStatus !== 'PASSED') { + throw new BadRequestException('Inventory must pass inspection before it can be marked ready for pickup'); + } + + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction !== 'IMPORT') { + throw new BadRequestException('Only IMPORT inventory can be marked ready for pickup'); + } + + return this.transition(id, 'READY_FOR_PICKUP', { + timestampField: 'readyForPickupAt', + activityType: 'READY_FOR_PICKUP', + description: 'Inventory ready for customer pickup', + performedBy, + preloaded: item, + }); + } + + /** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */ + async release(id: string, dto: ReleaseOrderDto): Promise { + const item = await this.findById(id); + if (item.status !== 'READY_FOR_PICKUP') { + throw new BadRequestException( + `Inventory must be READY_FOR_PICKUP to issue a release order (current: ${item.status})`, + ); + } + + const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date(); + const reference = dto.reference?.trim() || null; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(id, { + releaseDate, + releaseOrderReference: reference, + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_RELEASED', + inventoryId: id, + warehouseId: item.warehouseId, + description: reference + ? `Release order ${reference} sent to customer` + : 'Release order sent to customer', + performedBy: dto.performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ + async deliver(id: string, dto: DeliverInventoryDto): Promise { + const item = await this.findById(id); + this.assertTransition(item.status, 'DELIVERED'); + + if (!item.releaseDate) { + throw new BadRequestException('A release order must be issued before the goods can be delivered'); + } + + const receiverName = dto.receiverName.trim(); + const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date(); + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(id, { + status: 'DELIVERED', + deliveredAt, + }); + + // Goods physically leave the warehouse on pickup — free up capacity. + await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1); + + // Proof of delivery is captured on the linked cargo. + if (item.cargoId) { + await manager.getRepository(Cargo).update(item.cargoId, { + receiverName, + deliveredAt, + deliveryRemarks: dto.remarks?.trim() ?? null, + }); + } + + await this.activityLog.record( + { + activityType: 'INVENTORY_DELIVERED', + inventoryId: id, + warehouseId: item.warehouseId, + description: `Delivered to ${receiverName}`, + performedBy: dto.performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + /** * Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record. * Reads wagon/schedule data read-only — never modifies scheduling. @@ -886,6 +1618,23 @@ export class WarehouseInventoryService { return rows?.[0]?.status ?? null; } + /** IMPORT | EXPORT | DOMESTIC derived from the booking ROUTE (yard countries), or null if missing. */ + private async getBookingDirection(bookingId: string): Promise { + const rows = await this.dataSource.query( + `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.bookings b + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, + [bookingId], + ); + if (!rows?.[0]) return null; + return deriveTradeDirection( + { country: rows[0].originCountry }, + { country: rows[0].destinationCountry }, + ); + } + private assertCapacity( label: string, node: LocationNode, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts index 3cbcc6833..f92401dfc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -1,5 +1,5 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; -import { FindManyOptions, ILike } from 'typeorm'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindManyOptions, ILike, QueryFailedError } from 'typeorm'; import { CreateWarehouseDto } from './dto/create-warehouse.dto'; import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; @@ -49,23 +49,27 @@ export class WarehousesService { async create(dto: CreateWarehouseDto): Promise { await this.assertCodeUnique(dto.code.trim()); - return this.warehousesRepository.create({ - name: dto.name.trim(), - code: dto.code.trim(), - type: dto.type, - stationId: dto.stationId ?? null, - facilityId: dto.facilityId ?? null, - locationName: dto.locationName?.trim() ?? null, - capacityWeight: dto.capacityWeight ?? null, - capacityContainers: dto.capacityContainers ?? null, - maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, - maxVolume: dto.maxVolume ?? null, - currentWeight: 0, - currentContainers: 0, - currentVolume: 0, - status: 'ACTIVE', - isActive: true, - }); + try { + return await this.warehousesRepository.create({ + name: dto.name.trim(), + code: dto.code.trim(), + type: dto.type, + stationId: dto.stationId ?? null, + facilityId: dto.facilityId ?? null, + locationName: dto.locationName?.trim() ?? null, + capacityWeight: dto.capacityWeight ?? null, + capacityContainers: dto.capacityContainers ?? null, + maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, + maxVolume: dto.maxVolume ?? null, + currentWeight: 0, + currentContainers: 0, + currentVolume: 0, + status: 'ACTIVE', + isActive: true, + }); + } catch (error) { + this.mapDbError(error); + } } async update(id: string, dto: UpdateWarehouseDto): Promise { @@ -77,20 +81,25 @@ export class WarehousesService { const status = dto.status ?? existing.status; - const updated = await this.warehousesRepository.update(id, { - name: dto.name?.trim() ?? existing.name, - code: dto.code?.trim() ?? existing.code, - type: dto.type ?? existing.type, - stationId: dto.stationId ?? existing.stationId, - facilityId: dto.facilityId ?? existing.facilityId, - locationName: dto.locationName?.trim() ?? existing.locationName, - capacityWeight: dto.capacityWeight ?? existing.capacityWeight, - capacityContainers: dto.capacityContainers ?? existing.capacityContainers, - maxWeight: dto.maxWeight ?? existing.maxWeight, - maxVolume: dto.maxVolume ?? existing.maxVolume, - status, - isActive: status === 'ACTIVE', - }); + let updated; + try { + updated = await this.warehousesRepository.update(id, { + name: dto.name?.trim() ?? existing.name, + code: dto.code?.trim() ?? existing.code, + type: dto.type ?? existing.type, + stationId: dto.stationId ?? existing.stationId, + facilityId: dto.facilityId ?? existing.facilityId, + locationName: dto.locationName?.trim() ?? existing.locationName, + capacityWeight: dto.capacityWeight ?? existing.capacityWeight, + capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + maxWeight: dto.maxWeight ?? existing.maxWeight, + maxVolume: dto.maxVolume ?? existing.maxVolume, + status, + isActive: status === 'ACTIVE', + }); + } catch (error) { + this.mapDbError(error); + } if (!updated) { throw new NotFoundException(`Warehouse ${id} not found`); @@ -99,6 +108,21 @@ export class WarehousesService { return this.findById(id); } + /** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */ + private mapDbError(error: unknown): never { + if (error instanceof QueryFailedError) { + const driver = (error as QueryFailedError & { driverError?: { code?: string; detail?: string } }).driverError; + if (driver?.code === '23503') { + throw new BadRequestException('Selected facility does not exist.'); + } + if (driver?.code === '22001') { + throw new BadRequestException('A field is too long (code max 40, name max 160 characters).'); + } + throw new BadRequestException(driver?.detail ?? error.message ?? 'Invalid warehouse data.'); + } + throw error as Error; + } + private async assertCodeUnique(code: string, ignoreId?: string): Promise { const [existing] = await this.warehousesRepository.findAll({ where: { code } }); diff --git a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts new file mode 100644 index 000000000..f9e4e2af7 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts @@ -0,0 +1,139 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; + +const SEED_REFS = ['SEED-B5-EXP-001', 'SEED-B5-EXP-002', 'SEED-B5-EXP-003']; + +const SEEDS = [ + { ref: 'SEED-B5-EXP-001', weight: 5000, notes: 'Electronics export cargo' }, + { ref: 'SEED-B5-EXP-002', weight: 8500, notes: 'Textile export cargo' }, + { ref: 'SEED-B5-EXP-003', weight: 3200, notes: 'Coffee export cargo' }, +]; + +/** + * Seeds 3 EXPORT+PAID bookings with READY_FOR_LOADING + inspection PASSED inventory + * so the Batch 5 "Ready To Load" tab has visible rows to test against. + * + * Origin: any Ethiopian yard (route-based direction = EXPORT when dest = Djibouti) + * Destination: any Djiboutian yard + * Uses the INDODE_OPEN warehouse created by IndodeFacilitySeeder. + */ +@Injectable() +export class Batch5TestDataSeeder { + private readonly logger = new Logger(Batch5TestDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + + const existing = await bookingRepo.findOne({ where: { reference: SEED_REFS[0] } }); + if (existing) { + this.logger.log('Batch 5 test data already seeded, skipping'); + return; + } + + try { + const yardRepo = this.dataSource.getRepository(Yard); + const serviceTypeRepo = this.dataSource.getRepository(ServiceType); + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard); + const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone); + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + + // Find Ethiopian origin yard and Djiboutian destination yard. + const originYard = + (await yardRepo.findOne({ where: { code: 'ADDIS_ABABA' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); + const destYard = + (await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + + if (!originYard || !destYard) { + this.logger.warn( + `Required yards not found (origin=${originYard?.code ?? 'none'}, dest=${destYard?.code ?? 'none'}); skipping Batch 5 seed`, + ); + return; + } + + // Find any active service type (bookings require one). + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + if (!serviceType) { + this.logger.warn('No service type found; skipping Batch 5 seed'); + return; + } + + // Find INDODE warehouse. + const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } }); + if (!warehouse) { + this.logger.warn('INDODE_OPEN warehouse not found; skipping Batch 5 seed'); + return; + } + + const warehouseYard = await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } }); + if (!warehouseYard) { + this.logger.warn('No warehouse yard found for INDODE_OPEN; skipping Batch 5 seed'); + return; + } + + const warehouseZone = await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }); + if (!warehouseZone) { + this.logger.warn('No warehouse zone found; skipping Batch 5 seed'); + return; + } + + const now = new Date(); + + for (const seed of SEEDS) { + const booking = await bookingRepo.save( + bookingRepo.create({ + reference: seed.ref, + originYardId: originYard.id, + destinationYardId: destYard.id, + serviceTypeId: serviceType.id, + status: 'PAID', + paymentStatus: 'PAID', + tradeDirection: 'EXPORT', + freightType: 'BULK', + cargoTotalWeightVgm: seed.weight, + cargoFreeText: seed.notes, + }), + ); + + await inventoryRepo.save( + inventoryRepo.create({ + bookingId: booking.id, + warehouseId: warehouse.id, + yardId: warehouseYard.id, + zoneId: warehouseZone.id, + status: 'READY_FOR_LOADING', + inspectionStatus: 'PASSED', + inspectedAt: new Date(now.getTime() - 3600 * 1000), + quantity: 1, + weight: seed.weight, + arrivedAt: new Date(now.getTime() - 7200 * 1000), + readyForLoadingAt: new Date(now.getTime() - 1800 * 1000), + notes: `[SEED-B5] ${seed.notes}`, + }), + ); + + this.logger.log(`Seeded ${seed.ref} → READY_FOR_LOADING + PASSED`); + } + + this.logger.log('✅ Batch 5 Ready-To-Load test data seeded successfully'); + } catch (error) { + this.logger.error( + `Batch5TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts new file mode 100644 index 000000000..dec3febe3 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts @@ -0,0 +1,103 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; + +/** + * Seeds two ARRIVED train schedules so the Import Arrive Queue (Batch 7) is demonstrable: + * - SEED-IMP-TRAIN-01: DJIB_PORT → MOJO (IMPORT) linked to booking SEED-IMP-001 → SHOWS + * - SEED-EXP-TRAIN-01: MOJO → DJIB_PORT (EXPORT) linked to booking SEED-EXP-001 → must NOT show + * + * Read-only train-schedule SERVICE logic is untouched; this only inserts fixture rows. + * Idempotent: guards on the import train number. + */ +@Injectable() +export class Batch7TestDataSeeder { + private readonly logger = new Logger(Batch7TestDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + + const existing = await scheduleRepo.findOne({ where: { trainNumber: 'SEED-IMP-TRAIN-01' } }); + if (existing) { + this.logger.log('Batch 7 test data already seeded, skipping'); + return; + } + + try { + const bookingRepo = this.dataSource.getRepository(Booking); + const locoRepo = this.dataSource.getRepository(Locomotive); + const trainSetRepo = this.dataSource.getRepository(TrainSet); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + + const importBooking = await bookingRepo.findOne({ where: { reference: 'SEED-IMP-001' } }); + const exportBooking = await bookingRepo.findOne({ where: { reference: 'SEED-EXP-001' } }); + if (!importBooking) { + this.logger.warn('SEED-IMP-001 booking not found; skipping Batch 7 seed'); + return; + } + + // One shared locomotive is fine — train_set.locomotive_id is not unique. + const loco = + (await locoRepo.findOne({ where: { code: 'SEED-LOCO-01' } })) ?? + (await locoRepo.save( + locoRepo.create({ code: 'SEED-LOCO-01', name: 'Seed Locomotive', maxPullWeightTons: 4000 }), + )); + + const now = new Date(); + const arrival = new Date(now.getTime() - 3600 * 1000); + const departure = new Date(now.getTime() - 6 * 3600 * 1000); + + const makeArrivedTrain = async ( + trainNumber: string, + booking: Booking, + ): Promise => { + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: loco.id, + totalWeightTons: 500, + totalLengthMeters: 300, + wagonCount: 10, + status: 'COMPLETED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber, + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ trainScheduleId: schedule.id, bookingId: booking.id }), + ); + + this.logger.log(`Seeded arrived train ${trainNumber} → booking ${booking.reference}`); + }; + + await makeArrivedTrain('SEED-IMP-TRAIN-01', importBooking); + if (exportBooking) { + await makeArrivedTrain('SEED-EXP-TRAIN-01', exportBooking); + } + + this.logger.log('✅ Batch 7 arrive-queue test data seeded successfully'); + } catch (error) { + this.logger.error( + `Batch7TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts new file mode 100644 index 000000000..c3a98c224 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; + +/** + * Makes the Batch 7 seed import train demonstrable for Batch 8: a booking riding an ARRIVED + * train is IN_TRANSIT until unloaded, so flip the seed import train's assigned bookings to + * IN_TRANSIT (an unload-eligible status). Idempotent — re-applying IN_TRANSIT is a no-op. + */ +@Injectable() +export class Batch8TestDataSeeder { + private readonly logger = new Logger(Batch8TestDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + try { + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + const bookingRepo = this.dataSource.getRepository(Booking); + + const train = await scheduleRepo.findOne({ where: { trainNumber: 'SEED-IMP-TRAIN-01' } }); + if (!train) { + this.logger.log('SEED-IMP-TRAIN-01 not found; skipping Batch 8 seed'); + return; + } + + const links = await scheduleBookingRepo.find({ where: { trainScheduleId: train.id } }); + let updated = 0; + for (const link of links) { + const booking = await bookingRepo.findOne({ where: { id: link.bookingId } }); + if (!booking || booking.status === 'IN_TRANSIT') continue; + await bookingRepo.update(booking.id, { status: 'IN_TRANSIT' }); + updated += 1; + } + + if (updated > 0) { + this.logger.log(`✅ Batch 8: set ${updated} import train booking(s) to IN_TRANSIT (unload-eligible)`); + } else { + this.logger.log('Batch 8: import train bookings already IN_TRANSIT, skipping'); + } + } catch (error) { + this.logger.error( + `Batch8TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts new file mode 100644 index 000000000..e00544dc0 --- /dev/null +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -0,0 +1,258 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; + +/** + * One coherent warehouse dataset so EVERY queue/tab shows representative data: + * Export → Receive Queue : PAID export bookings, not yet received + * Export → Ready To Load : EXPORT inventory READY_FOR_LOADING + inspection PASSED + * Export → Loaded/Dispatch : EXPORT inventory LOADED + * Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory) + * Import → Unloaded Queue : UNLOADED import inventory + * Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED) + * + * Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it + * never collides with other seeders. To repopulate after items are walked through their lifecycle, + * delete the WH-DEMO-* bookings (cascades) and reboot. + */ +@Injectable() +export class WarehouseDemoSeeder { + private readonly logger = new Logger(WarehouseDemoSeeder.name); + private readonly SENTINEL = 'WH-DEMO-RCV-1'; + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + if (await bookingRepo.findOne({ where: { reference: this.SENTINEL } })) { + this.logger.log('Warehouse demo data already seeded, skipping'); + return; + } + + try { + const yardRepo = this.dataSource.getRepository(Yard); + const serviceTypeRepo = this.dataSource.getRepository(ServiceType); + const cargoTypeRepo = this.dataSource.getRepository(CargoType); + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const whYardRepo = this.dataSource.getRepository(WarehouseYard); + const whZoneRepo = this.dataSource.getRepository(WarehouseZone); + const invRepo = this.dataSource.getRepository(WarehouseInventory); + + const djibYard = + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + const ethYard = + (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + + if (!djibYard || !ethYard || !serviceType) { + this.logger.warn( + `Missing yards/service type (djib=${djibYard?.code}, eth=${ethYard?.code}, svc=${serviceType?.code}); skipping`, + ); + return; + } + + const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } }); + const whYard = warehouse ? await whYardRepo.findOne({ where: { warehouseId: warehouse.id } }) : null; + const whZone = whYard ? await whZoneRepo.findOne({ where: { yardId: whYard.id } }) : null; + if (!warehouse || !whYard || !whZone) { + this.logger.warn('INDODE_OPEN warehouse/yard/zone missing; skipping warehouse demo seed'); + return; + } + + const now = Date.now(); + const ago = (mins: number) => new Date(now - mins * 60_000); + + // EXPORT booking = Ethiopia → Djibouti; IMPORT booking = Djibouti → Ethiopia. + const makeBooking = async ( + reference: string, + direction: 'EXPORT' | 'IMPORT', + status: string, + weight: number, + idx: number, + ): Promise => + bookingRepo.save( + bookingRepo.create({ + reference, + originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id, + destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id, + serviceTypeId: serviceType.id, + status, + paymentStatus: 'PAID', + tradeDirection: direction, + freightType: idx % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType ? null : `${direction} demo cargo ${idx}`, + cargoTotalWeightVgm: weight, + }), + ); + + const makeInventory = async ( + booking: Booking, + status: string, + weight: number, + extra: Partial, + ): Promise => { + await invRepo.save( + invRepo.create({ + warehouseId: warehouse.id, + yardId: whYard.id, + zoneId: whZone.id, + bookingId: booking.id, + quantity: 1, + weight, + status: status as WarehouseInventory['status'], + notes: '[WH-DEMO]', + ...extra, + }), + ); + }; + + let created = 0; + + // 1) Export Receive Queue — 3 PAID export bookings, NO inventory. + for (let i = 1; i <= 3; i++) { + await makeBooking(`WH-DEMO-RCV-${i}`, 'EXPORT', 'PAID', 4000 + i * 500, i); + created++; + } + + // 2) Export Ready To Load — EXPORT inventory READY_FOR_LOADING + PASSED. + for (let i = 1; i <= 3; i++) { + const b = await makeBooking(`WH-DEMO-RTL-${i}`, 'EXPORT', 'PAID', 6000 + i * 500, i); + await makeInventory(b, 'READY_FOR_LOADING', 6000 + i * 500, { + inspectionStatus: 'PASSED', + arrivedAt: ago(180), + inspectedAt: ago(120), + readyForLoadingAt: ago(60), + }); + created++; + } + + // 3) Export Loaded / Dispatch Queue — EXPORT inventory LOADED. + for (let i = 1; i <= 2; i++) { + const b = await makeBooking(`WH-DEMO-LOAD-${i}`, 'EXPORT', 'PAID', 7000 + i * 500, i); + await makeInventory(b, 'LOADED', 7000 + i * 500, { + inspectionStatus: 'PASSED', + arrivedAt: ago(240), + inspectedAt: ago(180), + readyForLoadingAt: ago(120), + loadedAt: ago(30), + }); + created++; + } + + // 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored). + for (let i = 1; i <= 3; i++) { + const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i); + await makeInventory(b, 'UNLOADED', 5000 + i * 500, { + arrivedAt: ago(90), + unloadedAt: ago(45), + }); + created++; + } + + // 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED). + for (let i = 1; i <= 3; i++) { + const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i); + await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, { + inspectionStatus: 'PASSED', + arrivedAt: ago(200), + unloadedAt: ago(160), + inspectedAt: ago(120), + readyForPickupAt: ago(60), + }); + created++; + } + + // 6) Import Arrive Queue — an ARRIVED import train with IN_TRANSIT bookings, no inventory yet. + await this.seedArrivedImportTrain(djibYard, ethYard, serviceType, cargoType, ago(60), ago(360)); + created += 1; + + this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`); + } catch (error) { + this.logger.error( + `WarehouseDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** An ARRIVED Djibouti→Ethiopia train with 3 IN_TRANSIT bookings (no inventory) for the Arrive Queue. */ + private async seedArrivedImportTrain( + djibYard: Yard, + ethYard: Yard, + serviceType: ServiceType, + cargoType: CargoType | null, + arrival: Date, + departure: Date, + ): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + const locoRepo = this.dataSource.getRepository(Locomotive); + const trainSetRepo = this.dataSource.getRepository(TrainSet); + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + + const loco = + (await locoRepo.findOne({ where: { code: 'WH-DEMO-LOCO' } })) ?? + (await locoRepo.save(locoRepo.create({ code: 'WH-DEMO-LOCO', name: 'Demo Locomotive', maxPullWeightTons: 4000 }))); + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: loco.id, + totalWeightTons: 500, + totalLengthMeters: 300, + wagonCount: 10, + status: 'COMPLETED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: djibYard.id, + destinationStationId: ethYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber: 'WH-DEMO-IMP-TRAIN', + }), + ); + + for (let i = 1; i <= 3; i++) { + const b = await bookingRepo.save( + bookingRepo.create({ + reference: `WH-DEMO-ARR-${i}`, + originYardId: djibYard.id, + destinationYardId: ethYard.id, + serviceTypeId: serviceType.id, + status: 'IN_TRANSIT', + paymentStatus: 'PAID', + tradeDirection: 'IMPORT', + freightType: i % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType ? null : `IMPORT arrive demo cargo ${i}`, + cargoTotalWeightVgm: 5000 + i * 400, + }), + ); + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ trainScheduleId: schedule.id, bookingId: b.id }), + ); + } + } +} diff --git a/apps/edr-freight-web/backoffice/index.css b/apps/edr-freight-web/backoffice/index.css index 5dd466b0c..132d7d926 100644 --- a/apps/edr-freight-web/backoffice/index.css +++ b/apps/edr-freight-web/backoffice/index.css @@ -1,6 +1,24 @@ @import "tailwindcss"; @import "@edr/ui-common/theme.css" layer(theme); +/* Bridge the central Mantine theme into Tailwind. freightMantineTheme + (createTheme) is the single source of truth; these just alias its generated + CSS variables so `bg-edr-*`, `text-edr-*`, `border-edr-*` utilities resolve + to the same tokens used by Mantine props. Mirrors edr-freight-web/portal. */ +@theme { + --color-edr-primary: var(--mantine-color-edr-green-5); + --color-edr-primary-dark: var(--mantine-color-edr-green-7); + --color-edr-bg: var(--mantine-color-edr-bg-6); + --color-edr-card: var(--mantine-color-edr-card-6); + --color-edr-border: var(--mantine-color-edr-border-6); + --color-edr-divider: var(--mantine-color-edr-divider-6); + --color-edr-text: var(--mantine-color-edr-text-6); + --color-edr-muted: var(--mantine-color-edr-muted-6); + --color-edr-soft: var(--mantine-color-edr-soft-6); + --color-edr-ink: var(--mantine-color-edr-ink-6); + --color-edr-accent: var(--mantine-color-edr-accent-6); +} + :root { --freight-brand: #1B9E7A; --freight-brand-dark: #15805F; diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index d680625ac..e8c9d21e6 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -9,10 +9,7 @@ "preview": "vite preview --port 5183", "lint": "eslint src", "test": "vitest run", - "type-check": "tsc --noEmit", - "build:user-management": "cd user-management-config && npm run build", - "backoffice": "npm run build:user-management && nx serve @fhc-platform/backoffice", - "backoffice:no-build": "nx serve @fhc-platform/backoffice" + "type-check": "tsc --noEmit" }, "dependencies": { "@edr/types": "workspace:*", @@ -22,7 +19,7 @@ "@mantine/hooks": "^9.3.0", "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", - "@tria-plc/iamui-common": "1.1.2", + "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -35,6 +32,7 @@ "react-router-dom": "^6.27.0", "recharts": "^3.8.1", "sonner": "^2.0.7", + "stream-browserify": "^3.0.0", "tailwind-merge": "^3.6.0", "tinymce": "^8.6.0", "zustand": "^5.0.0" diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index e8879ba0e..aae725618 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,78 +1,76 @@ -import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { Boxes, + Container, FileText, LayoutDashboard, LayoutGrid, Network, - Paperclip, + Package, PackageCheck, + PackageOpen, + Paperclip, Send, Settings, SlidersHorizontal, Train, Truck, - Container, - Package, - PackageOpen, Users, Wallet, - //TrainTrack, } from "lucide-react"; +import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; -import LoadingScreen from "./components/LoadingScreen"; import { useAuth } from "./auth/useAuth"; +import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; -import PaymentsPage from "./pages/payments/PaymentsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; -import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; -import OverviewPage from "./pages/dashboard/OverviewPage"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; +import OverviewPage from "./pages/dashboard/OverviewPage"; +import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; +import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; +import { RequirePermission } from "./components/auth/RequirePermission"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; import UsersPage from "./pages/dashboard/user-management/UsersPage"; -import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import RoutesPage from "./pages/fleet/RoutesPage"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; -import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; -import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; -import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; -import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; -import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; -import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; -import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; -import FleetResourcePage from "./pages/fleet/FleetResourcePage"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; -import { RequirePermission } from "./components/auth/RequirePermission"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; -import RoutesPage from "./pages/fleet/RoutesPage"; -import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; +import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; +import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; +import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; +import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; +import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; +import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; +import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; +import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; -import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; -import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; -import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; -import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; -import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; -import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; -import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; +import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Main menu", - mutedTitle: true, items: [ { label: "Overview", @@ -216,34 +214,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Administration", items: [ - { - label: "User management", - href: "/dashboard/user-management", - icon: , - permission: FREIGHT_PERMS.admin, - children: [ - { - label: "Users", - href: "/dashboard/user-management/users", - }, - { - label: "Employees", - href: "/dashboard/user-management/employees", - }, - { - label: "Position Types", - href: "/dashboard/user-management/position-types", - }, - { - label: "Permissions", - href: "/dashboard/user-management/permissions", - }, - { - label: "Roles", - href: "/dashboard/user-management/roles", - }, - ], - }, { label: "File settings", href: "/dashboard/file-settings", @@ -344,218 +314,210 @@ const App = () => { return ( } /> - } /> - } /> + } /> + } /> ); } return ( - } /> - } /> - }> - } /> - } /> + } /> + } /> + } /> + }> + } /> + } /> - } /> - - - - } - /> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + + + + } + /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - {/* iframe-based user management module */} - } /> + {/* Legacy embedded user management routes */} + } /> + } /> + } /> + {/* } /> */} + } /> + } /> - {/* Legacy embedded user management routes */} - } /> - } /> - } /> - {/* } /> */} - } /> - } /> + + + + } + /> + + + + } + /> - - - - } - /> - - - - } - /> + } + /> + + + + } + /> + } /> + } /> + } /> - } - /> - - - - } - /> - } /> - } /> - } /> + } + /> + } /> - } - /> - } /> + } + /> + } /> - } - /> - } /> + } /> + } /> - } /> - } /> + } /> + } /> + - } - /> - } - /> - - - } /> + } /> ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx index 25b3816b7..a38e2f0e5 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx @@ -68,7 +68,7 @@ export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps icon={ShieldCheck} title="Approval chain" extra={ - + {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} } @@ -144,11 +144,11 @@ function StepRow({ const canApprove = canActOnApprovalStep(user, step, steps); const statusColor = step.status === "APPROVED" - ? "green" + ? "edr-green" : step.status === "REJECTED" ? "red" : isNext - ? "green" + ? "edr-green" : "gray"; return ( @@ -200,7 +200,7 @@ function StepRow({ {canApprove && ( - diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingLifecycleStepper.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingLifecycleStepper.tsx index eb00b8b71..5caa10fd8 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingLifecycleStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingLifecycleStepper.tsx @@ -68,7 +68,7 @@ export function BookingLifecycleStepper({ status }: BookingLifecycleStepperProps diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCard.tsx index 7d0535bd4..6f102e5c6 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCard.tsx @@ -29,7 +29,7 @@ export function BookingPaymentCard({ @@ -70,7 +64,7 @@ export function BookingRequestHero({ - diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index 2a594a645..1922cb92b 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -35,7 +35,7 @@ const FleetRecordActions = ({ {showDetail ? ( + + + Search bookings, trains… + + + + + {/* Right: actions + avatar */} + + + + + - )} - - - + + + + + + + - - - + {enableThemeToggle && ( + + + {theme === "dark" ? ( + + ) : ( + + )} + + + )} - - - - -
- - setIsUserMenuOpen(true)} - onClose={() => setIsUserMenuOpen(false)} - > - -
-
-
{initials}
-
- - - {userName} - - - {userEmail ?? "Administrator"} - - - -
-
- - - -
-
{initials}
-
- - + {/* Avatar pill */} + + + + + + {initials} + + + + {userName} - {userEmail && ( - - {userEmail} - - )} - - - - - } - onClick={() => { - setIsUserMenuOpen(false); - navigate("/dashboard/profile"); - }} - > - Profile - - } - onClick={() => { - setIsUserMenuOpen(false); - navigate("/dashboard/profile#signature"); - }} - > - My signature - - } - color="red" - onClick={() => { - setIsUserMenuOpen(false); - onLogout?.(); - }} - > - Logout - - - + + {userEmail ?? "Administrator"} + +
+ + + - {headerRight} + + + + {userName} + + {userEmail && ( + + {userEmail} + + )} + + + } + onClick={() => navigate("/dashboard/profile")} + > + Profile + + } + onClick={() => navigate("/dashboard/profile#signature")} + > + My signature + + + } + color="red" + onClick={() => onLogout?.()} + > + Logout + + +
+ + {headerRight} + - + ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx index a8e0b0aa6..cd930d29b 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx @@ -1,14 +1,16 @@ +import { AppShell, Box } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; import { type ReactNode, useEffect, useState } from "react"; -import { Box, Paper, MantineProvider } from "@mantine/core"; import FreightDashboardHeader from "./FreightDashboardHeader"; import FreightSidebar from "./FreightSidebar"; import { getPageMeta } from "./route-meta"; import type { SidebarSection } from "./types"; -import { freightMantineTheme } from "@/theme/freight-brand"; type Theme = "light" | "dark"; const THEME_STORAGE_KEY = "edr-theme"; +const HEADER_HEIGHT = 64; +const NAVBAR_WIDTH = 280; function getInitialTheme(): Theme { if (typeof window === "undefined") return "light"; @@ -45,6 +47,9 @@ const FreightDashboardLayout = ({ children, }: FreightDashboardLayoutProps) => { const pageMeta = getPageMeta(activeHref); + const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] = + useDisclosure(false); + const [theme, setTheme] = useState(() => enableThemeToggle ? getInitialTheme() : "light", ); @@ -52,100 +57,62 @@ const FreightDashboardLayout = ({ useEffect(() => { if (!enableThemeToggle) return; const root = document.documentElement; - if (theme === "dark") { - root.classList.add("dark"); - } else { - root.classList.remove("dark"); - } + root.classList.toggle("dark", theme === "dark"); window.localStorage.setItem(THEME_STORAGE_KEY, theme); }, [theme, enableThemeToggle]); const toggleTheme = () => setTheme((current) => (current === "dark" ? "light" : "dark")); + const navigate = (href: string) => { + closeMobile(); + onNavigate?.(href); + }; + return ( - <> - - - + - + + + + {/* Internal scroll keeps the fixed-viewport model the dashboard pages + assume (sidebar + header stay put, content scrolls beneath). */} - - - - - - - - - - {children} - - - + {children} - - + + ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css deleted file mode 100644 index 17dce66c3..000000000 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css +++ /dev/null @@ -1,320 +0,0 @@ -/* ============================================================ - EDR Freight — Sidebar styles - Polished, professional navigation surface. - ============================================================ */ - -.fsb-aside { - height: 100%; - max-height: 100%; - width: 280px; - flex-shrink: 0; - border-radius: 16px; - border: 1px solid #eef1f4; - background: #ffffff; - box-shadow: - 0 1px 2px rgba(15, 23, 42, 0.04), - 0 8px 24px -16px rgba(15, 23, 42, 0.12); - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* ---- Brand header ---- */ -.fsb-brand { - position: relative; - display: flex; - align-items: center; - gap: 12px; - height: 80px; - padding: 0 20px; - flex-shrink: 0; - border-bottom: 1px solid #f1f5f9; - overflow: hidden; -} - -.fsb-brand::after { - content: ""; - position: absolute; - inset: 0; - background: - radial-gradient(120px 80px at 24px 18px, rgba(34, 197, 94, 0.08), transparent 70%); - pointer-events: none; -} - -.fsb-logo { - position: relative; - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - width: 44px; - height: 44px; - border-radius: 13px; - background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 60%, #15805F 100%); - box-shadow: - 0 6px 16px -4px rgba(27, 158, 122, 0.45), - inset 0 1px 0 rgba(255, 255, 255, 0.25); - flex-shrink: 0; -} - -/* ---- Nav scroll region ---- */ -.fsb-nav { - flex: 1; - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; - padding: 14px 12px 12px; - display: flex; - flex-direction: column; - gap: 20px; -} - -.fsb-nav::-webkit-scrollbar { - width: 6px; -} -.fsb-nav::-webkit-scrollbar-thumb { - background: #e2e8f0; - border-radius: 3px; -} -.fsb-nav::-webkit-scrollbar-thumb:hover { - background: #cbd5e1; -} -.fsb-nav::-webkit-scrollbar-track { - background: transparent; -} - -.fsb-section-label { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.7px; - text-transform: uppercase; - color: #94a3b8; - padding: 0 12px; - margin-bottom: 6px; -} - -/* ---- Top-level item ---- */ -.fsb-item { - position: relative; - display: flex; - align-items: center; - gap: 11px; - width: 100%; - padding: 9px 12px; - border-radius: 11px; - cursor: pointer; - color: #475569; - font-size: 14px; - font-weight: 500; - line-height: 1.2; - text-align: left; - text-decoration: none; - transition: - background-color 160ms ease, - color 160ms ease, - box-shadow 160ms ease; -} - -.fsb-item:hover { - background-color: #f5f7fa; - color: #0f172a; -} - -.fsb-item[data-active="true"] { - background: linear-gradient( - 135deg, - rgba(34, 197, 94, 0.12) 0%, - rgba(27, 158, 122, 0.06) 100% - ); - color: #1B9E7A; - font-weight: 600; -} - -.fsb-item[data-active="true"]::before { - content: ""; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 22px; - border-radius: 0 4px 4px 0; - background: linear-gradient(180deg, #2DBF95 0%, #1B9E7A 100%); -} - -.fsb-item-label { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* ---- Icon well ---- */ -.fsb-icon { - display: flex; - align-items: center; - justify-content: center; - width: 31px; - height: 31px; - border-radius: 9px; - flex-shrink: 0; - background: #f1f5f9; - color: #64748b; - transition: all 160ms ease; -} - -.fsb-item:hover .fsb-icon { - background: #e6ebf1; - color: #334155; -} - -.fsb-item[data-active="true"] .fsb-icon { - background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 100%); - color: #ffffff; - box-shadow: 0 5px 12px -2px rgba(27, 158, 122, 0.45); -} - -.fsb-chevron { - flex-shrink: 0; - color: #94a3b8; - transition: transform 220ms ease; -} - -.fsb-chevron-btn { - display: flex; - align-items: center; - justify-content: center; - padding: 0; - margin: 0; - border: none; - background: transparent; - cursor: pointer; - flex-shrink: 0; -} - -.fsb-chevron-btn:hover .fsb-chevron { - color: #64748b; -} - -/* ---- Nested branch ---- */ -.fsb-branch { - margin: 2px 0 2px 22px; - padding-left: 12px; - border-left: 1.5px solid #eef2f6; - display: flex; - flex-direction: column; - gap: 2px; -} - -/* group header (non-navigable) */ -.fsb-group { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 7px 10px; - border-radius: 8px; - cursor: pointer; - background: transparent; - transition: background-color 150ms ease; -} -.fsb-group:hover { - background-color: #f5f7fa; -} -.fsb-group-label { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.4px; - text-transform: uppercase; - color: #94a3b8; -} -.fsb-group[data-active="true"] .fsb-group-label { - color: #1B9E7A; -} - -/* child leaf */ -.fsb-child { - position: relative; - display: flex; - align-items: center; - gap: 9px; - width: 100%; - padding: 7px 10px; - border-radius: 8px; - cursor: pointer; - color: #64748b; - font-size: 13px; - font-weight: 500; - text-decoration: none; - transition: - background-color 150ms ease, - color 150ms ease; -} -.fsb-child:hover { - background-color: #f5f7fa; - color: #0f172a; -} -.fsb-child[data-active="true"] { - color: #1B9E7A; - font-weight: 600; - background-color: rgba(27, 158, 122, 0.08); -} - -.fsb-dot { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; - background: #cbd5e1; - transition: all 150ms ease; -} -.fsb-child:hover .fsb-dot { - background: #94a3b8; -} -.fsb-child[data-active="true"] .fsb-dot { - background: #1B9E7A; - box-shadow: 0 0 0 3px rgba(27, 158, 122, 0.16); -} - -/* ---- Footer status card ---- */ -.fsb-footer { - flex-shrink: 0; - padding: 12px; - border-top: 1px solid #f1f5f9; -} -.fsb-status { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - border-radius: 11px; - background: linear-gradient(135deg, #E7F8F2 0%, #f8fafc 100%); - border: 1px solid #e7f3ec; -} -.fsb-pulse { - position: relative; - width: 9px; - height: 9px; - border-radius: 50%; - background: #2DBF95; - flex-shrink: 0; -} -.fsb-pulse::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 50%; - background: #2DBF95; - animation: fsb-pulse 2s ease-out infinite; -} -@keyframes fsb-pulse { - 0% { - transform: scale(1); - opacity: 0.6; - } - 100% { - transform: scale(2.6); - opacity: 0; - } -} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 344e80c18..59efe49a7 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -1,277 +1,248 @@ import { - type MouseEvent, + AppShell, + Box, + Group, + NavLink, + ScrollArea, + Stack, + Text, + UnstyledButton, +} from "@mantine/core"; +import { ChevronDown, X } from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useMemo, useState, } from "react"; -import { ChevronDown, Train } from "lucide-react"; -import { Box, Stack, Text } from "@mantine/core"; import type { SidebarItem, SidebarSection } from "./types"; -import "./FreightSidebar.css"; export interface FreightSidebarProps { sections: SidebarSection[]; activeHref?: string; onNavigate?: (href: string) => void; + /** Close handler for the mobile drawer (X button, hidden on desktop). */ + onClose?: () => void; } -const sidebarItemKey = (item: SidebarItem, parentKey: string) => - item.href ?? `${parentKey}::${item.label}`; +const BRAND_LOGO = "/assets/logo.svg"; -const collectSidebarHrefs = (items: SidebarItem[]): string[] => - items.flatMap((item) => { - const hrefs: string[] = []; - if (item.href) hrefs.push(item.href.toLowerCase()); - if (item.children?.length) - hrefs.push(...collectSidebarHrefs(item.children)); - return hrefs; - }); +// Active / inactive NavLink styling, expressed through the shared edr-* theme +// tokens (bridged into Tailwind in index.css). Items are pills floating on the +// page background — the navbar itself has no surface of its own. +const navClassNames = (active: boolean) => + active + ? { + root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]", + label: "text-edr-primary-dark! font-medium! text-sm!", + section: "text-edr-primary-dark!", + } + : { + root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4", + label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!", + section: "text-edr-text!", + }; -const flattenSectionItems = (sections: SidebarSection[]) => - sections.flatMap((section) => section.items); +const itemKey = (parentKey: string, item: SidebarItem, index: number) => + `${parentKey}/${item.href ?? item.label}/${index}`; + +const collectHrefs = (items: SidebarItem[]): string[] => + items.flatMap((item) => [ + ...(item.href ? [item.href.toLowerCase()] : []), + ...(item.children?.length ? collectHrefs(item.children) : []), + ]); const FreightSidebar = ({ sections, activeHref, onNavigate, + onClose, }: FreightSidebarProps) => { - const items = useMemo(() => flattenSectionItems(sections), [sections]); const activePath = activeHref?.toLowerCase() ?? ""; const isHrefActive = useCallback( (href: string) => { const normalized = href.toLowerCase(); - return ( - activePath === normalized || activePath.startsWith(`${normalized}/`) - ); + return activePath === normalized || activePath.startsWith(`${normalized}/`); }, [activePath], ); - const branchContainsActive = useCallback( - (branch: SidebarItem[]) => - collectSidebarHrefs(branch).some((href) => isHrefActive(href)), + const branchActive = useCallback( + (items: SidebarItem[]) => collectHrefs(items).some(isHrefActive), [isHrefActive], ); - const defaultExpanded = useMemo(() => { + // Branches containing the active route start expanded; manual toggles win + // afterwards (merge keeps user intent while still opening newly-active paths). + const defaultOpen = useMemo(() => { const acc: Record = {}; - - const walk = (entries: SidebarItem[], parentKey: string) => { - for (const entry of entries) { - if (!entry.children?.length) continue; - const key = sidebarItemKey(entry, parentKey); + const walk = (items: SidebarItem[], parentKey: string) => { + items.forEach((item, i) => { + if (!item.children?.length) return; + const key = itemKey(parentKey, item, i); acc[key] = - branchContainsActive(entry.children) || - (entry.href ? isHrefActive(entry.href) : false); - walk(entry.children, key); - } + (item.href ? isHrefActive(item.href) : false) || + branchActive(item.children); + walk(item.children, key); + }); }; - - for (const item of items) { - if (!item.children?.length) continue; - const key = item.href ?? item.label; - acc[key] = - activePath === key.toLowerCase() || - activePath.startsWith(`${key.toLowerCase()}/`) || - branchContainsActive(item.children); - walk(item.children, key); - } - + sections.forEach((section) => walk(section.items, section.title)); return acc; - }, [activePath, branchContainsActive, isHrefActive, items]); - - const [expanded, setExpanded] = - useState>(defaultExpanded); + }, [sections, isHrefActive, branchActive]); + const [openMap, setOpenMap] = useState(defaultOpen); useEffect(() => { - setExpanded((current) => ({ ...defaultExpanded, ...current })); - }, [defaultExpanded]); + setOpenMap((current) => ({ ...defaultOpen, ...current })); + }, [defaultOpen]); - const navigateTo = (event: MouseEvent, href: string) => { - if (onNavigate) { - event.preventDefault(); - onNavigate(href); - } - }; + const toggle = useCallback( + (key: string) => setOpenMap((m) => ({ ...m, [key]: !m[key] })), + [], + ); - const toggleExpanded = (key: string) => { - setExpanded((current) => ({ ...current, [key]: !current[key] })); - }; + const renderItem = useCallback( + (item: SidebarItem, key: string): ReactNode => { + const hasChildren = !!item.children?.length; - const renderNavBranch = ( - children: SidebarItem[], - depth: number, - parentKey: string, - ): ReactNode => - children.map((child) => { - const key = sidebarItemKey(child, parentKey); - const isGroup = Boolean(child.children?.length) && !child.href; - - if (isGroup) { - const isOpen = expanded[key] ?? false; - const groupActive = branchContainsActive(child.children!); + if (hasChildren) { + const isLink = !!item.href; + const active = + (isLink ? isHrefActive(item.href!) : false) || + branchActive(item.children!); + const isOpen = openMap[key] ?? false; return ( -
- - {isOpen && ( -
- {renderNavBranch(child.children!, depth + 1, key)} -
+ className="flex cursor-pointer items-center" + > + + + } + > + {item.children!.map((child, i) => + renderItem(child, itemKey(key, child, i)), )} -
+ ); } - if (!child.href) return null; - - const childActive = isHrefActive(child.href); + if (!item.href) return null; + const active = isHrefActive(item.href); return ( - navigateTo(e, child.href!)} - > - - {child.label} - + label={item.label} + leftSection={item.icon} + active={active} + classNames={navClassNames(active)} + onClick={() => onNavigate?.(item.href!)} + /> ); - }); + }, + [branchActive, isHrefActive, onNavigate, openMap, toggle], + ); - const renderTopLevelItem = (item: SidebarItem) => { - if (!item.href) return null; - - const hasChildren = Boolean(item.children?.length); - const itemHref = item.href.toLowerCase(); - const childActive = hasChildren - ? branchContainsActive(item.children!) - : false; - const isCurrentItem = hasChildren - ? activePath === itemHref - : isHrefActive(itemHref); - const isActive = isCurrentItem || childActive; - const isOpen = expanded[item.href] ?? false; - - return ( - - { - if (hasChildren) { - setExpanded((current) => ({ - ...current, - [item.href!]: true, - })); - } - navigateTo(e, item.href!); - }} - > - {item.icon && {item.icon}} - {item.label} - {hasChildren && ( - - )} - - - {hasChildren && isOpen && ( -
- {renderNavBranch(item.children!, 0, item.href)} -
- )} -
- ); - }; - - return ( - -
-
- -
- - - EDR Freight - + const renderedSections = useMemo( + () => + sections.map((section) => ( + - Backoffice Console + {section.title} - -
- - - -
-
- - - - All systems operational - - - EDR Platform · v1.0 - + + {section.items.map((item, i) => + renderItem(item, itemKey(section.title, item, i)), + )} -
-
-
+ + )), + [renderItem, sections], + ); + + return ( + + {/* Brand — aligns with the 64px header for a continuous top edge */} + + + EDR Freight + + + EDR Freight + + + Backoffice Console + + + + {onClose && ( + + + + )} + + + {/* Nav */} + + {renderedSections} + + ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index 879292143..0c03b21b1 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -50,6 +50,27 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "View booking payment transactions", }, }, + { + prefix: "/dashboard/warehouse-dashboard", + meta: { + title: "Warehouse Dashboard", + subtitle: "Live overview of warehouse capacity and inventory lifecycle", + }, + }, + { + prefix: "/dashboard/warehouses/", + meta: { + title: "Warehouse detail", + subtitle: "Yards, zones, and inventory for this warehouse", + }, + }, + { + prefix: "/dashboard/warehouses", + meta: { + title: "Warehouses", + subtitle: "Manage warehouses, yards and zones", + }, + }, { prefix: "/dashboard/operations/train-scheduling-v2/", meta: { diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx index 04740585b..f2ce3b21b 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx @@ -1,65 +1,36 @@ -import { Group, Paper, Text } from "@mantine/core"; +import { KpiStrip, type KpiItem } from "@/components/page"; -import { - OverviewKpiCard, - type KpiGraphVariant, - type OverviewKpiItem, -} from "./OverviewKpiCard"; +import type { OverviewKpiItem } from "./OverviewKpiCard"; -/** Rotate mini-graph types per card so each strip reads as a lively mix. */ -const VARIANT_CYCLE: KpiGraphVariant[] = ["area", "line", "ring"]; - -/** Cohesive accent rotation — gold-forward with an orange and neutral break. */ -const ACCENT_CYCLE: NonNullable[] = [ - "gold", - "orange", - "default", -]; +/** + * Map the overview accent vocabulary onto brand / Mantine palette colors so the + * shared KpiStrip renders a flat tinted icon chip per cell — no gradients, + * gauges or sparklines. + */ +const ACCENT_COLOR: Record = { + default: "edr-green", + emerald: "edr-green", + amber: "yellow", + rose: "red", + sky: "blue", + violet: "violet", + gold: "yellow", + orange: "orange", +}; interface OverviewKpiStripProps { - title?: string; items: OverviewKpiItem[]; } -/** Parse a numeric magnitude out of a KPI value (handles formatted currency strings). */ -function toNumber(value: number | string): number { - if (typeof value === "number") return value; - const parsed = Number(String(value).replace(/[^0-9.-]/g, "")); - return Number.isFinite(parsed) ? parsed : 0; -} +/** Clean KPI strip for the overview tabs — delegates to the shared KpiStrip. */ +export function OverviewKpiStrip({ items }: OverviewKpiStripProps) { + const kpiItems: KpiItem[] = items.map((item) => ({ + label: item.label, + value: item.value, + icon: item.icon, + hint: item.hint, + color: ACCENT_COLOR[item.accent ?? "default"] ?? "edr-green", + })); -export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) { - const max = Math.max(...items.map((item) => toNumber(item.value)), 0); - - return ( - - {title && ( - - {title} - - )} - - {items.map((item, index) => ( - 0 ? toNumber(item.value) / max : 0), - }} - /> - ))} - - - ); + return ; } diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx index 3c2e79f75..529981add 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx @@ -47,11 +47,11 @@ export function OverviewPageHeader({ data={RANGE_OPTIONS} size="sm" radius="lg" - color="green" + color="edr-green" /> - + diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx index b39d5def8..2438b0368 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx @@ -78,7 +78,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { {isFetching && (
- +
)} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/overview.css b/apps/edr-freight-web/backoffice/src/components/overview/overview.css index 343e622d7..86b468df3 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/overview.css +++ b/apps/edr-freight-web/backoffice/src/components/overview/overview.css @@ -16,7 +16,7 @@ font-weight: 600; } .ov-seg-label[data-active] { - color: #15805f; + color: var(--mantine-color-edr-green-7); } /* ---- Premium tab bar ---- */ @@ -47,10 +47,8 @@ box-shadow: 0 2px 10px -4px rgba(15, 23, 42, 0.18); } .ov-tab[data-active] { - background: linear-gradient(135deg, #2dbf95 0%, #1b9e7a 100%) !important; + background: var(--mantine-color-edr-green-5) !important; color: #ffffff !important; - box-shadow: 0 10px 20px -8px rgba(27, 158, 122, 0.55); - transform: translateY(-1px); } .ov-tab[data-active]:hover { color: #ffffff; diff --git a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx new file mode 100644 index 000000000..6c8167a33 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx @@ -0,0 +1,94 @@ +import { Card, Skeleton, Text } from "@mantine/core"; +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +export interface KpiItem { + label: string; + value: ReactNode; + /** Optional leading icon rendered in a tinted chip. */ + icon?: LucideIcon; + /** Secondary line under the label (e.g. a unit or comparison). */ + hint?: string; + /** + * Mantine color name for the icon chip (e.g. "edr-green", "red", "yellow"). + * Defaults to the brand green so a strip reads as uniform unless a page opts + * into semantic tints. + */ + color?: string; +} + +export interface KpiStripProps { + items: KpiItem[]; + /** Show skeletons in place of values while data loads. */ + loading?: boolean; +} + +/** + * A single bordered card divided into up to five KPI cells: + * `[ kpi | kpi | kpi ]`. Hairline dividers separate cells (vertical on wide + * screens, horizontal when they wrap). Surface, border and shadow all come from + * the theme — no per-cell backgrounds, gradients or custom shadows. + */ +export function KpiStrip({ items, loading = false }: KpiStripProps) { + // The spec caps a strip at five cells; extra items are dropped rather than + // silently overflowing into an unreadable row. + const cells = items.slice(0, 5); + + return ( + +
+ {cells.map((item, index) => { + const Icon = item.icon; + const color = item.color ?? "edr-green"; + return ( +
0 && + "border-t border-edr-border sm:border-l sm:border-t-0", + )} + > + {Icon ? ( +
+ +
+ ) : null} + +
+ {loading ? ( + + ) : ( + + {item.value} + + )} + + {item.label} + {item.hint ? ` · ${item.hint}` : ""} + +
+
+ ); + })} +
+
+ ); +} + +export default KpiStrip; diff --git a/apps/edr-freight-web/backoffice/src/components/page/PageContainer.tsx b/apps/edr-freight-web/backoffice/src/components/page/PageContainer.tsx new file mode 100644 index 000000000..15c599294 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/page/PageContainer.tsx @@ -0,0 +1,26 @@ +import { Box, Stack, type MantineSpacing } from "@mantine/core"; +import type { ReactNode } from "react"; + +export interface PageContainerProps { + children: ReactNode; + /** Drop the max-width cap for full-bleed pages (boards, very wide tables). */ + fluid?: boolean; + /** Vertical gap between the page's stacked sections. */ + gap?: MantineSpacing; +} + +/** + * Standard page shell: one consistent inset + a vertical Stack so every + * dashboard page shares the same outer padding and inter-section rhythm. + * The surrounding AppShell.Main already paints the page background, so this + * never sets its own — pages stay on the shared `edr-bg` surface. + */ +export function PageContainer({ children, fluid = false, gap = "lg" }: PageContainerProps) { + return ( + + {children} + + ); +} + +export default PageContainer; diff --git a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx new file mode 100644 index 000000000..087567585 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx @@ -0,0 +1,78 @@ +import { ActionIcon, Group, Stack, Text, Title } from "@mantine/core"; +import { ArrowLeft } from "lucide-react"; +import type { ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; + +import Breadcrumbs, { type BreadcrumbItem } from "@/components/ui/Breadcrumbs"; + +export interface PageHeaderProps { + title: string; + subtitle?: string; + /** Breadcrumb trail — pass only on nested pages (details, sub-resources). */ + breadcrumbs?: BreadcrumbItem[]; + /** Route to return to; renders a back arrow before the title. */ + backTo?: string; + /** Inline content beside the title (e.g. status badges). */ + meta?: ReactNode; + /** Right-aligned actions — the primary CTA lives here. */ + action?: ReactNode; +} + +/** + * Unified page header: optional breadcrumbs, a title (with optional back arrow + * and inline meta), a subtitle, and a right-aligned action slot. Keeps title / + * action placement and spacing identical across every dashboard page. + */ +export function PageHeader({ + title, + subtitle, + breadcrumbs, + backTo, + meta, + action, +}: PageHeaderProps) { + const navigate = useNavigate(); + + return ( + + {breadcrumbs?.length ? : null} + + + + {backTo ? ( + navigate(backTo)} + aria-label="Go back" + > + + + ) : null} + +
+ + + {title} + + {meta} + + {subtitle ? ( + + {subtitle} + + ) : null} +
+
+ + {action ? ( + + {action} + + ) : null} +
+
+ ); +} + +export default PageHeader; diff --git a/apps/edr-freight-web/backoffice/src/components/page/index.ts b/apps/edr-freight-web/backoffice/src/components/page/index.ts new file mode 100644 index 000000000..8d455cf10 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/page/index.ts @@ -0,0 +1,6 @@ +export { PageContainer } from "./PageContainer"; +export type { PageContainerProps } from "./PageContainer"; +export { PageHeader } from "./PageHeader"; +export type { PageHeaderProps } from "./PageHeader"; +export { KpiStrip } from "./KpiStrip"; +export type { KpiItem, KpiStripProps } from "./KpiStrip"; diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ManageRuleEngineOrderDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ManageRuleEngineOrderDialog.tsx index df5da1b17..ced8eb2b1 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ManageRuleEngineOrderDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ManageRuleEngineOrderDialog.tsx @@ -333,7 +333,7 @@ const ManageRuleEngineOrderDialog = ({ Cancel -
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx new file mode 100644 index 000000000..4b5485815 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core'; +import { Info } from 'lucide-react'; + +import { useToast } from '@/hooks/use-toast'; +import { useDeliverInventory } from '@/hooks/useWarehouses'; +import type { WarehouseInventoryItem } from '@/types/warehouse'; +import { extractErrorMessage } from './options'; + +interface DeliverInventoryModalProps { + opened: boolean; + onClose: () => void; + item: WarehouseInventoryItem | null; +} + +export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) { + const { toast } = useToast(); + const deliverMutation = useDeliverInventory(); + const [receiverName, setReceiverName] = useState(''); + const [remarks, setRemarks] = useState(''); + + useEffect(() => { + if (opened) { + setReceiverName(''); + setRemarks(''); + } + }, [opened, item]); + + const handleSubmit = async () => { + if (!item) return; + if (!receiverName.trim()) { + toast({ variant: 'destructive', title: 'Receiver name is required' }); + return; + } + try { + await deliverMutation.mutateAsync({ + id: item.id, + payload: { receiverName: receiverName.trim(), remarks: remarks.trim() || undefined }, + }); + toast({ title: 'Delivered — proof of delivery captured' }); + onClose(); + } catch (error) { + toast({ variant: 'destructive', title: 'Delivery failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + } color="green" variant="light"> + + A release order must already be issued. Capturing the receiver marks the goods DELIVERED. + + + setReceiverName(e.currentTarget.value)} + /> +