mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 20:33:27 +00:00
Merge pull request #679 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -53,23 +53,23 @@ import {
|
|||||||
} from "./seed/edr-freight.seed";
|
} from "./seed/edr-freight.seed";
|
||||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||||
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
// Disabled seeds — imports commented out with their provider/injection/run below.
|
||||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
// import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||||
|
// import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||||
import { PaymentModule } from "./modules/payment/payment.module";
|
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 { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||||
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||||
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||||
import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||||
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
// import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
// import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
// import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||||
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||||
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||||
import { EdrTruckFleetSeeder } from "./seed/edr-truck-fleet.seeder";
|
|
||||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||||
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
@@ -195,22 +195,22 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
FreightPositionsSeeder,
|
FreightPositionsSeeder,
|
||||||
DemoUsersSeeder,
|
|
||||||
FreightStaffUsersSeeder,
|
|
||||||
PricingDataSeeder,
|
|
||||||
FileUploadSettingsSeeder,
|
FileUploadSettingsSeeder,
|
||||||
FreightPermissionKeyMigrationSeeder,
|
FreightPermissionKeyMigrationSeeder,
|
||||||
DemoFreightDataSeeder,
|
// Disabled seeds — providers commented out (imports/injection/run too):
|
||||||
GovCompaniesSeeder,
|
// DemoUsersSeeder,
|
||||||
EdrTruckFleetSeeder,
|
// FreightStaffUsersSeeder,
|
||||||
IndodeFacilitySeeder,
|
// PricingDataSeeder,
|
||||||
Batch14TestDataSeeder,
|
// DemoFreightDataSeeder,
|
||||||
Batch5TestDataSeeder,
|
// GovCompaniesSeeder,
|
||||||
Batch7TestDataSeeder,
|
// IndodeFacilitySeeder,
|
||||||
Batch8TestDataSeeder,
|
// Batch14TestDataSeeder,
|
||||||
WarehouseDemoSeeder,
|
// Batch5TestDataSeeder,
|
||||||
ExportDjiboutiInterchangeDemoSeeder,
|
// Batch7TestDataSeeder,
|
||||||
MarshallingDemoTrainsSeeder,
|
// Batch8TestDataSeeder,
|
||||||
|
// WarehouseDemoSeeder,
|
||||||
|
// ExportDjiboutiInterchangeDemoSeeder,
|
||||||
|
// MarshallingDemoTrainsSeeder,
|
||||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||||
PaidImportExportMileDemoSeeder,
|
PaidImportExportMileDemoSeeder,
|
||||||
],
|
],
|
||||||
@@ -220,53 +220,66 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly seeder: DataSeeder,
|
private readonly seeder: DataSeeder,
|
||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
|
||||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
|
||||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
|
||||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
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 freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
// Disabled seeds — injections commented out (imports/provider/run too):
|
||||||
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||||
private readonly edrTruckFleetSeeder: EdrTruckFleetSeeder,
|
// 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() {
|
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.freightPermissionKeyMigrationSeeder.run();
|
||||||
await this.seeder.run();
|
await this.seeder.run();
|
||||||
await this.edrOrgSeeder.run();
|
await this.edrOrgSeeder.run();
|
||||||
await this.freightPositionsSeeder.run();
|
await this.freightPositionsSeeder.run();
|
||||||
await this.demoUsersSeeder.run();
|
|
||||||
await this.freightStaffUsersSeeder.run();
|
// File upload settings — keep enabled.
|
||||||
await this.pricingDataSeeder.run();
|
|
||||||
await this.fileUploadSettingsSeeder.run();
|
await this.fileUploadSettingsSeeder.run();
|
||||||
await this.indodeFacilitySeeder.run();
|
|
||||||
await this.batch14TestDataSeeder.run();
|
// Dropdown settings are not seeded on boot; run them with
|
||||||
await this.batch5TestDataSeeder.run();
|
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
|
||||||
await this.batch7TestDataSeeder.run();
|
|
||||||
await this.batch8TestDataSeeder.run();
|
// ── Disabled: demo / test / reference data seeds ──
|
||||||
await this.warehouseDemoSeeder.run();
|
// Uncomment a line to re-enable that seed.
|
||||||
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
// await this.demoUsersSeeder.run();
|
||||||
await this.marshallingDemoTrainsSeeder.run();
|
// await this.freightStaffUsersSeeder.run();
|
||||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
// await this.pricingDataSeeder.run();
|
||||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
// await this.indodeFacilitySeeder.run();
|
||||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
// await this.batch14TestDataSeeder.run();
|
||||||
// FileUploadSettingsSeeder) are intentionally disabled — they stay
|
// await this.batch5TestDataSeeder.run();
|
||||||
// registered as providers but are not run. Re-inject + call .run() to enable.
|
// await this.batch7TestDataSeeder.run();
|
||||||
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
// await this.batch8TestDataSeeder.run();
|
||||||
// rules are disabled inside the seeder). Kept running for the staff users.
|
// await this.warehouseDemoSeeder.run();
|
||||||
await this.demoFreightDataSeeder.run();
|
// await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||||
// Government entities (with importer/exporter profiles) that government
|
// await this.marshallingDemoTrainsSeeder.run();
|
||||||
// bookings bill to. Idempotent — keyed by fixed IDs.
|
// demoFreightDataSeeder seeds ONLY the 4 staff users (wagons + approval
|
||||||
await this.govCompaniesSeeder.run();
|
// rules are already disabled inside the seeder).
|
||||||
await this.edrTruckFleetSeeder.run();
|
// 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) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export const WagonTransferRequest = () =>
|
|||||||
export const WagonTransferFulfill = () =>
|
export const WagonTransferFulfill = () =>
|
||||||
BookingStaff(FREIGHT_PERMS.wagons.transferFulfill);
|
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). */
|
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -208,6 +208,8 @@ export interface BatchBoardScheduleDetail {
|
|||||||
docReviewEndsAt: string | null;
|
docReviewEndsAt: string | null;
|
||||||
paymentPhaseEndsAt: string | null;
|
paymentPhaseEndsAt: string | null;
|
||||||
bookingCycleNo: number;
|
bookingCycleNo: number;
|
||||||
|
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||||
|
train: BatchBoardSchedule["train"];
|
||||||
locomotive: BatchBoardSchedule["locomotive"];
|
locomotive: BatchBoardSchedule["locomotive"];
|
||||||
capacity: BatchBoardSchedule["capacity"];
|
capacity: BatchBoardSchedule["capacity"];
|
||||||
counts: BatchBoardSchedule["counts"];
|
counts: BatchBoardSchedule["counts"];
|
||||||
@@ -235,6 +237,12 @@ export interface BatchBoardSchedule {
|
|||||||
docReviewEndsAt: string | null;
|
docReviewEndsAt: string | null;
|
||||||
paymentPhaseEndsAt: string | null;
|
paymentPhaseEndsAt: string | null;
|
||||||
bookingCycleNo: number;
|
bookingCycleNo: number;
|
||||||
|
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||||
|
train: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
trainName: string | null;
|
||||||
|
} | null;
|
||||||
locomotive: {
|
locomotive: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -877,7 +885,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||||||
where,
|
where,
|
||||||
relations: {
|
relations: {
|
||||||
trainSet: { locomotive: true },
|
trainSet: { locomotive: true, train: true },
|
||||||
originStation: true,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
// Yards supply the route's display name for `routeName` below;
|
// Yards supply the route's display name for `routeName` below;
|
||||||
@@ -1128,6 +1136,13 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
? s.paymentPhaseEndsAt.toISOString()
|
? s.paymentPhaseEndsAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
bookingCycleNo: s.bookingCycleNo ?? 0,
|
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
|
locomotive: loco
|
||||||
? {
|
? {
|
||||||
code: loco.code,
|
code: loco.code,
|
||||||
@@ -1247,6 +1262,13 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
? s.paymentPhaseEndsAt.toISOString()
|
? s.paymentPhaseEndsAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
bookingCycleNo: s.bookingCycleNo ?? 0,
|
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
|
locomotive: loco
|
||||||
? {
|
? {
|
||||||
code: loco.code,
|
code: loco.code,
|
||||||
|
|||||||
@@ -282,6 +282,8 @@ interface BookingWindowRow {
|
|||||||
origin_code: string | null;
|
origin_code: string | null;
|
||||||
destination_label: string | null;
|
destination_label: string | null;
|
||||||
destination_code: string | null;
|
destination_code: string | null;
|
||||||
|
/** Full ordered corridor (origin → milestones → destination) from the schedule's route. */
|
||||||
|
route_stations: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -1329,9 +1331,15 @@ export class TrainSchedulingService {
|
|||||||
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
|
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
|
||||||
const lengthCapWithOverage =
|
const lengthCapWithOverage =
|
||||||
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
|
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
|
||||||
if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) {
|
// The locomotives pull GROSS weight: the customers' cargo plus the empty
|
||||||
|
// weight of every planned wagon — cargo-only comparison understates the load.
|
||||||
|
const planTareTons = roundTons(
|
||||||
|
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
||||||
|
);
|
||||||
|
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
|
||||||
|
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
|
||||||
throw new BadRequestException(
|
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) {
|
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
|
||||||
@@ -4605,14 +4613,8 @@ export class TrainSchedulingService {
|
|||||||
name: link.locomotive!.name ?? null,
|
name: link.locomotive!.name ?? null,
|
||||||
})),
|
})),
|
||||||
wagonCount: wagons.length,
|
wagonCount: wagons.length,
|
||||||
maxGrossTons: roundTons(
|
totalTareTons: roundTons(
|
||||||
wagons.reduce(
|
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
|
||||||
(sum, w) =>
|
|
||||||
sum +
|
|
||||||
(Number(w.wagonType?.tareWeightTons) || 0) +
|
|
||||||
(Number(w.wagonType?.capacityTons) || 0),
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
totalLengthMeters: roundTons(
|
totalLengthMeters: roundTons(
|
||||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
||||||
@@ -4725,7 +4727,11 @@ export class TrainSchedulingService {
|
|||||||
ts.booking_cycle_no,
|
ts.booking_cycle_no,
|
||||||
ts.scheduled_departure_date,
|
ts.scheduled_departure_date,
|
||||||
oy.label AS origin_label, oy.code AS origin_code,
|
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
|
FROM freight.train_schedules ts
|
||||||
LEFT JOIN freight.contract_routes cr
|
LEFT JOIN freight.contract_routes cr
|
||||||
ON cr.deleted_at IS NULL
|
ON cr.deleted_at IS NULL
|
||||||
@@ -4777,7 +4783,11 @@ export class TrainSchedulingService {
|
|||||||
ts.booking_cycle_no,
|
ts.booking_cycle_no,
|
||||||
ts.scheduled_departure_date,
|
ts.scheduled_departure_date,
|
||||||
oy.label AS origin_label, oy.code AS origin_code,
|
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
|
FROM freight.train_schedules ts
|
||||||
JOIN freight.contract_routes cr
|
JOIN freight.contract_routes cr
|
||||||
ON cr.contract_id = $1
|
ON cr.contract_id = $1
|
||||||
@@ -4823,7 +4833,11 @@ export class TrainSchedulingService {
|
|||||||
ts.booking_cycle_no,
|
ts.booking_cycle_no,
|
||||||
ts.scheduled_departure_date,
|
ts.scheduled_departure_date,
|
||||||
oy.label AS origin_label, oy.code AS origin_code,
|
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
|
FROM freight.train_schedules ts
|
||||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||||
@@ -4845,6 +4859,17 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mapBookingWindowRow(r: BookingWindowRow) {
|
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 {
|
return {
|
||||||
scheduleId: r.schedule_id,
|
scheduleId: r.schedule_id,
|
||||||
reference: r.reference ?? null,
|
reference: r.reference ?? null,
|
||||||
@@ -4860,8 +4885,9 @@ export class TrainSchedulingService {
|
|||||||
bookingWindowStatus: r.booking_window_status,
|
bookingWindowStatus: r.booking_window_status,
|
||||||
bookingCycleNo: r.booking_cycle_no,
|
bookingCycleNo: r.booking_cycle_no,
|
||||||
departureDate: r.scheduled_departure_date,
|
departureDate: r.scheduled_departure_date,
|
||||||
origin: r.origin_label ?? r.origin_code ?? null,
|
origin,
|
||||||
destination: r.destination_label ?? r.destination_code ?? null,
|
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,
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
@@ -19,6 +20,7 @@ import { BuildTrainDto } from './dto/build-train.dto';
|
|||||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||||
|
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||||
import { TrainBuilderService } from './train-builder.service';
|
import { TrainBuilderService } from './train-builder.service';
|
||||||
|
|
||||||
@ApiTags('train-builder')
|
@ApiTags('train-builder')
|
||||||
@@ -57,6 +59,15 @@ export class TrainBuilderController {
|
|||||||
return this.trainBuilderService.setLocomotives(id, dto);
|
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')
|
@Post(':id/wagons')
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
@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 {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
@@ -10,6 +10,7 @@ import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
|||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
|
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
|
||||||
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||||
import { BuildTrainDto } from './dto/build-train.dto';
|
import { BuildTrainDto } from './dto/build-train.dto';
|
||||||
@@ -202,7 +203,6 @@ export class TrainBuilderService {
|
|||||||
const totalLengthMeters = round(
|
const totalLengthMeters = round(
|
||||||
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
|
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
|
||||||
);
|
);
|
||||||
const maxGrossTons = round(totalTareTons + totalCapacityTons);
|
|
||||||
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
|
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
|
||||||
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
|
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
|
||||||
|
|
||||||
@@ -221,14 +221,17 @@ export class TrainBuilderService {
|
|||||||
totals: {
|
totals: {
|
||||||
wagonCount: wagons.length,
|
wagonCount: wagons.length,
|
||||||
totalTareTons,
|
totalTareTons,
|
||||||
|
// Informational only — building never checks against full capacity;
|
||||||
|
// the real gross check (cargo + tare vs haul limit) runs at allocation.
|
||||||
totalCapacityTons,
|
totalCapacityTons,
|
||||||
maxGrossTons,
|
|
||||||
totalLengthMeters,
|
totalLengthMeters,
|
||||||
maxPullWeightTons,
|
maxPullWeightTons,
|
||||||
maxTrainLengthMeters,
|
maxTrainLengthMeters,
|
||||||
// Fully loaded gross vs. what the weakest locomotive can haul.
|
// Cargo the locomotives can still haul once pulling the empty consist.
|
||||||
weightUtilizationPct: maxPullWeightTons
|
payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)),
|
||||||
? round((maxGrossTons / maxPullWeightTons) * 100)
|
// Share of the haul limit consumed by the empty wagons alone.
|
||||||
|
tareUtilizationPct: maxPullWeightTons
|
||||||
|
? round((totalTareTons / maxPullWeightTons) * 100)
|
||||||
: null,
|
: null,
|
||||||
lengthUtilizationPct: maxTrainLengthMeters
|
lengthUtilizationPct: maxTrainLengthMeters
|
||||||
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
|
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
|
||||||
@@ -269,6 +272,54 @@ export class TrainBuilderService {
|
|||||||
return this.getComposition(id);
|
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. */
|
/** Append AVAILABLE wagons from the train's own yard to the consist. */
|
||||||
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
|
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -361,12 +412,8 @@ export class TrainBuilderService {
|
|||||||
.map((link) => link.locomotive)
|
.map((link) => link.locomotive)
|
||||||
.filter((loco): loco is Locomotive => Boolean(loco));
|
.filter((loco): loco is Locomotive => Boolean(loco));
|
||||||
const wagons = train.wagons ?? [];
|
const wagons = train.wagons ?? [];
|
||||||
const maxGrossTons = round(
|
const totalTareTons = round(
|
||||||
wagons.reduce(
|
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
|
||||||
(sum, w) =>
|
|
||||||
sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0),
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id: train.id,
|
id: train.id,
|
||||||
@@ -379,7 +426,7 @@ export class TrainBuilderService {
|
|||||||
: null,
|
: null,
|
||||||
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
|
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
|
||||||
wagonCount: wagons.length,
|
wagonCount: wagons.length,
|
||||||
maxGrossTons,
|
totalTareTons,
|
||||||
totalLengthMeters: round(
|
totalLengthMeters: round(
|
||||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
|||||||
|
|
||||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
import { Wagon } from './wagon.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.
|
* 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 })
|
@Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true })
|
||||||
movedByUserId?: string | null;
|
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' })
|
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||||
occurredAt!: Date;
|
occurredAt!: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
FleetManage,
|
FleetManage,
|
||||||
FleetView,
|
FleetView,
|
||||||
WagonTransferFulfill,
|
WagonTransferFulfill,
|
||||||
|
WagonTransferHistoryAll,
|
||||||
WagonTransferRequest,
|
WagonTransferRequest,
|
||||||
} from '../../common/booking-guards';
|
} from '../../common/booking-guards';
|
||||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||||
@@ -50,6 +51,30 @@ export class WagonTransferRequestsController {
|
|||||||
return this.service.listRequests(status);
|
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')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get one transfer request' })
|
@ApiOperation({ summary: 'Get one transfer request' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -6,14 +6,24 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
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 { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||||
import { Wagon } from './entities/wagon.entity';
|
import { Wagon } from './entities/wagon.entity';
|
||||||
|
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||||
import { WagonsService } from './wagons.service';
|
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 = {
|
const REQUEST_RELATIONS = {
|
||||||
fromYard: true,
|
fromYard: true,
|
||||||
toYard: true,
|
toYard: true,
|
||||||
@@ -33,6 +43,8 @@ export class WagonTransferRequestsService {
|
|||||||
private readonly requestRepo: Repository<WagonTransferRequest>,
|
private readonly requestRepo: Repository<WagonTransferRequest>,
|
||||||
@InjectRepository(Wagon)
|
@InjectRepository(Wagon)
|
||||||
private readonly wagonRepo: Repository<Wagon>,
|
private readonly wagonRepo: Repository<Wagon>,
|
||||||
|
@InjectRepository(WagonMovement)
|
||||||
|
private readonly movementRepo: Repository<WagonMovement>,
|
||||||
private readonly wagonsService: WagonsService,
|
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(
|
await this.wagonsService.bulkTransfer(
|
||||||
{ wagonIds, toYardId: request.toYardId },
|
{ wagonIds, toYardId: request.toYardId },
|
||||||
userId,
|
userId,
|
||||||
|
{ transferRequestId: request.id },
|
||||||
);
|
);
|
||||||
|
|
||||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||||
@@ -138,6 +152,38 @@ export class WagonTransferRequestsService {
|
|||||||
return this.findById(id);
|
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. */
|
/** Withdraw a still-PENDING request. */
|
||||||
async cancelRequest(id: string): Promise<WagonTransferRequest> {
|
async cancelRequest(id: string): Promise<WagonTransferRequest> {
|
||||||
const request = await this.findById(id);
|
const request = await this.findById(id);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Wagon } from './entities/wagon.entity';
|
import { Wagon } from './entities/wagon.entity';
|
||||||
|
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||||
import { Train } from '../trains/entities/train.entity';
|
import { Train } from '../trains/entities/train.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.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';
|
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Wagon, WagonTransferRequest, Train, Yard])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
Wagon,
|
||||||
|
WagonMovement,
|
||||||
|
WagonTransferRequest,
|
||||||
|
Train,
|
||||||
|
Yard,
|
||||||
|
]),
|
||||||
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
WagonsController,
|
WagonsController,
|
||||||
TrainWagonsReorderController,
|
TrainWagonsReorderController,
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export class WagonsService {
|
|||||||
async bulkTransfer(
|
async bulkTransfer(
|
||||||
dto: BulkTransferWagonsDto,
|
dto: BulkTransferWagonsDto,
|
||||||
userId?: string | null,
|
userId?: string | null,
|
||||||
|
opts?: { transferRequestId?: string | null },
|
||||||
): Promise<{ moved: number }> {
|
): Promise<{ moved: number }> {
|
||||||
const { wagonIds, toYardId } = dto;
|
const { wagonIds, toYardId } = dto;
|
||||||
if (!wagonIds.length) return { moved: 0 };
|
if (!wagonIds.length) return { moved: 0 };
|
||||||
@@ -215,6 +216,7 @@ export class WagonsService {
|
|||||||
toYardId,
|
toYardId,
|
||||||
kind: WagonMovementKind.Manual,
|
kind: WagonMovementKind.Manual,
|
||||||
movedByUserId: userId ?? null,
|
movedByUserId: userId ?? null,
|
||||||
|
transferRequestId: opts?.transferRequestId ?? null,
|
||||||
occurredAt: new Date(),
|
occurredAt: new Date(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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-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-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-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-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-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
||||||
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update 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.
|
// executes the move). Distinct keys so OCC can hold fulfil without request.
|
||||||
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
||||||
transferFulfill: 'edr_freight_app:wagons:transfer_fulfill',
|
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: {
|
trains: {
|
||||||
view: 'edr_freight_app:trains:view',
|
view: 'edr_freight_app:trains:view',
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { api } from "@/services/api";
|
|||||||
import { PageContainer } from "@/components/page";
|
import { PageContainer } from "@/components/page";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import {
|
import {
|
||||||
useContractCapacity,
|
useContractCapacity,
|
||||||
useContractDetail,
|
useContractDetail,
|
||||||
@@ -180,6 +181,9 @@ export default function GlCreateBookingForm() {
|
|||||||
}>();
|
}>();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const requestIdParam = searchParams.get("requestId");
|
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 navigate = useNavigate();
|
||||||
const { data: contract, isLoading } = useContractDetail(id);
|
const { data: contract, isLoading } = useContractDetail(id);
|
||||||
const mutations = useContractMutations(id ?? "");
|
const mutations = useContractMutations(id ?? "");
|
||||||
@@ -205,6 +209,13 @@ export default function GlCreateBookingForm() {
|
|||||||
enabled: Boolean(requestId),
|
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
|
// 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
|
// 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.
|
// 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);
|
if (bookingRequest.notes) setNotes(bookingRequest.notes);
|
||||||
}, [bookingRequest, prefilled]);
|
}, [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
|
// Seed one shipment line per contracted size exactly once — same seeding the
|
||||||
// portal form does. Subsequent renders reuse the lines.
|
// portal form does. Subsequent renders reuse the lines.
|
||||||
useEffect(() => {
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -506,12 +506,19 @@ function TrackBed() {
|
|||||||
|
|
||||||
export function TrainCompositionDiagram({
|
export function TrainCompositionDiagram({
|
||||||
locomotive,
|
locomotive,
|
||||||
|
locomotives,
|
||||||
wagons,
|
wagons,
|
||||||
freightType,
|
freightType,
|
||||||
trainNumber,
|
trainNumber,
|
||||||
totalLengthMeters,
|
totalLengthMeters,
|
||||||
}: {
|
}: {
|
||||||
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
|
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[];
|
wagons: DiagramWagonInput[];
|
||||||
freightType?: string | null;
|
freightType?: string | null;
|
||||||
trainNumber?: string | null;
|
trainNumber?: string | null;
|
||||||
@@ -519,6 +526,11 @@ export function TrainCompositionDiagram({
|
|||||||
}) {
|
}) {
|
||||||
const { ref, width } = useElementSize();
|
const { ref, width } = useElementSize();
|
||||||
|
|
||||||
|
const locos = useMemo(
|
||||||
|
() => (locomotives?.length ? locomotives : locomotive ? [locomotive] : []),
|
||||||
|
[locomotives, locomotive],
|
||||||
|
);
|
||||||
|
|
||||||
const normalized = useMemo(
|
const normalized = useMemo(
|
||||||
() => wagons.map((w) => normalizeWagon(w, freightType)),
|
() => wagons.map((w) => normalizeWagon(w, freightType)),
|
||||||
[wagons, freightType],
|
[wagons, freightType],
|
||||||
@@ -533,6 +545,11 @@ export function TrainCompositionDiagram({
|
|||||||
// ceiling the allocation engine spends from.
|
// ceiling the allocation engine spends from.
|
||||||
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
|
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
|
||||||
const grossWeight = totalWeight + totalTare;
|
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 {
|
return {
|
||||||
total: normalized.length,
|
total: normalized.length,
|
||||||
assigned,
|
assigned,
|
||||||
@@ -541,22 +558,25 @@ export function TrainCompositionDiagram({
|
|||||||
totalTare: Math.round(totalTare * 100) / 100,
|
totalTare: Math.round(totalTare * 100) / 100,
|
||||||
grossWeight: Math.round(grossWeight * 100) / 100,
|
grossWeight: Math.round(grossWeight * 100) / 100,
|
||||||
totalCapacity,
|
totalCapacity,
|
||||||
pullUtil:
|
pullLimit,
|
||||||
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
|
pullUtil: pullLimit
|
||||||
? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
|
? Math.min(100, Math.round((grossWeight / pullLimit) * 100))
|
||||||
: null,
|
: 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 perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
|
||||||
const cars = useMemo(
|
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]);
|
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
|
||||||
|
|
||||||
if (!locomotive && !wagons.length) return null;
|
if (!locos.length && !wagons.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -658,8 +678,8 @@ export function TrainCompositionDiagram({
|
|||||||
<Text size="xs" fw={700} c="edr-green.8">
|
<Text size="xs" fw={700} c="edr-green.8">
|
||||||
Locomotive load ·{" "}
|
Locomotive load ·{" "}
|
||||||
{stats.totalTare > 0
|
{stats.totalTare > 0
|
||||||
? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
? `${stats.grossWeight}T of ${stats.pullLimit}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
||||||
: `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
|
: `${stats.totalWeight}T of ${stats.pullLimit}T`}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
|
<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" }}>
|
<Group key={carIndex} gap={0} wrap="nowrap" style={{ flexDirection: reversed ? "row-reverse" : "row" }}>
|
||||||
{carIndex > 0 ? <Coupler /> : null}
|
{carIndex > 0 ? <Coupler /> : null}
|
||||||
{car.kind === "loco" ? (
|
{car.kind === "loco" ? (
|
||||||
locomotive ? (
|
<LocomotiveCar
|
||||||
<LocomotiveCar
|
code={car.l.code ?? "LOCO"}
|
||||||
code={locomotive.code ?? "LOCO"}
|
name={car.l.name}
|
||||||
name={locomotive.name}
|
maxPullWeightTons={car.l.maxPullWeightTons}
|
||||||
maxPullWeightTons={locomotive.maxPullWeightTons}
|
/>
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
) : (
|
) : (
|
||||||
<WagonCar wagon={car.w} />
|
<WagonCar wagon={car.w} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Stack,
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -17,6 +19,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
History,
|
||||||
Inbox,
|
Inbox,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
@@ -25,8 +28,13 @@ import {
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
import type {
|
||||||
|
WagonMovementRecord,
|
||||||
|
WagonTransferRequest,
|
||||||
|
} from "@/services/wagon.service";
|
||||||
|
|
||||||
export interface WagonTransferRequestsModalProps {
|
export interface WagonTransferRequestsModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@@ -57,16 +65,172 @@ const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
|
|||||||
</Group>
|
</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
|
* 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
|
* 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.
|
* (of the requested type) and execute the move, or cancel the request.
|
||||||
|
* A second tab shows per-user transfer history.
|
||||||
*/
|
*/
|
||||||
const WagonTransferRequestsModal = ({
|
const WagonTransferRequestsModal = ({
|
||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
}: WagonTransferRequestsModalProps) => {
|
}: WagonTransferRequestsModalProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [tab, setTab] = useState<string | null>("queue");
|
||||||
const [active, setActive] = useState<WagonTransferRequest | null>(null);
|
const [active, setActive] = useState<WagonTransferRequest | null>(null);
|
||||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -175,6 +339,17 @@ const WagonTransferRequestsModal = ({
|
|||||||
</Group>
|
</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 ? (
|
{!active ? (
|
||||||
// ---- Pending queue ----
|
// ---- Pending queue ----
|
||||||
isLoading ? (
|
isLoading ? (
|
||||||
@@ -337,6 +512,12 @@ const WagonTransferRequestsModal = ({
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="history">
|
||||||
|
<HistoryPanel opened={opened} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ export const FREIGHT_PERMS = {
|
|||||||
create: "edr_freight_app:wagons:create",
|
create: "edr_freight_app:wagons:create",
|
||||||
update: "edr_freight_app:wagons:update",
|
update: "edr_freight_app:wagons:update",
|
||||||
delete: "edr_freight_app:wagons:delete",
|
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: {
|
trains: {
|
||||||
view: "edr_freight_app:trains:view",
|
view: "edr_freight_app:trains:view",
|
||||||
|
|||||||
@@ -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 { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -75,6 +82,8 @@ interface ClearanceRow {
|
|||||||
freightType: string;
|
freightType: string;
|
||||||
originLabel: string;
|
originLabel: string;
|
||||||
destinationLabel: string;
|
destinationLabel: string;
|
||||||
|
/** Full ordered corridor across the contract's route legs (origin → … → destination). */
|
||||||
|
routeStops: string[];
|
||||||
contractKind: string;
|
contractKind: string;
|
||||||
serviceTypeName: string;
|
serviceTypeName: string;
|
||||||
customs: boolean;
|
customs: boolean;
|
||||||
@@ -93,6 +102,25 @@ function yardLabel(
|
|||||||
return yard.label ?? yard.name ?? yard.code ?? fallback;
|
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 {
|
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||||
const routes = [...(contract.routes ?? [])].sort(
|
const routes = [...(contract.routes ?? [])].sort(
|
||||||
(a, b) => a.sortOrder - b.sortOrder,
|
(a, b) => a.sortOrder - b.sortOrder,
|
||||||
@@ -109,6 +137,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
|||||||
freightType: contract.freightType ?? "—",
|
freightType: contract.freightType ?? "—",
|
||||||
originLabel: yardLabel(first?.originYard),
|
originLabel: yardLabel(first?.originYard),
|
||||||
destinationLabel: yardLabel(last?.destinationYard),
|
destinationLabel: yardLabel(last?.destinationYard),
|
||||||
|
routeStops: contractRouteStops(routes),
|
||||||
contractKind: contract.contractKind,
|
contractKind: contract.contractKind,
|
||||||
serviceTypeName: contract.serviceType?.serviceName ?? "—",
|
serviceTypeName: contract.serviceType?.serviceName ?? "—",
|
||||||
customs:
|
customs:
|
||||||
@@ -412,14 +441,23 @@ export default function ContractClearanceListPage() {
|
|||||||
const r = row.original;
|
const r = row.original;
|
||||||
return (
|
return (
|
||||||
<Stack gap={4} py={2}>
|
<Stack gap={4} py={2}>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="wrap">
|
||||||
<Text size="sm" fw={500} truncate maw={120}>
|
{(r.routeStops.length >= 2
|
||||||
{r.originLabel}
|
? r.routeStops
|
||||||
</Text>
|
: [r.originLabel, r.destinationLabel]
|
||||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
).map((stop, i) => (
|
||||||
<Text size="sm" fw={500} truncate maw={120}>
|
<Fragment key={i}>
|
||||||
{r.destinationLabel}
|
{i > 0 ? (
|
||||||
</Text>
|
<ArrowRight
|
||||||
|
size={14}
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{stop}
|
||||||
|
</Text>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={8} align="center">
|
<Group gap={8} align="center">
|
||||||
<DirectionIcon direction={r.tradeDirection} />
|
<DirectionIcon direction={r.tradeDirection} />
|
||||||
@@ -638,6 +676,11 @@ export default function ContractClearanceListPage() {
|
|||||||
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
|
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onRebook={(row) =>
|
||||||
|
navigate(
|
||||||
|
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
onViewContract={(contractId) =>
|
onViewContract={(contractId) =>
|
||||||
navigate(`/dashboard/contracts/clearance/${contractId}`)
|
navigate(`/dashboard/contracts/clearance/${contractId}`)
|
||||||
}
|
}
|
||||||
@@ -731,6 +774,7 @@ function ShipmentBookingsTable({
|
|||||||
canCreateBooking,
|
canCreateBooking,
|
||||||
onOpen,
|
onOpen,
|
||||||
onCreateBooking,
|
onCreateBooking,
|
||||||
|
onRebook,
|
||||||
onViewContract,
|
onViewContract,
|
||||||
}: {
|
}: {
|
||||||
rows: ShipmentBookingRow[];
|
rows: ShipmentBookingRow[];
|
||||||
@@ -739,6 +783,7 @@ function ShipmentBookingsTable({
|
|||||||
canCreateBooking: boolean;
|
canCreateBooking: boolean;
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
onCreateBooking: (row: ShipmentBookingRow) => void;
|
onCreateBooking: (row: ShipmentBookingRow) => void;
|
||||||
|
onRebook: (row: ShipmentBookingRow) => void;
|
||||||
onViewContract: (contractId: string) => void;
|
onViewContract: (contractId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
// A bare initiated instance that has cleared but not yet been created by GL.
|
// A bare initiated instance that has cleared but not yet been created by GL.
|
||||||
@@ -748,6 +793,14 @@ function ShipmentBookingsTable({
|
|||||||
!r.bookingCreated &&
|
!r.bookingCreated &&
|
||||||
r.status === "CLEARANCE_READY";
|
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>[]>(
|
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -877,6 +930,7 @@ function ShipmentBookingsTable({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
const bookable = isBookable(r);
|
const bookable = isBookable(r);
|
||||||
|
const rebookable = isRebookable(r);
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
justify="flex-end"
|
justify="flex-end"
|
||||||
@@ -896,6 +950,17 @@ function ShipmentBookingsTable({
|
|||||||
Create booking
|
Create booking
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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 shadow="md" radius="md" position="bottom-end" withinPortal>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -919,6 +984,14 @@ function ShipmentBookingsTable({
|
|||||||
Create booking
|
Create booking
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
{rebookable ? (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<RefreshCw size={14} />}
|
||||||
|
onClick={() => onRebook(r)}
|
||||||
|
>
|
||||||
|
Rebook (GL)
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
{r.contractId ? (
|
{r.contractId ? (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<ExternalLink size={14} />}
|
leftSection={<ExternalLink size={14} />}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { isAxiosError } from "axios";
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
MapPin,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Replace,
|
Replace,
|
||||||
Ruler,
|
Ruler,
|
||||||
@@ -29,9 +30,10 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
|
|
||||||
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
|
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
|
||||||
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
|
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
|
||||||
|
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
|
||||||
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
|
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
|
||||||
import TrainConsistStrip from "@/components/trainBuilder/TrainConsistStrip";
|
|
||||||
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
|
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
|
||||||
|
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
@@ -62,6 +64,7 @@ export default function TrainBuilderDetailPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [locoModalOpen, setLocoModalOpen] = useState(false);
|
const [locoModalOpen, setLocoModalOpen] = useState(false);
|
||||||
|
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||||
|
|
||||||
const compositionQuery = useQuery(
|
const compositionQuery = useQuery(
|
||||||
@@ -144,6 +147,13 @@ export default function TrainBuilderDetailPage() {
|
|||||||
>
|
>
|
||||||
Change locomotives
|
Change locomotives
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<MapPin size={15} />}
|
||||||
|
disabled={!composition.editable}
|
||||||
|
onClick={() => setYardModalOpen(true)}
|
||||||
|
>
|
||||||
|
Change yard
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
color="red"
|
color="red"
|
||||||
leftSection={<Trash2 size={15} />}
|
leftSection={<Trash2 size={15} />}
|
||||||
@@ -162,8 +172,8 @@ export default function TrainBuilderDetailPage() {
|
|||||||
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
|
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
|
||||||
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
|
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
|
||||||
{
|
{
|
||||||
label: "Max gross / haul limit",
|
label: "Payload available",
|
||||||
value: `${totals.maxGrossTons}T / ${totals.maxPullWeightTons}T`,
|
value: `${totals.payloadCapacityTons}T of ${totals.maxPullWeightTons}T`,
|
||||||
icon: Weight,
|
icon: Weight,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -180,33 +190,40 @@ export default function TrainBuilderDetailPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Card>
|
<Stack gap="sm">
|
||||||
<Stack gap="md">
|
<TrainCompositionDiagram
|
||||||
<Group justify="space-between" align="center">
|
locomotives={composition.locomotives.map((loco) => ({
|
||||||
<Text fw={600}>Consist</Text>
|
code: loco.code,
|
||||||
<Text size="xs" c="dimmed">
|
name: loco.name,
|
||||||
{composition.locomotives.length} locomotive
|
maxPullWeightTons: loco.maxPullWeightTons,
|
||||||
{composition.locomotives.length === 1 ? "" : "s"} · {totals.wagonCount} wagon
|
}))}
|
||||||
{totals.wagonCount === 1 ? "" : "s"}
|
wagons={composition.wagons.map((wagon, index) => ({
|
||||||
</Text>
|
sequenceNo: wagon.sequenceNumber ?? index + 1,
|
||||||
</Group>
|
capacityTons: wagon.wagonType?.capacityTons ?? 0,
|
||||||
<TrainConsistStrip
|
// No bookings at build time — wagons ride empty until allocation.
|
||||||
locomotives={composition.locomotives}
|
assignedWeightTons: 0,
|
||||||
wagons={composition.wagons}
|
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 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 }}>
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
<UtilizationBar label="Length utilization" pct={totals.lengthUtilizationPct} />
|
<UtilizationBar label="Length utilization" pct={totals.lengthUtilizationPct} />
|
||||||
</Grid.Col>
|
</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>
|
</Grid>
|
||||||
</Stack>
|
</Card>
|
||||||
</Card>
|
</Stack>
|
||||||
|
|
||||||
<Grid gap="lg" align="stretch">
|
<Grid gap="lg" align="stretch">
|
||||||
{composition.editable ? (
|
{composition.editable ? (
|
||||||
@@ -297,6 +314,12 @@ export default function TrainBuilderDetailPage() {
|
|||||||
onClose={() => setLocoModalOpen(false)}
|
onClose={() => setLocoModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ChangeYardModal
|
||||||
|
composition={composition}
|
||||||
|
opened={yardModalOpen}
|
||||||
|
onClose={() => setYardModalOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={disbandOpen}
|
opened={disbandOpen}
|
||||||
onClose={() => setDisbandOpen(false)}
|
onClose={() => setDisbandOpen(false)}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export default function TrainBuilderListPage() {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{row.original.wagonCount} wagons · {row.original.maxGrossTons}T ·{" "}
|
{row.original.wagonCount} wagons · {row.original.totalTareTons}T tare ·{" "}
|
||||||
{row.original.totalLengthMeters}m
|
{row.original.totalLengthMeters}m
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -838,6 +838,12 @@ export default function BatchScheduleDetailPage() {
|
|||||||
}).format(new Date(data.scheduleDate)) + " EAT"
|
}).format(new Date(data.scheduleDate)) + " EAT"
|
||||||
: "No date"}
|
: "No date"}
|
||||||
</HeroChip>
|
</HeroChip>
|
||||||
|
{data.train ? (
|
||||||
|
<HeroChip icon={<TrainFront size={12} />}>
|
||||||
|
Train {data.train.code}
|
||||||
|
{data.train.trainName ? ` — ${data.train.trainName}` : ""}
|
||||||
|
</HeroChip>
|
||||||
|
) : null}
|
||||||
{data.locomotive ? (
|
{data.locomotive ? (
|
||||||
<HeroChip icon={<TrainFront size={12} />}>
|
<HeroChip icon={<TrainFront size={12} />}>
|
||||||
Loco {data.locomotive.code} ·{" "}
|
Loco {data.locomotive.code} ·{" "}
|
||||||
|
|||||||
@@ -757,13 +757,14 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TrainCompositionDiagram
|
<TrainCompositionDiagram
|
||||||
locomotive={schedule.trainSet?.locomotive}
|
locomotive={schedule.trainSet?.locomotive}
|
||||||
|
locomotives={locomotives}
|
||||||
wagons={
|
wagons={
|
||||||
schedule.trainSet?.wagons?.length
|
schedule.trainSet?.wagons?.length
|
||||||
? schedule.trainSet.wagons
|
? schedule.trainSet.wagons
|
||||||
: displayWagonPlan
|
: displayWagonPlan
|
||||||
}
|
}
|
||||||
freightType={freightType}
|
freightType={freightType}
|
||||||
trainNumber={schedule.trainNumber}
|
trainNumber={schedule.train ? schedule.train.code : schedule.trainNumber}
|
||||||
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
|
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
|
||||||
/>
|
/>
|
||||||
<Paper
|
<Paper
|
||||||
@@ -1003,6 +1004,16 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
items={[
|
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",
|
label: locomotives.length > 1 ? "Locomotives" : "Locomotive",
|
||||||
value: locomotives.length
|
value: locomotives.length
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ import {
|
|||||||
type WagonMovementRecord,
|
type WagonMovementRecord,
|
||||||
type WagonTransferRequest,
|
type WagonTransferRequest,
|
||||||
type CreateTransferRequestPayload,
|
type CreateTransferRequestPayload,
|
||||||
|
type TransferHistory,
|
||||||
} from "./wagon.service";
|
} from "./wagon.service";
|
||||||
import { warehouseService } from "./warehouse.service";
|
import { warehouseService } from "./warehouse.service";
|
||||||
|
|
||||||
@@ -1701,6 +1702,21 @@ export const api = {
|
|||||||
undefined,
|
undefined,
|
||||||
() => [["wagonTransferRequests"]],
|
() => [["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: {
|
trains: {
|
||||||
@@ -1781,6 +1797,15 @@ export const api = {
|
|||||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
() => 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>(
|
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||||
"train-builder",
|
"train-builder",
|
||||||
"assignWagons",
|
"assignWagons",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface BuiltTrainSummary {
|
|||||||
currentYard: YardRefLite | null;
|
currentYard: YardRefLite | null;
|
||||||
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
||||||
wagonCount: number;
|
wagonCount: number;
|
||||||
maxGrossTons: number;
|
totalTareTons: number;
|
||||||
totalLengthMeters: number;
|
totalLengthMeters: number;
|
||||||
maxPullWeightTons: number;
|
maxPullWeightTons: number;
|
||||||
}
|
}
|
||||||
@@ -63,12 +63,15 @@ export interface TrainCompositionWagon {
|
|||||||
export interface TrainCompositionTotals {
|
export interface TrainCompositionTotals {
|
||||||
wagonCount: number;
|
wagonCount: number;
|
||||||
totalTareTons: number;
|
totalTareTons: number;
|
||||||
|
/** Informational only — building never checks against full capacity. */
|
||||||
totalCapacityTons: number;
|
totalCapacityTons: number;
|
||||||
maxGrossTons: number;
|
|
||||||
totalLengthMeters: number;
|
totalLengthMeters: number;
|
||||||
maxPullWeightTons: number;
|
maxPullWeightTons: number;
|
||||||
maxTrainLengthMeters: 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;
|
lengthUtilizationPct: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +129,7 @@ export interface AvailableTrain {
|
|||||||
currentYard: YardRefLite | null;
|
currentYard: YardRefLite | null;
|
||||||
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
||||||
wagonCount: number;
|
wagonCount: number;
|
||||||
maxGrossTons: number;
|
totalTareTons: number;
|
||||||
totalLengthMeters: number;
|
totalLengthMeters: number;
|
||||||
maxPullWeightTons: number;
|
maxPullWeightTons: number;
|
||||||
atOriginYard: boolean;
|
atOriginYard: boolean;
|
||||||
@@ -157,6 +160,9 @@ export const trainBuilderService = {
|
|||||||
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
||||||
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
||||||
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
|
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[]) =>
|
assignWagons: (id: string, wagonIds: string[]) =>
|
||||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||||
removeWagon: (id: string, wagonId: string) =>
|
removeWagon: (id: string, wagonId: string) =>
|
||||||
|
|||||||
@@ -55,9 +55,12 @@ export interface WagonMovementRecord {
|
|||||||
bookingId: string | null;
|
bookingId: string | null;
|
||||||
kind: Freight.WagonMovementKind;
|
kind: Freight.WagonMovementKind;
|
||||||
movedByUserId: string | null;
|
movedByUserId: string | null;
|
||||||
|
/** The transfer request this move fulfilled, when one drove it. */
|
||||||
|
transferRequestId: string | null;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
wagon?: { id: string; wagonNumber?: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const wagonService = {
|
export const wagonService = {
|
||||||
@@ -120,11 +123,25 @@ export interface CreateTransferRequestPayload {
|
|||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
|
||||||
|
export interface TransferHistory {
|
||||||
|
requests: WagonTransferRequest[];
|
||||||
|
movements: WagonMovementRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
export const wagonTransferRequestService = {
|
export const wagonTransferRequestService = {
|
||||||
list: (status?: Freight.WagonTransferRequestStatus) =>
|
list: (status?: Freight.WagonTransferRequestStatus) =>
|
||||||
apiClient.get<WagonTransferRequest[]>(
|
apiClient.get<WagonTransferRequest[]>(
|
||||||
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
|
`/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) =>
|
getById: (id: string) =>
|
||||||
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
||||||
create: (data: CreateTransferRequestPayload) =>
|
create: (data: CreateTransferRequestPayload) =>
|
||||||
|
|||||||
@@ -309,6 +309,12 @@ export interface BatchBoardSchedule {
|
|||||||
docReviewEndsAt: string | null;
|
docReviewEndsAt: string | null;
|
||||||
paymentPhaseEndsAt: string | null;
|
paymentPhaseEndsAt: string | null;
|
||||||
bookingCycleNo: number;
|
bookingCycleNo: number;
|
||||||
|
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||||
|
train: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
trainName: string | null;
|
||||||
|
} | null;
|
||||||
locomotive: {
|
locomotive: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -417,6 +423,8 @@ export interface BatchBoardScheduleDetail {
|
|||||||
docReviewEndsAt: string | null;
|
docReviewEndsAt: string | null;
|
||||||
paymentPhaseEndsAt: string | null;
|
paymentPhaseEndsAt: string | null;
|
||||||
bookingCycleNo: number;
|
bookingCycleNo: number;
|
||||||
|
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||||
|
train: BatchBoardSchedule["train"];
|
||||||
locomotive: BatchBoardSchedule["locomotive"];
|
locomotive: BatchBoardSchedule["locomotive"];
|
||||||
capacity: BatchBoardSchedule["capacity"];
|
capacity: BatchBoardSchedule["capacity"];
|
||||||
counts: BatchBoardSchedule["counts"];
|
counts: BatchBoardSchedule["counts"];
|
||||||
@@ -511,6 +519,12 @@ export interface TrainScheduleDetail {
|
|||||||
deferredBookings?: DeferredBookingRow[];
|
deferredBookings?: DeferredBookingRow[];
|
||||||
freightType?: FreightType | null;
|
freightType?: FreightType | null;
|
||||||
trainNumber?: string | 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;
|
direction?: string | null;
|
||||||
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
|
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
|
||||||
requiresLoadingConfirmation?: boolean;
|
requiresLoadingConfirmation?: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||||
import { memo, useMemo, useState } from "react";
|
import { Fragment, memo, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CountdownTimer } from "@edr/ui-common";
|
import { CountdownTimer } from "@edr/ui-common";
|
||||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||||
|
import { windowRouteStops } from "@/pages/contracts/booking-window";
|
||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
|
|
||||||
const INK = "#10202F";
|
const INK = "#10202F";
|
||||||
@@ -284,14 +285,21 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box style={{ minWidth: 0 }}>
|
<Box style={{ minWidth: 0 }}>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="wrap">
|
||||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
{windowRouteStops(w).map((stop, i) => (
|
||||||
{w.origin ?? "—"}
|
<Fragment key={i}>
|
||||||
</Text>
|
{i > 0 ? (
|
||||||
<ArrowRight size={13} color={MUTED} style={{ flexShrink: 0 }} />
|
<ArrowRight
|
||||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
size={13}
|
||||||
{w.destination ?? "—"}
|
color={MUTED}
|
||||||
</Text>
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||||
|
{stop}
|
||||||
|
</Text>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
{w.reference && (
|
{w.reference && (
|
||||||
<Text
|
<Text
|
||||||
fz={11}
|
fz={11}
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ export function ReadonlyBookingView({
|
|||||||
["PAID", "TRUCK_ASSIGNED"].includes(status));
|
["PAID", "TRUCK_ASSIGNED"].includes(status));
|
||||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||||
const isExpired = status === "EXPIRED";
|
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 isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||||
const isClearance = [
|
const isClearance = [
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
@@ -169,7 +174,7 @@ export function ReadonlyBookingView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
menuActions={{
|
menuActions={{
|
||||||
onRebook,
|
onRebook: canSelfRebook ? onRebook : undefined,
|
||||||
onSupport: () => navigate("/support"),
|
onSupport: () => navigate("/support"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -186,14 +191,18 @@ export function ReadonlyBookingView({
|
|||||||
: "This booking process has been terminated."
|
: "This booking process has been terminated."
|
||||||
}
|
}
|
||||||
reason={booking.latestChangeRequestNote}
|
reason={booking.latestChangeRequestNote}
|
||||||
onRebook={onRebook}
|
onRebook={canSelfRebook ? onRebook : undefined}
|
||||||
/>
|
/>
|
||||||
) : isExpired ? (
|
) : isExpired ? (
|
||||||
<CancelledBanner
|
<CancelledBanner
|
||||||
pillLabel="Expired"
|
pillLabel="Expired"
|
||||||
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
|
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."
|
subtitle={
|
||||||
onRebook={onRebook}
|
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 ? (
|
) : isPendingConsolidation ? (
|
||||||
<ConsolidationWaitingBanner
|
<ConsolidationWaitingBanner
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -21,7 +21,11 @@ import {
|
|||||||
import { CountdownTimer } from "@edr/ui-common";
|
import { CountdownTimer } from "@edr/ui-common";
|
||||||
|
|
||||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||||
import { formatWindowOpensAt, soonestUpcomingWindow } from "./booking-window";
|
import {
|
||||||
|
formatWindowOpensAt,
|
||||||
|
soonestUpcomingWindow,
|
||||||
|
windowRouteStops,
|
||||||
|
} from "./booking-window";
|
||||||
|
|
||||||
const INK = "#10202F";
|
const INK = "#10202F";
|
||||||
const MUTED = "#6B7C8E";
|
const MUTED = "#6B7C8E";
|
||||||
@@ -189,14 +193,21 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={6} wrap="nowrap" mt={10}>
|
<Group gap={6} wrap="wrap" mt={10}>
|
||||||
<Text fz={15} fw={700} style={{ color: INK }} truncate>
|
{windowRouteStops(w).map((stop, i) => (
|
||||||
{w.origin ?? "—"}
|
<Fragment key={i}>
|
||||||
</Text>
|
{i > 0 ? (
|
||||||
<ArrowRight size={14} color={MUTED} style={{ flexShrink: 0 }} />
|
<ArrowRight
|
||||||
<Text fz={15} fw={700} style={{ color: INK }} truncate>
|
size={14}
|
||||||
{w.destination ?? "—"}
|
color={MUTED}
|
||||||
</Text>
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Text fz={15} fw={700} style={{ color: INK }}>
|
||||||
|
{stop}
|
||||||
|
</Text>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={6} wrap="nowrap" mt={8}>
|
<Group gap={6} wrap="nowrap" mt={8}>
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ export default function NewShipmentRequestPage() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [scheduledDate, setScheduledDate] = useState("");
|
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 [notes, setNotes] = useState("");
|
||||||
|
|
||||||
const { data: contract, isLoading } = useQuery({
|
const { data: contract, isLoading } = useQuery({
|
||||||
@@ -76,6 +81,22 @@ export default function NewShipmentRequestPage() {
|
|||||||
contract.contractKind === "GENERAL" &&
|
contract.contractKind === "GENERAL" &&
|
||||||
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
|
(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 handleSubmit = () => {
|
||||||
const dto: Freight.CreateBookingRequestDto = {
|
const dto: Freight.CreateBookingRequestDto = {
|
||||||
contractRouteId: route?.id,
|
contractRouteId: route?.id,
|
||||||
@@ -84,17 +105,24 @@ export default function NewShipmentRequestPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isContainer) {
|
if (isContainer) {
|
||||||
const size = contract.cargoScope?.[0]?.containerSize ?? "20FT";
|
// One line per size the user filled; 0 (or blank) sizes are dropped.
|
||||||
dto.containers = [
|
const containers = sizes
|
||||||
{
|
.map((size) => ({ containerSize: size, quantity: Number(qtyBySize[size]) || 0 }))
|
||||||
containerSize: size,
|
.filter((line) => line.quantity > 0);
|
||||||
quantity: Number(quantity) || 1,
|
if (containers.length === 0) {
|
||||||
},
|
toast.error("Enter a quantity for at least one container size");
|
||||||
];
|
return;
|
||||||
|
}
|
||||||
|
dto.containers = containers;
|
||||||
} else {
|
} else {
|
||||||
|
const amount = Number(bulkAmount) || 0;
|
||||||
|
if (amount <= 0) {
|
||||||
|
toast.error(isPerItem ? "Enter the number of items" : "Enter the cargo weight");
|
||||||
|
return;
|
||||||
|
}
|
||||||
dto.bulk = {
|
dto.bulk = {
|
||||||
cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null,
|
cargoTypeId: bulkScope?.cargoTypeId ?? null,
|
||||||
cargoWeightTons: Number(quantity) || undefined,
|
...(isPerItem ? { itemCount: amount } : { cargoWeightTons: amount }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,17 +159,47 @@ export default function NewShipmentRequestPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<NumberInput
|
{isContainer ? (
|
||||||
label={isContainer ? "Number of containers" : "Cargo weight (tons)"}
|
<Stack gap="sm">
|
||||||
description={
|
{sizes.map((size) => (
|
||||||
hasCustoms
|
<NumberInput
|
||||||
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
|
key={size}
|
||||||
: undefined
|
label={`Number of ${size} containers`}
|
||||||
}
|
value={qtyBySize[size] ?? 0}
|
||||||
value={quantity}
|
onChange={(v) =>
|
||||||
onChange={setQuantity}
|
setQtyBySize((prev) => ({ ...prev, [size]: v }))
|
||||||
min={1}
|
}
|
||||||
/>
|
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 ? (
|
{capacity?.length ? (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
|
|||||||
return windows.some((w) => w.isOpenNow);
|
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 next upcoming (not-yet-open) window the customer should come back for —
|
||||||
* the one that OPENS soonest from now. Two guards matter here:
|
* the one that OPENS soonest from now. Two guards matter here:
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ export interface MyBookingWindow {
|
|||||||
departureDate: string;
|
departureDate: string;
|
||||||
origin: string | null;
|
origin: string | null;
|
||||||
destination: 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 {
|
export interface GeneratePriceResponse {
|
||||||
|
|||||||
@@ -327,6 +327,8 @@ export interface IWagonMovement extends BaseEntity {
|
|||||||
bookingId?: string | null;
|
bookingId?: string | null;
|
||||||
kind: WagonMovementKind;
|
kind: WagonMovementKind;
|
||||||
movedByUserId?: string | null;
|
movedByUserId?: string | null;
|
||||||
|
/** The transfer request this move fulfilled, when it came from one. */
|
||||||
|
transferRequestId?: string | null;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user