Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/priority

This commit is contained in:
Marshal
2026-06-22 11:43:38 +00:00
151 changed files with 8535 additions and 9476 deletions

6
.gitmodules vendored
View File

@@ -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

View File

@@ -36,8 +36,8 @@
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "^1.4.3",
"@tria-plc/iamapi-common": "^0.6.6",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",

View File

@@ -48,6 +48,10 @@ import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
//New Trains, Wagons, Container and Cargo management modules
@@ -131,6 +135,10 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
DemoFreightDataSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
Batch5TestDataSeeder,
Batch7TestDataSeeder,
Batch8TestDataSeeder,
WarehouseDemoSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -139,6 +147,14 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { }
@@ -149,6 +165,16 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.run();
await this.indodeFacilitySeeder.run();
await this.batch14TestDataSeeder.run();
await this.batch5TestDataSeeder.run();
await this.batch7TestDataSeeder.run();
await this.batch8TestDataSeeder.run();
await this.warehouseDemoSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
// FileUploadSettingsSeeder) are intentionally disabled — they stay
// registered as providers but are not run. Re-inject + call .run() to enable.

View File

@@ -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<void> {
await queryRunner.createTable(
new Table({

View File

@@ -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<void> {
await queryRunner.createTable(
new Table({

View File

@@ -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<void> {
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<void> {
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');
}
}
}

View File

@@ -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<void> {
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<void> {
if (await queryRunner.hasColumn(this.table, 'unloaded_at')) {
await queryRunner.dropColumn(this.table, 'unloaded_at');
}
}
}

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from 'typeorm';
/**
* Fix: migration 1750000000001 (AddFacilityIdToWarehouses) silently skipped because
* the freight.warehouses table didn't exist yet at that timestamp. The column was
* never added. Add it now with idempotent guards.
*/
export class AddFacilityIdToWarehousesFix1791000000004 implements MigrationInterface {
private readonly table = 'freight.warehouses';
public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasColumn(this.table, 'facility_id'))) {
await queryRunner.addColumn(
this.table,
new TableColumn({
name: 'facility_id',
type: 'uuid',
isNullable: true,
}),
);
}
const table = await queryRunner.getTable(this.table);
const hasFk = table?.foreignKeys.some((fk) => fk.columnNames.includes('facility_id'));
if (!hasFk) {
await queryRunner.createForeignKey(
this.table,
new TableForeignKey({
columnNames: ['facility_id'],
referencedColumnNames: ['id'],
referencedTableName: 'facilities',
referencedSchema: 'freight',
onDelete: 'SET NULL',
}),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable(this.table);
if (!table) return;
const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.includes('facility_id'));
if (foreignKey) {
await queryRunner.dropForeignKey(this.table, foreignKey);
}
if (await queryRunner.hasColumn(this.table, 'facility_id')) {
await queryRunner.dropColumn(this.table, 'facility_id');
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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];

View File

@@ -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<WarehouseInventoryStatus, WarehouseInventoryStatus[]> = {
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;

View File

@@ -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)

View File

@@ -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<ImportTrainRow[]> {
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<ImportTrainItemRow[]> {
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;
}
}

View File

@@ -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,
};
}
}

View File

@@ -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) {

View File

@@ -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<EligibleBookingRow[]> {
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<BulkReceiveResult> {
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<LoadPassedExportResult> {
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<ReadyToLoadRow[]> {
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<ReadyToLoadRow[]> {
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<ReadyToLoadRow[]> {
return this.exportInventoryByStatus('LOADED');
}
/** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */
private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> {
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<ImportUnloadedRow[]> {
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<ImportUnloadedRow[]> {
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<BulkDispatchResult> {
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<AutoUnloadArrivedResult> {
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<BulkInspectResult> {
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<WarehouseInventory> {
@@ -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<WarehouseInventory> {
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<WarehouseInventory> {
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<WarehouseInventory> {
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<string | null> {
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,

View File

@@ -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<Warehouse> {
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<Warehouse> {
@@ -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<void> {
const [existing] = await this.warehousesRepository.findAll({ where: { code } });

View File

@@ -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<void> {
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)}`,
);
}
}
}

View File

@@ -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<void> {
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<void> => {
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)}`,
);
}
}
}

View File

@@ -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<void> {
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)}`,
);
}
}
}

View File

@@ -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<void> {
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<Booking> =>
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<WarehouseInventory>,
): Promise<void> => {
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<void> {
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 }),
);
}
}
}

View File

@@ -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;

View File

@@ -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"

View File

@@ -1,78 +1,76 @@
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import {
Boxes,
Container,
FileText,
LayoutDashboard,
LayoutGrid,
Network,
Paperclip,
Package,
PackageCheck,
PackageOpen,
Paperclip,
Send,
Settings,
SlidersHorizontal,
Train,
Truck,
Container,
Package,
PackageOpen,
Users,
Wallet,
//TrainTrack,
} from "lucide-react";
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
import LoadingScreen from "./components/LoadingScreen";
import { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
import UsersPage from "./pages/dashboard/user-management/UsersPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import RoutesPage from "./pages/fleet/RoutesPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
import { RequirePermission } from "./components/auth/RequirePermission";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
mutedTitle: true,
items: [
{
label: "Overview",
@@ -216,34 +214,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Administration",
items: [
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
permission: FREIGHT_PERMS.admin,
children: [
{
label: "Users",
href: "/dashboard/user-management/users",
},
{
label: "Employees",
href: "/dashboard/user-management/employees",
},
{
label: "Position Types",
href: "/dashboard/user-management/position-types",
},
{
label: "Permissions",
href: "/dashboard/user-management/permissions",
},
{
label: "Roles",
href: "/dashboard/user-management/roles",
},
],
},
{
label: "File settings",
href: "/dashboard/file-settings",
@@ -344,218 +314,210 @@ const App = () => {
return (
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path='/um/*' element={<UserManagementHostPage />} />
<Route path="*" element={<Navigate to="/um" replace />} />
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);
}
return (
<Routes>
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="profile" element={<MyProfilePage />} />
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route
path="payments"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<PaymentsPage />
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route
path="payments"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<PaymentsPage />
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
<Route
path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
{/* iframe-based user management module */}
<Route path="um/*" element={<UserManagementHostPage />} />
{/* Legacy embedded user management routes */}
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
{/* Legacy embedded user management routes */}
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route
path="file-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<FileUploadSettingsPage />
</RequirePermission>
}
/>
<Route
path="dropdown-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<DropdownSettingsPage />
</RequirePermission>
}
/>
<Route
path="file-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<FileUploadSettingsPage />
</RequirePermission>
}
/>
<Route
path="dropdown-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<DropdownSettingsPage />
</RequirePermission>
}
/>
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route
path="configuration/train-scheduling-rules"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainSchedulingGlobalRulesPage />
</RequirePermission>
}
/>
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route
path="configuration/train-scheduling-rules"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainSchedulingGlobalRulesPage />
</RequirePermission>
}
/>
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-configs" replace />}
/>
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-configs" replace />}
/>
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route path="org-structure" element={<Navigate to="/um" replace />} />
<Route path="org-structure/*" element={<Navigate to="/um" replace />} />
</Route>
<Route
path="org-structure"
element={<Navigate to="/dashboard/user-management" replace />}
/>
<Route
path="org-structure/*"
element={<Navigate to="/dashboard/user-management" replace />}
/>
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>
);
};

View File

@@ -68,7 +68,7 @@ export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps
icon={ShieldCheck}
title="Approval chain"
extra={
<Badge color="green" variant="light" radius="sm">
<Badge color="edr-green" variant="light" radius="sm">
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
</Badge>
}
@@ -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 && (
<Button
size="compact-sm"
color="green"
color="edr-green"
leftSection={<Check size={14} />}
disabled={isPending}
onClick={() => onApprove(step)}

View File

@@ -86,7 +86,7 @@ export function BookingActionsMenu({
key={action.id}
size="sm"
variant={action.primary && !destructive ? "filled" : "default"}
color={destructive ? "red" : action.primary ? "green" : "gray"}
color={destructive ? "red" : action.primary ? "edr-green" : "gray"}
leftSection={<Icon size={16} />}
disabled={mutations.isPending}
onClick={() => handleAction(action)}
@@ -113,7 +113,7 @@ export function BookingActionsMenu({
{variant === "table" && primary && (
<Button
size="compact-sm"
color="green"
color="edr-green"
visibleFrom="lg"
leftSection={<primary.icon size={14} />}
disabled={mutations.isPending}

View File

@@ -49,7 +49,7 @@ export function BookingConfirmDialog({
const inputMissing =
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
const isDestructive = action.variant === "destructive";
const accent = isDestructive ? "red" : "green";
const accent = isDestructive ? "red" : "edr-green";
return (
<Modal

View File

@@ -20,7 +20,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
<Text
size="xl"
fw={700}
c="green.9"
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>

View File

@@ -62,7 +62,7 @@ export function BookingRequestsHeader({
return (
<Stack gap="lg">
<Group justify="flex-end" gap="sm">
<Button color="green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
<Button color="edr-green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
Create booking
</Button>
<Button

View File

@@ -8,7 +8,7 @@ const statusColorMap: Record<string, string> = {
CHANGES_REQUESTED: "orange",
PENDING_APPROVAL: "yellow",
APPROVED_PENDING_SIGNATURE: "cyan",
APPROVED: "green",
APPROVED: "edr-green",
CONTRACT_READY: "indigo",
SIGNED_CUSTOMER: "cyan",
FULLY_EXECUTED: "indigo",
@@ -16,7 +16,7 @@ const statusColorMap: Record<string, string> = {
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
SELECTED_FOR_BATCH: "orange",
EXPIRED: "red",
PAID: "green",
PAID: "edr-green",
IN_TRANSIT: "cyan",
COMPLETED: "indigo",
REJECTED: "red",

View File

@@ -1,3 +1,4 @@
import { Badge, ScrollArea, Tabs } from "@mantine/core";
import {
CheckCircle,
ClipboardCheck,
@@ -8,13 +9,12 @@ import {
Wallet,
XCircle,
} from "lucide-react";
import { Badge, Tabs } from "@mantine/core";
import "@/components/overview/overview.css";
import {
BOOKING_LIST_TABS,
type BookingStatusTabKey,
} from "@/features/bookings/booking-status.config";
import "@/components/overview/overview.css";
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
all: <LayoutGrid size={17} strokeWidth={1.85} />,
@@ -43,12 +43,13 @@ export function BookingStatusTabs({
value={active}
onChange={(value) => onChange((value as BookingStatusTabKey) ?? "all")}
variant="pills"
color="green"
color="edr-green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
{BOOKING_LIST_TABS.map((tab) => {
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
{BOOKING_LIST_TABS.map((tab) => {
const isActive = active === tab.key;
const count = counts?.[tab.key];
return (
@@ -56,13 +57,14 @@ export function BookingStatusTabs({
key={tab.key}
value={tab.key}
leftSection={TAB_ICONS[tab.key]}
size={"sm"}
rightSection={
count !== undefined ? (
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "green" : "gray"}
color={isActive ? "edr-green" : "gray"}
styles={
isActive
? { root: { background: "rgba(255,255,255,0.9)", color: "#15805f" } }
@@ -77,8 +79,9 @@ export function BookingStatusTabs({
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
})}
</Tabs.List>
</ScrollArea>
</Tabs>
);
}

View File

@@ -83,7 +83,7 @@ export function BookingWorkflowStepper({
<Text
size="xs"
fw={isActive ? 600 : 500}
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>

View File

@@ -190,7 +190,7 @@ export function OperationsBookingQueue({
<Badge variant="light">{selected.length} selected</Badge>
<Button
size="compact-sm"
color="green"
color="edr-green"
disabled={!selected.length}
onClick={(e) => {
e.stopPropagation();

View File

@@ -19,14 +19,14 @@ export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCar
<SectionCard
icon={CheckCircle}
title="Approval Workflow"
accent="green"
accent="edr-green"
extra={
<Badge color="green" variant="light" radius="sm">
<Badge color="edr-green" variant="light" radius="sm">
{approvedCount} / {steps.length} approved
</Badge>
}
>
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="green">
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="edr-green">
{steps.map((step) => (
<Timeline.Item
key={step.id}

View File

@@ -28,7 +28,7 @@ export function BookingDetailToolbar({
<Button variant="default" leftSection={<Download size={16} />} onClick={onExport}>
Export
</Button>
<Button color="green" leftSection={<CheckCircle size={16} />} onClick={onAction}>
<Button color="edr-green" leftSection={<CheckCircle size={16} />} onClick={onAction}>
Take Action
</Button>
</Group>

View File

@@ -68,7 +68,7 @@ export function BookingLifecycleStepper({ status }: BookingLifecycleStepperProps
<Text
size="xs"
fw={isActive ? 600 : 500}
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>

View File

@@ -29,7 +29,7 @@ export function BookingPaymentCard({
</Text>
</Group>
<Badge
color={paymentStatus === "PAID" ? "green" : "yellow"}
color={paymentStatus === "PAID" ? "edr-green" : "yellow"}
variant="light"
radius="sm"
mt="xs"

View File

@@ -49,13 +49,7 @@ export function BookingRequestHero({
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
style={{ position: "relative", overflow: "hidden" }}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
@@ -70,7 +64,7 @@ export function BookingRequestHero({
</Button>
<Button
variant="light"
color="green"
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
@@ -87,7 +81,7 @@ export function BookingRequestHero({
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px", color: "#0f172a" }}>
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<BookingStatusBadge status={booking.status} />
@@ -130,7 +124,7 @@ export function BookingRequestHero({
minimumFractionDigits: 2,
})}`}
hint={booking.paymentStatus}
accent="green"
accent="edr-green"
/>
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" accent="blue" />
<HeroTile
@@ -177,7 +171,7 @@ function HeroTile({
label,
value,
hint,
accent = "green",
accent = "edr-green",
}: {
icon: LucideIcon;
label: string;
@@ -204,7 +198,7 @@ function HeroTile({
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}>
{label}
</Text>
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap", color: "#0f172a" }}>
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}>
{value}
</Text>
{hint ? (

View File

@@ -30,7 +30,7 @@ export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) {
</ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
<Group justify="space-between">
<Badge color="green" variant="light" size="sm" radius="sm">
<Badge color="edr-green" variant="light" size="sm" radius="sm">
{note.type}
</Badge>
<Text size="xs" c="dimmed">

View File

@@ -9,38 +9,29 @@ export interface SectionCardProps {
title: string;
/** Optional one-line context shown under the title. */
subtitle?: string;
/** Mantine palette key used to tint the icon chip + top accent (default green). */
/** Mantine palette key used to tint the icon chip (default brand green). */
accent?: string;
extra?: ReactNode;
children: ReactNode;
}
/** Consistent card with a colored icon chip + accent stripe header used by every detail section. */
/** Consistent card with a colored icon chip header used by every detail section. */
export function SectionCard({
icon: Icon,
title,
subtitle,
accent = "green",
accent = "edr-green",
extra,
children,
}: SectionCardProps) {
return (
<Paper radius="md" withBorder style={{ ...detailStyles.card, overflow: "hidden" }}>
<Box
style={{
height: 3,
background: `linear-gradient(90deg, var(--mantine-color-${accent}-5) 0%, var(--mantine-color-${accent}-7) 100%)`,
}}
/>
<Group
justify="space-between"
px="xl"
py="md"
wrap="nowrap"
style={{
...detailStyles.cardHeader,
background: `linear-gradient(180deg, var(--mantine-color-${accent}-0) 0%, white 100%)`,
}}
style={detailStyles.cardHeader}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Box

View File

@@ -55,7 +55,7 @@ export const detailStyles = {
export function approvalStatusColor(status: string): string {
switch (status) {
case "APPROVED":
return "green";
return "edr-green";
case "PENDING":
return "yellow";
case "REJECTED":

View File

@@ -94,8 +94,8 @@ const FleetCardGrid = ({
width: 40,
height: 40,
borderRadius: 10,
background: "var(--mantine-color-green-0)",
color: "var(--mantine-color-green-7)",
background: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
display: "flex",
alignItems: "center",
justifyContent: "center",

View File

@@ -195,7 +195,7 @@ const FleetFormDialog = ({
<Button variant="default" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button color="green" loading={isSubmitting} onClick={handleSubmit}>
<Button color="edr-green" loading={isSubmitting} onClick={handleSubmit}>
Save
</Button>
</Group>

View File

@@ -35,7 +35,7 @@ const FleetRecordActions = ({
{showDetail ? (
<Button
variant="light"
color="green"
color="edr-green"
size="compact-sm"
radius="md"
onClick={handleDetail}
@@ -72,7 +72,7 @@ const FleetRecordActions = ({
<Group gap={4} wrap="nowrap" justify="flex-end">
{showDetail ? (
<Tooltip label="Manage wagons">
<ActionIcon variant="subtle" color="green" size="md" radius="md" onClick={handleDetail}>
<ActionIcon variant="subtle" color="edr-green" size="md" radius="md" onClick={handleDetail}>
<Truck size={16} />
</ActionIcon>
</Tooltip>

View File

@@ -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 ? (
<Button
color="green"
color="edr-green"
radius="lg"
size="sm"
fw={600}

View File

@@ -1,126 +0,0 @@
/* ============================================================
EDR Freight — Header styles
============================================================ */
.fdh-root {
display: flex;
height: 80px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 22px;
}
/* eyebrow above the page title */
.fdh-eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #1B9E7A;
margin-bottom: 3px;
}
.fdh-eyebrow-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #2DBF95;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.16);
}
/* action icon buttons */
.fdh-icon-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: #f7f9fb;
border: 1px solid #eef1f4;
color: #475569;
cursor: pointer;
transition: all 160ms ease;
}
.fdh-icon-btn:hover {
background: #ffffff;
border-color: rgba(27, 158, 122, 0.28);
color: #1B9E7A;
box-shadow: 0 4px 12px -4px rgba(27, 158, 122, 0.28);
transform: translateY(-1px);
}
.fdh-icon-btn:active {
transform: translateY(0);
}
/* notification badge */
.fdh-badge {
position: absolute;
top: -5px;
right: -5px;
min-width: 17px;
height: 17px;
padding: 0 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
color: #ffffff;
font-size: 10px;
font-weight: 700;
line-height: 1;
border: 2px solid #ffffff;
box-shadow: 0 2px 6px -1px rgba(239, 68, 68, 0.45);
}
.fdh-divider {
width: 1px;
height: 30px;
background: #e9eef3;
margin: 0 2px;
}
/* user button */
.fdh-user {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 12px 5px 5px;
border-radius: 13px;
cursor: pointer;
border: 1px solid transparent;
transition: all 160ms ease;
}
.fdh-user:hover {
background: #f7f9fb;
border-color: #eef1f4;
}
.fdh-avatar-ring {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
padding: 2px;
background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 100%);
box-shadow: 0 4px 10px -3px rgba(27, 158, 122, 0.4);
}
.fdh-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, #1B9E7A 0%, #15805F 100%);
color: #ffffff;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.3px;
border: 2px solid #ffffff;
}

View File

@@ -1,20 +1,31 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import {
AppShell,
Avatar,
Box,
Burger,
Divider,
Group,
Indicator,
Menu,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import {
Bell,
ChevronDown,
FileSignature,
Languages,
LogOut,
MessageSquare,
Moon,
Search,
Sun,
User,
} from "lucide-react";
import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core";
import { type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { PageMeta } from "./types";
import "./FreightDashboardHeader.css";
export interface FreightDashboardHeaderProps {
pageMeta: PageMeta;
@@ -26,10 +37,16 @@ export interface FreightDashboardHeaderProps {
onLogout?: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
mobileOpened: boolean;
onToggleMobile: () => void;
}
// Every header control is a consistent 36px frosted chip — same language as the
// portal AppLayout's floating "islands".
const ISLAND =
"flex size-9 shrink-0 items-center justify-center rounded-full border border-edr-border bg-white text-edr-text transition-colors hover:bg-[#F1F4F7]";
const FreightDashboardHeader = ({
pageMeta,
headerRight,
enableThemeToggle = false,
userName = "User",
@@ -38,8 +55,11 @@ const FreightDashboardHeader = ({
onLogout,
theme,
onToggleTheme,
mobileOpened,
onToggleMobile,
}: FreightDashboardHeaderProps) => {
const navigate = useNavigate();
const initials =
userInitials ??
(userName
@@ -50,191 +70,147 @@ const FreightDashboardHeader = ({
.join("") ||
"U");
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isUserMenuOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (
userMenuRef.current &&
!userMenuRef.current.contains(event.target as Node)
) {
setIsUserMenuOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsUserMenuOpen(false);
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isUserMenuOpen]);
return (
<header className="fdh-root">
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<span className="fdh-eyebrow">
{/* <span className="fdh-eyebrow-dot" />
Freight Backoffice */}
</span>
<Text
fw={700}
truncate
style={{ fontSize: "20px", lineHeight: 1.2, color: "#0f172a", letterSpacing: "-0.4px" }}
>
{pageMeta.title}
</Text>
{/* <Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
{pageMeta.subtitle}
</Text> */}
</Stack>
<Group gap={10} wrap="nowrap">
{enableThemeToggle && (
<Tooltip
label={theme === "dark" ? "Light mode" : "Dark mode"}
withArrow
openDelay={300}
<AppShell.Header
withBorder={false}
// Frosted glass: fully transparent background + backdrop blur, mirroring
// the portal AppLayout. Inline styles win over Mantine's cascade layer
// (a Tailwind bg-transparent would lose to --mantine-color-body).
style={{
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
border: "none",
boxShadow: "none",
background: "transparent",
}}
>
<Group h="100%" px={20} justify="space-between" wrap="nowrap">
{/* Left: burger (mobile) + search — the search now occupies the slot
the page title used to hold; each page owns its own title. */}
<Group gap={12} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
<Burger
opened={mobileOpened}
onClick={onToggleMobile}
hiddenFrom="sm"
size="sm"
aria-label="Toggle sidebar"
/>
<Group
gap={8}
align="center"
visibleFrom="sm"
className="h-9 cursor-text rounded-full border border-edr-border bg-white px-3.5"
style={{ width: 280 }}
>
<button
type="button"
className="fdh-icon-btn"
onClick={onToggleTheme}
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
<Search size={15} className="text-edr-muted" strokeWidth={1.8} />
<Text size="sm" className="select-none text-edr-muted!">
Search bookings, trains
</Text>
</Group>
</Group>
{/* Right: actions + avatar */}
<Group gap={10} wrap="nowrap" align="center">
<Tooltip label="Language" withArrow openDelay={300}>
<UnstyledButton className={ISLAND} aria-label="Language">
<Languages size={17} strokeWidth={1.8} />
</UnstyledButton>
</Tooltip>
)}
<Tooltip label="Language" withArrow openDelay={300}>
<button type="button" className="fdh-icon-btn" aria-label="Language">
<Languages size={18} />
</button>
</Tooltip>
<Tooltip label="Notifications" withArrow openDelay={300}>
<Indicator
color="edr-accent"
size={8}
offset={6}
withBorder
aria-label="Unread notifications"
>
<UnstyledButton className={ISLAND} aria-label="Notifications">
<Bell size={17} strokeWidth={1.8} />
</UnstyledButton>
</Indicator>
</Tooltip>
<Tooltip label="Messages" withArrow openDelay={300}>
<button type="button" className="fdh-icon-btn" aria-label="Messages">
<MessageSquare size={18} />
<span className="fdh-badge">3</span>
</button>
</Tooltip>
{enableThemeToggle && (
<Tooltip
label={theme === "dark" ? "Light mode" : "Dark mode"}
withArrow
openDelay={300}
>
<UnstyledButton
className={ISLAND}
onClick={onToggleTheme}
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun size={17} strokeWidth={1.8} />
) : (
<Moon size={17} strokeWidth={1.8} />
)}
</UnstyledButton>
</Tooltip>
)}
<Tooltip label="Notifications" withArrow openDelay={300}>
<button
type="button"
className="fdh-icon-btn"
aria-label="Notifications"
>
<Bell size={18} />
<span className="fdh-badge">5</span>
</button>
</Tooltip>
<div className="fdh-divider" />
<Menu
position="bottom-end"
shadow="lg"
radius="md"
width={240}
opened={isUserMenuOpen}
onOpen={() => setIsUserMenuOpen(true)}
onClose={() => setIsUserMenuOpen(false)}
>
<Menu.Target>
<div className="fdh-user">
<div className="fdh-avatar-ring">
<div className="fdh-avatar">{initials}</div>
</div>
<Stack gap={0} style={{ minWidth: 0 }} visibleFrom="sm">
<Text
size="sm"
fw={600}
truncate
style={{ color: "#0f172a", lineHeight: 1.25, maxWidth: 140 }}
>
{userName}
</Text>
<Text
size="xs"
truncate
style={{ color: "#94a3b8", lineHeight: 1.25, maxWidth: 140 }}
>
{userEmail ?? "Administrator"}
</Text>
</Stack>
<ChevronDown
size={16}
style={{
color: "#94a3b8",
flexShrink: 0,
transition: "transform 0.2s",
transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</div>
</Menu.Target>
<Menu.Dropdown>
<Box px="sm" py="xs">
<Group gap={10} wrap="nowrap">
<div className="fdh-avatar-ring">
<div className="fdh-avatar">{initials}</div>
</div>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate style={{ color: "#0f172a" }}>
{/* Avatar pill */}
<Menu width={240} position="bottom-end" withinPortal shadow="md" offset={8} radius="md">
<Menu.Target>
<UnstyledButton className="flex h-9 cursor-pointer items-center gap-2 rounded-full border border-edr-border bg-white py-0 pl-1 pr-2.5">
<Avatar radius="xl" size={28} color="edr-green.5">
<Text fw={700} fz={12} c="white">
{initials}
</Text>
</Avatar>
<Box visibleFrom="sm" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} truncate className="text-edr-text!" style={{ lineHeight: 1.2, maxWidth: 120 }}>
{userName}
</Text>
{userEmail && (
<Text size="xs" truncate style={{ color: "#94a3b8" }}>
{userEmail}
</Text>
)}
</Stack>
</Group>
</Box>
<Menu.Divider />
<Menu.Item
leftSection={<User size={15} />}
onClick={() => {
setIsUserMenuOpen(false);
navigate("/dashboard/profile");
}}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => {
setIsUserMenuOpen(false);
navigate("/dashboard/profile#signature");
}}
>
My signature
</Menu.Item>
<Menu.Item
leftSection={<LogOut size={15} />}
color="red"
onClick={() => {
setIsUserMenuOpen(false);
onLogout?.();
}}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Text size="xs" truncate className="text-edr-muted!" style={{ lineHeight: 1.2, maxWidth: 120, fontSize: 11 }}>
{userEmail ?? "Administrator"}
</Text>
</Box>
<ChevronDown size={15} className="text-edr-muted" strokeWidth={1.8} />
</UnstyledButton>
</Menu.Target>
{headerRight}
<Menu.Dropdown>
<Box px="sm" py="xs">
<Text size="sm" fw={600} truncate className="text-edr-text!">
{userName}
</Text>
{userEmail && (
<Text size="xs" c="dimmed" truncate>
{userEmail}
</Text>
)}
</Box>
<Divider />
<Menu.Item
leftSection={<User size={15} />}
onClick={() => navigate("/dashboard/profile")}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/dashboard/profile#signature")}
>
My signature
</Menu.Item>
<Divider />
<Menu.Item
leftSection={<LogOut size={15} />}
color="red"
onClick={() => onLogout?.()}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
{headerRight}
</Group>
</Group>
</header>
</AppShell.Header>
);
};

View File

@@ -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<Theme>(() =>
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 (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
rel="stylesheet"
<AppShell
layout="alt"
padding={0}
className="bg-edr-bg"
header={{ height: HEADER_HEIGHT }}
navbar={{
width: NAVBAR_WIDTH,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened },
}}
>
<FreightDashboardHeader
pageMeta={pageMeta}
headerRight={headerRight}
enableThemeToggle={enableThemeToggle}
userName={userName}
userEmail={userEmail}
userInitials={userInitials}
onLogout={onLogout}
theme={theme}
onToggleTheme={toggleTheme}
mobileOpened={mobileOpened}
onToggleMobile={toggleMobile}
/>
<MantineProvider theme={freightMantineTheme}>
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={navigate}
onClose={closeMobile}
/>
<AppShell.Main>
{/* Internal scroll keeps the fixed-viewport model the dashboard pages
assume (sidebar + header stay put, content scrolls beneath). */}
<Box
style={{
display: "flex",
height: "100dvh",
overflow: "hidden",
background: "var(--mantine-color-gray-1)",
padding: "0px",
fontFamily: "'Outfit', var(--font-sans)",
}}
className="overflow-y-auto bg-edr-bg"
style={{ height: `calc(100dvh - ${HEADER_HEIGHT}px)` }}
>
<Box style={{ display: "flex", height: "100%", minHeight: 0, width: "100%", gap: "5px" }}>
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={onNavigate}
/>
<Box
style={{
display: "flex",
height: "100%",
minHeight: 0,
minWidth: 0,
flex: 1,
flexDirection: "column",
gap: "8px",
}}
>
<Paper
p={0}
radius="lg"
withBorder
style={{
flexShrink: 0,
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
}}
>
<FreightDashboardHeader
pageMeta={pageMeta}
headerRight={headerRight}
enableThemeToggle={enableThemeToggle}
userName={userName}
userEmail={userEmail}
userInitials={userInitials}
onLogout={onLogout}
theme={theme}
onToggleTheme={toggleTheme}
/>
</Paper>
<Paper
p={{ base: 16, md: 24 }}
radius="lg"
withBorder
style={{
minHeight: 0,
flex: 1,
overflowY: "auto",
overscrollBehavior: "contain",
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
}}
>
{children}
</Paper>
</Box>
</Box>
{children}
</Box>
</MantineProvider>
</>
</AppShell.Main>
</AppShell>
);
};

View File

@@ -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;
}
}

View File

@@ -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<string, boolean> = {};
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<Record<string, boolean>>(defaultExpanded);
}, [sections, isHrefActive, branchActive]);
const [openMap, setOpenMap] = useState(defaultOpen);
useEffect(() => {
setExpanded((current) => ({ ...defaultExpanded, ...current }));
}, [defaultExpanded]);
setOpenMap((current) => ({ ...defaultOpen, ...current }));
}, [defaultOpen]);
const navigateTo = (event: MouseEvent<HTMLAnchorElement>, 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 (
<div key={key}>
<button
type="button"
className="fsb-group"
data-active={groupActive}
onClick={() => toggleExpanded(key)}
>
<span className="fsb-group-label">{child.label}</span>
<ChevronDown
size={13}
className="fsb-chevron"
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
color: groupActive ? "#1B9E7A" : undefined,
// `opened` is controlled so a link-parent navigates on row click
// without collapsing; the chevron is the only toggle affordance.
<NavLink
key={key}
label={item.label}
leftSection={item.icon}
active={active}
opened={isOpen}
classNames={navClassNames(active)}
onClick={ () => toggle(key)}
rightSection={
<Box
component="span"
role="button"
aria-label={isOpen ? "Collapse section" : "Expand section"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggle(key);
}}
/>
</button>
{isOpen && (
<div className="fsb-branch">
{renderNavBranch(child.children!, depth + 1, key)}
</div>
className="flex cursor-pointer items-center"
>
<ChevronDown
size={16}
className="text-edr-muted transition-transform duration-200"
style={{ transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)" }}
/>
</Box>
}
>
{item.children!.map((child, i) =>
renderItem(child, itemKey(key, child, i)),
)}
</div>
</NavLink>
);
}
if (!child.href) return null;
const childActive = isHrefActive(child.href);
if (!item.href) return null;
const active = isHrefActive(item.href);
return (
<a
<NavLink
key={key}
href={child.href}
className="fsb-child"
data-active={childActive}
onClick={(e) => navigateTo(e, child.href!)}
>
<span className="fsb-dot" />
<span className="fsb-item-label">{child.label}</span>
</a>
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 (
<Box key={item.href}>
<a
href={item.href}
className="fsb-item"
data-active={isActive}
onClick={(e) => {
if (hasChildren) {
setExpanded((current) => ({
...current,
[item.href!]: true,
}));
}
navigateTo(e, item.href!);
}}
>
{item.icon && <span className="fsb-icon">{item.icon}</span>}
<span className="fsb-item-label">{item.label}</span>
{hasChildren && (
<button
type="button"
className="fsb-chevron-btn"
aria-label={isOpen ? "Collapse section" : "Expand section"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleExpanded(item.href!);
}}
>
<ChevronDown
size={16}
className="fsb-chevron"
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
}}
/>
</button>
)}
</a>
{hasChildren && isOpen && (
<div className="fsb-branch">
{renderNavBranch(item.children!, 0, item.href)}
</div>
)}
</Box>
);
};
return (
<Box component="aside" className="fsb-aside">
<div className="fsb-brand">
<div className="fsb-logo">
<Train size={23} color="white" strokeWidth={2.1} />
</div>
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
<Text
size="md"
fw={700}
style={{ letterSpacing: "-0.3px", lineHeight: 1.2, color: "#0f172a" }}
>
EDR Freight
</Text>
const renderedSections = useMemo(
() =>
sections.map((section) => (
<Box key={section.title}>
<Text
size="xs"
fw={600}
style={{ letterSpacing: "0.4px", color: "#94a3b8" }}
tt="uppercase"
px="sm"
mb={6}
className={ "text-edr-muted!" }
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
>
Backoffice Console
{section.title}
</Text>
</Stack>
</div>
<nav className="fsb-nav">
{sections.map((section) => (
<div key={section.title}>
<div className="fsb-section-label">{section.title}</div>
<Stack gap={3}>
{section.items.map((item) => renderTopLevelItem(item))}
</Stack>
</div>
))}
</nav>
<div className="fsb-footer">
<div className="fsb-status">
<span className="fsb-pulse" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" fw={600} style={{ color: "#15805F", lineHeight: 1.3 }}>
All systems operational
</Text>
<Text size="10px" style={{ color: "#94a3b8", lineHeight: 1.3 }}>
EDR Platform · v1.0
</Text>
<Stack gap={2}>
{section.items.map((item, i) =>
renderItem(item, itemKey(section.title, item, i)),
)}
</Stack>
</div>
</div>
</Box>
</Box>
)),
[renderItem, sections],
);
return (
<AppShell.Navbar
withBorder={false}
// White surface + hairline right border, matching the portal AppLayout.
// Inline styles beat Mantine's cascade layer (where a Tailwind bg-* would
// lose to the navbar's default --mantine-color-body).
style={{
backgroundColor: "var(--mantine-color-edr-card-6)",
borderRight: "1px solid var(--mantine-color-edr-border-6)",
}}
>
{/* Brand — aligns with the 64px header for a continuous top edge */}
<Box className="flex h-16 shrink-0 items-center justify-between px-5">
<Group gap={10} wrap="nowrap">
<img
src={BRAND_LOGO}
alt="EDR Freight"
className="size-8 shrink-0 object-contain"
/>
<Box>
<Text
className="text-edr-primary! leading-tight"
fw={700}
fz={15}
style={{ letterSpacing: "-0.01em" }}
>
EDR Freight
</Text>
<Text
className="text-edr-muted!"
fz={10}
fw={500}
style={{ letterSpacing: "0.02em" }}
>
Backoffice Console
</Text>
</Box>
</Group>
{onClose && (
<UnstyledButton onClick={onClose} hiddenFrom="sm" aria-label="Close sidebar">
<X size={18} className="text-edr-muted" strokeWidth={1.8} />
</UnstyledButton>
)}
</Box>
{/* Nav */}
<AppShell.Section grow component={ScrollArea} type="never" px="sm" pb="md">
<Stack gap="lg">{renderedSections}</Stack>
</AppShell.Section>
</AppShell.Navbar>
);
};

View File

@@ -50,6 +50,27 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "View booking payment transactions",
},
},
{
prefix: "/dashboard/warehouse-dashboard",
meta: {
title: "Warehouse Dashboard",
subtitle: "Live overview of warehouse capacity and inventory lifecycle",
},
},
{
prefix: "/dashboard/warehouses/",
meta: {
title: "Warehouse detail",
subtitle: "Yards, zones, and inventory for this warehouse",
},
},
{
prefix: "/dashboard/warehouses",
meta: {
title: "Warehouses",
subtitle: "Manage warehouses, yards and zones",
},
},
{
prefix: "/dashboard/operations/train-scheduling-v2/",
meta: {

View File

@@ -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<OverviewKpiItem["accent"]>[] = [
"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<string, string> = {
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 (
<Paper
p="lg"
radius="lg"
withBorder
style={{
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
{title && (
<Text size="sm" fw={600} mb="md" c="dimmed">
{title}
</Text>
)}
<Group gap="md" align="stretch" wrap="wrap">
{items.map((item, index) => (
<OverviewKpiCard
key={item.label}
item={{
...item,
accent: ACCENT_CYCLE[index % ACCENT_CYCLE.length],
variant: item.variant ?? VARIANT_CYCLE[index % VARIANT_CYCLE.length],
progress:
item.progress ?? (max > 0 ? toNumber(item.value) / max : 0),
}}
/>
))}
</Group>
</Paper>
);
return <KpiStrip items={kpiItems} />;
}

View File

@@ -47,11 +47,11 @@ export function OverviewPageHeader({
data={RANGE_OPTIONS}
size="sm"
radius="lg"
color="green"
color="edr-green"
/>
<ActionIcon
variant="light"
color="green"
color="edr-green"
size="lg"
radius="lg"
aria-label="Refresh dashboard"

View File

@@ -49,7 +49,7 @@ export function OverviewQuickLinks() {
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group align="flex-start" gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="green" size="lg" radius="md">
<ThemeIcon variant="light" color="edr-green" size="lg" radius="md">
<Icon size={18} />
</ThemeIcon>
<Stack gap={2}>

View File

@@ -78,7 +78,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
<Stack gap="md" pos="relative">
{isFetching && (
<Center style={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}>
<Loader size="sm" color="green" />
<Loader size="sm" color="edr-green" />
</Center>
)}

View File

@@ -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;

View File

@@ -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 (
<Card withBorder shadow="sm" p={0} className="overflow-hidden">
<div className="flex flex-col sm:flex-row">
{cells.map((item, index) => {
const Icon = item.icon;
const color = item.color ?? "edr-green";
return (
<div
key={item.label}
className={cn(
"flex flex-1 items-center gap-3 px-5 py-4",
index > 0 &&
"border-t border-edr-border sm:border-l sm:border-t-0",
)}
>
{Icon ? (
<div
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
style={{
background: `var(--mantine-color-${color}-1)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
<Icon size={20} strokeWidth={2} />
</div>
) : null}
<div style={{ minWidth: 0 }}>
{loading ? (
<Skeleton height={26} width={72} radius="sm" my={2} />
) : (
<Text
fw={800}
fz={24}
lh={1.05}
c="edr-text"
style={{ letterSpacing: "-0.02em" }}
truncate
>
{item.value}
</Text>
)}
<Text size="xs" fw={600} c="edr-muted" truncate>
{item.label}
{item.hint ? ` · ${item.hint}` : ""}
</Text>
</div>
</div>
);
})}
</div>
</Card>
);
}
export default KpiStrip;

View File

@@ -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 (
<Box px="lg" py="lg" mx="auto" w="100%" maw={fluid ? undefined : 1600}>
<Stack gap={gap}>{children}</Stack>
</Box>
);
}
export default PageContainer;

View File

@@ -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 (
<Stack gap="sm">
{breadcrumbs?.length ? <Breadcrumbs items={breadcrumbs} /> : null}
<Group justify="space-between" align="flex-start" gap="md">
<Group gap="sm" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
{backTo ? (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => navigate(backTo)}
aria-label="Go back"
>
<ArrowLeft size={18} />
</ActionIcon>
) : null}
<div style={{ minWidth: 0 }}>
<Group gap="sm" align="center" wrap="nowrap">
<Title order={2} className="truncate">
{title}
</Title>
{meta}
</Group>
{subtitle ? (
<Text c="dimmed" size="sm" mt={4}>
{subtitle}
</Text>
) : null}
</div>
</Group>
{action ? (
<Group gap="sm" wrap="nowrap">
{action}
</Group>
) : null}
</Group>
</Stack>
);
}
export default PageHeader;

View File

@@ -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";

View File

@@ -333,7 +333,7 @@ const ManageRuleEngineOrderDialog = ({
Cancel
</Button>
<Button
color="green"
color="edr-green"
onClick={handleSave}
disabled={isLoading || isSaving}
leftSection={

View File

@@ -1,5 +1,5 @@
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
import { Stack, Group, Text, Card, SimpleGrid } from "@mantine/core";
import { Stack, Group, Text, Card, SimpleGrid, Skeleton } from "@mantine/core";
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine";
@@ -99,13 +99,16 @@ const RuleEngineCardGrid = ({
<Stack gap="md" p="md">
<SimpleGrid cols={{ base: 1, sm: 2, md: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} p="md" radius="lg" withBorder style={{ height: "280px", background: "var(--mantine-color-gray-0)" }}>
<div style={{ animation: "pulse 2s infinite", opacity: 0.5 }}>
<div style={{ height: "20px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "12px" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "20px", width: "80%" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "8px" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px" }} />
</div>
<Card key={index} p="lg">
<Group gap="sm" mb="md">
<Skeleton height={44} width={44} radius="md" />
<Stack gap={6} style={{ flex: 1 }}>
<Skeleton height={14} width="70%" radius="sm" />
<Skeleton height={10} width="40%" radius="sm" />
</Stack>
</Group>
<Skeleton height={12} radius="sm" mb={8} />
<Skeleton height={12} width="80%" radius="sm" />
</Card>
))}
</SimpleGrid>
@@ -124,8 +127,8 @@ const RuleEngineCardGrid = ({
);
}
const avatarBg = "#f1f5f9";
const avatarText = "#475569";
const avatarBg = "var(--mantine-color-gray-1)";
const avatarText = "var(--mantine-color-gray-7)";
return (
<Stack gap="md" p="md">
@@ -152,24 +155,7 @@ const RuleEngineCardGrid = ({
<Card
key={record.id}
p="lg"
radius="lg"
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
display: "flex",
flexDirection: "column",
transition: "all 0.2s ease",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.08)";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
style={{ display: "flex", flexDirection: "column" }}
>
<Group justify="space-between" align="flex-start" mb="md">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
@@ -207,7 +193,7 @@ const RuleEngineCardGrid = ({
</Group>
{(subtitle || presentation.detailColumns.length > 0) && (
<Stack gap="xs" style={{ flex: 1, marginBottom: "md" }}>
<Stack gap="xs" mb="md" style={{ flex: 1 }}>
{subtitle && (
<Group gap="xs">
<Text size="xs" c="dimmed" fw={500}>
@@ -234,7 +220,7 @@ const RuleEngineCardGrid = ({
</Stack>
)}
<Group justify="flex-end" gap="xs" style={{ borderTop: "1px solid var(--mantine-color-gray-1)", paddingTop: "md" }}>
<Group justify="flex-end" gap="xs" pt="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
<RuleEngineRecordActions
record={record}
config={config}

View File

@@ -119,15 +119,6 @@ const resolveSelectValue = (
const inputStyles = {
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" },
input: {
borderColor: "#e2e8f0",
background: "white",
transition: "border-color 0.15s ease, box-shadow 0.15s ease",
"&:focus": {
borderColor: "var(--freight-brand)",
boxShadow: "0 0 0 3px var(--freight-brand-ring)",
},
},
} as const;
const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => (
@@ -228,8 +219,8 @@ const RuleEngineFormDialog = ({
px="md"
style={{
minHeight: 42,
background: "#f8fafc",
border: "1px solid #e2e8f0",
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
}}
>
@@ -240,7 +231,7 @@ const RuleEngineFormDialog = ({
checked={Boolean(values[field.name])}
onChange={(e) => setField(field.name, e.currentTarget.checked)}
size="md"
color="green"
color="edr-green"
/>
</Group>
);
@@ -387,7 +378,7 @@ const RuleEngineFormDialog = ({
) : undefined
}
radius="md"
color="green"
color="edr-green"
variant="filled"
fw={600}
size="md"

View File

@@ -52,7 +52,7 @@ const RuleEngineToolbar = ({
onChange={(value) => onViewModeChange(value as RuleEngineViewMode)}
size="sm"
radius="lg"
color="green"
color="edr-green"
data={[
{
value: "table",
@@ -101,7 +101,7 @@ const RuleEngineToolbar = ({
leftSection={<Plus size={18} />}
size="sm"
radius="lg"
color="green"
color="edr-green"
variant="filled"
fw={600}
style={{ whiteSpace: "nowrap" }}

View File

@@ -39,7 +39,7 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
const active = Boolean(value);
return (
<Badge
color={active ? "green" : "gray"}
color={active ? "edr-green" : "gray"}
variant={active ? "filled" : "light"}
size="sm"
radius="md"
@@ -53,7 +53,7 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
const status = String(value);
const color =
status === "LIVE"
? "green"
? "edr-green"
: status === "DRAFT"
? "yellow"
: status === "PENDING_APPROVAL"

View File

@@ -40,7 +40,7 @@ export const ruleEngineCard = {
"group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md",
header: "border-b border-border bg-muted/25 px-3 py-2.5 sm:px-4 sm:py-3.5",
avatar:
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[#f1f5f9] text-xs font-semibold text-slate-600 sm:h-10 sm:w-10 sm:text-sm",
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground sm:h-10 sm:w-10 sm:text-sm",
title: "truncate text-sm font-semibold text-foreground sm:text-[15px]",
meta: "text-xs text-muted-foreground",
detailLabel:

View File

@@ -447,27 +447,27 @@ export function AllocateBookingWizard({
if (key === "bookings") {
if (previewResult) {
return (
<Badge variant="light" color={previewResult.valid ? "green" : "red"} radius="sm">
<Badge variant="light" color={previewResult.valid ? "edr-green" : "red"} radius="sm">
{previewResult.valid ? "Plan valid" : "Has issues"}
</Badge>
);
}
return allBookingIds.length ? (
<Badge variant="light" color="green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm">
{allBookingIds.length} selected
</Badge>
) : null;
}
if (key === "wagon" && displayWagonPlan.length) {
return (
<Badge variant="light" color="green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
);
}
if (key === "container" && containerUnits.length) {
return (
<Badge variant="light" color={containerComplete ? "green" : "yellow"} radius="sm">
<Badge variant="light" color={containerComplete ? "edr-green" : "yellow"} radius="sm">
{containerUnits.length} units
</Badge>
);
@@ -577,7 +577,7 @@ export function AllocateBookingWizard({
size="sm"
/>
<Button
color="green"
color="edr-green"
radius="md"
leftSection={<Eye size={16} />}
loading={preview.isPending}
@@ -648,7 +648,7 @@ export function AllocateBookingWizard({
<Group>
{!hasContainerStep ? (
<Button
color="green"
color="edr-green"
radius="md"
loading={assign.isPending || create.isPending}
onClick={handleAssign}
@@ -657,7 +657,7 @@ export function AllocateBookingWizard({
</Button>
) : (
<Button
color="green"
color="edr-green"
radius="md"
rightSection={<ContainerIcon size={16} />}
onClick={() => setActiveStep(2)}
@@ -692,7 +692,7 @@ export function AllocateBookingWizard({
)}
<Group>
<Button
color="green"
color="edr-green"
radius="md"
loading={assign.isPending || create.isPending}
onClick={handleAssign}
@@ -732,7 +732,7 @@ export function AllocateBookingWizard({
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
@@ -741,14 +741,14 @@ export function AllocateBookingWizard({
</Text>
<Text size="sm" c="dimmed">
Booking {booking.reference} is scheduled on train{" "}
<Text span fw={600} c="green.7">
<Text span fw={600} c="edr-green.7">
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}
</Text>
.
</Text>
<Group mt="sm">
<Button
color="green"
color="edr-green"
radius="md"
onClick={() => {
onClose();
@@ -777,14 +777,14 @@ export function AllocateBookingWizard({
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600}>Ready to finalize</Text>
<Text size="sm" c="dimmed">
Finalizing locks the plan, moves the schedule to{" "}
<Text span fw={600} c="green.7">
<Text span fw={600} c="edr-green.7">
SCHEDULED
</Text>
, and completes the booking allocation.
@@ -794,7 +794,7 @@ export function AllocateBookingWizard({
</Paper>
<Group>
<Button
color="green"
color="edr-green"
size="md"
radius="md"
leftSection={<CheckCircle2 size={18} />}
@@ -852,7 +852,7 @@ export function AllocateBookingWizard({
size={56}
radius="lg"
variant="white"
style={{ color: "var(--mantine-color-green-7)" }}
style={{ color: "var(--mantine-color-edr-green-7)" }}
>
<Train size={28} />
</ThemeIcon>
@@ -884,7 +884,7 @@ export function AllocateBookingWizard({
size="lg"
radius="sm"
variant="white"
c={previewResult.valid ? "green.8" : "red.7"}
c={previewResult.valid ? "edr-green.8" : "red.7"}
leftSection={
<Box
w={8}
@@ -892,7 +892,7 @@ export function AllocateBookingWizard({
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-green-6)"
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-red-6)",
}}
/>
@@ -941,7 +941,7 @@ export function AllocateBookingWizard({
size={44}
radius="md"
variant="gradient"
gradient={{ from: "green", to: "teal", deg: 135 }}
gradient={{ from: "edr-green", to: "teal", deg: 135 }}
>
<RouteIcon size={22} />
</ThemeIcon>
@@ -959,9 +959,9 @@ export function AllocateBookingWizard({
size={64}
thickness={6}
roundCaps
sections={[{ value: progressPct, color: "green" }]}
sections={[{ value: progressPct, color: "edr-green" }]}
label={
<Text ta="center" size="xs" fw={700} c="green.7">
<Text ta="center" size="xs" fw={700} c="edr-green.7">
{progressPct}%
</Text>
}

View File

@@ -120,7 +120,7 @@ export function ContainerPlacementGrid({
value={progress}
size="sm"
radius="xl"
color={issues.length ? "yellow" : "green"}
color={issues.length ? "yellow" : "edr-green"}
/>
</Stack>
</Paper>
@@ -135,7 +135,7 @@ export function ContainerPlacementGrid({
</Stack>
) : (
<Badge
color="green"
color="edr-green"
variant="light"
size="sm"
w="fit-content"
@@ -163,7 +163,7 @@ export function ContainerPlacementGrid({
{unit.label} · {unit.containerTypeCode} · {unit.grossWeightTons}T
</Text>
</Stack>
<Badge size="sm" variant="light" color={isComplete ? "green" : "gray"}>
<Badge size="sm" variant="light" color={isComplete ? "edr-green" : "gray"}>
{isComplete ? "Ready" : "Pending"}
</Badge>
</Group>

View File

@@ -37,14 +37,14 @@ function EligibleBookingRow({
p="sm"
style={{
border: `1px solid ${
selected ? "var(--mantine-color-green-3)" : "var(--mantine-color-gray-2)"
selected ? "var(--mantine-color-edr-green-3)" : "var(--mantine-color-gray-2)"
}`,
borderRadius: 12,
background: selected ? "var(--mantine-color-green-0)" : "white",
background: selected ? "var(--mantine-color-edr-green-0)" : "white",
transition: "border-color 120ms ease, background 120ms ease",
}}
>
<Checkbox checked={selected} onChange={onToggle} mt={4} color="green" />
<Checkbox checked={selected} onChange={onToggle} mt={4} color="edr-green" />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<Package size={14} />
@@ -205,7 +205,7 @@ export function EligibleBookingsPanel({
</Text>
</Stack>
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
<Badge variant="light" color="green">
<Badge variant="light" color="edr-green">
{selectedInBucket.length} selected
</Badge>
<Button

View File

@@ -43,7 +43,7 @@ export function FleetAvailabilitySummary({
</Text>
</Stack>
</Group>
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "green"}>
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "edr-green"}>
{fillRate}% fleet coverage
</Badge>
</Group>
@@ -59,7 +59,7 @@ export function FleetAvailabilitySummary({
value={fillRate}
size="sm"
radius="xl"
color={totalShortfall > 0 ? "yellow" : "green"}
color={totalShortfall > 0 ? "yellow" : "edr-green"}
/>
</Stack>
) : null}
@@ -86,7 +86,7 @@ export function FleetAvailabilitySummary({
{row.shortfall}
</Badge>
) : (
<Text size="sm" c="green">
<Text size="sm" c="edr-green">
0
</Text>
)}

View File

@@ -143,7 +143,7 @@ export function RouteCorridorTrack({
fw={passed ? 700 : 600}
ta="center"
lineClamp={2}
c={passed ? "green.8" : "dimmed"}
c={passed ? "edr-green.8" : "dimmed"}
>
{station.label}
</Text>
@@ -172,7 +172,7 @@ export function RouteCorridorTrack({
<Button
size="compact-xs"
radius="md"
color={isFinal ? "teal" : "green"}
color={isFinal ? "teal" : "edr-green"}
variant={isFinal ? "filled" : "light"}
loading={loggingSeq === station.sequenceNo}
onClick={() => onLogCheckpoint?.(station.sequenceNo)}

View File

@@ -26,7 +26,7 @@ interface ScheduleBatchPanelProps {
}
const windowColor: Record<string, string> = {
OPEN: "green",
OPEN: "edr-green",
FULL: "orange",
CLOSED: "gray",
};
@@ -68,7 +68,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Group justify="space-between" align="center" mb="md" wrap="wrap">
<Group gap="sm">
<ThemeIcon size={36} radius="md" variant="light" color="green">
<ThemeIcon size={36} radius="md" variant="light" color="edr-green">
<Layers size={18} />
</ThemeIcon>
<div>
@@ -90,7 +90,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
<Button
size="compact-sm"
variant="light"
color="green"
color="edr-green"
leftSection={<PlayCircle size={15} />}
loading={actions.runBatch.isPending}
onClick={() => run(actions.runBatch.mutateAsync(schedule.id), "Batch fill run")}
@@ -169,7 +169,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
<Button
size="compact-xs"
variant="light"
color="green"
color="edr-green"
leftSection={<CheckCircle2 size={13} />}
onClick={() => run(actions.markPaid.mutateAsync(b.id), "Marked paid")}
>
@@ -228,7 +228,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
Cancel
</Button>
<Button
color="green"
color="edr-green"
disabled={!moveTarget}
loading={actions.moveSchedule.isPending}
onClick={() => {

View File

@@ -1,4 +1,3 @@
import { ArrowRight, Package, Train } from "lucide-react";
import {
Badge,
Button,
@@ -8,6 +7,7 @@ import {
Tabs,
Text,
} from "@mantine/core";
import { ArrowRight, Package, Train } from "lucide-react";
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
@@ -46,7 +46,7 @@ export function ScheduleBookingsStep({
defaultValue={assignedBookings.length ? "on-train" : "add"}
radius="md"
variant="pills"
color="green"
color="edr-green"
>
<Tabs.List mb="md">
<Tabs.Tab
@@ -54,7 +54,7 @@ export function ScheduleBookingsStep({
leftSection={<Train size={14} />}
rightSection={
assignedBookings.length ? (
<Badge size="xs" variant="light" color="green" circle>
<Badge size="xs" variant="light" color="edr-green" circle>
{assignedBookings.length}
</Badge>
) : undefined
@@ -76,9 +76,9 @@ export function ScheduleBookingsStep({
justify="space-between"
p="sm"
style={{
border: "1px solid var(--mantine-color-green-2)",
border: "1px solid var(--mantine-color-edr-green-2)",
borderRadius: 12,
background: "var(--mantine-color-green-0)",
background: "var(--mantine-color-edr-green-0)",
}}
>
<Stack gap={4}>
@@ -87,7 +87,7 @@ export function ScheduleBookingsStep({
{booking.reference}
</Text>
{booking.weightTons != null ? (
<Badge variant="outline" size="xs" color="green">
<Badge variant="outline" size="xs" color="edr-green">
{booking.weightTons}T
</Badge>
) : null}
@@ -97,7 +97,7 @@ export function ScheduleBookingsStep({
Assigned to this consist
</Text>
<ArrowRight size={12} />
<Text size="xs" c="green.7" fw={500}>
<Text size="xs" c="edr-green.7" fw={500}>
Ready for wagon plan
</Text>
</Group>

View File

@@ -3,7 +3,7 @@ import { Badge } from "@mantine/core";
const STATUS_COLORS: Record<string, string> = {
DRAFT: "gray",
SCHEDULED: "blue",
DISPATCHED: "green",
DISPATCHED: "edr-green",
ARRIVED: "teal",
CANCELLED: "red",
};
@@ -34,7 +34,7 @@ export function SchedulingStatusBadge({ status }: { status?: string | null }) {
HOLDING: "yellow",
ELIGIBLE: "blue",
SCHEDULED: "indigo",
DISPATCHED: "green",
DISPATCHED: "edr-green",
};
return (
<Badge variant="light" color={colors[status] ?? "gray"} size="sm">

View File

@@ -54,7 +54,7 @@ export function PreviewSummary({
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
];
return (
<Paper p="md" radius="xl" withBorder bg="green.0">
<Paper p="md" radius="xl" withBorder bg="edr-green.0">
<Text size="sm" fw={600} mb="sm">
Plan summary
</Text>

View File

@@ -49,7 +49,7 @@ export function SchedulingWorkflowHeader({
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="flex-start">
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "green", to: "teal", deg: 135 }}>
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "edr-green", to: "teal", deg: 135 }}>
<Icon size={20} />
</ThemeIcon>
<Stack gap={4}>
@@ -63,7 +63,7 @@ export function SchedulingWorkflowHeader({
) : null}
</Stack>
</Group>
<Badge size="lg" variant="light" color="green">
<Badge size="lg" variant="light" color="edr-green">
Step {activeStep + 1} of {totalSteps}
</Badge>
</Group>
@@ -83,7 +83,7 @@ export function SchedulingWorkflowHeader({
{progress}%
</Text>
</Group>
<Progress value={progress} size="sm" radius="xl" color="green" />
<Progress value={progress} size="sm" radius="xl" color="edr-green" />
</Stack>
</Paper>
);

View File

@@ -618,18 +618,18 @@ export function TrainCompositionDiagram({
p="sm"
style={{
borderRadius: 12,
background: "linear-gradient(135deg, var(--mantine-color-green-0), #F2FBF7)",
border: "1px solid var(--mantine-color-green-1)",
background: "linear-gradient(135deg, var(--mantine-color-edr-green-0), #F2FBF7)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Group justify="space-between" mb={6}>
<Group gap={6} wrap="nowrap">
<Gauge size={14} color={freightBrand.primary} />
<Text size="xs" fw={700} c="green.8">
<Text size="xs" fw={700} c="edr-green.8">
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
</Text>
</Group>
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "green.7"}>
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
{stats.pullUtil}%
</Text>
</Group>
@@ -639,7 +639,7 @@ export function TrainCompositionDiagram({
radius="xl"
striped={stats.pullUtil > 95}
animated={stats.pullUtil > 95}
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "green"}
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "edr-green"}
/>
</Box>
) : null}

View File

@@ -133,7 +133,7 @@ export function WagonPlanGrid({
value={utilization}
size="sm"
radius="xl"
color={utilization > 95 ? "red" : utilization > 80 ? "yellow" : "green"}
color={utilization > 95 ? "red" : utilization > 80 ? "yellow" : "edr-green"}
/>
</Stack>
) : null}

View File

@@ -123,13 +123,13 @@ export function WorkflowStep({
<Text
size="xs"
fw={700}
c={isComplete || isActive ? "green.7" : "dimmed"}
c={isComplete || isActive ? "edr-green.7" : "dimmed"}
style={{ letterSpacing: 0.6 }}
>
STEP {index + 1}
</Text>
{isComplete ? (
<Badge size="xs" variant="light" color="green" radius="sm">
<Badge size="xs" variant="light" color="edr-green" radius="sm">
Done
</Badge>
) : null}
@@ -155,10 +155,10 @@ export function WorkflowStep({
height: 28,
borderRadius: 8,
background: open
? "var(--mantine-color-green-0)"
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-gray-1)",
color: open
? "var(--mantine-color-green-7)"
? "var(--mantine-color-edr-green-7)"
: "var(--mantine-color-gray-6)",
}}
>
@@ -209,7 +209,7 @@ export function WorkflowRail({ children }: { children: ReactNode }) {
bottom: 24,
width: 2,
background:
"linear-gradient(180deg, var(--mantine-color-green-3) 0%, var(--mantine-color-gray-3) 100%)",
"linear-gradient(180deg, var(--mantine-color-edr-green-3) 0%, var(--mantine-color-gray-3) 100%)",
borderRadius: 2,
pointerEvents: "none",
}}

View File

@@ -27,7 +27,7 @@ export const PIPELINE_STAGES: ReadonlyArray<{
{
key: "allocated",
label: "Allocated",
color: "green",
color: "edr-green",
hint: "Assigned to wagons on the train",
},
{
@@ -137,7 +137,7 @@ export function BookingPipeline({
}
const WINDOW_META: Record<string, { color: string; label: string; pulse: boolean }> = {
OPEN: { color: "green", label: "Window open", pulse: true },
OPEN: { color: "edr-green", label: "Window open", pulse: true },
FULL: { color: "orange", label: "Full", pulse: false },
CLOSED: { color: "gray", label: "Closed", pulse: false },
};

View File

@@ -95,7 +95,7 @@ export const AssignedBookingsPanel = ({
}}
>
<Group gap={8} wrap="nowrap" align="flex-start">
<ThemeIcon size={30} radius="md" variant="light" color="green">
<ThemeIcon size={30} radius="md" variant="light" color="edr-green">
<Package size={16} />
</ThemeIcon>
<Box style={{ flex: 1, minWidth: 0 }}>
@@ -107,7 +107,7 @@ export const AssignedBookingsPanel = ({
<Badge
size="xs"
variant="light"
color="green"
color="edr-green"
leftSection={<TrainFront size={9} />}
>
{wagonCountByBooking.get(booking.id)}

View File

@@ -43,7 +43,7 @@ function InfoRow({
return (
<Group justify="space-between" wrap="nowrap" gap="md">
<Group gap={8} wrap="nowrap">
<ThemeIcon size={28} radius="md" variant="light" color="green">
<ThemeIcon size={28} radius="md" variant="light" color="edr-green">
{icon}
</ThemeIcon>
<Text size="sm" c="dimmed">
@@ -146,7 +146,7 @@ export const BookingDetailModal = ({
bookingWagons.length ? (
<Group gap={4} justify="flex-end">
{bookingWagons.map((w) => (
<Badge key={w.id} size="sm" variant="outline" color="green" radius="sm">
<Badge key={w.id} size="sm" variant="outline" color="edr-green" radius="sm">
#{w.sequenceNo}
</Badge>
))}

View File

@@ -29,7 +29,7 @@ interface CompositionBookingTabsProps {
type TabKey = "assigned" | "unassigned" | "payment" | "expired" | "removed";
const TAB_META: Record<TabKey, { label: string; icon: LucideIcon; color: string }> = {
assigned: { label: "Assigned to train", icon: PackageCheck, color: "green" },
assigned: { label: "Assigned to train", icon: PackageCheck, color: "edr-green" },
unassigned: { label: "Unassigned (ready to load)", icon: PackagePlus, color: "orange" },
payment: { label: "Awaiting payment", icon: CreditCard, color: "orange" },
expired: { label: "Expired bookings", icon: XCircle, color: "red" },
@@ -152,7 +152,7 @@ export const CompositionBookingTabs = ({
value={tab}
onChange={(v) => v && setTab(v as TabKey)}
variant="default"
color="green"
color="edr-green"
style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}
>
<Tabs.List grow>
@@ -220,7 +220,7 @@ export const CompositionBookingTabs = ({
}}
>
<Group gap={5} wrap="nowrap">
<PackageCheck size={13} color="var(--mantine-color-green-7)" />
<PackageCheck size={13} color="var(--mantine-color-edr-green-7)" />
<Text size="xs" c="dimmed">
{assignedCount} on train
</Text>

View File

@@ -60,7 +60,7 @@ export const RemoveBookingConfirmModal = ({
<Text fw={800} size="sm">
{target?.reference ?? "Booking"}
</Text>
<Badge variant="light" color="green" leftSection={<TrainFront size={10} />}>
<Badge variant="light" color="edr-green" leftSection={<TrainFront size={10} />}>
{target?.wagonCount ?? 0} wagon{target?.wagonCount === 1 ? "" : "s"}
</Badge>
</Group>

View File

@@ -165,7 +165,7 @@ export const TrainConsistView = ({
{selectedWagon ? (
<Box>
<Group gap={6} mb={6} wrap="nowrap">
<Badge variant="light" color="green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm">
Editing wagon #{selectedWagon.sequenceNo}
</Badge>
<Text size="xs" c="dimmed">

View File

@@ -48,18 +48,18 @@ const YardFleetBanner = ({ fleetAtOrigin }: { fleetAtOrigin: FleetAvailabilityRo
py={6}
style={{
borderRadius: 8,
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-1)",
background: "var(--mantine-color-edr-green-0)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Group gap={5} wrap="nowrap">
<MapPin size={13} color="var(--mantine-color-green-7)" />
<Text size="xs" fw={700} c="green.8">
<MapPin size={13} color="var(--mantine-color-edr-green-7)" />
<Text size="xs" fw={700} c="edr-green.8">
Origin yard
</Text>
</Group>
{fleetAtOrigin.map((row) => (
<Badge key={row.wagonTypeId} size="sm" variant="light" color="green">
<Badge key={row.wagonTypeId} size="sm" variant="light" color="edr-green">
{row.wagonTypeCode}: {row.available}
</Badge>
))}
@@ -148,7 +148,7 @@ export const UnassignedBookingsPanel = ({
}
style={{
cursor: "pointer",
borderColor: isActive ? "var(--mantine-color-green-5)" : undefined,
borderColor: isActive ? "var(--mantine-color-edr-green-5)" : undefined,
}}
>
<Stack gap={6}>
@@ -162,7 +162,7 @@ export const UnassignedBookingsPanel = ({
{booking.reference}
</Text>
{booking.priorityScore ? (
<Badge size="xs" color="green">
<Badge size="xs" color="edr-green">
P{booking.priorityScore}
</Badge>
) : null}
@@ -194,7 +194,7 @@ export const UnassignedBookingsPanel = ({
<Button
size="xs"
variant="light"
color="green"
color="edr-green"
disabled={!fits}
onClick={(e) => {
e.stopPropagation();

View File

@@ -47,7 +47,7 @@ export const WagonCard = ({
<Card.Section withBorder inheritPadding py="xs" style={{ background: freightBrand.mutedBg }}>
<Group justify="space-between">
<Group gap={6}>
<ThemeIcon size={28} radius="md" variant="white" color="green">
<ThemeIcon size={28} radius="md" variant="white" color="edr-green">
<TrainFront size={16} />
</ThemeIcon>
<div>
@@ -55,7 +55,7 @@ export const WagonCard = ({
<Text size="sm" fw={800}>
Wagon #{wagon.sequenceNo}
</Text>
<Badge size="xs" variant="light" color="green">
<Badge size="xs" variant="light" color="edr-green">
{wagonType}
</Badge>
</Group>
@@ -142,7 +142,7 @@ export const WagonCard = ({
</Group>
<Progress
value={Math.min(weightPercent, 100)}
color={weightPercent > 90 ? "red" : weightPercent > 75 ? "orange" : "green"}
color={weightPercent > 90 ? "red" : weightPercent > 75 ? "orange" : "edr-green"}
size="sm"
radius="xl"
/>

View File

@@ -32,7 +32,7 @@ const STATUS_META: Record<
{ color: string; dot: string; label?: string }
> = {
DRAFT: { color: "gray", dot: "var(--mantine-color-gray-5)" },
SCHEDULED: { color: "green", dot: freightBrand.primary },
SCHEDULED: { color: "edr-green", dot: freightBrand.primary },
DISPATCHED: { color: "teal", dot: "var(--mantine-color-teal-6)" },
ARRIVED: { color: "blue", dot: "var(--mantine-color-blue-6)" },
CANCELLED: { color: "red", dot: "var(--mantine-color-red-6)" },

View File

@@ -2,7 +2,7 @@ import type { MantineTheme } from "@mantine/core";
export const schedulingWorkflow = {
stepper: {
color: "green" as const,
color: "edr-green" as const,
iconSize: 32,
size: "sm" as const,
},
@@ -12,11 +12,11 @@ export const schedulingWorkflow = {
withBorder: true,
},
heroGradient: (theme: MantineTheme) =>
`linear-gradient(135deg, ${theme.colors.green[0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
`linear-gradient(135deg, ${theme.colors["edr-green"][0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
workflowGradient: (theme: MantineTheme) =>
`linear-gradient(180deg, ${theme.white} 0%, ${theme.colors.gray[0]} 100%)`,
accentColor: "green" as const,
successColor: "green" as const,
accentColor: "edr-green" as const,
successColor: "edr-green" as const,
warningColor: "yellow" as const,
};

View File

@@ -1,6 +1,6 @@
import { Fragment } from "react";
import { Anchor, Breadcrumbs as MantineBreadcrumbs, Text } from "@mantine/core";
import { ChevronRight } from "lucide-react";
import { Link } from "react-router-dom";
import { ChevronRight, Home } from "lucide-react";
export interface BreadcrumbItem {
label: string;
@@ -12,45 +12,48 @@ export interface BreadcrumbsProps {
}
export default function Breadcrumbs({ items }: BreadcrumbsProps) {
return (
<nav
aria-label="Breadcrumb"
className="flex items-center text-sm text-slate-500"
>
<Link
to="/"
aria-label="Home"
className="flex items-center transition hover:text-[var(--freight-brand)]"
>
{/* <Home className="h-4 w-4" /> */}
Dashboard
</Link>
// The dashboard root is always the first crumb; callers pass only the trail
// beyond it.
const crumbs: BreadcrumbItem[] = [{ label: "Dashboard", href: "/" }, ...items];
{items.map((item, i) => {
const isLast = i === items.length - 1;
return (
<MantineBreadcrumbs
separator={<ChevronRight size={14} aria-hidden />}
separatorMargin="xs"
styles={{
root: { flexWrap: "wrap", rowGap: 4 },
separator: { color: "var(--mantine-color-edr-muted-5)" },
}}
>
{crumbs.map((item, index) => {
const isLast = index === crumbs.length - 1;
if (item.href && !isLast) {
return (
<Anchor
key={`${item.label}-${index}`}
component={Link}
to={item.href}
size="sm"
c="dimmed"
>
{item.label}
</Anchor>
);
}
return (
<Fragment key={`${item.label}-${i}`}>
<ChevronRight className="mx-2 h-4 w-4 text-slate-300" />
{item.href && !isLast ? (
<Link
to={item.href}
className="transition hover:text-[var(--freight-brand)]"
>
{item.label}
</Link>
) : (
<span
aria-current={isLast ? "page" : undefined}
className="font-medium text-slate-900"
>
{item.label}
</span>
)}
</Fragment>
<Text
key={`${item.label}-${index}`}
size="sm"
fw={isLast ? 600 : 400}
c={isLast ? "edr-text" : "dimmed"}
aria-current={isLast ? "page" : undefined}
>
{item.label}
</Text>
);
})}
</nav>
</MantineBreadcrumbs>
);
}

View File

@@ -56,7 +56,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
return (
<>
<Button
color="green"
color="edr-green"
size="sm"
radius="lg"
leftSection={<Plus size={16} />}
@@ -91,7 +91,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
<Button variant="default" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
<Button color="edr-green" loading={assign.isPending} onClick={handleAssign}>
Assign
</Button>
</Group>

View File

@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core';
import { Info } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useDeliverInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface DeliverInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) {
const { toast } = useToast();
const deliverMutation = useDeliverInventory();
const [receiverName, setReceiverName] = useState('');
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (opened) {
setReceiverName('');
setRemarks('');
}
}, [opened, item]);
const handleSubmit = async () => {
if (!item) return;
if (!receiverName.trim()) {
toast({ variant: 'destructive', title: 'Receiver name is required' });
return;
}
try {
await deliverMutation.mutateAsync({
id: item.id,
payload: { receiverName: receiverName.trim(), remarks: remarks.trim() || undefined },
});
toast({ title: 'Delivered — proof of delivery captured' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Delivery failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Deliver to customer (proof of delivery)" centered size="md">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="green" variant="light">
<Text size="sm">
A release order must already be issued. Capturing the receiver marks the goods <b>DELIVERED</b>.
</Text>
</Alert>
<TextInput
label="Receiver name"
required
placeholder="Who received the goods"
value={receiverName}
onChange={(e) => setReceiverName(e.currentTarget.value)}
/>
<Textarea
label="Remarks"
placeholder="Optional delivery notes"
minRows={2}
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={deliverMutation.isPending}>
Cancel
</Button>
<Button color="green" onClick={handleSubmit} loading={deliverMutation.isPending}>
Confirm delivery
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -15,7 +15,7 @@ const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
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
<Button
variant="light"
color="green"
color="edr-green"
leftSection={<DoorOpen size={16} />}
loading={gateClear.isPending}
onClick={handleGateClearance}

View File

@@ -205,7 +205,7 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
<Button variant="default" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button color="green" onClick={handleSubmit} loading={submitting} disabled={!inventoryId}>
<Button color="edr-green" onClick={handleSubmit} loading={submitting} disabled={!inventoryId}>
Save report
</Button>
</Group>

View File

@@ -1,18 +1,23 @@
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,
useStoreInventory,
} from '@/hooks/useWarehouses';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
@@ -20,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<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
@@ -32,10 +39,46 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const storeMutation = useStoreInventory();
const readyMutation = useMarkReadyForLoading();
const pickupMutation = useMarkReadyForPickup();
const dispatchMutation = useDispatchInventory();
const inspectMutation = useBulkMarkInspected();
const [selected, setSelected] = useState<Set<string>>(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<unknown>, label: string) => {
setBusyId(item.id);
@@ -63,6 +106,14 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
return;
case 'dispatch':
return runDirect(item, () => dispatchMutation.mutateAsync(item.id), 'Inventory dispatched');
case 'ready-for-pickup':
return runDirect(item, () => pickupMutation.mutateAsync(item.id), 'Ready for pickup');
case 'release':
setReleaseItem(item);
return;
case 'deliver':
setDeliverItem(item);
return;
default:
return;
}
@@ -78,15 +129,39 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
return (
<>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
/>
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Selected: <b>{selected.size}</b>
</Text>
<Button
size="compact-sm"
variant="light"
leftSection={<ClipboardCheck size={14} />}
disabled={selected.size === 0}
loading={inspectMutation.isPending}
onClick={markInspected}
>
Mark Selected as Inspected
</Button>
</Group>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
onLastMile={onLastMile}
selectedIds={selected}
onToggleSelect={toggleSelect}
onToggleSelectAll={toggleSelectAll}
allSelected={allSelected}
someSelected={someSelected}
/>
</Stack>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<ReserveInventoryModal
@@ -110,6 +185,8 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
onClose={() => setFeeItem(null)}
inventoryId={feeItem?.id ?? null}
/>
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
</>
);
}

View File

@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core';
import { Info } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useReleaseInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface ReleaseOrderModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
const { toast } = useToast();
const releaseMutation = useReleaseInventory();
const [reference, setReference] = useState('');
useEffect(() => {
if (opened) setReference(item?.releaseOrderReference ?? '');
}, [opened, item]);
const handleSubmit = async () => {
if (!item) return;
try {
await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } });
toast({ title: 'Release order issued' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Issue release order (DO)" centered size="md">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm">
Records the delivery order / release order sent to the customer. Once issued, the goods can be
picked up and delivered.
</Text>
</Alert>
<TextInput
label="Release order reference"
placeholder="e.g. DO-2026-001"
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending}>
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending}>
Issue release order
</Button>
</Group>
</Stack>
</Modal>
);
}

Some files were not shown because too many files have changed in this diff Show More