schedule logic

This commit is contained in:
Marshal
2026-06-10 09:22:57 +00:00
parent 0335555892
commit 088295d81f
23 changed files with 1766 additions and 319 deletions

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLocomotiveReadiness1781000000000 implements MigrationInterface {
name = 'AddLocomotiveReadiness1781000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.locomotives
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_locomotives_readiness
ON freight.locomotives (readiness)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`);
await queryRunner.query(`
ALTER TABLE freight.locomotives
DROP COLUMN IF EXISTS readiness
`);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTrainCheckpointEvents1781000000001 implements MigrationInterface {
name = 'CreateTrainCheckpointEvents1781000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_checkpoint_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_schedule_id UUID NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
yard_id UUID NOT NULL,
sequence_no INT NOT NULL,
kind VARCHAR(20) NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
note TEXT NULL,
recorded_by_user_id UUID NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_checkpoint_events_schedule
ON freight.train_checkpoint_events (train_schedule_id, sequence_no)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_train_checkpoint_events_schedule`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_checkpoint_events`);
}
}

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import { WagonReadiness } from '@edr/types';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
@@ -12,12 +13,20 @@ export const LOCOMOTIVE_STATUSES = [
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const;
/** Locomotives reuse the wagon readiness values (IMPORT_READY / EXPORT_READY). */
export const LOCOMOTIVE_READINESS_VALUES = [
WagonReadiness.ImportReady,
WagonReadiness.ExportReady,
] as const;
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
export type LocomotiveReadiness = (typeof LOCOMOTIVE_READINESS_VALUES)[number];
@Entity({ schema: 'freight', name: 'locomotives' })
@Index(['code'])
@Index(['status'])
@Index(['readiness'])
export class Locomotive extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string;
@@ -37,6 +46,9 @@ export class Locomotive extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
status!: LocomotiveStatus;
@Column({ name: 'readiness', type: 'varchar', length: 20, default: WagonReadiness.ImportReady })
readiness!: LocomotiveReadiness;
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
powerKw?: number | null;

View File

@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
import { TrainCheckpointKind } from '@edr/types';
import {
IsEnum,
IsInt,
IsISO8601,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
export class RecordCheckpointDto {
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
@IsInt()
@Min(0)
sequenceNo!: number;
@ApiProperty({ enum: TrainCheckpointKind, required: false })
@IsOptional()
@IsEnum(TrainCheckpointKind)
kind?: TrainCheckpointKind;
@ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' })
@IsOptional()
@IsISO8601()
occurredAt?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -0,0 +1,45 @@
import { BaseEntity } from '@edr/api-common';
import { TrainCheckpointKind } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
/**
* One staff-logged tracking checkpoint for a dispatched train as it passes a
* station along its route (origin → milestones → destination).
*/
@Entity({ schema: 'freight', name: 'train_checkpoint_events' })
@Index(['trainScheduleId'])
@Index(['trainScheduleId', 'sequenceNo'])
export class TrainCheckpointEvent extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_schedule_id' })
trainSchedule?: TrainSchedule;
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'yard_id' })
yard?: Yard;
/** Position along the corridor: 0 = origin, N+1 = destination. */
@Column({ name: 'sequence_no', type: 'int' })
sequenceNo!: number;
@Column({ name: 'kind', type: 'varchar', length: 20 })
kind!: TrainCheckpointKind;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
@Column({ name: 'recorded_by_user_id', type: 'uuid', nullable: true })
recordedByUserId?: string | null;
}

View File

@@ -0,0 +1,24 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
@Injectable()
export class TrainCheckpointEventsRepository extends BaseRepository<TrainCheckpointEvent> {
constructor(
@InjectRepository(TrainCheckpointEvent)
repository: Repository<TrainCheckpointEvent>,
) {
super(repository);
}
findBySchedule(trainScheduleId: string): Promise<TrainCheckpointEvent[]> {
return this.findAll({
where: { trainScheduleId },
relations: { yard: true },
order: { sequenceNo: 'ASC', occurredAt: 'ASC' },
});
}
}

View File

@@ -21,6 +21,7 @@ import { PinWagonsDto } from './dto/pin-wagons.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { TrainSchedulingService } from './train-scheduling.service';
@@ -161,6 +162,30 @@ export class TrainSchedulingController {
return this.trainSchedulingService.dispatchSchedule(id);
}
@Get('schedules/:id/checkpoints')
@TrainSchedulingView()
@ApiOperation({ summary: 'Get the tracking corridor + logged checkpoints for a train' })
getScheduleCheckpoints(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleCheckpoints(id);
}
@Post('schedules/:id/checkpoints')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Log the train passing a station (final station triggers arrival)' })
recordCheckpoint(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RecordCheckpointDto,
) {
return this.trainSchedulingService.recordCheckpoint(id, dto);
}
@Post('schedules/:id/arrive')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Mark a dispatched train arrived (flip readiness, free assets)' })
arriveSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.arriveSchedule(id);
}
@Get('container/schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'List container train schedules' })

View File

@@ -14,7 +14,9 @@ import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { Wagon } from '../wagons/entities/wagon.entity';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service';
@@ -29,6 +31,7 @@ import { TrainSchedulingService } from './train-scheduling.service';
Wagon,
Container,
TrainSchedulingGlobalRules,
TrainCheckpointEvent,
]),
BookingsModule,
LocomotivesModule,
@@ -38,7 +41,7 @@ import { TrainSchedulingService } from './train-scheduling.service';
RuleEngineModule,
],
controllers: [TrainSchedulingController],
providers: [TrainSchedulingService],
providers: [TrainSchedulingService, TrainCheckpointEventsRepository],
exports: [TrainSchedulingService],
})
export class TrainSchedulingModule {}

View File

@@ -125,6 +125,13 @@ describe('TrainSchedulingService', () => {
findAll: jest.fn().mockResolvedValue([]),
};
const trainCheckpointEventsRepository = {
findBySchedule: jest.fn().mockResolvedValue([]),
findAll: jest.fn().mockResolvedValue([]),
create: jest.fn(),
update: jest.fn(),
};
service = new TrainSchedulingService(
dataSource as never,
bookingsRepository as never,
@@ -135,6 +142,7 @@ describe('TrainSchedulingService', () => {
wagonBookingAllocationsRepository as never,
wagonAllocationContainerItemsRepository as never,
wagonAllocationBulkLoadsRepository as never,
trainCheckpointEventsRepository as never,
);
const defaultFleetWagons = [

View File

@@ -1,6 +1,7 @@
import {
AllocationLoadType,
SchedulingStatus,
TrainCheckpointKind,
TrainScheduleStatus as TrainScheduleStatusEnum,
WagonStatus,
} from '@edr/types';
@@ -74,7 +75,11 @@ import {
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { wagonReadinessMatchesSchedule } from './wagon-readiness.util';
import { flipReadiness, wagonReadinessMatchesSchedule } from './wagon-readiness.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
@@ -98,6 +103,7 @@ export class TrainSchedulingService {
private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository,
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
private readonly configService?: ConfigService,
) {}
@@ -219,11 +225,17 @@ export class TrainSchedulingService {
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
}
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive);
const direction = deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
);
if (!wagonReadinessMatchesSchedule(lockedLocomotive.readiness, direction)) {
throw new ConflictException(
`Locomotive ${lockedLocomotive.code} is ${lockedLocomotive.readiness} and cannot run a ${direction} schedule`,
);
}
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive);
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
routeId: route.id,
@@ -581,6 +593,237 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
private async buildScheduleStations(schedule: TrainSchedule) {
type Station = { sequenceNo: number; yardId: string; label: string; code: string };
const stations: Station[] = [];
const route = schedule.routeId
? await this.dataSource.getRepository(Route).findOne({
where: { id: schedule.routeId },
relations: { originYard: true, destinationYard: true, milestones: { yard: true } },
})
: null;
if (route) {
const origin = route.originYard;
const destination = route.destinationYard;
const milestones = [...(route.milestones ?? [])].sort(
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
);
stations.push({
sequenceNo: 0,
yardId: route.originYardId,
label: origin?.label ?? origin?.code ?? 'Origin',
code: origin?.code ?? '',
});
milestones.forEach((m, i) =>
stations.push({
sequenceNo: i + 1,
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
code: m.yard?.code ?? '',
}),
);
stations.push({
sequenceNo: milestones.length + 1,
yardId: route.destinationYardId,
label: destination?.label ?? destination?.code ?? 'Destination',
code: destination?.code ?? '',
});
return stations;
}
// Fallback: no route milestones — just origin → destination from the schedule stations.
stations.push({
sequenceNo: 0,
yardId: schedule.originStationId,
label: schedule.originStation?.label ?? schedule.originStation?.code ?? 'Origin',
code: schedule.originStation?.code ?? '',
});
stations.push({
sequenceNo: 1,
yardId: schedule.destinationStationId,
label:
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? 'Destination',
code: schedule.destinationStation?.code ?? '',
});
return stations;
}
/** Track payload for a schedule: ordered stations, logged checkpoints, current position. */
async getScheduleCheckpoints(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const stations = await this.buildScheduleStations(schedule);
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
const currentSequenceNo = events.length
? Math.max(...events.map((e) => e.sequenceNo))
: -1;
return {
scheduleId,
status: schedule.status,
direction: schedule.direction ?? null,
trainNumber: schedule.trainNumber ?? null,
actualDepartureAt: schedule.actualDepartureAt
? schedule.actualDepartureAt.toISOString()
: null,
actualArrivalAt: schedule.actualArrivalAt
? schedule.actualArrivalAt.toISOString()
: null,
origin: stations[0]?.label ?? null,
destination: stations[stations.length - 1]?.label ?? null,
stations,
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
sequenceNo: e.sequenceNo,
yardId: e.yardId,
label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind,
occurredAt: e.occurredAt.toISOString(),
note: e.note ?? null,
})),
};
}
/** Log the train passing a station. Logging the destination station triggers arrival. */
async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only DISPATCHED trains can be tracked');
}
const stations = await this.buildScheduleStations(schedule);
const finalSeq = stations[stations.length - 1].sequenceNo;
const station = stations.find((s) => s.sequenceNo === dto.sequenceNo);
if (!station) {
throw new BadRequestException(`Station ${dto.sequenceNo} is not on this route`);
}
const kind =
dto.kind ??
(dto.sequenceNo === 0
? TrainCheckpointKind.Departed
: dto.sequenceNo === finalSeq
? TrainCheckpointKind.Arrived
: TrainCheckpointKind.Passed);
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
const [existing] = await this.trainCheckpointEventsRepository.findAll({
where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
});
if (existing) {
await this.trainCheckpointEventsRepository.update(existing.id, {
kind,
occurredAt,
note: dto.note ?? null,
yardId: station.yardId,
});
} else {
await this.trainCheckpointEventsRepository.create({
trainScheduleId: scheduleId,
yardId: station.yardId,
sequenceNo: dto.sequenceNo,
kind,
occurredAt,
note: dto.note ?? null,
});
}
if (dto.sequenceNo === finalSeq) {
await this.arriveSchedule(scheduleId);
}
return this.getScheduleCheckpoints(scheduleId);
}
/**
* Mark a dispatched train arrived: close out the schedule, flip readiness on the
* locomotive + wagons (they have repositioned), and free the assets for re-use.
*/
async arriveSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only DISPATCHED trains can arrive');
}
const isDomestic = schedule.direction === 'DOMESTIC';
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Arrived,
{ actualArrivalAt: now },
manager,
);
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
status: 'COMPLETED',
});
}
if (schedule.trainSet?.locomotiveId) {
const loco = await manager
.getRepository(Locomotive)
.findOne({ where: { id: schedule.trainSet.locomotiveId } });
if (loco) {
await manager.getRepository(Locomotive).update(loco.id, {
status: 'AVAILABLE',
readiness: isDomestic ? loco.readiness : flipReadiness(loco.readiness),
});
}
}
for (const slot of schedule.trainSet?.wagons ?? []) {
if (!slot.physicalWagonId) continue;
const wagon = await manager
.getRepository(Wagon)
.findOne({ where: { id: slot.physicalWagonId } });
if (!wagon) continue;
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
status: WagonStatus.Available,
readiness: isDomestic ? wagon.readiness : flipReadiness(wagon.readiness),
});
}
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
const stations = await this.buildScheduleStations(schedule);
const finalStation = stations[stations.length - 1];
const [existingFinal] = await this.trainCheckpointEventsRepository.findAll({
where: { trainScheduleId: scheduleId, sequenceNo: finalStation.sequenceNo },
});
if (!existingFinal) {
await manager.getRepository(TrainCheckpointEvent).save(
manager.getRepository(TrainCheckpointEvent).create({
trainScheduleId: scheduleId,
yardId: finalStation.yardId,
sequenceNo: finalStation.sequenceNo,
kind: TrainCheckpointKind.Arrived,
occurredAt: now,
}),
);
}
});
return this.getTrainScheduleById(scheduleId);
}
async getContainerTrainSchedules() {
const schedules = await this.trainSchedulesRepository.findAll({
relations: {
@@ -1339,6 +1582,7 @@ export class TrainSchedulingService {
id: schedule.trainSet.locomotive.id,
code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name ?? null,
readiness: schedule.trainSet.locomotive.readiness ?? null,
}
: null,
wagonCount: schedule.trainSet?.wagonCount ?? 0,
@@ -1407,6 +1651,7 @@ export class TrainSchedulingService {
code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name,
status: schedule.trainSet.locomotive.status,
readiness: schedule.trainSet.locomotive.readiness ?? null,
maxPullWeightTons: roundTons(
Number(schedule.trainSet.locomotive.maxPullWeightTons),
),

View File

@@ -16,3 +16,16 @@ export function wagonReadinessMatchesSchedule(
if (!required) return true;
return wagonReadiness === required;
}
/**
* Toggle a readiness value (IMPORT_READY ↔ EXPORT_READY). Used when a train
* reaches its destination: the asset has repositioned, so it is now ready for
* the opposite direction. Direction-agnostic so it handles round trips.
*/
export function flipReadiness(
readiness: WagonReadiness | string,
): WagonReadiness {
return readiness === WagonReadiness.ImportReady
? WagonReadiness.ExportReady
: WagonReadiness.ImportReady;
}

View File

@@ -38,6 +38,7 @@ import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import TrainsPage from "./pages/trains/TrainsPage";
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
@@ -259,6 +260,10 @@ const App = () => {
path="operations/train-scheduling-v2/:scheduleId"
element={<TrainScheduleV2DetailPage />}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={<TrainScheduleTrackPage />}
/>
<Route path="routes" element={<RoutesPage />} />
<Route path="locomotives" element={<FleetResourcePage />} />
<Route path="trains" element={<FleetResourcePage />} />

View File

@@ -2,19 +2,34 @@ import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import {
Badge,
Box,
Button,
Card,
Checkbox,
Group,
Modal,
Paper,
Radio,
RingProgress,
Select,
SimpleGrid,
Stack,
Stepper,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { CheckCircle2 } from "lucide-react";
import {
CheckCircle2,
Container as ContainerIcon,
Eye,
Flame,
LayoutGrid,
Package,
Route as RouteIcon,
Train,
Wallet,
Weight,
} from "lucide-react";
import {
useAvailableLocomotives,
@@ -46,10 +61,16 @@ import { shouldShowContainerPlacementStep } from "./schedulingContainerStep.util
import { FleetAvailabilitySummary } from "./FleetAvailabilitySummary";
import { ScheduleBookingsStep } from "./ScheduleBookingsStep";
import { PreviewSummary, ScheduleWarningsAlert } from "./ScheduleWarningsAlert";
import { SchedulingWorkflowHeader } from "./SchedulingWorkflowHeader";
import { schedulingWorkflow } from "./schedulingWorkflow.styles";
import { SchedulingStatusBadge } from "./ScheduleStatusBadge";
import { FreightTypeBadge, SchedulingStatusBadge } from "./ScheduleStatusBadge";
import {
RouteCorridor,
StatTile,
StatusPill,
scheduleBrand,
} from "./scheduleVisuals";
import { TrainCompositionDiagram } from "./TrainCompositionDiagram";
import { WagonPlanGrid } from "./WagonPlanGrid";
import { WorkflowRail, WorkflowStep } from "./WorkflowStep";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
@@ -157,13 +178,6 @@ export function AllocateBookingWizard({
const previewFreightType = previewResult?.summary?.freightMode as FreightType | undefined;
const finalizeStep = hasContainerStep ? 3 : 2;
const stepLabels = [
"Bookings",
"Wagon plan",
...(hasContainerStep ? ["Containers"] : []),
"Finalize",
];
useEffect(() => {
if (!opened) {
setActiveStep(0);
@@ -216,6 +230,21 @@ export function AllocateBookingWizard({
[routesQuery.data],
);
const displayWagonPlan = useMemo(() => {
const savedWagons = assignedSchedule?.trainSet?.wagons ?? [];
const physicalBySeq = new Map(
savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]),
);
if (previewResult?.wagonPlan?.length) {
return previewResult.wagonPlan.map((slot) => ({
...slot,
physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null,
}));
}
if (savedWagons.length) return savedWagons;
return [];
}, [previewResult?.wagonPlan, assignedSchedule?.trainSet?.wagons]);
const ensureSchedule = async (): Promise<string> => {
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
if (!routeId || !scheduleDate || !locomotiveId) {
@@ -357,305 +386,602 @@ export function AllocateBookingWizard({
}
};
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const holdCountdown = formatCountdown(booking.holdExpiresAt);
const stepDescription =
activeStep === 0
? "Select & preview"
: activeStep === 1
? "Allocations"
: hasContainerStep && activeStep === 2
? "Map units"
: "Depart";
const containerComplete =
hasContainerStep &&
containerUnits.length > 0 &&
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
const stepIcon =
activeStep === 0
? "package"
: activeStep === 1
? "layout"
: hasContainerStep && activeStep === 2
? "container"
: "check";
const stepsMeta = [
{
key: "bookings",
icon: Package,
title: "Bookings",
subtitle: "Select cargo & preview the plan",
complete: Boolean(previewResult) || Boolean(assignedSchedule),
},
{
key: "wagon",
icon: LayoutGrid,
title: "Wagon plan",
subtitle: "Review generated allocations",
complete: displayWagonPlan.length > 0,
},
...(hasContainerStep
? [
{
key: "container",
icon: ContainerIcon,
title: "Containers",
subtitle: "Map units to wagon slots",
complete: containerComplete,
},
]
: []),
{
key: "finalize",
icon: CheckCircle2,
title: "Finalize",
subtitle: "Lock the plan & dispatch",
complete: allocationComplete,
},
];
const completedCount = stepsMeta.filter((s) => s.complete).length;
const progressPct = Math.round((completedCount / stepsMeta.length) * 100);
const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i));
const renderStepRightSlot = (key: string) => {
if (key === "bookings") {
if (previewResult) {
return (
<Badge variant="light" color={previewResult.valid ? "green" : "red"} radius="sm">
{previewResult.valid ? "Plan valid" : "Has issues"}
</Badge>
);
}
return allBookingIds.length ? (
<Badge variant="light" color="green" radius="sm">
{allBookingIds.length} selected
</Badge>
) : null;
}
if (key === "wagon" && displayWagonPlan.length) {
return (
<Badge variant="light" color="green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
);
}
if (key === "container" && containerUnits.length) {
return (
<Badge variant="light" color={containerComplete ? "green" : "yellow"} radius="sm">
{containerUnits.length} units
</Badge>
);
}
if (key === "finalize" && allocationComplete) {
return <StatusPill status="SCHEDULED" />;
}
return null;
};
const renderStepBody = (key: string) => {
if (key === "bookings") {
return (
<Stack gap="md">
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="sm">
<Text fw={600} size="sm">
Train schedule
</Text>
<Radio.Group
value={scheduleMode}
onChange={(v) => setScheduleMode(v as "existing" | "new")}
>
<Group gap="lg">
<Radio value="existing" label="Use existing draft schedule" />
<Radio value="new" label="Create new schedule" />
</Group>
</Radio.Group>
{scheduleMode === "existing" ? (
<Select
label="Draft schedule"
data={matchingSchedules.map((s) => ({
value: s.id,
label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`,
}))}
value={selectedScheduleId}
onChange={setSelectedScheduleId}
searchable
/>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<Select
label="Route"
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
value={routeId || null}
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
<Select
label="Locomotive"
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code} · ${
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
searchable
/>
</SimpleGrid>
)}
{holdCountdown ? (
<Text size="xs" c={holdCountdown.includes("expired") ? "red" : "yellow.8"}>
Hold window: {holdCountdown}
</Text>
) : null}
</Stack>
</Paper>
<ScheduleBookingsStep
assignedBookings={(assignedSchedule?.bookings ?? []).map((b) => ({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons,
}))}
eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading}
selectedIds={allBookingIds}
onSelectionChange={(ids) => {
setExtraBookingIds(ids.filter((id) => id !== booking.id));
}}
freightType={bookingFreightType}
/>
<Group
align="center"
justify="space-between"
wrap="wrap"
gap="md"
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Checkbox
label="Force assign (bypass hold / overweight warnings)"
checked={forceAssign}
onChange={(e) => setForceAssign(e.currentTarget.checked)}
size="sm"
/>
<Button
color="green"
radius="md"
leftSection={<Eye size={16} />}
loading={preview.isPending}
onClick={handlePreview}
>
Preview plan
</Button>
</Group>
{previewResult ? (
<Stack gap="sm">
<ScheduleWarningsAlert
violations={previewResult.violations}
warnings={previewResult.warnings}
/>
<FleetAvailabilitySummary
fleetAvailability={previewResult.fleetAvailability}
deferredBookings={previewResult.deferredBookings}
/>
<PreviewSummary summary={previewResult.summary} />
</Stack>
) : null}
</Stack>
);
}
if (key === "wagon") {
return (
<Stack gap="md">
{!displayWagonPlan.length && !previewResult ? (
<Paper p="md" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run a preview from the Bookings step to generate the wagon plan.
</Text>
</Paper>
) : null}
{reschedulePlan?.displaced.length ? (
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-orange-2)", background: "var(--mantine-color-orange-0)" }}>
<Stack gap="sm">
<Text fw={600} size="sm" c="orange.8">
Government preempt bookings to displace
</Text>
{reschedulePlan.displaced.map((b) => (
<Text key={b.id} size="sm">
{b.reference} (priority {b.priorityScore})
</Text>
))}
<Checkbox
label="I confirm displacing the bookings listed above"
checked={confirmPreempt}
onChange={(e) => setConfirmPreempt(e.currentTarget.checked)}
/>
</Stack>
</Paper>
) : null}
<ScheduleWarningsAlert
violations={previewResult?.violations}
warnings={previewResult?.warnings}
/>
<FleetAvailabilitySummary
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
/>
<WagonPlanGrid
wagonPlan={displayWagonPlan}
freightType={previewFreightType ?? bookingFreightType}
/>
<Group>
{!hasContainerStep ? (
<Button
color="green"
radius="md"
loading={assign.isPending || create.isPending}
onClick={handleAssign}
>
Assign bookings
</Button>
) : (
<Button
color="green"
radius="md"
rightSection={<ContainerIcon size={16} />}
onClick={() => setActiveStep(2)}
>
Continue to containers
</Button>
)}
<Button variant="default" radius="md" onClick={handlePreview}>
Refresh preview
</Button>
</Group>
</Stack>
);
}
if (key === "container") {
return (
<Stack gap="md">
{!containerUnits.length ? (
<Paper p="md" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run preview from the Bookings step to load container units for numbering.
</Text>
</Paper>
) : (
<ContainerPlacementGrid
units={containerUnits}
containerSlots={containerSlots}
placements={containerPlacements}
onChange={setContainerPlacements}
/>
)}
<Group>
<Button
color="green"
radius="md"
loading={assign.isPending || create.isPending}
onClick={handleAssign}
>
Assign bookings
</Button>
<Button variant="default" radius="md" onClick={() => setActiveStep(finalizeStep)}>
Skip to finalize
</Button>
</Group>
</Stack>
);
}
// finalize
return (
<Stack gap="md">
{displayWagonPlan.length || assignedSchedule?.trainSet?.wagons?.length ? (
<TrainCompositionDiagram
locomotive={assignedSchedule?.trainSet?.locomotive}
wagons={
assignedSchedule?.trainSet?.wagons?.length
? assignedSchedule.trainSet.wagons
: displayWagonPlan
}
freightType={previewFreightType ?? bookingFreightType}
trainNumber={assignedSchedule?.trainNumber}
totalLengthMeters={assignedSchedule?.trainSet?.totalLengthMeters}
/>
) : null}
{allocationComplete ? (
<Paper
p="lg"
radius="lg"
withBorder
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
<Text fw={700} size="lg">
Allocation complete
</Text>
<Text size="sm" c="dimmed">
Booking {booking.reference} is scheduled on train{" "}
<Text span fw={600} c="green.7">
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}
</Text>
.
</Text>
<Group mt="sm">
<Button
color="green"
radius="md"
onClick={() => {
onClose();
if (assignedSchedule?.id) {
navigate(
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
);
}
}}
>
View schedule
</Button>
<Button variant="default" radius="md" onClick={onClose}>
Close
</Button>
</Group>
</Stack>
</Group>
</Paper>
) : (
<>
<Paper
p="lg"
radius="lg"
withBorder
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600}>Ready to finalize</Text>
<Text size="sm" c="dimmed">
Finalizing locks the plan, moves the schedule to{" "}
<Text span fw={600} c="green.7">
SCHEDULED
</Text>
, and completes the booking allocation.
</Text>
</Stack>
</Group>
</Paper>
<Group>
<Button
color="green"
size="md"
radius="md"
leftSection={<CheckCircle2 size={18} />}
loading={finalize.isPending}
onClick={handleFinalize}
>
Finalize schedule
</Button>
</Group>
</>
)}
</Stack>
);
};
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>Allocate booking {booking.reference}</Text>}
withCloseButton
size="90%"
radius="xl"
radius="lg"
centered
styles={{ content: { maxWidth: 1200 } }}
padding="lg"
styles={{ content: { maxWidth: 1200 }, body: { paddingTop: 8 } }}
>
<Stack gap="lg">
<SchedulingWorkflowHeader
title="Allocation workflow"
subtitle={`${booking.reference} · ${booking.originYard?.name ?? "Origin"}${booking.destinationYard?.name ?? "Destination"}`}
activeStep={activeStep}
totalSteps={stepLabels.length}
stepLabel={stepLabels[activeStep] ?? ""}
stepDescription={stepDescription}
stepIcon={stepIcon}
/>
<Stepper
active={activeStep}
onStepClick={setActiveStep}
color={schedulingWorkflow.stepper.color}
iconSize={schedulingWorkflow.stepper.iconSize}
size={schedulingWorkflow.stepper.size}
{/* Hero */}
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: scheduleBrand.heroGradient,
boxShadow: scheduleBrand.shadow,
}}
>
<Stepper.Step label="Bookings" description="Select & preview">
<Stack gap="md" mt="lg">
<Card withBorder padding="md" radius="xl">
<Stack gap="xs">
<Group justify="space-between">
<Text fw={600}>{booking.reference}</Text>
<SchedulingStatusBadge status={booking.schedulingStatus} />
<Box
style={{
position: "absolute",
top: -90,
right: -50,
width: 280,
height: 280,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon
size={56}
radius="lg"
variant="white"
style={{ color: "var(--mantine-color-green-7)" }}
>
<Train size={28} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} c="white" fw={700}>
Allocate {booking.reference}
</Title>
</Group>
<Text size="sm" c="dimmed">
{booking.freightType} · {booking.cargoTotalWeightVgm}T
</Text>
<Text size="sm">
{booking.originYard?.name ?? "Origin"} {" "}
{booking.destinationYard?.name ?? "Destination"}
</Text>
{booking.freightType === "CONTAINER" && booking.bookingContainers?.length ? (
<Text size="sm" c="dimmed">
{booking.bookingContainers.map((c) => `${c.quantity}× container`).join(", ")}
</Text>
) : null}
{holdCountdown ? (
<Text size="sm" c={holdCountdown.includes("expired") ? "red" : "yellow"}>
Hold window: {holdCountdown}
</Text>
) : null}
</Stack>
</Card>
<Paper p="md" radius="xl" withBorder>
<Stack gap="md">
<Text fw={600} size="sm">
Train schedule
</Text>
<Radio.Group
value={scheduleMode}
onChange={(v) => setScheduleMode(v as "existing" | "new")}
>
<Stack gap="sm">
<Radio value="existing" label="Use existing draft schedule" />
<Radio value="new" label="Create new schedule" />
</Stack>
</Radio.Group>
{scheduleMode === "existing" ? (
<Select
label="Draft schedule"
data={matchingSchedules.map((s) => ({
value: s.id,
label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`,
}))}
value={selectedScheduleId}
onChange={setSelectedScheduleId}
searchable
<Box maw={360}>
<RouteCorridor
onDark
origin={booking.originYard?.name ?? booking.originYard?.label}
destination={
booking.destinationYard?.name ?? booking.destinationYard?.label
}
/>
) : (
<Stack gap="sm">
<Select
label="Route"
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
value={routeId || null}
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
<Select
label="Locomotive"
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: l.code,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
searchable
/>
</Stack>
)}
</Box>
<Group gap="sm" align="center">
<FreightTypeBadge freightType={booking.freightType} />
{booking.schedulingStatus ? (
<SchedulingStatusBadge status={booking.schedulingStatus} />
) : null}
</Group>
</Stack>
</Paper>
<ScheduleBookingsStep
assignedBookings={(assignedSchedule?.bookings ?? []).map((b) => ({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons,
}))}
eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading}
selectedIds={allBookingIds}
onSelectionChange={(ids) => {
setExtraBookingIds(ids.filter((id) => id !== booking.id));
}}
freightType={bookingFreightType}
/>
<Group align="center" wrap="wrap">
<Button loading={preview.isPending} onClick={handlePreview}>
Preview plan
</Button>
<Checkbox
label="Force assign (bypass hold/overweight warnings)"
checked={forceAssign}
onChange={(e) => setForceAssign(e.currentTarget.checked)}
/>
</Group>
{previewResult ? (
<Stack gap="sm">
<ScheduleWarningsAlert
violations={previewResult.violations}
warnings={previewResult.warnings}
/>
<FleetAvailabilitySummary
fleetAvailability={previewResult.fleetAvailability}
deferredBookings={previewResult.deferredBookings}
/>
<PreviewSummary summary={previewResult.summary} />
</Stack>
) : null}
</Stack>
</Stepper.Step>
<Stepper.Step label="Wagon plan" description="Allocations">
<Stack gap="md" mt="lg">
<ScheduleWarningsAlert
violations={previewResult?.violations}
warnings={previewResult?.warnings}
/>
{reschedulePlan?.displaced.length ? (
<Card withBorder padding="md" radius="xl">
<Stack gap="sm">
<Text fw={600} size="sm" c="orange">
Government preempt bookings to displace
</Text>
{reschedulePlan.displaced.map((b) => (
<Text key={b.id} size="sm">
{b.reference} (priority {b.priorityScore})
</Text>
))}
<Checkbox
label="I confirm displacing the bookings listed above"
checked={confirmPreempt}
onChange={(e) => setConfirmPreempt(e.currentTarget.checked)}
<Badge
size="lg"
radius="sm"
variant="white"
c={previewResult.valid ? "green.8" : "red.7"}
leftSection={
<Box
w={8}
h={8}
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-green-6)"
: "var(--mantine-color-red-6)",
}}
/>
</Stack>
</Card>
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
<PreviewSummary summary={previewResult?.summary} />
<FleetAvailabilitySummary
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile
onDark
icon={Wallet}
label="Total value"
value={`${booking.paymentCurrency} ${amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`}
hint={booking.paymentStatus}
/>
<WagonPlanGrid
wagonPlan={previewResult?.wagonPlan ?? []}
freightType={previewFreightType ?? bookingFreightType}
<StatTile onDark icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
<StatTile
onDark
icon={ContainerIcon}
label="Containers"
value={containerCount || "—"}
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
/>
<Group>
{!hasContainerStep ? (
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
Assign bookings
</Button>
) : (
<Button variant="light" onClick={() => setActiveStep(2)}>
Continue to containers
</Button>
)}
<Button variant="default" onClick={handlePreview}>
Refresh preview
</Button>
<StatTile
onDark
icon={Flame}
label="Priority"
value={booking.priorityScore ?? 0}
hint={booking.tradeDirection}
/>
</SimpleGrid>
</Stack>
</Paper>
{/* Workflow */}
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="lg">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon
size={44}
radius="md"
variant="gradient"
gradient={{ from: "green", to: "teal", deg: 135 }}
>
<RouteIcon size={22} />
</ThemeIcon>
<Stack gap={2}>
<Title order={4} fw={700}>
Allocation workflow
</Title>
<Text size="sm" c="dimmed">
{completedCount} of {stepsMeta.length} steps complete · expand any step
to edit
</Text>
</Stack>
</Group>
</Stack>
</Stepper.Step>
<RingProgress
size={64}
thickness={6}
roundCaps
sections={[{ value: progressPct, color: "green" }]}
label={
<Text ta="center" size="xs" fw={700} c="green.7">
{progressPct}%
</Text>
}
/>
</Group>
{hasContainerStep ? (
<Stepper.Step label="Containers" description="Map units">
<Stack gap="md" mt="lg">
{!containerUnits.length ? (
<Paper p="md" radius="xl" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run preview from the Bookings step to load container units for numbering.
</Text>
</Paper>
) : (
<ContainerPlacementGrid
units={containerUnits}
containerSlots={containerSlots}
placements={containerPlacements}
onChange={setContainerPlacements}
/>
)}
<Group>
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
Assign bookings
</Button>
<Button variant="light" onClick={() => setActiveStep(finalizeStep)}>
Skip to finalize
</Button>
</Group>
</Stack>
</Stepper.Step>
) : null}
<Stepper.Step label="Finalize" description="Depart">
<Stack gap="md" mt="lg">
{allocationComplete ? (
<Paper p="lg" radius="xl" withBorder bg="teal.0">
<Stack gap="md" align="center">
<CheckCircle2 size={40} color="var(--mantine-color-teal-7)" />
<Text fw={700} size="lg">
Allocation complete
</Text>
<Text size="sm" c="dimmed" ta="center">
Booking {booking.reference} is scheduled on train{" "}
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}.
</Text>
<Group>
<Button
color="teal"
onClick={() => {
onClose();
if (assignedSchedule?.id) {
navigate(
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
);
}
}}
>
View schedule
</Button>
<Button variant="default" onClick={onClose}>
Close
</Button>
</Group>
</Stack>
</Paper>
) : (
<>
<Paper p="md" radius="xl" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Finalize moves the schedule to SCHEDULED and completes the booking
allocation.
</Text>
</Paper>
<Group>
<Button color="teal" loading={finalize.isPending} onClick={handleFinalize}>
Finalize schedule
</Button>
</Group>
</>
)}
</Stack>
</Stepper.Step>
</Stepper>
<WorkflowRail>
{stepsMeta.map((step, index) => (
<WorkflowStep
key={step.key}
index={index}
icon={step.icon}
title={step.title}
subtitle={step.subtitle}
state={
activeStep === index
? "active"
: step.complete
? "complete"
: "upcoming"
}
open={activeStep === index}
onToggle={() => toggleStep(index)}
rightSlot={renderStepRightSlot(step.key)}
>
{renderStepBody(step.key)}
</WorkflowStep>
))}
</WorkflowRail>
</Stack>
</Paper>
</Stack>
</Modal>
);

View File

@@ -0,0 +1,192 @@
import { Fragment } from "react";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import { Check, Flag, MapPin, Train } from "lucide-react";
import { freightBrand } from "@/theme/freight-brand";
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
export interface RouteCorridorTrackProps {
stations: TrackStation[];
/** Highest sequenceNo reached so far (1 = not yet departed). */
currentSequenceNo: number;
checkpoints: TrainCheckpoint[];
/** True when the train is DISPATCHED and staff may log progress. */
canLog: boolean;
loggingSeq?: number | null;
onLogCheckpoint?: (sequenceNo: number) => void;
}
const COLUMN_WIDTH = 150;
const PASSED = freightBrand.primary;
const UPCOMING = "var(--mantine-color-gray-3)";
function railColor(active: boolean) {
return active ? PASSED : UPCOMING;
}
export function RouteCorridorTrack({
stations,
currentSequenceNo,
checkpoints,
canLog,
loggingSeq,
onLogCheckpoint,
}: RouteCorridorTrackProps) {
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
const lastIndex = stations.length - 1;
return (
<Box style={{ overflowX: "auto", paddingBottom: 4 }}>
<Group
gap={0}
wrap="nowrap"
align="flex-start"
style={{ minWidth: stations.length * COLUMN_WIDTH }}
>
{stations.map((station, index) => {
const passed = station.sequenceNo <= currentSequenceNo;
const isCurrent = station.sequenceNo === currentSequenceNo;
const isFinal = index === lastIndex;
const isNext = canLog && station.sequenceNo === currentSequenceNo + 1;
const checkpoint = bySeq.get(station.sequenceNo);
// left rail solid once this node is reached; right rail solid once the next node is reached
const leftActive = station.sequenceNo <= currentSequenceNo;
const rightActive = station.sequenceNo + 1 <= currentSequenceNo;
return (
<Fragment key={station.sequenceNo}>
<Stack gap={6} align="center" style={{ width: COLUMN_WIDTH, flexShrink: 0 }}>
{/* rail + node */}
<Box style={{ position: "relative", height: 44, width: "100%" }}>
{index > 0 && (
<Box
style={{
position: "absolute",
top: 21,
left: 0,
width: "50%",
height: 3,
borderRadius: 2,
background: railColor(leftActive),
}}
/>
)}
{index < lastIndex && (
<Box
style={{
position: "absolute",
top: 21,
left: "50%",
width: "50%",
height: 3,
borderRadius: 2,
background: railColor(rightActive),
}}
/>
)}
{/* train marker hovering over the current node */}
{isCurrent && (
<Box
style={{
position: "absolute",
top: -8,
left: "50%",
transform: "translateX(-50%)",
color: freightBrand.primaryDark,
}}
>
<Train size={18} />
</Box>
)}
{/* node */}
<Box
style={{
position: "absolute",
top: 12,
left: "50%",
transform: "translateX(-50%)",
width: 22,
height: 22,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: passed ? PASSED : "white",
border: `2px solid ${
passed
? PASSED
: isNext
? freightBrand.primaryLight
: "var(--mantine-color-gray-4)"
}`,
boxShadow: isCurrent ? `0 0 0 4px ${freightBrand.ring}` : "none",
color: "white",
zIndex: 1,
}}
>
{passed ? (
<Check size={13} />
) : isFinal ? (
<Flag size={12} color="var(--mantine-color-gray-5)" />
) : (
<MapPin size={12} color="var(--mantine-color-gray-5)" />
)}
</Box>
</Box>
{/* label */}
<Stack gap={0} align="center" style={{ minWidth: 0, padding: "0 6px" }}>
<Text
size="xs"
fw={passed ? 700 : 600}
ta="center"
lineClamp={2}
c={passed ? "green.8" : "dimmed"}
>
{station.label}
</Text>
{index === 0 ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
Origin
</Badge>
) : isFinal ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
Destination
</Badge>
) : null}
</Stack>
{/* checkpoint time or action */}
{checkpoint ? (
<Text size="10px" c="dimmed" ta="center">
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
) : isNext ? (
<Button
size="compact-xs"
radius="md"
color={isFinal ? "teal" : "green"}
variant={isFinal ? "filled" : "light"}
loading={loggingSeq === station.sequenceNo}
onClick={() => onLogCheckpoint?.(station.sequenceNo)}
>
{isFinal ? "Mark arrived" : "Log pass"}
</Button>
) : (
<Box style={{ height: 22 }} />
)}
</Stack>
</Fragment>
);
})}
</Group>
</Box>
);
}

View File

@@ -47,6 +47,7 @@ export const QUERY_KEYS = {
stations: () => ["train-scheduling", "stations"] as const,
schedules: () => ["train-scheduling", "schedules"] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
track: (id: string) => ["train-scheduling", "track", id] as const,
},
FLEET: {

View File

@@ -160,6 +160,8 @@ export const URL_CONSTANTS = {
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
RESCHEDULE_PREVIEW: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/preview`,
RESCHEDULE_EXECUTE: (id: string) =>

View File

@@ -7,6 +7,7 @@ import type {
CreateTrainSchedulePayload,
FreightType,
PinWagonsPayload,
RecordCheckpointPayload,
TrainScheduleFilters,
TrainSchedulePreviewPayload,
} from "@/types/trainScheduling";
@@ -41,6 +42,13 @@ export const useAvailableLocomotives = () =>
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
});
export const useTrainTrack = (id: string | undefined) =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),
queryFn: () => trainSchedulingService.getTrack(id!),
enabled: Boolean(id),
});
export const useScheduleMutations = (scheduleId?: string) => {
const qc = useQueryClient();
@@ -52,6 +60,9 @@ export const useScheduleMutations = (scheduleId?: string) => {
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
});
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(scheduleId),
});
}
void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
};
@@ -118,5 +129,28 @@ export const useScheduleMutations = (scheduleId?: string) => {
onSuccess: invalidate,
});
return { create, preview, assign, unassign, pin, finalize, dispatch, cancel, invalidate };
const recordCheckpoint = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: RecordCheckpointPayload }) =>
trainSchedulingService.recordCheckpoint(id, payload),
onSuccess: invalidate,
});
const arrive = useMutation({
mutationFn: (id: string) => trainSchedulingService.arriveSchedule(id),
onSuccess: invalidate,
});
return {
create,
preview,
assign,
unassign,
pin,
finalize,
dispatch,
cancel,
recordCheckpoint,
arrive,
invalidate,
};
};

View File

@@ -0,0 +1,263 @@
import { Link, useParams } from "react-router-dom";
import { isAxiosError } from "axios";
import {
ArrowLeft,
CalendarClock,
CheckCircle2,
Flag,
MapPin,
Navigation,
Train,
} from "lucide-react";
import {
Badge,
Box,
Button,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Timeline,
Title,
} from "@mantine/core";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import {
RouteCorridor,
StatTile,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import { useTrainTrack, useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const data = error.response?.data as Record<string, unknown> | undefined;
const message = data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
function formatDateTime(iso?: string | null) {
if (!iso) return "—";
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function TrainScheduleTrackPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
const trackQuery = useTrainTrack(scheduleId);
const { recordCheckpoint } = useScheduleMutations(scheduleId);
if (trackQuery.isLoading) {
return (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
);
}
const track = trackQuery.data;
if (!track || !scheduleId) {
return (
<Text c="dimmed" py="xl">
Tracking data not found.
</Text>
);
}
const canLog = track.status === "DISPATCHED";
const totalStations = track.stations.length;
const reached = Math.min(track.currentSequenceNo + 1, totalStations);
const progressLabel = `${reached} / ${totalStations}`;
const handleLog = (sequenceNo: number) => {
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo } },
{
onSuccess: () => {
toast({
title: isFinal
? "Train arrived — assets freed, readiness flipped"
: "Checkpoint logged",
});
},
onError: (err) =>
toast({
title: "Could not log checkpoint",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
return (
<Stack gap="lg">
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
>
Back to schedule
</Button>
{/* Hero */}
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: scheduleBrand.heroGradient,
boxShadow: scheduleBrand.shadow,
}}
>
<Box
style={{
position: "absolute",
top: -90,
right: -50,
width: 280,
height: 280,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={56} radius="lg" variant="white" style={{ color: "var(--mantine-color-green-7)" }}>
<Navigation size={28} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} c="white" fw={700}>
Track train
</Title>
{track.trainNumber ? (
<Badge variant="white" c="green.8" radius="sm" style={{ fontWeight: 600 }}>
{track.trainNumber}
</Badge>
) : null}
{track.direction ? (
<Badge variant="white" c="green.8" radius="sm">
{track.direction}
</Badge>
) : null}
</Group>
<Box maw={340}>
<RouteCorridor onDark origin={track.origin} destination={track.destination} />
</Box>
<StatusPill status={track.status} />
</Stack>
</Group>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile onDark icon={Train} label="Progress" value={progressLabel} hint="stations reached" />
<StatTile onDark icon={MapPin} label="Current" value={track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"} />
<StatTile onDark icon={CalendarClock} label="Departed" value={formatDateTime(track.actualDepartureAt)} />
<StatTile onDark icon={Flag} label="Arrived" value={formatDateTime(track.actualArrivalAt)} />
</SimpleGrid>
</Stack>
</Paper>
{/* Corridor */}
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="lg">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="gradient" gradient={{ from: "green", to: "teal", deg: 135 }}>
<Navigation size={22} />
</ThemeIcon>
<Stack gap={2}>
<Title order={4} fw={700}>
Route corridor
</Title>
<Text size="sm" c="dimmed">
{canLog
? "Log the train passing each station; the final station marks arrival."
: track.status === "ARRIVED"
? "This train has arrived at its destination."
: "Tracking becomes available once the train is dispatched."}
</Text>
</Stack>
</Group>
</Group>
<RouteCorridorTrack
stations={track.stations}
currentSequenceNo={track.currentSequenceNo}
checkpoints={track.checkpoints}
canLog={canLog}
loggingSeq={
recordCheckpoint.isPending ? recordCheckpoint.variables?.payload.sequenceNo : null
}
onLogCheckpoint={handleLog}
/>
</Stack>
</Paper>
{/* Timeline */}
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="md">
<Title order={5} fw={700}>
Checkpoint log
</Title>
{track.checkpoints.length === 0 ? (
<Text size="sm" c="dimmed">
No checkpoints logged yet.
</Text>
) : (
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="green">
{track.checkpoints.map((cp) => (
<Timeline.Item
key={cp.id}
bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />}
title={
<Group gap="sm">
<Text fw={600} size="sm">
{cp.label ?? `Station ${cp.sequenceNo}`}
</Text>
<Badge
size="xs"
radius="sm"
variant="light"
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "green"}
>
{cp.kind}
</Badge>
</Group>
}
>
<Text size="xs" c="dimmed">
{formatDateTime(cp.occurredAt)}
</Text>
{cp.note ? <Text size="xs">{cp.note}</Text> : null}
</Timeline.Item>
))}
</Timeline>
)}
</Stack>
</Paper>
</Stack>
);
}

View File

@@ -8,6 +8,7 @@ import {
Container as ContainerIcon,
Eye,
LayoutGrid,
Navigation,
Package,
Route as RouteIcon,
Send,
@@ -771,17 +772,32 @@ export default function TrainScheduleV2DetailPage() {
</Group>
</Stack>
</Group>
{schedule.status !== "DISPATCHED" ? (
<Button
variant="white"
c="green.8"
radius="lg"
size="sm"
onClick={() => setMaintenanceOpen(true)}
>
Reschedule train
</Button>
) : null}
<Group gap="sm">
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
variant="white"
c="green.8"
radius="lg"
size="sm"
leftSection={<Navigation size={16} />}
>
Track train
</Button>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Button
variant="white"
c="green.8"
radius="lg"
size="sm"
onClick={() => setMaintenanceOpen(true)}
>
Reschedule train
</Button>
) : null}
</Group>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
@@ -790,6 +806,13 @@ export default function TrainScheduleV2DetailPage() {
icon={Train}
label="Locomotive"
value={schedule.trainSet?.locomotive?.code ?? "—"}
hint={
schedule.trainSet?.locomotive?.readiness === "EXPORT_READY"
? "Export-ready"
: schedule.trainSet?.locomotive?.readiness === "IMPORT_READY"
? "Import-ready"
: undefined
}
/>
<StatTile
onDark

View File

@@ -17,7 +17,7 @@ import {
ThemeIcon,
Title,
} from "@mantine/core";
import { ArrowRight, CalendarClock, Send, Train, Weight } from "lucide-react";
import { ArrowRight, CalendarClock, Navigation, Send, Train, Weight } from "lucide-react";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
@@ -253,6 +253,21 @@ export default function TrainScheduleV2ListPage() {
>
Open
</Button>
{["DISPATCHED", "ARRIVED"].includes(row.original.status) ? (
<Button
variant="light"
color="teal"
size="compact-sm"
leftSection={<Navigation size={14} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${row.original.id}/track`,
)
}
>
Track
</Button>
) : null}
{["DRAFT", "SCHEDULED"].includes(row.original.status) ? (
<Button
variant="subtle"
@@ -493,6 +508,11 @@ export default function TrainScheduleV2ListPage() {
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
)
}
onTrack={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
)
}
/>
))}
</SimpleGrid>
@@ -542,7 +562,9 @@ export default function TrainScheduleV2ListPage() {
placeholder="Select locomotive"
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""}`,
label: `${l.code}${l.name ? `${l.name}` : ""} · ${
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
@@ -601,11 +623,14 @@ function MetricChip({
function ScheduleCard({
schedule,
onOpen,
onTrack,
}: {
schedule: TrainScheduleListItem;
onOpen: () => void;
onTrack: () => void;
}) {
const { day, time } = splitDate(schedule.scheduleDate);
const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
return (
<Card
radius="lg"
@@ -667,20 +692,37 @@ function ScheduleCard({
</Group>
</Group>
<Button
variant="light"
color="green"
size="sm"
radius="md"
fullWidth
rightSection={<ArrowRight size={15} />}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
Open schedule
</Button>
<Group gap="xs" wrap="nowrap">
<Button
variant="light"
color="green"
size="sm"
radius="md"
style={{ flex: 1 }}
rightSection={<ArrowRight size={15} />}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
Open schedule
</Button>
{canTrack ? (
<Button
variant="light"
color="teal"
size="sm"
radius="md"
leftSection={<Navigation size={15} />}
onClick={(e) => {
e.stopPropagation();
onTrack();
}}
>
Track
</Button>
) : null}
</Group>
</Stack>
</Card>
);

View File

@@ -8,12 +8,14 @@ import type {
FreightType,
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
TrainSchedulingGlobalRules,
TrainTrackResponse,
YardOption,
} from '@/types/trainScheduling';
@@ -137,6 +139,32 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
const response = await client.get<TrainTrackResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
);
return unwrap(response.data);
},
recordCheckpoint: async (
scheduleId: string,
payload: RecordCheckpointPayload,
): Promise<TrainTrackResponse> => {
const response = await client.post<TrainTrackResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
payload,
);
return unwrap(response.data);
},
arriveSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId),
{},
);
return unwrap(response.data);
},
cancelSchedule: async (
id: string,
freightType: FreightType = "CONTAINER",

View File

@@ -127,6 +127,8 @@ export interface TrainSchedulePreviewResponse {
containerSlotSequenceNos?: number[];
}
export type Readiness = "IMPORT_READY" | "EXPORT_READY";
export interface LocomotiveRecord {
id: string;
code: string;
@@ -134,6 +136,7 @@ export interface LocomotiveRecord {
maxPullWeightTons: number;
maxTrainLengthMeters: number;
status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE";
readiness?: Readiness | null;
locomotiveType?: "DIESEL" | "ELECTRIC";
}
@@ -150,6 +153,7 @@ export interface TrainScheduleListItem {
id: string;
code: string;
name?: string | null;
readiness?: Readiness | null;
}
| null;
wagonCount: number;
@@ -197,6 +201,7 @@ export interface TrainScheduleDetail {
scheduledDepartureDate: string;
scheduledArrivalDate?: string | null;
actualDepartureAt?: string | null;
actualArrivalAt?: string | null;
originStation?: {
id: string;
label?: string;
@@ -218,6 +223,7 @@ export interface TrainScheduleDetail {
code: string;
name?: string | null;
status: string;
readiness?: Readiness | null;
maxPullWeightTons: number;
maxTrainLengthMeters?: number;
} | null;
@@ -249,6 +255,46 @@ export interface TrainScheduleDetail {
warnings?: string[];
}
export type TrainCheckpointKind = "DEPARTED" | "PASSED" | "ARRIVED";
export interface TrackStation {
sequenceNo: number;
yardId: string;
label: string;
code: string;
}
export interface TrainCheckpoint {
id: string;
sequenceNo: number;
yardId: string;
label: string | null;
kind: TrainCheckpointKind;
occurredAt: string;
note: string | null;
}
export interface TrainTrackResponse {
scheduleId: string;
status: TrainScheduleStatus | string;
direction?: string | null;
trainNumber?: string | null;
actualDepartureAt?: string | null;
actualArrivalAt?: string | null;
origin: string | null;
destination: string | null;
stations: TrackStation[];
currentSequenceNo: number;
checkpoints: TrainCheckpoint[];
}
export interface RecordCheckpointPayload {
sequenceNo: number;
kind?: TrainCheckpointKind;
occurredAt?: string;
note?: string;
}
export interface TrainScheduleFilters {
originStationId?: string;
destinationStationId?: string;

View File

@@ -146,6 +146,22 @@ export enum WagonReadiness {
export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
export enum TrainCheckpointKind {
Departed = "DEPARTED",
Passed = "PASSED",
Arrived = "ARRIVED",
}
export interface ITrainCheckpointEvent extends BaseEntity {
trainScheduleId: string;
yardId: string;
sequenceNo: number;
kind: TrainCheckpointKind;
occurredAt: string;
note?: string | null;
recordedByUserId?: string | null;
}
export enum BulkPricingUnit {
PerWagon = "PER_WAGON",
PerTon = "PER_TON",