mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -53,23 +53,23 @@ import {
|
||||
} from "./seed/edr-freight.seed";
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||
// Disabled seeds — imports commented out with their provider/injection/run below.
|
||||
// import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
// import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
// 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 { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.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 { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { EdrTruckFleetSeeder } from "./seed/edr-truck-fleet.seeder";
|
||||
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
@@ -195,22 +195,22 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
FreightPositionsSeeder,
|
||||
DemoUsersSeeder,
|
||||
FreightStaffUsersSeeder,
|
||||
PricingDataSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
DemoFreightDataSeeder,
|
||||
GovCompaniesSeeder,
|
||||
EdrTruckFleetSeeder,
|
||||
IndodeFacilitySeeder,
|
||||
Batch14TestDataSeeder,
|
||||
Batch5TestDataSeeder,
|
||||
Batch7TestDataSeeder,
|
||||
Batch8TestDataSeeder,
|
||||
WarehouseDemoSeeder,
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
MarshallingDemoTrainsSeeder,
|
||||
// Disabled seeds — providers commented out (imports/injection/run too):
|
||||
// DemoUsersSeeder,
|
||||
// FreightStaffUsersSeeder,
|
||||
// PricingDataSeeder,
|
||||
// DemoFreightDataSeeder,
|
||||
// GovCompaniesSeeder,
|
||||
// IndodeFacilitySeeder,
|
||||
// Batch14TestDataSeeder,
|
||||
// Batch5TestDataSeeder,
|
||||
// Batch7TestDataSeeder,
|
||||
// Batch8TestDataSeeder,
|
||||
// WarehouseDemoSeeder,
|
||||
// ExportDjiboutiInterchangeDemoSeeder,
|
||||
// MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidImportExportMileDemoSeeder,
|
||||
],
|
||||
@@ -220,53 +220,66 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
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 exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
private readonly edrTruckFleetSeeder: EdrTruckFleetSeeder,
|
||||
// Disabled seeds — injections commented out (imports/provider/run too):
|
||||
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
// private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
// 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 exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
// private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
// private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
// private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
) { }
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
// ── Enabled: permissions + file-upload settings (+ dropdown settings) only ──
|
||||
// Everything else below is intentionally disabled. Seeders stay registered
|
||||
// as providers and injected; only their .run() calls are commented out, so
|
||||
// re-enabling any of them is a one-line uncomment.
|
||||
|
||||
// Permissions foundation — keep enabled:
|
||||
// freightPermissionKeyMigration → renames legacy permission keys
|
||||
// seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
|
||||
// edrOrgSeeder → seeds org/unit + the Permission catalog
|
||||
// freightPositionsSeeder → seeds Position + PositionPermission rows
|
||||
// (depends on edrOrgSeeder, must run after)
|
||||
await this.freightPermissionKeyMigrationSeeder.run();
|
||||
await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
|
||||
// File upload settings — keep enabled.
|
||||
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();
|
||||
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
await this.marshallingDemoTrainsSeeder.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.
|
||||
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
||||
// rules are disabled inside the seeder). Kept running for the staff users.
|
||||
await this.demoFreightDataSeeder.run();
|
||||
// Government entities (with importer/exporter profiles) that government
|
||||
// bookings bill to. Idempotent — keyed by fixed IDs.
|
||||
await this.govCompaniesSeeder.run();
|
||||
await this.edrTruckFleetSeeder.run();
|
||||
|
||||
// Dropdown settings are not seeded on boot; run them with
|
||||
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
|
||||
|
||||
// ── Disabled: demo / test / reference data seeds ──
|
||||
// Uncomment a line to re-enable that seed.
|
||||
// await this.demoUsersSeeder.run();
|
||||
// await this.freightStaffUsersSeeder.run();
|
||||
// await this.pricingDataSeeder.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();
|
||||
// await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
// await this.marshallingDemoTrainsSeeder.run();
|
||||
// demoFreightDataSeeder seeds ONLY the 4 staff users (wagons + approval
|
||||
// rules are already disabled inside the seeder).
|
||||
// await this.demoFreightDataSeeder.run();
|
||||
// Government entities (importer/exporter profiles) that government bookings
|
||||
// bill to. Idempotent — keyed by fixed IDs.
|
||||
// await this.govCompaniesSeeder.run();
|
||||
}
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
@@ -34,6 +34,10 @@ export const WagonTransferRequest = () =>
|
||||
export const WagonTransferFulfill = () =>
|
||||
BookingStaff(FREIGHT_PERMS.wagons.transferFulfill);
|
||||
|
||||
/** Admin: read every staffer's wagon-transfer history (not just one's own). */
|
||||
export const WagonTransferHistoryAll = () =>
|
||||
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||
|
||||
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* The person who signs off a handover must record their full name (a signature
|
||||
* is optional, especially for self-haul). Stored per handover record.
|
||||
*/
|
||||
export class AddHandoverSignerName2130000000000 implements MigrationInterface {
|
||||
name = "AddHandoverSignerName2130000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_handovers
|
||||
ADD COLUMN IF NOT EXISTS signer_name varchar(160)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_handovers
|
||||
DROP COLUMN IF EXISTS signer_name
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Accrual alert acknowledgements: ops can mark an in-warehouse item's fee
|
||||
* accrual as reviewed (optionally snoozed until a date) so it stops nudging and
|
||||
* drops down the accrual dashboard. One row per inventory item.
|
||||
*/
|
||||
export class CreateAccrualAcks2140000000000 implements MigrationInterface {
|
||||
name = "CreateAccrualAcks2140000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_accrual_acks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inventory_id uuid NOT NULL UNIQUE,
|
||||
acknowledged_by uuid,
|
||||
acknowledged_at timestamptz NOT NULL DEFAULT now(),
|
||||
snooze_until timestamptz,
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_accrual_acks`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Consist adjustments from a schedule: staff can trim free wagons off a built
|
||||
* train when their tare pushes gross weight over the locomotives' pull limit
|
||||
* (incl. overage tolerance), or couple extra yard wagons on while weight and
|
||||
* length headroom remain. Each add/remove is logged here so the schedule keeps
|
||||
* an auditable history; the built train itself is updated in place.
|
||||
*
|
||||
* Plain columns (no FKs) so the history survives wagon/train deletion.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. IF NOT EXISTS keeps that idempotent.
|
||||
*/
|
||||
export class ScheduleWagonAdjustmentLogs2170000000000 implements MigrationInterface {
|
||||
name = 'ScheduleWagonAdjustmentLogs2170000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.schedule_wagon_adjustment_logs (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
train_schedule_id uuid NOT NULL,
|
||||
train_id uuid NOT NULL,
|
||||
action varchar(10) NOT NULL,
|
||||
wagon_id uuid NOT NULL,
|
||||
wagon_number varchar(50) NOT NULL,
|
||||
adjusted_by_user_id uuid,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT "PK_schedule_wagon_adjustment_logs" PRIMARY KEY (id)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_swal_train_schedule_id"
|
||||
ON freight.schedule_wagon_adjustment_logs (train_schedule_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_swal_train_id"
|
||||
ON freight.schedule_wagon_adjustment_logs (train_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_id";`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_schedule_id";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.schedule_wagon_adjustment_logs;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Link each physical wagon move back to the transfer request that drove it, so
|
||||
* the history can show "Request S→K, 3× NX70 → wagons W101, W102, W103".
|
||||
* Nullable — legacy moves and non-request manual corrections carry no request.
|
||||
* Also indexes `moved_by_user_id` for the per-user history queries.
|
||||
*/
|
||||
export class LinkWagonMovementToTransferRequest2180000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'LinkWagonMovementToTransferRequest2180000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_movements
|
||||
ADD COLUMN IF NOT EXISTS transfer_request_id uuid NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_wm_transfer_request'
|
||||
) THEN
|
||||
ALTER TABLE freight.wagon_movements
|
||||
ADD CONSTRAINT fk_wm_transfer_request
|
||||
FOREIGN KEY (transfer_request_id)
|
||||
REFERENCES freight.wagon_transfer_requests (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wm_transfer_request
|
||||
ON freight.wagon_movements (transfer_request_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wm_moved_by
|
||||
ON freight.wagon_movements (moved_by_user_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_moved_by`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_transfer_request`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_movements
|
||||
DROP CONSTRAINT IF EXISTS fk_wm_transfer_request
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_movements
|
||||
DROP COLUMN IF EXISTS transfer_request_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const;
|
||||
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
|
||||
|
||||
/**
|
||||
* History row for a consist adjustment made from a schedule: staff coupled a
|
||||
* wagon onto (ADD) or detached one from (REMOVE) the schedule's built train —
|
||||
* e.g. trimming free wagons whose tare pushed gross weight over the
|
||||
* locomotives' pull limit. Plain columns (no FK relations) so the history
|
||||
* survives the wagon or train being deleted later.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
|
||||
@Index(['trainScheduleId'])
|
||||
@Index(['trainId'])
|
||||
export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||
trainScheduleId!: string;
|
||||
|
||||
@Column({ name: 'train_id', type: 'uuid' })
|
||||
trainId!: string;
|
||||
|
||||
@Column({ name: 'action', type: 'varchar', length: 10 })
|
||||
action!: WagonAdjustmentAction;
|
||||
|
||||
@Column({ name: 'wagon_id', type: 'uuid' })
|
||||
wagonId!: string;
|
||||
|
||||
@Column({ name: 'wagon_number', type: 'varchar', length: 50 })
|
||||
wagonNumber!: string;
|
||||
|
||||
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
|
||||
adjustedByUserId!: string | null;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
|
||||
occurredAt!: Date;
|
||||
}
|
||||
@@ -208,6 +208,8 @@ export interface BatchBoardScheduleDetail {
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train: BatchBoardSchedule["train"];
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
@@ -235,6 +237,12 @@ export interface BatchBoardSchedule {
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train: {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
} | null;
|
||||
locomotive: {
|
||||
code: string;
|
||||
name: string | null;
|
||||
@@ -877,7 +885,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||||
where,
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
trainSet: { locomotive: true, train: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
// Yards supply the route's display name for `routeName` below;
|
||||
@@ -1128,6 +1136,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? s.paymentPhaseEndsAt.toISOString()
|
||||
: null,
|
||||
bookingCycleNo: s.bookingCycleNo ?? 0,
|
||||
train: s.trainSet?.train
|
||||
? {
|
||||
id: s.trainSet.train.id,
|
||||
code: s.trainSet.train.code,
|
||||
trainName: s.trainSet.train.trainName ?? null,
|
||||
}
|
||||
: null,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
@@ -1247,6 +1262,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? s.paymentPhaseEndsAt.toISOString()
|
||||
: null,
|
||||
bookingCycleNo: s.bookingCycleNo ?? 0,
|
||||
train: s.trainSet?.train
|
||||
? {
|
||||
id: s.trainSet.train.id,
|
||||
code: s.trainSet.train.code,
|
||||
trainName: s.trainSet.train.trainName ?? null,
|
||||
}
|
||||
: null,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class AdjustScheduleConsistDto {
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description:
|
||||
"AVAILABLE wagons from the train's current yard to couple onto the built train (blocked when they push gross weight or length past the locomotive limits incl. tolerance).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('all', { each: true })
|
||||
addWagonIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Free (unloaded) wagons to detach permanently from the built train — e.g. trimming tare when gross weight exceeds the pull limit.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('all', { each: true })
|
||||
removeWagonIds?: string[];
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from "./dto/import-djibouti-operation.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||
import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto";
|
||||
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
|
||||
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||
@@ -166,6 +167,34 @@ export class TrainSchedulingController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/consist")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Built-train consist snapshot for a schedule: gross weight/length vs locomotive limits (incl. tolerance), trimmable + addable wagons, adjustment history",
|
||||
})
|
||||
getScheduleConsist(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getScheduleConsist(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/adjust-consist")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)",
|
||||
})
|
||||
adjustScheduleConsist(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: AdjustScheduleConsistDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainSchedulingService.adjustScheduleConsist(
|
||||
id,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Get("bookable-schedules")
|
||||
// No staff guard: customers hit this while creating a booking to find OPEN
|
||||
// same-route schedules. Do not attach train_scheduling permissions here.
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
FindOptionsWhere,
|
||||
ILike,
|
||||
In,
|
||||
IsNull,
|
||||
Not,
|
||||
QueryFailedError,
|
||||
Raw,
|
||||
@@ -48,6 +49,7 @@ import { Train } from '../trains/entities/train.entity';
|
||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
@@ -61,6 +63,7 @@ import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-book
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto';
|
||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||||
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
||||
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
||||
@@ -282,6 +285,8 @@ interface BookingWindowRow {
|
||||
origin_code: string | null;
|
||||
destination_label: string | null;
|
||||
destination_code: string | null;
|
||||
/** Full ordered corridor (origin → milestones → destination) from the schedule's route. */
|
||||
route_stations: string[] | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -1329,9 +1334,29 @@ export class TrainSchedulingService {
|
||||
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
|
||||
const lengthCapWithOverage =
|
||||
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
|
||||
if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) {
|
||||
// The locomotives pull GROSS weight: the customers' cargo plus wagon tare.
|
||||
// A built train hauls EVERY coupled wagon's tare — empty ones included —
|
||||
// so train-bound schedules count the full consist, not just planned slots.
|
||||
const consistWagons = schedule.trainSet?.trainId
|
||||
? await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
})
|
||||
: null;
|
||||
const planTareTons = consistWagons
|
||||
? roundTons(
|
||||
consistWagons.reduce(
|
||||
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
|
||||
0,
|
||||
),
|
||||
)
|
||||
: roundTons(
|
||||
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
||||
);
|
||||
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
|
||||
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
|
||||
throw new BadRequestException(
|
||||
`Train set locomotives cannot pull ${totalWeightTons}T`,
|
||||
`Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`,
|
||||
);
|
||||
}
|
||||
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
|
||||
@@ -4605,14 +4630,8 @@ export class TrainSchedulingService {
|
||||
name: link.locomotive!.name ?? null,
|
||||
})),
|
||||
wagonCount: wagons.length,
|
||||
maxGrossTons: roundTons(
|
||||
wagons.reduce(
|
||||
(sum, w) =>
|
||||
sum +
|
||||
(Number(w.wagonType?.tareWeightTons) || 0) +
|
||||
(Number(w.wagonType?.capacityTons) || 0),
|
||||
0,
|
||||
),
|
||||
totalTareTons: roundTons(
|
||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
|
||||
),
|
||||
totalLengthMeters: roundTons(
|
||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
||||
@@ -4624,6 +4643,294 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consist snapshot for the adjust-consist UI: the built train's wagons with
|
||||
* loaded/removable flags, gross weight (cargo + FULL consist tare) and length
|
||||
* against the locomotive limits incl. overage tolerance, addable yard wagons,
|
||||
* and the adjustment history.
|
||||
*/
|
||||
async getScheduleConsist(scheduleId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
const builtTrain = schedule.trainSet?.train;
|
||||
if (!builtTrain) {
|
||||
throw new BadRequestException(
|
||||
'This schedule was not created from a built train — its consist cannot be adjusted here',
|
||||
);
|
||||
}
|
||||
|
||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: builtTrain.id },
|
||||
relations: { wagonType: true },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
});
|
||||
const addableWagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: {
|
||||
trainId: IsNull(),
|
||||
status: WagonStatus.Available,
|
||||
currentYardId: builtTrain.currentYardId ?? undefined,
|
||||
},
|
||||
relations: { wagonType: true },
|
||||
order: { wagonNumber: 'ASC' },
|
||||
});
|
||||
const adjustments = await this.dataSource
|
||||
.getRepository(ScheduleWagonAdjustmentLog)
|
||||
.find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });
|
||||
|
||||
// Slots with cargo aboard — their physical wagons are "loaded" and can
|
||||
// never be trimmed.
|
||||
const loadedWagonIds = new Set(
|
||||
(schedule.trainSet?.wagons ?? [])
|
||||
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
||||
.map((slot) => slot.physicalWagonId as string),
|
||||
);
|
||||
|
||||
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||||
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
|
||||
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
|
||||
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
|
||||
const overageToleranceMeters = roundTons(Number(limits?.overageToleranceMeters) || 0);
|
||||
|
||||
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
|
||||
const consistTareTons = roundTons(
|
||||
wagons.reduce((sum, w) => sum + Number(w.wagonType?.tareWeightTons ?? 0), 0),
|
||||
);
|
||||
const consistLengthMeters = roundTons(
|
||||
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
|
||||
);
|
||||
|
||||
const mapWagon = (wagon: Wagon) => ({
|
||||
id: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
sequenceNumber: wagon.sequenceNumber,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
tareWeightTons: roundTons(Number(wagon.wagonType.tareWeightTons ?? 0)),
|
||||
capacityTons: roundTons(Number(wagon.wagonType.capacityTons ?? 0)),
|
||||
lengthMeters: roundTons(Number(wagon.wagonType.lengthMeters ?? 0)),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
return {
|
||||
schedule: { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status },
|
||||
train: {
|
||||
id: builtTrain.id,
|
||||
code: builtTrain.code,
|
||||
trainName: builtTrain.trainName ?? null,
|
||||
currentYardId: builtTrain.currentYardId ?? null,
|
||||
},
|
||||
limits: {
|
||||
maxPullWeightTons,
|
||||
overageToleranceTons,
|
||||
pullCapTons: roundTons(maxPullWeightTons + overageToleranceTons),
|
||||
maxTrainLengthMeters,
|
||||
overageToleranceMeters,
|
||||
lengthCapMeters: roundTons(maxTrainLengthMeters + overageToleranceMeters),
|
||||
},
|
||||
totals: {
|
||||
wagonCount: wagons.length,
|
||||
cargoTons,
|
||||
consistTareTons,
|
||||
grossTons: roundTons(cargoTons + consistTareTons),
|
||||
consistLengthMeters,
|
||||
},
|
||||
wagons: wagons.map((wagon) => ({
|
||||
...mapWagon(wagon),
|
||||
loaded: loadedWagonIds.has(wagon.id),
|
||||
// Free = not pinned to any run; only free wagons can be trimmed.
|
||||
removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id),
|
||||
})),
|
||||
addableWagons: addableWagons.map(mapWagon),
|
||||
adjustments: adjustments.map((log) => ({
|
||||
id: log.id,
|
||||
action: log.action,
|
||||
wagonId: log.wagonId,
|
||||
wagonNumber: log.wagonNumber,
|
||||
adjustedByUserId: log.adjustedByUserId,
|
||||
occurredAt: log.occurredAt,
|
||||
})),
|
||||
editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently adjust the built train's consist from a schedule: trim free
|
||||
* wagons (their tare no longer rides — the usual fix when gross weight beats
|
||||
* the pull limit) and/or couple extra AVAILABLE yard wagons while weight and
|
||||
* length headroom remain (limits incl. overage tolerance). The built train
|
||||
* updates in place, the schedule's wagon cap follows, and every change is
|
||||
* logged for the schedule's history.
|
||||
*/
|
||||
async adjustScheduleConsist(
|
||||
scheduleId: string,
|
||||
dto: AdjustScheduleConsistDto,
|
||||
userId?: string | null,
|
||||
) {
|
||||
const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
|
||||
const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
|
||||
if (!addWagonIds.length && !removeWagonIds.length) {
|
||||
throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove');
|
||||
}
|
||||
const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id));
|
||||
if (overlap.length) {
|
||||
throw new BadRequestException('A wagon cannot be added and removed in the same adjustment');
|
||||
}
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||
throw new BadRequestException(
|
||||
'The consist is frozen once the train is dispatched — adjust before departure',
|
||||
);
|
||||
}
|
||||
const builtTrainRef = schedule.trainSet?.train;
|
||||
if (!builtTrainRef) {
|
||||
throw new BadRequestException(
|
||||
'This schedule was not created from a built train — its consist cannot be adjusted here',
|
||||
);
|
||||
}
|
||||
const loadedWagonIds = new Set(
|
||||
(schedule.trainSet?.wagons ?? [])
|
||||
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
||||
.map((slot) => slot.physicalWagonId as string),
|
||||
);
|
||||
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||||
const pullCapTons = roundTons(
|
||||
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
|
||||
);
|
||||
const lengthCapMeters = roundTons(
|
||||
Number(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0),
|
||||
);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await manager.getRepository(Train).findOne({
|
||||
where: { id: builtTrainRef.id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!train) throw new NotFoundException(`Train ${builtTrainRef.id} not found`);
|
||||
|
||||
const consist = await manager.getRepository(Wagon).find({
|
||||
where: { trainId: train.id },
|
||||
relations: { wagonType: true },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
});
|
||||
const consistById = new Map(consist.map((w) => [w.id, w]));
|
||||
|
||||
// --- validate removals: must be coupled and free (no cargo, no pin) ---
|
||||
const removed: Wagon[] = [];
|
||||
for (const wagonId of removeWagonIds) {
|
||||
const wagon = consistById.get(wagonId);
|
||||
if (!wagon) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
|
||||
}
|
||||
if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
|
||||
);
|
||||
}
|
||||
removed.push(wagon);
|
||||
}
|
||||
|
||||
// --- validate additions: AVAILABLE, loose, standing in the train's yard ---
|
||||
const added: Wagon[] = [];
|
||||
for (const wagonId of addWagonIds) {
|
||||
const wagon = await manager.getRepository(Wagon).findOne({
|
||||
where: { id: wagonId },
|
||||
relations: { wagonType: true },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
|
||||
if (wagon.trainId) {
|
||||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on a train`);
|
||||
}
|
||||
if (wagon.status !== WagonStatus.Available) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`,
|
||||
);
|
||||
}
|
||||
if (wagon.currentYardId !== train.currentYardId) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`,
|
||||
);
|
||||
}
|
||||
added.push(wagon);
|
||||
}
|
||||
|
||||
// --- headroom check (only additions can push the train over a cap) ---
|
||||
const removedIds = new Set(removed.map((w) => w.id));
|
||||
const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added];
|
||||
const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
|
||||
const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
|
||||
const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
|
||||
const finalLengthMeters = roundTons(finalConsist.reduce((s, w) => s + lengthOf(w), 0));
|
||||
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
|
||||
const finalGrossTons = roundTons(cargoTons + finalTareTons);
|
||||
if (added.length && pullCapTons > 0 && finalGrossTons > pullCapTons) {
|
||||
throw new BadRequestException(
|
||||
`Adding these wagons puts gross weight at ${finalGrossTons}T (${cargoTons}T cargo + ${finalTareTons}T tare), over the locomotives' ${pullCapTons}T limit incl. tolerance`,
|
||||
);
|
||||
}
|
||||
if (added.length && lengthCapMeters > 0 && finalLengthMeters > lengthCapMeters) {
|
||||
throw new BadRequestException(
|
||||
`Adding these wagons puts consist length at ${finalLengthMeters}m, over the locomotives' ${lengthCapMeters}m limit incl. tolerance`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- apply: detach trims, couple additions, compact the sequence ---
|
||||
for (const wagon of removed) {
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
status: WagonStatus.Available,
|
||||
});
|
||||
}
|
||||
const remaining = consist.filter((w) => !removedIds.has(w.id));
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
if (remaining[i].sequenceNumber !== i + 1) {
|
||||
await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 });
|
||||
}
|
||||
}
|
||||
let sequence = remaining.length;
|
||||
for (const wagon of added) {
|
||||
sequence += 1;
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: train.id,
|
||||
sequenceNumber: sequence,
|
||||
status: WagonStatus.Assigned,
|
||||
});
|
||||
}
|
||||
|
||||
// The schedule is full when every consist wagon is allocated.
|
||||
await manager
|
||||
.getRepository(TrainSchedule)
|
||||
.update(scheduleId, { maxWagons: finalConsist.length });
|
||||
|
||||
const logRepo = manager.getRepository(ScheduleWagonAdjustmentLog);
|
||||
const now = new Date();
|
||||
await logRepo.save(
|
||||
[
|
||||
...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })),
|
||||
...added.map((wagon) => ({ action: 'ADD' as const, wagon })),
|
||||
].map(({ action, wagon }) =>
|
||||
logRepo.create({
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: train.id,
|
||||
action,
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
adjustedByUserId: userId ?? null,
|
||||
occurredAt: now,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return this.getScheduleConsist(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive a built train's lifecycle status from its schedules after one of
|
||||
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
|
||||
@@ -4725,7 +5032,11 @@ export class TrainSchedulingService {
|
||||
ts.booking_cycle_no,
|
||||
ts.scheduled_departure_date,
|
||||
oy.label AS origin_label, oy.code AS origin_code,
|
||||
dy.label AS destination_label, dy.code AS destination_code
|
||||
dy.label AS destination_label, dy.code AS destination_code,
|
||||
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
|
||||
FROM freight.route_milestones rm
|
||||
JOIN freight.yards rmy ON rmy.id = rm.yard_id
|
||||
WHERE rm.route_id = ts.route_id) AS route_stations
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.contract_routes cr
|
||||
ON cr.deleted_at IS NULL
|
||||
@@ -4777,7 +5088,11 @@ export class TrainSchedulingService {
|
||||
ts.booking_cycle_no,
|
||||
ts.scheduled_departure_date,
|
||||
oy.label AS origin_label, oy.code AS origin_code,
|
||||
dy.label AS destination_label, dy.code AS destination_code
|
||||
dy.label AS destination_label, dy.code AS destination_code,
|
||||
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
|
||||
FROM freight.route_milestones rm
|
||||
JOIN freight.yards rmy ON rmy.id = rm.yard_id
|
||||
WHERE rm.route_id = ts.route_id) AS route_stations
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.contract_routes cr
|
||||
ON cr.contract_id = $1
|
||||
@@ -4823,7 +5138,11 @@ export class TrainSchedulingService {
|
||||
ts.booking_cycle_no,
|
||||
ts.scheduled_departure_date,
|
||||
oy.label AS origin_label, oy.code AS origin_code,
|
||||
dy.label AS destination_label, dy.code AS destination_code
|
||||
dy.label AS destination_label, dy.code AS destination_code,
|
||||
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
|
||||
FROM freight.route_milestones rm
|
||||
JOIN freight.yards rmy ON rmy.id = rm.yard_id
|
||||
WHERE rm.route_id = ts.route_id) AS route_stations
|
||||
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
|
||||
@@ -4845,6 +5164,17 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private mapBookingWindowRow(r: BookingWindowRow) {
|
||||
const origin = r.origin_label ?? r.origin_code ?? null;
|
||||
const destination = r.destination_label ?? r.destination_code ?? null;
|
||||
// Full corridor from the route's milestones (origin → stops → destination).
|
||||
// Falls back to the schedule's origin/destination when no milestones exist.
|
||||
const milestoneStops = (r.route_stations ?? []).filter(
|
||||
(s): s is string => Boolean(s),
|
||||
);
|
||||
const routeStations =
|
||||
milestoneStops.length >= 2
|
||||
? milestoneStops
|
||||
: [origin, destination].filter((s): s is string => Boolean(s));
|
||||
return {
|
||||
scheduleId: r.schedule_id,
|
||||
reference: r.reference ?? null,
|
||||
@@ -4860,8 +5190,9 @@ export class TrainSchedulingService {
|
||||
bookingWindowStatus: r.booking_window_status,
|
||||
bookingCycleNo: r.booking_cycle_no,
|
||||
departureDate: r.scheduled_departure_date,
|
||||
origin: r.origin_label ?? r.origin_code ?? null,
|
||||
destination: r.destination_label ?? r.destination_code ?? null,
|
||||
origin,
|
||||
destination,
|
||||
routeStations,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateTrainYardDto {
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Yard the train now sits in. The coupled locomotives and wagons are relocated with it.',
|
||||
})
|
||||
@IsUUID()
|
||||
currentYardId!: string;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
@@ -19,6 +20,7 @@ import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
|
||||
@ApiTags('train-builder')
|
||||
@@ -57,6 +59,15 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.setLocomotives(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/yard')
|
||||
@FleetManage()
|
||||
@ApiOperation({
|
||||
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
|
||||
})
|
||||
setYard(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTrainYardDto) {
|
||||
return this.trainBuilderService.setYard(id, dto.currentYardId);
|
||||
}
|
||||
|
||||
@Post(':id/wagons')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Freight, WagonStatus } from '@edr/types';
|
||||
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
@@ -10,6 +10,8 @@ import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
@@ -202,7 +204,6 @@ export class TrainBuilderService {
|
||||
const totalLengthMeters = round(
|
||||
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
|
||||
);
|
||||
const maxGrossTons = round(totalTareTons + totalCapacityTons);
|
||||
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
|
||||
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
|
||||
|
||||
@@ -221,14 +222,17 @@ export class TrainBuilderService {
|
||||
totals: {
|
||||
wagonCount: wagons.length,
|
||||
totalTareTons,
|
||||
// Informational only — building never checks against full capacity;
|
||||
// the real gross check (cargo + tare vs haul limit) runs at allocation.
|
||||
totalCapacityTons,
|
||||
maxGrossTons,
|
||||
totalLengthMeters,
|
||||
maxPullWeightTons,
|
||||
maxTrainLengthMeters,
|
||||
// Fully loaded gross vs. what the weakest locomotive can haul.
|
||||
weightUtilizationPct: maxPullWeightTons
|
||||
? round((maxGrossTons / maxPullWeightTons) * 100)
|
||||
// Cargo the locomotives can still haul once pulling the empty consist.
|
||||
payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)),
|
||||
// Share of the haul limit consumed by the empty wagons alone.
|
||||
tareUtilizationPct: maxPullWeightTons
|
||||
? round((totalTareTons / maxPullWeightTons) * 100)
|
||||
: null,
|
||||
lengthUtilizationPct: maxTrainLengthMeters
|
||||
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
|
||||
@@ -269,6 +273,54 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocate the train to another yard. The consist moves as one unit: every
|
||||
* coupled locomotive and wagon follows to the new yard (so their current
|
||||
* yards always match the train's), and each wagon gets a movement-ledger row.
|
||||
* Blocked while the train is out on a dispatched run.
|
||||
*/
|
||||
async setYard(id: string, currentYardId: string) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
if (train.currentYardId === currentYardId) return;
|
||||
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
|
||||
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
|
||||
|
||||
await manager.getRepository(Train).update(train.id, { currentYardId: yard.id });
|
||||
|
||||
const links = await manager
|
||||
.getRepository(TrainLocomotive)
|
||||
.find({ where: { trainId: train.id } });
|
||||
if (links.length) {
|
||||
await manager
|
||||
.getRepository(Locomotive)
|
||||
.update(
|
||||
{ id: In(links.map((link) => link.locomotiveId)) },
|
||||
{ currentYardId: yard.id },
|
||||
);
|
||||
}
|
||||
|
||||
const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } });
|
||||
const now = new Date();
|
||||
for (const wagon of wagons) {
|
||||
if (wagon.currentYardId === yard.id) continue;
|
||||
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
|
||||
// Ledger row keeps the wagon's yard history auditable (mirrors the
|
||||
// manual-relocation path in the wagons service).
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: wagon.id,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toYardId: yard.id,
|
||||
kind: WagonMovementKind.Manual,
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Append AVAILABLE wagons from the train's own yard to the consist. */
|
||||
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -361,12 +413,8 @@ export class TrainBuilderService {
|
||||
.map((link) => link.locomotive)
|
||||
.filter((loco): loco is Locomotive => Boolean(loco));
|
||||
const wagons = train.wagons ?? [];
|
||||
const maxGrossTons = round(
|
||||
wagons.reduce(
|
||||
(sum, w) =>
|
||||
sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0),
|
||||
0,
|
||||
),
|
||||
const totalTareTons = round(
|
||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
|
||||
);
|
||||
return {
|
||||
id: train.id,
|
||||
@@ -379,7 +427,7 @@ export class TrainBuilderService {
|
||||
: null,
|
||||
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
|
||||
wagonCount: wagons.length,
|
||||
maxGrossTons,
|
||||
totalTareTons,
|
||||
totalLengthMeters: round(
|
||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
||||
),
|
||||
@@ -464,9 +512,13 @@ export class TrainBuilderService {
|
||||
startCount: number,
|
||||
): Promise<void> {
|
||||
const uniqueIds = [...new Set(wagonIds)];
|
||||
let sequence = startCount;
|
||||
const wagonRepo = manager.getRepository(Wagon);
|
||||
|
||||
// First pass: lock + validate every wagon so the length gate below sees
|
||||
// the full incoming set before any row is written.
|
||||
const toAttach: Wagon[] = [];
|
||||
for (const wagonId of uniqueIds) {
|
||||
const wagon = await manager.getRepository(Wagon).findOne({
|
||||
const wagon = await wagonRepo.findOne({
|
||||
where: { id: wagonId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
@@ -483,8 +535,16 @@ export class TrainBuilderService {
|
||||
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
|
||||
);
|
||||
}
|
||||
toAttach.push(wagon);
|
||||
}
|
||||
if (!toAttach.length) return;
|
||||
|
||||
await this.assertConsistLengthWithinLimit(manager, train, toAttach);
|
||||
|
||||
let sequence = startCount;
|
||||
for (const wagon of toAttach) {
|
||||
sequence += 1;
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
await wagonRepo.update(wagon.id, {
|
||||
trainId: train.id,
|
||||
sequenceNumber: sequence,
|
||||
status: WagonStatus.Assigned,
|
||||
@@ -492,6 +552,58 @@ export class TrainBuilderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The consist (already-attached wagons + the incoming ones) must fit the
|
||||
* train's locomotive length limit — the weakest locomotive of the set caps
|
||||
* the train, mirroring how scheduling derives capacity.
|
||||
*/
|
||||
private async assertConsistLengthWithinLimit(
|
||||
manager: EntityManager,
|
||||
train: Train,
|
||||
incoming: Wagon[],
|
||||
): Promise<void> {
|
||||
const links = await manager.getRepository(TrainLocomotive).find({
|
||||
where: { trainId: train.id },
|
||||
relations: { locomotive: true },
|
||||
});
|
||||
const limits = minLocomotiveLimits(
|
||||
links
|
||||
.map((link) => link.locomotive)
|
||||
.filter((loco): loco is Locomotive => Boolean(loco)),
|
||||
);
|
||||
const maxLengthMeters = Number(limits?.maxTrainLengthMeters ?? 0);
|
||||
if (!Number.isFinite(maxLengthMeters) || maxLengthMeters <= 0) return;
|
||||
|
||||
const existing = await manager.getRepository(Wagon).find({
|
||||
where: { trainId: train.id },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
const currentLength = existing.reduce(
|
||||
(sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const typeIds = [...new Set(incoming.map((w) => w.wagonTypeId).filter(Boolean))];
|
||||
const types = typeIds.length
|
||||
? await manager.getRepository(WagonType).find({ where: { id: In(typeIds) } })
|
||||
: [];
|
||||
const lengthByType = new Map(types.map((t) => [t.id, Number(t.lengthMeters) || 0]));
|
||||
const addedLength = incoming.reduce(
|
||||
(sum, w) => sum + (lengthByType.get(w.wagonTypeId) ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const totalLength = currentLength + addedLength;
|
||||
if (totalLength > maxLengthMeters) {
|
||||
throw new BadRequestException(
|
||||
`Cannot attach wagons — train length would be ${round(totalLength)} m ` +
|
||||
`(current ${round(currentLength)} m + ${round(addedLength)} m added), ` +
|
||||
`over the locomotive limit of ${round(maxLengthMeters)} m. ` +
|
||||
'Remove wagons from the consist or use locomotives with a higher length limit.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact wagon sequence numbers back to 1..n after a removal. */
|
||||
private async resequenceWagons(manager: EntityManager, trainId: string): Promise<void> {
|
||||
const wagons = await manager.getRepository(Wagon).find({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Wagon } from './wagon.entity';
|
||||
import { WagonTransferRequest } from './wagon-transfer-request.entity';
|
||||
|
||||
/**
|
||||
* Ledger of every physical wagon relocation between yards — one row per move.
|
||||
@@ -51,6 +52,14 @@ export class WagonMovement extends BaseEntity {
|
||||
@Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true })
|
||||
movedByUserId?: string | null;
|
||||
|
||||
/** The transfer request this move fulfilled, when it came from one. */
|
||||
@Column({ name: 'transfer_request_id', type: 'uuid', nullable: true })
|
||||
transferRequestId?: string | null;
|
||||
|
||||
@ManyToOne(() => WagonTransferRequest, { nullable: true })
|
||||
@JoinColumn({ name: 'transfer_request_id' })
|
||||
transferRequest?: WagonTransferRequest | null;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FleetManage,
|
||||
FleetView,
|
||||
WagonTransferFulfill,
|
||||
WagonTransferHistoryAll,
|
||||
WagonTransferRequest,
|
||||
} from '../../common/booking-guards';
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
@@ -50,6 +51,30 @@ export class WagonTransferRequestsController {
|
||||
return this.service.listRequests(status);
|
||||
}
|
||||
|
||||
// NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
|
||||
// matches in declaration order, so `/history` would otherwise be captured by
|
||||
// the `:id` param route (and rejected by ParseUUIDPipe).
|
||||
@Get('history')
|
||||
@ApiOperation({
|
||||
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
|
||||
})
|
||||
myHistory(@CurrentUser() user: TCurrentUser) {
|
||||
// Never fall through to the all-staff view: getHistory(undefined) means
|
||||
// "everyone", so a missing caller id must return empty, not leak scope.
|
||||
if (!user?.id) return { requests: [], movements: [] };
|
||||
return this.service.getHistory(user.id);
|
||||
}
|
||||
|
||||
@Get('history/all')
|
||||
@WagonTransferHistoryAll()
|
||||
@ApiQuery({ name: 'userId', required: false })
|
||||
@ApiOperation({
|
||||
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
|
||||
})
|
||||
allHistory(@Query('userId') userId?: string) {
|
||||
return this.service.getHistory(userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one transfer request' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -6,14 +6,24 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
/** Bundled per-user activity: requests they touched + wagons they moved. */
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
movements: WagonMovement[];
|
||||
}
|
||||
|
||||
/** How many ledger rows the history returns at most (newest first). */
|
||||
const HISTORY_LIMIT = 500;
|
||||
|
||||
const REQUEST_RELATIONS = {
|
||||
fromYard: true,
|
||||
toYard: true,
|
||||
@@ -33,6 +43,8 @@ export class WagonTransferRequestsService {
|
||||
private readonly requestRepo: Repository<WagonTransferRequest>,
|
||||
@InjectRepository(Wagon)
|
||||
private readonly wagonRepo: Repository<Wagon>,
|
||||
@InjectRepository(WagonMovement)
|
||||
private readonly movementRepo: Repository<WagonMovement>,
|
||||
private readonly wagonsService: WagonsService,
|
||||
) {}
|
||||
|
||||
@@ -125,10 +137,12 @@ export class WagonTransferRequestsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows).
|
||||
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
|
||||
// each stamped with this request's id so history can link them back).
|
||||
await this.wagonsService.bulkTransfer(
|
||||
{ wagonIds, toYardId: request.toYardId },
|
||||
userId,
|
||||
{ transferRequestId: request.id },
|
||||
);
|
||||
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
@@ -138,6 +152,38 @@ export class WagonTransferRequestsService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user transfer history: the requests a user filed OR fulfilled, plus the
|
||||
* individual wagons they physically moved (linked back to their request when
|
||||
* one drove the move). Pass a `userId` to scope to one staffer; pass
|
||||
* `undefined` for the admin all-staff view. Scope is decided by the CALLER
|
||||
* (the controller passes the caller's id unless they hold the history-all
|
||||
* permission) — this method trusts its argument.
|
||||
*/
|
||||
async getHistory(userId?: string | null): Promise<TransferHistory> {
|
||||
const requests = await this.requestRepo.find({
|
||||
where: userId
|
||||
? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
|
||||
: {},
|
||||
relations: REQUEST_RELATIONS,
|
||||
order: { createdAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
});
|
||||
|
||||
const movements = await this.movementRepo.find({
|
||||
// Own view: moves I made. All view: every user-attributed move (skip the
|
||||
// system-written loaded/reposition legs that carry no mover).
|
||||
where: userId
|
||||
? { movedByUserId: userId }
|
||||
: { movedByUserId: Not(IsNull()) },
|
||||
relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
|
||||
order: { occurredAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
});
|
||||
|
||||
return { requests, movements };
|
||||
}
|
||||
|
||||
/** Withdraw a still-PENDING request. */
|
||||
async cancelRequest(id: string): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
@@ -10,7 +11,15 @@ import { WagonsService } from './wagons.service';
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Wagon, WagonTransferRequest, Train, Yard])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Wagon,
|
||||
WagonMovement,
|
||||
WagonTransferRequest,
|
||||
Train,
|
||||
Yard,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
WagonsController,
|
||||
TrainWagonsReorderController,
|
||||
|
||||
@@ -180,6 +180,7 @@ export class WagonsService {
|
||||
async bulkTransfer(
|
||||
dto: BulkTransferWagonsDto,
|
||||
userId?: string | null,
|
||||
opts?: { transferRequestId?: string | null },
|
||||
): Promise<{ moved: number }> {
|
||||
const { wagonIds, toYardId } = dto;
|
||||
if (!wagonIds.length) return { moved: 0 };
|
||||
@@ -215,6 +216,7 @@ export class WagonsService {
|
||||
toYardId,
|
||||
kind: WagonMovementKind.Manual,
|
||||
movedByUserId: userId ?? null,
|
||||
transferRequestId: opts?.transferRequestId ?? null,
|
||||
occurredAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
/** Acknowledge (optionally snooze) an item's fee-accrual alert. */
|
||||
export class AcknowledgeAccrualDto {
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 90, description: 'Days to suppress alerts; omit = indefinitely.' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(90)
|
||||
snoozeDays?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Optional reason / note.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/** The customer approving a handover must record their full name (signature optional). */
|
||||
export class ApproveDeliveryDto {
|
||||
@ApiProperty({ description: 'Full name of the person approving delivery.' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(160)
|
||||
signerName!: string;
|
||||
}
|
||||
@@ -37,6 +37,10 @@ export class BookingHandover extends BaseEntity {
|
||||
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
|
||||
signedAt?: Date | null;
|
||||
|
||||
/** Full name of the person who signed off the handover (required at sign time). */
|
||||
@Column({ name: 'signer_name', type: 'varchar', length: 160, nullable: true })
|
||||
signerName?: string | null;
|
||||
|
||||
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
|
||||
signedByUserId?: string | null;
|
||||
|
||||
|
||||
@@ -183,12 +183,20 @@ export class HandoverService {
|
||||
}
|
||||
|
||||
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
|
||||
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
|
||||
async signForBooking(
|
||||
bookingId: string,
|
||||
userId?: string | null,
|
||||
signerName?: string | null,
|
||||
): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(BookingHandover)
|
||||
.update(
|
||||
{ bookingId, signedAt: IsNull() },
|
||||
{ signedAt: new Date(), signedByUserId: userId ?? null },
|
||||
{
|
||||
signedAt: new Date(),
|
||||
signedByUserId: userId ?? null,
|
||||
signerName: signerName?.trim() || null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
|
||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||
@@ -26,6 +29,35 @@ interface ItemAttributes {
|
||||
zoneId: string | null;
|
||||
}
|
||||
|
||||
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
|
||||
|
||||
export interface AccrualDashboardRow {
|
||||
inventoryId: string;
|
||||
status: string;
|
||||
bookingId: string | null;
|
||||
companyId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
warehouseCode: string | null;
|
||||
zoneCode: string | null;
|
||||
receivedAt: string | null;
|
||||
currency: string;
|
||||
accruedAmount: number;
|
||||
freeDaysLeft: number | null;
|
||||
charging: boolean;
|
||||
alert: AccrualAlert;
|
||||
/** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */
|
||||
acknowledged: boolean;
|
||||
snoozeUntil: string | null;
|
||||
breakdown: Array<{
|
||||
type: FeeRuleType;
|
||||
amount: number;
|
||||
freeDays: number;
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
|
||||
@@ -70,12 +102,82 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeService {
|
||||
private readonly logger = new Logger(WarehouseFeeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Daily accrual alerts: for every in-warehouse item that is charging or within
|
||||
* its last free days, send the customer an in-app notification with the
|
||||
* outstanding accrued amount so they can collect before (more) charges hit.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' })
|
||||
async sendAccrualAlerts(): Promise<void> {
|
||||
try {
|
||||
const alerts = (await this.accrualDashboard()).filter(
|
||||
(r) => r.alert !== 'OK' && !r.acknowledged,
|
||||
);
|
||||
if (!alerts.length) return;
|
||||
this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`);
|
||||
|
||||
// Per-customer: notify each company about its own items.
|
||||
for (const row of alerts.filter((r) => r.companyId)) {
|
||||
const ref = row.bookingReference ?? row.inventoryId.slice(0, 8);
|
||||
const amount = `${row.accruedAmount.toFixed(2)} ${row.currency}`;
|
||||
const body = row.charging
|
||||
? `Storage/demurrage is now charging on booking ${ref} — ${amount} accrued. Collect the cargo to stop further charges.`
|
||||
: `Booking ${ref} has ${row.freeDaysLeft ?? 0} free day(s) left before storage/demurrage charges start (${amount} accrued so far).`;
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: row.companyId! },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: row.charging ? 'Storage charges accruing' : 'Free days ending soon',
|
||||
body,
|
||||
link: row.bookingId ? `/bookings/${row.bookingId}` : undefined,
|
||||
data: {
|
||||
inventoryId: row.inventoryId,
|
||||
bookingId: row.bookingId,
|
||||
alert: row.alert,
|
||||
accruedAmount: row.accruedAmount,
|
||||
action: 'ACCRUAL_ALERT',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Accrual alert failed for ${row.inventoryId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ops staff: one digest covering every alerting item.
|
||||
const charging = alerts.filter((r) => r.charging).length;
|
||||
const nearing = alerts.length - charging;
|
||||
const currency = alerts[0]?.currency ?? 'USD';
|
||||
const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0);
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Warehouse fee accruals need attention',
|
||||
body: `${charging} item(s) charging, ${nearing} nearing the free-day limit — ${total.toFixed(2)} ${currency} accruing. Review the accrual dashboard.`,
|
||||
link: '/dashboard/warehouse-fee-invoices',
|
||||
data: { charging, nearing, totalAccrued: Math.round(total * 100) / 100, action: 'ACCRUAL_ALERT_DIGEST' },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Accrual staff digest failed: ${(err as Error).message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Accrual alert tick failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
||||
listRules(): Promise<WarehouseFeeRule[]> {
|
||||
return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } });
|
||||
@@ -416,6 +518,138 @@ export class WarehouseFeeService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live accrual dashboard: for every item still in the warehouse, the fees
|
||||
* accruing right now (storage + demurrage + double-handling), how many free
|
||||
* days remain, and an alert level so staff can act before charges land.
|
||||
*/
|
||||
async accrualDashboard(billingCurrency = 'USD'): Promise<AccrualDashboardRow[]> {
|
||||
const items: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
bookingId: string | null;
|
||||
companyId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
warehouseCode: string | null;
|
||||
zoneCode: string | null;
|
||||
receivedAt: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.status,
|
||||
b.id AS "bookingId",
|
||||
b.company_id AS "companyId",
|
||||
b.reference AS "bookingReference",
|
||||
c.name AS "customerName",
|
||||
w.code AS "warehouseCode",
|
||||
z.code AS "zoneCode",
|
||||
inv.created_at AS "receivedAt"
|
||||
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 c ON c.id = b.company_id
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.warehouse_zones z ON z.id = inv.zone_id
|
||||
WHERE inv.deleted_at IS NULL
|
||||
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
|
||||
ORDER BY inv.created_at ASC`,
|
||||
);
|
||||
|
||||
const ackRows: Array<{ inventoryId: string; snoozeUntil: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT inventory_id AS "inventoryId", snooze_until AS "snoozeUntil"
|
||||
FROM freight.warehouse_accrual_acks`,
|
||||
);
|
||||
const now = new Date();
|
||||
const acks = new Map(ackRows.map((a) => [a.inventoryId, a.snoozeUntil]));
|
||||
|
||||
const rows = await Promise.all(
|
||||
items.map(async (it): Promise<AccrualDashboardRow> => {
|
||||
const previews = (await this.previewForInventory(it.id, billingCurrency)).filter(
|
||||
(p) => p.ruleId,
|
||||
);
|
||||
const accruedAmount =
|
||||
Math.round(previews.reduce((sum, p) => sum + (p.amount ?? 0), 0) * 100) / 100;
|
||||
const charging = previews.some((p) => p.chargeableDays > 0);
|
||||
const freeDaysLeftVals = previews
|
||||
.filter((p) => p.endIsOpen)
|
||||
.map((p) => Math.max(0, p.freeDays - p.elapsedDays));
|
||||
const freeDaysLeft = freeDaysLeftVals.length ? Math.min(...freeDaysLeftVals) : null;
|
||||
const alert: AccrualAlert = charging
|
||||
? 'CHARGING'
|
||||
: freeDaysLeft != null && freeDaysLeft <= 2
|
||||
? 'WARNING'
|
||||
: 'OK';
|
||||
return {
|
||||
inventoryId: it.id,
|
||||
status: it.status,
|
||||
bookingId: it.bookingId,
|
||||
companyId: it.companyId,
|
||||
bookingReference: it.bookingReference,
|
||||
customerName: it.customerName,
|
||||
warehouseCode: it.warehouseCode,
|
||||
zoneCode: it.zoneCode,
|
||||
receivedAt: it.receivedAt,
|
||||
currency: billingCurrency,
|
||||
accruedAmount,
|
||||
freeDaysLeft,
|
||||
charging,
|
||||
alert,
|
||||
acknowledged:
|
||||
acks.has(it.id) &&
|
||||
(acks.get(it.id) == null || new Date(acks.get(it.id) as string) > now),
|
||||
snoozeUntil: acks.get(it.id) ?? null,
|
||||
breakdown: previews.map((p) => ({
|
||||
type: p.ruleType,
|
||||
amount: p.amount,
|
||||
freeDays: p.freeDays,
|
||||
elapsedDays: p.elapsedDays,
|
||||
chargeableDays: p.chargeableDays,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2);
|
||||
// Acknowledged items sink to the bottom; among the rest, worst alert first.
|
||||
return rows.sort(
|
||||
(a, b) =>
|
||||
Number(a.acknowledged) - Number(b.acknowledged) ||
|
||||
rank(a.alert) - rank(b.alert) ||
|
||||
b.accruedAmount - a.accruedAmount,
|
||||
);
|
||||
}
|
||||
|
||||
/** Mark an item's accrual reviewed. `snoozeDays` > 0 suppresses alerts until then; omitted = indefinitely. */
|
||||
async acknowledgeAccrual(
|
||||
inventoryId: string,
|
||||
opts: { snoozeDays?: number; note?: string; userId?: string } = {},
|
||||
): Promise<void> {
|
||||
const snoozeUntil =
|
||||
opts.snoozeDays && opts.snoozeDays > 0
|
||||
? new Date(Date.now() + opts.snoozeDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO freight.warehouse_accrual_acks
|
||||
(inventory_id, acknowledged_by, acknowledged_at, snooze_until, note, updated_at)
|
||||
VALUES ($1, $2, now(), $3, $4, now())
|
||||
ON CONFLICT (inventory_id) DO UPDATE
|
||||
SET acknowledged_by = EXCLUDED.acknowledged_by,
|
||||
acknowledged_at = now(),
|
||||
snooze_until = EXCLUDED.snooze_until,
|
||||
note = EXCLUDED.note,
|
||||
updated_at = now()`,
|
||||
[inventoryId, opts.userId ?? null, snoozeUntil, opts.note?.trim() || null],
|
||||
);
|
||||
}
|
||||
|
||||
/** Remove an acknowledgement so the item re-surfaces for alerts. */
|
||||
async unacknowledgeAccrual(inventoryId: string): Promise<void> {
|
||||
await this.dataSource.query(
|
||||
`DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`,
|
||||
[inventoryId],
|
||||
);
|
||||
}
|
||||
|
||||
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||||
const item = await this.loadItem(inventoryId);
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||
import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||
@@ -19,10 +21,12 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||
@ApiTags('warehouse-inspection')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
|
||||
export class WarehouseInspectionController {
|
||||
constructor(private readonly inspectionService: WarehouseInspectionService) {}
|
||||
|
||||
@Post('warehouse-inventory/:inventoryId/inspection-reports')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.create)
|
||||
@ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' })
|
||||
create(
|
||||
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
|
||||
@@ -46,12 +50,14 @@ export class WarehouseInspectionController {
|
||||
}
|
||||
|
||||
@Patch('warehouse-inspection-reports/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
|
||||
@ApiOperation({ summary: 'Update an inspection report' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) {
|
||||
return this.inspectionService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post('warehouse-inspection-reports/:id/attachments')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload inspection images / documents' })
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Reques
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||
@@ -11,6 +13,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
@@ -29,48 +32,63 @@ export class WarehouseInventoryController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'List warehouse inventory' })
|
||||
findAll(@Query() filter: FilterWarehouseInventoryDto) {
|
||||
return this.inventoryService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get('ready-for-loading')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'List inventory ready for loading' })
|
||||
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
|
||||
return this.inventoryService.findReadyForLoading(filter);
|
||||
}
|
||||
|
||||
@Get('inquiry')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Locate any item inside the warehouse' })
|
||||
inquiry(@Query() filter: InquiryWarehouseInventoryDto) {
|
||||
return this.inventoryService.inquiry(filter);
|
||||
}
|
||||
|
||||
@Get('arrival-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' })
|
||||
arrivalQueue() {
|
||||
return this.inventoryService.arrivalQueue();
|
||||
}
|
||||
|
||||
@Get('ops-stats')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'At-a-glance warehouse ops counters for the KPI strip' })
|
||||
opsStats() {
|
||||
return this.inventoryService.opsStats();
|
||||
}
|
||||
|
||||
@Get('zone-occupancy')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
|
||||
zoneOccupancy(@Query('yardId') yardId?: string) {
|
||||
return this.inventoryService.zoneOccupancy(yardId);
|
||||
}
|
||||
|
||||
@Post('auto-unload-arrived')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
||||
autoUnloadArrived() {
|
||||
return this.inventoryService.autoUnloadArrived();
|
||||
}
|
||||
|
||||
@Post('auto-load-ready')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
|
||||
@ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' })
|
||||
autoLoadReady() {
|
||||
return this.inventoryService.autoLoadReady();
|
||||
}
|
||||
|
||||
@Get('eligible-bookings')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@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;
|
||||
@@ -78,6 +96,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post('receive-bulk')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
||||
receiveBulk(@Body() dto: BulkReceiveDto) {
|
||||
return this.inventoryService.bulkReceive(dto);
|
||||
@@ -85,36 +104,42 @@ export class WarehouseInventoryController {
|
||||
|
||||
|
||||
@Get('ready-to-load-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })
|
||||
readyToLoadExport() {
|
||||
return this.inventoryService.readyToLoadExport();
|
||||
}
|
||||
|
||||
@Get('received-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' })
|
||||
receivedExport() {
|
||||
return this.inventoryService.receivedExport();
|
||||
}
|
||||
|
||||
@Get('loaded-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
|
||||
loadedExport() {
|
||||
return this.inventoryService.loadedExport();
|
||||
}
|
||||
|
||||
@Get('loadable-trains')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
|
||||
loadableTrains() {
|
||||
return this.inventoryService.loadableTrains();
|
||||
}
|
||||
|
||||
@Get('train/:scheduleId/loadable-items')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' })
|
||||
trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.inventoryService.trainLoadableItems(scheduleId);
|
||||
}
|
||||
|
||||
@Post('train/:scheduleId/load')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
|
||||
@ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' })
|
||||
loadItemsOntoTrain(
|
||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||
@@ -124,18 +149,21 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post('bulk-dispatch-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
||||
@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')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.inspect)
|
||||
@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')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
|
||||
unloadBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@@ -145,24 +173,28 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post(':id/gate-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
|
||||
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
|
||||
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.gateClearance(id, performedBy);
|
||||
}
|
||||
|
||||
@Get('import/arrive-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' })
|
||||
importArriveQueue() {
|
||||
return this.scheduling.importArriveQueue();
|
||||
}
|
||||
|
||||
@Get('import/trains/:scheduleId/items')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@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')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
|
||||
autoUnloadArrivedBookings(@Body() dto: {
|
||||
scheduleId: string;
|
||||
@@ -179,12 +211,14 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('import/unloaded-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' })
|
||||
importUnloadedQueue() {
|
||||
return this.inventoryService.importUnloadedQueue();
|
||||
}
|
||||
|
||||
@Get('export/djibouti-arrival-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' })
|
||||
exportDjiboutiArrivalQueue(
|
||||
@Query('scheduleId') scheduleId?: string,
|
||||
@@ -203,102 +237,119 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('export/djibouti-trains/:scheduleId/items')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' })
|
||||
exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.scheduling.exportDjiboutiTrainDetail(scheduleId);
|
||||
}
|
||||
|
||||
@Post('export/auto-unload-at-djibouti')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
|
||||
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
|
||||
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
|
||||
}
|
||||
|
||||
@Get('import/pickup-ready-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
|
||||
importPickupReadyQueue() {
|
||||
return this.inventoryService.importPickupReadyQueue();
|
||||
}
|
||||
|
||||
@Get('loadable-wagons')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
|
||||
loadableWagons() {
|
||||
return this.scheduling.listLoadableWagons();
|
||||
}
|
||||
|
||||
@Get('booking/:bookingId/schedule')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' })
|
||||
bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.scheduling.getBookingSchedule(bookingId);
|
||||
}
|
||||
|
||||
@Post('receive')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
|
||||
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
|
||||
return this.inventoryService.receive(dto);
|
||||
}
|
||||
|
||||
@Post('reserve')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
|
||||
reserve(@Body() dto: ReserveInventoryDto) {
|
||||
return this.inventoryService.reserve(dto);
|
||||
}
|
||||
|
||||
@Get(':id/movements')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Inventory movement history' })
|
||||
movements(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findMovements(id);
|
||||
}
|
||||
|
||||
@Get(':id/activity')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Inventory activity log' })
|
||||
activity(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findActivity(id);
|
||||
}
|
||||
|
||||
@Get(':id/loadings')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Loading records for an inventory item' })
|
||||
loadings(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findLoadingsByInventory(id);
|
||||
}
|
||||
|
||||
@Post(':id/move')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
|
||||
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
|
||||
return this.inventoryService.move(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/store')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
||||
return this.inventoryService.store(id, dto.performedBy, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-loading')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
|
||||
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.readyForLoading(id, performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
|
||||
@ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) {
|
||||
return this.inventoryService.load(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-pickup')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@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')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.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);
|
||||
}
|
||||
|
||||
@Get(':id/release-document')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'View warehouse release / exit paper PDF' })
|
||||
async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.releaseDocument(id);
|
||||
@@ -309,6 +360,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('customer-truck-exit-paper/:assignmentId')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' })
|
||||
async truckExitPaper(
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@@ -322,6 +374,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get(':id/grn-document')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'View goods received note PDF' })
|
||||
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.grnDocument(id);
|
||||
@@ -342,12 +395,17 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/approve-delivery')
|
||||
@ApiOperation({ summary: "Approve delivery using the current customer's saved signature" })
|
||||
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
|
||||
approveDeliveryForBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: ApproveDeliveryDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
|
||||
return this.inventoryService.approveDeliveryForBooking(
|
||||
bookingId,
|
||||
req.user?.id ?? req.user?.sub,
|
||||
dto.signerName,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/handovers')
|
||||
@@ -362,6 +420,26 @@ export class WarehouseInventoryController {
|
||||
return this.handoverService.requestSignature(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/grn-document')
|
||||
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
|
||||
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/release-document')
|
||||
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
|
||||
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/handover-document')
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
|
||||
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
@@ -385,12 +463,14 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.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')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
||||
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
||||
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.dispatch(id, performedBy);
|
||||
|
||||
@@ -397,6 +397,45 @@ export class WarehouseInventoryService {
|
||||
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
||||
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
|
||||
*/
|
||||
/**
|
||||
* At-a-glance warehouse ops counters for the KPI strip:
|
||||
* - receivedToday: items received today
|
||||
* - pendingInspection: RECEIVED items not yet inspected
|
||||
* - trucksOnSite: customer trucks arrived but not departed
|
||||
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
|
||||
*/
|
||||
async opsStats(): Promise<{
|
||||
receivedToday: number;
|
||||
pendingInspection: number;
|
||||
trucksOnSite: number;
|
||||
itemsAging: number;
|
||||
}> {
|
||||
const [row]: Array<{
|
||||
receivedToday: number;
|
||||
pendingInspection: number;
|
||||
trucksOnSite: number;
|
||||
itemsAging: number;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
|
||||
(SELECT count(*)::int FROM freight.customer_truck_assignments
|
||||
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite",
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL
|
||||
AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
|
||||
AND created_at < now() - interval '7 days') AS "itemsAging"`,
|
||||
);
|
||||
return {
|
||||
receivedToday: row?.receivedToday ?? 0,
|
||||
pendingInspection: row?.pendingInspection ?? 0,
|
||||
trucksOnSite: row?.trucksOnSite ?? 0,
|
||||
itemsAging: row?.itemsAging ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live occupancy per zone: rated capacity vs the weight/items currently held
|
||||
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
|
||||
@@ -454,14 +493,17 @@ export class WarehouseInventoryService {
|
||||
|
||||
return rows.map((r) => {
|
||||
const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null;
|
||||
// Zone capacity_weight is in TONNES; inventory weight is in KG — normalise
|
||||
// used weight to tonnes before comparing so weight occupancy is correct.
|
||||
const usedWeightTons = r.usedWeight / 1000;
|
||||
const byWeight =
|
||||
capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null;
|
||||
capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null;
|
||||
const byItems =
|
||||
r.capacityContainers && r.capacityContainers > 0
|
||||
? (r.usedItems / r.capacityContainers) * 100
|
||||
: null;
|
||||
// Prefer container-count occupancy (unit-consistent). Weight capacity is
|
||||
// tonnes while inventory weight is kg, so weight% is only a rough fallback.
|
||||
// Container zones use item-count occupancy; bulk zones (no container cap)
|
||||
// fall back to the now unit-correct weight occupancy.
|
||||
const pct = byItems ?? byWeight;
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -3132,16 +3174,20 @@ export class WarehouseInventoryService {
|
||||
async approveDeliveryForBooking(
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
signerName?: string,
|
||||
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
|
||||
if (!userId) {
|
||||
throw new BadRequestException('Authentication is required to approve delivery');
|
||||
}
|
||||
|
||||
const signature = await this.signatures.getForUser(userId);
|
||||
if (!signature?.signatureImageUrl) {
|
||||
throw new BadRequestException('Please save your signature before approving delivery');
|
||||
const name = signerName?.trim();
|
||||
if (!name) {
|
||||
throw new BadRequestException('Please enter your full name to approve delivery');
|
||||
}
|
||||
|
||||
// A saved signature is applied when available; otherwise the typed full name
|
||||
// is the record of who approved (self-haul customers may have no signature).
|
||||
const signature = await this.signatures.getForUser(userId).catch(() => null);
|
||||
|
||||
const [item]: Array<{
|
||||
id: string;
|
||||
warehouseId: string | null;
|
||||
@@ -3176,8 +3222,8 @@ export class WarehouseInventoryService {
|
||||
const approvedAt = new Date();
|
||||
const approval = {
|
||||
approvedAt: approvedAt.toISOString(),
|
||||
signerDisplayName: signature.signerDisplayName,
|
||||
signatureImageUrl: signature.signatureImageUrl,
|
||||
signerDisplayName: name,
|
||||
signatureImageUrl: signature?.signatureImageUrl ?? null,
|
||||
userId,
|
||||
};
|
||||
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
|
||||
@@ -3192,8 +3238,8 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
inventoryId: item.id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: `Customer approved delivery as ${signature.signerDisplayName}`,
|
||||
performedBy: signature.signerDisplayName,
|
||||
description: `Customer approved delivery as ${name}`,
|
||||
performedBy: name,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
@@ -3201,13 +3247,13 @@ export class WarehouseInventoryService {
|
||||
|
||||
// Sign the structured handover record(s) for this booking (self-haul: before
|
||||
// the truck leaves). Kept alongside the legacy approval note.
|
||||
await this.handover.signForBooking(bookingId, userId);
|
||||
await this.handover.signForBooking(bookingId, userId, name);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
inventoryId: item.id,
|
||||
approvedAt: approval.approvedAt,
|
||||
signerDisplayName: signature.signerDisplayName,
|
||||
signerDisplayName: name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3226,6 +3272,31 @@ export class WarehouseInventoryService {
|
||||
return this.handoverDocument(inv.id);
|
||||
}
|
||||
|
||||
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
|
||||
private async primaryInventoryIdForBooking(bookingId: string): Promise<string> {
|
||||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC NULLS LAST, created_at DESC
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!inv) {
|
||||
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
|
||||
}
|
||||
return inv.id;
|
||||
}
|
||||
|
||||
/** Booking-scoped GRN document (customer portal). */
|
||||
async grnDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
return this.grnDocument(await this.primaryInventoryIdForBooking(bookingId));
|
||||
}
|
||||
|
||||
/** Booking-scoped gate-clearance / release document (customer portal). */
|
||||
async releaseDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
|
||||
}
|
||||
|
||||
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
@@ -13,12 +15,14 @@ export class WarehouseInvoiceController {
|
||||
constructor(private readonly invoiceService: WarehouseInvoiceService) {}
|
||||
|
||||
@Post('warehouse-inventory/:id/generate-fee-invoice')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
|
||||
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
|
||||
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
|
||||
return this.invoiceService.generateForInventory(id, dto);
|
||||
}
|
||||
|
||||
@Post('last-mile/:id/generate-truck-detention-invoice')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
|
||||
@ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' })
|
||||
generateTruckDetention(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -28,6 +32,7 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Get('warehouse-inventory/:id/fee-invoices')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
|
||||
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
|
||||
listForInventory(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoiceService.listForInventory(id);
|
||||
@@ -40,6 +45,7 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Get('warehouse-fee-invoices')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
|
||||
@ApiOperation({ summary: 'List / filter warehouse fee invoices' })
|
||||
findAll(
|
||||
@Query('status') status?: string,
|
||||
@@ -86,12 +92,14 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Patch('warehouse-fee-invoices/:id/cancel')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.cancel)
|
||||
@ApiOperation({ summary: 'Cancel a warehouse fee invoice' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoiceService.cancel(id);
|
||||
}
|
||||
|
||||
@Post('warehouse-fee-invoices/:id/pay')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.pay)
|
||||
@ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' })
|
||||
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
|
||||
return this.invoiceService.pay(id, dto);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
@ApiTags('warehouse-loadings')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-loadings')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
export class WarehouseLoadingsController {
|
||||
constructor(private readonly inventoryService: WarehouseInventoryService) {}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
AllocationPreviewDto,
|
||||
CreateAllocationRuleDto,
|
||||
UpdateAllocationRuleDto,
|
||||
} from './dto/allocation-rule.dto';
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { AcknowledgeAccrualDto } from './dto/acknowledge-accrual.dto';
|
||||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
|
||||
@@ -21,18 +24,21 @@ export class WarehouseRulesController {
|
||||
|
||||
// ── Allocation rules ───────────────────────────────────────────────────────
|
||||
@Get('warehouse-allocation-rules')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view)
|
||||
@ApiOperation({ summary: 'List warehouse allocation rules' })
|
||||
listAllocationRules() {
|
||||
return this.allocationService.listRules();
|
||||
}
|
||||
|
||||
@Post('warehouse-allocation-rules')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.create)
|
||||
@ApiOperation({ summary: 'Create a warehouse allocation rule' })
|
||||
createAllocationRule(@Body() dto: CreateAllocationRuleDto) {
|
||||
return this.allocationService.createRule(dto);
|
||||
}
|
||||
|
||||
@Patch('warehouse-allocation-rules/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.update)
|
||||
@ApiOperation({ summary: 'Update a warehouse allocation rule' })
|
||||
updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) {
|
||||
return this.allocationService.updateRule(id, dto);
|
||||
@@ -40,12 +46,14 @@ export class WarehouseRulesController {
|
||||
|
||||
@Delete('warehouse-allocation-rules/:id')
|
||||
@HttpCode(204)
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.delete)
|
||||
@ApiOperation({ summary: 'Delete a warehouse allocation rule' })
|
||||
deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.allocationService.deleteRule(id);
|
||||
}
|
||||
|
||||
@Post('warehouse-allocation/preview')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view)
|
||||
@ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' })
|
||||
previewAllocation(@Body() dto: AllocationPreviewDto) {
|
||||
return this.allocationService.resolveLocation(dto);
|
||||
@@ -53,18 +61,21 @@ export class WarehouseRulesController {
|
||||
|
||||
// ── Fee rules ────────────────────────────────────────────────────────────────
|
||||
@Get('warehouse-fee-rules')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'List storage / demurrage fee rules' })
|
||||
listFeeRules() {
|
||||
return this.feeService.listRules();
|
||||
}
|
||||
|
||||
@Post('warehouse-fee-rules')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.create)
|
||||
@ApiOperation({ summary: 'Create a storage / demurrage fee rule' })
|
||||
createFeeRule(@Body() dto: CreateFeeRuleDto) {
|
||||
return this.feeService.createRule(dto);
|
||||
}
|
||||
|
||||
@Patch('warehouse-fee-rules/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
|
||||
@ApiOperation({ summary: 'Update a fee rule' })
|
||||
updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) {
|
||||
return this.feeService.updateRule(id, dto);
|
||||
@@ -72,12 +83,42 @@ export class WarehouseRulesController {
|
||||
|
||||
@Delete('warehouse-fee-rules/:id')
|
||||
@HttpCode(204)
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.delete)
|
||||
@ApiOperation({ summary: 'Delete a fee rule' })
|
||||
deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.feeService.deleteRule(id);
|
||||
}
|
||||
|
||||
@Get('warehouse-fees/accrual-dashboard')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'Live per-item fee accrual (storage/demurrage) with alerts' })
|
||||
accrualDashboard(@Query('billingCurrency') billingCurrency?: string) {
|
||||
return this.feeService.accrualDashboard(billingCurrency);
|
||||
}
|
||||
|
||||
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
|
||||
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })
|
||||
acknowledgeAccrual(
|
||||
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
|
||||
@Body() dto: AcknowledgeAccrualDto,
|
||||
) {
|
||||
return this.feeService.acknowledgeAccrual(inventoryId, {
|
||||
snoozeDays: dto.snoozeDays,
|
||||
note: dto.note,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('warehouse-fees/accrual/:inventoryId/acknowledge')
|
||||
@HttpCode(204)
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
|
||||
@ApiOperation({ summary: 'Remove an accrual acknowledgement (re-surface for alerts)' })
|
||||
unacknowledgeAccrual(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) {
|
||||
return this.feeService.unacknowledgeAccrual(inventoryId);
|
||||
}
|
||||
|
||||
@Get('warehouse-inventory/:id/fee-preview')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
|
||||
feePreview(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -87,6 +128,7 @@ export class WarehouseRulesController {
|
||||
}
|
||||
|
||||
@Get('last-mile/:id/truck-detention-preview')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' })
|
||||
truckDetentionPreview(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
@@ -9,6 +11,7 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
@ApiTags('warehouse-yards')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
||||
export class WarehouseYardsController {
|
||||
constructor(
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
@@ -28,18 +31,21 @@ export class WarehouseYardsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.update)
|
||||
@ApiOperation({ summary: 'Update warehouse yard' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) {
|
||||
return this.yardsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Get(':yardId/zones')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
||||
@ApiOperation({ summary: 'List zones within a yard' })
|
||||
listZones(@Param('yardId', ParseUUIDPipe) yardId: string) {
|
||||
return this.zonesService.findByYard(yardId);
|
||||
}
|
||||
|
||||
@Post(':yardId/zones')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.create)
|
||||
@ApiOperation({ summary: 'Create a zone within a yard' })
|
||||
createZone(
|
||||
@Param('yardId', ParseUUIDPipe) yardId: string,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
@ApiTags('warehouse-zones')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-zones')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
||||
export class WarehouseZonesController {
|
||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||
|
||||
@@ -23,6 +26,7 @@ export class WarehouseZonesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.update)
|
||||
@ApiOperation({ summary: 'Update warehouse zone' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
|
||||
return this.zonesService.update(id, dto);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
|
||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
|
||||
@@ -12,6 +14,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
@ApiTags('warehouses')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouses')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouses.view)
|
||||
export class WarehousesController {
|
||||
constructor(
|
||||
private readonly warehousesService: WarehousesService,
|
||||
@@ -26,12 +29,14 @@ export class WarehousesController {
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseDashboard.view)
|
||||
@ApiOperation({ summary: 'Warehouse dashboard metrics' })
|
||||
dashboard() {
|
||||
return this.dashboardService.getDashboard();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouses.create)
|
||||
@ApiOperation({ summary: 'Create warehouse' })
|
||||
create(@Body() dto: CreateWarehouseDto) {
|
||||
return this.warehousesService.create(dto);
|
||||
@@ -44,18 +49,21 @@ export class WarehousesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouses.update)
|
||||
@ApiOperation({ summary: 'Update warehouse' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) {
|
||||
return this.warehousesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Get(':warehouseId/yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
||||
@ApiOperation({ summary: 'List yards within a warehouse' })
|
||||
listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) {
|
||||
return this.yardsService.findByWarehouse(warehouseId);
|
||||
}
|
||||
|
||||
@Post(':warehouseId/yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.create)
|
||||
@ApiOperation({ summary: 'Create a yard within a warehouse' })
|
||||
createYard(
|
||||
@Param('warehouseId', ParseUUIDPipe) warehouseId: string,
|
||||
|
||||
@@ -177,6 +177,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'),
|
||||
perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'),
|
||||
perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'),
|
||||
perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"),
|
||||
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
|
||||
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
||||
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
|
||||
@@ -446,6 +447,9 @@ export const FREIGHT_PERMS = {
|
||||
// executes the move). Distinct keys so OCC can hold fulfil without request.
|
||||
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
||||
transferFulfill: 'edr_freight_app:wagons:transfer_fulfill',
|
||||
// Admin: read every staffer's transfer history. Without it, a user only sees
|
||||
// their own (the /history endpoint uses the caller id, backend-enforced).
|
||||
transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all',
|
||||
},
|
||||
trains: {
|
||||
view: 'edr_freight_app:trains:view',
|
||||
|
||||
@@ -49,6 +49,7 @@ import { api } from "@/services/api";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import {
|
||||
useContractCapacity,
|
||||
useContractDetail,
|
||||
@@ -180,6 +181,9 @@ export default function GlCreateBookingForm() {
|
||||
}>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestIdParam = searchParams.get("requestId");
|
||||
// Rebook: copy an EXPIRED booking's cargo into a fresh booking on the same
|
||||
// contract (GL only picks a new schedule). Set by the clearance Rebook action.
|
||||
const copyFromParam = searchParams.get("copyFrom");
|
||||
const navigate = useNavigate();
|
||||
const { data: contract, isLoading } = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
@@ -205,6 +209,13 @@ export default function GlCreateBookingForm() {
|
||||
enabled: Boolean(requestId),
|
||||
});
|
||||
|
||||
// The expired booking a Rebook is copying from (its cargo seeds the form).
|
||||
const { data: copyFromBooking } = useQuery({
|
||||
queryKey: ["rebook-copy-from", copyFromParam],
|
||||
queryFn: () => bookingsService.getById(copyFromParam!),
|
||||
enabled: Boolean(copyFromParam),
|
||||
});
|
||||
|
||||
// Same window-gating the customer sees: booking is only allowed while a
|
||||
// window is OPEN for one of the contract's routes. Intercity contracts are
|
||||
// never window-gated — the shipment rides a passing train staff pick later.
|
||||
@@ -363,6 +374,28 @@ export default function GlCreateBookingForm() {
|
||||
if (bookingRequest.notes) setNotes(bookingRequest.notes);
|
||||
}, [bookingRequest, prefilled]);
|
||||
|
||||
// Rebook seed: copy the source booking's container lines once. (Bulk weight /
|
||||
// item count isn't on the booking payload yet, so bulk rebooks fall through to
|
||||
// the normal contract seed and GL re-enters the quantity.)
|
||||
useEffect(() => {
|
||||
if (!copyFromBooking || prefilled) return;
|
||||
const lines = copyFromBooking.bookingContainers ?? [];
|
||||
if (!lines.length) return;
|
||||
setPrefilled(true);
|
||||
setContainerLines(
|
||||
lines.map((c) => {
|
||||
const qty = Math.max(1, c.quantity);
|
||||
return {
|
||||
containerSize: String(c.containerType?.sizeFt ?? ""),
|
||||
quantity: String(qty),
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
units: Array.from({ length: qty }, emptyUnit),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, [copyFromBooking, prefilled]);
|
||||
|
||||
// Seed one shipment line per contracted size exactly once — same seeding the
|
||||
// portal form does. Subsequent renders reuse the lines.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Alert, Button, Group, Modal, Select, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { TrainComposition } from "@/services/trainBuilder.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Relocate the train to another yard. The consist moves as one unit — every
|
||||
* coupled locomotive and wagon follows, so their current yards always match
|
||||
* the train's.
|
||||
*/
|
||||
export default function ChangeYardModal({ composition, opened, onClose }: ChangeYardModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [yardId, setYardId] = useState("");
|
||||
|
||||
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
|
||||
const setYard = useMutation(api.trainBuilder.setYard.mutationOptions());
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setYardId(composition?.currentYard?.id ?? "");
|
||||
}, [opened, composition]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!composition || !yardId) return;
|
||||
try {
|
||||
await setYard.mutateAsync({ id: composition.id, currentYardId: yardId });
|
||||
toast({ title: "Train relocated" });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Relocation failed",
|
||||
description: parseError(err, "Could not change the yard"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const memberCount =
|
||||
(composition?.locomotives.length ?? 0) + (composition?.totals.wagonCount ?? 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>Change yard — train {composition?.code}</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="yellow" icon={<MapPin size={16} />}>
|
||||
The whole consist moves with the train: {composition?.locomotives.length ?? 0}{" "}
|
||||
locomotive{(composition?.locomotives.length ?? 0) === 1 ? "" : "s"} and{" "}
|
||||
{composition?.totals.wagonCount ?? 0} wagon
|
||||
{(composition?.totals.wagonCount ?? 0) === 1 ? "" : "s"} ({memberCount} vehicles)
|
||||
are relocated so their current yard always matches the train's. Wagon moves are
|
||||
recorded in the movement ledger.
|
||||
</Alert>
|
||||
<Select
|
||||
label="New yard"
|
||||
placeholder="Select yard"
|
||||
data={(yardsQuery.data ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
}))}
|
||||
value={yardId || null}
|
||||
onChange={(v) => setYardId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={setYard.isPending}
|
||||
disabled={!yardId || yardId === composition?.currentYard?.id}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Relocate train
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChangeYardModalProps {
|
||||
composition: TrainComposition | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Train as TrainIcon } from "lucide-react";
|
||||
|
||||
import type {
|
||||
TrainCompositionLocomotive,
|
||||
TrainCompositionWagon,
|
||||
} from "@/services/trainBuilder.service";
|
||||
|
||||
/**
|
||||
* Visual consist: locomotives + wagons drawn in order on a rail, the way the
|
||||
* train would leave the yard. Scrolls horizontally for long consists.
|
||||
*/
|
||||
export default function TrainConsistStrip({
|
||||
locomotives,
|
||||
wagons,
|
||||
emptyHint = "No wagons attached yet — add wagons from the yard below.",
|
||||
}: TrainConsistStripProps) {
|
||||
return (
|
||||
<Box
|
||||
px="md"
|
||||
py="lg"
|
||||
style={{
|
||||
overflowX: "auto",
|
||||
borderRadius: 12,
|
||||
background:
|
||||
"linear-gradient(180deg, var(--mantine-color-gray-0) 0%, var(--mantine-color-gray-1) 100%)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Box style={{ display: "inline-block", minWidth: "100%" }}>
|
||||
<Group gap={0} wrap="nowrap" align="flex-end">
|
||||
{locomotives.map((loco, index) => (
|
||||
<Group key={loco.id} gap={0} wrap="nowrap" align="flex-end">
|
||||
{index > 0 ? <Coupler /> : null}
|
||||
<LocomotiveCar locomotive={loco} />
|
||||
</Group>
|
||||
))}
|
||||
{wagons.map((wagon) => (
|
||||
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-end">
|
||||
<Coupler />
|
||||
<WagonCar wagon={wagon} />
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
{/* The rail */}
|
||||
<Box
|
||||
mt={6}
|
||||
style={{
|
||||
height: 0,
|
||||
borderTop: "3px solid var(--mantine-color-gray-4)",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-3)",
|
||||
}}
|
||||
/>
|
||||
{!wagons.length ? (
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{emptyHint}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TrainConsistStripProps {
|
||||
locomotives: TrainCompositionLocomotive[];
|
||||
wagons: TrainCompositionWagon[];
|
||||
emptyHint?: string;
|
||||
}
|
||||
|
||||
function Coupler() {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: 12,
|
||||
height: 4,
|
||||
marginBottom: 18,
|
||||
background: "var(--mantine-color-gray-5)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LocomotiveCar({ locomotive }: { locomotive: TrainCompositionLocomotive }) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${locomotive.code}${locomotive.name ? ` — ${locomotive.name}` : ""} · ${
|
||||
locomotive.role === "LEAD" ? "Lead" : "Assist"
|
||||
} · pulls ${locomotive.maxPullWeightTons}T`}
|
||||
withArrow
|
||||
>
|
||||
<Stack
|
||||
gap={2}
|
||||
align="center"
|
||||
px="sm"
|
||||
py={6}
|
||||
style={{
|
||||
minWidth: 96,
|
||||
borderRadius: "10px 14px 4px 4px",
|
||||
background:
|
||||
"linear-gradient(180deg, var(--mantine-color-edr-green-6) 0%, var(--mantine-color-edr-green-8) 100%)",
|
||||
color: "white",
|
||||
border: "1px solid var(--mantine-color-edr-green-9)",
|
||||
flexShrink: 0,
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<TrainIcon size={13} />
|
||||
<Text size="xs" fw={700} ff="monospace" lh={1.2}>
|
||||
{locomotive.code}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="10px" fw={600} tt="uppercase" style={{ opacity: 0.85 }} lh={1}>
|
||||
{locomotive.role === "LEAD" ? "Lead loco" : "Assist loco"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function WagonCar({ wagon }: { wagon: TrainCompositionWagon }) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${wagon.wagonNumber}${
|
||||
wagon.wagonType
|
||||
? ` · ${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
|
||||
: ""
|
||||
}`}
|
||||
withArrow
|
||||
>
|
||||
<Stack
|
||||
gap={2}
|
||||
align="center"
|
||||
px="xs"
|
||||
py={6}
|
||||
style={{
|
||||
minWidth: 76,
|
||||
borderRadius: 6,
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderBottom: "3px solid var(--mantine-color-edr-green-3)",
|
||||
flexShrink: 0,
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
<Text size="10px" c="dimmed" lh={1}>
|
||||
#{wagon.sequenceNumber ?? "—"}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} ff="monospace" lh={1.2}>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed" lh={1}>
|
||||
{wagon.wagonType?.code ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Modal,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { AlertTriangle, History, Minus, Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { ConsistWagonRef } from "@/services/trainBuilder.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0;
|
||||
const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0;
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
/**
|
||||
* Adjust the built train's consist from a schedule: trim free wagons (their
|
||||
* tare no longer rides — the fix when gross weight beats the pull limit) or
|
||||
* couple extra yard wagons while weight/length headroom remains. Changes are
|
||||
* permanent on the train and logged on the schedule.
|
||||
*/
|
||||
export default function AdjustConsistModal({
|
||||
scheduleId,
|
||||
opened,
|
||||
onClose,
|
||||
}: AdjustConsistModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||
const [addIds, setAddIds] = useState<string[]>([]);
|
||||
|
||||
const consistQuery = useQuery(
|
||||
api.trainScheduling.scheduleConsist.queryOptions({
|
||||
input: { scheduleId },
|
||||
enabled: opened && Boolean(scheduleId),
|
||||
}),
|
||||
);
|
||||
const adjust = useMutation(api.trainScheduling.adjustConsist.mutationOptions());
|
||||
const data = consistQuery.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setRemoveIds([]);
|
||||
setAddIds([]);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Live projection: gross = cargo + tare of (consist − trims + adds).
|
||||
const projection = useMemo(() => {
|
||||
if (!data) return null;
|
||||
const removed = new Set(removeIds);
|
||||
const keptTare = data.wagons
|
||||
.filter((w) => !removed.has(w.id))
|
||||
.reduce((s, w) => s + tareOf(w), 0);
|
||||
const keptLength = data.wagons
|
||||
.filter((w) => !removed.has(w.id))
|
||||
.reduce((s, w) => s + lengthOf(w), 0);
|
||||
const addedWagons = data.addableWagons.filter((w) => addIds.includes(w.id));
|
||||
const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0);
|
||||
const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0);
|
||||
const gross = round2(data.totals.cargoTons + tare);
|
||||
return {
|
||||
wagonCount: data.totals.wagonCount - removeIds.length + addIds.length,
|
||||
tare: round2(tare),
|
||||
gross,
|
||||
length: round2(length),
|
||||
grossPct: data.limits.pullCapTons
|
||||
? Math.round((gross / data.limits.pullCapTons) * 100)
|
||||
: null,
|
||||
lengthPct: data.limits.lengthCapMeters
|
||||
? Math.round((length / data.limits.lengthCapMeters) * 100)
|
||||
: null,
|
||||
overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons,
|
||||
overLength:
|
||||
data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters,
|
||||
};
|
||||
}, [data, removeIds, addIds]);
|
||||
|
||||
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
|
||||
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!removeIds.length && !addIds.length) return;
|
||||
try {
|
||||
await adjust.mutateAsync({
|
||||
scheduleId,
|
||||
payload: {
|
||||
...(addIds.length ? { addWagonIds: addIds } : {}),
|
||||
...(removeIds.length ? { removeWagonIds: removeIds } : {}),
|
||||
},
|
||||
});
|
||||
toast({
|
||||
title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${
|
||||
removeIds.length && addIds.length ? ", " : ""
|
||||
}${addIds.length ? `${addIds.length} added` : ""}`,
|
||||
});
|
||||
setRemoveIds([]);
|
||||
setAddIds([]);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Adjustment failed",
|
||||
description: parseError(err, "Could not adjust the consist"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Text fw={600}>
|
||||
Adjust consist{data ? ` — train ${data.train.code}` : ""}
|
||||
</Text>
|
||||
}
|
||||
radius="lg"
|
||||
size={860}
|
||||
centered
|
||||
>
|
||||
{consistQuery.isLoading || !data ? (
|
||||
<Text py="lg" ta="center" c="dimmed" size="sm">
|
||||
{consistQuery.isError
|
||||
? "This schedule has no built train to adjust."
|
||||
: "Loading consist…"}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{!data.editable ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
The consist is frozen once the train is dispatched.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<LimitGauge
|
||||
label="Gross weight"
|
||||
detail={`${data.totals.cargoTons}T cargo + ${projection?.tare}T tare = ${projection?.gross}T of ${data.limits.pullCapTons}T (limit ${data.limits.maxPullWeightTons}T + ${data.limits.overageToleranceTons}T tolerance)`}
|
||||
pct={projection?.grossPct ?? null}
|
||||
over={projection?.overWeight ?? false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<LimitGauge
|
||||
label="Consist length"
|
||||
detail={`${projection?.length}m of ${data.limits.lengthCapMeters}m (limit ${data.limits.maxTrainLengthMeters}m + ${data.limits.overageToleranceMeters}m tolerance)`}
|
||||
pct={projection?.lengthPct ?? null}
|
||||
over={projection?.overLength ?? false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<Group gap={6}>
|
||||
<Minus size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
Trim coupled wagons ({data.totals.wagonCount})
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Only free (unloaded, unpinned) wagons can be detached. Detaching is
|
||||
permanent — the wagon returns to the yard as available.
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={260} type="auto">
|
||||
<Stack gap={4}>
|
||||
{data.wagons.map((wagon) => (
|
||||
<WagonRow
|
||||
key={wagon.id}
|
||||
wagon={wagon}
|
||||
checked={removeIds.includes(wagon.id)}
|
||||
disabled={!data.editable || !wagon.removable}
|
||||
badge={
|
||||
wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null
|
||||
}
|
||||
onToggle={toggle(setRemoveIds)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<Group gap={6}>
|
||||
<Plus size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
Couple yard wagons ({data.addableWagons.length} available)
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
AVAILABLE wagons standing in the train's yard. Blocked when they push
|
||||
gross weight or length past the locomotive limits incl. tolerance.
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={260} type="auto">
|
||||
<Stack gap={4}>
|
||||
{data.addableWagons.length ? (
|
||||
data.addableWagons.map((wagon) => (
|
||||
<WagonRow
|
||||
key={wagon.id}
|
||||
wagon={wagon}
|
||||
checked={addIds.includes(wagon.id)}
|
||||
disabled={!data.editable}
|
||||
badge={null}
|
||||
onToggle={toggle(setAddIds)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" py="sm" ta="center">
|
||||
No available wagons in this yard
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{data.adjustments.length ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap={4}>
|
||||
<Group gap={6}>
|
||||
<History size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
Adjustment history
|
||||
</Text>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={120} type="auto">
|
||||
<Stack gap={2}>
|
||||
{data.adjustments.map((log) => (
|
||||
<Group key={log.id} gap="xs">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={log.action === "ADD" ? "edr-green" : "red"}
|
||||
>
|
||||
{log.action === "ADD" ? "Added" : "Trimmed"}
|
||||
</Badge>
|
||||
<Text size="xs" ff="monospace">
|
||||
{log.wagonNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(log.occurredAt).toLocaleString()}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
Projected consist: {projection?.wagonCount} wagons
|
||||
</Text>
|
||||
<Group>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
loading={adjust.isPending}
|
||||
disabled={
|
||||
!data.editable ||
|
||||
(!removeIds.length && !addIds.length) ||
|
||||
(addIds.length > 0 && (projection?.overWeight || projection?.overLength))
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Apply{" "}
|
||||
{removeIds.length ? `−${removeIds.length}` : ""}
|
||||
{removeIds.length && addIds.length ? " / " : ""}
|
||||
{addIds.length ? `+${addIds.length}` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AdjustConsistModalProps {
|
||||
scheduleId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function LimitGauge({
|
||||
label,
|
||||
detail,
|
||||
pct,
|
||||
over,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
pct: number | null;
|
||||
over: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={700} c={over ? "red.7" : "edr-green.7"}>
|
||||
{pct != null ? `${pct}%` : "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={Math.min(pct ?? 0, 100)}
|
||||
size="md"
|
||||
radius="xl"
|
||||
color={over ? "red" : (pct ?? 0) > 85 ? "yellow" : "edr-green"}
|
||||
striped={over}
|
||||
animated={over}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{detail}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function WagonRow({
|
||||
wagon,
|
||||
checked,
|
||||
disabled,
|
||||
badge,
|
||||
onToggle,
|
||||
}: {
|
||||
wagon: ConsistWagonRef;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
badge: string | null;
|
||||
onToggle: (id: string, checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p={6}
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
opacity: disabled && !badge ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onToggle(wagon.id, e.currentTarget.checked)}
|
||||
aria-label={`Select wagon ${wagon.wagonNumber}`}
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{wagon.wagonType
|
||||
? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m`
|
||||
: "Unknown type"}
|
||||
</Text>
|
||||
</Stack>
|
||||
{badge ? (
|
||||
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}>
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -506,12 +506,19 @@ function TrackBed() {
|
||||
|
||||
export function TrainCompositionDiagram({
|
||||
locomotive,
|
||||
locomotives,
|
||||
wagons,
|
||||
freightType,
|
||||
trainNumber,
|
||||
totalLengthMeters,
|
||||
}: {
|
||||
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
|
||||
/** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */
|
||||
locomotives?: Array<{
|
||||
code?: string | null;
|
||||
name?: string | null;
|
||||
maxPullWeightTons?: number | null;
|
||||
}> | null;
|
||||
wagons: DiagramWagonInput[];
|
||||
freightType?: string | null;
|
||||
trainNumber?: string | null;
|
||||
@@ -519,6 +526,11 @@ export function TrainCompositionDiagram({
|
||||
}) {
|
||||
const { ref, width } = useElementSize();
|
||||
|
||||
const locos = useMemo(
|
||||
() => (locomotives?.length ? locomotives : locomotive ? [locomotive] : []),
|
||||
[locomotives, locomotive],
|
||||
);
|
||||
|
||||
const normalized = useMemo(
|
||||
() => wagons.map((w) => normalizeWagon(w, freightType)),
|
||||
[wagons, freightType],
|
||||
@@ -533,6 +545,11 @@ export function TrainCompositionDiagram({
|
||||
// ceiling the allocation engine spends from.
|
||||
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
|
||||
const grossWeight = totalWeight + totalTare;
|
||||
// Weakest locomotive caps the set — same rule the allocation engine applies.
|
||||
const pullLimits = locos
|
||||
.map((l) => Number(l.maxPullWeightTons))
|
||||
.filter((v) => Number.isFinite(v) && v > 0);
|
||||
const pullLimit = pullLimits.length ? Math.min(...pullLimits) : null;
|
||||
return {
|
||||
total: normalized.length,
|
||||
assigned,
|
||||
@@ -541,22 +558,25 @@ export function TrainCompositionDiagram({
|
||||
totalTare: Math.round(totalTare * 100) / 100,
|
||||
grossWeight: Math.round(grossWeight * 100) / 100,
|
||||
totalCapacity,
|
||||
pullUtil:
|
||||
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
|
||||
? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
|
||||
: null,
|
||||
pullLimit,
|
||||
pullUtil: pullLimit
|
||||
? Math.min(100, Math.round((grossWeight / pullLimit) * 100))
|
||||
: null,
|
||||
};
|
||||
}, [normalized, locomotive]);
|
||||
}, [normalized, locos]);
|
||||
|
||||
// cars-per-row from measured width; locomotive counts as one car
|
||||
// cars-per-row from measured width; each locomotive counts as one car
|
||||
const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
|
||||
const cars = useMemo(
|
||||
() => [{ kind: "loco" as const }, ...normalized.map((w) => ({ kind: "wagon" as const, w }))],
|
||||
[normalized],
|
||||
() => [
|
||||
...locos.map((l) => ({ kind: "loco" as const, l })),
|
||||
...normalized.map((w) => ({ kind: "wagon" as const, w })),
|
||||
],
|
||||
[locos, normalized],
|
||||
);
|
||||
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
|
||||
|
||||
if (!locomotive && !wagons.length) return null;
|
||||
if (!locos.length && !wagons.length) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -658,8 +678,8 @@ export function TrainCompositionDiagram({
|
||||
<Text size="xs" fw={700} c="edr-green.8">
|
||||
Locomotive load ·{" "}
|
||||
{stats.totalTare > 0
|
||||
? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
||||
: `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
|
||||
? `${stats.grossWeight}T of ${stats.pullLimit}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
||||
: `${stats.totalWeight}T of ${stats.pullLimit}T`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
|
||||
@@ -702,13 +722,11 @@ export function TrainCompositionDiagram({
|
||||
<Group key={carIndex} gap={0} wrap="nowrap" style={{ flexDirection: reversed ? "row-reverse" : "row" }}>
|
||||
{carIndex > 0 ? <Coupler /> : null}
|
||||
{car.kind === "loco" ? (
|
||||
locomotive ? (
|
||||
<LocomotiveCar
|
||||
code={locomotive.code ?? "LOCO"}
|
||||
name={locomotive.name}
|
||||
maxPullWeightTons={locomotive.maxPullWeightTons}
|
||||
/>
|
||||
) : null
|
||||
<LocomotiveCar
|
||||
code={car.l.code ?? "LOCO"}
|
||||
name={car.l.name}
|
||||
maxPullWeightTons={car.l.maxPullWeightTons}
|
||||
/>
|
||||
) : (
|
||||
<WagonCar wagon={car.w} />
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
@@ -17,6 +19,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
History,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Warehouse,
|
||||
@@ -25,8 +28,13 @@ import {
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||
import type {
|
||||
WagonMovementRecord,
|
||||
WagonTransferRequest,
|
||||
} from "@/services/wagon.service";
|
||||
|
||||
export interface WagonTransferRequestsModalProps {
|
||||
opened: boolean;
|
||||
@@ -57,16 +65,172 @@ const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
|
||||
</Group>
|
||||
);
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "gray",
|
||||
FULFILLED: "teal",
|
||||
CANCELLED: "red",
|
||||
};
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-user transfer history. A staffer sees their OWN activity — the requests
|
||||
* they filed or fulfilled, and the individual wagons they moved. Holders of
|
||||
* `transfer_history_all` get an "All staff" toggle that widens the view; the
|
||||
* backend enforces the scope regardless of the toggle.
|
||||
*/
|
||||
function HistoryPanel({ opened }: { opened: boolean }) {
|
||||
const { user } = useAuth();
|
||||
const canSeeAll = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.wagons.transferHistoryAll,
|
||||
);
|
||||
const myId = (user as { id?: string } | null | undefined)?.id;
|
||||
const [allStaff, setAllStaff] = useState(false);
|
||||
const scopeAll = canSeeAll && allStaff;
|
||||
|
||||
const mine = useQuery({
|
||||
...api.wagonTransferRequests.history.queryOptions(),
|
||||
enabled: opened && !scopeAll,
|
||||
});
|
||||
const all = useQuery({
|
||||
...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }),
|
||||
enabled: opened && scopeAll,
|
||||
});
|
||||
const source = scopeAll ? all : mine;
|
||||
const requests = source.data?.requests ?? [];
|
||||
const movements: WagonMovementRecord[] = source.data?.movements ?? [];
|
||||
|
||||
const roleBadge = (r: WagonTransferRequest) => {
|
||||
if (myId && r.fulfilledByUserId === myId)
|
||||
return (
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
fulfilled
|
||||
</Badge>
|
||||
);
|
||||
if (myId && r.requestedByUserId === myId)
|
||||
return (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
requested
|
||||
</Badge>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{canSeeAll ? (
|
||||
<Group justify="flex-end">
|
||||
<Switch
|
||||
checked={allStaff}
|
||||
onChange={(e) => setAllStaff(e.currentTarget.checked)}
|
||||
label="All staff"
|
||||
color="edr-green"
|
||||
/>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{source.isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Text fw={700} size="sm" mb={8}>
|
||||
Requests{scopeAll ? "" : " you touched"}
|
||||
</Text>
|
||||
{requests.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No requests yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<RequestSummary r={r} />
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{roleBadge(r)}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[r.status] ?? "gray"}
|
||||
>
|
||||
{r.status.toLowerCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text fw={700} size="sm" mb={8}>
|
||||
Wagons moved
|
||||
</Text>
|
||||
{movements.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No wagon moves yet.
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={260}>
|
||||
<Stack gap={6}>
|
||||
{movements.map((m) => (
|
||||
<Card key={m.id} withBorder radius="md" padding="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{m.wagon?.wagonNumber ?? "Wagon"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{yardLabel(m.fromYard)} → {yardLabel(m.toYard)}
|
||||
</Text>
|
||||
{m.transferRequestId ? (
|
||||
<Badge size="xs" variant="light" color="teal">
|
||||
from request
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{fmtDateTime(m.occurredAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
|
||||
* one to hand-pick exactly the requested number of wagons from the source yard
|
||||
* (of the requested type) and execute the move, or cancel the request.
|
||||
* A second tab shows per-user transfer history.
|
||||
*/
|
||||
const WagonTransferRequestsModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
}: WagonTransferRequestsModalProps) => {
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState<string | null>("queue");
|
||||
const [active, setActive] = useState<WagonTransferRequest | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -175,6 +339,17 @@ const WagonTransferRequestsModal = ({
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Tabs value={tab} onChange={setTab} keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="queue" leftSection={<Inbox size={14} />}>
|
||||
Queue
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="queue">
|
||||
{!active ? (
|
||||
// ---- Pending queue ----
|
||||
isLoading ? (
|
||||
@@ -337,6 +512,12 @@ const WagonTransferRequestsModal = ({
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
<HistoryPanel opened={opened} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
|
||||
|
||||
import { useAccrualDashboard } from '@/hooks/useWarehouses';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse';
|
||||
|
||||
const ALERT_META: Record<AccrualAlert, { color: string; label: string }> = {
|
||||
CHARGING: { color: 'red', label: 'Charging' },
|
||||
WARNING: { color: 'orange', label: 'Free days ending' },
|
||||
OK: { color: 'teal', label: 'Within free days' },
|
||||
};
|
||||
|
||||
function money(amount: number, currency: string): string {
|
||||
return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
|
||||
}
|
||||
|
||||
function freeDaysLabel(row: AccrualDashboardRow): string {
|
||||
if (row.charging) return 'charging now';
|
||||
if (row.freeDaysLeft == null) return '—';
|
||||
return `${row.freeDaysLeft} day${row.freeDaysLeft === 1 ? '' : 's'} left`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live accrual dashboard: storage / demurrage ticking per in-warehouse item,
|
||||
* sorted so items already charging (or about to) surface first. Read-only.
|
||||
*/
|
||||
export function AccrualDashboard() {
|
||||
const { data: rows = [], isLoading } = useAccrualDashboard();
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const refresh = () =>
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-fees', 'accrual-dashboard'] });
|
||||
|
||||
const ack = useMutation({
|
||||
mutationFn: ({ id, snoozeDays }: { id: string; snoozeDays?: number }) =>
|
||||
warehouseService.acknowledgeAccrual(id, snoozeDays ? { snoozeDays } : {}),
|
||||
onSuccess: (_r, v) => {
|
||||
toast({ title: v.snoozeDays ? `Snoozed ${v.snoozeDays} days` : 'Marked reviewed' });
|
||||
void refresh();
|
||||
},
|
||||
onError: () => toast({ variant: 'destructive', title: 'Could not acknowledge' }),
|
||||
});
|
||||
const unack = useMutation({
|
||||
mutationFn: (id: string) => warehouseService.unacknowledgeAccrual(id),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Acknowledgement removed' });
|
||||
void refresh();
|
||||
},
|
||||
onError: () => toast({ variant: 'destructive', title: 'Could not un-acknowledge' }),
|
||||
});
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const currency = rows[0]?.currency ?? 'USD';
|
||||
return {
|
||||
currency,
|
||||
charging: rows.filter((r) => r.alert === 'CHARGING').length,
|
||||
atRisk: rows.filter((r) => r.alert === 'WARNING').length,
|
||||
totalAccruing: Math.round(rows.reduce((s, r) => s + r.accruedAmount, 0) * 100) / 100,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="sm">
|
||||
<StatCard
|
||||
icon={<DollarSign size={18} />}
|
||||
label="Accruing now"
|
||||
value={money(summary.totalAccruing, summary.currency)}
|
||||
color="edr-green"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<AlertTriangle size={18} />}
|
||||
label="Charging"
|
||||
value={summary.charging}
|
||||
color={summary.charging > 0 ? 'red' : 'gray'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock size={18} />}
|
||||
label="Free days ending (≤2d)"
|
||||
value={summary.atRisk}
|
||||
color={summary.atRisk > 0 ? 'orange' : 'gray'}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
{rows.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl" size="sm">
|
||||
No in-warehouse items are accruing fees.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table verticalSpacing="sm" highlightOnHover striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Location</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Accrued</Table.Th>
|
||||
<Table.Th>Free days</Table.Th>
|
||||
<Table.Th>Alert</Table.Th>
|
||||
<Table.Th ta="right" />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const meta = ALERT_META[row.alert];
|
||||
const busy = ack.isPending || unack.isPending;
|
||||
return (
|
||||
<Table.Tr key={row.inventoryId} style={{ opacity: row.acknowledged ? 0.55 : 1 }}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{row.bookingReference ?? row.inventoryId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{row.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{[row.warehouseCode, row.zoneCode].filter(Boolean).join(' · ') || '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{row.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fw={600} size="sm" c={row.accruedAmount > 0 ? 'red' : undefined}>
|
||||
{money(row.accruedAmount, row.currency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c={row.charging ? 'red' : undefined}>
|
||||
{freeDaysLabel(row)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{row.acknowledged ? (
|
||||
<Badge color="gray" variant="light" size="sm" leftSection={<Check size={11} />}>
|
||||
Reviewed{row.snoozeUntil ? ' (snoozed)' : ''}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color={meta.color} variant={row.alert === 'OK' ? 'light' : 'filled'} size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" loading={busy} aria-label="Accrual actions">
|
||||
<MoreVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{row.acknowledged ? (
|
||||
<Menu.Item
|
||||
leftSection={<Bell size={14} />}
|
||||
onClick={() => unack.mutate(row.inventoryId)}
|
||||
>
|
||||
Un-acknowledge
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<Check size={14} />}
|
||||
onClick={() => ack.mutate({ id: row.inventoryId })}
|
||||
>
|
||||
Mark reviewed
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<BellOff size={14} />}
|
||||
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 3 })}
|
||||
>
|
||||
Snooze 3 days
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<BellOff size={14} />}
|
||||
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 7 })}
|
||||
>
|
||||
Snooze 7 days
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon color={color} variant="light" size={40} radius="md">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={800} fz={20} lh={1.1} truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import { openPdfBlob, saveBlob } from './pdf';
|
||||
|
||||
interface InventoryWorkbenchProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -138,6 +138,42 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
}
|
||||
};
|
||||
|
||||
// One-click bundle: download every available document for the item (GRN +
|
||||
// gate clearance / release order + handover). Best-effort — docs that aren't
|
||||
// generatable yet for this item are skipped.
|
||||
const downloadDocumentBundle = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
const ref = item.booking?.reference ?? item.bookingId ?? item.id;
|
||||
const jobs: Array<{ name: string; fn: () => Promise<{ data: Blob }> }> = [
|
||||
{ name: `GRN-${ref}.pdf`, fn: () => warehouseService.downloadGrnDocument(item.id) },
|
||||
{ name: `gate-clearance-${ref}.pdf`, fn: () => warehouseService.downloadReleaseDocument(item.id) },
|
||||
{ name: `handover-${ref}.pdf`, fn: () => warehouseService.downloadHandoverDocument(item.id) },
|
||||
];
|
||||
let saved = 0;
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const response = await job.fn();
|
||||
saveBlob(response.data, job.name);
|
||||
saved += 1;
|
||||
} catch {
|
||||
// Document not available for this item yet — skip it.
|
||||
}
|
||||
}
|
||||
setBusyId(null);
|
||||
if (saved === 0) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'No documents available',
|
||||
description: 'This item has no GRN, gate clearance or handover document yet.',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Downloaded ${saved} document${saved !== 1 ? 's' : ''}`,
|
||||
description: `Bundle for ${ref} (available documents only).`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const acceptLastMile = async (item: WarehouseInventoryItem) => {
|
||||
const reference = item.booking?.reference;
|
||||
if (!reference) {
|
||||
@@ -243,6 +279,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
onFeePreview={setFeeItem}
|
||||
onReleaseDocument={downloadReleaseDocument}
|
||||
onHandoverDocument={openHandoverDocument}
|
||||
onDownloadBundle={downloadDocumentBundle}
|
||||
onLastMile={onLastMile ? acceptLastMile : undefined}
|
||||
selectedIds={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
@@ -24,6 +24,7 @@ interface WarehouseInventoryTableProps {
|
||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onHandoverDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onDownloadBundle?: (item: WarehouseInventoryItem) => void;
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
@@ -110,6 +111,7 @@ export function WarehouseInventoryTable({
|
||||
onFeePreview,
|
||||
onReleaseDocument,
|
||||
onHandoverDocument,
|
||||
onDownloadBundle,
|
||||
onLastMile,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
@@ -285,6 +287,13 @@ export function WarehouseInventoryTable({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDownloadBundle && item.grnNumber && (
|
||||
<Tooltip label="Download document bundle (GRN + gate clearance + handover)" withArrow>
|
||||
<ActionIcon variant="subtle" color="grape" onClick={() => onDownloadBundle(item)}>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onLastMile && item.booking?.lastMileDeliveryAddress && (
|
||||
<Tooltip label="Last mile delivery" withArrow>
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AlertTriangle, ClipboardCheck, PackageCheck, Truck } from "lucide-react";
|
||||
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { useWarehouseOpsStats } from "@/hooks/useWarehouses";
|
||||
|
||||
/**
|
||||
* At-a-glance warehouse ops KPIs (received today, pending inspection, trucks
|
||||
* on-site, items aging). Drop-in for any warehouse ops page header.
|
||||
*/
|
||||
export function WarehouseOpsKpiStrip() {
|
||||
const { data, isLoading } = useWarehouseOpsStats();
|
||||
|
||||
return (
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Received today",
|
||||
value: data?.receivedToday ?? 0,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Pending inspection",
|
||||
value: data?.pendingInspection ?? 0,
|
||||
icon: ClipboardCheck,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Trucks on-site",
|
||||
value: data?.trucksOnSite ?? 0,
|
||||
icon: Truck,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
label: "Items aging (>7d)",
|
||||
value: data?.itemsAging ?? 0,
|
||||
icon: AlertTriangle,
|
||||
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
|
||||
hint: "In warehouse over 7 days",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -30,3 +30,5 @@ export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
export { FeePreviewModal } from './FeePreviewModal';
|
||||
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
|
||||
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
|
||||
export { AccrualDashboard } from './AccrualDashboard';
|
||||
|
||||
@@ -22,3 +22,16 @@ export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window
|
||||
URL.revokeObjectURL(url);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Force a browser download of a blob under the given filename (no preview tab). */
|
||||
export function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// Delay revoke so the download has time to start (esp. for rapid multi-saves).
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
}
|
||||
|
||||
@@ -486,6 +486,7 @@ export const URL_CONSTANTS = {
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
RESERVE: "/warehouse-inventory/reserve",
|
||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||
OPS_STATS: "/warehouse-inventory/ops-stats",
|
||||
ZONE_OCCUPANCY: (yardId?: string) =>
|
||||
yardId
|
||||
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
||||
@@ -557,6 +558,9 @@ export const URL_CONSTANTS = {
|
||||
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
|
||||
FEE_PREVIEW: (inventoryId: string) =>
|
||||
`/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
|
||||
ACCRUAL_ACK: (inventoryId: string) =>
|
||||
`/warehouse-fees/accrual/${inventoryId}/acknowledge`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVOICES: {
|
||||
|
||||
@@ -144,6 +144,22 @@ export function useZoneOccupancy(yardId?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** At-a-glance warehouse ops counters for the KPI strip. */
|
||||
export function useWarehouseOpsStats() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'ops-stats'],
|
||||
queryFn: () => warehouseService.opsStats().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
/** Live per-item fee accrual (storage/demurrage) with alerts. */
|
||||
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
|
||||
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateZone() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -106,6 +106,9 @@ export const FREIGHT_PERMS = {
|
||||
create: "edr_freight_app:wagons:create",
|
||||
update: "edr_freight_app:wagons:update",
|
||||
delete: "edr_freight_app:wagons:delete",
|
||||
transferRequest: "edr_freight_app:wagons:transfer_request",
|
||||
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
||||
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
||||
},
|
||||
trains: {
|
||||
view: "edr_freight_app:trains:view",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -324,6 +325,21 @@ export default function NewBookingPage() {
|
||||
const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0);
|
||||
const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight;
|
||||
|
||||
// 20ft containers ride two per wagon, so a booking must hold an even number
|
||||
// of them — odd counts would leave half a wagon waiting on a co-loader
|
||||
// (cross-booking consolidation is disabled for now).
|
||||
const twentyFtCount = useMemo(() => {
|
||||
const sizeById = new Map<string, string>();
|
||||
for (const group of refData?.containers ?? []) {
|
||||
for (const type of group.types) sizeById.set(type.id, group.size);
|
||||
}
|
||||
return lines.reduce((sum, l) => {
|
||||
const size = l.containerTypeId ? (sizeById.get(l.containerTypeId) ?? "") : "";
|
||||
return String(size).includes("20") ? sum + (l.quantity || 0) : sum;
|
||||
}, 0);
|
||||
}, [refData?.containers, lines]);
|
||||
const hasOdd20ft = freightType === "CONTAINER" && twentyFtCount % 2 === 1;
|
||||
|
||||
// ---- validation ----
|
||||
const lineValid = (l: ContainerLine) =>
|
||||
Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0;
|
||||
@@ -344,7 +360,7 @@ export default function NewBookingPage() {
|
||||
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
|
||||
(freightType === "BULK"
|
||||
? Boolean(cargoTypeId) && bulkWeight > 0
|
||||
: allLinesValid);
|
||||
: allLinesValid && !hasOdd20ft);
|
||||
|
||||
const updateLine = (key: string, patch: Partial<ContainerLine>) =>
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
|
||||
@@ -842,6 +858,20 @@ export default function NewBookingPage() {
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{hasOdd20ft ? (
|
||||
<Alert color="red" icon={<AlertTriangle size={16} />} radius="md" mt="md">
|
||||
<Text size="sm" fw={600}>
|
||||
Odd number of 20ft containers ({twentyFtCount})
|
||||
</Text>
|
||||
<Text size="xs" mt={4}>
|
||||
20ft containers travel two per wagon, so they must be booked in
|
||||
even numbers. Add one more 20ft container or remove one — e.g.
|
||||
book {twentyFtCount + 1} or {twentyFtCount - 1} instead of{" "}
|
||||
{twentyFtCount}.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack gap="sm" mt="lg">
|
||||
<Button
|
||||
size="md"
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -75,6 +82,8 @@ interface ClearanceRow {
|
||||
freightType: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
/** Full ordered corridor across the contract's route legs (origin → … → destination). */
|
||||
routeStops: string[];
|
||||
contractKind: string;
|
||||
serviceTypeName: string;
|
||||
customs: boolean;
|
||||
@@ -93,6 +102,25 @@ function yardLabel(
|
||||
return yard.label ?? yard.name ?? yard.code ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain the contract's ordered route legs into one corridor of stops —
|
||||
* origin of the first leg, then each leg's destination (Djibouti → Adama →
|
||||
* Dire Dawa). A leg whose origin differs from the previous destination inserts
|
||||
* that stop too, so gapped route lists stay readable.
|
||||
*/
|
||||
function contractRouteStops(routes: Freight.IContractRoute[]): string[] {
|
||||
const stops: string[] = [];
|
||||
for (const r of routes) {
|
||||
const origin = yardLabel(r.originYard);
|
||||
const destination = yardLabel(r.destinationYard);
|
||||
if (stops.length === 0 || stops[stops.length - 1] !== origin) {
|
||||
stops.push(origin);
|
||||
}
|
||||
stops.push(destination);
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
@@ -109,6 +137,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
freightType: contract.freightType ?? "—",
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
routeStops: contractRouteStops(routes),
|
||||
contractKind: contract.contractKind,
|
||||
serviceTypeName: contract.serviceType?.serviceName ?? "—",
|
||||
customs:
|
||||
@@ -412,14 +441,23 @@ export default function ContractClearanceListPage() {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.originLabel}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.destinationLabel}
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
{(r.routeStops.length >= 2
|
||||
? r.routeStops
|
||||
: [r.originLabel, r.destinationLabel]
|
||||
).map((stop, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<ArrowRight
|
||||
size={14}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
<Text size="sm" fw={500}>
|
||||
{stop}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={r.tradeDirection} />
|
||||
@@ -638,6 +676,11 @@ export default function ContractClearanceListPage() {
|
||||
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
|
||||
)
|
||||
}
|
||||
onRebook={(row) =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
|
||||
)
|
||||
}
|
||||
onViewContract={(contractId) =>
|
||||
navigate(`/dashboard/contracts/clearance/${contractId}`)
|
||||
}
|
||||
@@ -731,6 +774,7 @@ function ShipmentBookingsTable({
|
||||
canCreateBooking,
|
||||
onOpen,
|
||||
onCreateBooking,
|
||||
onRebook,
|
||||
onViewContract,
|
||||
}: {
|
||||
rows: ShipmentBookingRow[];
|
||||
@@ -739,6 +783,7 @@ function ShipmentBookingsTable({
|
||||
canCreateBooking: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onCreateBooking: (row: ShipmentBookingRow) => void;
|
||||
onRebook: (row: ShipmentBookingRow) => void;
|
||||
onViewContract: (contractId: string) => void;
|
||||
}) {
|
||||
// A bare initiated instance that has cleared but not yet been created by GL.
|
||||
@@ -748,6 +793,14 @@ function ShipmentBookingsTable({
|
||||
!r.bookingCreated &&
|
||||
r.status === "CLEARANCE_READY";
|
||||
|
||||
// A customs shipment whose booking lost its slot — GL rebooks it (customer
|
||||
// can't self-rebook a customs booking). Copies the expired booking's cargo.
|
||||
const isRebookable = (r: ShipmentBookingRow) =>
|
||||
canCreateBooking &&
|
||||
Boolean(r.contractId) &&
|
||||
r.customs &&
|
||||
r.status === "EXPIRED";
|
||||
|
||||
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -877,6 +930,7 @@ function ShipmentBookingsTable({
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const bookable = isBookable(r);
|
||||
const rebookable = isRebookable(r);
|
||||
return (
|
||||
<Group
|
||||
justify="flex-end"
|
||||
@@ -896,6 +950,17 @@ function ShipmentBookingsTable({
|
||||
Create booking
|
||||
</Button>
|
||||
) : null}
|
||||
{rebookable ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="grape"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
onClick={() => onRebook(r)}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
) : null}
|
||||
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
@@ -919,6 +984,14 @@ function ShipmentBookingsTable({
|
||||
Create booking
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{rebookable ? (
|
||||
<Menu.Item
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
onClick={() => onRebook(r)}
|
||||
>
|
||||
Rebook (GL)
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{r.contractId ? (
|
||||
<Menu.Item
|
||||
leftSection={<ExternalLink size={14} />}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { isAxiosError } from "axios";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarClock,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
Replace,
|
||||
Ruler,
|
||||
@@ -29,9 +30,10 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
|
||||
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
|
||||
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
|
||||
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
|
||||
import TrainConsistStrip from "@/components/trainBuilder/TrainConsistStrip";
|
||||
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -62,6 +64,7 @@ export default function TrainBuilderDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [locoModalOpen, setLocoModalOpen] = useState(false);
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||
|
||||
const compositionQuery = useQuery(
|
||||
@@ -144,6 +147,13 @@ export default function TrainBuilderDetailPage() {
|
||||
>
|
||||
Change locomotives
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<MapPin size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setYardModalOpen(true)}
|
||||
>
|
||||
Change yard
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={15} />}
|
||||
@@ -162,8 +172,8 @@ export default function TrainBuilderDetailPage() {
|
||||
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
|
||||
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
|
||||
{
|
||||
label: "Max gross / haul limit",
|
||||
value: `${totals.maxGrossTons}T / ${totals.maxPullWeightTons}T`,
|
||||
label: "Tare weight / haul limit",
|
||||
value: `${totals.totalTareTons}T of ${totals.maxPullWeightTons}T`,
|
||||
icon: Weight,
|
||||
},
|
||||
{
|
||||
@@ -180,33 +190,40 @@ export default function TrainBuilderDetailPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600}>Consist</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{composition.locomotives.length} locomotive
|
||||
{composition.locomotives.length === 1 ? "" : "s"} · {totals.wagonCount} wagon
|
||||
{totals.wagonCount === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
<TrainConsistStrip
|
||||
locomotives={composition.locomotives}
|
||||
wagons={composition.wagons}
|
||||
/>
|
||||
<Stack gap="sm">
|
||||
<TrainCompositionDiagram
|
||||
locomotives={composition.locomotives.map((loco) => ({
|
||||
code: loco.code,
|
||||
name: loco.name,
|
||||
maxPullWeightTons: loco.maxPullWeightTons,
|
||||
}))}
|
||||
wagons={composition.wagons.map((wagon, index) => ({
|
||||
sequenceNo: wagon.sequenceNumber ?? index + 1,
|
||||
capacityTons: wagon.wagonType?.capacityTons ?? 0,
|
||||
// No bookings at build time — wagons ride empty until allocation.
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
|
||||
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||||
physicalWagonNumber: wagon.wagonNumber,
|
||||
allocations: [],
|
||||
}))}
|
||||
trainNumber={composition.code}
|
||||
totalLengthMeters={totals.totalLengthMeters}
|
||||
/>
|
||||
<Card>
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<UtilizationBar
|
||||
label="Weight utilization (fully loaded)"
|
||||
pct={totals.weightUtilizationPct}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<UtilizationBar label="Length utilization" pct={totals.lengthUtilizationPct} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
The real weight check happens at allocation: booked cargo weight plus
|
||||
wagon tare (gross) must stay within the locomotives' haul limit.
|
||||
</Text>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Grid gap="lg" align="stretch">
|
||||
{composition.editable ? (
|
||||
@@ -297,6 +314,12 @@ export default function TrainBuilderDetailPage() {
|
||||
onClose={() => setLocoModalOpen(false)}
|
||||
/>
|
||||
|
||||
<ChangeYardModal
|
||||
composition={composition}
|
||||
opened={yardModalOpen}
|
||||
onClose={() => setYardModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={disbandOpen}
|
||||
onClose={() => setDisbandOpen(false)}
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function TrainBuilderListPage() {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.wagonCount} wagons · {row.original.maxGrossTons}T ·{" "}
|
||||
{row.original.wagonCount} wagons · {row.original.totalTareTons}T tare ·{" "}
|
||||
{row.original.totalLengthMeters}m
|
||||
</Text>
|
||||
),
|
||||
|
||||
@@ -43,6 +43,7 @@ import { CountdownTimer, DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import { KpiStrip, PageContainer } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import {
|
||||
TrainConsistView,
|
||||
@@ -684,6 +685,7 @@ export default function BatchScheduleDetailPage() {
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
|
||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -838,6 +840,12 @@ export default function BatchScheduleDetailPage() {
|
||||
}).format(new Date(data.scheduleDate)) + " EAT"
|
||||
: "No date"}
|
||||
</HeroChip>
|
||||
{data.train ? (
|
||||
<HeroChip icon={<TrainFront size={12} />}>
|
||||
Train {data.train.code}
|
||||
{data.train.trainName ? ` — ${data.train.trainName}` : ""}
|
||||
</HeroChip>
|
||||
) : null}
|
||||
{data.locomotive ? (
|
||||
<HeroChip icon={<TrainFront size={12} />}>
|
||||
Loco {data.locomotive.code} ·{" "}
|
||||
@@ -864,6 +872,17 @@ export default function BatchScheduleDetailPage() {
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
{data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<TrainFront size={16} />}
|
||||
onClick={() => setAdjustConsistOpen(true)}
|
||||
>
|
||||
Adjust consist
|
||||
</Button>
|
||||
) : null}
|
||||
{data.windowPhase === "DOC_REVIEW" ? (
|
||||
<Button
|
||||
color="yellow"
|
||||
@@ -1175,6 +1194,12 @@ export default function BatchScheduleDetailPage() {
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<AdjustConsistModal
|
||||
scheduleId={data.scheduleId}
|
||||
opened={adjustConsistOpen}
|
||||
onClose={() => setAdjustConsistOpen(false)}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityR
|
||||
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
||||
@@ -101,6 +102,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
|
||||
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
||||
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
||||
const [gatepassReference, setGatepassReference] = useState("");
|
||||
@@ -757,13 +759,14 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Stack gap="md">
|
||||
<TrainCompositionDiagram
|
||||
locomotive={schedule.trainSet?.locomotive}
|
||||
locomotives={locomotives}
|
||||
wagons={
|
||||
schedule.trainSet?.wagons?.length
|
||||
? schedule.trainSet.wagons
|
||||
: displayWagonPlan
|
||||
}
|
||||
freightType={freightType}
|
||||
trainNumber={schedule.trainNumber}
|
||||
trainNumber={schedule.train ? schedule.train.code : schedule.trainNumber}
|
||||
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
<Paper
|
||||
@@ -948,6 +951,18 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Reschedule train
|
||||
</Button>
|
||||
) : null}
|
||||
{schedule.train && ["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<Train size={16} />}
|
||||
onClick={() => setAdjustConsistOpen(true)}
|
||||
>
|
||||
Adjust consist
|
||||
</Button>
|
||||
) : null}
|
||||
{gatepassApplies ? (
|
||||
gatepassSecured ? (
|
||||
<Button
|
||||
@@ -1003,6 +1018,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
...(schedule.train
|
||||
? [
|
||||
{
|
||||
label: "Train",
|
||||
value: schedule.train.code,
|
||||
hint: schedule.train.trainName ?? "Built train (Train Builder)",
|
||||
icon: Train,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: locomotives.length > 1 ? "Locomotives" : "Locomotive",
|
||||
value: locomotives.length
|
||||
@@ -1162,6 +1187,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AdjustConsistModal
|
||||
scheduleId={schedule.id}
|
||||
opened={adjustConsistOpen}
|
||||
onClose={() => setAdjustConsistOpen(false)}
|
||||
/>
|
||||
|
||||
<BookingWindowSettingsModal
|
||||
scheduleId={scheduleId ?? null}
|
||||
opened={windowSettingsOpen}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
VisualEmptyState,
|
||||
WarehouseOpsKpiStrip,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
@@ -291,6 +292,8 @@ export default function ArrivalQueuePage() {
|
||||
breadcrumbs={[{ label: 'Arrival queue' }]}
|
||||
/>
|
||||
|
||||
<WarehouseOpsKpiStrip />
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Stack gap={2}>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ActivityTimeline,
|
||||
InventoryMovementHistoryTable,
|
||||
VisualEmptyState,
|
||||
WarehouseOpsKpiStrip,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
@@ -292,6 +293,8 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
breadcrumbs={[{ label: 'Djibouti Arrival / Unloading Queue' }]}
|
||||
/>
|
||||
|
||||
<WarehouseOpsKpiStrip />
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{trains.length} arrived export train(s)</Text>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
InventoryWorkbench,
|
||||
VisualEmptyState,
|
||||
WarehouseOpsKpiStrip,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel';
|
||||
@@ -74,6 +75,8 @@ export default function LoadingQueuePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<WarehouseOpsKpiStrip />
|
||||
|
||||
<Card>
|
||||
<Tabs defaultValue="ready">
|
||||
<Tabs.List>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { AccrualDashboard } from '@/components/warehouses';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
@@ -122,6 +123,13 @@ export default function WarehouseInvoicesPage() {
|
||||
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
|
||||
/>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text fw={700} size="sm" tt="uppercase" c="dimmed">
|
||||
Accruing now
|
||||
</Text>
|
||||
<AccrualDashboard />
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<Group justify="space-between" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
|
||||
@@ -184,10 +184,12 @@ import {
|
||||
import { trainService, type Train } from "./trains.service";
|
||||
import {
|
||||
trainBuilderService,
|
||||
type AdjustConsistPayload,
|
||||
type AvailableTrain,
|
||||
type BuildTrainPayload,
|
||||
type BuiltTrainListFilters,
|
||||
type BuiltTrainListResponse,
|
||||
type ScheduleConsist,
|
||||
type TrainComposition,
|
||||
} from "./trainBuilder.service";
|
||||
import { trainSchedulingService } from "./trainScheduling.service";
|
||||
@@ -200,6 +202,7 @@ import {
|
||||
type WagonMovementRecord,
|
||||
type WagonTransferRequest,
|
||||
type CreateTransferRequestPayload,
|
||||
type TransferHistory,
|
||||
} from "./wagon.service";
|
||||
import { warehouseService } from "./warehouse.service";
|
||||
|
||||
@@ -319,6 +322,30 @@ export const api = {
|
||||
({ routeId }) => QUERY_KEYS.TRAIN_SCHEDULING.availableTrains(routeId),
|
||||
),
|
||||
|
||||
scheduleConsist: endpoint<{ scheduleId: string }, ScheduleConsist>(
|
||||
"train-scheduling",
|
||||
"schedule-consist",
|
||||
({ scheduleId }) =>
|
||||
trainBuilderService.scheduleConsist(scheduleId).then((r) => r.data),
|
||||
({ scheduleId }) => [
|
||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
"consist",
|
||||
scheduleId,
|
||||
],
|
||||
),
|
||||
|
||||
adjustConsist: endpoint<
|
||||
{ scheduleId: string; payload: AdjustConsistPayload },
|
||||
ScheduleConsist
|
||||
>(
|
||||
"train-scheduling",
|
||||
"adjust-consist",
|
||||
({ scheduleId, payload }) =>
|
||||
trainBuilderService.adjustConsist(scheduleId, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
bookableSchedules: endpoint<
|
||||
{ originYardId?: string | null; destinationYardId?: string | null },
|
||||
BookableSchedule[]
|
||||
@@ -1701,6 +1728,21 @@ export const api = {
|
||||
undefined,
|
||||
() => [["wagonTransferRequests"]],
|
||||
),
|
||||
|
||||
history: endpoint<void, TransferHistory>(
|
||||
"wagonTransferRequests",
|
||||
"history",
|
||||
() => wagonTransferRequestService.myHistory().then((r) => r.data),
|
||||
() => ["wagonTransferRequests", "history", "mine"],
|
||||
),
|
||||
|
||||
historyAll: endpoint<{ userId?: string }, TransferHistory>(
|
||||
"wagonTransferRequests",
|
||||
"historyAll",
|
||||
({ userId }) =>
|
||||
wagonTransferRequestService.allHistory(userId).then((r) => r.data),
|
||||
({ userId }) => ["wagonTransferRequests", "history", "all", userId ?? ""],
|
||||
),
|
||||
},
|
||||
|
||||
trains: {
|
||||
@@ -1781,6 +1823,15 @@ export const api = {
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
|
||||
"train-builder",
|
||||
"setYard",
|
||||
({ id, currentYardId }) =>
|
||||
trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||
"train-builder",
|
||||
"assignWagons",
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface BuiltTrainSummary {
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
||||
wagonCount: number;
|
||||
maxGrossTons: number;
|
||||
totalTareTons: number;
|
||||
totalLengthMeters: number;
|
||||
maxPullWeightTons: number;
|
||||
}
|
||||
@@ -63,12 +63,15 @@ export interface TrainCompositionWagon {
|
||||
export interface TrainCompositionTotals {
|
||||
wagonCount: number;
|
||||
totalTareTons: number;
|
||||
/** Informational only — building never checks against full capacity. */
|
||||
totalCapacityTons: number;
|
||||
maxGrossTons: number;
|
||||
totalLengthMeters: number;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
weightUtilizationPct: number | null;
|
||||
/** Cargo the locomotives can still haul once pulling the empty consist. */
|
||||
payloadCapacityTons: number;
|
||||
/** Share of the haul limit consumed by the empty wagons alone. */
|
||||
tareUtilizationPct: number | null;
|
||||
lengthUtilizationPct: number | null;
|
||||
}
|
||||
|
||||
@@ -126,7 +129,7 @@ export interface AvailableTrain {
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
||||
wagonCount: number;
|
||||
maxGrossTons: number;
|
||||
totalTareTons: number;
|
||||
totalLengthMeters: number;
|
||||
maxPullWeightTons: number;
|
||||
atOriginYard: boolean;
|
||||
@@ -150,6 +153,64 @@ const toQuery = (filters: BuiltTrainListFilters = {}) => {
|
||||
return qs ? `?${qs}` : "";
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule consist adjustment (train-bound schedules)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConsistWagonRef {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
sequenceNumber: number | null;
|
||||
wagonType: {
|
||||
id: string;
|
||||
code: string;
|
||||
tareWeightTons: number;
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ScheduleConsist {
|
||||
schedule: { id: string; reference: string | null; status: string };
|
||||
train: {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
currentYardId: string | null;
|
||||
};
|
||||
limits: {
|
||||
maxPullWeightTons: number;
|
||||
overageToleranceTons: number;
|
||||
pullCapTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
overageToleranceMeters: number;
|
||||
lengthCapMeters: number;
|
||||
};
|
||||
totals: {
|
||||
wagonCount: number;
|
||||
cargoTons: number;
|
||||
consistTareTons: number;
|
||||
grossTons: number;
|
||||
consistLengthMeters: number;
|
||||
};
|
||||
wagons: Array<ConsistWagonRef & { loaded: boolean; removable: boolean }>;
|
||||
addableWagons: ConsistWagonRef[];
|
||||
adjustments: Array<{
|
||||
id: string;
|
||||
action: "ADD" | "REMOVE";
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
adjustedByUserId: string | null;
|
||||
occurredAt: string;
|
||||
}>;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export interface AdjustConsistPayload {
|
||||
addWagonIds?: string[];
|
||||
removeWagonIds?: string[];
|
||||
}
|
||||
|
||||
export const trainBuilderService = {
|
||||
list: (filters: BuiltTrainListFilters = {}) =>
|
||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||
@@ -157,6 +218,9 @@ export const trainBuilderService = {
|
||||
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
||||
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
||||
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
|
||||
/** Relocate the train — coupled locomotives and wagons move with it. */
|
||||
setYard: (id: string, currentYardId: string) =>
|
||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),
|
||||
assignWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||
removeWagon: (id: string, wagonId: string) =>
|
||||
@@ -169,4 +233,13 @@ export const trainBuilderService = {
|
||||
apiClient.get<AvailableTrain[]>(`/train-scheduling/available-trains`, {
|
||||
params: { routeId },
|
||||
}),
|
||||
/** Consist snapshot for a train-bound schedule (adjust-consist UI). */
|
||||
scheduleConsist: (scheduleId: string) =>
|
||||
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
|
||||
/** Permanently trim/add wagons on the schedule's built train. */
|
||||
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
|
||||
apiClient.post<ScheduleConsist>(
|
||||
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
|
||||
payload,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -55,9 +55,12 @@ export interface WagonMovementRecord {
|
||||
bookingId: string | null;
|
||||
kind: Freight.WagonMovementKind;
|
||||
movedByUserId: string | null;
|
||||
/** The transfer request this move fulfilled, when one drove it. */
|
||||
transferRequestId: string | null;
|
||||
occurredAt: string;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
wagon?: { id: string; wagonNumber?: string } | null;
|
||||
}
|
||||
|
||||
export const wagonService = {
|
||||
@@ -120,11 +123,25 @@ export interface CreateTransferRequestPayload {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
movements: WagonMovementRecord[];
|
||||
}
|
||||
|
||||
export const wagonTransferRequestService = {
|
||||
list: (status?: Freight.WagonTransferRequestStatus) =>
|
||||
apiClient.get<WagonTransferRequest[]>(
|
||||
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
|
||||
),
|
||||
/** The caller's own history (both roles: requests they filed and fulfilled). */
|
||||
myHistory: () =>
|
||||
apiClient.get<TransferHistory>('/wagon-transfer-requests/history'),
|
||||
/** Admin: any/all staff's history (optional userId filter). */
|
||||
allHistory: (userId?: string) =>
|
||||
apiClient.get<TransferHistory>(
|
||||
`/wagon-transfer-requests/history/all${userId ? `?userId=${userId}` : ''}`,
|
||||
),
|
||||
getById: (id: string) =>
|
||||
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
||||
create: (data: CreateTransferRequestPayload) =>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { api as apiClient } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
ZoneOccupancy,
|
||||
WarehouseOpsStats,
|
||||
AccrualDashboardRow,
|
||||
AllocationCriteria,
|
||||
AllocationPreviewResult,
|
||||
AllocationRule,
|
||||
@@ -370,6 +372,8 @@ export const warehouseService = {
|
||||
apiClient.get<ZoneOccupancy[]>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.ZONE_OCCUPANCY(yardId),
|
||||
),
|
||||
opsStats: () =>
|
||||
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
|
||||
autoUnloadArrived: () =>
|
||||
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
||||
autoLoadReady: () =>
|
||||
@@ -422,6 +426,14 @@ export const warehouseService = {
|
||||
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), {
|
||||
params: cleanParams({ billingCurrency }),
|
||||
}),
|
||||
accrualDashboard: (billingCurrency?: 'ETB' | 'USD') =>
|
||||
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
|
||||
params: cleanParams({ billingCurrency }),
|
||||
}),
|
||||
acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) =>
|
||||
apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body),
|
||||
unacknowledgeAccrual: (inventoryId: string) =>
|
||||
apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId)),
|
||||
|
||||
// ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
|
||||
listInvoices: (filter?: WarehouseInvoiceFilter) =>
|
||||
|
||||
@@ -309,6 +309,12 @@ export interface BatchBoardSchedule {
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train: {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
} | null;
|
||||
locomotive: {
|
||||
code: string;
|
||||
name: string | null;
|
||||
@@ -417,6 +423,8 @@ export interface BatchBoardScheduleDetail {
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train: BatchBoardSchedule["train"];
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
@@ -511,6 +519,12 @@ export interface TrainScheduleDetail {
|
||||
deferredBookings?: DeferredBookingRow[];
|
||||
freightType?: FreightType | null;
|
||||
trainNumber?: string | null;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train?: {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName?: string | null;
|
||||
} | null;
|
||||
direction?: string | null;
|
||||
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
|
||||
requiresLoadingConfirmation?: boolean;
|
||||
|
||||
@@ -1107,3 +1107,40 @@ export interface ZoneOccupancy {
|
||||
/** 0–100+, container-count based (weight is a rough fallback). Null if no capacity set. */
|
||||
occupancyPct: number | null;
|
||||
}
|
||||
|
||||
/** At-a-glance warehouse ops counters for the KPI strip. */
|
||||
export interface WarehouseOpsStats {
|
||||
receivedToday: number;
|
||||
pendingInspection: number;
|
||||
trucksOnSite: number;
|
||||
itemsAging: number;
|
||||
}
|
||||
|
||||
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
|
||||
|
||||
/** One item's live fee accrual for the accrual dashboard. */
|
||||
export interface AccrualDashboardRow {
|
||||
inventoryId: string;
|
||||
status: string;
|
||||
bookingId: string | null;
|
||||
companyId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
warehouseCode: string | null;
|
||||
zoneCode: string | null;
|
||||
receivedAt: string | null;
|
||||
currency: string;
|
||||
accruedAmount: number;
|
||||
freeDaysLeft: number | null;
|
||||
charging: boolean;
|
||||
alert: AccrualAlert;
|
||||
acknowledged: boolean;
|
||||
snoozeUntil: string | null;
|
||||
breakdown: Array<{
|
||||
type: string;
|
||||
amount: number;
|
||||
freeDays: number;
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { Fragment, memo, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||
import { windowRouteStops } from "@/pages/contracts/booking-window";
|
||||
import { Card } from "./Card";
|
||||
|
||||
const INK = "#10202F";
|
||||
@@ -284,14 +285,21 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
||||
}}
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{w.origin ?? "—"}
|
||||
</Text>
|
||||
<ArrowRight size={13} color={MUTED} style={{ flexShrink: 0 }} />
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{w.destination ?? "—"}
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
{windowRouteStops(w).map((stop, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<ArrowRight
|
||||
size={13}
|
||||
color={MUTED}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||
{stop}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{w.reference && (
|
||||
<Text
|
||||
fz={11}
|
||||
|
||||
@@ -133,6 +133,11 @@ export function ReadonlyBookingView({
|
||||
["PAID", "TRUCK_ASSIGNED"].includes(status));
|
||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||
const isExpired = status === "EXPIRED";
|
||||
// Customs (Path B) bookings are created AND rebooked by Global Logistics, not
|
||||
// the customer. Hide the customer's rebook everywhere and tell them GL will
|
||||
// handle it. Non-customs bookings stay self-service.
|
||||
const isCustoms = Boolean(booking.customsClearingEnabled);
|
||||
const canSelfRebook = !isCustoms;
|
||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||
const isClearance = [
|
||||
"AWAITING_DOCUMENTS",
|
||||
@@ -169,7 +174,7 @@ export function ReadonlyBookingView({
|
||||
)
|
||||
}
|
||||
menuActions={{
|
||||
onRebook,
|
||||
onRebook: canSelfRebook ? onRebook : undefined,
|
||||
onSupport: () => navigate("/support"),
|
||||
}}
|
||||
/>
|
||||
@@ -186,14 +191,18 @@ export function ReadonlyBookingView({
|
||||
: "This booking process has been terminated."
|
||||
}
|
||||
reason={booking.latestChangeRequestNote}
|
||||
onRebook={onRebook}
|
||||
onRebook={canSelfRebook ? onRebook : undefined}
|
||||
/>
|
||||
) : isExpired ? (
|
||||
<CancelledBanner
|
||||
pillLabel="Expired"
|
||||
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
|
||||
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
||||
onRebook={onRebook}
|
||||
subtitle={
|
||||
isCustoms
|
||||
? "Payment wasn't completed in time, so this booking lost its slot. Global Logistics will rebook this shipment for you — no action is needed on your side."
|
||||
: "Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
||||
}
|
||||
onRebook={canSelfRebook ? onRebook : undefined}
|
||||
/>
|
||||
) : isPendingConsolidation ? (
|
||||
<ConsolidationWaitingBanner
|
||||
|
||||
@@ -21,6 +21,10 @@ import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModa
|
||||
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
import { fmtDate } from "../utils";
|
||||
import { IconSquare } from "./Documents";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
@@ -361,6 +365,39 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
|
||||
const company = contract?.company;
|
||||
|
||||
// One-click warehouse-document bundle: GRN + gate clearance + handover.
|
||||
const [bundleBusy, setBundleBusy] = useState(false);
|
||||
const downloadWarehouseDocuments = async () => {
|
||||
setBundleBusy(true);
|
||||
const ref = booking.reference ?? booking.id;
|
||||
const jobs: Array<{ name: string; fn: () => Promise<Blob> }> = [
|
||||
{ name: `GRN-${ref}.pdf`, fn: () => bookingsService.downloadBookingGrnDocument(booking.id) },
|
||||
{
|
||||
name: `gate-clearance-${ref}.pdf`,
|
||||
fn: () => bookingsService.downloadBookingReleaseDocument(booking.id),
|
||||
},
|
||||
{
|
||||
name: `handover-${ref}.pdf`,
|
||||
fn: () => bookingsService.downloadBookingHandoverDocument(booking.id),
|
||||
},
|
||||
];
|
||||
let saved = 0;
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
saveBlob(await job.fn(), job.name);
|
||||
saved += 1;
|
||||
} catch {
|
||||
// Document not available for this booking yet — skip it.
|
||||
}
|
||||
}
|
||||
setBundleBusy(false);
|
||||
if (saved === 0) {
|
||||
toast.error("No warehouse documents are available for this booking yet.");
|
||||
} else {
|
||||
toast.success(`Downloaded ${saved} document${saved !== 1 ? "s" : ""}.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* ── 1. Clearance documents ──────────────────────────────────────── */}
|
||||
@@ -552,6 +589,23 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
|
||||
<SectionCard>
|
||||
<CardTitle>Warehouse documents</CardTitle>
|
||||
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
|
||||
Goods Received Note, gate clearance / release order and handover — download all
|
||||
available documents for this booking in one click.
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<Download size={16} />}
|
||||
color="edr-green"
|
||||
loading={bundleBusy}
|
||||
onClick={downloadWarehouseDocuments}
|
||||
>
|
||||
Download documents
|
||||
</Button>
|
||||
</SectionCard>
|
||||
|
||||
{!hasContract && otherBookingFiles.length === 0 && (
|
||||
<SectionCard>
|
||||
<Alert color="gray" radius="md" icon={<Info size={16} />}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core";
|
||||
import { Alert, Button, Group, Loader, Modal, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Info } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -47,6 +47,7 @@ export function ApproveDeliveryModal({
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
|
||||
const {
|
||||
data: docBlob,
|
||||
@@ -122,8 +123,9 @@ export function ApproveDeliveryModal({
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light" icon={<Info size={16} />}>
|
||||
<Text size="sm">
|
||||
Review the handover document below. Approving applies your saved signature
|
||||
and confirms you received the goods.
|
||||
Review the handover document below, then type your full name to sign and
|
||||
confirm you received the goods. Your saved signature is applied automatically
|
||||
if you have one.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
@@ -151,6 +153,15 @@ export function ApproveDeliveryModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Your full name"
|
||||
placeholder="e.g. Abebe Kebede"
|
||||
required
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.currentTarget.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
@@ -159,8 +170,8 @@ export function ApproveDeliveryModal({
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={busy}
|
||||
disabled={isLoading || isError}
|
||||
onClick={() => approve.mutate({ id: bookingId })}
|
||||
disabled={isLoading || isError || !signerName.trim()}
|
||||
onClick={() => approve.mutate({ id: bookingId, signerName: signerName.trim() })}
|
||||
>
|
||||
Approve & sign delivery
|
||||
</Button>
|
||||
|
||||
@@ -787,13 +787,17 @@ export function Step5CargoDetails({
|
||||
const result = calcWagons(containers ?? []);
|
||||
if (result.hasOddUnit) {
|
||||
return (
|
||||
<AlertBox tone="warning">
|
||||
<p className="font-semibold">Unpaired 20ft Container</p>
|
||||
<AlertBox tone="error">
|
||||
<p className="font-semibold">
|
||||
Odd number of 20ft containers ({result.ft20Wagons})
|
||||
</p>
|
||||
<p className="mt-1 text-xs">
|
||||
One 20ft container occupies only half a wagon. The wagon will
|
||||
depart once a co-loader is found to fill the remaining slot,
|
||||
which <strong>may delay departure</strong> beyond the standard
|
||||
lead time.
|
||||
20ft containers travel two per wagon, so they must be booked in
|
||||
even numbers. Please <strong>add one more 20ft container</strong>{" "}
|
||||
or <strong>remove one</strong> (e.g. book{" "}
|
||||
{result.ft20Wagons + 1} or {result.ft20Wagons - 1} instead of{" "}
|
||||
{result.ft20Wagons}) — the booking cannot be submitted with an
|
||||
unpaired 20ft container.
|
||||
</p>
|
||||
</AlertBox>
|
||||
);
|
||||
|
||||
@@ -25,10 +25,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@/types";
|
||||
import {
|
||||
calcWagons,
|
||||
type BookingFormInputValues,
|
||||
type BookingFormValues,
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
import { AlertBox, StepHeader } from "./shared";
|
||||
|
||||
export const REVIEW_STEP_TARGETS = {
|
||||
contract: 1,
|
||||
@@ -160,6 +161,12 @@ export function Step8Review({
|
||||
.join(", ")
|
||||
: "";
|
||||
|
||||
// 20ft containers must pair up (two per wagon) — an odd total blocks submit.
|
||||
const { hasOddUnit: hasOdd20ft, ft20Wagons: twentyFtCount } =
|
||||
values.cargoType === "container"
|
||||
? calcWagons(values.containers ?? [])
|
||||
: { hasOddUnit: false, ft20Wagons: 0 };
|
||||
|
||||
const isGeneralContract = values.bookingType === "general_contract";
|
||||
// Both one-time and general contracts take the bulk amount from the cargo step
|
||||
// (cargoWeight); general contracts no longer collect a per-route quantity.
|
||||
@@ -529,10 +536,27 @@ export function Step8Review({
|
||||
</Paper>
|
||||
|
||||
<Paper radius={20} p="lg" withBorder bg="white">
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Ready to submit. You'll review the unit rates before final
|
||||
submission.
|
||||
</Text>
|
||||
{hasOdd20ft ? (
|
||||
<Box mb="md">
|
||||
<AlertBox tone="error">
|
||||
<p className="font-semibold">
|
||||
Odd number of 20ft containers ({twentyFtCount})
|
||||
</p>
|
||||
<p className="mt-1 text-xs">
|
||||
20ft containers travel two per wagon, so they must be booked
|
||||
in even numbers. Go back to the cargo step and{" "}
|
||||
<strong>add one more 20ft container</strong> or{" "}
|
||||
<strong>remove one</strong> (e.g. {twentyFtCount + 1} or{" "}
|
||||
{twentyFtCount - 1} instead of {twentyFtCount}).
|
||||
</p>
|
||||
</AlertBox>
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Ready to submit. You'll review the unit rates before final
|
||||
submission.
|
||||
</Text>
|
||||
)}
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -543,7 +567,7 @@ export function Step8Review({
|
||||
leftSection={<Send size={16} />}
|
||||
onClick={onSubmit}
|
||||
loading={submitPending}
|
||||
disabled={submitPending}
|
||||
disabled={submitPending || hasOdd20ft}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||
import { formatWindowOpensAt, soonestUpcomingWindow } from "./booking-window";
|
||||
import {
|
||||
formatWindowOpensAt,
|
||||
soonestUpcomingWindow,
|
||||
windowRouteStops,
|
||||
} from "./booking-window";
|
||||
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
@@ -189,14 +193,21 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="nowrap" mt={10}>
|
||||
<Text fz={15} fw={700} style={{ color: INK }} truncate>
|
||||
{w.origin ?? "—"}
|
||||
</Text>
|
||||
<ArrowRight size={14} color={MUTED} style={{ flexShrink: 0 }} />
|
||||
<Text fz={15} fw={700} style={{ color: INK }} truncate>
|
||||
{w.destination ?? "—"}
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap" mt={10}>
|
||||
{windowRouteStops(w).map((stop, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<ArrowRight
|
||||
size={14}
|
||||
color={MUTED}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
<Text fz={15} fw={700} style={{ color: INK }}>
|
||||
{stop}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="nowrap" mt={8}>
|
||||
|
||||
@@ -26,7 +26,12 @@ export default function NewShipmentRequestPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [quantity, setQuantity] = useState<number | string>(1);
|
||||
// Container contracts: one quantity per enabled size (e.g. 20ft + 40ft).
|
||||
const [qtyBySize, setQtyBySize] = useState<Record<string, number | string>>(
|
||||
{},
|
||||
);
|
||||
// Bulk contracts: a single amount — tons (PER_TON) or item count (PER_ITEM).
|
||||
const [bulkAmount, setBulkAmount] = useState<number | string>("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const { data: contract, isLoading } = useQuery({
|
||||
@@ -76,6 +81,22 @@ export default function NewShipmentRequestPage() {
|
||||
contract.contractKind === "GENERAL" &&
|
||||
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
|
||||
|
||||
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
|
||||
const SIZE_ORDER = ["20ft", "40ft"];
|
||||
const enabledSizes = SIZE_ORDER.filter((s) =>
|
||||
contract.cargoScope?.some(
|
||||
(l) => (l.containerSize ?? "").toLowerCase() === s,
|
||||
),
|
||||
);
|
||||
// A CONTAINER contract should always carry scope lines; fall back to both.
|
||||
const sizes = enabledSizes.length ? enabledSizes : SIZE_ORDER;
|
||||
|
||||
// Bulk: pick the bulk scope line and read how it's measured.
|
||||
const bulkScope =
|
||||
contract.cargoScope?.find((l) => !l.containerSize) ??
|
||||
contract.cargoScope?.[0];
|
||||
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
|
||||
|
||||
const handleSubmit = () => {
|
||||
const dto: Freight.CreateBookingRequestDto = {
|
||||
contractRouteId: route?.id,
|
||||
@@ -84,17 +105,24 @@ export default function NewShipmentRequestPage() {
|
||||
};
|
||||
|
||||
if (isContainer) {
|
||||
const size = contract.cargoScope?.[0]?.containerSize ?? "20FT";
|
||||
dto.containers = [
|
||||
{
|
||||
containerSize: size,
|
||||
quantity: Number(quantity) || 1,
|
||||
},
|
||||
];
|
||||
// One line per size the user filled; 0 (or blank) sizes are dropped.
|
||||
const containers = sizes
|
||||
.map((size) => ({ containerSize: size, quantity: Number(qtyBySize[size]) || 0 }))
|
||||
.filter((line) => line.quantity > 0);
|
||||
if (containers.length === 0) {
|
||||
toast.error("Enter a quantity for at least one container size");
|
||||
return;
|
||||
}
|
||||
dto.containers = containers;
|
||||
} else {
|
||||
const amount = Number(bulkAmount) || 0;
|
||||
if (amount <= 0) {
|
||||
toast.error(isPerItem ? "Enter the number of items" : "Enter the cargo weight");
|
||||
return;
|
||||
}
|
||||
dto.bulk = {
|
||||
cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null,
|
||||
cargoWeightTons: Number(quantity) || undefined,
|
||||
cargoTypeId: bulkScope?.cargoTypeId ?? null,
|
||||
...(isPerItem ? { itemCount: amount } : { cargoWeightTons: amount }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,17 +159,47 @@ export default function NewShipmentRequestPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
label={isContainer ? "Number of containers" : "Cargo weight (tons)"}
|
||||
description={
|
||||
hasCustoms
|
||||
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
|
||||
: undefined
|
||||
}
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
min={1}
|
||||
/>
|
||||
{isContainer ? (
|
||||
<Stack gap="sm">
|
||||
{sizes.map((size) => (
|
||||
<NumberInput
|
||||
key={size}
|
||||
label={`Number of ${size} containers`}
|
||||
value={qtyBySize[size] ?? 0}
|
||||
onChange={(v) =>
|
||||
setQtyBySize((prev) => ({ ...prev, [size]: v }))
|
||||
}
|
||||
min={0}
|
||||
allowDecimal={false}
|
||||
/>
|
||||
))}
|
||||
{sizes.length > 1 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Enter a quantity for each size you need — leave a size at 0 if
|
||||
you don't need it.
|
||||
</Text>
|
||||
) : null}
|
||||
{hasCustoms ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Global Logistics schedules the shipment date during customs
|
||||
clearance — you only state the quantity.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<NumberInput
|
||||
label={isPerItem ? "Number of items" : "Cargo weight (tons)"}
|
||||
description={
|
||||
hasCustoms
|
||||
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
|
||||
: undefined
|
||||
}
|
||||
value={bulkAmount}
|
||||
onChange={setBulkAmount}
|
||||
min={0}
|
||||
allowDecimal={!isPerItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{capacity?.length ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
|
||||
@@ -25,6 +25,16 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
|
||||
return windows.some((w) => w.isOpenNow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full ordered corridor for a window — every stop from origin through the
|
||||
* intermediate milestones to the destination. Falls back to origin/destination
|
||||
* when the backend sends no milestone chain (older schedules, routeless windows).
|
||||
*/
|
||||
export function windowRouteStops(w: MyBookingWindow): string[] {
|
||||
if (w.routeStations && w.routeStations.length >= 2) return w.routeStations;
|
||||
return [w.origin ?? "—", w.destination ?? "—"];
|
||||
}
|
||||
|
||||
/**
|
||||
* The next upcoming (not-yet-open) window the customer should come back for —
|
||||
* the one that OPENS soonest from now. Two guards matter here:
|
||||
|
||||
@@ -387,10 +387,10 @@ export const api = {
|
||||
({ orderId }) => bookingsService.checkPayment(orderId),
|
||||
),
|
||||
|
||||
approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>(
|
||||
approveDelivery: endpoint<{ id: string; signerName: string }, ApproveDeliveryResponse>(
|
||||
"bookings",
|
||||
"approveDelivery",
|
||||
({ id }) => bookingsService.approveDelivery(id),
|
||||
({ id, signerName }) => bookingsService.approveDelivery(id, signerName),
|
||||
),
|
||||
|
||||
getBookableSchedules: endpoint<
|
||||
|
||||
@@ -94,6 +94,12 @@ export interface MyBookingWindow {
|
||||
departureDate: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
/**
|
||||
* Full ordered corridor for the window's route — origin, every intermediate
|
||||
* milestone stop, then destination (e.g. Djibouti → Adama → Dire Dawa).
|
||||
* Falls back to [origin, destination] when the route has no milestones.
|
||||
*/
|
||||
routeStations: string[];
|
||||
}
|
||||
|
||||
export interface GeneratePriceResponse {
|
||||
@@ -209,6 +215,20 @@ export const bookingsService = {
|
||||
);
|
||||
return data;
|
||||
},
|
||||
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
downloadBookingReleaseDocument: async (bookingId: string): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/bookings/${bookingId}/release-document`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
||||
return data.data;
|
||||
@@ -359,9 +379,13 @@ export const bookingsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
approveDelivery: async (id: string): Promise<ApproveDeliveryResponse> => {
|
||||
approveDelivery: async (
|
||||
id: string,
|
||||
signerName: string,
|
||||
): Promise<ApproveDeliveryResponse> => {
|
||||
const { data } = await client.post(
|
||||
`/api/warehouse-inventory/bookings/${id}/approve-delivery`,
|
||||
{ signerName },
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
@@ -1745,13 +1745,40 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Resolves the passenger's actual boarding/alighting stations for one leg from
|
||||
// originStationId/destinationStationId (set when the booking covers only part of a
|
||||
// longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via
|
||||
// the schedule's stopTimes, falling back to the schedule's own full-route endpoints
|
||||
// when there's no segment override (older records, or a booking that covers the
|
||||
// whole run). Mirrors notifications.service.ts's resolveSegmentStations — that's
|
||||
// already applied to SMS/email; this brings the booking API (voucher, detail page,
|
||||
// confirmation) to the same behavior instead of always showing the train's full route.
|
||||
private resolveSegmentStations(
|
||||
schedule: any,
|
||||
originStationId: string | null | undefined,
|
||||
destinationStationId: string | null | undefined,
|
||||
): { origin: any; destination: any } {
|
||||
const stopTimes: any[] = schedule?.stopTimes ?? [];
|
||||
const findStation = (stationId: string | null | undefined, fallback: any) => {
|
||||
if (stationId && stopTimes.length > 0) {
|
||||
const stop = stopTimes.find((st: any) => st.stationId === stationId);
|
||||
if (stop?.station) return stop.station;
|
||||
}
|
||||
return fallback ?? null;
|
||||
};
|
||||
return {
|
||||
origin: findStation(originStationId, schedule?.originStation),
|
||||
destination: findStation(destinationStationId, schedule?.destinationStation),
|
||||
};
|
||||
}
|
||||
|
||||
async getByRef(bookingRefOrId: string) {
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId);
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||
paymentIntent: true, tickets: true,
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
@@ -1820,6 +1847,19 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
const outboundSegment = this.resolveSegmentStations(
|
||||
(booking as any).schedule,
|
||||
(booking as any).originStationId,
|
||||
(booking as any).destinationStationId,
|
||||
);
|
||||
const returnSegment = (booking as any).returnSchedule
|
||||
? this.resolveSegmentStations(
|
||||
(booking as any).returnSchedule,
|
||||
(booking as any).returnOriginStationId,
|
||||
(booking as any).returnDestinationStationId,
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
|
||||
@@ -1836,8 +1876,8 @@ export class BookingsService {
|
||||
id: (booking as any).schedule.id,
|
||||
trainNumber: (booking as any).schedule.train.number,
|
||||
trainName: (booking as any).schedule.train.name,
|
||||
origin: { id: (booking as any).schedule.originStation.id, name: (booking as any).schedule.originStation.name, code: (booking as any).schedule.originStation.code, city: (booking as any).schedule.originStation.city },
|
||||
destination: { id: (booking as any).schedule.destinationStation.id, name: (booking as any).schedule.destinationStation.name, code: (booking as any).schedule.destinationStation.code, city: (booking as any).schedule.destinationStation.city },
|
||||
origin: { id: outboundSegment.origin.id, name: outboundSegment.origin.name, code: outboundSegment.origin.code, city: outboundSegment.origin.city },
|
||||
destination: { id: outboundSegment.destination.id, name: outboundSegment.destination.name, code: outboundSegment.destination.code, city: outboundSegment.destination.city },
|
||||
departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt,
|
||||
},
|
||||
returnSchedule: (booking as any).returnSchedule
|
||||
@@ -1845,8 +1885,8 @@ export class BookingsService {
|
||||
id: (booking as any).returnSchedule.id,
|
||||
trainNumber: (booking as any).returnSchedule.train.number,
|
||||
trainName: (booking as any).returnSchedule.train.name,
|
||||
origin: { id: (booking as any).returnSchedule.originStation.id, name: (booking as any).returnSchedule.originStation.name, code: (booking as any).returnSchedule.originStation.code, city: (booking as any).returnSchedule.originStation.city },
|
||||
destination: { id: (booking as any).returnSchedule.destinationStation.id, name: (booking as any).returnSchedule.destinationStation.name, code: (booking as any).returnSchedule.destinationStation.code, city: (booking as any).returnSchedule.destinationStation.city },
|
||||
origin: { id: returnSegment!.origin.id, name: returnSegment!.origin.name, code: returnSegment!.origin.code, city: returnSegment!.origin.city },
|
||||
destination: { id: returnSegment!.destination.id, name: returnSegment!.destination.name, code: returnSegment!.destination.code, city: returnSegment!.destination.city },
|
||||
departureAt: (booking as any).returnSchedule.departureAt, arrivalAt: (booking as any).returnSchedule.arrivalAt,
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR
|
||||
import { FleetService } from './fleet.service';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Fleet')
|
||||
@Controller('fleet')
|
||||
@@ -21,6 +22,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Post('coach-types')
|
||||
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Create a coach type' })
|
||||
@ApiBody({ type: CreateCoachTypeDto })
|
||||
@ApiResponse({ status: 201, description: 'Coach type created' })
|
||||
@@ -29,6 +31,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Patch('coach-types/:id')
|
||||
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Update a coach type' })
|
||||
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
|
||||
@ApiBody({ type: UpdateCoachTypeDto })
|
||||
@@ -59,6 +62,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Post('classes')
|
||||
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Create a class' })
|
||||
@ApiBody({ type: CreateClassDto })
|
||||
@ApiResponse({ status: 201, description: 'Class created' })
|
||||
@@ -67,6 +71,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Patch('classes/:id')
|
||||
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Update a class' })
|
||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||
@ApiBody({ type: UpdateClassDto })
|
||||
@@ -98,6 +103,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Post('seat-classes')
|
||||
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
|
||||
@ApiBody({ type: CreateClassDto })
|
||||
@ApiResponse({ status: 201, description: 'Class created' })
|
||||
@@ -106,6 +112,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Patch('seat-classes/:id')
|
||||
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
|
||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||
@ApiBody({ type: UpdateClassDto })
|
||||
@@ -136,6 +143,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Post('trains')
|
||||
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Create a train service' })
|
||||
@ApiBody({ type: CreateTrainDto })
|
||||
@ApiResponse({ status: 201, description: 'Train created' })
|
||||
@@ -144,6 +152,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Patch('trains/:id')
|
||||
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Update a train service' })
|
||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||
@ApiBody({ type: CreateTrainDto })
|
||||
@@ -166,6 +175,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Patch('trains/:id/restore')
|
||||
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Restore (reactivate) a deactivated train' })
|
||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Train restored' })
|
||||
@@ -268,6 +278,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Post('coaches')
|
||||
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
|
||||
@ApiBody({ type: CreateCoachDto })
|
||||
@ApiResponse({
|
||||
@@ -293,6 +304,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Patch('coaches/:id')
|
||||
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Update coach properties' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiBody({ type: UpdateCoachDto })
|
||||
@@ -331,6 +343,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Post('assignments')
|
||||
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Assign a coach to a schedule' })
|
||||
@ApiBody({ type: AssignCoachDto })
|
||||
@ApiResponse({ status: 201, description: 'Coach assigned' })
|
||||
|
||||
@@ -3,10 +3,10 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PackagesService } from './packages.service';
|
||||
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Packages')
|
||||
@Controller('packages')
|
||||
@@ -21,7 +21,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Get('inquiries')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.inquiries.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all inquiries (backoffice)' })
|
||||
listInquiries(
|
||||
@@ -34,7 +34,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Patch('inquiries/:id/status')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.inquiries.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update inquiry status (backoffice)' })
|
||||
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
|
||||
@@ -57,7 +57,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Get('all')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all packages (backoffice)' })
|
||||
listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
@@ -65,7 +65,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Get('bookings')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all package bookings (backoffice)' })
|
||||
listBookings(
|
||||
@@ -124,7 +124,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Create package (admin)' })
|
||||
create(@Body() dto: CreatePackageDto) {
|
||||
@@ -132,7 +132,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update package (admin)' })
|
||||
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
|
||||
@@ -149,7 +149,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Patch(':id/activate')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Activate package (admin)' })
|
||||
activate(@Param('id') id: string) {
|
||||
@@ -157,7 +157,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Patch(':id/deactivate')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Deactivate package (admin)' })
|
||||
deactivate(@Param('id') id: string) {
|
||||
@@ -165,7 +165,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Post(':id/tiers')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Add price tier to package (admin)' })
|
||||
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
|
||||
@@ -173,7 +173,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Patch('tiers/:tierId')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update price tier (admin)' })
|
||||
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
|
||||
|
||||
@@ -55,7 +55,11 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Get("all")
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
|
||||
@PassengerStaff([
|
||||
PASSENGER_PERMS.payments.view,
|
||||
PASSENGER_PERMS.payments.viewAll,
|
||||
PASSENGER_PERMS.admin,
|
||||
])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
|
||||
@ApiQuery({ name: "search", required: false })
|
||||
@@ -146,7 +150,11 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Post("refund")
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.refund, PASSENGER_PERMS.admin])
|
||||
@PassengerStaff([
|
||||
PASSENGER_PERMS.payments.manage,
|
||||
PASSENGER_PERMS.payments.refund,
|
||||
PASSENGER_PERMS.admin,
|
||||
])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" })
|
||||
refund(@Body() dto: RefundDto) {
|
||||
@@ -155,6 +163,7 @@ export class PaymentsController {
|
||||
|
||||
@Post(":bookingId/force-confirm")
|
||||
@PassengerStaff([
|
||||
PASSENGER_PERMS.payments.manage,
|
||||
PASSENGER_PERMS.payments.manageMethods,
|
||||
PASSENGER_PERMS.admin,
|
||||
])
|
||||
@@ -174,6 +183,7 @@ export class PaymentsController {
|
||||
|
||||
@Post("methods")
|
||||
@PassengerStaff([
|
||||
PASSENGER_PERMS.paymentMethods.manage,
|
||||
PASSENGER_PERMS.payments.manageMethods,
|
||||
PASSENGER_PERMS.admin,
|
||||
])
|
||||
@@ -187,6 +197,7 @@ export class PaymentsController {
|
||||
|
||||
@Patch("methods/:id")
|
||||
@PassengerStaff([
|
||||
PASSENGER_PERMS.paymentMethods.manage,
|
||||
PASSENGER_PERMS.payments.manageMethods,
|
||||
PASSENGER_PERMS.admin,
|
||||
])
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Routes')
|
||||
@Controller('routes')
|
||||
@@ -13,7 +13,7 @@ export class RoutesController {
|
||||
// ── Routes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Create a reusable route with its ordered stops',
|
||||
description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI).
|
||||
@@ -41,7 +41,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
getRoute(@Param('id') id: string) { return this.service.getRoute(id); }
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Route updated' })
|
||||
@@ -68,7 +68,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
||||
|
||||
@Post(':id/stops')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Add a stop to an existing route' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Stop added' })
|
||||
@@ -108,7 +108,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
|
||||
|
||||
@Put(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Set the default coach lineup for this route',
|
||||
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Schedule')
|
||||
@Controller('schedules')
|
||||
@@ -12,14 +12,14 @@ export class SchedulesController {
|
||||
constructor(private service: SchedulesService) {}
|
||||
|
||||
@Post('bulk-generate')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Bulk generate repetitive schedules' })
|
||||
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
|
||||
return this.service.bulkGenerateSchedules(dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Create a train schedule from a route template' })
|
||||
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
||||
|
||||
@@ -42,13 +42,13 @@ export class SchedulesController {
|
||||
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
|
||||
|
||||
@Post('fares')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
|
||||
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
||||
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
||||
|
||||
@Patch('fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update a fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Fare rule updated' })
|
||||
@@ -65,7 +65,7 @@ export class SchedulesController {
|
||||
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
|
||||
|
||||
@Post('segment-fares')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Create a segment fare rule' })
|
||||
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
||||
|
||||
@@ -80,7 +80,7 @@ export class SchedulesController {
|
||||
}
|
||||
|
||||
@Patch('routes/fare-rules/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update a route-level fare override' })
|
||||
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
|
||||
updateRouteFareRule(@Param('id') id: string, @Body() dto: any) {
|
||||
@@ -96,7 +96,7 @@ export class SchedulesController {
|
||||
}
|
||||
|
||||
@Post('routes/:routeId/fare-rules')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Create a route-level fare override' })
|
||||
@ApiParam({ name: 'routeId', description: 'Route UUID' })
|
||||
createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) {
|
||||
@@ -110,7 +110,7 @@ export class SchedulesController {
|
||||
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
|
||||
|
||||
@Patch('segment-fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update a segment fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
|
||||
@@ -131,7 +131,7 @@ export class SchedulesController {
|
||||
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update a schedule (partial)' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
|
||||
@@ -139,7 +139,7 @@ export class SchedulesController {
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update schedule status' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
|
||||
@@ -161,7 +161,7 @@ export class SchedulesController {
|
||||
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
||||
|
||||
@Patch(':id/stops/:sequence')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update a stop time' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
|
||||
@@ -172,7 +172,7 @@ export class SchedulesController {
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
@Put(':scheduleId/fares/:seatClassId')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Override fare for a specific seat class on a schedule',
|
||||
description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.',
|
||||
@@ -220,12 +220,13 @@ export class SchedulesController {
|
||||
}
|
||||
|
||||
@Post(':id/fares/sync')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Sync fares from fare engine' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
|
||||
|
||||
@Post(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Assign coaches to a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
assignCoaches(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SeatClassesService } from './seat-classes.service';
|
||||
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Seat Classes')
|
||||
@Controller('seat-classes')
|
||||
@@ -26,7 +26,7 @@ export class SeatClassesController {
|
||||
getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); }
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Create a seat class' })
|
||||
@ApiBody({ type: CreateSeatClassDto })
|
||||
@ApiResponse({ status: 201, description: 'Seat class created' })
|
||||
@@ -34,7 +34,7 @@ export class SeatClassesController {
|
||||
createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); }
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update a seat class' })
|
||||
@ApiParam({ name: 'id', description: 'Seat class UUID' })
|
||||
@ApiBody({ type: UpdateSeatClassDto })
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
import { SeatsService } from "./seats.service";
|
||||
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { IamGuard } from "../../common/iam-adapter";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@ApiTags("Seats")
|
||||
@Controller("seats")
|
||||
@@ -205,7 +206,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
|
||||
// ── Seat Block / Unblock ───────────────────────────────────────────────────
|
||||
@Post(":seatId/block")
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@@ -215,7 +216,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
}
|
||||
|
||||
@Delete(":seatId/block")
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Unblock a seat" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@@ -226,7 +227,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
|
||||
// ── Maintenance ───────────────────────────────────────────────────────────
|
||||
@Post(":seatId/maintenance")
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@@ -236,7 +237,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
}
|
||||
|
||||
@Delete(":seatId/maintenance")
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Clear seat maintenance status" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@@ -247,7 +248,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
|
||||
// ── Remove Seat ────────────────────────────────────────────────────────────
|
||||
@Patch(":seatId/remove")
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Remove a seat by marking with negative seatNumber",
|
||||
@@ -263,7 +264,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
}
|
||||
|
||||
@Patch(":seatId/undo-remove")
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Undo seat removal by restoring original seatNumber",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Patch, Delete, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { StationsService } from './stations.service';
|
||||
import { CreateStationDto } from './stations.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Stations')
|
||||
@Controller('stations')
|
||||
@@ -79,8 +79,8 @@ export class StationsController {
|
||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Create new station' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
@@ -105,8 +105,8 @@ export class StationsController {
|
||||
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update station' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
|
||||
@@ -54,4 +54,19 @@ export const EDR_PASSENGER_ROLES: PassengerSeedRole[] = [
|
||||
name: { en: 'EDR Passenger Finance' },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.finance],
|
||||
},
|
||||
{
|
||||
key: 'edr_passenger_operations_manager',
|
||||
name: { en: 'EDR Passenger Operations Manager' },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsManager],
|
||||
},
|
||||
{
|
||||
key: 'edr_passenger_marketing_manager',
|
||||
name: { en: 'EDR Passenger Marketing Manager' },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketingManager],
|
||||
},
|
||||
{
|
||||
key: 'edr_passenger_finance_manager',
|
||||
name: { en: 'EDR Passenger Finance Manager' },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.financeManager],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -34,6 +34,38 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
|
||||
perm('75b5ff62-a8e4-4331-b6e6-d53e1456d10e', 'edr_passenger_app:currencies:manage', 'Manage currencies'),
|
||||
perm('4a47da9b-cf6e-4240-aff8-aadf01641c54', 'edr_passenger_app:notifications:send', 'Send notifications'),
|
||||
perm('bfe3428f-8b85-4a36-87c6-33063b084bf3', 'edr_passenger_app:dashboard:view', 'View dashboard'),
|
||||
|
||||
// ── Master Data ────────────────────────────────────────────────────────────
|
||||
perm('102969bc-13a2-4f4a-aa7f-ce4b2599ce82', 'edr_passenger_app:stations:view', 'View stations'),
|
||||
perm('931cf6fc-8f41-46b6-82b8-b242f75d296e', 'edr_passenger_app:stations:manage', 'Manage stations'),
|
||||
perm('82a801b3-2451-409b-99e8-9f4d3515d329', 'edr_passenger_app:trains:view', 'View trains'),
|
||||
perm('e2a7f842-7691-4007-bd71-fd91c467044d', 'edr_passenger_app:trains:manage', 'Manage trains'),
|
||||
perm('71de593c-ae4a-4d15-9f0a-b889eeb4910c', 'edr_passenger_app:coaches:view', 'View coaches'),
|
||||
perm('39b3de2e-c779-4010-8ef3-d478f00a15c6', 'edr_passenger_app:coaches:manage', 'Manage coaches'),
|
||||
perm('ba0fdd0b-6580-48a8-b31f-eb5ef76a2453', 'edr_passenger_app:seats:view', 'View seats'),
|
||||
perm('6fb9affb-1885-446e-a8e4-962af9fae33f', 'edr_passenger_app:seats:manage', 'Manage seats'),
|
||||
perm('b7b659d9-f453-41d3-a6db-72e671446214', 'edr_passenger_app:classes:view', 'View classes'),
|
||||
perm('b2d06665-33f5-46d7-a895-785b5fb1896a', 'edr_passenger_app:classes:manage', 'Manage classes'),
|
||||
perm('8731ee98-24c2-4f8b-9c06-cf3fa900a95a', 'edr_passenger_app:routes:view', 'View routes'),
|
||||
perm('5851233c-78de-45b9-9d3f-63816d068622', 'edr_passenger_app:routes:manage', 'Manage routes'),
|
||||
perm('c453bdf9-496a-4ac8-b733-8eb5dd5d591a', 'edr_passenger_app:schedules:view', 'View schedules'),
|
||||
perm('d3f3cfd0-c7ce-47ab-be7f-bf3d6b40e488', 'edr_passenger_app:schedules:manage', 'Manage schedules'),
|
||||
|
||||
// ── Tourism ────────────────────────────────────────────────────────────────
|
||||
perm('d78d810b-3003-4d81-92d5-41c437f3cc42', 'edr_passenger_app:packages:view', 'View packages'),
|
||||
perm('dcfab0d9-1f80-4822-892a-e2851b549297', 'edr_passenger_app:packages:manage', 'Manage packages'),
|
||||
perm('6d7ab68c-1b88-405d-9f92-b130055eece6', 'edr_passenger_app:inquiries:view', 'View package inquiries'),
|
||||
perm('dbe5a07a-d12f-4a36-b191-0bb4f980054e', 'edr_passenger_app:inquiries:manage', 'Manage package inquiries'),
|
||||
|
||||
// ── Finance ────────────────────────────────────────────────────────────────
|
||||
perm('4b6efd87-f230-4109-abe1-593e53cb0c10', 'edr_passenger_app:tariff_rates:view', 'View tariff rates'),
|
||||
perm('94f17a59-397c-4c9c-a424-38a9c66c9e50', 'edr_passenger_app:tariff_rates:manage', 'Manage tariff rates'),
|
||||
perm('2dc4eb75-5b28-4ead-a2d4-82ac95cd290c', 'edr_passenger_app:payments:view', 'View payments'),
|
||||
perm('418b5f64-656b-4d44-a543-930ada9ec1a7', 'edr_passenger_app:payments:manage', 'Manage payments'),
|
||||
perm('3f3d5479-af33-4883-867e-aae9e2aeeeca', 'edr_passenger_app:currencies:view', 'View currencies'),
|
||||
perm('b4e63290-cc3a-4df8-9f2a-9ff726e86e36', 'edr_passenger_app:payment_methods:view', 'View payment methods'),
|
||||
perm('f9fb6af2-e869-4e6e-938c-259643393315', 'edr_passenger_app:payment_methods:manage', 'Manage payment methods'),
|
||||
|
||||
perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'),
|
||||
];
|
||||
|
||||
@@ -54,10 +86,57 @@ export const PASSENGER_PERMS = {
|
||||
manage: 'edr_passenger_app:tickets:manage',
|
||||
},
|
||||
payments: {
|
||||
view: 'edr_passenger_app:payments:view',
|
||||
manage: 'edr_passenger_app:payments:manage',
|
||||
// legacy keys — retained as aliases for backward compatibility
|
||||
viewAll: 'edr_passenger_app:payments:view_all',
|
||||
refund: 'edr_passenger_app:payments:refund',
|
||||
manageMethods: 'edr_passenger_app:payments:manage_methods',
|
||||
},
|
||||
paymentMethods: {
|
||||
view: 'edr_passenger_app:payment_methods:view',
|
||||
manage: 'edr_passenger_app:payment_methods:manage',
|
||||
},
|
||||
stations: {
|
||||
view: 'edr_passenger_app:stations:view',
|
||||
manage: 'edr_passenger_app:stations:manage',
|
||||
},
|
||||
trains: {
|
||||
view: 'edr_passenger_app:trains:view',
|
||||
manage: 'edr_passenger_app:trains:manage',
|
||||
},
|
||||
coaches: {
|
||||
view: 'edr_passenger_app:coaches:view',
|
||||
manage: 'edr_passenger_app:coaches:manage',
|
||||
},
|
||||
seats: {
|
||||
view: 'edr_passenger_app:seats:view',
|
||||
manage: 'edr_passenger_app:seats:manage',
|
||||
},
|
||||
classes: {
|
||||
view: 'edr_passenger_app:classes:view',
|
||||
manage: 'edr_passenger_app:classes:manage',
|
||||
},
|
||||
routes: {
|
||||
view: 'edr_passenger_app:routes:view',
|
||||
manage: 'edr_passenger_app:routes:manage',
|
||||
},
|
||||
schedules: {
|
||||
view: 'edr_passenger_app:schedules:view',
|
||||
manage: 'edr_passenger_app:schedules:manage',
|
||||
},
|
||||
packages: {
|
||||
view: 'edr_passenger_app:packages:view',
|
||||
manage: 'edr_passenger_app:packages:manage',
|
||||
},
|
||||
inquiries: {
|
||||
view: 'edr_passenger_app:inquiries:view',
|
||||
manage: 'edr_passenger_app:inquiries:manage',
|
||||
},
|
||||
tariffRates: {
|
||||
view: 'edr_passenger_app:tariff_rates:view',
|
||||
manage: 'edr_passenger_app:tariff_rates:manage',
|
||||
},
|
||||
reports: {
|
||||
view: 'edr_passenger_app:reports:view',
|
||||
},
|
||||
@@ -73,6 +152,7 @@ export const PASSENGER_PERMS = {
|
||||
manage: 'edr_passenger_app:agents:manage',
|
||||
},
|
||||
currencies: {
|
||||
view: 'edr_passenger_app:currencies:view',
|
||||
manage: 'edr_passenger_app:currencies:manage',
|
||||
},
|
||||
notifications: {
|
||||
@@ -126,9 +206,36 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
],
|
||||
|
||||
finance: [
|
||||
PASSENGER_PERMS.payments.view,
|
||||
PASSENGER_PERMS.payments.viewAll,
|
||||
PASSENGER_PERMS.payments.refund,
|
||||
PASSENGER_PERMS.reports.view,
|
||||
PASSENGER_PERMS.dashboard.view,
|
||||
],
|
||||
|
||||
operationsManager: [
|
||||
PASSENGER_PERMS.stations.view, PASSENGER_PERMS.stations.manage,
|
||||
PASSENGER_PERMS.trains.view, PASSENGER_PERMS.trains.manage,
|
||||
PASSENGER_PERMS.coaches.view, PASSENGER_PERMS.coaches.manage,
|
||||
PASSENGER_PERMS.seats.view, PASSENGER_PERMS.seats.manage,
|
||||
PASSENGER_PERMS.classes.view, PASSENGER_PERMS.classes.manage,
|
||||
PASSENGER_PERMS.routes.view, PASSENGER_PERMS.routes.manage,
|
||||
PASSENGER_PERMS.schedules.view, PASSENGER_PERMS.schedules.manage,
|
||||
PASSENGER_PERMS.dashboard.view,
|
||||
],
|
||||
|
||||
marketingManager: [
|
||||
PASSENGER_PERMS.packages.view, PASSENGER_PERMS.packages.manage,
|
||||
PASSENGER_PERMS.inquiries.view, PASSENGER_PERMS.inquiries.manage,
|
||||
PASSENGER_PERMS.dashboard.view,
|
||||
],
|
||||
|
||||
financeManager: [
|
||||
PASSENGER_PERMS.tariffRates.view, PASSENGER_PERMS.tariffRates.manage,
|
||||
PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.manage,
|
||||
PASSENGER_PERMS.currencies.view, PASSENGER_PERMS.currencies.manage,
|
||||
PASSENGER_PERMS.paymentMethods.view, PASSENGER_PERMS.paymentMethods.manage,
|
||||
PASSENGER_PERMS.reports.view,
|
||||
PASSENGER_PERMS.dashboard.view,
|
||||
],
|
||||
} as const;
|
||||
|
||||
@@ -9,14 +9,13 @@ import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { apiClient, paymentsApi } from '@/lib/api';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { usePermission } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
export default function PaymentMethodsPage() {
|
||||
const canManagePayments = usePermission(PERMS.payments.manage);
|
||||
const canManageMethods = usePermission(PERMS.paymentMethods.manage);
|
||||
const canManageAdmin = usePermission(PERMS.admin);
|
||||
const canManage = canManagePayments || canManageAdmin;
|
||||
const canManage = canManageMethods || canManageAdmin;
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
@@ -228,11 +227,11 @@ export default function PaymentMethodsPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Payment Methods</h1>
|
||||
<p className="text-muted-foreground">Manage supported payment systems</p>
|
||||
</div>
|
||||
<PermissionGuard permission={PERMS.admin}>
|
||||
{canManage && (
|
||||
<ActionButton icon={Plus} onClick={() => setCreateModalOpen(true)}>
|
||||
Add Method
|
||||
</ActionButton>
|
||||
</PermissionGuard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{successMessage && (
|
||||
|
||||
@@ -70,33 +70,33 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{
|
||||
title: 'Tourism',
|
||||
items: [
|
||||
{ name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin },
|
||||
// { name: 'Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.admin },
|
||||
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin },
|
||||
{ name: 'Packages', href: '/packages', icon: Package, permission: PERMS.packages.view },
|
||||
// { name: 'Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.packages.view },
|
||||
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.inquiries.view },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Master Data',
|
||||
items: [
|
||||
{ name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.admin },
|
||||
{ name: 'Trains', href: '/trains', icon: Train, permission: PERMS.admin },
|
||||
{ name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.admin },
|
||||
{ name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.admin },
|
||||
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.admin },
|
||||
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.admin },
|
||||
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.admin },
|
||||
{ name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view },
|
||||
{ name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view },
|
||||
{ name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view },
|
||||
{ name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view },
|
||||
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
|
||||
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
|
||||
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Financial',
|
||||
items: [
|
||||
// { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
|
||||
{ name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.admin },
|
||||
{ name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.tariffRates.view },
|
||||
// { name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin },
|
||||
{ name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view },
|
||||
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
|
||||
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.view },
|
||||
// { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },
|
||||
{ name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view },
|
||||
{ name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.paymentMethods.view },
|
||||
// { name: 'Wallet Accounts', href: '/wallet-accounts', icon: Wallet, permission: PERMS.payments.view },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -13,11 +13,69 @@ export const PERMS = {
|
||||
view: 'edr_passenger_app:tickets:view',
|
||||
manage: 'edr_passenger_app:tickets:manage',
|
||||
},
|
||||
payments: {
|
||||
view: 'edr_passenger_app:payments:view_all',
|
||||
refund: 'edr_passenger_app:payments:refund',
|
||||
manage: 'edr_passenger_app:payments:manage_methods',
|
||||
|
||||
// ── Master Data ────────────────────────────────────────────────
|
||||
stations: {
|
||||
view: 'edr_passenger_app:stations:view',
|
||||
manage: 'edr_passenger_app:stations:manage',
|
||||
},
|
||||
trains: {
|
||||
view: 'edr_passenger_app:trains:view',
|
||||
manage: 'edr_passenger_app:trains:manage',
|
||||
},
|
||||
coaches: {
|
||||
view: 'edr_passenger_app:coaches:view',
|
||||
manage: 'edr_passenger_app:coaches:manage',
|
||||
},
|
||||
seats: {
|
||||
view: 'edr_passenger_app:seats:view',
|
||||
manage: 'edr_passenger_app:seats:manage',
|
||||
},
|
||||
classes: {
|
||||
view: 'edr_passenger_app:classes:view',
|
||||
manage: 'edr_passenger_app:classes:manage',
|
||||
},
|
||||
routes: {
|
||||
view: 'edr_passenger_app:routes:view',
|
||||
manage: 'edr_passenger_app:routes:manage',
|
||||
},
|
||||
schedules: {
|
||||
view: 'edr_passenger_app:schedules:view',
|
||||
manage: 'edr_passenger_app:schedules:manage',
|
||||
},
|
||||
|
||||
// ── Tourism ────────────────────────────────────────────────────
|
||||
packages: {
|
||||
view: 'edr_passenger_app:packages:view',
|
||||
manage: 'edr_passenger_app:packages:manage',
|
||||
},
|
||||
inquiries: {
|
||||
view: 'edr_passenger_app:inquiries:view',
|
||||
manage: 'edr_passenger_app:inquiries:manage',
|
||||
},
|
||||
|
||||
// ── Finance ────────────────────────────────────────────────────
|
||||
tariffRates: {
|
||||
view: 'edr_passenger_app:tariff_rates:view',
|
||||
manage: 'edr_passenger_app:tariff_rates:manage',
|
||||
},
|
||||
payments: {
|
||||
view: 'edr_passenger_app:payments:view',
|
||||
manage: 'edr_passenger_app:payments:manage',
|
||||
// legacy aliases — still honoured by the backend guards
|
||||
viewAll: 'edr_passenger_app:payments:view_all',
|
||||
refund: 'edr_passenger_app:payments:refund',
|
||||
manageMethods: 'edr_passenger_app:payments:manage_methods',
|
||||
},
|
||||
paymentMethods: {
|
||||
view: 'edr_passenger_app:payment_methods:view',
|
||||
manage: 'edr_passenger_app:payment_methods:manage',
|
||||
},
|
||||
currencies: {
|
||||
view: 'edr_passenger_app:currencies:view',
|
||||
manage: 'edr_passenger_app:currencies:manage',
|
||||
},
|
||||
|
||||
reports: {
|
||||
view: 'edr_passenger_app:reports:view',
|
||||
},
|
||||
@@ -32,9 +90,6 @@ export const PERMS = {
|
||||
view: 'edr_passenger_app:agents:view',
|
||||
manage: 'edr_passenger_app:agents:manage',
|
||||
},
|
||||
currencies: {
|
||||
manage: 'edr_passenger_app:currencies:manage',
|
||||
},
|
||||
notifications: {
|
||||
send: 'edr_passenger_app:notifications:send',
|
||||
},
|
||||
|
||||
@@ -730,7 +730,7 @@ export default function ConfirmationPage() {
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p className="text-sm text-green-800 dark:text-green-300">
|
||||
✅ Please arrive at the station at least 30 minutes before
|
||||
✅ Please arrive at the station at least 2 hours before
|
||||
departure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1927,9 +1927,6 @@ export default function SeatsPage() {
|
||||
? allCoachSeats?.find((s: any) => s.id === assignedSeatId)
|
||||
: null;
|
||||
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
|
||||
const seatFare = assignedSeat
|
||||
? (getSeatFare(assignedSeat) ?? (isPackageBooking ? packageTierPriceMinor ?? null : null))
|
||||
: isPackageBooking && assignedSeatId ? (packageTierPriceMinor ?? null) : null;
|
||||
const isActive = i === activePassengerIndex;
|
||||
const isClickable = i <= maxSelectableIndex;
|
||||
return (
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function HowToGuidePage() {
|
||||
</p>
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 p-4 rounded-lg">
|
||||
<p className="text-sm text-green-800 dark:text-green-300">
|
||||
📱 Show the QR code at the gate for easy check-in. Arrive at least 30 minutes before departure.
|
||||
📱 Show the QR code at the gate for easy check-in. Arrive at least 2 hours before departure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,7 +225,7 @@ export default function HowToGuidePage() {
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">What should I bring on the day of travel?</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Bring your ticket (digital or printed), valid ID/passport, and arrive 30 minutes before departure.
|
||||
Bring your ticket (digital or printed), valid ID/passport, and arrive 2 hours before departure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -374,7 +374,7 @@ function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: numb
|
||||
doc.text('BEFORE YOU TRAVEL', margin + padX, y + 6, { charSpace: 0.3 });
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
|
||||
doc.text('Present this voucher (printed or on your phone) at the terminal for boarding.', margin + padX, y + 11);
|
||||
doc.text('Please arrive at least 30 minutes before scheduled departure.', margin + padX, y + 15);
|
||||
doc.text('Please arrive at least 2 hours before scheduled departure.', margin + padX, y + 15);
|
||||
|
||||
return y + cardH + 4;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
# Build: DOCKER_BUILDKIT=1 docker compose build
|
||||
# Run: docker compose up -d
|
||||
services:
|
||||
# Message broker for payment event delivery (payment-api -> passenger/freight).
|
||||
# Management UI: http://localhost:15672 (login edr / edr_secret). vhost: payment.
|
||||
# In deployed envs this is a shared/managed RabbitMQ; only PAYMENT_RABBITMQ_URL changes.
|
||||
freight-api:
|
||||
build:
|
||||
context: .
|
||||
@@ -21,26 +18,19 @@ services:
|
||||
extra_hosts:
|
||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||
|
||||
# Standalone GT06 GPS tracker ingester (@edr/gps-tracker). Raw TCP only, no
|
||||
# HTTP. Writes freight.gps_devices / freight.gps_positions in the shared
|
||||
# freight DB; the /gps REST API stays in freight-api. Never runs migrations.
|
||||
gps-tracker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/edr-gps-tracker/Dockerfile
|
||||
secrets:
|
||||
- npmrc
|
||||
depends_on:
|
||||
- freight-api
|
||||
ports:
|
||||
# Raw TCP — reachable by tracker SIMs. Not HTTP; no L7 proxy can host-route it.
|
||||
- "${GT06_TCP_PORT:-5023}:5023"
|
||||
environment:
|
||||
GT06_TCP_PORT: "5023"
|
||||
GT06_TCP_HOST: "0.0.0.0"
|
||||
env_file:
|
||||
# Reuses the freight DB credentials (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME).
|
||||
- apps/edr-freight-api/.env
|
||||
- apps/edr-gps-tracker/.env
|
||||
restart: unless-stopped
|
||||
|
||||
passenger-api:
|
||||
@@ -54,17 +44,7 @@ services:
|
||||
extra_hosts:
|
||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:4000/health/ready"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 1G
|
||||
|
||||
freight-portal:
|
||||
build:
|
||||
context: .
|
||||
@@ -80,6 +60,7 @@ services:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_PORTAL_PORT:-5173}:80"
|
||||
|
||||
freight-backoffice:
|
||||
build:
|
||||
context: .
|
||||
@@ -95,6 +76,7 @@ services:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
|
||||
|
||||
passenger-portal:
|
||||
build:
|
||||
context: .
|
||||
@@ -110,17 +92,7 @@ services:
|
||||
env_file:
|
||||
- apps/edr-passenger-web/portal/.env
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_PORTAL_PORT:-5174}/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
|
||||
passenger-backoffice:
|
||||
build:
|
||||
context: .
|
||||
@@ -136,17 +108,6 @@ services:
|
||||
env_file:
|
||||
- apps/edr-passenger-web/backoffice/.env
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_BACKOFFICE_PORT:-5184}/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
|
||||
payment-api:
|
||||
build:
|
||||
|
||||
@@ -327,6 +327,8 @@ export interface IWagonMovement extends BaseEntity {
|
||||
bookingId?: string | null;
|
||||
kind: WagonMovementKind;
|
||||
movedByUserId?: string | null;
|
||||
/** The transfer request this move fulfilled, when it came from one. */
|
||||
transferRequestId?: string | null;
|
||||
occurredAt: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user