feat(booking): Implement train schedule selection and update booking data structures

This commit is contained in:
ghost2023
2026-06-13 11:26:57 +03:00
parent 407cf3ed1f
commit 9aecbb6508
11 changed files with 798 additions and 543 deletions

View File

@@ -8,87 +8,98 @@ import {
Patch,
Post,
Query,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
} from "@nestjs/common";
import { CurrentUser } from "@edr/api-common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { AssignUnassignedBookingDto } from './dto/assign-unassigned-booking.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.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 { AvailableLocomotivesQueryDto } from './dto/available-locomotives-query.dto';
import { BookableSchedulesQueryDto } from './dto/bookable-schedules-query.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { TrainSchedulingService } from './train-scheduling.service';
import { BookingBatchService } from './booking-batch.service';
import {
TrainSchedulingManage,
TrainSchedulingView,
} from "../../common/booking-guards";
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
import { PinWagonsDto } from "./dto/pin-wagons.dto";
import { UpdateContainerItemDto } from "./dto/update-container-item.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 { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
@ApiTags('train-scheduling')
@ApiTags("train-scheduling")
@ApiBearerAuth()
@Controller('train-scheduling')
@Controller("train-scheduling")
export class TrainSchedulingController {
constructor(
private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService,
) {}
) { }
@Get('global-rules')
@Get("global-rules")
@TrainSchedulingView()
@ApiOperation({ summary: 'Get global train scheduling rules (singleton)' })
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
getGlobalRules() {
return this.trainSchedulingService.getTrainSchedulingGlobalRules();
}
@Patch('global-rules')
@Patch("global-rules")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update global train scheduling rules (singleton)' })
@ApiOperation({ summary: "Update global train scheduling rules (singleton)" })
updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) {
return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto);
}
@Get('eligible-bookings')
@Get("eligible-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible bookings (container and/or bulk)' })
@ApiOperation({ summary: "List eligible bookings (container and/or bulk)" })
getEligibleBookings(@Query() query: GetEligibleBookingsDto) {
return this.trainSchedulingService.getEligibleBookings(query);
}
@Get('batch-board')
@Get("batch-board")
@TrainSchedulingView()
@ApiOperation({ summary: 'Batch monitoring board: schedules with bookings grouped by state' })
@ApiOperation({
summary: "Batch monitoring board: schedules with bookings grouped by state",
})
getBatchBoard() {
return this.bookingBatchService.getBatchBoard();
}
@Get('batch-board/:scheduleId')
@Get("batch-board/:scheduleId")
@TrainSchedulingView()
@ApiOperation({ summary: 'Batch board detail for one schedule with EAT 3h windows' })
getBatchBoardDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
@ApiOperation({
summary: "Batch board detail for one schedule with EAT 3h windows",
})
getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) {
return this.bookingBatchService.getBatchBoardDetail(scheduleId);
}
@Get('available-locomotives')
@Get("available-locomotives")
@TrainSchedulingView()
@ApiOperation({
summary: 'List AVAILABLE locomotives at the route origin yard',
summary: "List AVAILABLE locomotives at the route origin yard",
})
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId);
return this.trainSchedulingService.getAvailableLocomotivesForRoute(
query.routeId,
);
}
@Get('bookable-schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'OPEN same-route schedules a new booking can target' })
@Get("bookable-schedules")
// @TrainSchedulingView()
@ApiOperation({
summary: "OPEN same-route schedules a new booking can target",
})
getBookableSchedules(@Query() query: BookableSchedulesQueryDto) {
return this.trainSchedulingService.getBookableSchedules(
query.originYardId,
@@ -96,282 +107,323 @@ export class TrainSchedulingController {
);
}
@Get('container/eligible-bookings')
@Get("container/eligible-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible container bookings' })
getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
@ApiOperation({ summary: "List eligible container bookings" })
getEligibleContainerBookings(
@Query() query: GetEligibleContainerBookingsDto,
) {
return this.trainSchedulingService.getEligibleContainerBookings(query);
}
@Get('bulk/eligible-bookings')
@Get("bulk/eligible-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible bulk bookings' })
@ApiOperation({ summary: "List eligible bulk bookings" })
getEligibleBulkBookings(@Query() query: GetEligibleBulkBookingsDto) {
return this.trainSchedulingService.getEligibleBulkBookings(query);
}
@Post('preview')
@Post("preview")
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a mixed-capable train schedule' })
@ApiOperation({ summary: "Preview a mixed-capable train schedule" })
previewTrainSchedule(@Body() dto: PreviewTrainScheduleDto) {
return this.trainSchedulingService.previewTrainSchedule(dto);
}
@Post('container/preview')
@Post("container/preview")
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a container train schedule' })
@ApiOperation({ summary: "Preview a container train schedule" })
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
return this.trainSchedulingService.previewContainerTrainSchedule(dto);
}
@Post('bulk/preview')
@Post("bulk/preview")
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a bulk train schedule' })
@ApiOperation({ summary: "Preview a bulk train schedule" })
previewBulkTrainSchedule(@Body() dto: PreviewBulkTrainScheduleDto) {
return this.trainSchedulingService.previewBulkTrainSchedule(dto);
}
@Post('container/schedules')
@Post("container/schedules")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a container train schedule' })
@ApiOperation({ summary: "Create a container train schedule" })
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Post('bulk/schedules')
@Post("bulk/schedules")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a bulk train schedule' })
@ApiOperation({ summary: "Create a bulk train schedule" })
createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Post('schedules/:id/assign-bookings')
@Post("schedules/:id/assign-bookings")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Assign bookings to a train schedule (mixed-capable)' })
@ApiOperation({
summary: "Assign bookings to a train schedule (mixed-capable)",
})
assignBookings(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AssignBookingsDto,
) {
return this.trainSchedulingService.assignBookingsToSchedule(id, dto);
}
@Post('container/schedules/:id/assign-bookings')
@Post("container/schedules/:id/assign-bookings")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Assign container bookings to a train schedule' })
@ApiOperation({ summary: "Assign container bookings to a train schedule" })
assignContainerBookings(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AssignBookingsDto,
) {
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'CONTAINER');
return this.trainSchedulingService.assignBookingsToSchedule(
id,
dto,
"CONTAINER",
);
}
@Post('bulk/schedules/:id/assign-bookings')
@Post("bulk/schedules/:id/assign-bookings")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Assign bulk bookings to a train schedule' })
@ApiOperation({ summary: "Assign bulk bookings to a train schedule" })
assignBulkBookings(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AssignBookingsDto,
) {
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'BULK');
return this.trainSchedulingService.assignBookingsToSchedule(
id,
dto,
"BULK",
);
}
@Delete('schedules/:id/bookings/:bookingId')
@Delete("schedules/:id/bookings/:bookingId")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Unassign a booking from a train schedule' })
@ApiOperation({ summary: "Unassign a booking from a train schedule" })
unassignBooking(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainSchedulingService.unassignBooking(id, bookingId, resolveAuthUserId(user));
return this.trainSchedulingService.unassignBooking(
id,
bookingId,
resolveAuthUserId(user),
);
}
@Delete('schedules/:id/wagons/:trainSetWagonId')
@Delete("schedules/:id/wagons/:trainSetWagonId")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Remove an empty wagon slot from a train' })
@ApiOperation({ summary: "Remove an empty wagon slot from a train" })
removeWagonSlot(
@Param('id', ParseUUIDPipe) id: string,
@Param('trainSetWagonId', ParseUUIDPipe) trainSetWagonId: string,
@Param("id", ParseUUIDPipe) id: string,
@Param("trainSetWagonId", ParseUUIDPipe) trainSetWagonId: string,
) {
return this.trainSchedulingService.removeTrainSetWagonSlot(id, trainSetWagonId);
return this.trainSchedulingService.removeTrainSetWagonSlot(
id,
trainSetWagonId,
);
}
@Patch('schedules/:id/container-items/:itemId')
@Patch("schedules/:id/container-items/:itemId")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a container number on a wagon slot' })
@ApiOperation({ summary: "Update a container number on a wagon slot" })
updateContainerItem(
@Param('id', ParseUUIDPipe) id: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
@Param("id", ParseUUIDPipe) id: string,
@Param("itemId", ParseUUIDPipe) itemId: string,
@Body() dto: UpdateContainerItemDto,
) {
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
}
@Get('schedules/:id/unassigned-bookings')
@Get("schedules/:id/unassigned-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: 'Get unassigned bookings for a schedule' })
getUnassignedBookings(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Get unassigned bookings for a schedule" })
getUnassignedBookings(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getUnassignedBookings(id);
}
@Post('schedules/:id/assign-unassigned-booking')
@Post("schedules/:id/assign-unassigned-booking")
@TrainSchedulingManage()
@ApiOperation({
summary: 'Assign one linked unallocated booking to wagons (preserves existing assignments)',
summary:
"Assign one linked unallocated booking to wagons (preserves existing assignments)",
})
assignUnassignedBooking(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AssignUnassignedBookingDto,
) {
return this.trainSchedulingService.assignUnassignedBookingToWagons(id, dto.bookingId);
return this.trainSchedulingService.assignUnassignedBookingToWagons(
id,
dto.bookingId,
);
}
@Get('schedules/:id/composition-removals')
@Get("schedules/:id/composition-removals")
@TrainSchedulingView()
@ApiOperation({ summary: 'Get removal log for a schedule' })
getCompositionRemovals(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Get removal log for a schedule" })
getCompositionRemovals(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getCompositionRemovals(id);
}
@Post('schedules/:id/pin-wagons')
@Post("schedules/:id/pin-wagons")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Pin physical wagons to train set slots' })
pinWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
@ApiOperation({ summary: "Pin physical wagons to train set slots" })
pinWagons(@Param("id", ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
return this.trainSchedulingService.pinWagons(id, dto);
}
@Post('schedules/:id/finalize')
@Post("schedules/:id/finalize")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Finalize a draft train schedule' })
finalizeSchedule(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Finalize a draft train schedule" })
finalizeSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.finalizeSchedule(id);
}
@Post('schedules/:id/dispatch')
@Post("schedules/:id/dispatch")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Dispatch a scheduled train' })
dispatchSchedule(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Dispatch a scheduled train" })
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.dispatchSchedule(id);
}
// ---- batch / booking-window staff actions ----
@Post('schedules/:id/run-batch')
@Post("schedules/:id/run-batch")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Manually run the batch fill for a schedule' })
async runBatch(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Manually run the batch fill for a schedule" })
async runBatch(@Param("id", ParseUUIDPipe) id: string) {
await this.bookingBatchService.fillSchedule(id);
return this.bookingBatchService.getBatchBoardDetail(id);
}
@Post('schedules/:id/run-allocation')
@Post("schedules/:id/run-allocation")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Run wagon-level allocation for all eligible linked bookings' })
async runAllocation(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({
summary: "Run wagon-level allocation for all eligible linked bookings",
})
async runAllocation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingBatchService.runWagonAllocation(id);
}
@Patch('schedules/:id/booking-window')
@Patch("schedules/:id/booking-window")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Open or close a schedule booking window' })
@ApiOperation({ summary: "Open or close a schedule booking window" })
async setBookingWindow(
@Param('id', ParseUUIDPipe) id: string,
@Body('status') status: 'OPEN' | 'CLOSED',
@Param("id", ParseUUIDPipe) id: string,
@Body("status") status: "OPEN" | "CLOSED",
) {
await this.trainSchedulingService.setBookingWindow(id, status === 'CLOSED' ? 'CLOSED' : 'OPEN');
await this.trainSchedulingService.setBookingWindow(
id,
status === "CLOSED" ? "CLOSED" : "OPEN",
);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post('bookings/:bookingId/mark-paid')
@Post("bookings/:bookingId/mark-paid")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Staff: mark a reserved booking paid and allocate it now' })
async markBookingPaid(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
@ApiOperation({
summary: "Staff: mark a reserved booking paid and allocate it now",
})
async markBookingPaid(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
await this.bookingBatchService.markPaid(bookingId);
return { ok: true };
}
@Post('bookings/:bookingId/expire')
@Post("bookings/:bookingId/expire")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Staff: expire a reservation and free its capacity' })
async expireBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
@ApiOperation({
summary: "Staff: expire a reservation and free its capacity",
})
async expireBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
await this.bookingBatchService.expireReservation(bookingId);
return { ok: true };
}
@Post('bookings/:bookingId/move-schedule')
@Post("bookings/:bookingId/move-schedule")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Re-point a booking to another OPEN same-route schedule' })
@ApiOperation({
summary: "Re-point a booking to another OPEN same-route schedule",
})
async moveBookingSchedule(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body('trainScheduleId', ParseUUIDPipe) trainScheduleId: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
@Body("trainScheduleId", ParseUUIDPipe) trainScheduleId: string,
) {
await this.bookingBatchService.moveToSchedule(bookingId, trainScheduleId);
return { ok: true };
}
@Get('schedules/:id/checkpoints')
@Get("schedules/:id/checkpoints")
@TrainSchedulingView()
@ApiOperation({ summary: 'Get the tracking corridor + logged checkpoints for a train' })
getScheduleCheckpoints(@Param('id', ParseUUIDPipe) id: string) {
@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')
@Post("schedules/:id/checkpoints")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Log the train passing a station (final station triggers arrival)' })
@ApiOperation({
summary: "Log the train passing a station (final station triggers arrival)",
})
recordCheckpoint(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RecordCheckpointDto,
) {
return this.trainSchedulingService.recordCheckpoint(id, dto);
}
@Post('schedules/:id/arrive')
@Post("schedules/:id/arrive")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Mark a dispatched train arrived (move assets to destination yard, free assets)' })
arriveSchedule(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({
summary:
"Mark a dispatched train arrived (move assets to destination yard, free assets)",
})
arriveSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.arriveSchedule(id);
}
@Get('container/schedules')
@Get("container/schedules")
@TrainSchedulingView()
@ApiOperation({ summary: 'List container train schedules' })
@ApiOperation({ summary: "List container train schedules" })
getContainerTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
}
@Get('bulk/schedules')
@Get("bulk/schedules")
@TrainSchedulingView()
@ApiOperation({ summary: 'List bulk train schedules' })
@ApiOperation({ summary: "List bulk train schedules" })
getBulkTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
}
@Get('container/schedules/:id')
@Get("container/schedules/:id")
@TrainSchedulingView()
@ApiOperation({ summary: 'Get container train schedule detail' })
getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Get container train schedule detail" })
getContainerTrainScheduleById(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Get('bulk/schedules/:id')
@Get("bulk/schedules/:id")
@TrainSchedulingView()
@ApiOperation({ summary: 'Get bulk train schedule detail' })
getBulkTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Get bulk train schedule detail" })
getBulkTrainScheduleById(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post('container/schedules/:id/cancel')
@Post("container/schedules/:id/cancel")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Cancel container train schedule' })
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Cancel container train schedule" })
cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
@Post('bulk/schedules/:id/cancel')
@Post("bulk/schedules/:id/cancel")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Cancel bulk train schedule' })
cancelBulkTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Cancel bulk train schedule" })
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
}

View File

@@ -38,6 +38,7 @@
"devDependencies": {
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@hookform/devtools": "^4.4.0",
"@tailwindcss/vite": "^4.3.0",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",

View File

@@ -7,6 +7,7 @@ import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { DevTool } from "@hookform/devtools";
import {
BookingFormInputValues,
STEPS,
@@ -22,6 +23,7 @@ import {
Step2ServiceType,
Step4Route,
Step5CargoDetails,
StepScheduling,
StepDocuments,
Step8Review,
} from "./new-booking-form/steps";
@@ -70,7 +72,11 @@ export default function NewBookingPage() {
const destinationYard = form.watch("destinationYard");
const direction = useMemo(
() => getRouteDirection(referenceData?.yard.find((y) => y.id === originYard), referenceData?.yard.find((y) => y.id === destinationYard)),
() =>
getRouteDirection(
referenceData?.yard.find((y) => y.id === originYard),
referenceData?.yard.find((y) => y.id === destinationYard),
),
[originYard, destinationYard],
);
@@ -100,15 +106,11 @@ export default function NewBookingPage() {
: Number(data.cargoWeight || 0);
// ── Reference data lookups ──────────────────────────────────────────
const yards = referenceData?.yard ?? [];
const services = referenceData?.service ?? [];
const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? [];
const findYardId = (name: string): string =>
yards.find((y) => y.name === name)?.id ?? "";
const findServiceTypeId = (): string => {
const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING";
return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? "";
@@ -117,13 +119,6 @@ export default function NewBookingPage() {
const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id;
const findContainerCargoTypeId = (): string => {
const group = cargoTree.find(
(g) => g.code === "CONTAINER" || /container/i.test(g.name),
);
return group?.id ?? "";
};
const findContainerTypeId = (name: string): string => {
for (const group of containerGroups) {
const ct = group.types.find((t) => t.name === name);
@@ -139,21 +134,18 @@ export default function NewBookingPage() {
?.children?.find((c) => c.name === data.bulkCommoditytype)
: undefined;
const cargoTypeId =
data.cargoType === "container"
? findContainerCargoTypeId()
: (selectedChild?.id ?? "");
const cargoTypeId = selectedChild?.id;
const cargoFreeText =
data.cargoType === "container"
? undefined
: selectedChild?.show_free_text_box
? data.bulkCommoditytype
? data.cargoFreeText
: undefined;
// ── Build API payload ───────────────────────────────────────────────
const apiPayload: CreateBookingPayload = {
scheduledDate: new Date().toISOString().slice(0, 10),
scheduledDate: new Date().toISOString(),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: findServiceTypeId(),
@@ -161,13 +153,13 @@ export default function NewBookingPage() {
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
originYardId: findYardId(data.originYard),
destinationYardId: findYardId(data.destinationYard),
originYardId: data.originYard,
destinationYardId: data.destinationYard,
tradeDirection: direction!,
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
cargoTypeId,
trainScheduleId: data.trainScheduleId,
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
paymentCurrency: "USD",
allowConsolidation: data.consolidationEnabled,
// @ts-ignore
freightType:
@@ -243,67 +235,59 @@ export default function NewBookingPage() {
Back to Bookings
</Button>
</Group>
<form
id="new-booking-form"
className="flex flex-col"
style={{ flex: 1 }}
onSubmit={handleSubmit}
>
{/* Step indicator */}
<Box>
<Box className="mx-auto max-w-5xl" style={{ paddingInline: "16px" }}>
<Box flex={1} p="24px">
<Box mb="lg">
<StepIndicator step={step} />
</Box>
</Box>
{/* Step content */}
<Box flex={1}>
<Box className="mx-auto max-w-5xl" style={{ padding: "32px 24px" }}>
{createMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to save draft
</Text>
<Text size="sm" mt={4} c="red.7">
{createMutation.error instanceof Error
? createMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
)}
{createMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to save draft
</Text>
<Text size="sm" mt={4} c="red.7">
{createMutation.error instanceof Error
? createMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
)}
{step === 1 && <Step1ContractType form={form} />}
{step === 2 && <Step2ServiceType form={form} />}
{step === 3 && (
<Step4Route
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 4 && (
<Step5CargoDetails
form={form}
direction={direction}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 5 && <StepDocuments form={form} />}
{step === 6 && (
<Step8Review
form={form}
setStep={setStep}
direction={direction}
/>
)}
</Box>
{step === 1 && <Step1ContractType form={form} />}
{step === 2 && <Step2ServiceType form={form} />}
{step === 3 && (
<Step4Route
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 4 && (
<Step5CargoDetails
form={form}
direction={direction!}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 5 && (
<StepScheduling form={form} referenceData={referenceData} />
)}
{step === 6 && <StepDocuments form={form} />}
{step === 7 && (
<Step8Review form={form} setStep={setStep} direction={direction!} />
)}
</Box>
{/* Navigation footer */}
@@ -359,6 +343,7 @@ export default function NewBookingPage() {
</Group>
</Box>
</form>
{/* <DevTool control={form.control} /> */}
</Box>
);
}

View File

@@ -101,10 +101,12 @@ export const bookingFormSchema = z
destinationYard: z.string().min(1, "Select a destination yard."),
shippingLine: z.string(),
scheduledDate: z.string().min(1, "Select a shipment date."),
trainScheduleId: z.string().min(1, "Select a shipment date."),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
freightType: z.string(), // parent group
bulkCommoditytype: z.string(),
cargoFreeText: z.string(),
isHazardous: z.boolean(),
isRefrigerated: z.boolean(),
containers: z.array(
@@ -229,8 +231,10 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
destinationYard: "",
shippingLine: "",
scheduledDate: "",
trainScheduleId: "",
cargoWeight: "",
bulkCommoditytype: "",
cargoFreeText: "",
isHazardous: false,
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
@@ -264,7 +268,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"containers",
"consolidationEnabled",
],
5: ["scheduledDate"],
5: ["scheduledDate", "trainScheduleId"],
6: ["documents"],
7: ["notes", "termsAccepted"],
};
@@ -280,50 +284,32 @@ export interface WagonConfig {
type: "20ft" | "40ft";
}
export interface WagonCalcResult {
totalWagons: number;
hasOddUnit: boolean;
sharedWagons: number;
wagonLayout: WagonConfig[];
ft40Wagons: number;
ft20Wagons: number;
}
export function getRouteDirection(
origin: Freight.BookingReferenceYard | null | undefined,
dest: Freight.BookingReferenceYard | null | undefined,
): Freight.ScheduleTradeDirection | null {
): Freight.ScheduleTradeDirection | null {
if (!origin || !dest) return null;
if(origin.country === 'ethiopia' && dest.country === 'ethiopia') {
return 'DOMESTIC';
}
if(origin.country === 'ethiopia' && dest.country === 'djibouti') {
return 'IMPORT';
if (origin.country === "ethiopia" && dest.country === "ethiopia") {
return "DOMESTIC";
}
if(origin.country === 'djibouti' && dest.country === 'ethiopia') {
return 'EXPORT';
if (origin.country === "ethiopia" && dest.country === "djibouti") {
return "IMPORT";
}
if (origin.country === "djibouti" && dest.country === "ethiopia") {
return "EXPORT";
}
return null;
}
export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
const Ft40Wagons = containers
.filter((c) => c.type === "40ft")
.reduce((sum, c) => sum + Number(c.qty), 0);
export function calcWagons(containers: ContainerConfig[]) {
const Ft20Wagons = containers
.filter((c) => c.type === "20ft")
.reduce((sum, c) => sum + Number(c.qty), 0);
const wagonLayout: WagonConfig[] = [];
let hasOddUnit = Ft20Wagons % 2 === 1;
let sharedWagons = Math.floor(Ft20Wagons / 2);
return {
totalWagons: sharedWagons + Ft40Wagons,
hasOddUnit,
sharedWagons,
ft40Wagons: Ft40Wagons,
ft20Wagons: Ft20Wagons,
wagonLayout,
};
}

View File

@@ -5,12 +5,17 @@ import { Controller, type UseFormReturn } from "react-hook-form";
import {
BOOKING_DOCS_SETTING,
BookingFormInputValues,
type BookingDocuments,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
function countAttached(documents: BookingDocuments): number {
return BOOKING_DOCS_SETTING.fields.filter((f) => {
@@ -57,7 +62,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
color: attached === total ? "#0A6F4D" : "#2E5B96",
}}
>
{attached === total ? <CheckCircle2 size={16} /> : `${attached}/${total}`}
{attached === total ? (
<CheckCircle2 size={16} />
) : (
`${attached}/${total}`
)}
</Box>
<Text size="sm" c="dimmed">
{attached === 0

View File

@@ -0,0 +1,389 @@
import {
Box,
Button,
Card,
Grid,
Group,
Stack,
Text,
Title,
useMantineTheme,
Divider,
} from "@mantine/core";
import { UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, BookingFormValues } from "./schema";
import {
ChevronLeft,
ChevronRight,
Info,
Calendar as CalendarIcon,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isToday,
isSameMonth,
addMonths,
} from "date-fns";
interface StepSchedulingProps {
form: UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
referenceData?: Freight.BookingReferenceData;
}
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const theme = useMantineTheme();
const [currentDate, setCurrentDate] = useState(new Date());
const selectedDate = form.watch("scheduledDate");
const originYardId = form.watch("originYard");
const destinationYardId = form.watch("destinationYard");
const cargoType = form.watch("cargoType");
const originName = useMemo(
() =>
referenceData?.yard.find((y) => y.id === originYardId)?.name ??
"Not selected",
[referenceData, originYardId],
);
const destinationName = useMemo(
() =>
referenceData?.yard.find((y) => y.id === destinationYardId)?.name ??
"Not selected",
[referenceData, destinationYardId],
);
const { data: bookableSchedules } = useQuery(
api.bookings.getBookableSchedules.queryOptions({
input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId,
}),
);
const scheduleMap = useMemo(() => {
const map = new Map<string, Freight.BookableScheduleItem>();
if (bookableSchedules) {
for (const s of bookableSchedules) {
if (!map.has(s.scheduleDate)) {
map.set(s.scheduleDate, s);
}
}
}
return map;
}, [bookableSchedules]);
const days = useMemo(() => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const calStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
const schedule = scheduleMap.get(dateString);
return {
day: date.getDate(),
dateString,
isToday: isToday(date),
isSelected: selectedDate === dateString,
isCurrentMonth: isSameMonth(date, currentDate),
isFull: schedule ? schedule.remainingWagons <= 0 : false,
hasSchedule: !!schedule,
remainingWagons: schedule?.remainingWagons ?? 0,
scheduleId: schedule?.id ?? "",
};
});
}, [currentDate, scheduleMap, selectedDate]);
const legendItems = [
{ label: "Available", color: theme.colors.gray[1] },
{ label: "Full", color: theme.colors["edr-red-soft"][0] },
{ label: "No Service", color: "transparent" },
{ label: "Selected", color: theme.colors["edr-green"][5] },
];
return (
<Stack gap="xl">
<Grid>
<Grid.Col span={{ base: 12, md: 7.5 }}>
<Card>
<Box>
<Title order={3} fw={700}>
Shipment Date
</Title>
<Text c="edr-muted" size="sm" mt={4}>
Pick a shipment date for your booking from the available
schedule.
</Text>
</Box>
<Stack gap="md">
<Group justify="space-between" align="center">
<Stack gap={0}>
<Text fw={700} size="xl">
{format(currentDate, "MMMM yyyy")}
</Text>
</Stack>
<Group gap="xs">
<Button
variant="default"
size="sm"
p={6}
radius="md"
onClick={() => setCurrentDate((d) => addMonths(d, -1))}
>
<ChevronLeft size={18} />
</Button>
<Button
variant="default"
size="sm"
p={6}
radius="md"
onClick={() => setCurrentDate((d) => addMonths(d, 1))}
>
<ChevronRight size={18} />
</Button>
</Group>
</Group>
<Box
style={{
display: "grid",
gap: "4px",
gridTemplateColumns: "repeat(7, 1fr)",
}}
>
{["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"].map((d) => (
<Text
key={d}
ta="center"
size="xs"
fw={700}
c="edr-muted"
mb={8}
>
{d}
</Text>
))}
{days.map((d, i) => {
const canSelect =
d.isCurrentMonth && d.hasSchedule && !d.isFull;
return (
<Button
key={i}
variant="unstyled"
disabled={!canSelect}
onClick={() => {
form.setValue("scheduledDate", d.dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", d.scheduleId, {
shouldValidate: true,
});
}}
style={{
height: "80px",
display: "flex",
flexDirection: "column",
alignItems: "start",
justifyContent: "start",
gap: "4px",
borderRadius: theme.radius.md,
cursor: canSelect ? "pointer" : "default",
backgroundColor: d.isSelected
? theme.colors["edr-green"][5]
: d.isFull
? theme.colors["edr-red-soft"][0]
: d.hasSchedule
? theme.colors.gray[0]
: "transparent",
border:
d.isToday && !d.isSelected
? `2px solid ${theme.colors["edr-green"][5]}`
: "none",
opacity: d.isCurrentMonth ? 1 : 0,
}}
>
<Text
size="sm"
fw={700}
c={d.isSelected ? "white" : "edr-text.0"}
>
{d.day}
</Text>
{d.isCurrentMonth && !d.isSelected && (
<>
{d.isFull && (
<Text
size="9px"
fw={800}
c="edr-red.0"
style={{ letterSpacing: "0.05em" }}
>
FULL
</Text>
)}
{d.hasSchedule && !d.isFull && (
<Text
size="9px"
fw={800}
c="edr-green.6"
style={{ letterSpacing: "0.05em" }}
>
{d.remainingWagons} WGN
</Text>
)}
</>
)}
</Button>
);
})}
</Box>
<Group gap="xl" mt="xs">
{legendItems.map((item) => (
<Group key={item.label} gap={8}>
<Box
style={{
width: 14,
height: 14,
borderRadius: 4,
backgroundColor: item.color,
border:
item.label === "No Service"
? `2px dashed ${theme.colors.gray[3]}`
: "none",
}}
/>
<Text size="xs" c="edr-muted" fw={600}>
{item.label}
</Text>
</Group>
))}
</Group>
</Stack>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4.5 }}>
<Card>
<Stack gap="xl">
<Title order={4} fw={800} style={{ letterSpacing: "-0.02em" }}>
Booking Summary
</Title>
<Stack gap="md">
<SummaryRow label="Origin Yard" value={originName} />
<SummaryRow label="Destination Yard" value={destinationName} />
<SummaryRow
label="Cargo Type"
value={
cargoType
? cargoType === "container"
? "Container Freight"
: "Bulk Freight"
: "Not selected"
}
/>
<Divider my="sm" color="gray.2" />
<Group justify="space-between" align="center">
<Stack gap={2}>
<Text
size="xs"
c="edr-muted"
fw={600}
style={{ letterSpacing: "0.05em" }}
>
Shipment Date
</Text>
<Text
fw={800}
size="lg"
c={selectedDate ? "edr-green.6" : "edr-muted"}
>
{selectedDate || "Not selected"}
</Text>
</Stack>
<Box
p={8}
style={{
borderRadius: theme.radius.md,
backgroundColor: selectedDate
? theme.colors["edr-green"][0]
: theme.colors.gray[1],
}}
>
<CalendarIcon
size={20}
color={
selectedDate
? theme.colors["edr-green"][6]
: theme.colors["edr-muted"][0]
}
/>
</Box>
</Group>
</Stack>
<Box
p="md"
style={{
borderRadius: theme.radius.md,
border: `1px dashed ${theme.colors.gray[4]}`,
backgroundColor: "white",
}}
>
<Group gap="xs" align="flex-start" wrap="nowrap">
<Info
size={16}
color={theme.colors["edr-muted"][0]}
style={{ flexShrink: 0, marginTop: 2 }}
/>
<Text
size="xs"
c="edr-muted"
fw={500}
style={{ lineHeight: 1.5 }}
>
Final confirmation of your selected date will be provided
after review of your booking details.
</Text>
</Group>
</Box>
</Stack>
</Card>
</Grid.Col>
</Grid>
</Stack>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<Stack gap={4}>
<Text
size="xs"
c="edr-muted"
fw={600}
textTransform="uppercase"
style={{ letterSpacing: "0.05em" }}
>
{label}
</Text>
<Text size="sm" fw={700} c="edr-text.0">
{value}
</Text>
</Stack>
);
}

View File

@@ -1,7 +1,14 @@
import { useMemo } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
import { ActionIcon, Button, Skeleton, Stack, Text, TextInput } from "@mantine/core";
import {
ActionIcon,
Button,
Skeleton,
Stack,
Text,
TextInput,
} from "@mantine/core";
import type { Freight } from "@edr/types";
import {
BookingFormInputValues,
@@ -17,7 +24,11 @@ import {
StepLabel,
} from "./shared";
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step5CargoDetails({
form,
@@ -32,6 +43,7 @@ export function Step5CargoDetails({
}) {
const cargoType = form.watch("cargoType");
const freightType = form.watch("freightType");
const bulkCommoditytype = form.watch("bulkCommoditytype");
const containers = form.watch("containers");
const { fields, append, remove } = useFieldArray({
@@ -39,16 +51,28 @@ export function Step5CargoDetails({
name: "containers",
});
const containerTypeOptions = useMemo(() => {
if (!referenceData?.containers) return [];
return referenceData.containers.flatMap((group) =>
group.types.map((t) => t.name),
const containerTypeOptionsBySize = useMemo(() => {
if (!referenceData?.containers) return new Map<string, string[]>();
return new Map(
referenceData.containers.map((g) => [g.size, g.types.map((t) => t.name)]),
);
}, [referenceData]);
const selectedCommodity = useMemo(() => {
if (!referenceData?.cargo_type || !freightType || !bulkCommoditytype) return null;
const group = referenceData.cargo_type.find(
(g) => g.code.toLowerCase() === freightType,
);
return group?.children?.find(
(c) => c.name === bulkCommoditytype,
) ?? null;
}, [referenceData, freightType, bulkCommoditytype]);
const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.filter((g) => g.code !== "CONTAINER");
return referenceData.cargo_type.filter(
(g) => g.code !== "CONTAINER" && !/container/i.test(g.name),
);
}, [referenceData]);
const commodityOptions = useMemo(() => {
@@ -219,6 +243,22 @@ export function Step5CargoDetails({
)}
/>
)}
{selectedCommodity?.show_free_text_box && (
<Controller
name="cargoFreeText"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
label="Describe cargo *"
placeholder="e.g. Charcoal, Wheat, etc."
error={fieldState.error?.message}
radius="md"
/>
)}
/>
)}
</div>
)}
@@ -253,7 +293,13 @@ export function Step5CargoDetails({
className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-gray-50/50 p-4"
>
<div className="flex items-center justify-between">
<Text size="xs" fw={600} c="dimmed" tt="uppercase" className="tracking-wide">
<Text
size="xs"
fw={600}
c="dimmed"
tt="uppercase"
className="tracking-wide"
>
Container {index + 1}
</Text>
{fields.length > 1 && (
@@ -300,7 +346,9 @@ export function Step5CargoDetails({
<Package className="h-4 w-4 text-emerald-600" />
<p className="font-semibold">{ct.label}</p>
</div>
<p className="text-xs text-gray-500">{ct.limit}</p>
<p className="text-xs text-gray-500">
{ct.limit}
</p>
</OptionCard>
))}
</div>
@@ -324,7 +372,10 @@ export function Step5CargoDetails({
type="button"
onClick={() =>
qtyField.onChange(
Math.max(1, Number(qtyField.value ?? 1) - 1).toString(),
Math.max(
1,
Number(qtyField.value ?? 1) - 1,
).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
@@ -333,7 +384,9 @@ export function Step5CargoDetails({
</button>
<input
value={qtyField.value ?? 1}
onChange={(e) => qtyField.onChange(e.target.value)}
onChange={(e) =>
qtyField.onChange(e.target.value)
}
onBlur={qtyField.onBlur}
type="number"
min={1}
@@ -388,7 +441,11 @@ export function Step5CargoDetails({
error={fieldState.error}
label="Container Type *"
placeholder="Select type..."
data={containerTypeOptions}
data={
containerTypeOptionsBySize.get(
containers[index]?.type ?? "20ft",
) ?? []
}
/>
)}
/>

View File

@@ -1,111 +0,0 @@
import { type UseFormReturn } from "react-hook-form";
import { type BookingFormValues, type WagonCalcResult } from "./schema";
import { AlertBox, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step6WagonAllocation({
form,
wagons,
}: {
form: BookingForm;
wagons: WagonCalcResult | null;
}) {
const containers = form.watch("containers") ?? [];
const totalContainers = containers.reduce(
(sum, c) => sum + Number(c.qty || 0),
0,
);
const containerSummary = containers
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ");
return (
<div className="space-y-6">
<StepHeader
title="Wagon Allocation"
description="System-calculated wagon requirements based on your container profile."
/>
{!wagons ? (
<AlertBox tone="info">
Complete the container configuration in the previous step to see wagon
allocation.
</AlertBox>
) : (
<>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl bg-primary/5 p-4 text-center">
<p className="text-3xl font-bold text-primary">
{wagons.totalWagons}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Wagons Required
</p>
</div>
<div className="rounded-xl bg-muted p-4 text-center">
<p className="text-3xl font-bold">{totalContainers}</p>
<p className="mt-1 text-xs text-muted-foreground">
{containerSummary || "Containers"}
</p>
</div>
<div className="rounded-xl bg-muted p-4 text-center">
<p className="text-3xl font-bold">{wagons.sharedWagons}</p>
<p className="mt-1 text-xs text-muted-foreground">Shared Slots</p>
</div>
</div>
<div>
<StepLabel>Wagon Layout</StepLabel>
<div className="mt-2 flex flex-wrap gap-2">
{new Array(wagons.ft40Wagons).fill(0).map((_, index) => (
<div
key={index}
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold
border-primary/30 bg-primary/5 text-primary `}
>
1 × 40ft
</div>
))}
{new Array(wagons.sharedWagons).fill(0).map((_, index) => (
<div
key={index}
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold
border-amber-300 bg-amber-50 text-amber-700
`}
>
2 × 20ft
</div>
))}
{wagons.hasOddUnit && (
<div
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold border-destructive! bg-destructive/10 text-destructive `}
>
1 × 20ft
</div>
)}
</div>
</div>
{wagons.hasOddUnit && (
<>
<AlertBox tone="warning">
<div className="flex items-start gap-2">
<div>
<p className="font-semibold">Unpaired 20ft Container</p>
<p className="mt-1 text-xs">
One 20ft container occupies only half a wagon. The wagon
will depart once a co-loader is found to fill the
remaining slot, which <strong>may delay departure</strong>{" "}
beyond the standard lead time.
</p>
</div>
</div>
</AlertBox>
</>
)}
</>
)}
</div>
);
}

View File

@@ -2,5 +2,6 @@ export { Step1ContractType } from "./step1-contract-type";
export { Step2ServiceType } from "./step2-service-type";
export { Step4Route } from "./step4-route";
export { Step5CargoDetails } from "./step5-cargo-details";
export { StepScheduling } from "./step-scheduling";
export { StepDocuments } from "./step-documents";
export { Step8Review } from "./step8-review";