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 3fcde133e..9c2a8ad78 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -35,8 +35,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 9277eb540..5f7164c12 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -47,6 +47,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 @@ -135,6 +139,10 @@ import { LastMileModule } from './modules/last-mile/last-mile.module'; DemoFreightDataSeeder, IndodeFacilitySeeder, Batch14TestDataSeeder, + Batch5TestDataSeeder, + Batch7TestDataSeeder, + Batch8TestDataSeeder, + WarehouseDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -143,6 +151,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, ) { } @@ -153,6 +169,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/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 fc58e2555..e41b4d726 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,47 +1,51 @@ -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"; @@ -51,30 +55,24 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail 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 FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; -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 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", @@ -144,11 +142,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, - // { - // label: "Trains", - // href: "/dashboard/trains", - // icon: , - // }, + // { // label: "Wagon types", // href: "/dashboard/wagon-types", @@ -242,34 +236,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", @@ -370,7 +336,7 @@ const App = () => { return ( } /> - } /> + } /> } /> ); @@ -378,37 +344,39 @@ const App = () => { 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/FleetToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetToolbar.tsx index 80138c024..dffd5acfc 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetToolbar.tsx @@ -1,6 +1,6 @@ -import type { ReactNode } from "react"; -import { LayoutGrid, Plus, Search, Table2 } from "lucide-react"; import { Box, Button, Group, SegmentedControl, TextInput } from "@mantine/core"; +import { LayoutGrid, Plus, Search, Table2 } from "lucide-react"; +import type { ReactNode } from "react"; import type { FleetViewMode } from "./useFleetViewMode"; @@ -67,7 +67,6 @@ const FleetToolbar = ({ onChange={(value) => onViewModeChange(value as FleetViewMode)} size="sm" radius="lg" - color="green" data={[ { value: "table", @@ -94,7 +93,7 @@ const FleetToolbar = ({ /> {onAdd ? ( + + + 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 de582d1d5..f3133cc70 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 @@ -61,7 +61,28 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ prefix: "/dashboard/operations/last-mile", meta: { title: "Last Mile", - subtitle: "Assign vehicles to final-leg deliveries", + subtitle: "Assign vehicles to final-leg deliveries" + } + }, + { + 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", }, }, { 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/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 1d7a8316d..ce4b81c05 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -15,7 +15,7 @@ const INVOICE_STATUS_COLOR: Record = { DRAFT: 'gray', ISSUED: 'orange', PARTIALLY_PAID: 'yellow', - PAID: 'green', + PAID: 'edr-green', CANCELLED: 'gray', }; @@ -175,7 +175,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa -
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index a2b1f7c2b..27ad0ebc6 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; -import { Center, Loader } from '@mantine/core'; +import { Button, Center, Group, Loader, Stack, Text } from '@mantine/core'; +import { ClipboardCheck } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { + useBulkMarkInspected, useDispatchInventory, useMarkReadyForLoading, useMarkReadyForPickup, @@ -23,10 +25,12 @@ import { extractErrorMessage } from './options'; interface InventoryWorkbenchProps { items: WarehouseInventoryItem[]; isLoading?: boolean; + /** Optional Last Mile action (Batch 8) — only shown for items whose booking requested door delivery. */ + onLastMile?: (item: WarehouseInventoryItem) => void; } /** Inventory table + all lifecycle actions (advance / move / reserve / history). */ -export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) { +export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) { const { toast } = useToast(); const [busyId, setBusyId] = useState(null); const [moveItem, setMoveItem] = useState(null); @@ -42,6 +46,39 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps const readyMutation = useMarkReadyForLoading(); const pickupMutation = useMarkReadyForPickup(); const dispatchMutation = useDispatchInventory(); + const inspectMutation = useBulkMarkInspected(); + + const [selected, setSelected] = useState>(new Set()); + const allSelected = items.length > 0 && selected.size === items.length; + const someSelected = selected.size > 0 && !allSelected; + const toggleSelect = (id: string) => + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + const toggleSelectAll = () => + setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id))); + + const markInspected = async () => { + if (selected.size === 0) { + toast({ variant: 'destructive', title: 'Select at least one item' }); + return; + } + try { + const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as { + data: { inspectedCount: number; skippedCount: number }; + }; + const r = res.data; + toast({ + title: `${r.inspectedCount} marked inspected`, + description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + }); + setSelected(new Set()); + } catch (error) { + toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) }); + } + }; const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise, label: string) => { setBusyId(item.id); @@ -92,15 +129,39 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps return ( <> - + + + + Selected: {selected.size} + + + + + + setMoveItem(null)} item={moveItem} /> void; - /** When supplied the booking field is locked to this booking. */ + /** When supplied the modal locks to a single booking (legacy single-receive). */ bookingId?: string; bookingLabel?: string; onReceived?: () => void; } -interface FormState { - bookingId: string; +interface Location { warehouseId: string; yardId: string; zoneId: string; - quantity: number | ''; - weight: number | ''; - volume: number | ''; - notes: string; } -const emptyForm = (bookingId?: string): FormState => ({ - bookingId: bookingId ?? '', - warehouseId: '', - yardId: '', - zoneId: '', - quantity: '', - weight: '', - volume: '', - notes: '', -}); - -export function ReceiveInventoryModal({ - opened, - onClose, - bookingId, - bookingLabel, - onReceived, -}: ReceiveInventoryModalProps) { - const { toast } = useToast(); - const receiveMutation = useReceiveInventory(); - const [form, setForm] = useState(emptyForm(bookingId)); - - useEffect(() => { - if (opened) setForm(emptyForm(bookingId)); - }, [opened, bookingId]); - - // Cascading data — only ACTIVE warehouses are selectable for receiving. +/** Cascading Warehouse → Yard → Zone selectors (ACTIVE only). */ +function LocationSelects({ + value, + onChange, +}: { + value: Location; + onChange: (next: Location) => void; +}) { const warehousesQuery = useWarehouses({ status: 'ACTIVE' }); - const yardsQuery = useWarehouseYards(form.warehouseId || undefined); - const zonesQuery = useWarehouseZones(form.yardId || undefined); + const yardsQuery = useWarehouseYards(value.warehouseId || undefined); + const zonesQuery = useWarehouseZones(value.yardId || undefined); const warehouseOptions = useMemo( - () => - (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), + () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), [warehousesQuery.data], ); const yardOptions = useMemo( @@ -83,10 +100,1084 @@ export function ReceiveInventoryModal({ [zonesQuery.data], ); - const submitting = receiveMutation.isPending; + return ( + + onChange({ ...value, yardId: v ?? '', zoneId: '' })} + /> + - setForm((f) => ({ ...f, warehouseId: value ?? '', yardId: '', zoneId: '' })) - } - /> - - setForm((f) => ({ ...f, zoneId: value ?? '' }))} - /> + setForm((f) => ({ ...f, ...next }))} /> { const v = e.currentTarget.value; setForm((f) => ({ ...f, notes: v })); }} + onChange={(e) => { + const v = e.currentTarget.value; + setForm((f) => ({ ...f, notes: v })); + }} /> - - @@ -212,3 +1268,8 @@ export function ReceiveInventoryModal({ ); } + +export function ReceiveInventoryModal(props: ReceiveInventoryModalProps) { + // Locked to a booking → legacy single receive; otherwise the Import/Export bulk flow. + return props.bookingId ? : ; +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx index 78b0cf1c8..b2a628d88 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx @@ -23,15 +23,15 @@ interface WarehouseDashboardChartsProps { } const ORANGE = '#f08c00'; -const GREEN = '#5bbf4a'; +const GREEN = '#22c55e'; // green from bookings -/** Inventory lifecycle status series — alternating orange / light green. */ +/** Inventory lifecycle status series — one distinct color per status (aligned with status badges). */ const STATUS_SERIES = [ - { key: 'stored', label: 'Stored', color: ORANGE }, - { key: 'reserved', label: 'Reserved', color: GREEN }, - { key: 'readyForLoading', label: 'Ready', color: ORANGE }, - { key: 'loaded', label: 'Loaded', color: GREEN }, - { key: 'dispatched', label: 'Dispatched', color: ORANGE }, + { key: 'stored', label: 'Stored', color: '#228be6' }, // blue + { key: 'reserved', label: 'Reserved', color: '#ae3ec9' }, // grape + { key: 'readyForLoading', label: 'Ready', color: '#f08c00' }, // orange + { key: 'loaded', label: 'Loaded', color: '#12b886' }, // teal + { key: 'dispatched', label: 'Dispatched', color: GREEN }, // green (bookings) ] as const; type Granularity = 'week' | 'month' | 'year'; @@ -157,8 +157,8 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps outerRadius={95} paddingAngle={2} > - {statusData.map((entry, i) => ( - + {statusData.map((entry) => ( + ))} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx index 8a282957a..366a3950c 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx @@ -1,4 +1,5 @@ -import { Stack, Table, Text } from '@mantine/core'; +import { Stack, Text } from '@mantine/core'; +import { DataTable, type ColumnDef } from '@edr/ui-common'; import type { InventoryInquiryResult } from '@/types/warehouse'; import { InventoryStatusBadge } from './badges'; @@ -16,70 +17,61 @@ const itemDescriptor = (result: InventoryInquiryResult) => { return '—'; }; -export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) { - if (results.length === 0) { - return ( - - No matching items. Adjust your search to locate cargo, containers or goods. +const columns: ColumnDef[] = [ + { + id: 'booking', + header: 'Booking', + cell: ({ row }) => ( + + {row.original.bookingNumber ?? row.original.bookingId.slice(0, 8)} - ); - } + ), + }, + { id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customerName ?? '—' }, + { id: 'item', header: 'Item', cell: ({ row }) => itemDescriptor(row.original) }, + { + id: 'warehouse', + header: 'Warehouse', + cell: ({ row }) => ( + + {row.original.warehouse?.name ?? '—'} + {row.original.warehouse?.code && ( + + {row.original.warehouse.code} + + )} + + ), + }, + { id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.name ?? '—' }, + { id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.name ?? '—' }, + { + id: 'status', + header: 'Status', + cell: ({ row }) => , + }, + { id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) }, + { id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) }, + { + id: 'arrived', + header: 'Arrived', + cell: ({ row }) => {formatDate(row.original.arrivedAt)}, + }, + { + id: 'ready', + header: 'Ready', + cell: ({ row }) => {formatDate(row.original.readyForLoadingAt)}, + }, +]; +export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) { return ( - - - - - Booking - Customer - Item - Warehouse - Yard - Zone - Status - Qty - Weight - Arrived - Ready - - - - {results.map((result) => ( - - - - {result.bookingNumber ?? result.bookingId.slice(0, 8)} - - - {result.customerName ?? '—'} - {itemDescriptor(result)} - - - {result.warehouse?.name ?? '—'} - {result.warehouse?.code && ( - - {result.warehouse.code} - - )} - - - {result.yard?.name ?? '—'} - {result.zone?.name ?? '—'} - - - - {formatNumber(result.quantity)} - {formatNumber(result.weight)} - - {formatDate(result.arrivedAt)} - - - {formatDate(result.readyForLoadingAt)} - - - ))} - -
-
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index 6aad4e168..a1a00d2e9 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -1,5 +1,7 @@ -import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core'; +import { useMemo } from 'react'; +import { ActionIcon, Badge, Button, Group, Text, Tooltip } from '@mantine/core'; import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react'; +import { DataTable, type ColumnDef } from '@edr/ui-common'; import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse'; import { getNextInventoryAction } from '@/types/warehouse'; @@ -14,6 +16,14 @@ interface WarehouseInventoryTableProps { onHistory: (item: WarehouseInventoryItem) => void; onInspect?: (item: WarehouseInventoryItem) => void; onFeePreview?: (item: WarehouseInventoryItem) => void; + // Optional Last Mile action — only rendered for items whose booking requested door delivery. + onLastMile?: (item: WarehouseInventoryItem) => void; + // Optional row selection (used for bulk Mark-as-Inspected). + selectedIds?: Set; + onToggleSelect?: (id: string) => void; + onToggleSelectAll?: () => void; + allSelected?: boolean; + someSelected?: boolean; } const itemKind = (item: WarehouseInventoryItem) => { @@ -28,7 +38,7 @@ const actionColor: Record = { reserve: 'grape', 'ready-for-loading': 'cyan', load: 'teal', - dispatch: 'green', + dispatch: 'edr-green', 'ready-for-pickup': 'orange', release: 'yellow', deliver: 'green', @@ -42,116 +52,124 @@ export function WarehouseInventoryTable({ onHistory, onInspect, onFeePreview, + onLastMile, + selectedIds, + onToggleSelect, + onToggleSelectAll, + allSelected, + someSelected, }: WarehouseInventoryTableProps) { - if (items.length === 0) { - return ( - - No inventory items found. - - ); - } + const columns = useMemo[]>( + () => [ + { + id: 'booking', + header: 'Booking', + cell: ({ row }) => + row.original.bookingId ? ( + + + {row.original.bookingId.slice(0, 8)}… + + + ) : ( + + — + + ), + }, + { + id: 'facility', + header: 'Facility', + cell: ({ row }) => row.original.warehouse?.facility?.name ?? '—', + }, + { id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' }, + { id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.code ?? '—' }, + { id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' }, + { + id: 'item', + header: 'Item', + cell: ({ row }) => { + const kind = itemKind(row.original); + return ( + + {kind.label} + + ); + }, + }, + { id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) }, + { id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) }, + { + id: 'status', + header: 'Status', + cell: ({ row }) => , + }, + { + id: 'arrived', + header: 'Arrived', + cell: ({ row }) => {formatDate(row.original.arrivedAt)}, + }, + { + id: 'actions', + header: '', + cell: ({ row }) => { + const item = row.original; + const busy = busyId === item.id; + const nextAction = INVENTORY_NEXT_ACTION[item.status]; + return ( + e.stopPropagation()}> + {nextAction && ( + + )} + {item.status !== 'DISPATCHED' && ( + + onMove(item)}> + + + + )} + {onInspect && ( + + onInspect(item)}> + + + + )} + {onFeePreview && ( + + onFeePreview(item)}> + + + + )} + + onHistory(item)}> + + + + + ); + }, + }, + ], + [busyId, onAdvance, onMove, onHistory, onInspect, onFeePreview], + ); return ( - - - - - Booking - Facility - Warehouse - Yard - Zone - Item - Qty - Weight - Status - Arrived - Actions - - - - {items.map((item) => { - const kind = itemKind(item); - const busy = busyId === item.id; - const nextAction = getNextInventoryAction(item); - return ( - - - {item.bookingId ? ( - - - {item.bookingId.slice(0, 8)}… - - - ) : ( - - — - - )} - - {item.warehouse?.facility?.name ?? '—'} - {item.warehouse?.code ?? '—'} - {item.yard?.code ?? '—'} - {item.zone?.code ?? '—'} - - - {kind.label} - - - {formatNumber(item.quantity)} - {formatNumber(item.weight)} - - - - - {formatDate(item.arrivedAt)} - - - - {nextAction && ( - - )} - {item.status !== 'DISPATCHED' && ( - - onMove(item)}> - - - - )} - {onInspect && ( - - onInspect(item)}> - - - - )} - {onFeePreview && ( - - onFeePreview(item)}> - - - - )} - - onHistory(item)}> - - - - - - - ); - })} - -
-
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx index fbd3a94c1..80b85f693 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; -import { ActionIcon, Anchor, Group, Table, Text } from '@mantine/core'; +import { ActionIcon, Group, Text } from '@mantine/core'; import { Eye, Pencil } from 'lucide-react'; +import { DataTable, type ColumnDef } from '@edr/ui-common'; import { useStations } from '@/hooks/useStations'; import type { Warehouse } from '@/types/warehouse'; @@ -20,73 +21,91 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro [stations], ); - if (warehouses.length === 0) { - return ( - - No warehouses found. - - ); - } + const columns: ColumnDef[] = [ + { + id: 'code', + header: 'Code', + cell: ({ row }) => ( + onView(row.original)} + > + {row.original.code} + + ), + }, + { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, + { + id: 'facility', + header: 'Facility', + cell: ({ row }) => { + const name = row.original.stationId + ? stationNameById.get(row.original.stationId) + : undefined; + return name ? ( + + {name} + + ) : ( + + — + + ); + }, + }, + { + id: 'type', + header: 'Type', + cell: ({ row }) => , + }, + { + id: 'location', + header: 'Location', + cell: ({ row }) => row.original.locationName ?? '—', + }, + { + id: 'weight', + header: 'Weight (cur / cap)', + cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight), + }, + { + id: 'containers', + header: 'Containers (cur / cap)', + cell: ({ row }) => + formatCapacity(row.original.currentContainers, row.original.capacityContainers), + }, + { + id: 'status', + header: 'Status', + cell: ({ row }) => , + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( + e.stopPropagation()}> + onView(row.original)} title="View"> + + + onEdit(row.original)} title="Edit"> + + + + ), + }, + ]; return ( - - - - - Code - Name - Facility - Type - Location - Weight (cur / cap) - Containers (cur / cap) - Status - Actions - - - - {warehouses.map((warehouse) => ( - - - onView(warehouse)}> - {warehouse.code} - - - {warehouse.name} - - {warehouse.stationId && stationNameById.get(warehouse.stationId) ? ( - - {stationNameById.get(warehouse.stationId)} - - ) : ( - - — - - )} - - - - - {warehouse.locationName ?? '—'} - {formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)} - {formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)} - - - - - - onView(warehouse)} title="View"> - - - onEdit(warehouse)} title="Edit"> - - - - - - ))} - -
-
+ onView(warehouse)} + emptyMessage="No warehouses found." + containerClassName="border-0 shadow-none" + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx index 9127881c9..1808a78bb 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx @@ -25,7 +25,7 @@ export function WarehouseTypeBadge({ type }: { type: WarehouseType }) { } export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) { - const color = status === 'ACTIVE' ? 'green' : 'gray'; + const color = status === 'ACTIVE' ? 'edr-green' : 'gray'; return ( {status} @@ -34,14 +34,14 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) { } const inventoryStatusColor: Record = { + UNLOADED: 'indigo', RECEIVED: 'yellow', STORED: 'blue', RESERVED: 'grape', READY_FOR_LOADING: 'cyan', LOADED: 'teal', - DISPATCHED: 'green', - READY_FOR_PICKUP: 'orange', - DELIVERED: 'green', + DISPATCHED: 'edr-green', + DELIVERED: 'edr-green', }; export function InventoryStatusBadge({ status }: { status: InventoryStatus }) { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 7b70f7d7e..832f923a0 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -307,6 +307,23 @@ export const URL_CONSTANTS = { MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`, RELEASE: (id: string) => `/warehouse-inventory/${id}/release`, DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`, + // Receive (Import/Export bulk) + ELIGIBLE_BOOKINGS: (direction?: string) => + direction + ? `/warehouse-inventory/eligible-bookings?direction=${direction}` + : `/warehouse-inventory/eligible-bookings`, + RECEIVE_BULK: '/warehouse-inventory/receive-bulk', + LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export', + BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected', + READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export', + LOADED_EXPORT: '/warehouse-inventory/loaded-export', + BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export', + IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue', + IMPORT_TRAIN_ITEMS: (scheduleId: string) => + `/warehouse-inventory/import/trains/${scheduleId}/items`, + IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings', + IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue', + IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue', }, WAREHOUSE_LOADINGS: { diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index f4dc8fb47..e96716be4 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,10 @@ -// API host is env-driven (set VITE_API_URL per environment, e.g. the remote -// https://edrfreightapi.triaplc.com for prod). Falls back to the local API for dev. -// export const API_BASE_URL = -// (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:3001'; +// Accept either a host-only URL or one that already ends with `/api`. +// The HTTP client appends `/api` itself, so we normalize here to avoid +// accidental `/api/api/...` requests from env values. +const rawApiBaseUrl = + (import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:3001"; -export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = rawApiBaseUrl + .trim() + .replace(/\/+$/, "") + .replace(/\/api$/, ""); diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 2521756a1..5e50c151a 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -14,6 +14,8 @@ import type { ReceiveInventoryPayload, ReleaseOrderPayload, DeliverInventoryPayload, + BulkReceivePayload, + BulkInspectPayload, ReserveInventoryPayload, SaveWarehousePayload, SaveYardPayload, @@ -186,6 +188,85 @@ export const useDeliverInventory = () => warehouseService.deliver(args.id, args.payload), ); +// ── Receive (Import/Export bulk) ─────────────────────────────────────────── +/** + * All not-yet-received PAID bookings, classified IMPORT/EXPORT by route, in one call. + * Both Receive tabs share this single query (same key) — only one HTTP request fires — + * then filter client-side by direction. + */ +export function useEligibleBookings(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'eligible-bookings'], + queryFn: () => warehouseService.eligibleBookings().then((r) => r.data), + enabled, + }); +} +export const useBulkReceive = () => + useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload)); +export const useLoadPassedExport = () => + useInventoryMutation(() => warehouseService.loadPassedExport()); +export const useBulkMarkInspected = () => + useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload)); + +export function useReadyToLoadExport(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'ready-to-load-export'], + queryFn: () => warehouseService.readyToLoadExport().then((r) => r.data), + enabled, + }); +} + +export function useLoadedExport(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'loaded-export'], + queryFn: () => warehouseService.loadedExport().then((r) => r.data), + enabled, + }); +} + +export const useBulkDispatchExport = () => + useInventoryMutation((inventoryIds: string[]) => warehouseService.bulkDispatchExport(inventoryIds)); + +/** Arrived IMPORT trains (route-derived). Read-only. */ +export function useImportArriveQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-arrive-queue'], + queryFn: () => warehouseService.importArriveQueue().then((r) => r.data), + enabled, + }); +} + +/** Assigned bookings/items for an arrived import train. Read-only. */ +export function useImportTrainItems(scheduleId?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-train-items', scheduleId], + queryFn: () => warehouseService.importTrainItems(scheduleId as string).then((r) => r.data), + enabled: Boolean(scheduleId), + }); +} + +/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */ +export const useAutoUnloadArrivedBookings = () => + useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId)); + +/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */ +export function useImportUnloadedQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-unloaded-queue'], + queryFn: () => warehouseService.importUnloadedQueue().then((r) => r.data), + enabled, + }); +} + +/** IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch. Read-only. */ +export function useImportPickupReadyQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-pickup-ready-queue'], + queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data), + enabled, + }); +} + // ── Loading (Batch 3) ──────────────────────────────────────────────────────── export function useLoadableWagons(enabled = true) { diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 23f05f3af..e689ef9f8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -1,6 +1,15 @@ import { type FormEvent, useState } from "react"; import { parsePhoneNumberFromString } from "libphonenumber-js"; -import { Eye, EyeOff, Mail, Smartphone, UserRound, ArrowUpRight, Globe, ChevronDown } from "lucide-react"; +import { + Eye, + EyeOff, + Mail, + Smartphone, + UserRound, + ArrowUpRight, + Globe, + ChevronDown, +} from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; @@ -13,10 +22,25 @@ const loginModes: Array<{ icon: typeof Mail; placeholder: string; }> = [ - { value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" }, - { value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" }, - { value: "username", label: "Username", icon: UserRound, placeholder: "username" }, -]; + { + value: "email", + label: "Email", + icon: Mail, + placeholder: "name@company.com", + }, + { + value: "phone", + label: "Phone", + icon: Smartphone, + placeholder: "09XXXXXXXX", + }, + { + value: "username", + label: "Username", + icon: UserRound, + placeholder: "username", + }, + ]; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const usernamePattern = /^[a-zA-Z0-9._-]{3,32}$/; @@ -43,7 +67,9 @@ const normalizeIdentifier = (mode: LoginMode, value: string) => { } if (!usernamePattern.test(trimmed)) { - throw new Error("Username must be 3-32 characters and use letters, numbers, ., _, or -."); + throw new Error( + "Username must be 3-32 characters and use letters, numbers, ., _, or -.", + ); } return trimmed; @@ -59,14 +85,24 @@ const primaryButtonClass = "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"; const LeftPanelDecor = () => ( -
+
{[0, 1, 2, 3, 4, 5].map((ring) => ( - + ))}
@@ -74,12 +110,23 @@ const LeftPanelDecor = () => ( ); const RightPanelDecor = () => ( -
+
@@ -138,13 +189,22 @@ const FormFooter = () => (
© 2026 EDR Freight @@ -176,7 +236,7 @@ const LoginPage = () => { setNormalizedIdentifier(normalized); const result = await login({ email: normalized, password }); - console.log(result) + console.log(result); if (result.mfaRequired) { setNeedsMfa(true); return; @@ -212,15 +272,20 @@ const LoginPage = () => {
-

Get Started

+

+ Get Started +

- Log in to access the freight backoffice & explore all logistics resources. + Log in to access the freight backoffice & explore all logistics + resources.

- +
- - I agree to EDR Freight{" "} - - Terms & Conditions - - . - - - {error ? (
{error}
) : null} - @@ -318,8 +377,10 @@ const LoginPage = () => {

We sent a verification code to{" "} - {normalizedIdentifier}. Enter it below - to complete sign in. + + {normalizedIdentifier} + + . Enter it below to complete sign in.

@@ -354,7 +415,11 @@ const LoginPage = () => { > Back -
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index e1dedc304..2e5b9ee0f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -12,6 +12,7 @@ import { Box, } from "@mantine/core"; +import { PageContainer } from "@/components/page"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard"; import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar"; @@ -61,7 +62,7 @@ export default function BookingRequestDetailPage() { if (isLoading) { return ( - +
@@ -70,13 +71,13 @@ export default function BookingRequestDetailPage() {
-
+ ); } if (isError || !booking) { return ( - +
@@ -111,7 +112,7 @@ export default function BookingRequestDetailPage() { - + ); } @@ -127,16 +128,15 @@ export default function BookingRequestDetailPage() { booking.status === "APPROVED_PENDING_SIGNATURE"; return ( - - - + + - + } onClick={() => navigate(`/dashboard/booking-requests/${booking.id}/contract`) @@ -206,8 +206,7 @@ export default function BookingRequestDetailPage() { - - - + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 38d1acec6..ecf08bdfc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -1,32 +1,45 @@ +import { + ActionIcon, + Box, + Button, + Card, + Group, + Stack, + Tabs, + Text, + TextInput, +} from "@mantine/core"; +import { + AlertTriangle, + ArrowRight, + Calendar, + CheckCircle2, + Clock, + LayoutList, + Package, + Plus, + RefreshCw, + Search, + User, + X, +} from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { ArrowRight, Calendar, Package, Search, User, X } from "lucide-react"; -import { - Container, - Stack, - Group, - Text, - Card, - TextInput, - ActionIcon, - Tabs, -} from "@mantine/core"; -import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; +import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusTabs, type BookingStatusTabKey, } from "@/components/bookings/BookingStatusTabs"; -import { BookingRequestsHeader } from "@/components/bookings/BookingRequestsHeader"; -import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; -import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell"; -import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard"; -import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; +import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty"; import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue"; import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings"; -import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard"; import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { @@ -36,13 +49,12 @@ import { } from "@/hooks/bookings/useBookings"; import type { BookingListFilter } from "@/services/bookings.service"; import type { BookingListRow } from "@/types/booking"; -import { cn } from "@/lib/utils"; import { + Badge, DataTable, DataTableFooter, - type ColumnDef, usePagination, - Badge, + type ColumnDef, } from "@edr/ui-common"; function getStatusesForTab(tab: BookingStatusTabKey): string | undefined { @@ -51,13 +63,25 @@ function getStatusesForTab(tab: BookingStatusTabKey): string | undefined { return match.statuses.join(","); } +function formatDate(value: string | null | undefined): string { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + type OperationsSubTab = "ready" | "scheduled"; export default function BookingRequestsPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); - const [activeTab, setActiveTab] = useState("in_approval"); + const [activeTab, setActiveTab] = useState("all"); const [operationsSubTab, setOperationsSubTab] = useState("ready"); const [allocateOpen, setAllocateOpen] = useState(false); const [allocateIds, setAllocateIds] = useState([]); @@ -252,7 +276,7 @@ export default function BookingRequestsPage() { cell: ({ row }) => ( - {row.original.scheduledDate} + {formatDate(row.original.scheduledDate)} ), }, @@ -263,29 +287,9 @@ export default function BookingRequestsPage() { ), }, - { - id: "amount", - header: () => ( - Amount - ), - cell: ({ row }) => { - const b = row.original; - return ( - - {b.paymentCurrency}{" "} - {b.totalAmount.toLocaleString(undefined, { - minimumFractionDigits: 2, - })} - - ); - }, - }, { id: "actions", size: 140, - header: () => ( - Actions - ), cell: ({ row }) => ( - - + + + + + + + } + /> - - navigate("/dashboard/booking-requests/new")} - onRefresh={handleRefresh} + items={[ + { + label: "In queue", + value: metrics?.inQueue ?? 0, + icon: LayoutList, + color: "edr-green", + }, + { + label: "Needs action", + value: metrics?.needsAction ?? 0, + icon: Clock, + color: "yellow", + }, + { + label: "Urgent", + value: metrics?.urgent ?? 0, + icon: AlertTriangle, + color: "red", + }, + { + label: "Completed", + value: tabCounts?.completed ?? 0, + icon: CheckCircle2, + color: "edr-green", + }, + ]} /> - - - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query && ( - setQuery("")} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - - {total} record{total !== 1 ? "s" : ""} - - + + + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query && ( + setQuery("")} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> + + {total} record{total !== 1 ? "s" : ""} + + + {isOperationsTab ? ( - - - setOperationsSubTab((value as OperationsSubTab) ?? "ready") - } - > - - Ready to allocate - On train / scheduled - - - {isError ? ( - - ) : operationsSubTab === "ready" ? ( - - ) : ( - - )} - + + + + setOperationsSubTab((value as OperationsSubTab) ?? "ready") + } + > + + Ready to allocate + On train / scheduled + + + {isError ? ( + + ) : operationsSubTab === "ready" ? ( + + ) : ( + + )} + + ) : showEmpty ? ( - + + + ) : ( -
+ -
+ )}
@@ -441,7 +479,6 @@ export default function BookingRequestsPage() { initialBookingIds={allocateIds} /> ) : null} -
-
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index 042067edb..3f5323c7e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -147,7 +147,7 @@ function FormSection({ icon: Icon, title, subtitle, - accent = "green", + accent = "edr-green", right, children, }: { @@ -396,7 +396,7 @@ export default function NewBookingPage() { {/* LEFT — form */} - + - + Summary diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index b3d4cc63e..8bcec226c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -132,7 +132,7 @@ const OverviewPage = () => { value={activeTab} onChange={(value) => setActiveTab((value as OverviewTabKey) ?? "bookings")} variant="pills" - color="green" + color="edr-green" keepMounted={false} classNames={{ list: "ov-tablist", tab: "ov-tab" }} > @@ -151,12 +151,7 @@ const OverviewPage = () => { size="sm" radius="sm" variant={isActive ? "white" : "light"} - color={isActive ? "green" : "gray"} - styles={ - isActive - ? { root: { background: "rgba(255,255,255,0.9)", color: "#15805f" } } - : undefined - } + color={isActive ? "edr-green" : "gray"} > {getTabBadge(tab)} diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementHostPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementHostPage.tsx index 526667a31..dedc41084 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementHostPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementHostPage.tsx @@ -1,113 +1,82 @@ -import { useEffect, useRef, useState } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { getCookie } from '@/auth/cookies'; +import { useEffect, useRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + UserManagementApp, + type UserManagementRuntimeOptions, + type UserManagementSessionSeed, +} from "@tria-plc/iamui"; -function readToken(): string | null { - return getCookie('auth-token') ?? null; -} +import { getCookie } from "@/auth/cookies"; -function readRefreshToken(): string | null { - return getCookie('refresh-token') ?? null; +import { iamConfig } from "./iamConfig"; + +function readInitialSession(): UserManagementSessionSeed | null { + const token = getCookie("auth-token"); + + if (!token) { + return null; + } + + const refreshToken = getCookie("refresh-token") ?? undefined; + + return { + token, + refreshToken, + rememberMe: true, + }; } export default function UserManagementHostPage() { - const navigate = useNavigate(); - const location = useLocation(); - const iframeRef = useRef(null); + const mountRef = useRef(null); + const rootRef = useRef(null); + const unmountTimerRef = useRef(null); - const mountBase = ( - (import.meta.env.VITE_USER_MANAGEMENT_BASE as string | undefined) ?? '/_um' - ).replace(/\/$/, ''); - - const moduleOrigin = window.location.origin; - - const [iframeSrc] = useState(() => { - const sub = location.pathname.replace(/^\/(?:dashboard\/)?um(?=\/|$)/, ''); - return mountBase + (sub || '/') + location.search; - }); - - // ✅ Send token when iframe loads - const handleIframeLoad = () => { - const token = readToken(); - const refreshToken = readRefreshToken(); - const target = iframeRef.current?.contentWindow; - - if (!token) { - console.warn('⚠️ No authentication token found'); - return; - } - - if (!target) { - console.warn('⚠️ No iframe reference'); - return; - } - - target.postMessage( - { - type: 'UM_AUTH_TOKEN', - token, - refreshToken, - }, - moduleOrigin - ); - - console.log('✅ Token sent to iframe module'); - }; - - // ✅ Listen for messages from iframe useEffect(() => { - const onMessage = (event: MessageEvent) => { - // Security: Only accept from same origin - if (event.origin !== moduleOrigin) { - console.warn('🚫 Blocked message from different origin:', event.origin); - return; - } + const mountNode = mountRef.current; - const data = event.data as { type?: string; path?: string } | undefined; - if (!data) return; + if (!mountNode) { + return; + } - // Handle auth request (if module asks for token again) - if (data.type === 'UM_REQUEST_AUTH') { - const token = readToken(); - const refreshToken = readRefreshToken(); - const target = iframeRef.current?.contentWindow; + if (unmountTimerRef.current !== null) { + window.clearTimeout(unmountTimerRef.current); + unmountTimerRef.current = null; + } - if (token && target) { - target.postMessage( - { - type: 'UM_AUTH_TOKEN', - token, - refreshToken, - }, - moduleOrigin - ); - console.log('✅ Token resent to iframe (on request)'); - } - return; - } + if (!rootRef.current) { + rootRef.current = createRoot(mountNode); + } - // Handle route synchronization - if (data.type === 'UM_ROUTE_CHANGED' && typeof data.path === 'string') { - const target = '/dashboard/um' + data.path; - if (window.location.pathname + window.location.search !== target) { - navigate(target, { replace: true }); - } - } + const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, ""); + const iamApiUrl = "/um-api"; + const runtime: UserManagementRuntimeOptions = { + basename: "/um", + apiBaseUrl, + apiUrl: iamApiUrl, + recordApiUrl: iamApiUrl, + chronicleUrl: iamApiUrl, + auditApiUrl: iamApiUrl, }; - window.addEventListener('message', onMessage); - return () => window.removeEventListener('message', onMessage); - }, [moduleOrigin, navigate]); + rootRef.current.render( + , + ); - return ( -
-