diff --git a/apps/edr-freight-api/src/migrations/3110000000000-AddYardToScheduleWagonAdjustmentLogs.ts b/apps/edr-freight-api/src/migrations/3110000000000-AddYardToScheduleWagonAdjustmentLogs.ts new file mode 100644 index 000000000..0e1d13175 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3110000000000-AddYardToScheduleWagonAdjustmentLogs.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE freight.schedule_wagon_adjustment_logs + ADD COLUMN IF NOT EXISTS yard_id uuid`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.schedule_wagon_adjustment_logs + DROP COLUMN IF EXISTS yard_id`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts index 485d29452..56459870c 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts @@ -1,15 +1,17 @@ import { BaseEntity } from '@edr/api-common'; 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]; /** * 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 — - * e.g. trimming free wagons whose tare pushed gross weight over the - * locomotives' pull limit. Plain columns (no FK relations) so the history - * survives the wagon or train being deleted later. + * wagon onto (ADD), detached one from (REMOVE), or swapped the physical wagon + * under a loaded slot (SWITCH — wagonNumber reads "OLD → NEW") on the + * schedule's built train. `yardId` records WHERE it happened: the origin yard + * 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' }) @Index(['trainScheduleId']) @@ -33,6 +35,9 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity { @Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true }) adjustedByUserId!: string | null; + @Column({ name: 'yard_id', type: 'uuid', nullable: true }) + yardId!: string | null; + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' }) occurredAt!: Date; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 6326efbbf..7541e4bd1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -614,6 +614,26 @@ export class BookingBatchService implements OnModuleInit { const linked = 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 (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return; await this.allocate(booking.trainScheduleId, booking, "paid"); @@ -3143,6 +3163,19 @@ export class BookingBatchService implements OnModuleInit { this.notifyBoardChanged(scheduleId, 'intercity_accepted'); 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); this.armSettle(scheduleId); this.notifyBoardChanged(scheduleId, 'intercity_accepted'); @@ -3363,7 +3396,11 @@ export class BookingBatchService implements OnModuleInit { `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${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); // Customer tracking: freight payment settled (commercial pay-window path). // Government allocations don't pay upfront — theirs stay pending. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts index e031bf9f0..215b6c806 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts @@ -1,5 +1,16 @@ 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 { @ApiPropertyOptional({ @@ -23,4 +34,15 @@ export class AdjustScheduleConsistDto { @IsArray() @IsUUID('all', { each: true }) 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[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 4d5ae2330..5775148d7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -330,8 +330,10 @@ export class IntercityService { .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) .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( - `((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'))`, ) .orderBy('booking.is_government', 'DESC') @@ -382,8 +384,12 @@ export class IntercityService { if (booking.trainScheduleId) { return 'Already assigned to a train'; } - const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED'; - if (booking.status !== readyStatus) { + // Commercial: FULLY_EXECUTED opens a pay window; PAID (payment landed, + // 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})`; } if (!this.corridorOnRoute(booking, milestoneSeq)) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 4f0c8903a..7b0f37b66 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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") // No staff guard: customers hit this while creating a booking to find OPEN // same-route schedules. Do not attach train_scheduling permissions here. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index fe2c6905d..d5a5161fb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -43,6 +43,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { Contract } from '../contracts/entities/contract.entity'; import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; 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> { + 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) { - const limits = await this.resolveTrainLimitConfig(dto); + const limits = await this.resolvePreviewLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, @@ -1265,7 +1289,7 @@ export class TrainSchedulingService { } async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) { - const limits = await this.resolveTrainLimitConfig(dto); + const limits = await this.resolvePreviewLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, @@ -1280,7 +1304,7 @@ export class TrainSchedulingService { } async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) { - const limits = await this.resolveTrainLimitConfig(dto); + const limits = await this.resolvePreviewLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, @@ -4657,16 +4681,29 @@ export class TrainSchedulingService { * schedule. Used to guard consist trims — the Wagon entity itself carries no * schedule-occupancy state anymore. */ - private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise> { + /** + * 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> { const runner = manager ?? this.dataSource; const rows: { physical_wagon_id: string }[] = await runner.query( `SELECT DISTINCT tsw.physical_wagon_id FROM freight.train_set_wagons tsw 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') AND ts.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)); } @@ -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 { + 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 { + const stops = this.mapScheduleStops(schedule).map((s) => s.yardId); + const atIdx = atYardId ? stops.indexOf(atYardId) : -1; + const aboard = new Set(); + 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 * 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({ where: { trainId: builtTrain.id }, relations: { wagonType: true }, order: { sequenceNumber: 'ASC' }, }); - const addableWagons = await this.dataSource.getRepository(Wagon).find({ - where: { - trainId: IsNull(), - status: WagonStatus.Available, - currentYardId: builtTrain.currentYardId ?? undefined, - }, - relations: { wagonType: true }, - order: { wagonNumber: 'ASC' }, - }); + const addableWagons = currentYardId + ? await this.dataSource.getRepository(Wagon).find({ + where: { + trainId: IsNull(), + status: WagonStatus.Available, + currentYardId, + }, + relations: { wagonType: true }, + order: { wagonNumber: 'ASC' }, + }) + : []; const adjustments = await this.dataSource .getRepository(ScheduleWagonAdjustmentLog) .find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 }); - // Slots with cargo aboard — their physical wagons are "loaded" and can - // never be trimmed. - const loadedWagonIds = new Set( - (schedule.trainSet?.wagons ?? []) - .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) - .map((slot) => slot.physicalWagonId as string), + // Slots whose cargo still rides beyond the current stop — those wagons + // cannot be trimmed, only switched. Cargo offloaded at this stop (or + // earlier) has released its wagon. + const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId); + // Only OTHER trains' pins block edits here — this train's own schedules + // (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 maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); @@ -5817,12 +5927,23 @@ export class TrainSchedulingService { bookingWindowStatus: schedule.bookingWindowStatus ?? null, } : null, - wagons: wagons.map((wagon) => ({ - ...mapWagon(wagon), - loaded: loadedWagonIds.has(wagon.id), - // Free = not pinned to any live run's slot; only free wagons can be trimmed. - removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id), - })), + wagons: wagons.map((wagon) => { + const loaded = loadedWagonIds.has(wagon.id); + const pinnedElsewhere = pinnedToLiveIds.has(wagon.id); + return { + ...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), adjustments: adjustments.map((log) => ({ id: log.id, @@ -5830,9 +5951,26 @@ export class TrainSchedulingService { wagonId: log.wagonId, wagonNumber: log.wagonNumber, adjustedByUserId: log.adjustedByUserId, + yardId: log.yardId ?? null, 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 removeWagonIds = [...new Set(dto.removeWagonIds ?? [])]; - if (!addWagonIds.length && !removeWagonIds.length) { - throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove'); + const switches = dto.switches ?? []; + 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)); - if (overlap.length) { - throw new BadRequestException('A wagon cannot be added and removed in the same adjustment'); + const switchFromIds = switches.map((s) => s.fromWagonId); + const switchToIds = switches.map((s) => s.toWagonId); + const touched = new Map(); + 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); 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( - '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; @@ -5872,11 +6029,9 @@ export class TrainSchedulingService { 'This schedule was not created from a built train — its consist cannot be adjusted here', ); } - const loadedWagonIds = new Set( - (schedule.trainSet?.wagons ?? []) - .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) - .map((slot) => slot.physicalWagonId as string), - ); + // Wagons whose cargo still rides beyond the current stop: never removable, + // but switchable — the replacement inherits the slot, cargo included. + const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId); const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const pullCapTons = roundTons( 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])); - // --- validate removals: must be coupled and free (no cargo, no pin) --- - const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager); + // --- validate removals: coupled, cargo offloaded, no foreign pin --- + 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[] = []; for (const wagonId of removeWagonIds) { const wagon = consistById.get(wagonId); if (!wagon) { 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( - `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); } - // --- validate additions: AVAILABLE, loose, standing in the train's yard --- - const added: Wagon[] = []; - for (const wagonId of addWagonIds) { + // Shared gate for every incoming wagon (couple or switch replacement): + // AVAILABLE, loose, and standing where the train stands right now. + const lockIncomingWagon = async (wagonId: string): Promise => { // No `relations` on this query: Postgres refuses FOR UPDATE through the // nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be // 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})`, ); } - if (wagon.currentYardId !== train.currentYardId) { + if (wagon.currentYardId !== currentYardId) { 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 = (await manager .getRepository(WagonType) .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 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 lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 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) { - await manager.getRepository(Wagon).update(wagon.id, { - trainId: null, - sequenceNumber: null, - status: WagonStatus.Available, - }); + await manager.getRepository(Wagon).update(wagon.id, detachPatch); } - const remaining = consist.filter((w) => !removedIds.has(w.id)); - for (let i = 0; i < remaining.length; i++) { - if (remaining[i].sequenceNumber !== i + 1) { - await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 }); + if (removed.length && ownSetIds.length) { + // This train's own pins (all its runs) on trimmed wagons are stale — + // clear them so the freed wagon isn't still claimed by slots it left. + 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) { sequence += 1; await manager.getRepository(Wagon).update(wagon.id, { @@ -6000,16 +6258,31 @@ export class TrainSchedulingService { const now = new Date(); await logRepo.save( [ - ...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })), - ...added.map((wagon) => ({ action: 'ADD' as const, wagon })), - ].map(({ action, wagon }) => + ...removed.map((wagon) => ({ + action: 'REMOVE' as const, + 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({ trainScheduleId: scheduleId, trainId: train.id, - action, - wagonId: wagon.id, - wagonNumber: wagon.wagonNumber, + action: entry.action, + wagonId: entry.wagonId, + wagonNumber: entry.wagonNumber, adjustedByUserId: userId ?? null, + yardId: currentYardId, occurredAt: now, }), ), @@ -6049,6 +6322,72 @@ export class TrainSchedulingService { 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) => ({ + ...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) => ({ + ...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 * them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED → @@ -6938,11 +7277,21 @@ export class TrainSchedulingService { (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 // another, stacking round-trips onto every detail request. // tareDims: booking weights are reported GROSS (cargo + wagon tare) — the // 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([ this.loadWagonTareDims(), requiresLoadingConfirmation @@ -6972,7 +7321,13 @@ export class TrainSchedulingService { 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 ? Boolean(importOp?.loadedOnTrainAt) : true; @@ -7315,6 +7670,10 @@ export class TrainSchedulingService { sb.booking?.destinationYard?.code ?? 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, arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null, // Loaded/unloaded is tracked on the schedule↔booking link, not the diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx index 14dd60912..8cdbe66bf 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx @@ -9,16 +9,18 @@ import { Modal, Progress, ScrollArea, + Select, Stack, Text, + Tooltip, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; 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 { 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"; 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 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 - * tare no longer rides — the fix when gross weight beats the pull limit) or - * couple extra yard wagons while weight/length headroom remains. Changes are - * permanent on the train and logged on the schedule. + * tare no longer rides — the fix when gross weight beats the pull limit), + * couple extra yard wagons while weight/length headroom remains, or SWITCH a + * 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({ scheduleId, @@ -48,6 +55,9 @@ export default function AdjustConsistModal({ const { toast } = useToast(); const [removeIds, setRemoveIds] = useState([]); const [addIds, setAddIds] = useState([]); + // 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>({}); const consistQuery = useQuery( api.trainScheduling.scheduleConsist.queryOptions({ @@ -62,13 +72,20 @@ export default function AdjustConsistModal({ if (opened) { setRemoveIds([]); setAddIds([]); + setSwitchMap({}); } }, [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 // the schedule's wagon-slot picture — the consist IS the booking capacity // (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(() => { if (!data) return null; const removed = new Set(removeIds); @@ -117,25 +134,58 @@ export default function AdjustConsistModal({ }; }, [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) => 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 () => { - if (!removeIds.length && !addIds.length) return; + if (!hasChanges) return; try { const result = await adjust.mutateAsync({ scheduleId, payload: { ...(addIds.length ? { addWagonIds: addIds } : {}), ...(removeIds.length ? { removeWagonIds: removeIds } : {}), + ...(switchCount + ? { + switches: Object.entries(switchMap).map(([fromWagonId, toWagonId]) => ({ + fromWagonId, + toWagonId, + })), + } + : {}), }, }); toast({ - title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${ - removeIds.length && addIds.length ? ", " : "" - }${addIds.length ? `${addIds.length} added` : ""}`, + title: `Consist updated — ${[ + removeIds.length ? `${removeIds.length} trimmed` : "", + addIds.length ? `${addIds.length} added` : "", + switchCount ? `${switchCount} switched` : "", + ] + .filter(Boolean) + .join(", ")}`, }); // Schedule-impact warnings from the API: window reopened / now FULL / // consist trimmed below what bookings already hold. @@ -151,6 +201,7 @@ export default function AdjustConsistModal({ } setRemoveIds([]); setAddIds([]); + setSwitchMap({}); } catch (err) { toast({ title: "Adjustment failed", @@ -170,7 +221,7 @@ export default function AdjustConsistModal({ } radius="lg" - size={860} + size={920} centered > {consistQuery.isLoading || !data ? ( @@ -181,9 +232,19 @@ export default function AdjustConsistModal({ ) : ( + {data.currentStop?.isMidRoute ? ( + }> + Standing at {data.currentStop.label} — 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}. + + ) : null} {!data.editable ? ( }> - 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."} ) : null} @@ -256,37 +317,39 @@ export default function AdjustConsistModal({ ) : null} - + - Trim coupled wagons ({data.totals.wagonCount}) + Coupled wagons ({data.totals.wagonCount}) - Only free (unloaded, unpinned) wagons can be detached. Detaching is - permanent — the wagon returns to the yard as available. + Trim only wagons carrying nothing beyond this stop. A loaded + wagon can't leave — but it can be switched: + the same-type replacement takes its position and its cargo + slot. Detaching is permanent. - + {data.wagons.map((wagon) => ( - ))} - + @@ -295,25 +358,30 @@ export default function AdjustConsistModal({ - AVAILABLE wagons standing in the train's yard. Blocked when they push - gross weight or length past the locomotive limits incl. tolerance. + AVAILABLE wagons standing at{" "} + {data.currentStop?.label ?? "the train's yard"}. Blocked when + they push gross weight or length past the locomotive limits + incl. tolerance. - + {data.addableWagons.length ? ( - data.addableWagons.map((wagon) => ( - - )) + data.addableWagons.map((wagon) => { + const takenBySwitch = usedReplacementIds.has(wagon.id); + return ( + + ); + }) ) : ( - No available wagons in this yard + No available wagons at this stop )} @@ -322,6 +390,19 @@ export default function AdjustConsistModal({ + {switchCount ? ( + } 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). + + ) : null} + {data.adjustments.length ? ( <> @@ -339,9 +420,19 @@ export default function AdjustConsistModal({ - {log.action === "ADD" ? "Added" : "Trimmed"} + {log.action === "ADD" + ? "Added" + : log.action === "SWITCH" + ? "Switched" + : "Trimmed"} {log.wagonNumber} @@ -369,15 +460,19 @@ export default function AdjustConsistModal({ loading={adjust.isPending} disabled={ !data.editable || - (!removeIds.length && !addIds.length) || + !hasChanges || (addIds.length > 0 && (projection?.overWeight || projection?.overLength)) } onClick={handleSubmit} > Apply{" "} - {removeIds.length ? `−${removeIds.length}` : ""} - {removeIds.length && addIds.length ? " / " : ""} - {addIds.length ? `+${addIds.length}` : ""} + {[ + removeIds.length ? `−${removeIds.length}` : "", + addIds.length ? `+${addIds.length}` : "", + switchCount ? `⇄${switchCount}` : "", + ] + .filter(Boolean) + .join(" / ")} @@ -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 = ( + onToggleRemove(wagon.id, e.currentTarget.checked)} + aria-label={`Trim wagon ${wagon.wagonNumber}`} + /> + ); + return ( + + {wagon.blockReason ? ( + + {checkbox} + + ) : ( + checkbox + )} + + + {wagon.wagonNumber} + + + {wagon.wagonType + ? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m` + : "Unknown type"} + + + {badge ? ( + + {badge} + + ) : null} + {editable && wagon.switchable && switchOptions.length ? ( +