Merge pull request #679 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-14 16:11:59 +03:00
committed by GitHub
36 changed files with 1101 additions and 359 deletions

View File

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

View File

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

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

View File

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

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, 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" })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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-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',

View File

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

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({ 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} />
)} )}

View File

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

View File

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

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 { 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} />}

View File

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

View File

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

View File

@@ -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} ·{" "}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&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 ? ( {capacity?.length ? (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">

View File

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

View File

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

View File

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