mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1040 from Tria-plc/freight_feature/usermanagement
intercity fix
This commit is contained in:
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<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) {
|
||||
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<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 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<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
|
||||
* 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<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);
|
||||
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<Wagon> => {
|
||||
// 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<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
|
||||
* 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
|
||||
|
||||
@@ -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<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(
|
||||
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({
|
||||
</Text>
|
||||
}
|
||||
radius="lg"
|
||||
size={860}
|
||||
size={920}
|
||||
centered
|
||||
>
|
||||
{consistQuery.isLoading || !data ? (
|
||||
@@ -181,9 +232,19 @@ export default function AdjustConsistModal({
|
||||
</Text>
|
||||
) : (
|
||||
<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 ? (
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
@@ -256,37 +317,39 @@ export default function AdjustConsistModal({
|
||||
) : null}
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<Stack gap="xs">
|
||||
<Group gap={6}>
|
||||
<Minus size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
Trim coupled wagons ({data.totals.wagonCount})
|
||||
Coupled wagons ({data.totals.wagonCount})
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
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 <strong>switched</strong>:
|
||||
the same-type replacement takes its position and its cargo
|
||||
slot. Detaching is permanent.
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={260} type="auto">
|
||||
<ScrollArea.Autosize mah={280} type="auto">
|
||||
<Stack gap={4}>
|
||||
{data.wagons.map((wagon) => (
|
||||
<WagonRow
|
||||
<CoupledWagonRow
|
||||
key={wagon.id}
|
||||
wagon={wagon}
|
||||
checked={removeIds.includes(wagon.id)}
|
||||
disabled={!data.editable || !wagon.removable}
|
||||
badge={
|
||||
wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null
|
||||
}
|
||||
onToggle={toggle(setRemoveIds)}
|
||||
editable={data.editable}
|
||||
switchValue={switchMap[wagon.id] ?? null}
|
||||
switchOptions={switchOptionsFor(wagon)}
|
||||
onToggleRemove={toggle(setRemoveIds)}
|
||||
onSwitch={setSwitch}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Stack gap="xs">
|
||||
<Group gap={6}>
|
||||
<Plus size={14} />
|
||||
@@ -295,25 +358,30 @@ export default function AdjustConsistModal({
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
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.
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={260} type="auto">
|
||||
<ScrollArea.Autosize mah={280} type="auto">
|
||||
<Stack gap={4}>
|
||||
{data.addableWagons.length ? (
|
||||
data.addableWagons.map((wagon) => (
|
||||
<WagonRow
|
||||
key={wagon.id}
|
||||
wagon={wagon}
|
||||
checked={addIds.includes(wagon.id)}
|
||||
disabled={!data.editable}
|
||||
badge={null}
|
||||
onToggle={toggle(setAddIds)}
|
||||
/>
|
||||
))
|
||||
data.addableWagons.map((wagon) => {
|
||||
const takenBySwitch = usedReplacementIds.has(wagon.id);
|
||||
return (
|
||||
<AddableWagonRow
|
||||
key={wagon.id}
|
||||
wagon={wagon}
|
||||
checked={addIds.includes(wagon.id)}
|
||||
disabled={!data.editable || takenBySwitch}
|
||||
badge={takenBySwitch ? "Switch target" : null}
|
||||
onToggle={toggle(setAddIds)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" py="sm" ta="center">
|
||||
No available wagons in this yard
|
||||
No available wagons at this stop
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -322,6 +390,19 @@ export default function AdjustConsistModal({
|
||||
</Grid.Col>
|
||||
</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 ? (
|
||||
<>
|
||||
<Divider />
|
||||
@@ -339,9 +420,19 @@ export default function AdjustConsistModal({
|
||||
<Badge
|
||||
size="xs"
|
||||
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>
|
||||
<Text size="xs" ff="monospace">
|
||||
{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(" / ")}
|
||||
</Button>
|
||||
</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,
|
||||
checked,
|
||||
disabled,
|
||||
@@ -471,7 +648,7 @@ function WagonRow({
|
||||
</Text>
|
||||
</Stack>
|
||||
{badge ? (
|
||||
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}>
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
@@ -574,6 +574,11 @@ export function AllocateBookingWizard({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
isGovernment: b.isGovernment,
|
||||
wagonsRequired: b.wagonsRequired,
|
||||
contractReference: b.contractReference,
|
||||
origin: b.origin,
|
||||
destination: b.destination,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Collapse,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
@@ -10,7 +12,7 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Info } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react";
|
||||
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
|
||||
@@ -27,6 +29,13 @@ interface Stop {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface LegBookingUsage {
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
wagons: number;
|
||||
grossTons: number;
|
||||
}
|
||||
|
||||
interface EdgeUsage {
|
||||
edge: number;
|
||||
from: Stop;
|
||||
@@ -35,6 +44,7 @@ interface EdgeUsage {
|
||||
grossTons: number;
|
||||
lengthMeters: number;
|
||||
bookingRefs: string[];
|
||||
bookings: LegBookingUsage[];
|
||||
}
|
||||
|
||||
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 lengthCap = schedule.maxLengthMeters ?? null;
|
||||
const wagonCap = schedule.maxWagons ?? null;
|
||||
const [expandedEdge, setExpandedEdge] = useState<number | null>(null);
|
||||
|
||||
const edges: EdgeUsage[] = useMemo(() => {
|
||||
if (stops.length < 2) return [];
|
||||
@@ -105,13 +116,36 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
const refs = new Set<string>();
|
||||
let grossTons = 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) {
|
||||
grossTons += (Number(w.tareWeightTons) || 0) + (Number(w.assignedWeightTons) || 0);
|
||||
lengthMeters += Number(w.lengthMeters) || 0;
|
||||
const bookingIdsOnWagon = new Set<string>();
|
||||
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 {
|
||||
edge,
|
||||
from,
|
||||
@@ -120,6 +154,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
grossTons: round1(grossTons),
|
||||
lengthMeters: round1(lengthMeters),
|
||||
bookingRefs: [...refs],
|
||||
bookings,
|
||||
};
|
||||
});
|
||||
}, [stops, wagons]);
|
||||
@@ -190,6 +225,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: 28 }} />
|
||||
<Table.Th>Leg</Table.Th>
|
||||
<Table.Th>Wagons</Table.Th>
|
||||
<Table.Th>Gross weight</Table.Th>
|
||||
@@ -199,43 +235,107 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{edges.map((e) => (
|
||||
<Table.Tr key={e.edge}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{e.from.label} → {e.to.label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.wagons} cap={wagonCap} unit="wagons" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.grossTons} cap={weightCap} unit="T" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.lengthMeters} cap={lengthCap} unit="m" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{e.bookingRefs.length ? (
|
||||
<Tooltip
|
||||
label={e.bookingRefs.join(", ")}
|
||||
multiline
|
||||
maw={320}
|
||||
withArrow
|
||||
>
|
||||
<Text size="sm" style={{ cursor: "help" }}>
|
||||
{e.bookingRefs.length}
|
||||
{edges.map((e) => {
|
||||
const isOpen = expandedEdge === e.edge;
|
||||
const hasBookings = e.bookings.length > 0;
|
||||
return (
|
||||
<Fragment key={e.edge}>
|
||||
<Table.Tr
|
||||
style={{ cursor: hasBookings ? "pointer" : undefined }}
|
||||
onClick={
|
||||
hasBookings
|
||||
? () => setExpandedEdge(isOpen ? null : e.edge)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Table.Td>
|
||||
{hasBookings ? (
|
||||
isOpen ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)
|
||||
) : null}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{e.from.label} → {e.to.label}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
0
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{legStatus(e)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.wagons} cap={wagonCap} unit="wagons" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.grossTons} cap={weightCap} unit="T" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<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>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Tabs,
|
||||
Text,
|
||||
} 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";
|
||||
|
||||
@@ -18,6 +18,10 @@ export type AssignedBookingRow = {
|
||||
reference: string;
|
||||
weightTons?: number;
|
||||
isGovernment?: boolean;
|
||||
wagonsRequired?: number | null;
|
||||
contractReference?: string | null;
|
||||
origin?: string | null;
|
||||
destination?: string | null;
|
||||
};
|
||||
|
||||
export function ScheduleBookingsStep({
|
||||
@@ -94,6 +98,16 @@ export function ScheduleBookingsStep({
|
||||
{booking.weightTons}T
|
||||
</Badge>
|
||||
) : 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 ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
@@ -105,6 +119,26 @@ export function ScheduleBookingsStep({
|
||||
</Badge>
|
||||
) : null}
|
||||
</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}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Assigned to this consist
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
return (schedule.bookings ?? []).reduce(
|
||||
(sum, b) => sum + (Number(b.weightTons) || 0),
|
||||
0,
|
||||
);
|
||||
const consist = schedule.trainSet?.heaviestLeg?.grossWeightTons;
|
||||
if (consist != null) return Number(consist) || 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
|
||||
* unknown). The API caps at the weakest loco, not the sum of all locos — a
|
||||
* consist can only pull as hard as its weakest engine. Both sides of this meter
|
||||
* are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
|
||||
* Pull capacity of the set. Locomotive pull weights ADD UP (they haul
|
||||
* together), so prefer the API's maxGrossWeightTons — the combined set limit
|
||||
* incl. overage tolerance, the same ceiling the validator holds each leg to —
|
||||
* and fall back to summing the locos' own limits.
|
||||
*/
|
||||
function pullCapacity(schedule: TrainScheduleDetail): number {
|
||||
if (schedule.maxGrossWeightTons != null) return Number(schedule.maxGrossWeightTons) || 0;
|
||||
const set = schedule.trainSet;
|
||||
if (!set) return 0;
|
||||
const locos =
|
||||
@@ -111,8 +135,7 @@ function pullCapacity(schedule: TrainScheduleDetail): number {
|
||||
: set.locomotive
|
||||
? [set.locomotive]
|
||||
: [];
|
||||
if (locos.length === 0) return 0;
|
||||
return Math.min(...locos.map((l) => Number(l.maxPullWeightTons) || 0));
|
||||
return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
|
||||
}
|
||||
|
||||
export function ScheduleWorkspacePanel({
|
||||
@@ -371,6 +394,7 @@ export function ScheduleWorkspacePanel({
|
||||
<Text size="xs" fw={600} c={over ? "red" : "dimmed"}>
|
||||
Load {used.toFixed(1)}T
|
||||
{capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""}
|
||||
{(schedule.stops?.length ?? 0) > 2 ? " · heaviest leg" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{over ? (
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
FileText,
|
||||
History as HistoryIcon,
|
||||
LayoutGrid,
|
||||
Navigation,
|
||||
Package,
|
||||
@@ -51,6 +52,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
@@ -646,6 +648,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
isGovernment: b.isGovernment,
|
||||
wagonsRequired: b.wagonsRequired,
|
||||
contractReference: b.contractReference,
|
||||
origin: b.origin,
|
||||
destination: b.destination,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
@@ -1166,6 +1172,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
|
||||
Leg capacity
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="workflow">
|
||||
@@ -1251,6 +1260,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Tabs.Panel value="legs">
|
||||
<LegCapacityPanel schedule={schedule} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{scheduleId ? (
|
||||
|
||||
@@ -194,6 +194,7 @@ import {
|
||||
type BuiltTrainListFilters,
|
||||
type BuiltTrainListResponse,
|
||||
type ScheduleConsist,
|
||||
type ScheduleHistoryEntry,
|
||||
type TrainComposition,
|
||||
type UpdateTrainDetailsPayload,
|
||||
type UsedTrainNumbers,
|
||||
@@ -362,6 +363,18 @@ export const api = {
|
||||
() => 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<
|
||||
{ originYardId?: string | null; destinationYardId?: string | null },
|
||||
BookableSchedule[]
|
||||
|
||||
@@ -231,17 +231,28 @@ export interface ScheduleConsist {
|
||||
grossTons: 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[];
|
||||
adjustments: Array<{
|
||||
id: string;
|
||||
action: "ADD" | "REMOVE";
|
||||
action: "ADD" | "REMOVE" | "SWITCH";
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
adjustedByUserId: string | null;
|
||||
yardId: string | null;
|
||||
occurredAt: string;
|
||||
}>;
|
||||
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
|
||||
* (weight/length only bind while building the consist), so the dialog can
|
||||
@@ -259,6 +270,20 @@ export interface ScheduleConsist {
|
||||
export interface AdjustConsistPayload {
|
||||
addWagonIds?: 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. */
|
||||
@@ -302,10 +327,15 @@ export const trainBuilderService = {
|
||||
/** Consist snapshot for a train-bound schedule (adjust-consist UI). */
|
||||
scheduleConsist: (scheduleId: string) =>
|
||||
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) =>
|
||||
apiClient.post<AdjustConsistResult>(
|
||||
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
|
||||
payload,
|
||||
),
|
||||
/** Unified wagon/booking change history for the schedule's History tab. */
|
||||
scheduleHistory: (scheduleId: string) =>
|
||||
apiClient.get<ScheduleHistoryEntry[]>(
|
||||
`/train-scheduling/schedules/${scheduleId}/history`,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -685,6 +685,7 @@ export interface TrainScheduleDetail {
|
||||
destinationYardId?: string | null;
|
||||
origin?: string | null;
|
||||
destination?: string | null;
|
||||
contractReference?: string | null;
|
||||
wagonsRequired?: number | null;
|
||||
loadedAt?: string | null;
|
||||
arrivedAt?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user