mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(freight): changed the train schedule
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface {
|
||||
name = 'AddRouteToTrainSchedules1750300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>) =>
|
||||
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<string>) =>
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TrainScheduleFilters>({});
|
||||
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
|
||||
const [preview, setPreview] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
const [routeId, setRouteId] = useState('');
|
||||
const [scheduleDate, setScheduleDate] = useState('');
|
||||
const [selectedLocomotiveId, setSelectedLocomotiveId] = useState('');
|
||||
const [detailId, setDetailId] = useState<string | null>(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 (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[{ label: 'Operations' }, { label: 'Train scheduling' }]} />
|
||||
<Breadcrumbs items={[{ label: 'Operations' }, { label: 'Train schedules' }]} />
|
||||
|
||||
<section className="overflow-hidden rounded-3xl border border-border bg-card shadow-sm">
|
||||
<div className="flex flex-col gap-5 border-b border-border px-6 py-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
@@ -266,9 +168,9 @@ const TrainsPage = () => {
|
||||
<TrainTrack className="size-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Train Scheduling</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Train Schedules</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 p-6 xl:grid-cols-[1.8fr,1fr]">
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-2xl border border-border bg-background/60 p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Calendar className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Filters
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Origin station</label>
|
||||
<Select
|
||||
value={filters.originStationId ?? ''}
|
||||
onValueChange={(value) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
originStationId: value === '__all__' ? undefined : value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All origins" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All origins</SelectItem>
|
||||
{(stationsQuery.data ?? []).map((station) => (
|
||||
<SelectItem key={station.id} value={station.id}>
|
||||
{station.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Destination station</label>
|
||||
<Select
|
||||
value={filters.destinationStationId ?? ''}
|
||||
onValueChange={(value) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
destinationStationId: value === '__all__' ? undefined : value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All destinations" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All destinations</SelectItem>
|
||||
{(stationsQuery.data ?? []).map((station) => (
|
||||
<SelectItem key={station.id} value={station.id}>
|
||||
{station.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Schedule date</label>
|
||||
<input
|
||||
className={inputClassName}
|
||||
type="date"
|
||||
value={filters.scheduleDate ?? ''}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
scheduleDate: event.target.value || undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border bg-background/60 p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Eligible container bookings</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Only paid container bookings not already assigned to a schedule appear here.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{eligibleQuery.data?.count ?? 0} bookings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||
<table className="min-w-full divide-y divide-border text-sm">
|
||||
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-3">Select</th>
|
||||
<th className="px-3 py-3">Booking</th>
|
||||
<th className="px-3 py-3">Customer</th>
|
||||
<th className="px-3 py-3">Container</th>
|
||||
<th className="px-3 py-3">Qty</th>
|
||||
<th className="px-3 py-3">Weight</th>
|
||||
<th className="px-3 py-3">Origin</th>
|
||||
<th className="px-3 py-3">Destination</th>
|
||||
<th className="px-3 py-3">Departure</th>
|
||||
<th className="px-3 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-card">
|
||||
{eligibleItems.map((booking) => (
|
||||
<tr key={booking.id} className="hover:bg-muted/20">
|
||||
<td className="px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBookingIds.includes(booking.id)}
|
||||
onChange={(event) => toggleBooking(booking, event.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3 font-medium">{booking.reference}</td>
|
||||
<td className="px-3 py-3">{booking.customer}</td>
|
||||
<td className="px-3 py-3">{booking.containerType}</td>
|
||||
<td className="px-3 py-3">{booking.quantity}</td>
|
||||
<td className="px-3 py-3">{booking.weightTons.toLocaleString()} T</td>
|
||||
<td className="px-3 py-3">{booking.origin}</td>
|
||||
<td className="px-3 py-3">{booking.destination}</td>
|
||||
<td className="px-3 py-3">{formatDate(booking.preferredDepartureDate)}</td>
|
||||
<td className="px-3 py-3">{booking.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
|
||||
No eligible container bookings matched the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-2xl border border-border bg-background/60 p-5">
|
||||
<div className="grid gap-6 p-6 xl:grid-cols-[1.1fr,1.4fr]">
|
||||
<section className="rounded-2xl border border-border bg-background/60 p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Calendar className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">Schedule builder</h2>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Selected bookings</p>
|
||||
<p className="mt-2 text-2xl font-semibold">{summary.count}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Total weight</p>
|
||||
<p className="mt-2 text-2xl font-semibold">{summary.totalWeightTons.toLocaleString()} T</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
|
||||
<p className="mt-2 text-sm font-medium">{summary.route}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule date</p>
|
||||
<p className="mt-2 text-sm font-medium">{summary.scheduleDate}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Estimated wagon type</p>
|
||||
<p className="mt-2 text-sm font-medium">NW5</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Estimated wagons / length</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{summary.wagonsNeeded} wagons / {summary.totalLengthMeters} m
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!selectedBookingIds.length || isBusy}
|
||||
onClick={() => previewMutation.mutate()}
|
||||
>
|
||||
Preview schedule
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Locomotive</label>
|
||||
<Select value={selectedLocomotiveId} onValueChange={setSelectedLocomotiveId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select available locomotive" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(locomotivesQuery.data ?? []).map((locomotive) => (
|
||||
<SelectItem key={locomotive.id} value={locomotive.id}>
|
||||
{locomotive.code} - {locomotive.maxPullWeightTons}T
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!preview?.valid || !selectedLocomotiveId || isBusy}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
Create schedule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<div className="mt-5 space-y-4 rounded-2xl border border-border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">Preview result</h3>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||
preview.valid
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
: 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300'
|
||||
}`}
|
||||
>
|
||||
{preview.valid ? 'Valid' : 'Invalid'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Wagons</p>
|
||||
<p className="mt-1 font-semibold">{preview.summary.wagonsNeeded}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Weight</p>
|
||||
<p className="mt-1 font-semibold">{preview.summary.totalWeightTons} T</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Length</p>
|
||||
<p className="mt-1 font-semibold">{preview.summary.totalLengthMeters} m</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preview.violations.length > 0 ? (
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700 dark:border-rose-950 dark:bg-rose-950/30 dark:text-rose-300">
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{preview.violations.map((violation) => (
|
||||
<li key={violation}>{violation}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border bg-background/60 p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Created schedules</h2>
|
||||
<p className="text-sm text-muted-foreground">Open a schedule to inspect wagons and allocations.</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{filteredSchedules.length} schedules
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-3 md:grid-cols-[1fr,220px]">
|
||||
<input
|
||||
className={inputClassName}
|
||||
placeholder="Search by schedule, route, locomotive, or status"
|
||||
value={scheduleSearch}
|
||||
onChange={(event) => setScheduleSearch(event.target.value)}
|
||||
/>
|
||||
<Select value={scheduleStatusFilter} onValueChange={setScheduleStatusFilter}>
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Route</label>
|
||||
<Select value={routeId} onValueChange={setRouteId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All statuses" />
|
||||
<SelectValue placeholder="Select active route" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All statuses</SelectItem>
|
||||
<SelectItem value="DRAFT">DRAFT</SelectItem>
|
||||
<SelectItem value="SCHEDULED">SCHEDULED</SelectItem>
|
||||
<SelectItem value="DISPATCHED">DISPATCHED</SelectItem>
|
||||
<SelectItem value="ARRIVED">ARRIVED</SelectItem>
|
||||
<SelectItem value="CANCELLED">CANCELLED</SelectItem>
|
||||
{activeRoutes.map((route) => (
|
||||
<SelectItem key={route.id} value={route.id}>
|
||||
{route.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||
<table className="min-w-full divide-y divide-border text-sm">
|
||||
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-3">Schedule</th>
|
||||
<th className="px-3 py-3">Departure</th>
|
||||
<th className="px-3 py-3">Route</th>
|
||||
<th className="px-3 py-3">Locomotive</th>
|
||||
<th className="px-3 py-3">Bookings</th>
|
||||
<th className="px-3 py-3">Wagons</th>
|
||||
<th className="px-3 py-3">Weight</th>
|
||||
<th className="px-3 py-3">Length</th>
|
||||
<th className="px-3 py-3">Status</th>
|
||||
<th className="px-3 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-card">
|
||||
{filteredSchedules.map((schedule) => (
|
||||
<tr key={schedule.id} className="hover:bg-muted/20">
|
||||
<td className="px-3 py-3 font-mono text-xs">{schedule.id}</td>
|
||||
<td className="px-3 py-3">{formatDate(schedule.scheduleDate)}</td>
|
||||
<td className="px-3 py-3">
|
||||
{schedule.origin} to {schedule.destination}
|
||||
</td>
|
||||
<td className="px-3 py-3">{schedule.locomotive?.code ?? '-'}</td>
|
||||
<td className="px-3 py-3">{schedule.bookingsCount}</td>
|
||||
<td className="px-3 py-3">{schedule.wagonCount}</td>
|
||||
<td className="px-3 py-3">{schedule.totalWeightTons} T</td>
|
||||
<td className="px-3 py-3">{schedule.totalLengthMeters} m</td>
|
||||
<td className="px-3 py-3">{schedule.status}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setDetailId(schedule.id)}>
|
||||
View
|
||||
</Button>
|
||||
{schedule.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => cancelMutation.mutate(schedule.id)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!schedulesQuery.isLoading && filteredSchedules.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
|
||||
No train schedules matched the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Departure date</label>
|
||||
<input
|
||||
className={inputClassName}
|
||||
type="date"
|
||||
value={scheduleDate}
|
||||
onChange={(event) => setScheduleDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Locomotive</label>
|
||||
<Select value={selectedLocomotiveId} onValueChange={setSelectedLocomotiveId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select available locomotive" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(locomotivesQuery.data ?? []).map((locomotive) => (
|
||||
<SelectItem key={locomotive.id} value={locomotive.id}>
|
||||
{locomotive.code} - {locomotive.maxPullWeightTons}T / {locomotive.maxTrainLengthMeters}m
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Origin</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{selectedRoute?.originYard?.label ?? selectedRoute?.originYard?.code ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Destination</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{selectedRoute?.destinationYard?.label ?? selectedRoute?.destinationYard?.code ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Locomotive capacity</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{selectedLocomotive
|
||||
? `${selectedLocomotive.maxPullWeightTons}T / ${selectedLocomotive.maxTrainLengthMeters}m`
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Next step</p>
|
||||
<p className="mt-2 text-sm font-medium">Assign bookings, then allocate wagons</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Button className="w-full" disabled={isBusy} onClick={() => createMutation.mutate()}>
|
||||
Create schedule
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border bg-background/60 p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Created schedules</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open a schedule to inspect the reserved locomotive and prepare for later booking and wagon work.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{filteredSchedules.length} schedules
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-3 md:grid-cols-[1fr,220px]">
|
||||
<input
|
||||
className={inputClassName}
|
||||
placeholder="Search by schedule, route, locomotive, or status"
|
||||
value={scheduleSearch}
|
||||
onChange={(event) => setScheduleSearch(event.target.value)}
|
||||
/>
|
||||
<Select value={scheduleStatusFilter} onValueChange={setScheduleStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All statuses</SelectItem>
|
||||
<SelectItem value="DRAFT">DRAFT</SelectItem>
|
||||
<SelectItem value="SCHEDULED">SCHEDULED</SelectItem>
|
||||
<SelectItem value="DISPATCHED">DISPATCHED</SelectItem>
|
||||
<SelectItem value="ARRIVED">ARRIVED</SelectItem>
|
||||
<SelectItem value="CANCELLED">CANCELLED</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||
<table className="min-w-full divide-y divide-border text-sm">
|
||||
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-3">Schedule</th>
|
||||
<th className="px-3 py-3">Departure</th>
|
||||
<th className="px-3 py-3">Route</th>
|
||||
<th className="px-3 py-3">Locomotive</th>
|
||||
<th className="px-3 py-3">Bookings</th>
|
||||
<th className="px-3 py-3">Wagons</th>
|
||||
<th className="px-3 py-3">Weight</th>
|
||||
<th className="px-3 py-3">Length</th>
|
||||
<th className="px-3 py-3">Status</th>
|
||||
<th className="px-3 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-card">
|
||||
{filteredSchedules.map((schedule) => (
|
||||
<tr key={schedule.id} className="hover:bg-muted/20">
|
||||
<td className="px-3 py-3 font-mono text-xs">{schedule.id}</td>
|
||||
<td className="px-3 py-3">{formatDate(schedule.scheduleDate)}</td>
|
||||
<td className="px-3 py-3">
|
||||
{schedule.routeName ?? `${schedule.origin ?? '-'} to ${schedule.destination ?? '-'}`}
|
||||
</td>
|
||||
<td className="px-3 py-3">{schedule.locomotive?.code ?? '-'}</td>
|
||||
<td className="px-3 py-3">{schedule.bookingsCount}</td>
|
||||
<td className="px-3 py-3">{schedule.wagonCount}</td>
|
||||
<td className="px-3 py-3">{schedule.totalWeightTons} T</td>
|
||||
<td className="px-3 py-3">{schedule.totalLengthMeters} m</td>
|
||||
<td className="px-3 py-3">{schedule.status}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setDetailId(schedule.id)}>
|
||||
View
|
||||
</Button>
|
||||
{schedule.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => cancelMutation.mutate(schedule.id)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!schedulesQuery.isLoading && filteredSchedules.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
|
||||
No train schedules matched the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -634,13 +375,13 @@ const TrainsPage = () => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Train schedule detail</DialogTitle>
|
||||
<DialogDescription>
|
||||
Inspect the selected schedule, locomotive, wagons, and booking allocations.
|
||||
Inspect the selected schedule. Booking assignment and wagon allocation happen after schedule creation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{detail ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule</p>
|
||||
<p className="mt-2 break-all font-mono text-xs">{detail.id}</p>
|
||||
@@ -651,6 +392,10 @@ const TrainsPage = () => {
|
||||
</div>
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
|
||||
<p className="mt-2 text-sm font-medium">{detail.route?.name ?? '-'}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Origin / destination</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '}
|
||||
{detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'}
|
||||
@@ -666,73 +411,63 @@ const TrainsPage = () => {
|
||||
<h3 className="text-lg font-semibold">Locomotive</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{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'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border p-4">
|
||||
<h3 className="text-lg font-semibold">Wagons and allocations</h3>
|
||||
<div className="mt-4 space-y-4">
|
||||
{(detail.trainSet?.wagons ?? []).map((wagon) => (
|
||||
<div key={wagon.id} className="rounded-xl border border-border p-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m
|
||||
</p>
|
||||
{(detail.trainSet?.wagons?.length ?? 0) === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">No wagons allocated yet.</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-4">
|
||||
{(detail.trainSet?.wagons ?? []).map((wagon) => (
|
||||
<div key={wagon.id} className="rounded-xl border border-border p-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto rounded-xl border border-border">
|
||||
<table className="min-w-full divide-y divide-border text-sm">
|
||||
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Booking</th>
|
||||
<th className="px-3 py-2">Allocated weight</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-card">
|
||||
{wagon.allocations.map((allocation) => (
|
||||
<tr key={allocation.id}>
|
||||
<td className="px-3 py-2">{allocation.bookingReference ?? allocation.bookingId}</td>
|
||||
<td className="px-3 py-2">{allocation.allocatedWeightTons} T</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border p-4">
|
||||
<h3 className="text-lg font-semibold">Bookings in schedule</h3>
|
||||
<div className="mt-4 overflow-x-auto rounded-xl border border-border">
|
||||
<table className="min-w-full divide-y divide-border text-sm">
|
||||
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Reference</th>
|
||||
<th className="px-3 py-2">Customer</th>
|
||||
<th className="px-3 py-2">Weight</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-card">
|
||||
{detail.bookings.map((booking) => (
|
||||
<tr key={booking.id}>
|
||||
<td className="px-3 py-2">{booking.reference ?? booking.id}</td>
|
||||
<td className="px-3 py-2">{booking.customer ?? '-'}</td>
|
||||
<td className="px-3 py-2">{booking.weightTons} T</td>
|
||||
<td className="px-3 py-2">{booking.status ?? '-'}</td>
|
||||
{detail.bookings.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">No bookings assigned yet.</p>
|
||||
) : (
|
||||
<div className="mt-4 overflow-x-auto rounded-xl border border-border">
|
||||
<table className="min-w-full divide-y divide-border text-sm">
|
||||
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Reference</th>
|
||||
<th className="px-3 py-2">Customer</th>
|
||||
<th className="px-3 py-2">Weight</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-card">
|
||||
{detail.bookings.map((booking) => (
|
||||
<tr key={booking.id}>
|
||||
<td className="px-3 py-2">{booking.reference ?? booking.id}</td>
|
||||
<td className="px-3 py-2">{booking.customer ?? '-'}</td>
|
||||
<td className="px-3 py-2">{booking.weightTons} T</td>
|
||||
<td className="px-3 py-2">{booking.status ?? '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -743,43 +478,5 @@ const TrainsPage = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrainsPage;
|
||||
|
||||
// export default function TrainsPage() {
|
||||
// const { data: trains, isLoading } = useTrains();
|
||||
// const deleteTrain = useDeleteTrain();
|
||||
// const [open, setOpen] = useState(false);
|
||||
|
||||
// if (isLoading) return <div className="p-8">Loading trains...</div>;
|
||||
|
||||
// return (
|
||||
// <Card>
|
||||
// <CardHeader className="flex flex-row items-center justify-between">
|
||||
// <CardTitle>Trains</CardTitle>
|
||||
// <Dialog open={open} onOpenChange={setOpen}>
|
||||
// <DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />New Train</Button></DialogTrigger>
|
||||
// <DialogContent><DialogHeader><DialogTitle>Create Train</DialogTitle></DialogHeader><CreateTrainForm onSuccess={() => setOpen(false)} /></DialogContent>
|
||||
// </Dialog>
|
||||
// </CardHeader>
|
||||
// <CardContent>
|
||||
// <Table>
|
||||
// <TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Name</TableHead><TableHead>Status</TableHead><TableHead>Capacity</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
|
||||
// <TableBody>
|
||||
// {trains?.map(train => (
|
||||
// <TableRow key={train.id}>
|
||||
// <TableCell>{train.trainNumber || train.code}</TableCell>
|
||||
// <TableCell>{train.trainName || '-'}</TableCell>
|
||||
// <TableCell><Badge variant="outline">{train.status}</Badge></TableCell>
|
||||
// <TableCell>{train.capacityTons} t</TableCell>
|
||||
// <TableCell className="flex space-x-2">
|
||||
// <Link to={`/trains/${train.id}`}><Button variant="ghost" size="icon"><Eye className="h-4 w-4" /></Button></Link>
|
||||
// <Button variant="ghost" size="icon" onClick={() => deleteTrain.mutate(train.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||
// </TableCell>
|
||||
// </TableRow>
|
||||
// ))}
|
||||
// </TableBody>
|
||||
// </Table>
|
||||
// </CardContent>
|
||||
// </Card>
|
||||
// );
|
||||
// }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user