mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/priority
This commit is contained in:
6
.gitmodules
vendored
6
.gitmodules
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
@@ -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({
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
139
apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts
Normal file
139
apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
103
apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts
Normal file
103
apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts
Normal file
51
apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
258
apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts
Normal file
258
apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts
Normal 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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -333,7 +333,7 @@ const ManageRuleEngineOrderDialog = ({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
color="edr-green"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || isSaving}
|
||||
leftSection={
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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)" },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user