This commit is contained in:
Marshal
2026-07-14 13:10:00 +00:00
parent 6d0cf50b4d
commit b5a97d344a
36 changed files with 1101 additions and 355 deletions

View File

@@ -53,22 +53,23 @@ import {
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
// Disabled seeds — imports commented out with their provider/injection/run below.
// import { DemoUsersSeeder } from "./seed/demo-users.seeder";
// import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
// import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
// import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
// import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules
@@ -192,21 +193,22 @@ import { LoggerMiddleware } from "./logger.middleware";
providers: [
EdrOrgSeeder,
FreightPositionsSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
GovCompaniesSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
Batch5TestDataSeeder,
Batch7TestDataSeeder,
Batch8TestDataSeeder,
WarehouseDemoSeeder,
ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder,
// Disabled seeds — providers commented out (imports/injection/run too):
// DemoUsersSeeder,
// FreightStaffUsersSeeder,
// PricingDataSeeder,
// DemoFreightDataSeeder,
// GovCompaniesSeeder,
// IndodeFacilitySeeder,
// Batch14TestDataSeeder,
// Batch5TestDataSeeder,
// Batch7TestDataSeeder,
// Batch8TestDataSeeder,
// WarehouseDemoSeeder,
// ExportDjiboutiInterchangeDemoSeeder,
// MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
],
@@ -216,51 +218,66 @@ export class AppModule implements OnApplicationBootstrap {
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
private readonly govCompaniesSeeder: GovCompaniesSeeder,
// Disabled seeds — injections commented out (imports/provider/run too):
// private readonly demoUsersSeeder: DemoUsersSeeder,
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
// private readonly pricingDataSeeder: PricingDataSeeder,
// private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
// private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
// private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
// private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
// private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
// private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
// private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
// private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
// private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
// private readonly govCompaniesSeeder: GovCompaniesSeeder,
) { }
async onApplicationBootstrap() {
// ── Enabled: permissions + file-upload settings (+ dropdown settings) only ──
// Everything else below is intentionally disabled. Seeders stay registered
// as providers and injected; only their .run() calls are commented out, so
// re-enabling any of them is a one-line uncomment.
// Permissions foundation — keep enabled:
// freightPermissionKeyMigration → renames legacy permission keys
// seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
// edrOrgSeeder → seeds org/unit + the Permission catalog
// freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.freightPositionsSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
await this.pricingDataSeeder.run();
// File upload settings — keep enabled.
await this.fileUploadSettingsSeeder.run();
await this.indodeFacilitySeeder.run();
await this.batch14TestDataSeeder.run();
await this.batch5TestDataSeeder.run();
await this.batch7TestDataSeeder.run();
await this.batch8TestDataSeeder.run();
await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.run();
await this.marshallingDemoTrainsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
// FileUploadSettingsSeeder) are intentionally disabled — they stay
// registered as providers but are not run. Re-inject + call .run() to enable.
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
// rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
// Government entities (with importer/exporter profiles) that government
// bookings bill to. Idempotent — keyed by fixed IDs.
await this.govCompaniesSeeder.run();
// Dropdown settings are not seeded on boot; run them with
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
// ── Disabled: demo / test / reference data seeds ──
// Uncomment a line to re-enable that seed.
// await this.demoUsersSeeder.run();
// await this.freightStaffUsersSeeder.run();
// await this.pricingDataSeeder.run();
// await this.indodeFacilitySeeder.run();
// await this.batch14TestDataSeeder.run();
// await this.batch5TestDataSeeder.run();
// await this.batch7TestDataSeeder.run();
// await this.batch8TestDataSeeder.run();
// await this.warehouseDemoSeeder.run();
// await this.exportDjiboutiInterchangeDemoSeeder.run();
// await this.marshallingDemoTrainsSeeder.run();
// demoFreightDataSeeder seeds ONLY the 4 staff users (wagons + approval
// rules are already disabled inside the seeder).
// await this.demoFreightDataSeeder.run();
// Government entities (importer/exporter profiles) that government bookings
// bill to. Idempotent — keyed by fixed IDs.
// await this.govCompaniesSeeder.run();
}
configure(consumer: MiddlewareConsumer) {

View File

@@ -34,6 +34,10 @@ export const WagonTransferRequest = () =>
export const WagonTransferFulfill = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferFulfill);
/** Admin: read every staffer's wagon-transfer history (not just one's own). */
export const WagonTransferHistoryAll = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);

View File

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

View File

@@ -208,6 +208,8 @@ export interface BatchBoardScheduleDetail {
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train: BatchBoardSchedule["train"];
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
@@ -235,6 +237,12 @@ export interface BatchBoardSchedule {
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train: {
id: string;
code: string;
trainName: string | null;
} | null;
locomotive: {
code: string;
name: string | null;
@@ -877,7 +885,7 @@ export class BookingBatchService implements OnModuleInit {
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: {
trainSet: { locomotive: true },
trainSet: { locomotive: true, train: true },
originStation: true,
destinationStation: true,
// Yards supply the route's display name for `routeName` below;
@@ -1128,6 +1136,13 @@ export class BookingBatchService implements OnModuleInit {
? s.paymentPhaseEndsAt.toISOString()
: null,
bookingCycleNo: s.bookingCycleNo ?? 0,
train: s.trainSet?.train
? {
id: s.trainSet.train.id,
code: s.trainSet.train.code,
trainName: s.trainSet.train.trainName ?? null,
}
: null,
locomotive: loco
? {
code: loco.code,
@@ -1247,6 +1262,13 @@ export class BookingBatchService implements OnModuleInit {
? s.paymentPhaseEndsAt.toISOString()
: null,
bookingCycleNo: s.bookingCycleNo ?? 0,
train: s.trainSet?.train
? {
id: s.trainSet.train.id,
code: s.trainSet.train.code,
trainName: s.trainSet.train.trainName ?? null,
}
: null,
locomotive: loco
? {
code: loco.code,

View File

@@ -282,6 +282,8 @@ interface BookingWindowRow {
origin_code: string | null;
destination_label: string | null;
destination_code: string | null;
/** Full ordered corridor (origin → milestones → destination) from the schedule's route. */
route_stations: string[] | null;
}
@Injectable()
@@ -1329,9 +1331,15 @@ export class TrainSchedulingService {
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
const lengthCapWithOverage =
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) {
// The locomotives pull GROSS weight: the customers' cargo plus 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(
`Train set locomotives cannot pull ${totalWeightTons}T`,
`Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`,
);
}
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
@@ -4605,14 +4613,8 @@ export class TrainSchedulingService {
name: link.locomotive!.name ?? null,
})),
wagonCount: wagons.length,
maxGrossTons: roundTons(
wagons.reduce(
(sum, w) =>
sum +
(Number(w.wagonType?.tareWeightTons) || 0) +
(Number(w.wagonType?.capacityTons) || 0),
0,
),
totalTareTons: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
),
totalLengthMeters: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
@@ -4725,7 +4727,11 @@ export class TrainSchedulingService {
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code
dy.label AS destination_label, dy.code AS destination_code,
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
FROM freight.route_milestones rm
JOIN freight.yards rmy ON rmy.id = rm.yard_id
WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
LEFT JOIN freight.contract_routes cr
ON cr.deleted_at IS NULL
@@ -4777,7 +4783,11 @@ export class TrainSchedulingService {
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code
dy.label AS destination_label, dy.code AS destination_code,
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
FROM freight.route_milestones rm
JOIN freight.yards rmy ON rmy.id = rm.yard_id
WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
JOIN freight.contract_routes cr
ON cr.contract_id = $1
@@ -4823,7 +4833,11 @@ export class TrainSchedulingService {
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code
dy.label AS destination_label, dy.code AS destination_code,
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
FROM freight.route_milestones rm
JOIN freight.yards rmy ON rmy.id = rm.yard_id
WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
@@ -4845,6 +4859,17 @@ export class TrainSchedulingService {
}
private mapBookingWindowRow(r: BookingWindowRow) {
const origin = r.origin_label ?? r.origin_code ?? null;
const destination = r.destination_label ?? r.destination_code ?? null;
// Full corridor from the route's milestones (origin → stops → destination).
// Falls back to the schedule's origin/destination when no milestones exist.
const milestoneStops = (r.route_stations ?? []).filter(
(s): s is string => Boolean(s),
);
const routeStations =
milestoneStops.length >= 2
? milestoneStops
: [origin, destination].filter((s): s is string => Boolean(s));
return {
scheduleId: r.schedule_id,
reference: r.reference ?? null,
@@ -4860,8 +4885,9 @@ export class TrainSchedulingService {
bookingWindowStatus: r.booking_window_status,
bookingCycleNo: r.booking_cycle_no,
departureDate: r.scheduled_departure_date,
origin: r.origin_label ?? r.origin_code ?? null,
destination: r.destination_label ?? r.destination_code ?? null,
origin,
destination,
routeStations,
};
}

View File

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

View File

@@ -7,6 +7,7 @@ import {
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
@@ -19,6 +20,7 @@ import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
import { TrainBuilderService } from './train-builder.service';
@ApiTags('train-builder')
@@ -57,6 +59,15 @@ export class TrainBuilderController {
return this.trainBuilderService.setLocomotives(id, dto);
}
@Patch(':id/yard')
@FleetManage()
@ApiOperation({
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
})
setYard(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTrainYardDto) {
return this.trainBuilderService.setYard(id, dto.currentYardId);
}
@Post(':id/wagons')
@FleetManage()
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })

View File

@@ -1,4 +1,4 @@
import { Freight, WagonStatus } from '@edr/types';
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
import {
BadRequestException,
ConflictException,
@@ -10,6 +10,7 @@ import { DataSource, EntityManager, ILike, In } from 'typeorm';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
@@ -202,7 +203,6 @@ export class TrainBuilderService {
const totalLengthMeters = round(
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
);
const maxGrossTons = round(totalTareTons + totalCapacityTons);
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
@@ -221,14 +221,17 @@ export class TrainBuilderService {
totals: {
wagonCount: wagons.length,
totalTareTons,
// Informational only — building never checks against full capacity;
// the real gross check (cargo + tare vs haul limit) runs at allocation.
totalCapacityTons,
maxGrossTons,
totalLengthMeters,
maxPullWeightTons,
maxTrainLengthMeters,
// Fully loaded gross vs. what the weakest locomotive can haul.
weightUtilizationPct: maxPullWeightTons
? round((maxGrossTons / maxPullWeightTons) * 100)
// Cargo the locomotives can still haul once pulling the empty consist.
payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)),
// Share of the haul limit consumed by the empty wagons alone.
tareUtilizationPct: maxPullWeightTons
? round((totalTareTons / maxPullWeightTons) * 100)
: null,
lengthUtilizationPct: maxTrainLengthMeters
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
@@ -269,6 +272,54 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/**
* Relocate the train to another yard. The consist moves as one unit: every
* coupled locomotive and wagon follows to the new yard (so their current
* yards always match the train's), and each wagon gets a movement-ledger row.
* Blocked while the train is out on a dispatched run.
*/
async setYard(id: string, currentYardId: string) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
if (train.currentYardId === currentYardId) return;
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
await manager.getRepository(Train).update(train.id, { currentYardId: yard.id });
const links = await manager
.getRepository(TrainLocomotive)
.find({ where: { trainId: train.id } });
if (links.length) {
await manager
.getRepository(Locomotive)
.update(
{ id: In(links.map((link) => link.locomotiveId)) },
{ currentYardId: yard.id },
);
}
const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } });
const now = new Date();
for (const wagon of wagons) {
if (wagon.currentYardId === yard.id) continue;
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
// Ledger row keeps the wagon's yard history auditable (mirrors the
// manual-relocation path in the wagons service).
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId: wagon.id,
fromYardId: wagon.currentYardId ?? null,
toYardId: yard.id,
kind: WagonMovementKind.Manual,
occurredAt: now,
}),
);
}
});
return this.getComposition(id);
}
/** Append AVAILABLE wagons from the train's own yard to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
@@ -361,12 +412,8 @@ export class TrainBuilderService {
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
const wagons = train.wagons ?? [];
const maxGrossTons = round(
wagons.reduce(
(sum, w) =>
sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0),
0,
),
const totalTareTons = round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
);
return {
id: train.id,
@@ -379,7 +426,7 @@ export class TrainBuilderService {
: null,
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
wagonCount: wagons.length,
maxGrossTons,
totalTareTons,
totalLengthMeters: round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),

View File

@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Wagon } from './wagon.entity';
import { WagonTransferRequest } from './wagon-transfer-request.entity';
/**
* Ledger of every physical wagon relocation between yards — one row per move.
@@ -51,6 +52,14 @@ export class WagonMovement extends BaseEntity {
@Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true })
movedByUserId?: string | null;
/** The transfer request this move fulfilled, when it came from one. */
@Column({ name: 'transfer_request_id', type: 'uuid', nullable: true })
transferRequestId?: string | null;
@ManyToOne(() => WagonTransferRequest, { nullable: true })
@JoinColumn({ name: 'transfer_request_id' })
transferRequest?: WagonTransferRequest | null;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;

View File

@@ -16,6 +16,7 @@ import {
FleetManage,
FleetView,
WagonTransferFulfill,
WagonTransferHistoryAll,
WagonTransferRequest,
} from '../../common/booking-guards';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
@@ -50,6 +51,30 @@ export class WagonTransferRequestsController {
return this.service.listRequests(status);
}
// NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
// matches in declaration order, so `/history` would otherwise be captured by
// the `:id` param route (and rejected by ParseUUIDPipe).
@Get('history')
@ApiOperation({
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
})
myHistory(@CurrentUser() user: TCurrentUser) {
// Never fall through to the all-staff view: getHistory(undefined) means
// "everyone", so a missing caller id must return empty, not leak scope.
if (!user?.id) return { requests: [], movements: [] };
return this.service.getHistory(user.id);
}
@Get('history/all')
@WagonTransferHistoryAll()
@ApiQuery({ name: 'userId', required: false })
@ApiOperation({
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
})
allHistory(@Query('userId') userId?: string) {
return this.service.getHistory(userId);
}
@Get(':id')
@ApiOperation({ summary: 'Get one transfer request' })
findOne(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -6,14 +6,24 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
import { Wagon } from './entities/wagon.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { WagonsService } from './wagons.service';
/** Bundled per-user activity: requests they touched + wagons they moved. */
export interface TransferHistory {
requests: WagonTransferRequest[];
movements: WagonMovement[];
}
/** How many ledger rows the history returns at most (newest first). */
const HISTORY_LIMIT = 500;
const REQUEST_RELATIONS = {
fromYard: true,
toYard: true,
@@ -33,6 +43,8 @@ export class WagonTransferRequestsService {
private readonly requestRepo: Repository<WagonTransferRequest>,
@InjectRepository(Wagon)
private readonly wagonRepo: Repository<Wagon>,
@InjectRepository(WagonMovement)
private readonly movementRepo: Repository<WagonMovement>,
private readonly wagonsService: WagonsService,
) {}
@@ -125,10 +137,12 @@ export class WagonTransferRequestsService {
);
}
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows).
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
// each stamped with this request's id so history can link them back).
await this.wagonsService.bulkTransfer(
{ wagonIds, toYardId: request.toYardId },
userId,
{ transferRequestId: request.id },
);
request.status = WagonTransferRequestStatus.Fulfilled;
@@ -138,6 +152,38 @@ export class WagonTransferRequestsService {
return this.findById(id);
}
/**
* Per-user transfer history: the requests a user filed OR fulfilled, plus the
* individual wagons they physically moved (linked back to their request when
* one drove the move). Pass a `userId` to scope to one staffer; pass
* `undefined` for the admin all-staff view. Scope is decided by the CALLER
* (the controller passes the caller's id unless they hold the history-all
* permission) — this method trusts its argument.
*/
async getHistory(userId?: string | null): Promise<TransferHistory> {
const requests = await this.requestRepo.find({
where: userId
? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
: {},
relations: REQUEST_RELATIONS,
order: { createdAt: 'DESC' },
take: HISTORY_LIMIT,
});
const movements = await this.movementRepo.find({
// Own view: moves I made. All view: every user-attributed move (skip the
// system-written loaded/reposition legs that carry no mover).
where: userId
? { movedByUserId: userId }
: { movedByUserId: Not(IsNull()) },
relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
order: { occurredAt: 'DESC' },
take: HISTORY_LIMIT,
});
return { requests, movements };
}
/** Withdraw a still-PENDING request. */
async cancelRequest(id: string): Promise<WagonTransferRequest> {
const request = await this.findById(id);

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Wagon } from './entities/wagon.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -10,7 +11,15 @@ import { WagonsService } from './wagons.service';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
@Module({
imports: [TypeOrmModule.forFeature([Wagon, WagonTransferRequest, Train, Yard])],
imports: [
TypeOrmModule.forFeature([
Wagon,
WagonMovement,
WagonTransferRequest,
Train,
Yard,
]),
],
controllers: [
WagonsController,
TrainWagonsReorderController,

View File

@@ -180,6 +180,7 @@ export class WagonsService {
async bulkTransfer(
dto: BulkTransferWagonsDto,
userId?: string | null,
opts?: { transferRequestId?: string | null },
): Promise<{ moved: number }> {
const { wagonIds, toYardId } = dto;
if (!wagonIds.length) return { moved: 0 };
@@ -215,6 +216,7 @@ export class WagonsService {
toYardId,
kind: WagonMovementKind.Manual,
movedByUserId: userId ?? null,
transferRequestId: opts?.transferRequestId ?? null,
occurredAt: new Date(),
}),
);

View File

@@ -177,6 +177,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'),
perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'),
perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'),
perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"),
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
@@ -446,6 +447,9 @@ export const FREIGHT_PERMS = {
// executes the move). Distinct keys so OCC can hold fulfil without request.
transferRequest: 'edr_freight_app:wagons:transfer_request',
transferFulfill: 'edr_freight_app:wagons:transfer_fulfill',
// Admin: read every staffer's transfer history. Without it, a user only sees
// their own (the /history endpoint uses the caller id, backend-enforced).
transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all',
},
trains: {
view: 'edr_freight_app:trains:view',

View File

@@ -49,6 +49,7 @@ import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import {
useContractCapacity,
useContractDetail,
@@ -180,6 +181,9 @@ export default function GlCreateBookingForm() {
}>();
const [searchParams] = useSearchParams();
const requestIdParam = searchParams.get("requestId");
// Rebook: copy an EXPIRED booking's cargo into a fresh booking on the same
// contract (GL only picks a new schedule). Set by the clearance Rebook action.
const copyFromParam = searchParams.get("copyFrom");
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
@@ -205,6 +209,13 @@ export default function GlCreateBookingForm() {
enabled: Boolean(requestId),
});
// The expired booking a Rebook is copying from (its cargo seeds the form).
const { data: copyFromBooking } = useQuery({
queryKey: ["rebook-copy-from", copyFromParam],
queryFn: () => bookingsService.getById(copyFromParam!),
enabled: Boolean(copyFromParam),
});
// Same window-gating the customer sees: booking is only allowed while a
// window is OPEN for one of the contract's routes. Intercity contracts are
// never window-gated — the shipment rides a passing train staff pick later.
@@ -363,6 +374,28 @@ export default function GlCreateBookingForm() {
if (bookingRequest.notes) setNotes(bookingRequest.notes);
}, [bookingRequest, prefilled]);
// Rebook seed: copy the source booking's container lines once. (Bulk weight /
// item count isn't on the booking payload yet, so bulk rebooks fall through to
// the normal contract seed and GL re-enters the quantity.)
useEffect(() => {
if (!copyFromBooking || prefilled) return;
const lines = copyFromBooking.bookingContainers ?? [];
if (!lines.length) return;
setPrefilled(true);
setContainerLines(
lines.map((c) => {
const qty = Math.max(1, c.quantity);
return {
containerSize: String(c.containerType?.sizeFt ?? ""),
quantity: String(qty),
hazardousQuantity: "0",
reeferQuantity: "0",
units: Array.from({ length: qty }, emptyUnit),
};
}),
);
}, [copyFromBooking, prefilled]);
// Seed one shipment line per contracted size exactly once — same seeding the
// portal form does. Subsequent renders reuse the lines.
useEffect(() => {

View File

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

View File

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

View File

@@ -506,12 +506,19 @@ function TrackBed() {
export function TrainCompositionDiagram({
locomotive,
locomotives,
wagons,
freightType,
trainNumber,
totalLengthMeters,
}: {
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
/** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */
locomotives?: Array<{
code?: string | null;
name?: string | null;
maxPullWeightTons?: number | null;
}> | null;
wagons: DiagramWagonInput[];
freightType?: string | null;
trainNumber?: string | null;
@@ -519,6 +526,11 @@ export function TrainCompositionDiagram({
}) {
const { ref, width } = useElementSize();
const locos = useMemo(
() => (locomotives?.length ? locomotives : locomotive ? [locomotive] : []),
[locomotives, locomotive],
);
const normalized = useMemo(
() => wagons.map((w) => normalizeWagon(w, freightType)),
[wagons, freightType],
@@ -533,6 +545,11 @@ export function TrainCompositionDiagram({
// ceiling the allocation engine spends from.
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
const grossWeight = totalWeight + totalTare;
// Weakest locomotive caps the set — same rule the allocation engine applies.
const pullLimits = locos
.map((l) => Number(l.maxPullWeightTons))
.filter((v) => Number.isFinite(v) && v > 0);
const pullLimit = pullLimits.length ? Math.min(...pullLimits) : null;
return {
total: normalized.length,
assigned,
@@ -541,22 +558,25 @@ export function TrainCompositionDiagram({
totalTare: Math.round(totalTare * 100) / 100,
grossWeight: Math.round(grossWeight * 100) / 100,
totalCapacity,
pullUtil:
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
: null,
pullLimit,
pullUtil: pullLimit
? Math.min(100, Math.round((grossWeight / pullLimit) * 100))
: null,
};
}, [normalized, locomotive]);
}, [normalized, locos]);
// cars-per-row from measured width; locomotive counts as one car
// cars-per-row from measured width; each locomotive counts as one car
const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
const cars = useMemo(
() => [{ kind: "loco" as const }, ...normalized.map((w) => ({ kind: "wagon" as const, w }))],
[normalized],
() => [
...locos.map((l) => ({ kind: "loco" as const, l })),
...normalized.map((w) => ({ kind: "wagon" as const, w })),
],
[locos, normalized],
);
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
if (!locomotive && !wagons.length) return null;
if (!locos.length && !wagons.length) return null;
return (
<Paper
@@ -658,8 +678,8 @@ export function TrainCompositionDiagram({
<Text size="xs" fw={700} c="edr-green.8">
Locomotive load ·{" "}
{stats.totalTare > 0
? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
: `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
? `${stats.grossWeight}T of ${stats.pullLimit}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
: `${stats.totalWeight}T of ${stats.pullLimit}T`}
</Text>
</Group>
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
@@ -702,13 +722,11 @@ export function TrainCompositionDiagram({
<Group key={carIndex} gap={0} wrap="nowrap" style={{ flexDirection: reversed ? "row-reverse" : "row" }}>
{carIndex > 0 ? <Coupler /> : null}
{car.kind === "loco" ? (
locomotive ? (
<LocomotiveCar
code={locomotive.code ?? "LOCO"}
name={locomotive.name}
maxPullWeightTons={locomotive.maxPullWeightTons}
/>
) : null
<LocomotiveCar
code={car.l.code ?? "LOCO"}
name={car.l.name}
maxPullWeightTons={car.l.maxPullWeightTons}
/>
) : (
<WagonCar wagon={car.w} />
)}

View File

@@ -10,6 +10,8 @@ import {
Modal,
ScrollArea,
Stack,
Switch,
Tabs,
Text,
ThemeIcon,
} from "@mantine/core";
@@ -17,6 +19,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowRight,
ChevronLeft,
History,
Inbox,
PackageCheck,
Warehouse,
@@ -25,8 +28,13 @@ import {
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import type { WagonTransferRequest } from "@/services/wagon.service";
import type {
WagonMovementRecord,
WagonTransferRequest,
} from "@/services/wagon.service";
export interface WagonTransferRequestsModalProps {
opened: boolean;
@@ -57,16 +65,172 @@ const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
</Group>
);
const STATUS_COLOR: Record<string, string> = {
PENDING: "gray",
FULFILLED: "teal",
CANCELLED: "red",
};
const fmtDateTime = (iso: string) =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
/**
* Per-user transfer history. A staffer sees their OWN activity — the requests
* they filed or fulfilled, and the individual wagons they moved. Holders of
* `transfer_history_all` get an "All staff" toggle that widens the view; the
* backend enforces the scope regardless of the toggle.
*/
function HistoryPanel({ opened }: { opened: boolean }) {
const { user } = useAuth();
const canSeeAll = hasPermission(
user,
FREIGHT_PERMS.wagons.transferHistoryAll,
);
const myId = (user as { id?: string } | null | undefined)?.id;
const [allStaff, setAllStaff] = useState(false);
const scopeAll = canSeeAll && allStaff;
const mine = useQuery({
...api.wagonTransferRequests.history.queryOptions(),
enabled: opened && !scopeAll,
});
const all = useQuery({
...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }),
enabled: opened && scopeAll,
});
const source = scopeAll ? all : mine;
const requests = source.data?.requests ?? [];
const movements: WagonMovementRecord[] = source.data?.movements ?? [];
const roleBadge = (r: WagonTransferRequest) => {
if (myId && r.fulfilledByUserId === myId)
return (
<Badge size="xs" variant="light" color="blue">
fulfilled
</Badge>
);
if (myId && r.requestedByUserId === myId)
return (
<Badge size="xs" variant="light" color="grape">
requested
</Badge>
);
return null;
};
return (
<Stack gap="lg">
{canSeeAll ? (
<Group justify="flex-end">
<Switch
checked={allStaff}
onChange={(e) => setAllStaff(e.currentTarget.checked)}
label="All staff"
color="edr-green"
/>
</Group>
) : null}
{source.isLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : (
<>
<div>
<Text fw={700} size="sm" mb={8}>
Requests{scopeAll ? "" : " you touched"}
</Text>
{requests.length === 0 ? (
<Text size="sm" c="dimmed">
No requests yet.
</Text>
) : (
<Stack gap={6}>
{requests.map((r) => (
<Card key={r.id} withBorder radius="md" padding="xs">
<Group justify="space-between" wrap="nowrap">
<RequestSummary r={r} />
<Group gap={8} wrap="nowrap">
{roleBadge(r)}
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[r.status] ?? "gray"}
>
{r.status.toLowerCase()}
</Badge>
</Group>
</Group>
</Card>
))}
</Stack>
)}
</div>
<Divider />
<div>
<Text fw={700} size="sm" mb={8}>
Wagons moved
</Text>
{movements.length === 0 ? (
<Text size="sm" c="dimmed">
No wagon moves yet.
</Text>
) : (
<ScrollArea.Autosize mah={260}>
<Stack gap={6}>
{movements.map((m) => (
<Card key={m.id} withBorder radius="md" padding="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Text fw={600} size="sm">
{m.wagon?.wagonNumber ?? "Wagon"}
</Text>
<Text size="xs" c="dimmed" truncate>
{yardLabel(m.fromYard)} {yardLabel(m.toYard)}
</Text>
{m.transferRequestId ? (
<Badge size="xs" variant="light" color="teal">
from request
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{fmtDateTime(m.occurredAt)}
</Text>
</Group>
</Card>
))}
</Stack>
</ScrollArea.Autosize>
)}
</div>
</>
)}
</Stack>
);
}
/**
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
* one to hand-pick exactly the requested number of wagons from the source yard
* (of the requested type) and execute the move, or cancel the request.
* A second tab shows per-user transfer history.
*/
const WagonTransferRequestsModal = ({
opened,
onClose,
}: WagonTransferRequestsModalProps) => {
const { toast } = useToast();
const [tab, setTab] = useState<string | null>("queue");
const [active, setActive] = useState<WagonTransferRequest | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
@@ -175,6 +339,17 @@ const WagonTransferRequestsModal = ({
</Group>
}
>
<Tabs value={tab} onChange={setTab} keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="queue" leftSection={<Inbox size={14} />}>
Queue
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="queue">
{!active ? (
// ---- Pending queue ----
isLoading ? (
@@ -337,6 +512,12 @@ const WagonTransferRequestsModal = ({
</Group>
</Stack>
)}
</Tabs.Panel>
<Tabs.Panel value="history">
<HistoryPanel opened={opened} />
</Tabs.Panel>
</Tabs>
</Modal>
);
};

View File

@@ -106,6 +106,9 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:wagons:create",
update: "edr_freight_app:wagons:update",
delete: "edr_freight_app:wagons:delete",
transferRequest: "edr_freight_app:wagons:transfer_request",
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
},
trains: {
view: "edr_freight_app:trains:view",

View File

@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
@@ -75,6 +82,8 @@ interface ClearanceRow {
freightType: string;
originLabel: string;
destinationLabel: string;
/** Full ordered corridor across the contract's route legs (origin → … → destination). */
routeStops: string[];
contractKind: string;
serviceTypeName: string;
customs: boolean;
@@ -93,6 +102,25 @@ function yardLabel(
return yard.label ?? yard.name ?? yard.code ?? fallback;
}
/**
* Chain the contract's ordered route legs into one corridor of stops —
* origin of the first leg, then each leg's destination (Djibouti → Adama →
* Dire Dawa). A leg whose origin differs from the previous destination inserts
* that stop too, so gapped route lists stay readable.
*/
function contractRouteStops(routes: Freight.IContractRoute[]): string[] {
const stops: string[] = [];
for (const r of routes) {
const origin = yardLabel(r.originYard);
const destination = yardLabel(r.destinationYard);
if (stops.length === 0 || stops[stops.length - 1] !== origin) {
stops.push(origin);
}
stops.push(destination);
}
return stops;
}
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
@@ -109,6 +137,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
freightType: contract.freightType ?? "—",
originLabel: yardLabel(first?.originYard),
destinationLabel: yardLabel(last?.destinationYard),
routeStops: contractRouteStops(routes),
contractKind: contract.contractKind,
serviceTypeName: contract.serviceType?.serviceName ?? "—",
customs:
@@ -412,14 +441,23 @@ export default function ContractClearanceListPage() {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={500} truncate maw={120}>
{r.originLabel}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500} truncate maw={120}>
{r.destinationLabel}
</Text>
<Group gap={6} wrap="wrap">
{(r.routeStops.length >= 2
? r.routeStops
: [r.originLabel, r.destinationLabel]
).map((stop, i) => (
<Fragment key={i}>
{i > 0 ? (
<ArrowRight
size={14}
className="shrink-0 text-muted-foreground"
/>
) : null}
<Text size="sm" fw={500}>
{stop}
</Text>
</Fragment>
))}
</Group>
<Group gap={8} align="center">
<DirectionIcon direction={r.tradeDirection} />
@@ -638,6 +676,11 @@ export default function ContractClearanceListPage() {
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
)
}
onRebook={(row) =>
navigate(
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
)
}
onViewContract={(contractId) =>
navigate(`/dashboard/contracts/clearance/${contractId}`)
}
@@ -731,6 +774,7 @@ function ShipmentBookingsTable({
canCreateBooking,
onOpen,
onCreateBooking,
onRebook,
onViewContract,
}: {
rows: ShipmentBookingRow[];
@@ -739,6 +783,7 @@ function ShipmentBookingsTable({
canCreateBooking: boolean;
onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
onRebook: (row: ShipmentBookingRow) => void;
onViewContract: (contractId: string) => void;
}) {
// A bare initiated instance that has cleared but not yet been created by GL.
@@ -748,6 +793,14 @@ function ShipmentBookingsTable({
!r.bookingCreated &&
r.status === "CLEARANCE_READY";
// A customs shipment whose booking lost its slot — GL rebooks it (customer
// can't self-rebook a customs booking). Copies the expired booking's cargo.
const isRebookable = (r: ShipmentBookingRow) =>
canCreateBooking &&
Boolean(r.contractId) &&
r.customs &&
r.status === "EXPIRED";
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
() => [
{
@@ -877,6 +930,7 @@ function ShipmentBookingsTable({
cell: ({ row }) => {
const r = row.original;
const bookable = isBookable(r);
const rebookable = isRebookable(r);
return (
<Group
justify="flex-end"
@@ -896,6 +950,17 @@ function ShipmentBookingsTable({
Create booking
</Button>
) : null}
{rebookable ? (
<Button
size="compact-sm"
color="grape"
radius="md"
leftSection={<RefreshCw size={14} />}
onClick={() => onRebook(r)}
>
Rebook
</Button>
) : null}
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
@@ -919,6 +984,14 @@ function ShipmentBookingsTable({
Create booking
</Menu.Item>
) : null}
{rebookable ? (
<Menu.Item
leftSection={<RefreshCw size={14} />}
onClick={() => onRebook(r)}
>
Rebook (GL)
</Menu.Item>
) : null}
{r.contractId ? (
<Menu.Item
leftSection={<ExternalLink size={14} />}

View File

@@ -16,6 +16,7 @@ import { isAxiosError } from "axios";
import {
AlertTriangle,
CalendarClock,
MapPin,
MoreHorizontal,
Replace,
Ruler,
@@ -29,9 +30,10 @@ import { useNavigate, useParams } from "react-router-dom";
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import TrainConsistStrip from "@/components/trainBuilder/TrainConsistStrip";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
@@ -62,6 +64,7 @@ export default function TrainBuilderDetailPage() {
const navigate = useNavigate();
const { toast } = useToast();
const [locoModalOpen, setLocoModalOpen] = useState(false);
const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const compositionQuery = useQuery(
@@ -144,6 +147,13 @@ export default function TrainBuilderDetailPage() {
>
Change locomotives
</Menu.Item>
<Menu.Item
leftSection={<MapPin size={15} />}
disabled={!composition.editable}
onClick={() => setYardModalOpen(true)}
>
Change yard
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={15} />}
@@ -162,8 +172,8 @@ export default function TrainBuilderDetailPage() {
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
{
label: "Max gross / haul limit",
value: `${totals.maxGrossTons}T / ${totals.maxPullWeightTons}T`,
label: "Payload available",
value: `${totals.payloadCapacityTons}T of ${totals.maxPullWeightTons}T`,
icon: Weight,
},
{
@@ -180,33 +190,40 @@ export default function TrainBuilderDetailPage() {
</Alert>
) : null}
<Card>
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600}>Consist</Text>
<Text size="xs" c="dimmed">
{composition.locomotives.length} locomotive
{composition.locomotives.length === 1 ? "" : "s"} · {totals.wagonCount} wagon
{totals.wagonCount === 1 ? "" : "s"}
</Text>
</Group>
<TrainConsistStrip
locomotives={composition.locomotives}
wagons={composition.wagons}
/>
<Stack gap="sm">
<TrainCompositionDiagram
locomotives={composition.locomotives.map((loco) => ({
code: loco.code,
name: loco.name,
maxPullWeightTons: loco.maxPullWeightTons,
}))}
wagons={composition.wagons.map((wagon, index) => ({
sequenceNo: wagon.sequenceNumber ?? index + 1,
capacityTons: wagon.wagonType?.capacityTons ?? 0,
// No bookings at build time — wagons ride empty until allocation.
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
wagonTypeCode: wagon.wagonType?.code ?? null,
physicalWagonNumber: wagon.wagonNumber,
allocations: [],
}))}
trainNumber={composition.code}
totalLengthMeters={totals.totalLengthMeters}
/>
<Card>
<Grid gap="lg">
<Grid.Col span={{ base: 12, sm: 6 }}>
<UtilizationBar
label="Weight utilization (fully loaded)"
pct={totals.weightUtilizationPct}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<UtilizationBar label="Length utilization" pct={totals.lengthUtilizationPct} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Text size="xs" c="dimmed" mt={4}>
The real weight check happens at allocation: booked cargo weight plus
wagon tare (gross) must stay within the locomotives' haul limit.
</Text>
</Grid.Col>
</Grid>
</Stack>
</Card>
</Card>
</Stack>
<Grid gap="lg" align="stretch">
{composition.editable ? (
@@ -297,6 +314,12 @@ export default function TrainBuilderDetailPage() {
onClose={() => setLocoModalOpen(false)}
/>
<ChangeYardModal
composition={composition}
opened={yardModalOpen}
onClose={() => setYardModalOpen(false)}
/>
<Modal
opened={disbandOpen}
onClose={() => setDisbandOpen(false)}

View File

@@ -183,7 +183,7 @@ export default function TrainBuilderListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm">
{row.original.wagonCount} wagons · {row.original.maxGrossTons}T ·{" "}
{row.original.wagonCount} wagons · {row.original.totalTareTons}T tare ·{" "}
{row.original.totalLengthMeters}m
</Text>
),

View File

@@ -838,6 +838,12 @@ export default function BatchScheduleDetailPage() {
}).format(new Date(data.scheduleDate)) + " EAT"
: "No date"}
</HeroChip>
{data.train ? (
<HeroChip icon={<TrainFront size={12} />}>
Train {data.train.code}
{data.train.trainName ? `${data.train.trainName}` : ""}
</HeroChip>
) : null}
{data.locomotive ? (
<HeroChip icon={<TrainFront size={12} />}>
Loco {data.locomotive.code} ·{" "}

View File

@@ -757,13 +757,14 @@ export default function TrainScheduleV2DetailPage() {
<Stack gap="md">
<TrainCompositionDiagram
locomotive={schedule.trainSet?.locomotive}
locomotives={locomotives}
wagons={
schedule.trainSet?.wagons?.length
? schedule.trainSet.wagons
: displayWagonPlan
}
freightType={freightType}
trainNumber={schedule.trainNumber}
trainNumber={schedule.train ? schedule.train.code : schedule.trainNumber}
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
/>
<Paper
@@ -1003,6 +1004,16 @@ export default function TrainScheduleV2DetailPage() {
<KpiStrip
items={[
...(schedule.train
? [
{
label: "Train",
value: schedule.train.code,
hint: schedule.train.trainName ?? "Built train (Train Builder)",
icon: Train,
},
]
: []),
{
label: locomotives.length > 1 ? "Locomotives" : "Locomotive",
value: locomotives.length

View File

@@ -200,6 +200,7 @@ import {
type WagonMovementRecord,
type WagonTransferRequest,
type CreateTransferRequestPayload,
type TransferHistory,
} from "./wagon.service";
import { warehouseService } from "./warehouse.service";
@@ -1701,6 +1702,21 @@ export const api = {
undefined,
() => [["wagonTransferRequests"]],
),
history: endpoint<void, TransferHistory>(
"wagonTransferRequests",
"history",
() => wagonTransferRequestService.myHistory().then((r) => r.data),
() => ["wagonTransferRequests", "history", "mine"],
),
historyAll: endpoint<{ userId?: string }, TransferHistory>(
"wagonTransferRequests",
"historyAll",
({ userId }) =>
wagonTransferRequestService.allHistory(userId).then((r) => r.data),
({ userId }) => ["wagonTransferRequests", "history", "all", userId ?? ""],
),
},
trains: {
@@ -1781,6 +1797,15 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
"train-builder",
"setYard",
({ id, currentYardId }) =>
trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"assignWagons",

View File

@@ -26,7 +26,7 @@ export interface BuiltTrainSummary {
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
wagonCount: number;
maxGrossTons: number;
totalTareTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
}
@@ -63,12 +63,15 @@ export interface TrainCompositionWagon {
export interface TrainCompositionTotals {
wagonCount: number;
totalTareTons: number;
/** Informational only — building never checks against full capacity. */
totalCapacityTons: number;
maxGrossTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
weightUtilizationPct: number | null;
/** Cargo the locomotives can still haul once pulling the empty consist. */
payloadCapacityTons: number;
/** Share of the haul limit consumed by the empty wagons alone. */
tareUtilizationPct: number | null;
lengthUtilizationPct: number | null;
}
@@ -126,7 +129,7 @@ export interface AvailableTrain {
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
wagonCount: number;
maxGrossTons: number;
totalTareTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
atOriginYard: boolean;
@@ -157,6 +160,9 @@ export const trainBuilderService = {
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
setLocomotives: (id: string, locomotiveIds: string[]) =>
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
/** Relocate the train — coupled locomotives and wagons move with it. */
setYard: (id: string, currentYardId: string) =>
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),
assignWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>

View File

@@ -55,9 +55,12 @@ export interface WagonMovementRecord {
bookingId: string | null;
kind: Freight.WagonMovementKind;
movedByUserId: string | null;
/** The transfer request this move fulfilled, when one drove it. */
transferRequestId: string | null;
occurredAt: string;
note: string | null;
createdAt: string;
wagon?: { id: string; wagonNumber?: string } | null;
}
export const wagonService = {
@@ -120,11 +123,25 @@ export interface CreateTransferRequestPayload {
note?: string;
}
/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
export interface TransferHistory {
requests: WagonTransferRequest[];
movements: WagonMovementRecord[];
}
export const wagonTransferRequestService = {
list: (status?: Freight.WagonTransferRequestStatus) =>
apiClient.get<WagonTransferRequest[]>(
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
),
/** The caller's own history (both roles: requests they filed and fulfilled). */
myHistory: () =>
apiClient.get<TransferHistory>('/wagon-transfer-requests/history'),
/** Admin: any/all staff's history (optional userId filter). */
allHistory: (userId?: string) =>
apiClient.get<TransferHistory>(
`/wagon-transfer-requests/history/all${userId ? `?userId=${userId}` : ''}`,
),
getById: (id: string) =>
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
create: (data: CreateTransferRequestPayload) =>

View File

@@ -309,6 +309,12 @@ export interface BatchBoardSchedule {
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train: {
id: string;
code: string;
trainName: string | null;
} | null;
locomotive: {
code: string;
name: string | null;
@@ -417,6 +423,8 @@ export interface BatchBoardScheduleDetail {
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train: BatchBoardSchedule["train"];
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
@@ -511,6 +519,12 @@ export interface TrainScheduleDetail {
deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null;
trainNumber?: string | null;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train?: {
id: string;
code: string;
trainName?: string | null;
} | null;
direction?: string | null;
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
requiresLoadingConfirmation?: boolean;

View File

@@ -1,5 +1,5 @@
import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo, useMemo, useState } from "react";
import { Fragment, memo, useMemo, useState } from "react";
import {
ArrowRight,
CalendarClock,
@@ -8,6 +8,7 @@ import {
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import { windowRouteStops } from "@/pages/contracts/booking-window";
import { Card } from "./Card";
const INK = "#10202F";
@@ -284,14 +285,21 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
}}
>
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{w.origin ?? "—"}
</Text>
<ArrowRight size={13} color={MUTED} style={{ flexShrink: 0 }} />
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{w.destination ?? "—"}
</Text>
<Group gap={6} wrap="wrap">
{windowRouteStops(w).map((stop, i) => (
<Fragment key={i}>
{i > 0 ? (
<ArrowRight
size={13}
color={MUTED}
style={{ flexShrink: 0 }}
/>
) : null}
<Text fz={14} fw={700} style={{ color: INK }}>
{stop}
</Text>
</Fragment>
))}
{w.reference && (
<Text
fz={11}

View File

@@ -128,6 +128,11 @@ export function ReadonlyBookingView({
);
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
// Customs (Path B) bookings are created AND rebooked by Global Logistics, not
// the customer. Hide the customer's rebook everywhere and tell them GL will
// handle it. Non-customs bookings stay self-service.
const isCustoms = Boolean(booking.customsClearingEnabled);
const canSelfRebook = !isCustoms;
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
const isClearance = [
"AWAITING_DOCUMENTS",
@@ -164,7 +169,7 @@ export function ReadonlyBookingView({
)
}
menuActions={{
onRebook,
onRebook: canSelfRebook ? onRebook : undefined,
onSupport: () => navigate("/support"),
}}
/>
@@ -181,14 +186,18 @@ export function ReadonlyBookingView({
: "This booking process has been terminated."
}
reason={booking.latestChangeRequestNote}
onRebook={onRebook}
onRebook={canSelfRebook ? onRebook : undefined}
/>
) : isExpired ? (
<CancelledBanner
pillLabel="Expired"
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
onRebook={onRebook}
subtitle={
isCustoms
? "Payment wasn't completed in time, so this booking lost its slot. Global Logistics will rebook this shipment for you — no action is needed on your side."
: "Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
}
onRebook={canSelfRebook ? onRebook : undefined}
/>
) : isPendingConsolidation ? (
<ConsolidationWaitingBanner

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import {
ActionIcon,
Badge,
@@ -21,7 +21,11 @@ import {
import { CountdownTimer } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import { formatWindowOpensAt, soonestUpcomingWindow } from "./booking-window";
import {
formatWindowOpensAt,
soonestUpcomingWindow,
windowRouteStops,
} from "./booking-window";
const INK = "#10202F";
const MUTED = "#6B7C8E";
@@ -189,14 +193,21 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
</Badge>
</Group>
<Group gap={6} wrap="nowrap" mt={10}>
<Text fz={15} fw={700} style={{ color: INK }} truncate>
{w.origin ?? "—"}
</Text>
<ArrowRight size={14} color={MUTED} style={{ flexShrink: 0 }} />
<Text fz={15} fw={700} style={{ color: INK }} truncate>
{w.destination ?? "—"}
</Text>
<Group gap={6} wrap="wrap" mt={10}>
{windowRouteStops(w).map((stop, i) => (
<Fragment key={i}>
{i > 0 ? (
<ArrowRight
size={14}
color={MUTED}
style={{ flexShrink: 0 }}
/>
) : null}
<Text fz={15} fw={700} style={{ color: INK }}>
{stop}
</Text>
</Fragment>
))}
</Group>
<Group gap={6} wrap="nowrap" mt={8}>

View File

@@ -26,7 +26,12 @@ export default function NewShipmentRequestPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [scheduledDate, setScheduledDate] = useState("");
const [quantity, setQuantity] = useState<number | string>(1);
// Container contracts: one quantity per enabled size (e.g. 20ft + 40ft).
const [qtyBySize, setQtyBySize] = useState<Record<string, number | string>>(
{},
);
// Bulk contracts: a single amount — tons (PER_TON) or item count (PER_ITEM).
const [bulkAmount, setBulkAmount] = useState<number | string>("");
const [notes, setNotes] = useState("");
const { data: contract, isLoading } = useQuery({
@@ -76,6 +81,22 @@ export default function NewShipmentRequestPage() {
contract.contractKind === "GENERAL" &&
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
const SIZE_ORDER = ["20ft", "40ft"];
const enabledSizes = SIZE_ORDER.filter((s) =>
contract.cargoScope?.some(
(l) => (l.containerSize ?? "").toLowerCase() === s,
),
);
// A CONTAINER contract should always carry scope lines; fall back to both.
const sizes = enabledSizes.length ? enabledSizes : SIZE_ORDER;
// Bulk: pick the bulk scope line and read how it's measured.
const bulkScope =
contract.cargoScope?.find((l) => !l.containerSize) ??
contract.cargoScope?.[0];
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
const handleSubmit = () => {
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
@@ -84,17 +105,24 @@ export default function NewShipmentRequestPage() {
};
if (isContainer) {
const size = contract.cargoScope?.[0]?.containerSize ?? "20FT";
dto.containers = [
{
containerSize: size,
quantity: Number(quantity) || 1,
},
];
// One line per size the user filled; 0 (or blank) sizes are dropped.
const containers = sizes
.map((size) => ({ containerSize: size, quantity: Number(qtyBySize[size]) || 0 }))
.filter((line) => line.quantity > 0);
if (containers.length === 0) {
toast.error("Enter a quantity for at least one container size");
return;
}
dto.containers = containers;
} else {
const amount = Number(bulkAmount) || 0;
if (amount <= 0) {
toast.error(isPerItem ? "Enter the number of items" : "Enter the cargo weight");
return;
}
dto.bulk = {
cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null,
cargoWeightTons: Number(quantity) || undefined,
cargoTypeId: bulkScope?.cargoTypeId ?? null,
...(isPerItem ? { itemCount: amount } : { cargoWeightTons: amount }),
};
}
@@ -131,17 +159,47 @@ export default function NewShipmentRequestPage() {
/>
)}
<NumberInput
label={isContainer ? "Number of containers" : "Cargo weight (tons)"}
description={
hasCustoms
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
: undefined
}
value={quantity}
onChange={setQuantity}
min={1}
/>
{isContainer ? (
<Stack gap="sm">
{sizes.map((size) => (
<NumberInput
key={size}
label={`Number of ${size} containers`}
value={qtyBySize[size] ?? 0}
onChange={(v) =>
setQtyBySize((prev) => ({ ...prev, [size]: v }))
}
min={0}
allowDecimal={false}
/>
))}
{sizes.length > 1 ? (
<Text size="xs" c="dimmed">
Enter a quantity for each size you need leave a size at 0 if
you don&apos;t need it.
</Text>
) : null}
{hasCustoms ? (
<Text size="xs" c="dimmed">
Global Logistics schedules the shipment date during customs
clearance you only state the quantity.
</Text>
) : null}
</Stack>
) : (
<NumberInput
label={isPerItem ? "Number of items" : "Cargo weight (tons)"}
description={
hasCustoms
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
: undefined
}
value={bulkAmount}
onChange={setBulkAmount}
min={0}
allowDecimal={!isPerItem}
/>
)}
{capacity?.length ? (
<Text size="xs" c="dimmed">

View File

@@ -25,6 +25,16 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
return windows.some((w) => w.isOpenNow);
}
/**
* Full ordered corridor for a window — every stop from origin through the
* intermediate milestones to the destination. Falls back to origin/destination
* when the backend sends no milestone chain (older schedules, routeless windows).
*/
export function windowRouteStops(w: MyBookingWindow): string[] {
if (w.routeStations && w.routeStations.length >= 2) return w.routeStations;
return [w.origin ?? "—", w.destination ?? "—"];
}
/**
* The next upcoming (not-yet-open) window the customer should come back for —
* the one that OPENS soonest from now. Two guards matter here:

View File

@@ -94,6 +94,12 @@ export interface MyBookingWindow {
departureDate: string;
origin: string | null;
destination: string | null;
/**
* Full ordered corridor for the window's route — origin, every intermediate
* milestone stop, then destination (e.g. Djibouti → Adama → Dire Dawa).
* Falls back to [origin, destination] when the route has no milestones.
*/
routeStations: string[];
}
export interface GeneratePriceResponse {

View File

@@ -327,6 +327,8 @@ export interface IWagonMovement extends BaseEntity {
bookingId?: string | null;
kind: WagonMovementKind;
movedByUserId?: string | null;
/** The transfer request this move fulfilled, when it came from one. */
transferRequestId?: string | null;
occurredAt: string;
note?: string | null;
}