mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
train
This commit is contained in:
@@ -53,22 +53,23 @@ import {
|
||||
} from "./seed/edr-freight.seed";
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||
// Disabled seeds — imports commented out with their provider/injection/run below.
|
||||
// import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
// import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||
import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||
// import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||
// import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||
// import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
@@ -192,21 +193,22 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
FreightPositionsSeeder,
|
||||
DemoUsersSeeder,
|
||||
FreightStaffUsersSeeder,
|
||||
PricingDataSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
DemoFreightDataSeeder,
|
||||
GovCompaniesSeeder,
|
||||
IndodeFacilitySeeder,
|
||||
Batch14TestDataSeeder,
|
||||
Batch5TestDataSeeder,
|
||||
Batch7TestDataSeeder,
|
||||
Batch8TestDataSeeder,
|
||||
WarehouseDemoSeeder,
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
MarshallingDemoTrainsSeeder,
|
||||
// Disabled seeds — providers commented out (imports/injection/run too):
|
||||
// DemoUsersSeeder,
|
||||
// FreightStaffUsersSeeder,
|
||||
// PricingDataSeeder,
|
||||
// DemoFreightDataSeeder,
|
||||
// GovCompaniesSeeder,
|
||||
// IndodeFacilitySeeder,
|
||||
// Batch14TestDataSeeder,
|
||||
// Batch5TestDataSeeder,
|
||||
// Batch7TestDataSeeder,
|
||||
// Batch8TestDataSeeder,
|
||||
// WarehouseDemoSeeder,
|
||||
// ExportDjiboutiInterchangeDemoSeeder,
|
||||
// MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidImportExportMileDemoSeeder,
|
||||
],
|
||||
@@ -216,51 +218,66 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
|
||||
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
|
||||
private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
|
||||
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
// Disabled seeds — injections commented out (imports/provider/run too):
|
||||
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
// private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
// private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
|
||||
// private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
|
||||
// private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
|
||||
// private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||
// private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||
// private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
// private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
// private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
// private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
// private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
) { }
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
// ── Enabled: permissions + file-upload settings (+ dropdown settings) only ──
|
||||
// Everything else below is intentionally disabled. Seeders stay registered
|
||||
// as providers and injected; only their .run() calls are commented out, so
|
||||
// re-enabling any of them is a one-line uncomment.
|
||||
|
||||
// Permissions foundation — keep enabled:
|
||||
// freightPermissionKeyMigration → renames legacy permission keys
|
||||
// seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
|
||||
// edrOrgSeeder → seeds org/unit + the Permission catalog
|
||||
// freightPositionsSeeder → seeds Position + PositionPermission rows
|
||||
// (depends on edrOrgSeeder, must run after)
|
||||
await this.freightPermissionKeyMigrationSeeder.run();
|
||||
await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
|
||||
// File upload settings — keep enabled.
|
||||
await this.fileUploadSettingsSeeder.run();
|
||||
await this.indodeFacilitySeeder.run();
|
||||
await this.batch14TestDataSeeder.run();
|
||||
await this.batch5TestDataSeeder.run();
|
||||
await this.batch7TestDataSeeder.run();
|
||||
await this.batch8TestDataSeeder.run();
|
||||
await this.warehouseDemoSeeder.run();
|
||||
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
await this.marshallingDemoTrainsSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
// FileUploadSettingsSeeder) are intentionally disabled — they stay
|
||||
// registered as providers but are not run. Re-inject + call .run() to enable.
|
||||
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
||||
// rules are disabled inside the seeder). Kept running for the staff users.
|
||||
await this.demoFreightDataSeeder.run();
|
||||
// Government entities (with importer/exporter profiles) that government
|
||||
// bookings bill to. Idempotent — keyed by fixed IDs.
|
||||
await this.govCompaniesSeeder.run();
|
||||
|
||||
// Dropdown settings are not seeded on boot; run them with
|
||||
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
|
||||
|
||||
// ── Disabled: demo / test / reference data seeds ──
|
||||
// Uncomment a line to re-enable that seed.
|
||||
// await this.demoUsersSeeder.run();
|
||||
// await this.freightStaffUsersSeeder.run();
|
||||
// await this.pricingDataSeeder.run();
|
||||
// await this.indodeFacilitySeeder.run();
|
||||
// await this.batch14TestDataSeeder.run();
|
||||
// await this.batch5TestDataSeeder.run();
|
||||
// await this.batch7TestDataSeeder.run();
|
||||
// await this.batch8TestDataSeeder.run();
|
||||
// await this.warehouseDemoSeeder.run();
|
||||
// await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
// await this.marshallingDemoTrainsSeeder.run();
|
||||
// demoFreightDataSeeder seeds ONLY the 4 staff users (wagons + approval
|
||||
// rules are already disabled inside the seeder).
|
||||
// await this.demoFreightDataSeeder.run();
|
||||
// Government entities (importer/exporter profiles) that government bookings
|
||||
// bill to. Idempotent — keyed by fixed IDs.
|
||||
// await this.govCompaniesSeeder.run();
|
||||
}
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
@@ -34,6 +34,10 @@ export const WagonTransferRequest = () =>
|
||||
export const WagonTransferFulfill = () =>
|
||||
BookingStaff(FREIGHT_PERMS.wagons.transferFulfill);
|
||||
|
||||
/** Admin: read every staffer's wagon-transfer history (not just one's own). */
|
||||
export const WagonTransferHistoryAll = () =>
|
||||
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||
|
||||
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Link each physical wagon move back to the transfer request that drove it, so
|
||||
* the history can show "Request S→K, 3× NX70 → wagons W101, W102, W103".
|
||||
* Nullable — legacy moves and non-request manual corrections carry no request.
|
||||
* Also indexes `moved_by_user_id` for the per-user history queries.
|
||||
*/
|
||||
export class LinkWagonMovementToTransferRequest2180000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'LinkWagonMovementToTransferRequest2180000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_movements
|
||||
ADD COLUMN IF NOT EXISTS transfer_request_id uuid NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_wm_transfer_request'
|
||||
) THEN
|
||||
ALTER TABLE freight.wagon_movements
|
||||
ADD CONSTRAINT fk_wm_transfer_request
|
||||
FOREIGN KEY (transfer_request_id)
|
||||
REFERENCES freight.wagon_transfer_requests (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wm_transfer_request
|
||||
ON freight.wagon_movements (transfer_request_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wm_moved_by
|
||||
ON freight.wagon_movements (moved_by_user_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_moved_by`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_transfer_request`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_movements
|
||||
DROP CONSTRAINT IF EXISTS fk_wm_transfer_request
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_movements
|
||||
DROP COLUMN IF EXISTS transfer_request_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,8 @@ export interface BatchBoardScheduleDetail {
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train: BatchBoardSchedule["train"];
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
@@ -235,6 +237,12 @@ export interface BatchBoardSchedule {
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train: {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
} | null;
|
||||
locomotive: {
|
||||
code: string;
|
||||
name: string | null;
|
||||
@@ -877,7 +885,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||||
where,
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
trainSet: { locomotive: true, train: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
// Yards supply the route's display name for `routeName` below;
|
||||
@@ -1128,6 +1136,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? s.paymentPhaseEndsAt.toISOString()
|
||||
: null,
|
||||
bookingCycleNo: s.bookingCycleNo ?? 0,
|
||||
train: s.trainSet?.train
|
||||
? {
|
||||
id: s.trainSet.train.id,
|
||||
code: s.trainSet.train.code,
|
||||
trainName: s.trainSet.train.trainName ?? null,
|
||||
}
|
||||
: null,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
@@ -1247,6 +1262,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? s.paymentPhaseEndsAt.toISOString()
|
||||
: null,
|
||||
bookingCycleNo: s.bookingCycleNo ?? 0,
|
||||
train: s.trainSet?.train
|
||||
? {
|
||||
id: s.trainSet.train.id,
|
||||
code: s.trainSet.train.code,
|
||||
trainName: s.trainSet.train.trainName ?? null,
|
||||
}
|
||||
: null,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
|
||||
@@ -282,6 +282,8 @@ interface BookingWindowRow {
|
||||
origin_code: string | null;
|
||||
destination_label: string | null;
|
||||
destination_code: string | null;
|
||||
/** Full ordered corridor (origin → milestones → destination) from the schedule's route. */
|
||||
route_stations: string[] | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -1329,9 +1331,15 @@ export class TrainSchedulingService {
|
||||
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
|
||||
const lengthCapWithOverage =
|
||||
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
|
||||
if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) {
|
||||
// The locomotives pull GROSS weight: the customers' cargo plus the empty
|
||||
// weight of every planned wagon — cargo-only comparison understates the load.
|
||||
const planTareTons = roundTons(
|
||||
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
||||
);
|
||||
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
|
||||
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
|
||||
throw new BadRequestException(
|
||||
`Train set locomotives cannot pull ${totalWeightTons}T`,
|
||||
`Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`,
|
||||
);
|
||||
}
|
||||
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
|
||||
@@ -4605,14 +4613,8 @@ export class TrainSchedulingService {
|
||||
name: link.locomotive!.name ?? null,
|
||||
})),
|
||||
wagonCount: wagons.length,
|
||||
maxGrossTons: roundTons(
|
||||
wagons.reduce(
|
||||
(sum, w) =>
|
||||
sum +
|
||||
(Number(w.wagonType?.tareWeightTons) || 0) +
|
||||
(Number(w.wagonType?.capacityTons) || 0),
|
||||
0,
|
||||
),
|
||||
totalTareTons: roundTons(
|
||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
|
||||
),
|
||||
totalLengthMeters: roundTons(
|
||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
||||
@@ -4725,7 +4727,11 @@ export class TrainSchedulingService {
|
||||
ts.booking_cycle_no,
|
||||
ts.scheduled_departure_date,
|
||||
oy.label AS origin_label, oy.code AS origin_code,
|
||||
dy.label AS destination_label, dy.code AS destination_code
|
||||
dy.label AS destination_label, dy.code AS destination_code,
|
||||
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
|
||||
FROM freight.route_milestones rm
|
||||
JOIN freight.yards rmy ON rmy.id = rm.yard_id
|
||||
WHERE rm.route_id = ts.route_id) AS route_stations
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.contract_routes cr
|
||||
ON cr.deleted_at IS NULL
|
||||
@@ -4777,7 +4783,11 @@ export class TrainSchedulingService {
|
||||
ts.booking_cycle_no,
|
||||
ts.scheduled_departure_date,
|
||||
oy.label AS origin_label, oy.code AS origin_code,
|
||||
dy.label AS destination_label, dy.code AS destination_code
|
||||
dy.label AS destination_label, dy.code AS destination_code,
|
||||
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
|
||||
FROM freight.route_milestones rm
|
||||
JOIN freight.yards rmy ON rmy.id = rm.yard_id
|
||||
WHERE rm.route_id = ts.route_id) AS route_stations
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.contract_routes cr
|
||||
ON cr.contract_id = $1
|
||||
@@ -4823,7 +4833,11 @@ export class TrainSchedulingService {
|
||||
ts.booking_cycle_no,
|
||||
ts.scheduled_departure_date,
|
||||
oy.label AS origin_label, oy.code AS origin_code,
|
||||
dy.label AS destination_label, dy.code AS destination_code
|
||||
dy.label AS destination_label, dy.code AS destination_code,
|
||||
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
|
||||
FROM freight.route_milestones rm
|
||||
JOIN freight.yards rmy ON rmy.id = rm.yard_id
|
||||
WHERE rm.route_id = ts.route_id) AS route_stations
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
@@ -4845,6 +4859,17 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private mapBookingWindowRow(r: BookingWindowRow) {
|
||||
const origin = r.origin_label ?? r.origin_code ?? null;
|
||||
const destination = r.destination_label ?? r.destination_code ?? null;
|
||||
// Full corridor from the route's milestones (origin → stops → destination).
|
||||
// Falls back to the schedule's origin/destination when no milestones exist.
|
||||
const milestoneStops = (r.route_stations ?? []).filter(
|
||||
(s): s is string => Boolean(s),
|
||||
);
|
||||
const routeStations =
|
||||
milestoneStops.length >= 2
|
||||
? milestoneStops
|
||||
: [origin, destination].filter((s): s is string => Boolean(s));
|
||||
return {
|
||||
scheduleId: r.schedule_id,
|
||||
reference: r.reference ?? null,
|
||||
@@ -4860,8 +4885,9 @@ export class TrainSchedulingService {
|
||||
bookingWindowStatus: r.booking_window_status,
|
||||
bookingCycleNo: r.booking_cycle_no,
|
||||
departureDate: r.scheduled_departure_date,
|
||||
origin: r.origin_label ?? r.origin_code ?? null,
|
||||
destination: r.destination_label ?? r.destination_code ?? null,
|
||||
origin,
|
||||
destination,
|
||||
routeStations,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateTrainYardDto {
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Yard the train now sits in. The coupled locomotives and wagons are relocated with it.',
|
||||
})
|
||||
@IsUUID()
|
||||
currentYardId!: string;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
@@ -19,6 +20,7 @@ import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
|
||||
@ApiTags('train-builder')
|
||||
@@ -57,6 +59,15 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.setLocomotives(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/yard')
|
||||
@FleetManage()
|
||||
@ApiOperation({
|
||||
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
|
||||
})
|
||||
setYard(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTrainYardDto) {
|
||||
return this.trainBuilderService.setYard(id, dto.currentYardId);
|
||||
}
|
||||
|
||||
@Post(':id/wagons')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Freight, WagonStatus } from '@edr/types';
|
||||
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
@@ -10,6 +10,7 @@ import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
@@ -202,7 +203,6 @@ export class TrainBuilderService {
|
||||
const totalLengthMeters = round(
|
||||
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
|
||||
);
|
||||
const maxGrossTons = round(totalTareTons + totalCapacityTons);
|
||||
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
|
||||
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
|
||||
|
||||
@@ -221,14 +221,17 @@ export class TrainBuilderService {
|
||||
totals: {
|
||||
wagonCount: wagons.length,
|
||||
totalTareTons,
|
||||
// Informational only — building never checks against full capacity;
|
||||
// the real gross check (cargo + tare vs haul limit) runs at allocation.
|
||||
totalCapacityTons,
|
||||
maxGrossTons,
|
||||
totalLengthMeters,
|
||||
maxPullWeightTons,
|
||||
maxTrainLengthMeters,
|
||||
// Fully loaded gross vs. what the weakest locomotive can haul.
|
||||
weightUtilizationPct: maxPullWeightTons
|
||||
? round((maxGrossTons / maxPullWeightTons) * 100)
|
||||
// Cargo the locomotives can still haul once pulling the empty consist.
|
||||
payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)),
|
||||
// Share of the haul limit consumed by the empty wagons alone.
|
||||
tareUtilizationPct: maxPullWeightTons
|
||||
? round((totalTareTons / maxPullWeightTons) * 100)
|
||||
: null,
|
||||
lengthUtilizationPct: maxTrainLengthMeters
|
||||
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
|
||||
@@ -269,6 +272,54 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocate the train to another yard. The consist moves as one unit: every
|
||||
* coupled locomotive and wagon follows to the new yard (so their current
|
||||
* yards always match the train's), and each wagon gets a movement-ledger row.
|
||||
* Blocked while the train is out on a dispatched run.
|
||||
*/
|
||||
async setYard(id: string, currentYardId: string) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
if (train.currentYardId === currentYardId) return;
|
||||
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
|
||||
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
|
||||
|
||||
await manager.getRepository(Train).update(train.id, { currentYardId: yard.id });
|
||||
|
||||
const links = await manager
|
||||
.getRepository(TrainLocomotive)
|
||||
.find({ where: { trainId: train.id } });
|
||||
if (links.length) {
|
||||
await manager
|
||||
.getRepository(Locomotive)
|
||||
.update(
|
||||
{ id: In(links.map((link) => link.locomotiveId)) },
|
||||
{ currentYardId: yard.id },
|
||||
);
|
||||
}
|
||||
|
||||
const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } });
|
||||
const now = new Date();
|
||||
for (const wagon of wagons) {
|
||||
if (wagon.currentYardId === yard.id) continue;
|
||||
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
|
||||
// Ledger row keeps the wagon's yard history auditable (mirrors the
|
||||
// manual-relocation path in the wagons service).
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: wagon.id,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toYardId: yard.id,
|
||||
kind: WagonMovementKind.Manual,
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Append AVAILABLE wagons from the train's own yard to the consist. */
|
||||
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -361,12 +412,8 @@ export class TrainBuilderService {
|
||||
.map((link) => link.locomotive)
|
||||
.filter((loco): loco is Locomotive => Boolean(loco));
|
||||
const wagons = train.wagons ?? [];
|
||||
const maxGrossTons = round(
|
||||
wagons.reduce(
|
||||
(sum, w) =>
|
||||
sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0),
|
||||
0,
|
||||
),
|
||||
const totalTareTons = round(
|
||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
|
||||
);
|
||||
return {
|
||||
id: train.id,
|
||||
@@ -379,7 +426,7 @@ export class TrainBuilderService {
|
||||
: null,
|
||||
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
|
||||
wagonCount: wagons.length,
|
||||
maxGrossTons,
|
||||
totalTareTons,
|
||||
totalLengthMeters: round(
|
||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Wagon } from './wagon.entity';
|
||||
import { WagonTransferRequest } from './wagon-transfer-request.entity';
|
||||
|
||||
/**
|
||||
* Ledger of every physical wagon relocation between yards — one row per move.
|
||||
@@ -51,6 +52,14 @@ export class WagonMovement extends BaseEntity {
|
||||
@Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true })
|
||||
movedByUserId?: string | null;
|
||||
|
||||
/** The transfer request this move fulfilled, when it came from one. */
|
||||
@Column({ name: 'transfer_request_id', type: 'uuid', nullable: true })
|
||||
transferRequestId?: string | null;
|
||||
|
||||
@ManyToOne(() => WagonTransferRequest, { nullable: true })
|
||||
@JoinColumn({ name: 'transfer_request_id' })
|
||||
transferRequest?: WagonTransferRequest | null;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FleetManage,
|
||||
FleetView,
|
||||
WagonTransferFulfill,
|
||||
WagonTransferHistoryAll,
|
||||
WagonTransferRequest,
|
||||
} from '../../common/booking-guards';
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
@@ -50,6 +51,30 @@ export class WagonTransferRequestsController {
|
||||
return this.service.listRequests(status);
|
||||
}
|
||||
|
||||
// NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
|
||||
// matches in declaration order, so `/history` would otherwise be captured by
|
||||
// the `:id` param route (and rejected by ParseUUIDPipe).
|
||||
@Get('history')
|
||||
@ApiOperation({
|
||||
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
|
||||
})
|
||||
myHistory(@CurrentUser() user: TCurrentUser) {
|
||||
// Never fall through to the all-staff view: getHistory(undefined) means
|
||||
// "everyone", so a missing caller id must return empty, not leak scope.
|
||||
if (!user?.id) return { requests: [], movements: [] };
|
||||
return this.service.getHistory(user.id);
|
||||
}
|
||||
|
||||
@Get('history/all')
|
||||
@WagonTransferHistoryAll()
|
||||
@ApiQuery({ name: 'userId', required: false })
|
||||
@ApiOperation({
|
||||
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
|
||||
})
|
||||
allHistory(@Query('userId') userId?: string) {
|
||||
return this.service.getHistory(userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one transfer request' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -6,14 +6,24 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
/** Bundled per-user activity: requests they touched + wagons they moved. */
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
movements: WagonMovement[];
|
||||
}
|
||||
|
||||
/** How many ledger rows the history returns at most (newest first). */
|
||||
const HISTORY_LIMIT = 500;
|
||||
|
||||
const REQUEST_RELATIONS = {
|
||||
fromYard: true,
|
||||
toYard: true,
|
||||
@@ -33,6 +43,8 @@ export class WagonTransferRequestsService {
|
||||
private readonly requestRepo: Repository<WagonTransferRequest>,
|
||||
@InjectRepository(Wagon)
|
||||
private readonly wagonRepo: Repository<Wagon>,
|
||||
@InjectRepository(WagonMovement)
|
||||
private readonly movementRepo: Repository<WagonMovement>,
|
||||
private readonly wagonsService: WagonsService,
|
||||
) {}
|
||||
|
||||
@@ -125,10 +137,12 @@ export class WagonTransferRequestsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows).
|
||||
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
|
||||
// each stamped with this request's id so history can link them back).
|
||||
await this.wagonsService.bulkTransfer(
|
||||
{ wagonIds, toYardId: request.toYardId },
|
||||
userId,
|
||||
{ transferRequestId: request.id },
|
||||
);
|
||||
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
@@ -138,6 +152,38 @@ export class WagonTransferRequestsService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user transfer history: the requests a user filed OR fulfilled, plus the
|
||||
* individual wagons they physically moved (linked back to their request when
|
||||
* one drove the move). Pass a `userId` to scope to one staffer; pass
|
||||
* `undefined` for the admin all-staff view. Scope is decided by the CALLER
|
||||
* (the controller passes the caller's id unless they hold the history-all
|
||||
* permission) — this method trusts its argument.
|
||||
*/
|
||||
async getHistory(userId?: string | null): Promise<TransferHistory> {
|
||||
const requests = await this.requestRepo.find({
|
||||
where: userId
|
||||
? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
|
||||
: {},
|
||||
relations: REQUEST_RELATIONS,
|
||||
order: { createdAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
});
|
||||
|
||||
const movements = await this.movementRepo.find({
|
||||
// Own view: moves I made. All view: every user-attributed move (skip the
|
||||
// system-written loaded/reposition legs that carry no mover).
|
||||
where: userId
|
||||
? { movedByUserId: userId }
|
||||
: { movedByUserId: Not(IsNull()) },
|
||||
relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
|
||||
order: { occurredAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
});
|
||||
|
||||
return { requests, movements };
|
||||
}
|
||||
|
||||
/** Withdraw a still-PENDING request. */
|
||||
async cancelRequest(id: string): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
@@ -10,7 +11,15 @@ import { WagonsService } from './wagons.service';
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Wagon, WagonTransferRequest, Train, Yard])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Wagon,
|
||||
WagonMovement,
|
||||
WagonTransferRequest,
|
||||
Train,
|
||||
Yard,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
WagonsController,
|
||||
TrainWagonsReorderController,
|
||||
|
||||
@@ -180,6 +180,7 @@ export class WagonsService {
|
||||
async bulkTransfer(
|
||||
dto: BulkTransferWagonsDto,
|
||||
userId?: string | null,
|
||||
opts?: { transferRequestId?: string | null },
|
||||
): Promise<{ moved: number }> {
|
||||
const { wagonIds, toYardId } = dto;
|
||||
if (!wagonIds.length) return { moved: 0 };
|
||||
@@ -215,6 +216,7 @@ export class WagonsService {
|
||||
toYardId,
|
||||
kind: WagonMovementKind.Manual,
|
||||
movedByUserId: userId ?? null,
|
||||
transferRequestId: opts?.transferRequestId ?? null,
|
||||
occurredAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -177,6 +177,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'),
|
||||
perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'),
|
||||
perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'),
|
||||
perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"),
|
||||
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
|
||||
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
||||
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
|
||||
@@ -446,6 +447,9 @@ export const FREIGHT_PERMS = {
|
||||
// executes the move). Distinct keys so OCC can hold fulfil without request.
|
||||
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
||||
transferFulfill: 'edr_freight_app:wagons:transfer_fulfill',
|
||||
// Admin: read every staffer's transfer history. Without it, a user only sees
|
||||
// their own (the /history endpoint uses the caller id, backend-enforced).
|
||||
transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all',
|
||||
},
|
||||
trains: {
|
||||
view: 'edr_freight_app:trains:view',
|
||||
|
||||
@@ -49,6 +49,7 @@ import { api } from "@/services/api";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import {
|
||||
useContractCapacity,
|
||||
useContractDetail,
|
||||
@@ -180,6 +181,9 @@ export default function GlCreateBookingForm() {
|
||||
}>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestIdParam = searchParams.get("requestId");
|
||||
// Rebook: copy an EXPIRED booking's cargo into a fresh booking on the same
|
||||
// contract (GL only picks a new schedule). Set by the clearance Rebook action.
|
||||
const copyFromParam = searchParams.get("copyFrom");
|
||||
const navigate = useNavigate();
|
||||
const { data: contract, isLoading } = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
@@ -205,6 +209,13 @@ export default function GlCreateBookingForm() {
|
||||
enabled: Boolean(requestId),
|
||||
});
|
||||
|
||||
// The expired booking a Rebook is copying from (its cargo seeds the form).
|
||||
const { data: copyFromBooking } = useQuery({
|
||||
queryKey: ["rebook-copy-from", copyFromParam],
|
||||
queryFn: () => bookingsService.getById(copyFromParam!),
|
||||
enabled: Boolean(copyFromParam),
|
||||
});
|
||||
|
||||
// Same window-gating the customer sees: booking is only allowed while a
|
||||
// window is OPEN for one of the contract's routes. Intercity contracts are
|
||||
// never window-gated — the shipment rides a passing train staff pick later.
|
||||
@@ -363,6 +374,28 @@ export default function GlCreateBookingForm() {
|
||||
if (bookingRequest.notes) setNotes(bookingRequest.notes);
|
||||
}, [bookingRequest, prefilled]);
|
||||
|
||||
// Rebook seed: copy the source booking's container lines once. (Bulk weight /
|
||||
// item count isn't on the booking payload yet, so bulk rebooks fall through to
|
||||
// the normal contract seed and GL re-enters the quantity.)
|
||||
useEffect(() => {
|
||||
if (!copyFromBooking || prefilled) return;
|
||||
const lines = copyFromBooking.bookingContainers ?? [];
|
||||
if (!lines.length) return;
|
||||
setPrefilled(true);
|
||||
setContainerLines(
|
||||
lines.map((c) => {
|
||||
const qty = Math.max(1, c.quantity);
|
||||
return {
|
||||
containerSize: String(c.containerType?.sizeFt ?? ""),
|
||||
quantity: String(qty),
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
units: Array.from({ length: qty }, emptyUnit),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, [copyFromBooking, prefilled]);
|
||||
|
||||
// Seed one shipment line per contracted size exactly once — same seeding the
|
||||
// portal form does. Subsequent renders reuse the lines.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Alert, Button, Group, Modal, Select, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { TrainComposition } from "@/services/trainBuilder.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Relocate the train to another yard. The consist moves as one unit — every
|
||||
* coupled locomotive and wagon follows, so their current yards always match
|
||||
* the train's.
|
||||
*/
|
||||
export default function ChangeYardModal({ composition, opened, onClose }: ChangeYardModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [yardId, setYardId] = useState("");
|
||||
|
||||
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
|
||||
const setYard = useMutation(api.trainBuilder.setYard.mutationOptions());
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setYardId(composition?.currentYard?.id ?? "");
|
||||
}, [opened, composition]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!composition || !yardId) return;
|
||||
try {
|
||||
await setYard.mutateAsync({ id: composition.id, currentYardId: yardId });
|
||||
toast({ title: "Train relocated" });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Relocation failed",
|
||||
description: parseError(err, "Could not change the yard"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const memberCount =
|
||||
(composition?.locomotives.length ?? 0) + (composition?.totals.wagonCount ?? 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>Change yard — train {composition?.code}</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="yellow" icon={<MapPin size={16} />}>
|
||||
The whole consist moves with the train: {composition?.locomotives.length ?? 0}{" "}
|
||||
locomotive{(composition?.locomotives.length ?? 0) === 1 ? "" : "s"} and{" "}
|
||||
{composition?.totals.wagonCount ?? 0} wagon
|
||||
{(composition?.totals.wagonCount ?? 0) === 1 ? "" : "s"} ({memberCount} vehicles)
|
||||
are relocated so their current yard always matches the train's. Wagon moves are
|
||||
recorded in the movement ledger.
|
||||
</Alert>
|
||||
<Select
|
||||
label="New yard"
|
||||
placeholder="Select yard"
|
||||
data={(yardsQuery.data ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
}))}
|
||||
value={yardId || null}
|
||||
onChange={(v) => setYardId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={setYard.isPending}
|
||||
disabled={!yardId || yardId === composition?.currentYard?.id}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Relocate train
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChangeYardModalProps {
|
||||
composition: TrainComposition | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Train as TrainIcon } from "lucide-react";
|
||||
|
||||
import type {
|
||||
TrainCompositionLocomotive,
|
||||
TrainCompositionWagon,
|
||||
} from "@/services/trainBuilder.service";
|
||||
|
||||
/**
|
||||
* Visual consist: locomotives + wagons drawn in order on a rail, the way the
|
||||
* train would leave the yard. Scrolls horizontally for long consists.
|
||||
*/
|
||||
export default function TrainConsistStrip({
|
||||
locomotives,
|
||||
wagons,
|
||||
emptyHint = "No wagons attached yet — add wagons from the yard below.",
|
||||
}: TrainConsistStripProps) {
|
||||
return (
|
||||
<Box
|
||||
px="md"
|
||||
py="lg"
|
||||
style={{
|
||||
overflowX: "auto",
|
||||
borderRadius: 12,
|
||||
background:
|
||||
"linear-gradient(180deg, var(--mantine-color-gray-0) 0%, var(--mantine-color-gray-1) 100%)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Box style={{ display: "inline-block", minWidth: "100%" }}>
|
||||
<Group gap={0} wrap="nowrap" align="flex-end">
|
||||
{locomotives.map((loco, index) => (
|
||||
<Group key={loco.id} gap={0} wrap="nowrap" align="flex-end">
|
||||
{index > 0 ? <Coupler /> : null}
|
||||
<LocomotiveCar locomotive={loco} />
|
||||
</Group>
|
||||
))}
|
||||
{wagons.map((wagon) => (
|
||||
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-end">
|
||||
<Coupler />
|
||||
<WagonCar wagon={wagon} />
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
{/* The rail */}
|
||||
<Box
|
||||
mt={6}
|
||||
style={{
|
||||
height: 0,
|
||||
borderTop: "3px solid var(--mantine-color-gray-4)",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-3)",
|
||||
}}
|
||||
/>
|
||||
{!wagons.length ? (
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{emptyHint}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TrainConsistStripProps {
|
||||
locomotives: TrainCompositionLocomotive[];
|
||||
wagons: TrainCompositionWagon[];
|
||||
emptyHint?: string;
|
||||
}
|
||||
|
||||
function Coupler() {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: 12,
|
||||
height: 4,
|
||||
marginBottom: 18,
|
||||
background: "var(--mantine-color-gray-5)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LocomotiveCar({ locomotive }: { locomotive: TrainCompositionLocomotive }) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${locomotive.code}${locomotive.name ? ` — ${locomotive.name}` : ""} · ${
|
||||
locomotive.role === "LEAD" ? "Lead" : "Assist"
|
||||
} · pulls ${locomotive.maxPullWeightTons}T`}
|
||||
withArrow
|
||||
>
|
||||
<Stack
|
||||
gap={2}
|
||||
align="center"
|
||||
px="sm"
|
||||
py={6}
|
||||
style={{
|
||||
minWidth: 96,
|
||||
borderRadius: "10px 14px 4px 4px",
|
||||
background:
|
||||
"linear-gradient(180deg, var(--mantine-color-edr-green-6) 0%, var(--mantine-color-edr-green-8) 100%)",
|
||||
color: "white",
|
||||
border: "1px solid var(--mantine-color-edr-green-9)",
|
||||
flexShrink: 0,
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<TrainIcon size={13} />
|
||||
<Text size="xs" fw={700} ff="monospace" lh={1.2}>
|
||||
{locomotive.code}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="10px" fw={600} tt="uppercase" style={{ opacity: 0.85 }} lh={1}>
|
||||
{locomotive.role === "LEAD" ? "Lead loco" : "Assist loco"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function WagonCar({ wagon }: { wagon: TrainCompositionWagon }) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${wagon.wagonNumber}${
|
||||
wagon.wagonType
|
||||
? ` · ${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
|
||||
: ""
|
||||
}`}
|
||||
withArrow
|
||||
>
|
||||
<Stack
|
||||
gap={2}
|
||||
align="center"
|
||||
px="xs"
|
||||
py={6}
|
||||
style={{
|
||||
minWidth: 76,
|
||||
borderRadius: 6,
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderBottom: "3px solid var(--mantine-color-edr-green-3)",
|
||||
flexShrink: 0,
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
<Text size="10px" c="dimmed" lh={1}>
|
||||
#{wagon.sequenceNumber ?? "—"}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} ff="monospace" lh={1.2}>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed" lh={1}>
|
||||
{wagon.wagonType?.code ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -506,12 +506,19 @@ function TrackBed() {
|
||||
|
||||
export function TrainCompositionDiagram({
|
||||
locomotive,
|
||||
locomotives,
|
||||
wagons,
|
||||
freightType,
|
||||
trainNumber,
|
||||
totalLengthMeters,
|
||||
}: {
|
||||
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
|
||||
/** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */
|
||||
locomotives?: Array<{
|
||||
code?: string | null;
|
||||
name?: string | null;
|
||||
maxPullWeightTons?: number | null;
|
||||
}> | null;
|
||||
wagons: DiagramWagonInput[];
|
||||
freightType?: string | null;
|
||||
trainNumber?: string | null;
|
||||
@@ -519,6 +526,11 @@ export function TrainCompositionDiagram({
|
||||
}) {
|
||||
const { ref, width } = useElementSize();
|
||||
|
||||
const locos = useMemo(
|
||||
() => (locomotives?.length ? locomotives : locomotive ? [locomotive] : []),
|
||||
[locomotives, locomotive],
|
||||
);
|
||||
|
||||
const normalized = useMemo(
|
||||
() => wagons.map((w) => normalizeWagon(w, freightType)),
|
||||
[wagons, freightType],
|
||||
@@ -533,6 +545,11 @@ export function TrainCompositionDiagram({
|
||||
// ceiling the allocation engine spends from.
|
||||
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
|
||||
const grossWeight = totalWeight + totalTare;
|
||||
// Weakest locomotive caps the set — same rule the allocation engine applies.
|
||||
const pullLimits = locos
|
||||
.map((l) => Number(l.maxPullWeightTons))
|
||||
.filter((v) => Number.isFinite(v) && v > 0);
|
||||
const pullLimit = pullLimits.length ? Math.min(...pullLimits) : null;
|
||||
return {
|
||||
total: normalized.length,
|
||||
assigned,
|
||||
@@ -541,22 +558,25 @@ export function TrainCompositionDiagram({
|
||||
totalTare: Math.round(totalTare * 100) / 100,
|
||||
grossWeight: Math.round(grossWeight * 100) / 100,
|
||||
totalCapacity,
|
||||
pullUtil:
|
||||
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
|
||||
? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
|
||||
: null,
|
||||
pullLimit,
|
||||
pullUtil: pullLimit
|
||||
? Math.min(100, Math.round((grossWeight / pullLimit) * 100))
|
||||
: null,
|
||||
};
|
||||
}, [normalized, locomotive]);
|
||||
}, [normalized, locos]);
|
||||
|
||||
// cars-per-row from measured width; locomotive counts as one car
|
||||
// cars-per-row from measured width; each locomotive counts as one car
|
||||
const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
|
||||
const cars = useMemo(
|
||||
() => [{ kind: "loco" as const }, ...normalized.map((w) => ({ kind: "wagon" as const, w }))],
|
||||
[normalized],
|
||||
() => [
|
||||
...locos.map((l) => ({ kind: "loco" as const, l })),
|
||||
...normalized.map((w) => ({ kind: "wagon" as const, w })),
|
||||
],
|
||||
[locos, normalized],
|
||||
);
|
||||
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
|
||||
|
||||
if (!locomotive && !wagons.length) return null;
|
||||
if (!locos.length && !wagons.length) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -658,8 +678,8 @@ export function TrainCompositionDiagram({
|
||||
<Text size="xs" fw={700} c="edr-green.8">
|
||||
Locomotive load ·{" "}
|
||||
{stats.totalTare > 0
|
||||
? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
||||
: `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
|
||||
? `${stats.grossWeight}T of ${stats.pullLimit}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
||||
: `${stats.totalWeight}T of ${stats.pullLimit}T`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
|
||||
@@ -702,13 +722,11 @@ export function TrainCompositionDiagram({
|
||||
<Group key={carIndex} gap={0} wrap="nowrap" style={{ flexDirection: reversed ? "row-reverse" : "row" }}>
|
||||
{carIndex > 0 ? <Coupler /> : null}
|
||||
{car.kind === "loco" ? (
|
||||
locomotive ? (
|
||||
<LocomotiveCar
|
||||
code={locomotive.code ?? "LOCO"}
|
||||
name={locomotive.name}
|
||||
maxPullWeightTons={locomotive.maxPullWeightTons}
|
||||
/>
|
||||
) : null
|
||||
<LocomotiveCar
|
||||
code={car.l.code ?? "LOCO"}
|
||||
name={car.l.name}
|
||||
maxPullWeightTons={car.l.maxPullWeightTons}
|
||||
/>
|
||||
) : (
|
||||
<WagonCar wagon={car.w} />
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
@@ -17,6 +19,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
History,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Warehouse,
|
||||
@@ -25,8 +28,13 @@ import {
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||
import type {
|
||||
WagonMovementRecord,
|
||||
WagonTransferRequest,
|
||||
} from "@/services/wagon.service";
|
||||
|
||||
export interface WagonTransferRequestsModalProps {
|
||||
opened: boolean;
|
||||
@@ -57,16 +65,172 @@ const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
|
||||
</Group>
|
||||
);
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "gray",
|
||||
FULFILLED: "teal",
|
||||
CANCELLED: "red",
|
||||
};
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-user transfer history. A staffer sees their OWN activity — the requests
|
||||
* they filed or fulfilled, and the individual wagons they moved. Holders of
|
||||
* `transfer_history_all` get an "All staff" toggle that widens the view; the
|
||||
* backend enforces the scope regardless of the toggle.
|
||||
*/
|
||||
function HistoryPanel({ opened }: { opened: boolean }) {
|
||||
const { user } = useAuth();
|
||||
const canSeeAll = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.wagons.transferHistoryAll,
|
||||
);
|
||||
const myId = (user as { id?: string } | null | undefined)?.id;
|
||||
const [allStaff, setAllStaff] = useState(false);
|
||||
const scopeAll = canSeeAll && allStaff;
|
||||
|
||||
const mine = useQuery({
|
||||
...api.wagonTransferRequests.history.queryOptions(),
|
||||
enabled: opened && !scopeAll,
|
||||
});
|
||||
const all = useQuery({
|
||||
...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }),
|
||||
enabled: opened && scopeAll,
|
||||
});
|
||||
const source = scopeAll ? all : mine;
|
||||
const requests = source.data?.requests ?? [];
|
||||
const movements: WagonMovementRecord[] = source.data?.movements ?? [];
|
||||
|
||||
const roleBadge = (r: WagonTransferRequest) => {
|
||||
if (myId && r.fulfilledByUserId === myId)
|
||||
return (
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
fulfilled
|
||||
</Badge>
|
||||
);
|
||||
if (myId && r.requestedByUserId === myId)
|
||||
return (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
requested
|
||||
</Badge>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{canSeeAll ? (
|
||||
<Group justify="flex-end">
|
||||
<Switch
|
||||
checked={allStaff}
|
||||
onChange={(e) => setAllStaff(e.currentTarget.checked)}
|
||||
label="All staff"
|
||||
color="edr-green"
|
||||
/>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{source.isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Text fw={700} size="sm" mb={8}>
|
||||
Requests{scopeAll ? "" : " you touched"}
|
||||
</Text>
|
||||
{requests.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No requests yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<RequestSummary r={r} />
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{roleBadge(r)}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[r.status] ?? "gray"}
|
||||
>
|
||||
{r.status.toLowerCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text fw={700} size="sm" mb={8}>
|
||||
Wagons moved
|
||||
</Text>
|
||||
{movements.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No wagon moves yet.
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={260}>
|
||||
<Stack gap={6}>
|
||||
{movements.map((m) => (
|
||||
<Card key={m.id} withBorder radius="md" padding="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{m.wagon?.wagonNumber ?? "Wagon"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{yardLabel(m.fromYard)} → {yardLabel(m.toYard)}
|
||||
</Text>
|
||||
{m.transferRequestId ? (
|
||||
<Badge size="xs" variant="light" color="teal">
|
||||
from request
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{fmtDateTime(m.occurredAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
|
||||
* one to hand-pick exactly the requested number of wagons from the source yard
|
||||
* (of the requested type) and execute the move, or cancel the request.
|
||||
* A second tab shows per-user transfer history.
|
||||
*/
|
||||
const WagonTransferRequestsModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
}: WagonTransferRequestsModalProps) => {
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState<string | null>("queue");
|
||||
const [active, setActive] = useState<WagonTransferRequest | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -175,6 +339,17 @@ const WagonTransferRequestsModal = ({
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Tabs value={tab} onChange={setTab} keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="queue" leftSection={<Inbox size={14} />}>
|
||||
Queue
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="queue">
|
||||
{!active ? (
|
||||
// ---- Pending queue ----
|
||||
isLoading ? (
|
||||
@@ -337,6 +512,12 @@ const WagonTransferRequestsModal = ({
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
<HistoryPanel opened={opened} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -106,6 +106,9 @@ export const FREIGHT_PERMS = {
|
||||
create: "edr_freight_app:wagons:create",
|
||||
update: "edr_freight_app:wagons:update",
|
||||
delete: "edr_freight_app:wagons:delete",
|
||||
transferRequest: "edr_freight_app:wagons:transfer_request",
|
||||
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
||||
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
||||
},
|
||||
trains: {
|
||||
view: "edr_freight_app:trains:view",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -75,6 +82,8 @@ interface ClearanceRow {
|
||||
freightType: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
/** Full ordered corridor across the contract's route legs (origin → … → destination). */
|
||||
routeStops: string[];
|
||||
contractKind: string;
|
||||
serviceTypeName: string;
|
||||
customs: boolean;
|
||||
@@ -93,6 +102,25 @@ function yardLabel(
|
||||
return yard.label ?? yard.name ?? yard.code ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain the contract's ordered route legs into one corridor of stops —
|
||||
* origin of the first leg, then each leg's destination (Djibouti → Adama →
|
||||
* Dire Dawa). A leg whose origin differs from the previous destination inserts
|
||||
* that stop too, so gapped route lists stay readable.
|
||||
*/
|
||||
function contractRouteStops(routes: Freight.IContractRoute[]): string[] {
|
||||
const stops: string[] = [];
|
||||
for (const r of routes) {
|
||||
const origin = yardLabel(r.originYard);
|
||||
const destination = yardLabel(r.destinationYard);
|
||||
if (stops.length === 0 || stops[stops.length - 1] !== origin) {
|
||||
stops.push(origin);
|
||||
}
|
||||
stops.push(destination);
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
@@ -109,6 +137,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
freightType: contract.freightType ?? "—",
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
routeStops: contractRouteStops(routes),
|
||||
contractKind: contract.contractKind,
|
||||
serviceTypeName: contract.serviceType?.serviceName ?? "—",
|
||||
customs:
|
||||
@@ -412,14 +441,23 @@ export default function ContractClearanceListPage() {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.originLabel}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.destinationLabel}
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
{(r.routeStops.length >= 2
|
||||
? r.routeStops
|
||||
: [r.originLabel, r.destinationLabel]
|
||||
).map((stop, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<ArrowRight
|
||||
size={14}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
<Text size="sm" fw={500}>
|
||||
{stop}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={r.tradeDirection} />
|
||||
@@ -638,6 +676,11 @@ export default function ContractClearanceListPage() {
|
||||
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
|
||||
)
|
||||
}
|
||||
onRebook={(row) =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
|
||||
)
|
||||
}
|
||||
onViewContract={(contractId) =>
|
||||
navigate(`/dashboard/contracts/clearance/${contractId}`)
|
||||
}
|
||||
@@ -731,6 +774,7 @@ function ShipmentBookingsTable({
|
||||
canCreateBooking,
|
||||
onOpen,
|
||||
onCreateBooking,
|
||||
onRebook,
|
||||
onViewContract,
|
||||
}: {
|
||||
rows: ShipmentBookingRow[];
|
||||
@@ -739,6 +783,7 @@ function ShipmentBookingsTable({
|
||||
canCreateBooking: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onCreateBooking: (row: ShipmentBookingRow) => void;
|
||||
onRebook: (row: ShipmentBookingRow) => void;
|
||||
onViewContract: (contractId: string) => void;
|
||||
}) {
|
||||
// A bare initiated instance that has cleared but not yet been created by GL.
|
||||
@@ -748,6 +793,14 @@ function ShipmentBookingsTable({
|
||||
!r.bookingCreated &&
|
||||
r.status === "CLEARANCE_READY";
|
||||
|
||||
// A customs shipment whose booking lost its slot — GL rebooks it (customer
|
||||
// can't self-rebook a customs booking). Copies the expired booking's cargo.
|
||||
const isRebookable = (r: ShipmentBookingRow) =>
|
||||
canCreateBooking &&
|
||||
Boolean(r.contractId) &&
|
||||
r.customs &&
|
||||
r.status === "EXPIRED";
|
||||
|
||||
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -877,6 +930,7 @@ function ShipmentBookingsTable({
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const bookable = isBookable(r);
|
||||
const rebookable = isRebookable(r);
|
||||
return (
|
||||
<Group
|
||||
justify="flex-end"
|
||||
@@ -896,6 +950,17 @@ function ShipmentBookingsTable({
|
||||
Create booking
|
||||
</Button>
|
||||
) : null}
|
||||
{rebookable ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="grape"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
onClick={() => onRebook(r)}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
) : null}
|
||||
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
@@ -919,6 +984,14 @@ function ShipmentBookingsTable({
|
||||
Create booking
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{rebookable ? (
|
||||
<Menu.Item
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
onClick={() => onRebook(r)}
|
||||
>
|
||||
Rebook (GL)
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{r.contractId ? (
|
||||
<Menu.Item
|
||||
leftSection={<ExternalLink size={14} />}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { isAxiosError } from "axios";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarClock,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
Replace,
|
||||
Ruler,
|
||||
@@ -29,9 +30,10 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
|
||||
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
|
||||
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
|
||||
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
|
||||
import TrainConsistStrip from "@/components/trainBuilder/TrainConsistStrip";
|
||||
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -62,6 +64,7 @@ export default function TrainBuilderDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [locoModalOpen, setLocoModalOpen] = useState(false);
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||
|
||||
const compositionQuery = useQuery(
|
||||
@@ -144,6 +147,13 @@ export default function TrainBuilderDetailPage() {
|
||||
>
|
||||
Change locomotives
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<MapPin size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setYardModalOpen(true)}
|
||||
>
|
||||
Change yard
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={15} />}
|
||||
@@ -162,8 +172,8 @@ export default function TrainBuilderDetailPage() {
|
||||
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
|
||||
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
|
||||
{
|
||||
label: "Max gross / haul limit",
|
||||
value: `${totals.maxGrossTons}T / ${totals.maxPullWeightTons}T`,
|
||||
label: "Payload available",
|
||||
value: `${totals.payloadCapacityTons}T of ${totals.maxPullWeightTons}T`,
|
||||
icon: Weight,
|
||||
},
|
||||
{
|
||||
@@ -180,33 +190,40 @@ export default function TrainBuilderDetailPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600}>Consist</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{composition.locomotives.length} locomotive
|
||||
{composition.locomotives.length === 1 ? "" : "s"} · {totals.wagonCount} wagon
|
||||
{totals.wagonCount === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
<TrainConsistStrip
|
||||
locomotives={composition.locomotives}
|
||||
wagons={composition.wagons}
|
||||
/>
|
||||
<Stack gap="sm">
|
||||
<TrainCompositionDiagram
|
||||
locomotives={composition.locomotives.map((loco) => ({
|
||||
code: loco.code,
|
||||
name: loco.name,
|
||||
maxPullWeightTons: loco.maxPullWeightTons,
|
||||
}))}
|
||||
wagons={composition.wagons.map((wagon, index) => ({
|
||||
sequenceNo: wagon.sequenceNumber ?? index + 1,
|
||||
capacityTons: wagon.wagonType?.capacityTons ?? 0,
|
||||
// No bookings at build time — wagons ride empty until allocation.
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
|
||||
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||||
physicalWagonNumber: wagon.wagonNumber,
|
||||
allocations: [],
|
||||
}))}
|
||||
trainNumber={composition.code}
|
||||
totalLengthMeters={totals.totalLengthMeters}
|
||||
/>
|
||||
<Card>
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<UtilizationBar
|
||||
label="Weight utilization (fully loaded)"
|
||||
pct={totals.weightUtilizationPct}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<UtilizationBar label="Length utilization" pct={totals.lengthUtilizationPct} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
The real weight check happens at allocation: booked cargo weight plus
|
||||
wagon tare (gross) must stay within the locomotives' haul limit.
|
||||
</Text>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Grid gap="lg" align="stretch">
|
||||
{composition.editable ? (
|
||||
@@ -297,6 +314,12 @@ export default function TrainBuilderDetailPage() {
|
||||
onClose={() => setLocoModalOpen(false)}
|
||||
/>
|
||||
|
||||
<ChangeYardModal
|
||||
composition={composition}
|
||||
opened={yardModalOpen}
|
||||
onClose={() => setYardModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={disbandOpen}
|
||||
onClose={() => setDisbandOpen(false)}
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function TrainBuilderListPage() {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.wagonCount} wagons · {row.original.maxGrossTons}T ·{" "}
|
||||
{row.original.wagonCount} wagons · {row.original.totalTareTons}T tare ·{" "}
|
||||
{row.original.totalLengthMeters}m
|
||||
</Text>
|
||||
),
|
||||
|
||||
@@ -838,6 +838,12 @@ export default function BatchScheduleDetailPage() {
|
||||
}).format(new Date(data.scheduleDate)) + " EAT"
|
||||
: "No date"}
|
||||
</HeroChip>
|
||||
{data.train ? (
|
||||
<HeroChip icon={<TrainFront size={12} />}>
|
||||
Train {data.train.code}
|
||||
{data.train.trainName ? ` — ${data.train.trainName}` : ""}
|
||||
</HeroChip>
|
||||
) : null}
|
||||
{data.locomotive ? (
|
||||
<HeroChip icon={<TrainFront size={12} />}>
|
||||
Loco {data.locomotive.code} ·{" "}
|
||||
|
||||
@@ -757,13 +757,14 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Stack gap="md">
|
||||
<TrainCompositionDiagram
|
||||
locomotive={schedule.trainSet?.locomotive}
|
||||
locomotives={locomotives}
|
||||
wagons={
|
||||
schedule.trainSet?.wagons?.length
|
||||
? schedule.trainSet.wagons
|
||||
: displayWagonPlan
|
||||
}
|
||||
freightType={freightType}
|
||||
trainNumber={schedule.trainNumber}
|
||||
trainNumber={schedule.train ? schedule.train.code : schedule.trainNumber}
|
||||
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
<Paper
|
||||
@@ -1003,6 +1004,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
...(schedule.train
|
||||
? [
|
||||
{
|
||||
label: "Train",
|
||||
value: schedule.train.code,
|
||||
hint: schedule.train.trainName ?? "Built train (Train Builder)",
|
||||
icon: Train,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: locomotives.length > 1 ? "Locomotives" : "Locomotive",
|
||||
value: locomotives.length
|
||||
|
||||
@@ -200,6 +200,7 @@ import {
|
||||
type WagonMovementRecord,
|
||||
type WagonTransferRequest,
|
||||
type CreateTransferRequestPayload,
|
||||
type TransferHistory,
|
||||
} from "./wagon.service";
|
||||
import { warehouseService } from "./warehouse.service";
|
||||
|
||||
@@ -1701,6 +1702,21 @@ export const api = {
|
||||
undefined,
|
||||
() => [["wagonTransferRequests"]],
|
||||
),
|
||||
|
||||
history: endpoint<void, TransferHistory>(
|
||||
"wagonTransferRequests",
|
||||
"history",
|
||||
() => wagonTransferRequestService.myHistory().then((r) => r.data),
|
||||
() => ["wagonTransferRequests", "history", "mine"],
|
||||
),
|
||||
|
||||
historyAll: endpoint<{ userId?: string }, TransferHistory>(
|
||||
"wagonTransferRequests",
|
||||
"historyAll",
|
||||
({ userId }) =>
|
||||
wagonTransferRequestService.allHistory(userId).then((r) => r.data),
|
||||
({ userId }) => ["wagonTransferRequests", "history", "all", userId ?? ""],
|
||||
),
|
||||
},
|
||||
|
||||
trains: {
|
||||
@@ -1781,6 +1797,15 @@ export const api = {
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
|
||||
"train-builder",
|
||||
"setYard",
|
||||
({ id, currentYardId }) =>
|
||||
trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||
"train-builder",
|
||||
"assignWagons",
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface BuiltTrainSummary {
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
||||
wagonCount: number;
|
||||
maxGrossTons: number;
|
||||
totalTareTons: number;
|
||||
totalLengthMeters: number;
|
||||
maxPullWeightTons: number;
|
||||
}
|
||||
@@ -63,12 +63,15 @@ export interface TrainCompositionWagon {
|
||||
export interface TrainCompositionTotals {
|
||||
wagonCount: number;
|
||||
totalTareTons: number;
|
||||
/** Informational only — building never checks against full capacity. */
|
||||
totalCapacityTons: number;
|
||||
maxGrossTons: number;
|
||||
totalLengthMeters: number;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
weightUtilizationPct: number | null;
|
||||
/** Cargo the locomotives can still haul once pulling the empty consist. */
|
||||
payloadCapacityTons: number;
|
||||
/** Share of the haul limit consumed by the empty wagons alone. */
|
||||
tareUtilizationPct: number | null;
|
||||
lengthUtilizationPct: number | null;
|
||||
}
|
||||
|
||||
@@ -126,7 +129,7 @@ export interface AvailableTrain {
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
||||
wagonCount: number;
|
||||
maxGrossTons: number;
|
||||
totalTareTons: number;
|
||||
totalLengthMeters: number;
|
||||
maxPullWeightTons: number;
|
||||
atOriginYard: boolean;
|
||||
@@ -157,6 +160,9 @@ export const trainBuilderService = {
|
||||
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
||||
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
||||
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
|
||||
/** Relocate the train — coupled locomotives and wagons move with it. */
|
||||
setYard: (id: string, currentYardId: string) =>
|
||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),
|
||||
assignWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||
removeWagon: (id: string, wagonId: string) =>
|
||||
|
||||
@@ -55,9 +55,12 @@ export interface WagonMovementRecord {
|
||||
bookingId: string | null;
|
||||
kind: Freight.WagonMovementKind;
|
||||
movedByUserId: string | null;
|
||||
/** The transfer request this move fulfilled, when one drove it. */
|
||||
transferRequestId: string | null;
|
||||
occurredAt: string;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
wagon?: { id: string; wagonNumber?: string } | null;
|
||||
}
|
||||
|
||||
export const wagonService = {
|
||||
@@ -120,11 +123,25 @@ export interface CreateTransferRequestPayload {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
movements: WagonMovementRecord[];
|
||||
}
|
||||
|
||||
export const wagonTransferRequestService = {
|
||||
list: (status?: Freight.WagonTransferRequestStatus) =>
|
||||
apiClient.get<WagonTransferRequest[]>(
|
||||
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
|
||||
),
|
||||
/** The caller's own history (both roles: requests they filed and fulfilled). */
|
||||
myHistory: () =>
|
||||
apiClient.get<TransferHistory>('/wagon-transfer-requests/history'),
|
||||
/** Admin: any/all staff's history (optional userId filter). */
|
||||
allHistory: (userId?: string) =>
|
||||
apiClient.get<TransferHistory>(
|
||||
`/wagon-transfer-requests/history/all${userId ? `?userId=${userId}` : ''}`,
|
||||
),
|
||||
getById: (id: string) =>
|
||||
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
||||
create: (data: CreateTransferRequestPayload) =>
|
||||
|
||||
@@ -309,6 +309,12 @@ export interface BatchBoardSchedule {
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train: {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
} | null;
|
||||
locomotive: {
|
||||
code: string;
|
||||
name: string | null;
|
||||
@@ -417,6 +423,8 @@ export interface BatchBoardScheduleDetail {
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train: BatchBoardSchedule["train"];
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
@@ -511,6 +519,12 @@ export interface TrainScheduleDetail {
|
||||
deferredBookings?: DeferredBookingRow[];
|
||||
freightType?: FreightType | null;
|
||||
trainNumber?: string | null;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train?: {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName?: string | null;
|
||||
} | null;
|
||||
direction?: string | null;
|
||||
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
|
||||
requiresLoadingConfirmation?: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { Fragment, memo, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||
import { windowRouteStops } from "@/pages/contracts/booking-window";
|
||||
import { Card } from "./Card";
|
||||
|
||||
const INK = "#10202F";
|
||||
@@ -284,14 +285,21 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
||||
}}
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{w.origin ?? "—"}
|
||||
</Text>
|
||||
<ArrowRight size={13} color={MUTED} style={{ flexShrink: 0 }} />
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{w.destination ?? "—"}
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
{windowRouteStops(w).map((stop, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<ArrowRight
|
||||
size={13}
|
||||
color={MUTED}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||
{stop}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{w.reference && (
|
||||
<Text
|
||||
fz={11}
|
||||
|
||||
@@ -128,6 +128,11 @@ export function ReadonlyBookingView({
|
||||
);
|
||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||
const isExpired = status === "EXPIRED";
|
||||
// Customs (Path B) bookings are created AND rebooked by Global Logistics, not
|
||||
// the customer. Hide the customer's rebook everywhere and tell them GL will
|
||||
// handle it. Non-customs bookings stay self-service.
|
||||
const isCustoms = Boolean(booking.customsClearingEnabled);
|
||||
const canSelfRebook = !isCustoms;
|
||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||
const isClearance = [
|
||||
"AWAITING_DOCUMENTS",
|
||||
@@ -164,7 +169,7 @@ export function ReadonlyBookingView({
|
||||
)
|
||||
}
|
||||
menuActions={{
|
||||
onRebook,
|
||||
onRebook: canSelfRebook ? onRebook : undefined,
|
||||
onSupport: () => navigate("/support"),
|
||||
}}
|
||||
/>
|
||||
@@ -181,14 +186,18 @@ export function ReadonlyBookingView({
|
||||
: "This booking process has been terminated."
|
||||
}
|
||||
reason={booking.latestChangeRequestNote}
|
||||
onRebook={onRebook}
|
||||
onRebook={canSelfRebook ? onRebook : undefined}
|
||||
/>
|
||||
) : isExpired ? (
|
||||
<CancelledBanner
|
||||
pillLabel="Expired"
|
||||
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
|
||||
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
||||
onRebook={onRebook}
|
||||
subtitle={
|
||||
isCustoms
|
||||
? "Payment wasn't completed in time, so this booking lost its slot. Global Logistics will rebook this shipment for you — no action is needed on your side."
|
||||
: "Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
||||
}
|
||||
onRebook={canSelfRebook ? onRebook : undefined}
|
||||
/>
|
||||
) : isPendingConsolidation ? (
|
||||
<ConsolidationWaitingBanner
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||
import { formatWindowOpensAt, soonestUpcomingWindow } from "./booking-window";
|
||||
import {
|
||||
formatWindowOpensAt,
|
||||
soonestUpcomingWindow,
|
||||
windowRouteStops,
|
||||
} from "./booking-window";
|
||||
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
@@ -189,14 +193,21 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="nowrap" mt={10}>
|
||||
<Text fz={15} fw={700} style={{ color: INK }} truncate>
|
||||
{w.origin ?? "—"}
|
||||
</Text>
|
||||
<ArrowRight size={14} color={MUTED} style={{ flexShrink: 0 }} />
|
||||
<Text fz={15} fw={700} style={{ color: INK }} truncate>
|
||||
{w.destination ?? "—"}
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap" mt={10}>
|
||||
{windowRouteStops(w).map((stop, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<ArrowRight
|
||||
size={14}
|
||||
color={MUTED}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
<Text fz={15} fw={700} style={{ color: INK }}>
|
||||
{stop}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="nowrap" mt={8}>
|
||||
|
||||
@@ -26,7 +26,12 @@ export default function NewShipmentRequestPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [quantity, setQuantity] = useState<number | string>(1);
|
||||
// Container contracts: one quantity per enabled size (e.g. 20ft + 40ft).
|
||||
const [qtyBySize, setQtyBySize] = useState<Record<string, number | string>>(
|
||||
{},
|
||||
);
|
||||
// Bulk contracts: a single amount — tons (PER_TON) or item count (PER_ITEM).
|
||||
const [bulkAmount, setBulkAmount] = useState<number | string>("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const { data: contract, isLoading } = useQuery({
|
||||
@@ -76,6 +81,22 @@ export default function NewShipmentRequestPage() {
|
||||
contract.contractKind === "GENERAL" &&
|
||||
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
|
||||
|
||||
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
|
||||
const SIZE_ORDER = ["20ft", "40ft"];
|
||||
const enabledSizes = SIZE_ORDER.filter((s) =>
|
||||
contract.cargoScope?.some(
|
||||
(l) => (l.containerSize ?? "").toLowerCase() === s,
|
||||
),
|
||||
);
|
||||
// A CONTAINER contract should always carry scope lines; fall back to both.
|
||||
const sizes = enabledSizes.length ? enabledSizes : SIZE_ORDER;
|
||||
|
||||
// Bulk: pick the bulk scope line and read how it's measured.
|
||||
const bulkScope =
|
||||
contract.cargoScope?.find((l) => !l.containerSize) ??
|
||||
contract.cargoScope?.[0];
|
||||
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
|
||||
|
||||
const handleSubmit = () => {
|
||||
const dto: Freight.CreateBookingRequestDto = {
|
||||
contractRouteId: route?.id,
|
||||
@@ -84,17 +105,24 @@ export default function NewShipmentRequestPage() {
|
||||
};
|
||||
|
||||
if (isContainer) {
|
||||
const size = contract.cargoScope?.[0]?.containerSize ?? "20FT";
|
||||
dto.containers = [
|
||||
{
|
||||
containerSize: size,
|
||||
quantity: Number(quantity) || 1,
|
||||
},
|
||||
];
|
||||
// One line per size the user filled; 0 (or blank) sizes are dropped.
|
||||
const containers = sizes
|
||||
.map((size) => ({ containerSize: size, quantity: Number(qtyBySize[size]) || 0 }))
|
||||
.filter((line) => line.quantity > 0);
|
||||
if (containers.length === 0) {
|
||||
toast.error("Enter a quantity for at least one container size");
|
||||
return;
|
||||
}
|
||||
dto.containers = containers;
|
||||
} else {
|
||||
const amount = Number(bulkAmount) || 0;
|
||||
if (amount <= 0) {
|
||||
toast.error(isPerItem ? "Enter the number of items" : "Enter the cargo weight");
|
||||
return;
|
||||
}
|
||||
dto.bulk = {
|
||||
cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null,
|
||||
cargoWeightTons: Number(quantity) || undefined,
|
||||
cargoTypeId: bulkScope?.cargoTypeId ?? null,
|
||||
...(isPerItem ? { itemCount: amount } : { cargoWeightTons: amount }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,17 +159,47 @@ export default function NewShipmentRequestPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
label={isContainer ? "Number of containers" : "Cargo weight (tons)"}
|
||||
description={
|
||||
hasCustoms
|
||||
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
|
||||
: undefined
|
||||
}
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
min={1}
|
||||
/>
|
||||
{isContainer ? (
|
||||
<Stack gap="sm">
|
||||
{sizes.map((size) => (
|
||||
<NumberInput
|
||||
key={size}
|
||||
label={`Number of ${size} containers`}
|
||||
value={qtyBySize[size] ?? 0}
|
||||
onChange={(v) =>
|
||||
setQtyBySize((prev) => ({ ...prev, [size]: v }))
|
||||
}
|
||||
min={0}
|
||||
allowDecimal={false}
|
||||
/>
|
||||
))}
|
||||
{sizes.length > 1 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Enter a quantity for each size you need — leave a size at 0 if
|
||||
you don't need it.
|
||||
</Text>
|
||||
) : null}
|
||||
{hasCustoms ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Global Logistics schedules the shipment date during customs
|
||||
clearance — you only state the quantity.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<NumberInput
|
||||
label={isPerItem ? "Number of items" : "Cargo weight (tons)"}
|
||||
description={
|
||||
hasCustoms
|
||||
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
|
||||
: undefined
|
||||
}
|
||||
value={bulkAmount}
|
||||
onChange={setBulkAmount}
|
||||
min={0}
|
||||
allowDecimal={!isPerItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{capacity?.length ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
|
||||
@@ -25,6 +25,16 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
|
||||
return windows.some((w) => w.isOpenNow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full ordered corridor for a window — every stop from origin through the
|
||||
* intermediate milestones to the destination. Falls back to origin/destination
|
||||
* when the backend sends no milestone chain (older schedules, routeless windows).
|
||||
*/
|
||||
export function windowRouteStops(w: MyBookingWindow): string[] {
|
||||
if (w.routeStations && w.routeStations.length >= 2) return w.routeStations;
|
||||
return [w.origin ?? "—", w.destination ?? "—"];
|
||||
}
|
||||
|
||||
/**
|
||||
* The next upcoming (not-yet-open) window the customer should come back for —
|
||||
* the one that OPENS soonest from now. Two guards matter here:
|
||||
|
||||
@@ -94,6 +94,12 @@ export interface MyBookingWindow {
|
||||
departureDate: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
/**
|
||||
* Full ordered corridor for the window's route — origin, every intermediate
|
||||
* milestone stop, then destination (e.g. Djibouti → Adama → Dire Dawa).
|
||||
* Falls back to [origin, destination] when the route has no milestones.
|
||||
*/
|
||||
routeStations: string[];
|
||||
}
|
||||
|
||||
export interface GeneratePriceResponse {
|
||||
|
||||
@@ -327,6 +327,8 @@ export interface IWagonMovement extends BaseEntity {
|
||||
bookingId?: string | null;
|
||||
kind: WagonMovementKind;
|
||||
movedByUserId?: string | null;
|
||||
/** The transfer request this move fulfilled, when it came from one. */
|
||||
transferRequestId?: string | null;
|
||||
occurredAt: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user