From b45421d2a20f5eebef2200ca8ce0cafdfc96c987 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Sat, 6 Jun 2026 16:31:19 +0300 Subject: [PATCH] feat(freight): changed the train schedule --- .../1750300000000-AddRouteToTrainSchedules.ts | 44 + .../entities/train-schedule.entity.ts | 8 + .../create-container-train-schedule.dto.ts | 12 +- .../train-scheduling.service.spec.ts | 124 +-- .../train-scheduling.service.ts | 140 ++- .../src/pages/trains/TrainsPage.tsx | 795 ++++++------------ .../backoffice/src/types/trainScheduling.ts | 9 +- 7 files changed, 392 insertions(+), 740 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts diff --git a/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts b/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts new file mode 100644 index 000000000..027ebfe98 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface { + name = 'AddRouteToTrainSchedules1750300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS route_id UUID NULL; + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_train_schedules_route' + ) THEN + ALTER TABLE freight.train_schedules + ADD CONSTRAINT fk_train_schedules_route + FOREIGN KEY (route_id) REFERENCES freight.routes(id); + END IF; + END $$; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_route_id + ON freight.train_schedules(route_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_route_id;`); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP CONSTRAINT IF EXISTS fk_train_schedules_route; + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS route_id; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 965723f6c..4edd09f09 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Route } from '../../routes/entities/route.entity'; import { TrainSet } from '../../train-sets/entities/train-set.entity'; import { TrainScheduleBooking } from './train-schedule-booking.entity'; @@ -26,6 +27,13 @@ export class TrainSchedule extends BaseEntity { @JoinColumn({ name: 'train_set_id' }) trainSet?: TrainSet; + @Column({ name: 'route_id', type: 'uuid', nullable: true }) + routeId?: string | null; + + @ManyToOne(() => Route) + @JoinColumn({ name: 'route_id' }) + route?: Route | null; + @Column({ name: 'origin_station_id', type: 'uuid' }) originStationId!: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 173b0a6b3..1b4fa29d8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -1,9 +1,15 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsUUID } from 'class-validator'; +import { IsDateString, IsUUID } from 'class-validator'; -import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto'; +export class CreateContainerTrainScheduleDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + routeId!: string; + + @ApiProperty({ example: '2026-06-20T08:00:00.000Z' }) + @IsDateString() + scheduleDate!: string; -export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto { @ApiProperty({ format: 'uuid' }) @IsUUID() locomotiveId!: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 33ae0b599..8f2b77bb4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -203,47 +203,12 @@ describe('TrainSchedulingService', () => { }); it('creates a schedule transactionally when validation passes', async () => { - const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')]; - const validation = { - valid: true, - violations: [], - bookings, - wagonType: nw5, - summary: { - totalBookings: 1, - totalWeightTons: 140, - wagonType: 'NW5', - wagonsNeeded: 2, - totalLengthMeters: 28, - }, - wagonPlan: [ - { - sequenceNo: 1, - capacityTons: 70, - lengthMeters: 14, - assignedWeightTons: 70, - allocations: [ - { - bookingId: 'b1', - bookingReference: 'BKG-CONT-001', - allocatedWeightTons: 70, - }, - ], - }, - { - sequenceNo: 2, - capacityTons: 70, - lengthMeters: 14, - assignedWeightTons: 70, - allocations: [ - { - bookingId: 'b1', - bookingReference: 'BKG-CONT-001', - allocatedWeightTons: 70, - }, - ], - }, - ], + const route = { + id: 'route-1', + name: 'Djibouti to Addis', + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + isActive: true, }; const lockedLocomotiveRepo = { @@ -254,23 +219,6 @@ describe('TrainSchedulingService', () => { create: jest.fn().mockImplementation((value) => value), save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), }; - const trainScheduleBookingRepo = { - count: jest.fn().mockResolvedValue(0), - create: jest.fn().mockImplementation((value) => value), - save: jest.fn().mockResolvedValue(undefined), - }; - const trainSetWagonRepo = { - create: jest.fn().mockImplementation((value) => value), - save: jest.fn().mockResolvedValue(undefined), - find: jest.fn().mockResolvedValue([ - { id: 'wagon-1', sequenceNo: 1 }, - { id: 'wagon-2', sequenceNo: 2 }, - ]), - }; - const wagonAllocRepo = { - create: jest.fn().mockImplementation((value) => value), - save: jest.fn().mockResolvedValue(undefined), - }; const trainSetRepo = { create: jest.fn().mockImplementation((value) => value), save: jest.fn().mockResolvedValue({ id: 'train-set-1' }), @@ -282,12 +230,6 @@ describe('TrainSchedulingService', () => { return lockedLocomotiveRepo; case 'TrainSchedule': return trainScheduleRepo; - case 'TrainScheduleBooking': - return trainScheduleBookingRepo; - case 'TrainSetWagon': - return trainSetWagonRepo; - case 'WagonBookingAllocation': - return wagonAllocRepo; case 'TrainSet': return trainSetRepo; default: @@ -296,70 +238,60 @@ describe('TrainSchedulingService', () => { }), }; - jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never); jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Route') { + return { findOne: jest.fn().mockResolvedValue(route) }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never); dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => callback(manager), ); const result = await service.createContainerTrainSchedule({ - bookingIds: ['b1'], + routeId: 'route-1', scheduleDate: '2026-06-20T08:00:00.000Z', - originStationId: 'yard-origin', - destinationStationId: 'yard-destination', locomotiveId: 'loc-1', }); expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled(); - expect(trainSetWagonRepo.save).toHaveBeenCalled(); - expect(wagonAllocRepo.save).toHaveBeenCalled(); expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' }); expect(result).toEqual({ id: 'schedule-1' }); }); it('rejects create when the locked locomotive is no longer available', async () => { - const validation = { - valid: true, - violations: [], - bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')], - wagonType: nw5, - summary: { - totalBookings: 1, - totalWeightTons: 70, - wagonType: 'NW5', - wagonsNeeded: 1, - totalLengthMeters: 14, - }, - wagonPlan: [ - { - sequenceNo: 1, - capacityTons: 70, - lengthMeters: 14, - assignedWeightTons: 70, - allocations: [], - }, - ], - }; const manager = { getRepository: jest.fn(() => ({ findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), })), }; - jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never); jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Route') { + return { + findOne: jest.fn().mockResolvedValue({ + id: 'route-1', + name: 'Djibouti to Addis', + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + isActive: true, + }), + }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => callback(manager), ); await expect( service.createContainerTrainSchedule({ - bookingIds: ['b1'], + routeId: 'route-1', scheduleDate: '2026-06-20T08:00:00.000Z', - originStationId: 'yard-origin', - destinationStationId: 'yard-destination', locomotiveId: 'loc-1', }), ).rejects.toBeInstanceOf(ConflictException); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 4686d6986..64147940d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -15,9 +15,9 @@ import { import { LocomotivesRepository } from "../locomotives/locomotives.repository"; import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity"; import { TrainSet } from "../train-sets/entities/train-set.entity"; +import { Route } from "../routes/entities/route.entity"; import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity"; import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; -import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity"; import { WagonType } from "../wagon-types/entities/wagon-type.entity"; import { WagonTypesRepository } from "../wagon-types/wagon-types.repository"; import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; @@ -179,19 +179,12 @@ export class TrainSchedulingService { } async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { - const validation = await this.validateContainerBookingsForScheduling(dto); - - if (!validation.valid) { - throw new BadRequestException({ - message: "train_schedule_invalid", - violations: validation.violations, - }); - } + const route = await this.getActiveRoute(dto.routeId); const locomotive = await this.selectOrValidateLocomotive( dto.locomotiveId, - validation.summary.totalWeightTons, - validation.summary.totalLengthMeters, + 0, + 0, ); const createdSchedule = await this.dataSource.transaction( @@ -212,99 +205,24 @@ export class TrainSchedulingService { ); } - if ( - Number(lockedLocomotive.maxPullWeightTons) < - validation.summary.totalWeightTons - ) { - throw new BadRequestException( - `Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`, - ); - } - - if ( - Number(lockedLocomotive.maxTrainLengthMeters) < - validation.summary.totalLengthMeters - ) { - throw new BadRequestException( - `Locomotive ${lockedLocomotive.code} cannot support ${validation.summary.totalLengthMeters}m`, - ); - } - - const existingScheduleCount = await manager - .getRepository(TrainScheduleBooking) - .count({ - where: { - bookingId: In(validation.bookings.map((booking) => booking.id)), - }, - }); - - if (existingScheduleCount > 0) { - throw new BadRequestException( - "One or more bookings are already scheduled", - ); - } - - const trainSet = await this.buildTrainSet( + const trainSet = await this.buildEmptyTrainSet( manager, lockedLocomotive, - validation.wagonType, - validation.summary.totalWeightTons, - validation.summary.totalLengthMeters, - validation.wagonPlan, ); const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, - originStationId: dto.originStationId, - destinationStationId: dto.destinationStationId, + routeId: route.id, + originStationId: route.originYardId, + destinationStationId: route.destinationYardId, scheduledDepartureDate: new Date(dto.scheduleDate), - status: "SCHEDULED", + status: "DRAFT", }); const savedSchedule = await manager .getRepository(TrainSchedule) .save(schedule); - const scheduleBookings = validation.bookings.map((booking) => - manager.getRepository(TrainScheduleBooking).create({ - trainScheduleId: savedSchedule.id, - bookingId: booking.id, - }), - ); - await manager - .getRepository(TrainScheduleBooking) - .save(scheduleBookings); - - const savedWagons = await manager.getRepository(TrainSetWagon).find({ - where: { trainSetId: trainSet.id }, - order: { sequenceNo: "ASC" }, - }); - - const wagonBySequence = new Map( - savedWagons.map((wagon) => [wagon.sequenceNo, wagon]), - ); - const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => { - const wagon = wagonBySequence.get(wagonPlan.sequenceNo); - - if (!wagon) { - throw new BadRequestException( - `Missing wagon sequence ${wagonPlan.sequenceNo}`, - ); - } - - return wagonPlan.allocations.map((allocation) => - manager.getRepository(WagonBookingAllocation).create({ - trainSetWagonId: wagon.id, - bookingId: allocation.bookingId, - allocatedWeightTons: allocation.allocatedWeightTons, - }), - ); - }); - - await manager - .getRepository(WagonBookingAllocation) - .save(allocationRows); - await locomotiveRepository.update(lockedLocomotive.id, { status: "ASSIGNED", }); @@ -589,6 +507,21 @@ export class TrainSchedulingService { return savedTrainSet; } + async buildEmptyTrainSet( + manager: EntityManager, + locomotive: Locomotive, + ) { + const trainSet = manager.getRepository(TrainSet).create({ + locomotiveId: locomotive.id, + totalWeightTons: 0, + totalLengthMeters: 0, + wagonCount: 0, + status: 'DRAFT', + }); + + return manager.getRepository(TrainSet).save(trainSet); + } + allocateBookingsToWagons( bookings: Booking[], baseWagonPlan: WagonPlanRecord[], @@ -648,6 +581,7 @@ export class TrainSchedulingService { const schedules = await this.dataSource.getRepository(TrainSchedule).find({ relations: { trainSet: { locomotive: true }, + route: true, originStation: true, destinationStation: true, scheduleBookings: true, @@ -658,6 +592,7 @@ export class TrainSchedulingService { return schedules.map((schedule) => ({ id: schedule.id, scheduleDate: schedule.scheduledDepartureDate, + routeName: schedule.route?.name ?? null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: @@ -689,6 +624,7 @@ export class TrainSchedulingService { .findOne({ where: { id }, relations: { + route: true, trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } }, @@ -708,6 +644,12 @@ export class TrainSchedulingService { return { id: schedule.id, status: schedule.status, + route: schedule.route + ? { + id: schedule.route.id, + name: schedule.route.name, + } + : null, scheduledDepartureDate: schedule.scheduledDepartureDate, scheduledArrivalDate: schedule.scheduledArrivalDate, originStation: schedule.originStation, @@ -830,6 +772,22 @@ export class TrainSchedulingService { }); } + private async getActiveRoute(routeId: string) { + const route = await this.dataSource.getRepository(Route).findOne({ + where: { id: routeId }, + }); + + if (!route) { + throw new NotFoundException(`Route ${routeId} not found`); + } + + if (!route.isActive) { + throw new BadRequestException(`Route ${route.name} is inactive`); + } + + return route; + } + private toUtcDateKey(value: Date | string) { const date = value instanceof Date ? value : new Date(value); return date.toISOString().slice(0, 10); diff --git a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx index 581515421..53ee2fa6f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx @@ -19,13 +19,8 @@ import { import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; +import { useRoutes } from '@/hooks/useRoutes'; import { trainSchedulingService } from '@/services/trainScheduling.service'; -import type { - EligibleContainerBooking, - TrainScheduleFilters, - TrainSchedulePreviewResponse, - YardOption, -} from '@/types/trainScheduling'; const inputClassName = 'w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950'; @@ -43,11 +38,6 @@ const formatDate = (value?: string | null) => { }).format(date); }; -const formatDayInput = (value?: string | null) => { - if (!value) return ''; - return value.slice(0, 10); -}; - const parseError = (error: unknown, fallback: string) => { if (isAxiosError(error)) { const message = error.response?.data?.message; @@ -59,62 +49,39 @@ const parseError = (error: unknown, fallback: string) => { return fallback; }; -const deriveFromBooking = ( - booking: EligibleContainerBooking | undefined, - stations: YardOption[], -) => { - if (!booking) { - return { originStationId: '', destinationStationId: '', scheduleDate: '' }; - } - - const originStationId = stations.find((station) => station.name === booking.origin)?.id ?? ''; - const destinationStationId = - stations.find((station) => station.name === booking.destination)?.id ?? ''; - - return { - originStationId, - destinationStationId, - scheduleDate: formatDayInput(booking.preferredDepartureDate), - }; -}; - const TrainsPage = () => { const qc = useQueryClient(); - const [filters, setFilters] = useState({}); - const [selectedBookingIds, setSelectedBookingIds] = useState([]); - const [preview, setPreview] = useState(null); + const [routeId, setRouteId] = useState(''); + const [scheduleDate, setScheduleDate] = useState(''); const [selectedLocomotiveId, setSelectedLocomotiveId] = useState(''); const [detailId, setDetailId] = useState(null); const [scheduleSearch, setScheduleSearch] = useState(''); const [scheduleStatusFilter, setScheduleStatusFilter] = useState('ALL'); - const stationsQuery = useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(), - queryFn: () => trainSchedulingService.getStations(), - }); - - const eligibleQuery = useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.eligible(filters), - queryFn: () => trainSchedulingService.getEligibleBookings(filters), - }); - + const routesQuery = useRoutes(); const locomotivesQuery = useQuery({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(), queryFn: () => trainSchedulingService.getAvailableLocomotives(), }); - const schedulesQuery = useQuery({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(), queryFn: () => trainSchedulingService.listSchedules(), }); - const detailQuery = useQuery({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ''), queryFn: () => trainSchedulingService.getScheduleById(detailId!), enabled: Boolean(detailId), }); - const eligibleItems = eligibleQuery.data?.items ?? []; + const activeRoutes = useMemo( + () => (routesQuery.data ?? []).filter((route) => route.isActive), + [routesQuery.data], + ); + const selectedRoute = activeRoutes.find((route) => route.id === routeId) ?? null; + const selectedLocomotive = (locomotivesQuery.data ?? []).find( + (locomotive) => locomotive.id === selectedLocomotiveId, + ); + const filteredSchedules = useMemo(() => { const query = scheduleSearch.trim().toLowerCase(); @@ -132,6 +99,7 @@ const TrainsPage = () => { const haystack = [ schedule.id, + schedule.routeName ?? '', schedule.origin ?? '', schedule.destination ?? '', schedule.locomotive?.code ?? '', @@ -143,70 +111,24 @@ const TrainsPage = () => { return haystack.includes(query); }); }, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]); - const selectedBookings = useMemo( - () => eligibleItems.filter((booking) => selectedBookingIds.includes(booking.id)), - [eligibleItems, selectedBookingIds], - ); - - const summary = useMemo(() => { - const totalWeightTons = selectedBookings.reduce((sum, booking) => sum + booking.weightTons, 0); - const wagonsNeeded = Math.ceil(totalWeightTons / 70); - const totalLengthMeters = wagonsNeeded * 14; - const routeSet = new Set(selectedBookings.map((booking) => `${booking.origin} -> ${booking.destination}`)); - const dateSet = new Set(selectedBookings.map((booking) => formatDayInput(booking.preferredDepartureDate))); - - return { - count: selectedBookings.length, - totalWeightTons, - wagonsNeeded: Number.isFinite(wagonsNeeded) ? wagonsNeeded : 0, - totalLengthMeters: Number.isFinite(totalLengthMeters) ? totalLengthMeters : 0, - route: routeSet.size === 1 ? [...routeSet][0] : selectedBookings.length ? 'Mixed route' : '-', - scheduleDate: dateSet.size === 1 ? [...dateSet][0] : selectedBookings.length ? 'Mixed date' : '-', - }; - }, [selectedBookings]); - - const previewMutation = useMutation({ - mutationFn: () => { - if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) { - throw new Error('Please select origin, destination, and schedule date'); - } - return trainSchedulingService.preview({ - bookingIds: selectedBookingIds, - scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(), - originStationId: filters.originStationId, - destinationStationId: filters.destinationStationId, - }); - }, - onSuccess: (data) => { - setPreview(data); - toast.success(data.valid ? 'Preview generated' : 'Preview has validation issues'); - }, - onError: (error) => { - toast.error(parseError(error, 'Failed to preview train schedule')); - }, - }); const createMutation = useMutation({ mutationFn: () => { - if (!selectedLocomotiveId) { - throw new Error('Please select a locomotive'); - } - if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) { - throw new Error('Please select origin, destination, and schedule date'); + if (!routeId || !scheduleDate || !selectedLocomotiveId) { + throw new Error('Please select route, departure date, and locomotive'); } + return trainSchedulingService.createSchedule({ - bookingIds: selectedBookingIds, - scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(), - originStationId: filters.originStationId, - destinationStationId: filters.destinationStationId, + routeId, + scheduleDate: new Date(`${scheduleDate}T08:00:00.000Z`).toISOString(), locomotiveId: selectedLocomotiveId, }); }, onSuccess: (data) => { toast.success('Train schedule created'); - setSelectedBookingIds([]); + setRouteId(''); + setScheduleDate(''); setSelectedLocomotiveId(''); - setPreview(null); void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() }); @@ -232,32 +154,12 @@ const TrainsPage = () => { }, }); - const toggleBooking = (booking: EligibleContainerBooking, checked: boolean) => { - setSelectedBookingIds((current) => { - if (checked) { - const next = [...new Set([...current, booking.id])]; - if (next.length === 1) { - const defaults = deriveFromBooking(booking, stationsQuery.data ?? []); - setFilters((prev) => ({ - ...prev, - originStationId: prev.originStationId || defaults.originStationId, - destinationStationId: prev.destinationStationId || defaults.destinationStationId, - scheduleDate: prev.scheduleDate || defaults.scheduleDate, - })); - } - return next; - } - return current.filter((id) => id !== booking.id); - }); - setPreview(null); - }; - const detail = detailQuery.data; - const isBusy = previewMutation.isPending || createMutation.isPending; + const isBusy = createMutation.isPending; return (
- +
@@ -266,9 +168,9 @@ const TrainsPage = () => {
-

Train Scheduling

+

Train Schedules

- Build container train schedules from compatible bookings, preview wagon plans, and assign locomotives. + Create the train schedule first, reserve the locomotive, and assign bookings and wagons later.

@@ -276,7 +178,7 @@ const TrainsPage = () => { variant="outline" className="gap-2" onClick={() => { - void eligibleQuery.refetch(); + void routesQuery.refetch(); void schedulesQuery.refetch(); void locomotivesQuery.refetch(); }} @@ -286,346 +188,185 @@ const TrainsPage = () => { -
-
-
-
- -

- Filters -

-
-
-
- - -
-
- - -
-
- - - setFilters((current) => ({ - ...current, - scheduleDate: event.target.value || undefined, - })) - } - /> -
-
-
- -
-
-
-

Eligible container bookings

-

- Only paid container bookings not already assigned to a schedule appear here. -

-
- - {eligibleQuery.data?.count ?? 0} bookings - -
- -
- - - - - - - - - - - - - - - - - {eligibleItems.map((booking) => ( - - - - - - - - - - - - - ))} - {!eligibleQuery.isLoading && eligibleItems.length === 0 ? ( - - - - ) : null} - -
SelectBookingCustomerContainerQtyWeightOriginDestinationDepartureStatus
- toggleBooking(booking, event.target.checked)} - /> - {booking.reference}{booking.customer}{booking.containerType}{booking.quantity}{booking.weightTons.toLocaleString()} T{booking.origin}{booking.destination}{formatDate(booking.preferredDepartureDate)}{booking.status}
- No eligible container bookings matched the current filters. -
-
-
-
- -
-
+
+
+
+

Schedule builder

-
-
-

Selected bookings

-

{summary.count}

-
-
-

Total weight

-

{summary.totalWeightTons.toLocaleString()} T

-
-
-

Route

-

{summary.route}

-
-
-

Schedule date

-

{summary.scheduleDate}

-
-
-

Estimated wagon type

-

NW5

-
-
-

Estimated wagons / length

-

- {summary.wagonsNeeded} wagons / {summary.totalLengthMeters} m -

-
-
+
-
- - -
- - -
- - -
- - {preview ? ( -
-
-

Preview result

- - {preview.valid ? 'Valid' : 'Invalid'} - -
- -
-
-

Wagons

-

{preview.summary.wagonsNeeded}

-
-
-

Weight

-

{preview.summary.totalWeightTons} T

-
-
-

Length

-

{preview.summary.totalLengthMeters} m

-
-
- - {preview.violations.length > 0 ? ( -
-
    - {preview.violations.map((violation) => ( -
  • {violation}
  • - ))} -
-
- ) : null} -
- ) : null} -
- -
-
-
-

Created schedules

-

Open a schedule to inspect wagons and allocations.

-
- - {filteredSchedules.length} schedules - -
- -
- setScheduleSearch(event.target.value)} - /> - - + - All statuses - DRAFT - SCHEDULED - DISPATCHED - ARRIVED - CANCELLED + {activeRoutes.map((route) => ( + + {route.name} + + ))}
-
- - - - - - - - - - - - - - - - - {filteredSchedules.map((schedule) => ( - - - - - - - - - - - - - ))} - {!schedulesQuery.isLoading && filteredSchedules.length === 0 ? ( - - - - ) : null} - -
ScheduleDepartureRouteLocomotiveBookingsWagonsWeightLengthStatusActions
{schedule.id}{formatDate(schedule.scheduleDate)} - {schedule.origin} to {schedule.destination} - {schedule.locomotive?.code ?? '-'}{schedule.bookingsCount}{schedule.wagonCount}{schedule.totalWeightTons} T{schedule.totalLengthMeters} m{schedule.status} -
- - {schedule.status !== 'CANCELLED' ? ( - - ) : null} -
-
- No train schedules matched the current filters. -
+
+ + setScheduleDate(event.target.value)} + />
-
-
+ +
+ + +
+
+ +
+
+

Origin

+

+ {selectedRoute?.originYard?.label ?? selectedRoute?.originYard?.code ?? '-'} +

+
+
+

Destination

+

+ {selectedRoute?.destinationYard?.label ?? selectedRoute?.destinationYard?.code ?? '-'} +

+
+
+

Locomotive capacity

+

+ {selectedLocomotive + ? `${selectedLocomotive.maxPullWeightTons}T / ${selectedLocomotive.maxTrainLengthMeters}m` + : '-'} +

+
+
+

Next step

+

Assign bookings, then allocate wagons

+
+
+ +
+ +
+ + +
+
+
+

Created schedules

+

+ Open a schedule to inspect the reserved locomotive and prepare for later booking and wagon work. +

+
+ + {filteredSchedules.length} schedules + +
+ +
+ setScheduleSearch(event.target.value)} + /> + +
+ +
+ + + + + + + + + + + + + + + + + {filteredSchedules.map((schedule) => ( + + + + + + + + + + + + + ))} + {!schedulesQuery.isLoading && filteredSchedules.length === 0 ? ( + + + + ) : null} + +
ScheduleDepartureRouteLocomotiveBookingsWagonsWeightLengthStatusActions
{schedule.id}{formatDate(schedule.scheduleDate)} + {schedule.routeName ?? `${schedule.origin ?? '-'} to ${schedule.destination ?? '-'}`} + {schedule.locomotive?.code ?? '-'}{schedule.bookingsCount}{schedule.wagonCount}{schedule.totalWeightTons} T{schedule.totalLengthMeters} m{schedule.status} +
+ + {schedule.status !== 'CANCELLED' ? ( + + ) : null} +
+
+ No train schedules matched the current filters. +
+
+
@@ -634,13 +375,13 @@ const TrainsPage = () => { Train schedule detail - Inspect the selected schedule, locomotive, wagons, and booking allocations. + Inspect the selected schedule. Booking assignment and wagon allocation happen after schedule creation. {detail ? (
-
+

Schedule

{detail.id}

@@ -651,6 +392,10 @@ const TrainsPage = () => {

Route

+

{detail.route?.name ?? '-'}

+
+
+

Origin / destination

{detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '} {detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'} @@ -666,73 +411,63 @@ const TrainsPage = () => {

Locomotive

{detail.trainSet?.locomotive - ? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity)` + ? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity / ${detail.trainSet.locomotive.maxTrainLengthMeters ?? 0}m)` : 'No locomotive attached'}

Wagons and allocations

-
- {(detail.trainSet?.wagons ?? []).map((wagon) => ( -
-
-
-

- Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'} -

-

- {wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m -

+ {(detail.trainSet?.wagons?.length ?? 0) === 0 ? ( +

No wagons allocated yet.

+ ) : ( +
+ {(detail.trainSet?.wagons ?? []).map((wagon) => ( +
+
+
+

+ Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'} +

+

+ {wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m +

+
-
- - - - - - - - - {wagon.allocations.map((allocation) => ( - - - - - ))} - -
BookingAllocated weight
{allocation.bookingReference ?? allocation.bookingId}{allocation.allocatedWeightTons} T
-
-
- ))} -
+ ))} +
+ )}

Bookings in schedule

-
- - - - - - - - - - - {detail.bookings.map((booking) => ( - - - - - + {detail.bookings.length === 0 ? ( +

No bookings assigned yet.

+ ) : ( +
+
ReferenceCustomerWeightStatus
{booking.reference ?? booking.id}{booking.customer ?? '-'}{booking.weightTons} T{booking.status ?? '-'}
+ + + + + + - ))} - -
ReferenceCustomerWeightStatus
-
+ + + {detail.bookings.map((booking) => ( + + {booking.reference ?? booking.id} + {booking.customer ?? '-'} + {booking.weightTons} T + {booking.status ?? '-'} + + ))} + + +
+ )}
) : ( @@ -743,43 +478,5 @@ const TrainsPage = () => {
); }; + export default TrainsPage; - -// export default function TrainsPage() { -// const { data: trains, isLoading } = useTrains(); -// const deleteTrain = useDeleteTrain(); -// const [open, setOpen] = useState(false); - -// if (isLoading) return
Loading trains...
; - -// return ( -// -// -// Trains -// -// -// Create Train setOpen(false)} /> -// -// -// -// -// NumberNameStatusCapacityActions -// -// {trains?.map(train => ( -// -// {train.trainNumber || train.code} -// {train.trainName || '-'} -// {train.status} -// {train.capacityTons} t -// -// -// -// -// -// ))} -// -//
-//
-//
-// ); -// } diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 44dae4876..6595cef1e 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -64,6 +64,7 @@ export interface LocomotiveRecord { export interface TrainScheduleListItem { id: string; scheduleDate: string; + routeName?: string | null; origin: string | null; destination: string | null; locomotive: @@ -83,6 +84,10 @@ export interface TrainScheduleListItem { export interface TrainScheduleDetail { id: string; status: string; + route?: { + id: string; + name: string; + } | null; scheduledDepartureDate: string; scheduledArrivalDate?: string | null; originStation?: { @@ -150,6 +155,8 @@ export interface TrainSchedulePreviewPayload { destinationStationId: string; } -export interface CreateTrainSchedulePayload extends TrainSchedulePreviewPayload { +export interface CreateTrainSchedulePayload { + routeId: string; + scheduleDate: string; locomotiveId: string; }