intercity fix

This commit is contained in:
Marshal
2026-07-31 10:50:16 +00:00
parent 74b7ee81fc
commit a64af98205
17 changed files with 1174 additions and 182 deletions

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Consist adjustments can now happen mid-route (train standing at a stop), so
* each history row records WHERE it happened. Nullable — rows written before
* this column simply have no yard.
*/
export class AddYardToScheduleWagonAdjustmentLogs3110000000000 implements MigrationInterface {
name = "AddYardToScheduleWagonAdjustmentLogs3110000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.schedule_wagon_adjustment_logs
ADD COLUMN IF NOT EXISTS yard_id uuid`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.schedule_wagon_adjustment_logs
DROP COLUMN IF EXISTS yard_id`,
);
}
}

View File

@@ -1,15 +1,17 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm'; import { Column, Entity, Index } from 'typeorm';
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const; export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE', 'SWITCH'] as const;
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number]; export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
/** /**
* History row for a consist adjustment made from a schedule: staff coupled a * History row for a consist adjustment made from a schedule: staff coupled a
* wagon onto (ADD) or detached one from (REMOVE) the schedule's built train — * wagon onto (ADD), detached one from (REMOVE), or swapped the physical wagon
* e.g. trimming free wagons whose tare pushed gross weight over the * under a loaded slot (SWITCH — wagonNumber reads "OLD → NEW") on the
* locomotives' pull limit. Plain columns (no FK relations) so the history * schedule's built train. `yardId` records WHERE it happened: the origin yard
* survives the wagon or train being deleted later. * before departure, or the mid-route stop the train was standing at. Plain
* columns (no FK relations) so the history survives the wagon or train being
* deleted later.
*/ */
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' }) @Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
@Index(['trainScheduleId']) @Index(['trainScheduleId'])
@@ -33,6 +35,9 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity {
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true }) @Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
adjustedByUserId!: string | null; adjustedByUserId!: string | null;
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
yardId!: string | null;
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' }) @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
occurredAt!: Date; occurredAt!: Date;
} }

View File

@@ -614,6 +614,26 @@ export class BookingBatchService implements OnModuleInit {
const linked = const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId); await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
// Intercity is allocated MANUALLY: payment secures the ride, staff then
// place it on whichever same-route train suits (intercity panel). Unpin
// from the train it reserved against — that train may be the wrong one by
// the time it departs — and return it to the waiting pool as PAID.
if (!linked && booking.tradeDirection === "DOMESTIC" && !booking.isGovernment) {
await this.dataSource.getRepository(Booking).update(bookingId, {
trainScheduleId: null,
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
} as never);
this.logger.log(
`[BATCH] intercity ${booking.reference ?? bookingId} PAID — awaiting manual placement by staff`,
);
void this.completeTrackingMilestones(bookingId, [
"FREIGHT_PAYMENT_PENDING",
"FREIGHT_PAYMENT_SETTLED",
]);
this.notifyBoardChanged(booking.trainScheduleId, "intercity_paid_unplaced");
return;
}
if (!linked) { if (!linked) {
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return; if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
await this.allocate(booking.trainScheduleId, booking, "paid"); await this.allocate(booking.trainScheduleId, booking, "paid");
@@ -3143,6 +3163,19 @@ export class BookingBatchService implements OnModuleInit {
this.notifyBoardChanged(scheduleId, 'intercity_accepted'); this.notifyBoardChanged(scheduleId, 'intercity_accepted');
return; return;
} }
// Manual placement of an ALREADY-PAID intercity booking: payment landed
// earlier (and unpinned it back to the pool) — staff are now choosing its
// train, so link directly. No new pay window; wagon assignment stays with
// staff in the workspace.
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
await this.dataSource
.getRepository(Booking)
.update(booking.id, { trainScheduleId: scheduleId });
booking.trainScheduleId = scheduleId;
await this.allocate(scheduleId, booking, 'paid');
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
return;
}
await this.reserve(booking, scheduleId); await this.reserve(booking, scheduleId);
this.armSettle(scheduleId); this.armSettle(scheduleId);
this.notifyBoardChanged(scheduleId, 'intercity_accepted'); this.notifyBoardChanged(scheduleId, 'intercity_accepted');
@@ -3363,7 +3396,11 @@ export class BookingBatchService implements OnModuleInit {
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`, `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
); );
this.notifier.secured(booking, reason, scheduleId); this.notifier.secured(booking, reason, scheduleId);
void this.triggerWagonAllocation(scheduleId); // Intercity rides are placed on wagons BY STAFF (workspace wizard) — auto
// wagon assignment is for the import/export batch flow only.
if (booking.tradeDirection !== 'DOMESTIC') {
void this.triggerWagonAllocation(scheduleId);
}
void this.markWagonAllocatedMilestone(booking.id); void this.markWagonAllocatedMilestone(booking.id);
// Customer tracking: freight payment settled (commercial pay-window path). // Customer tracking: freight payment settled (commercial pay-window path).
// Government allocations don't pay upfront — theirs stay pending. // Government allocations don't pay upfront — theirs stay pending.

View File

@@ -1,5 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsOptional, IsUUID } from 'class-validator'; import { Type } from 'class-transformer';
import { IsArray, IsOptional, IsUUID, ValidateNested } from 'class-validator';
export class ConsistWagonSwitchDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Coupled wagon being taken out of the consist.' })
@IsUUID()
fromWagonId!: string;
@ApiPropertyOptional({ format: 'uuid', description: 'AVAILABLE same-type wagon from the current yard that takes its place (and its slot, cargo included).' })
@IsUUID()
toWagonId!: string;
}
export class AdjustScheduleConsistDto { export class AdjustScheduleConsistDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
@@ -23,4 +34,15 @@ export class AdjustScheduleConsistDto {
@IsArray() @IsArray()
@IsUUID('all', { each: true }) @IsUUID('all', { each: true })
removeWagonIds?: string[]; removeWagonIds?: string[];
@ApiPropertyOptional({
type: [ConsistWagonSwitchDto],
description:
"Wagon swaps: the replacement takes over the outgoing wagon's position AND its slot, so cargo allocations ride the new wagon. This is how a LOADED wagon leaves the train — removal is blocked for it, switching is not. Replacement must be the same wagon type, AVAILABLE, standing in the train's current yard.",
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ConsistWagonSwitchDto)
switches?: ConsistWagonSwitchDto[];
} }

View File

@@ -330,8 +330,10 @@ export class IntercityService {
.leftJoinAndSelect('booking.destinationYard', 'destinationYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`) .where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id IS NULL') .andWhere('booking.train_schedule_id IS NULL')
// PAID = customer paid but staff have not placed it on a train yet
// (intercity allocation is manual) — it stays in the pool until they do.
.andWhere( .andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') `((booking.is_government = false AND booking.status IN ('FULLY_EXECUTED', 'PAID'))
OR (booking.is_government = true AND booking.status = 'APPROVED'))`, OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
) )
.orderBy('booking.is_government', 'DESC') .orderBy('booking.is_government', 'DESC')
@@ -382,8 +384,12 @@ export class IntercityService {
if (booking.trainScheduleId) { if (booking.trainScheduleId) {
return 'Already assigned to a train'; return 'Already assigned to a train';
} }
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED'; // Commercial: FULLY_EXECUTED opens a pay window; PAID (payment landed,
if (booking.status !== readyStatus) { // awaiting manual placement) links straight onto the chosen train.
const readyStatuses = booking.isGovernment
? ['APPROVED']
: ['FULLY_EXECUTED', 'PAID'];
if (!readyStatuses.includes(booking.status)) {
return `Not ready to board (status ${booking.status})`; return `Not ready to board (status ${booking.status})`;
} }
if (!this.corridorOnRoute(booking, milestoneSeq)) { if (!this.corridorOnRoute(booking, milestoneSeq)) {

View File

@@ -195,6 +195,16 @@ export class TrainSchedulingController {
); );
} }
@Get("schedules/:id/history")
@TrainSchedulingView()
@ApiOperation({
summary:
"Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first",
})
getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleHistory(id);
}
@Get("bookable-schedules") @Get("bookable-schedules")
// No staff guard: customers hit this while creating a booking to find OPEN // No staff guard: customers hit this while creating a booking to find OPEN
// same-route schedules. Do not attach train_scheduling permissions here. // same-route schedules. Do not attach train_scheduling permissions here.

View File

@@ -43,6 +43,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Contract } from '../contracts/entities/contract.entity';
import { Container } from '../container-management/entities/container.entity'; import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository'; import { LocomotivesRepository } from '../locomotives/locomotives.repository';
@@ -1249,8 +1250,31 @@ export class TrainSchedulingService {
}; };
} }
/**
* Limits for a preview aimed at an EXISTING schedule must be the schedule's
* own: its locomotive set and its built-consist wagon cap. Resolving from
* the dto alone re-derived the global wagon cap (53) and rejected a
* physically-coupled 54-wagon train the assign path would accept.
*/
private async resolvePreviewLimitConfig(dto: {
targetScheduleId?: string;
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}): Promise<Required<TrainLimitConfig>> {
const target = dto.targetScheduleId
? await this.trainSchedulesRepository.findByIdWithFullGraph(dto.targetScheduleId)
: null;
if (!target) return this.resolveTrainLimitConfig(dto);
return this.resolveTrainLimitConfig(
dto,
combinedLocomotiveLimits(this.locomotivesOfTrainSet(target.trainSet)),
target.maxWagons ?? undefined,
);
}
async previewTrainSchedule(dto: PreviewTrainScheduleDto) { async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
const limits = await this.resolveTrainLimitConfig(dto); const limits = await this.resolvePreviewLimitConfig(dto);
return this.buildPreviewResponse( return this.buildPreviewResponse(
await this.validateBookingsForScheduling( await this.validateBookingsForScheduling(
dto, dto,
@@ -1265,7 +1289,7 @@ export class TrainSchedulingService {
} }
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) { async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
const limits = await this.resolveTrainLimitConfig(dto); const limits = await this.resolvePreviewLimitConfig(dto);
return this.buildPreviewResponse( return this.buildPreviewResponse(
await this.validateBookingsForScheduling( await this.validateBookingsForScheduling(
dto, dto,
@@ -1280,7 +1304,7 @@ export class TrainSchedulingService {
} }
async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) { async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) {
const limits = await this.resolveTrainLimitConfig(dto); const limits = await this.resolvePreviewLimitConfig(dto);
return this.buildPreviewResponse( return this.buildPreviewResponse(
await this.validateBookingsForScheduling( await this.validateBookingsForScheduling(
dto, dto,
@@ -4657,16 +4681,29 @@ export class TrainSchedulingService {
* schedule. Used to guard consist trims — the Wagon entity itself carries no * schedule. Used to guard consist trims — the Wagon entity itself carries no
* schedule-occupancy state anymore. * schedule-occupancy state anymore.
*/ */
private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise<Set<string>> { /**
* Physical wagons pinned to any live run's slot. `excludeTrainId` drops the
* pins of that BUILT TRAIN's own schedules (this run and its siblings — e.g.
* the paired return leg): a consist edit is an edit of the TRAIN, sibling
* runs ride whatever it is composed of and their pins are re-pointed by the
* edit itself. Only pins held by live schedules of OTHER trains block it.
*/
private async wagonIdsPinnedToLiveSchedules(
manager?: EntityManager,
excludeTrainId?: string,
): Promise<Set<string>> {
const runner = manager ?? this.dataSource; const runner = manager ?? this.dataSource;
const rows: { physical_wagon_id: string }[] = await runner.query( const rows: { physical_wagon_id: string }[] = await runner.query(
`SELECT DISTINCT tsw.physical_wagon_id `SELECT DISTINCT tsw.physical_wagon_id
FROM freight.train_set_wagons tsw FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
JOIN freight.train_sets tset ON tset.id = tsw.train_set_id
WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.deleted_at IS NULL AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL AND tsw.deleted_at IS NULL
AND tsw.physical_wagon_id IS NOT NULL`, AND tsw.physical_wagon_id IS NOT NULL
AND ($1::uuid IS NULL OR tset.train_id IS NULL OR tset.train_id <> $1)`,
[excludeTrainId ?? null],
); );
return new Set(rows.map((row) => row.physical_wagon_id)); return new Set(rows.map((row) => row.physical_wagon_id));
} }
@@ -5711,6 +5748,70 @@ export class TrainSchedulingService {
}); });
} }
/**
* Where consist work can physically happen right now. Before departure it is
* the built train's own yard. After dispatch it is the route stop the train
* is STANDING AT per its latest checkpoint — null while rolling between
* stops or when the last checkpoint is off-route, and consist work is closed
* there. Arrived/cancelled schedules always return null (history only).
*/
private async currentConsistYardId(
schedule: TrainSchedule,
): Promise<string | null> {
if (
schedule.status === TrainScheduleStatusEnum.Draft ||
schedule.status === TrainScheduleStatusEnum.Scheduled
) {
return schedule.trainSet?.train?.currentYardId ?? schedule.originStationId;
}
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) return null;
const rows: Array<{ yard_id: string | null }> = await this.dataSource.query(
`SELECT yard_id
FROM freight.train_checkpoint_events
WHERE train_schedule_id = $1
ORDER BY occurred_at DESC, created_at DESC
LIMIT 1`,
[schedule.id],
);
const yardId = rows[0]?.yard_id ?? null;
if (!yardId) return null;
return this.mapScheduleStops(schedule).some((s) => s.yardId === yardId)
? yardId
: null;
}
/**
* Physical wagons whose cargo still RIDES beyond the given stop: any
* allocation whose booking alights strictly after it. Cargo whose
* destination is this stop (or an earlier one) has been offloaded here and
* no longer blocks its wagon — that wagon may be trimmed or switched away.
* Before departure the stop is the origin, so every allocated wagon counts
* as aboard — one rule covers both phases. Unknown destinations and
* off-route stops stay conservative (aboard).
*/
// ponytail: trusts booking.destinationYardId, not a physical unload
// confirmation — if staff trim before actually unloading, the cargo strands.
// Wire the journey unload flag in if that ever bites.
private wagonIdsWithCargoBeyond(
schedule: TrainSchedule,
atYardId: string | null,
): Set<string> {
const stops = this.mapScheduleStops(schedule).map((s) => s.yardId);
const atIdx = atYardId ? stops.indexOf(atYardId) : -1;
const aboard = new Set<string>();
for (const slot of schedule.trainSet?.wagons ?? []) {
if (!slot.physicalWagonId || !(slot.allocations?.length ?? 0)) continue;
const ridesOn = (slot.allocations ?? []).some((allocation) => {
const destination = allocation.booking?.destinationYardId;
const destIdx = destination ? stops.indexOf(destination) : -1;
if (destIdx < 0 || atIdx < 0) return true;
return destIdx > atIdx;
});
if (ridesOn) aboard.add(slot.physicalWagonId);
}
return aboard;
}
/** /**
* Consist snapshot for the adjust-consist UI: the built train's wagons with * Consist snapshot for the adjust-consist UI: the built train's wagons with
* loaded/removable flags, gross weight (cargo + FULL consist tare) and length * loaded/removable flags, gross weight (cargo + FULL consist tare) and length
@@ -5727,32 +5828,41 @@ export class TrainSchedulingService {
); );
} }
// Where the train stands right now — the origin yard before departure, the
// checkpoint stop after it. Null = rolling; the consist is view-only then.
const currentYardId = await this.currentConsistYardId(schedule);
const wagons = await this.dataSource.getRepository(Wagon).find({ const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id }, where: { trainId: builtTrain.id },
relations: { wagonType: true }, relations: { wagonType: true },
order: { sequenceNumber: 'ASC' }, order: { sequenceNumber: 'ASC' },
}); });
const addableWagons = await this.dataSource.getRepository(Wagon).find({ const addableWagons = currentYardId
where: { ? await this.dataSource.getRepository(Wagon).find({
trainId: IsNull(), where: {
status: WagonStatus.Available, trainId: IsNull(),
currentYardId: builtTrain.currentYardId ?? undefined, status: WagonStatus.Available,
}, currentYardId,
relations: { wagonType: true }, },
order: { wagonNumber: 'ASC' }, relations: { wagonType: true },
}); order: { wagonNumber: 'ASC' },
})
: [];
const adjustments = await this.dataSource const adjustments = await this.dataSource
.getRepository(ScheduleWagonAdjustmentLog) .getRepository(ScheduleWagonAdjustmentLog)
.find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 }); .find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });
// Slots with cargo aboard — their physical wagons are "loaded" and can // Slots whose cargo still rides beyond the current stop — those wagons
// never be trimmed. // cannot be trimmed, only switched. Cargo offloaded at this stop (or
const loadedWagonIds = new Set( // earlier) has released its wagon.
(schedule.trainSet?.wagons ?? []) const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId);
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) // Only OTHER trains' pins block edits here — this train's own schedules
.map((slot) => slot.physicalWagonId as string), // (incl. the paired return run) have their pins managed by the edit itself
// (removal clears, switch re-points).
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(
undefined,
builtTrain.id,
); );
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
@@ -5817,12 +5927,23 @@ export class TrainSchedulingService {
bookingWindowStatus: schedule.bookingWindowStatus ?? null, bookingWindowStatus: schedule.bookingWindowStatus ?? null,
} }
: null, : null,
wagons: wagons.map((wagon) => ({ wagons: wagons.map((wagon) => {
...mapWagon(wagon), const loaded = loadedWagonIds.has(wagon.id);
loaded: loadedWagonIds.has(wagon.id), const pinnedElsewhere = pinnedToLiveIds.has(wagon.id);
// Free = not pinned to any live run's slot; only free wagons can be trimmed. return {
removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id), ...mapWagon(wagon),
})), loaded,
removable: !pinnedElsewhere && !loaded,
// A loaded wagon can't leave, but its SLOT can change wagon: switch
// moves the cargo allocations onto a same-type replacement.
switchable: !pinnedElsewhere,
blockReason: pinnedElsewhere
? 'Pinned by another live schedule'
: loaded
? 'Cargo aboard rides beyond this stop — switch it instead'
: null,
};
}),
addableWagons: addableWagons.map(mapWagon), addableWagons: addableWagons.map(mapWagon),
adjustments: adjustments.map((log) => ({ adjustments: adjustments.map((log) => ({
id: log.id, id: log.id,
@@ -5830,9 +5951,26 @@ export class TrainSchedulingService {
wagonId: log.wagonId, wagonId: log.wagonId,
wagonNumber: log.wagonNumber, wagonNumber: log.wagonNumber,
adjustedByUserId: log.adjustedByUserId, adjustedByUserId: log.adjustedByUserId,
yardId: log.yardId ?? null,
occurredAt: log.occurredAt, occurredAt: log.occurredAt,
})), })),
editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status), // Editable before departure, and after it whenever the train is standing
// at a route stop (mid-route wagon work at station B); frozen while
// rolling and once arrived/cancelled.
editable:
['DRAFT', 'SCHEDULED'].includes(schedule.status) ||
(schedule.status === TrainScheduleStatusEnum.Dispatched &&
currentYardId != null),
currentStop: currentYardId
? {
yardId: currentYardId,
label:
this.mapScheduleStops(schedule).find(
(s) => s.yardId === currentYardId,
)?.label ?? currentYardId,
isMidRoute: schedule.status === TrainScheduleStatusEnum.Dispatched,
}
: null,
}; };
} }
@@ -5851,19 +5989,38 @@ export class TrainSchedulingService {
) { ) {
const addWagonIds = [...new Set(dto.addWagonIds ?? [])]; const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])]; const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
if (!addWagonIds.length && !removeWagonIds.length) { const switches = dto.switches ?? [];
throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove'); if (!addWagonIds.length && !removeWagonIds.length && !switches.length) {
throw new BadRequestException(
'Nothing to adjust — pass wagons to add, remove and/or switch',
);
} }
const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id)); const switchFromIds = switches.map((s) => s.fromWagonId);
if (overlap.length) { const switchToIds = switches.map((s) => s.toWagonId);
throw new BadRequestException('A wagon cannot be added and removed in the same adjustment'); const touched = new Map<string, number>();
for (const id of [...addWagonIds, ...removeWagonIds, ...switchFromIds, ...switchToIds]) {
touched.set(id, (touched.get(id) ?? 0) + 1);
}
if ([...touched.values()].some((count) => count > 1)) {
throw new BadRequestException(
'Each wagon may appear once per adjustment — not in two lists or two switches',
);
} }
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { // Consist edits are open before departure, and after it whenever the train
// is STANDING AT a route stop (checkpointed): that is exactly the "switch
// wagons at station B" window. Rolling between stops → frozen.
const currentYardId = await this.currentConsistYardId(schedule);
const editableStatus =
['DRAFT', 'SCHEDULED'].includes(schedule.status) ||
schedule.status === TrainScheduleStatusEnum.Dispatched;
if (!editableStatus || !currentYardId) {
throw new BadRequestException( throw new BadRequestException(
'The consist is frozen once the train is dispatched — adjust before departure', schedule.status === TrainScheduleStatusEnum.Dispatched
? 'The train is rolling — consist changes are only possible while it stands at a route stop (latest checkpoint)'
: 'The consist can no longer be adjusted — the run is over',
); );
} }
const builtTrainRef = schedule.trainSet?.train; const builtTrainRef = schedule.trainSet?.train;
@@ -5872,11 +6029,9 @@ export class TrainSchedulingService {
'This schedule was not created from a built train — its consist cannot be adjusted here', 'This schedule was not created from a built train — its consist cannot be adjusted here',
); );
} }
const loadedWagonIds = new Set( // Wagons whose cargo still rides beyond the current stop: never removable,
(schedule.trainSet?.wagons ?? []) // but switchable — the replacement inherits the slot, cargo included.
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId);
.map((slot) => slot.physicalWagonId as string),
);
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const pullCapTons = roundTons( const pullCapTons = roundTons(
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0), Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
@@ -5899,25 +6054,41 @@ export class TrainSchedulingService {
}); });
const consistById = new Map(consist.map((w) => [w.id, w])); const consistById = new Map(consist.map((w) => [w.id, w]));
// --- validate removals: must be coupled and free (no cargo, no pin) --- // --- validate removals: coupled, cargo offloaded, no foreign pin ---
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager); const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(
manager,
train.id,
);
// Every live train set of THIS built train (this run + siblings, e.g.
// the paired return leg) — their pins follow the consist edit.
const ownSetIds = (
await manager.getRepository(TrainSet).find({
where: { trainId: train.id },
select: { id: true },
})
).map((set) => set.id);
const removed: Wagon[] = []; const removed: Wagon[] = [];
for (const wagonId of removeWagonIds) { for (const wagonId of removeWagonIds) {
const wagon = consistById.get(wagonId); const wagon = consistById.get(wagonId);
if (!wagon) { if (!wagon) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`); throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
} }
if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) { if (loadedWagonIds.has(wagon.id)) {
throw new ConflictException( throw new ConflictException(
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`, `Wagon ${wagon.wagonNumber} carries cargo riding beyond this stop — it cannot be trimmed, only switched`,
);
}
if (pinnedToLiveIds.has(wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned by another live schedule and cannot be trimmed`,
); );
} }
removed.push(wagon); removed.push(wagon);
} }
// --- validate additions: AVAILABLE, loose, standing in the train's yard --- // Shared gate for every incoming wagon (couple or switch replacement):
const added: Wagon[] = []; // AVAILABLE, loose, and standing where the train stands right now.
for (const wagonId of addWagonIds) { const lockIncomingWagon = async (wagonId: string): Promise<Wagon> => {
// No `relations` on this query: Postgres refuses FOR UPDATE through the // No `relations` on this query: Postgres refuses FOR UPDATE through the
// nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be // nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be
// applied to the nullable side of an outer join"). Lock the row alone, // applied to the nullable side of an outer join"). Lock the row alone,
@@ -5935,21 +6106,57 @@ export class TrainSchedulingService {
`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`, `Wagon ${wagon.wagonNumber} is not available (${wagon.status})`,
); );
} }
if (wagon.currentYardId !== train.currentYardId) { if (wagon.currentYardId !== currentYardId) {
throw new BadRequestException( throw new BadRequestException(
`Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`, `Wagon ${wagon.wagonNumber} is not at the train's current stop — only wagons standing there can be coupled`,
); );
} }
wagon.wagonType = wagon.wagonType =
(await manager (await manager
.getRepository(WagonType) .getRepository(WagonType)
.findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined; .findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined;
added.push(wagon); return wagon;
};
const added: Wagon[] = [];
for (const wagonId of addWagonIds) {
added.push(await lockIncomingWagon(wagonId));
} }
// --- headroom check (only additions can push the train over a cap) --- // --- validate switches: outgoing coupled + not foreign-pinned; the
// replacement passes the incoming gate AND matches the wagon type, so
// the slot's cargo (weight, TEU geometry) rides it unchanged ---
const switchPairs: Array<{ from: Wagon; to: Wagon }> = [];
for (const { fromWagonId, toWagonId } of switches) {
const from = consistById.get(fromWagonId);
if (!from) {
throw new NotFoundException(
`Wagon ${fromWagonId} is not coupled to train ${train.code}`,
);
}
if (pinnedToLiveIds.has(from.id)) {
throw new ConflictException(
`Wagon ${from.wagonNumber} is pinned by another live schedule and cannot be switched`,
);
}
const to = await lockIncomingWagon(toWagonId);
if (to.wagonTypeId !== from.wagonTypeId) {
throw new BadRequestException(
`Wagon ${to.wagonNumber} (${to.wagonType?.code ?? 'unknown type'}) is not the same type as ${from.wagonNumber} (${from.wagonType?.code ?? 'unknown type'}) — a switch must not change what the slot can carry`,
);
}
switchPairs.push({ from, to });
}
// --- headroom check (only additions can push the train over a cap;
// switches are same-type and cancel out, but are computed honestly) ---
const removedIds = new Set(removed.map((w) => w.id)); const removedIds = new Set(removed.map((w) => w.id));
const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added]; const switchedFromIds = new Set(switchPairs.map((p) => p.from.id));
const finalConsist = [
...consist.filter((w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id)),
...added,
...switchPairs.map((p) => p.to),
];
const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0); const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0); const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0)); const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
@@ -5967,21 +6174,72 @@ export class TrainSchedulingService {
); );
} }
// --- apply: detach trims, couple additions, compact the sequence --- // --- apply: detach trims, couple additions, swap switches, compact ---
// A wagon leaving the train stands wherever the train stands — stamping
// the stop yard is what makes it findable (and re-couplable) at B.
const detachPatch = {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
trainSetWagonId: null,
currentTrainScheduleId: null,
currentYardId,
};
for (const wagon of removed) { for (const wagon of removed) {
await manager.getRepository(Wagon).update(wagon.id, { await manager.getRepository(Wagon).update(wagon.id, detachPatch);
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
});
} }
const remaining = consist.filter((w) => !removedIds.has(w.id)); if (removed.length && ownSetIds.length) {
for (let i = 0; i < remaining.length; i++) { // This train's own pins (all its runs) on trimmed wagons are stale —
if (remaining[i].sequenceNumber !== i + 1) { // clear them so the freed wagon isn't still claimed by slots it left.
await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 }); await manager
.getRepository(TrainSetWagon)
.update(
{ trainSetId: In(ownSetIds), physicalWagonId: In(removed.map((w) => w.id)) },
{ physicalWagonId: null },
);
}
// Switches: the replacement takes the outgoing wagon's position AND its
// slot pins, so every cargo allocation now rides the new wagon. The
// outgoing wagon is left standing at the stop.
for (const { from, to } of switchPairs) {
const slots = ownSetIds.length
? await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: In(ownSetIds), physicalWagonId: from.id },
})
: [];
for (const slot of slots) {
await manager
.getRepository(TrainSetWagon)
.update(slot.id, { physicalWagonId: to.id });
}
const ownSlot =
slots.find((slot) => slot.trainSetId === schedule.trainSetId) ?? slots[0];
await manager.getRepository(Wagon).update(to.id, {
trainId: train.id,
sequenceNumber: from.sequenceNumber,
status: WagonStatus.Assigned,
trainSetWagonId: ownSlot?.id ?? null,
currentTrainScheduleId: from.currentTrainScheduleId ?? null,
});
// Mirror on the in-memory row — the compaction below sorts by it.
to.sequenceNumber = from.sequenceNumber;
await manager.getRepository(Wagon).update(from.id, detachPatch);
}
const remaining = consist.filter(
(w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id),
);
const switchedIn = switchPairs.map((p) => p.to);
const compacted = [...remaining, ...switchedIn].sort(
(a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0),
);
for (let i = 0; i < compacted.length; i++) {
if (compacted[i].sequenceNumber !== i + 1) {
await manager.getRepository(Wagon).update(compacted[i].id, { sequenceNumber: i + 1 });
} }
} }
let sequence = remaining.length; let sequence = compacted.length;
for (const wagon of added) { for (const wagon of added) {
sequence += 1; sequence += 1;
await manager.getRepository(Wagon).update(wagon.id, { await manager.getRepository(Wagon).update(wagon.id, {
@@ -6000,16 +6258,31 @@ export class TrainSchedulingService {
const now = new Date(); const now = new Date();
await logRepo.save( await logRepo.save(
[ [
...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })), ...removed.map((wagon) => ({
...added.map((wagon) => ({ action: 'ADD' as const, wagon })), action: 'REMOVE' as const,
].map(({ action, wagon }) => wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
})),
...added.map((wagon) => ({
action: 'ADD' as const,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
})),
...switchPairs.map(({ from, to }) => ({
action: 'SWITCH' as const,
wagonId: to.id,
// varchar(50) — two long wagon numbers could overflow the column.
wagonNumber: `${from.wagonNumber}${to.wagonNumber}`.slice(0, 50),
})),
].map((entry) =>
logRepo.create({ logRepo.create({
trainScheduleId: scheduleId, trainScheduleId: scheduleId,
trainId: train.id, trainId: train.id,
action, action: entry.action,
wagonId: wagon.id, wagonId: entry.wagonId,
wagonNumber: wagon.wagonNumber, wagonNumber: entry.wagonNumber,
adjustedByUserId: userId ?? null, adjustedByUserId: userId ?? null,
yardId: currentYardId,
occurredAt: now, occurredAt: now,
}), }),
), ),
@@ -6049,6 +6322,72 @@ export class TrainSchedulingService {
return { ...(await this.getScheduleConsist(scheduleId)), warnings }; return { ...(await this.getScheduleConsist(scheduleId)), warnings };
} }
/**
* Unified change history for the schedule detail "History" tab: wagon
* consist adjustments (ADD / REMOVE / SWITCH, with the stop they happened
* at) merged with booking composition removals, newest first. Actor resolves
* through iam.users; rows survive wagon/train deletion (log tables carry
* plain columns, no FKs).
*/
async getScheduleHistory(scheduleId: string) {
type HistoryRow = {
id: string;
kind: 'WAGON' | 'BOOKING';
action: string;
subject: string | null;
yardLabel: string | null;
actor: string | null;
note: string | null;
occurredAt: Date;
};
const wagonRows: HistoryRow[] = (
await this.dataSource.query(
`SELECT l.id,
l.action,
l.wagon_number AS "subject",
COALESCE(y.label, y.code) AS "yardLabel",
COALESCE(u.username, u.email) AS "actor",
l.occurred_at AS "occurredAt"
FROM freight.schedule_wagon_adjustment_logs l
LEFT JOIN freight.yards y ON y.id = l.yard_id
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
WHERE l.train_schedule_id = $1
AND l.deleted_at IS NULL
ORDER BY l.occurred_at DESC
LIMIT 200`,
[scheduleId],
)
).map((r: Omit<HistoryRow, 'kind' | 'note'>) => ({
...r,
kind: 'WAGON' as const,
note: null,
}));
const bookingRows: HistoryRow[] = (
await this.dataSource.query(
`SELECT r.id,
r.booking_reference AS "subject",
r.notes AS "note",
COALESCE(u.username, u.email) AS "actor",
r.removed_at AS "occurredAt"
FROM freight.train_composition_removal_logs r
LEFT JOIN iam.users u ON u.id = r.removed_by_user_id
WHERE r.schedule_id = $1
AND r.deleted_at IS NULL
ORDER BY r.removed_at DESC
LIMIT 200`,
[scheduleId],
)
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'yardLabel'>) => ({
...r,
kind: 'BOOKING' as const,
action: 'BOOKING_REMOVED',
yardLabel: null,
}));
return [...wagonRows, ...bookingRows].sort(
(a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
}
/** /**
* Re-derive a built train's lifecycle status from its schedules after one of * Re-derive a built train's lifecycle status from its schedules after one of
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED → * them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
@@ -6938,11 +7277,21 @@ export class TrainSchedulingService {
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]), (snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
); );
// Booking has no ORM relation to Contract (FK only) — fetched separately
// by id so the "on this train" cards can show the contract reference.
const contractIds = [
...new Set(
(schedule.scheduleBookings ?? [])
.map((sb) => sb.booking?.contractId)
.filter((id): id is string => Boolean(id)),
),
];
// All independent lookups fired at once — they used to run one after // All independent lookups fired at once — they used to run one after
// another, stacking round-trips onto every detail request. // another, stacking round-trips onto every detail request.
// tareDims: booking weights are reported GROSS (cargo + wagon tare) — the // tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
// number the locomotive actually hauls against its pull limit. // number the locomotive actually hauls against its pull limit.
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] = const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons, contracts] =
await Promise.all([ await Promise.all([
this.loadWagonTareDims(), this.loadWagonTareDims(),
requiresLoadingConfirmation requiresLoadingConfirmation
@@ -6972,7 +7321,13 @@ export class TrainSchedulingService {
order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' }, order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
}) })
: [], : [],
contractIds.length
? this.dataSource
.getRepository(Contract)
.find({ where: { id: In(contractIds) }, select: { id: true, reference: true } })
: [],
]); ]);
const contractReferenceById = new Map(contracts.map((c) => [c.id, c.reference]));
const loadingConfirmed = requiresLoadingConfirmation const loadingConfirmed = requiresLoadingConfirmation
? Boolean(importOp?.loadedOnTrainAt) ? Boolean(importOp?.loadedOnTrainAt)
: true; : true;
@@ -7315,6 +7670,10 @@ export class TrainSchedulingService {
sb.booking?.destinationYard?.code ?? sb.booking?.destinationYard?.code ??
null, null,
wagonsRequired: sb.booking ? this.effectiveWagonsRequired(sb.booking) : null, wagonsRequired: sb.booking ? this.effectiveWagonsRequired(sb.booking) : null,
contractReference:
(sb.booking?.contractId
? contractReferenceById.get(sb.booking.contractId)
: null) ?? null,
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null, loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null, arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
// Loaded/unloaded is tracked on the schedule↔booking link, not the // Loaded/unloaded is tracked on the schedule↔booking link, not the

View File

@@ -9,16 +9,18 @@ import {
Modal, Modal,
Progress, Progress,
ScrollArea, ScrollArea,
Select,
Stack, Stack,
Text, Text,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { AlertTriangle, History, Minus, Plus } from "lucide-react"; import { AlertTriangle, ArrowLeftRight, History, MapPin, Minus, Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { ConsistWagonRef } from "@/services/trainBuilder.service"; import type { ConsistWagonRef, ScheduleConsist } from "@/services/trainBuilder.service";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
const parseError = (error: unknown, fallback: string) => { const parseError = (error: unknown, fallback: string) => {
@@ -34,11 +36,16 @@ const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0;
const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0; const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0;
const round2 = (v: number) => Math.round(v * 100) / 100; const round2 = (v: number) => Math.round(v * 100) / 100;
type ConsistWagon = ScheduleConsist["wagons"][number];
/** /**
* Adjust the built train's consist from a schedule: trim free wagons (their * Adjust the built train's consist from a schedule: trim free wagons (their
* tare no longer rides — the fix when gross weight beats the pull limit) or * tare no longer rides — the fix when gross weight beats the pull limit),
* couple extra yard wagons while weight/length headroom remains. Changes are * couple extra yard wagons while weight/length headroom remains, or SWITCH a
* permanent on the train and logged on the schedule. * wagon for a same-type replacement the replacement inherits the slot, cargo
* included, which is the only way a loaded wagon leaves the train. Works
* before departure and mid-route while the train stands at a checkpointed
* stop. Changes are permanent on the train and logged on the schedule.
*/ */
export default function AdjustConsistModal({ export default function AdjustConsistModal({
scheduleId, scheduleId,
@@ -48,6 +55,9 @@ export default function AdjustConsistModal({
const { toast } = useToast(); const { toast } = useToast();
const [removeIds, setRemoveIds] = useState<string[]>([]); const [removeIds, setRemoveIds] = useState<string[]>([]);
const [addIds, setAddIds] = useState<string[]>([]); const [addIds, setAddIds] = useState<string[]>([]);
// fromWagonId → toWagonId. A switch is same-type, so it never moves the
// weight/length/slot projections — it only changes which steel rides.
const [switchMap, setSwitchMap] = useState<Record<string, string>>({});
const consistQuery = useQuery( const consistQuery = useQuery(
api.trainScheduling.scheduleConsist.queryOptions({ api.trainScheduling.scheduleConsist.queryOptions({
@@ -62,13 +72,20 @@ export default function AdjustConsistModal({
if (opened) { if (opened) {
setRemoveIds([]); setRemoveIds([]);
setAddIds([]); setAddIds([]);
setSwitchMap({});
} }
}, [opened]); }, [opened]);
const switchCount = Object.keys(switchMap).length;
const usedReplacementIds = useMemo(
() => new Set(Object.values(switchMap)),
[switchMap],
);
// Live projection: gross = cargo + tare of (consist trims + adds), plus // Live projection: gross = cargo + tare of (consist trims + adds), plus
// the schedule's wagon-slot picture — the consist IS the booking capacity // the schedule's wagon-slot picture — the consist IS the booking capacity
// (weight/length only bind while assembling the consist), so trims/adds // (weight/length only bind while assembling the consist), so trims/adds
// move the FULL line in real time. // move the FULL line in real time. Switches are same-type and cancel out.
const projection = useMemo(() => { const projection = useMemo(() => {
if (!data) return null; if (!data) return null;
const removed = new Set(removeIds); const removed = new Set(removeIds);
@@ -117,25 +134,58 @@ export default function AdjustConsistModal({
}; };
}, [data, removeIds, addIds]); }, [data, removeIds, addIds]);
const hasChanges = removeIds.length > 0 || addIds.length > 0; const hasChanges = removeIds.length > 0 || addIds.length > 0 || switchCount > 0;
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) => const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id))); setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
// Same-type replacements standing at the current stop, minus wagons already
// spoken for by another switch or a couple selection.
const switchOptionsFor = (wagon: ConsistWagon) =>
(data?.addableWagons ?? [])
.filter(
(candidate) =>
candidate.wagonType?.id === wagon.wagonType?.id &&
!addIds.includes(candidate.id) &&
(!usedReplacementIds.has(candidate.id) ||
switchMap[wagon.id] === candidate.id),
)
.map((candidate) => ({ value: candidate.id, label: candidate.wagonNumber }));
const setSwitch = (fromId: string, toId: string | null) =>
setSwitchMap((prev) => {
const next = { ...prev };
if (toId) next[fromId] = toId;
else delete next[fromId];
return next;
});
const handleSubmit = async () => { const handleSubmit = async () => {
if (!removeIds.length && !addIds.length) return; if (!hasChanges) return;
try { try {
const result = await adjust.mutateAsync({ const result = await adjust.mutateAsync({
scheduleId, scheduleId,
payload: { payload: {
...(addIds.length ? { addWagonIds: addIds } : {}), ...(addIds.length ? { addWagonIds: addIds } : {}),
...(removeIds.length ? { removeWagonIds: removeIds } : {}), ...(removeIds.length ? { removeWagonIds: removeIds } : {}),
...(switchCount
? {
switches: Object.entries(switchMap).map(([fromWagonId, toWagonId]) => ({
fromWagonId,
toWagonId,
})),
}
: {}),
}, },
}); });
toast({ toast({
title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${ title: `Consist updated — ${[
removeIds.length && addIds.length ? ", " : "" removeIds.length ? `${removeIds.length} trimmed` : "",
}${addIds.length ? `${addIds.length} added` : ""}`, addIds.length ? `${addIds.length} added` : "",
switchCount ? `${switchCount} switched` : "",
]
.filter(Boolean)
.join(", ")}`,
}); });
// Schedule-impact warnings from the API: window reopened / now FULL / // Schedule-impact warnings from the API: window reopened / now FULL /
// consist trimmed below what bookings already hold. // consist trimmed below what bookings already hold.
@@ -151,6 +201,7 @@ export default function AdjustConsistModal({
} }
setRemoveIds([]); setRemoveIds([]);
setAddIds([]); setAddIds([]);
setSwitchMap({});
} catch (err) { } catch (err) {
toast({ toast({
title: "Adjustment failed", title: "Adjustment failed",
@@ -170,7 +221,7 @@ export default function AdjustConsistModal({
</Text> </Text>
} }
radius="lg" radius="lg"
size={860} size={920}
centered centered
> >
{consistQuery.isLoading || !data ? ( {consistQuery.isLoading || !data ? (
@@ -181,9 +232,19 @@ export default function AdjustConsistModal({
</Text> </Text>
) : ( ) : (
<Stack gap="md"> <Stack gap="md">
{data.currentStop?.isMidRoute ? (
<Alert color="blue" icon={<MapPin size={16} />}>
Standing at <strong>{data.currentStop.label}</strong> mid-route
consist work is open: couple or switch wagons standing at this
stop, trim wagons whose cargo was offloaded here. Detached wagons
stay at {data.currentStop.label}.
</Alert>
) : null}
{!data.editable ? ( {!data.editable ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}> <Alert color="yellow" icon={<AlertTriangle size={16} />}>
The consist is frozen once the train is dispatched. {data.schedule.status === "DISPATCHED"
? "The train is rolling — consist changes are only possible while it stands at a route stop."
: "The consist can no longer be adjusted — the run is over."}
</Alert> </Alert>
) : null} ) : null}
@@ -256,37 +317,39 @@ export default function AdjustConsistModal({
) : null} ) : null}
<Grid gap="md"> <Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}> <Grid.Col span={{ base: 12, md: 7 }}>
<Stack gap="xs"> <Stack gap="xs">
<Group gap={6}> <Group gap={6}>
<Minus size={14} /> <Minus size={14} />
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
Trim coupled wagons ({data.totals.wagonCount}) Coupled wagons ({data.totals.wagonCount})
</Text> </Text>
</Group> </Group>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Only free (unloaded, unpinned) wagons can be detached. Detaching is Trim only wagons carrying nothing beyond this stop. A loaded
permanent the wagon returns to the yard as available. wagon can't leave — but it can be <strong>switched</strong>:
the same-type replacement takes its position and its cargo
slot. Detaching is permanent.
</Text> </Text>
<ScrollArea.Autosize mah={260} type="auto"> <ScrollArea.Autosize mah={280} type="auto">
<Stack gap={4}> <Stack gap={4}>
{data.wagons.map((wagon) => ( {data.wagons.map((wagon) => (
<WagonRow <CoupledWagonRow
key={wagon.id} key={wagon.id}
wagon={wagon} wagon={wagon}
checked={removeIds.includes(wagon.id)} checked={removeIds.includes(wagon.id)}
disabled={!data.editable || !wagon.removable} editable={data.editable}
badge={ switchValue={switchMap[wagon.id] ?? null}
wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null switchOptions={switchOptionsFor(wagon)}
} onToggleRemove={toggle(setRemoveIds)}
onToggle={toggle(setRemoveIds)} onSwitch={setSwitch}
/> />
))} ))}
</Stack> </Stack>
</ScrollArea.Autosize> </ScrollArea.Autosize>
</Stack> </Stack>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}> <Grid.Col span={{ base: 12, md: 5 }}>
<Stack gap="xs"> <Stack gap="xs">
<Group gap={6}> <Group gap={6}>
<Plus size={14} /> <Plus size={14} />
@@ -295,25 +358,30 @@ export default function AdjustConsistModal({
</Text> </Text>
</Group> </Group>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
AVAILABLE wagons standing in the train's yard. Blocked when they push AVAILABLE wagons standing at{" "}
gross weight or length past the locomotive limits incl. tolerance. {data.currentStop?.label ?? "the train's yard"}. Blocked when
they push gross weight or length past the locomotive limits
incl. tolerance.
</Text> </Text>
<ScrollArea.Autosize mah={260} type="auto"> <ScrollArea.Autosize mah={280} type="auto">
<Stack gap={4}> <Stack gap={4}>
{data.addableWagons.length ? ( {data.addableWagons.length ? (
data.addableWagons.map((wagon) => ( data.addableWagons.map((wagon) => {
<WagonRow const takenBySwitch = usedReplacementIds.has(wagon.id);
key={wagon.id} return (
wagon={wagon} <AddableWagonRow
checked={addIds.includes(wagon.id)} key={wagon.id}
disabled={!data.editable} wagon={wagon}
badge={null} checked={addIds.includes(wagon.id)}
onToggle={toggle(setAddIds)} disabled={!data.editable || takenBySwitch}
/> badge={takenBySwitch ? "Switch target" : null}
)) onToggle={toggle(setAddIds)}
/>
);
})
) : ( ) : (
<Text size="sm" c="dimmed" py="sm" ta="center"> <Text size="sm" c="dimmed" py="sm" ta="center">
No available wagons in this yard No available wagons at this stop
</Text> </Text>
)} )}
</Stack> </Stack>
@@ -322,6 +390,19 @@ export default function AdjustConsistModal({
</Grid.Col> </Grid.Col>
</Grid> </Grid>
{switchCount ? (
<Alert color="blue" icon={<ArrowLeftRight size={16} />} py={8}>
{Object.entries(switchMap)
.map(([fromId, toId]) => {
const from = data.wagons.find((w) => w.id === fromId);
const to = data.addableWagons.find((w) => w.id === toId);
return `${from?.wagonNumber ?? "?"} → ${to?.wagonNumber ?? "?"}`;
})
.join(" · ")}{" "}
— cargo allocations move to the replacement wagon(s).
</Alert>
) : null}
{data.adjustments.length ? ( {data.adjustments.length ? (
<> <>
<Divider /> <Divider />
@@ -339,9 +420,19 @@ export default function AdjustConsistModal({
<Badge <Badge
size="xs" size="xs"
variant="light" variant="light"
color={log.action === "ADD" ? "edr-green" : "red"} color={
log.action === "ADD"
? "edr-green"
: log.action === "SWITCH"
? "blue"
: "red"
}
> >
{log.action === "ADD" ? "Added" : "Trimmed"} {log.action === "ADD"
? "Added"
: log.action === "SWITCH"
? "Switched"
: "Trimmed"}
</Badge> </Badge>
<Text size="xs" ff="monospace"> <Text size="xs" ff="monospace">
{log.wagonNumber} {log.wagonNumber}
@@ -369,15 +460,19 @@ export default function AdjustConsistModal({
loading={adjust.isPending} loading={adjust.isPending}
disabled={ disabled={
!data.editable || !data.editable ||
(!removeIds.length && !addIds.length) || !hasChanges ||
(addIds.length > 0 && (projection?.overWeight || projection?.overLength)) (addIds.length > 0 && (projection?.overWeight || projection?.overLength))
} }
onClick={handleSubmit} onClick={handleSubmit}
> >
Apply{" "} Apply{" "}
{removeIds.length ? `${removeIds.length}` : ""} {[
{removeIds.length && addIds.length ? " / " : ""} removeIds.length ? `${removeIds.length}` : "",
{addIds.length ? `+${addIds.length}` : ""} addIds.length ? `+${addIds.length}` : "",
switchCount ? `⇄${switchCount}` : "",
]
.filter(Boolean)
.join(" / ")}
</Button> </Button>
</Group> </Group>
</Group> </Group>
@@ -429,7 +524,89 @@ function LimitGauge({
); );
} }
function WagonRow({ /** Coupled row: trim checkbox (reason-badged when blocked) + switch picker. */
function CoupledWagonRow({
wagon,
checked,
editable,
switchValue,
switchOptions,
onToggleRemove,
onSwitch,
}: {
wagon: ConsistWagon;
checked: boolean;
editable: boolean;
switchValue: string | null;
switchOptions: Array<{ value: string; label: string }>;
onToggleRemove: (id: string, checked: boolean) => void;
onSwitch: (fromId: string, toId: string | null) => void;
}) {
const badge = wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null;
const checkbox = (
<Checkbox
size="sm"
checked={checked}
disabled={!editable || !wagon.removable || Boolean(switchValue)}
onChange={(e) => onToggleRemove(wagon.id, e.currentTarget.checked)}
aria-label={`Trim wagon ${wagon.wagonNumber}`}
/>
);
return (
<Group
gap="sm"
wrap="nowrap"
p={6}
style={{
border: switchValue
? "1px solid var(--mantine-color-blue-4)"
: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-md)",
background: switchValue ? "var(--mantine-color-blue-0)" : undefined,
}}
>
{wagon.blockReason ? (
<Tooltip label={wagon.blockReason} withArrow>
<span>{checkbox}</span>
</Tooltip>
) : (
checkbox
)}
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m`
: "Unknown type"}
</Text>
</Stack>
{badge ? (
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}>
{badge}
</Badge>
) : null}
{editable && wagon.switchable && switchOptions.length ? (
<Select
size="xs"
w={148}
placeholder="Switch with"
leftSection={<ArrowLeftRight size={12} />}
data={switchOptions}
value={switchValue}
onChange={(toId) => onSwitch(wagon.id, toId)}
clearable
searchable
disabled={checked}
aria-label={`Switch wagon ${wagon.wagonNumber}`}
/>
) : null}
</Group>
);
}
function AddableWagonRow({
wagon, wagon,
checked, checked,
disabled, disabled,
@@ -471,7 +648,7 @@ function WagonRow({
</Text> </Text>
</Stack> </Stack>
{badge ? ( {badge ? (
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}> <Badge size="xs" variant="light" color="blue">
{badge} {badge}
</Badge> </Badge>
) : null} ) : null}

View File

@@ -574,6 +574,11 @@ export function AllocateBookingWizard({
id: b.id, id: b.id,
reference: b.reference ?? b.id.slice(0, 8), reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons, weightTons: b.weightTons,
isGovernment: b.isGovernment,
wagonsRequired: b.wagonsRequired,
contractReference: b.contractReference,
origin: b.origin,
destination: b.destination,
}))} }))}
eligibleItems={eligibleQuery.data?.items ?? []} eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading} eligibleLoading={eligibleQuery.isLoading}

View File

@@ -1,8 +1,10 @@
import { useMemo } from "react"; import { Fragment, useMemo, useState } from "react";
import { import {
Alert, Alert,
Badge, Badge,
Box, Box,
Collapse,
Group,
Paper, Paper,
Progress, Progress,
Stack, Stack,
@@ -10,7 +12,7 @@ import {
Text, Text,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { Info } from "lucide-react"; import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react";
import type { TrainScheduleDetail } from "@/types/trainScheduling"; import type { TrainScheduleDetail } from "@/types/trainScheduling";
@@ -27,6 +29,13 @@ interface Stop {
label: string; label: string;
} }
interface LegBookingUsage {
bookingId: string;
reference: string;
wagons: number;
grossTons: number;
}
interface EdgeUsage { interface EdgeUsage {
edge: number; edge: number;
from: Stop; from: Stop;
@@ -35,6 +44,7 @@ interface EdgeUsage {
grossTons: number; grossTons: number;
lengthMeters: number; lengthMeters: number;
bookingRefs: string[]; bookingRefs: string[];
bookings: LegBookingUsage[];
} }
const round1 = (n: number) => Math.round(n * 10) / 10; const round1 = (n: number) => Math.round(n * 10) / 10;
@@ -90,6 +100,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
const weightCap = schedule.maxGrossWeightTons ?? null; const weightCap = schedule.maxGrossWeightTons ?? null;
const lengthCap = schedule.maxLengthMeters ?? null; const lengthCap = schedule.maxLengthMeters ?? null;
const wagonCap = schedule.maxWagons ?? null; const wagonCap = schedule.maxWagons ?? null;
const [expandedEdge, setExpandedEdge] = useState<number | null>(null);
const edges: EdgeUsage[] = useMemo(() => { const edges: EdgeUsage[] = useMemo(() => {
if (stops.length < 2) return []; if (stops.length < 2) return [];
@@ -105,13 +116,36 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
const refs = new Set<string>(); const refs = new Set<string>();
let grossTons = 0; let grossTons = 0;
let lengthMeters = 0; let lengthMeters = 0;
// Per booking on this leg: wagon count (distinct wagons carrying at
// least one of its allocations — a shared wagon counts for each
// booking riding it, so per-booking wagon counts can sum to more than
// the leg's total) and its allocated weight share.
const byBooking = new Map<string, LegBookingUsage>();
for (const w of active) { for (const w of active) {
grossTons += (Number(w.tareWeightTons) || 0) + (Number(w.assignedWeightTons) || 0); grossTons += (Number(w.tareWeightTons) || 0) + (Number(w.assignedWeightTons) || 0);
lengthMeters += Number(w.lengthMeters) || 0; lengthMeters += Number(w.lengthMeters) || 0;
const bookingIdsOnWagon = new Set<string>();
for (const a of w.allocations ?? []) { for (const a of w.allocations ?? []) {
if (a.bookingReference) refs.add(a.bookingReference); if (!a.bookingReference) continue;
refs.add(a.bookingReference);
const row = byBooking.get(a.bookingId) ?? {
bookingId: a.bookingId,
reference: a.bookingReference,
wagons: 0,
grossTons: 0,
};
row.grossTons += Number(a.allocatedWeightTons) || 0;
byBooking.set(a.bookingId, row);
bookingIdsOnWagon.add(a.bookingId);
}
for (const bookingId of bookingIdsOnWagon) {
const row = byBooking.get(bookingId);
if (row) row.wagons += 1;
} }
} }
const bookings = [...byBooking.values()]
.map((b) => ({ ...b, grossTons: round1(b.grossTons) }))
.sort((a, b) => b.grossTons - a.grossTons);
return { return {
edge, edge,
from, from,
@@ -120,6 +154,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
grossTons: round1(grossTons), grossTons: round1(grossTons),
lengthMeters: round1(lengthMeters), lengthMeters: round1(lengthMeters),
bookingRefs: [...refs], bookingRefs: [...refs],
bookings,
}; };
}); });
}, [stops, wagons]); }, [stops, wagons]);
@@ -190,6 +225,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
<Table verticalSpacing="sm" highlightOnHover> <Table verticalSpacing="sm" highlightOnHover>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th style={{ width: 28 }} />
<Table.Th>Leg</Table.Th> <Table.Th>Leg</Table.Th>
<Table.Th>Wagons</Table.Th> <Table.Th>Wagons</Table.Th>
<Table.Th>Gross weight</Table.Th> <Table.Th>Gross weight</Table.Th>
@@ -199,43 +235,107 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{edges.map((e) => ( {edges.map((e) => {
<Table.Tr key={e.edge}> const isOpen = expandedEdge === e.edge;
<Table.Td> const hasBookings = e.bookings.length > 0;
<Text size="sm" fw={600} style={{ whiteSpace: "nowrap" }}> return (
{e.from.label} {e.to.label} <Fragment key={e.edge}>
</Text> <Table.Tr
</Table.Td> style={{ cursor: hasBookings ? "pointer" : undefined }}
<Table.Td> onClick={
<UsageCell used={e.wagons} cap={wagonCap} unit="wagons" /> hasBookings
</Table.Td> ? () => setExpandedEdge(isOpen ? null : e.edge)
<Table.Td> : undefined
<UsageCell used={e.grossTons} cap={weightCap} unit="T" /> }
</Table.Td> >
<Table.Td> <Table.Td>
<UsageCell used={e.lengthMeters} cap={lengthCap} unit="m" /> {hasBookings ? (
</Table.Td> isOpen ? (
<Table.Td> <ChevronDown size={14} />
{e.bookingRefs.length ? ( ) : (
<Tooltip <ChevronRight size={14} />
label={e.bookingRefs.join(", ")} )
multiline ) : null}
maw={320} </Table.Td>
withArrow <Table.Td>
> <Text size="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
<Text size="sm" style={{ cursor: "help" }}> {e.from.label} {e.to.label}
{e.bookingRefs.length}
</Text> </Text>
</Tooltip> </Table.Td>
) : ( <Table.Td>
<Text size="sm" c="dimmed"> <UsageCell used={e.wagons} cap={wagonCap} unit="wagons" />
0 </Table.Td>
</Text> <Table.Td>
)} <UsageCell used={e.grossTons} cap={weightCap} unit="T" />
</Table.Td> </Table.Td>
<Table.Td>{legStatus(e)}</Table.Td> <Table.Td>
</Table.Tr> <UsageCell used={e.lengthMeters} cap={lengthCap} unit="m" />
))} </Table.Td>
<Table.Td>
{hasBookings ? (
<Badge variant="light" color="gray" size="sm">
{e.bookings.length}
</Badge>
) : (
<Text size="sm" c="dimmed">
0
</Text>
)}
</Table.Td>
<Table.Td>{legStatus(e)}</Table.Td>
</Table.Tr>
{hasBookings ? (
<Table.Tr key={`${e.edge}-detail`}>
<Table.Td colSpan={7} p={0} style={{ border: 0 }}>
<Collapse expanded={isOpen}>
<Box
p="sm"
style={{
background: "var(--mantine-color-gray-0)",
borderTop: "1px solid var(--mantine-color-gray-2)",
borderBottom: "1px solid var(--mantine-color-gray-2)",
}}
>
<Text size="xs" fw={600} c="dimmed" mb={6} tt="uppercase">
Bookings riding {e.from.label} {e.to.label}
</Text>
<Table verticalSpacing={4} withRowBorders={false}>
<Table.Tbody>
{e.bookings.map((b) => (
<Table.Tr key={b.bookingId}>
<Table.Td w="40%">
<Text size="sm" fw={500}>
{b.reference}
</Text>
</Table.Td>
<Table.Td w="30%">
<Group gap={4}>
<Train size={12} />
<Text size="xs" c="dimmed">
{b.wagons} wagon{b.wagons === 1 ? "" : "s"}
</Text>
</Group>
</Table.Td>
<Table.Td w="30%">
<Group gap={4}>
<Weight size={12} />
<Text size="xs" c="dimmed">
{b.grossTons}T
</Text>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</Collapse>
</Table.Td>
</Table.Tr>
) : null}
</Fragment>
);
})}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Table.ScrollContainer> </Table.ScrollContainer>

View File

@@ -7,7 +7,7 @@ import {
Tabs, Tabs,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { ArrowRight, Landmark, Package, Train } from "lucide-react"; import { ArrowRight, FileText, Landmark, MapPin, Package, Train } from "lucide-react";
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling"; import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
@@ -18,6 +18,10 @@ export type AssignedBookingRow = {
reference: string; reference: string;
weightTons?: number; weightTons?: number;
isGovernment?: boolean; isGovernment?: boolean;
wagonsRequired?: number | null;
contractReference?: string | null;
origin?: string | null;
destination?: string | null;
}; };
export function ScheduleBookingsStep({ export function ScheduleBookingsStep({
@@ -94,6 +98,16 @@ export function ScheduleBookingsStep({
{booking.weightTons}T {booking.weightTons}T
</Badge> </Badge>
) : null} ) : null}
{booking.wagonsRequired != null ? (
<Badge
variant="outline"
size="xs"
color="gray"
leftSection={<Train size={10} />}
>
{booking.wagonsRequired} wagon{booking.wagonsRequired === 1 ? "" : "s"}
</Badge>
) : null}
{booking.isGovernment ? ( {booking.isGovernment ? (
<Badge <Badge
variant="light" variant="light"
@@ -105,6 +119,26 @@ export function ScheduleBookingsStep({
</Badge> </Badge>
) : null} ) : null}
</Group> </Group>
{booking.contractReference || booking.origin || booking.destination ? (
<Group gap="xs">
{booking.contractReference ? (
<Group gap={4}>
<FileText size={12} />
<Text size="xs" c="dimmed">
{booking.contractReference}
</Text>
</Group>
) : null}
{booking.origin || booking.destination ? (
<Group gap={4}>
<MapPin size={12} />
<Text size="xs" c="dimmed">
{booking.origin ?? "?"} {booking.destination ?? "?"}
</Text>
</Group>
) : null}
</Group>
) : null}
<Group gap={6}> <Group gap={6}>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Assigned to this consist Assigned to this consist

View File

@@ -0,0 +1,132 @@
import {
Badge,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Timeline,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
ArrowLeftRight,
History,
MapPin,
Minus,
PackageMinus,
Plus,
User,
} from "lucide-react";
import { api } from "@/services/api";
import type { ScheduleHistoryEntry } from "@/services/trainBuilder.service";
const ACTION_META: Record<
ScheduleHistoryEntry["action"],
{ label: string; color: string; icon: typeof Plus }
> = {
ADD: { label: "Wagon coupled", color: "edr-green", icon: Plus },
REMOVE: { label: "Wagon trimmed", color: "red", icon: Minus },
SWITCH: { label: "Wagon switched", color: "blue", icon: ArrowLeftRight },
BOOKING_REMOVED: { label: "Booking removed", color: "orange", icon: PackageMinus },
};
/**
* "History" tab: every change made to the train after it was scheduled —
* wagons coupled/trimmed/switched (with the stop where it happened) and
* bookings removed from the composition — newest first.
*/
export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: string }) {
const historyQuery = useQuery(
api.trainScheduling.scheduleHistory.queryOptions({
input: { scheduleId },
enabled: Boolean(scheduleId),
}),
);
const entries = historyQuery.data ?? [];
return (
<Paper radius="xl" p="lg">
<Stack gap="lg">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
<History size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={700} fz="lg">
Change history
</Text>
<Text size="sm" c="dimmed">
Wagons coupled, trimmed or switched and bookings removed after
this train was scheduled, newest first.
</Text>
</Stack>
</Group>
{historyQuery.isLoading ? (
<Text size="sm" c="dimmed" ta="center" py="md">
Loading history
</Text>
) : entries.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No changes recorded yet the consist and composition are as
scheduled.
</Text>
) : (
<Timeline bulletSize={26} lineWidth={2} color="edr-green">
{entries.map((entry) => {
const meta = ACTION_META[entry.action] ?? ACTION_META.ADD;
const Icon = meta.icon;
return (
<Timeline.Item
key={`${entry.kind}-${entry.id}`}
bullet={<Icon size={13} />}
color={meta.color}
title={
<Group gap="xs" wrap="nowrap">
<Badge size="sm" variant="light" color={meta.color}>
{meta.label}
</Badge>
{entry.subject ? (
<Text size="sm" fw={600} ff="monospace">
{entry.subject}
</Text>
) : null}
</Group>
}
>
<Group gap="md" mt={2}>
<Text size="xs" c="dimmed">
{new Date(entry.occurredAt).toLocaleString()}
</Text>
{entry.yardLabel ? (
<Group gap={4} wrap="nowrap">
<MapPin size={12} />
<Text size="xs" c="dimmed">
at {entry.yardLabel}
</Text>
</Group>
) : null}
{entry.actor ? (
<Group gap={4} wrap="nowrap">
<User size={12} />
<Text size="xs" c="dimmed">
{entry.actor}
</Text>
</Group>
) : null}
</Group>
{entry.note ? (
<Text size="xs" c="dimmed" mt={2} fs="italic">
{entry.note}
</Text>
) : null}
</Timeline.Item>
);
})}
</Timeline>
)}
</Stack>
</Paper>
);
}

View File

@@ -87,22 +87,46 @@ function phaseCountdown(
} }
} }
/** GROSS weight already on this train (each booking's cargo + wagon tare) — /**
* compared against the locomotive pull limit, which is a gross ceiling. */ * GROSS weight the locomotives actually haul: the HEAVIEST LEG, never the
* whole-route sum — disjoint legs (Mojo→Dire + Dire→Doraleh) are pulled one
* at a time, so summing every booking over-reports a multi-stop train.
* Prefers the API's consist-derived heaviestLeg; before allocation it falls
* back to a per-leg max over the bookings (same span math as the header strip).
*/
function usedWeight(schedule: TrainScheduleDetail): number { function usedWeight(schedule: TrainScheduleDetail): number {
return (schedule.bookings ?? []).reduce( const consist = schedule.trainSet?.heaviestLeg?.grossWeightTons;
(sum, b) => sum + (Number(b.weightTons) || 0), if (consist != null) return Number(consist) || 0;
0,
); const bookings = schedule.bookings ?? [];
const stops = schedule.stops ?? [];
if (stops.length <= 2) {
return bookings.reduce((sum, b) => sum + (Number(b.weightTons) || 0), 0);
}
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
const lastIdx = stops.length - 1;
let heaviest = 0;
for (let edge = 0; edge < lastIdx; edge += 1) {
let legTons = 0;
for (const b of bookings) {
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
const toRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx;
const to = toRaw != null && toRaw > from ? toRaw : lastIdx;
if (from <= edge && edge < to) legTons += Number(b.weightTons) || 0;
}
heaviest = Math.max(heaviest, legTons);
}
return heaviest;
} }
/** /**
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when * Pull capacity of the set. Locomotive pull weights ADD UP (they haul
* unknown). The API caps at the weakest loco, not the sum of all locos — a * together), so prefer the API's maxGrossWeightTons — the combined set limit
* consist can only pull as hard as its weakest engine. Both sides of this meter * incl. overage tolerance, the same ceiling the validator holds each leg to —
* are gross: `usedWeight` sums per-booking gross (cargo + wagon tare). * and fall back to summing the locos' own limits.
*/ */
function pullCapacity(schedule: TrainScheduleDetail): number { function pullCapacity(schedule: TrainScheduleDetail): number {
if (schedule.maxGrossWeightTons != null) return Number(schedule.maxGrossWeightTons) || 0;
const set = schedule.trainSet; const set = schedule.trainSet;
if (!set) return 0; if (!set) return 0;
const locos = const locos =
@@ -111,8 +135,7 @@ function pullCapacity(schedule: TrainScheduleDetail): number {
: set.locomotive : set.locomotive
? [set.locomotive] ? [set.locomotive]
: []; : [];
if (locos.length === 0) return 0; return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
return Math.min(...locos.map((l) => Number(l.maxPullWeightTons) || 0));
} }
export function ScheduleWorkspacePanel({ export function ScheduleWorkspacePanel({
@@ -371,6 +394,7 @@ export function ScheduleWorkspacePanel({
<Text size="xs" fw={600} c={over ? "red" : "dimmed"}> <Text size="xs" fw={600} c={over ? "red" : "dimmed"}>
Load {used.toFixed(1)}T Load {used.toFixed(1)}T
{capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""} {capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""}
{(schedule.stops?.length ?? 0) > 2 ? " · heaviest leg" : ""}
</Text> </Text>
</Group> </Group>
{over ? ( {over ? (

View File

@@ -26,6 +26,7 @@ import {
Container as ContainerIcon, Container as ContainerIcon,
Eye, Eye,
FileText, FileText,
History as HistoryIcon,
LayoutGrid, LayoutGrid,
Navigation, Navigation,
Package, Package,
@@ -51,6 +52,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"; import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel"; import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
@@ -646,6 +648,10 @@ export default function TrainScheduleV2DetailPage() {
reference: b.reference ?? b.id.slice(0, 8), reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons, weightTons: b.weightTons,
isGovernment: b.isGovernment, isGovernment: b.isGovernment,
wagonsRequired: b.wagonsRequired,
contractReference: b.contractReference,
origin: b.origin,
destination: b.destination,
}))} }))}
eligibleItems={eligibleQuery.data?.items ?? []} eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading} eligibleLoading={eligibleQuery.isLoading}
@@ -1166,6 +1172,9 @@ export default function TrainScheduleV2DetailPage() {
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}> <Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
Leg capacity Leg capacity
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
History
</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="workflow"> <Tabs.Panel value="workflow">
@@ -1251,6 +1260,10 @@ export default function TrainScheduleV2DetailPage() {
<Tabs.Panel value="legs"> <Tabs.Panel value="legs">
<LegCapacityPanel schedule={schedule} /> <LegCapacityPanel schedule={schedule} />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="history">
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
</Tabs.Panel>
</Tabs> </Tabs>
{scheduleId ? ( {scheduleId ? (

View File

@@ -194,6 +194,7 @@ import {
type BuiltTrainListFilters, type BuiltTrainListFilters,
type BuiltTrainListResponse, type BuiltTrainListResponse,
type ScheduleConsist, type ScheduleConsist,
type ScheduleHistoryEntry,
type TrainComposition, type TrainComposition,
type UpdateTrainDetailsPayload, type UpdateTrainDetailsPayload,
type UsedTrainNumbers, type UsedTrainNumbers,
@@ -362,6 +363,18 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS, () => TRAIN_BUILDER_INVALIDATIONS,
), ),
scheduleHistory: endpoint<{ scheduleId: string }, ScheduleHistoryEntry[]>(
"train-scheduling",
"schedule-history",
({ scheduleId }) =>
trainBuilderService.scheduleHistory(scheduleId).then((r) => r.data),
({ scheduleId }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"history",
scheduleId,
],
),
bookableSchedules: endpoint< bookableSchedules: endpoint<
{ originYardId?: string | null; destinationYardId?: string | null }, { originYardId?: string | null; destinationYardId?: string | null },
BookableSchedule[] BookableSchedule[]

View File

@@ -231,17 +231,28 @@ export interface ScheduleConsist {
grossTons: number; grossTons: number;
consistLengthMeters: number; consistLengthMeters: number;
}; };
wagons: Array<ConsistWagonRef & { loaded: boolean; removable: boolean }>; wagons: Array<
ConsistWagonRef & {
loaded: boolean;
removable: boolean;
/** Loaded wagons can't leave, but their SLOT can change wagon. */
switchable: boolean;
blockReason: string | null;
}
>;
addableWagons: ConsistWagonRef[]; addableWagons: ConsistWagonRef[];
adjustments: Array<{ adjustments: Array<{
id: string; id: string;
action: "ADD" | "REMOVE"; action: "ADD" | "REMOVE" | "SWITCH";
wagonId: string; wagonId: string;
wagonNumber: string; wagonNumber: string;
adjustedByUserId: string | null; adjustedByUserId: string | null;
yardId: string | null;
occurredAt: string; occurredAt: string;
}>; }>;
editable: boolean; editable: boolean;
/** Where the train stands — mid-route this is the checkpointed stop. */
currentStop: { yardId: string; label: string; isMidRoute: boolean } | null;
/** /**
* Wagon-slot picture of the schedule: the consist IS the booking capacity * Wagon-slot picture of the schedule: the consist IS the booking capacity
* (weight/length only bind while building the consist), so the dialog can * (weight/length only bind while building the consist), so the dialog can
@@ -259,6 +270,20 @@ export interface ScheduleConsist {
export interface AdjustConsistPayload { export interface AdjustConsistPayload {
addWagonIds?: string[]; addWagonIds?: string[];
removeWagonIds?: string[]; removeWagonIds?: string[];
/** Replacement takes the outgoing wagon's position and slot, cargo included. */
switches?: Array<{ fromWagonId: string; toWagonId: string }>;
}
/** One row of the schedule's unified change history (History tab). */
export interface ScheduleHistoryEntry {
id: string;
kind: "WAGON" | "BOOKING";
action: "ADD" | "REMOVE" | "SWITCH" | "BOOKING_REMOVED";
subject: string | null;
yardLabel: string | null;
actor: string | null;
note: string | null;
occurredAt: string;
} }
/** Adjust response: fresh consist + schedule-impact warnings to surface. */ /** Adjust response: fresh consist + schedule-impact warnings to surface. */
@@ -302,10 +327,15 @@ export const trainBuilderService = {
/** Consist snapshot for a train-bound schedule (adjust-consist UI). */ /** Consist snapshot for a train-bound schedule (adjust-consist UI). */
scheduleConsist: (scheduleId: string) => scheduleConsist: (scheduleId: string) =>
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`), apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
/** Permanently trim/add wagons on the schedule's built train. */ /** Permanently trim/add/switch wagons on the schedule's built train. */
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) => adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
apiClient.post<AdjustConsistResult>( apiClient.post<AdjustConsistResult>(
`/train-scheduling/schedules/${scheduleId}/adjust-consist`, `/train-scheduling/schedules/${scheduleId}/adjust-consist`,
payload, payload,
), ),
/** Unified wagon/booking change history for the schedule's History tab. */
scheduleHistory: (scheduleId: string) =>
apiClient.get<ScheduleHistoryEntry[]>(
`/train-scheduling/schedules/${scheduleId}/history`,
),
}; };

View File

@@ -685,6 +685,7 @@ export interface TrainScheduleDetail {
destinationYardId?: string | null; destinationYardId?: string | null;
origin?: string | null; origin?: string | null;
destination?: string | null; destination?: string | null;
contractReference?: string | null;
wagonsRequired?: number | null; wagonsRequired?: number | null;
loadedAt?: string | null; loadedAt?: string | null;
arrivedAt?: string | null; arrivedAt?: string | null;