diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index dc4f292b7..8a2d52172 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -1,28 +1,28 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { In, Not } from 'typeorm'; +import { Inject, Injectable } from "@nestjs/common"; +import { In, Not } from "typeorm"; -import { CargoType } from '../rule-engine/entities/cargo-type.entity'; -import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../rule-engine/entities/container-type.entity"; import { CARGO_TYPES_REPOSITORY, ICargoTypesRepository, -} from '../rule-engine/interfaces/cargo-types.repository.interface'; +} from "../rule-engine/interfaces/cargo-types.repository.interface"; import { CONTAINER_TYPES_REPOSITORY, IContainerTypesRepository, -} from '../rule-engine/interfaces/container-types.repository.interface'; +} from "../rule-engine/interfaces/container-types.repository.interface"; import { IServiceTypesRepository, SERVICE_TYPES_REPOSITORY, -} from '../rule-engine/interfaces/service-types.repository.interface'; +} from "../rule-engine/interfaces/service-types.repository.interface"; import { IShippingLinesRepository, SHIPPING_LINES_REPOSITORY, -} from '../rule-engine/interfaces/shipping-lines.repository.interface'; +} from "../rule-engine/interfaces/shipping-lines.repository.interface"; import { IYardsRepository, YARDS_REPOSITORY, -} from '../rule-engine/interfaces/yards.repository.interface'; +} from "../rule-engine/interfaces/yards.repository.interface"; import { BookingReferenceCargoTypeChildDto, BookingReferenceCargoTypeGroupDto, @@ -32,9 +32,9 @@ import { BookingReferenceServiceDto, BookingReferenceShippingLineDto, BookingReferenceYardDto, -} from './dto/booking-reference-data.dto'; +} from "./dto/booking-reference-data.dto"; -const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const; +const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const; export function buildCargoTypeTree( rows: CargoType[], @@ -42,13 +42,16 @@ export function buildCargoTypeTree( const active = rows.filter((r) => r.isActive); const parents = active .filter((r) => !r.parentGroupId) - .sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code)); + .sort( + (a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), + ); return parents.map((parent) => { const children = active .filter((r) => r.parentGroupId === parent.id) .sort( - (a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), + (a, b) => + a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), ) .map( (child): BookingReferenceCargoTypeChildDto => ({ @@ -79,14 +82,14 @@ export function groupContainersBySize( for (const ct of active) { const sizeKey = - ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other'; + ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : "other"; const list = bySize.get(sizeKey) ?? []; list.push(ct); bySize.set(sizeKey, list); } const sortSizeKey = (key: string): number => { - if (key === 'other') return Number.MAX_SAFE_INTEGER; + if (key === "other") return Number.MAX_SAFE_INTEGER; const n = parseInt(key, 10); return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n; }; @@ -126,7 +129,7 @@ export class BookingReferenceDataService { private readonly shippingLinesRepository: IShippingLinesRepository, @Inject(CARGO_TYPES_REPOSITORY) private readonly cargoTypesRepository: ICargoTypesRepository, - ) {} + ) { } async getReferenceData(): Promise { const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] = @@ -136,23 +139,23 @@ export class BookingReferenceDataService { isActive: true, code: Not(In([...LEGACY_YARD_CODES])), }, - order: { displayOrder: 'ASC', code: 'ASC' }, + order: { displayOrder: "ASC", code: "ASC" }, }), this.containerTypesRepository.findAll({ where: { isActive: true }, - order: { displayOrder: 'ASC', code: 'ASC' }, + order: { displayOrder: "ASC", code: "ASC" }, }), this.serviceTypesRepository.findAll({ where: { isActive: true }, - order: { displayOrder: 'ASC', code: 'ASC' }, + order: { displayOrder: "ASC", code: "ASC" }, }), this.shippingLinesRepository.findAll({ where: { isActive: true }, - order: { label: 'ASC', code: 'ASC' }, + order: { label: "ASC", code: "ASC" }, }), this.cargoTypesRepository.findAll({ where: { isActive: true }, - order: { displayOrder: 'ASC', code: 'ASC' }, + order: { displayOrder: "ASC", code: "ASC" }, }), ]); @@ -168,9 +171,8 @@ export class BookingReferenceDataService { containers: groupContainersBySize(containerTypes), service: serviceTypes.map( (s): BookingReferenceServiceDto => ({ - id: s.id, name: s.serviceName, - code: s.code, + ...s, }), ), shipping_line: shippingLines.map( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 3c191c713..3ef7693ab 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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); } } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx index 4b801d99f..3d8a0792a 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx @@ -13,6 +13,8 @@ const statusColorMap: Record = { FULLY_EXECUTED: "indigo", PNR_GENERATED: "violet", PAYMENT_VERIFICATION_IN_PROGRESS: "yellow", + SELECTED_FOR_BATCH: "orange", + EXPIRED: "red", PAID: "green", IN_TRANSIT: "cyan", COMPLETED: "indigo", diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCountdownCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCountdownCard.tsx new file mode 100644 index 000000000..2242fb278 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCountdownCard.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from "react"; +import { Group, Stack, Text } from "@mantine/core"; +import { Timer } from "lucide-react"; + +import { SectionCard } from "./SectionCard"; + +export interface BookingPaymentCountdownCardProps { + /** ISO timestamp marking the end of the pay window. */ + paymentDeadline: string; +} + +interface Remaining { + days: number; + hours: number; + minutes: number; + seconds: number; + expired: boolean; +} + +function getRemaining(deadlineMs: number): Remaining { + const diff = deadlineMs - Date.now(); + if (diff <= 0) { + return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; + } + const totalSeconds = Math.floor(diff / 1000); + return { + days: Math.floor(totalSeconds / 86400), + hours: Math.floor((totalSeconds % 86400) / 3600), + minutes: Math.floor((totalSeconds % 3600) / 60), + seconds: totalSeconds % 60, + expired: false, + }; +} + +function Segment({ value, label }: { value: number; label: string }) { + return ( + + + {String(value).padStart(2, "0")} + + + {label} + + + ); +} + +/** Live countdown to the payment deadline. Ticks every second; shows an expired state past the deadline. */ +export function BookingPaymentCountdownCard({ paymentDeadline }: BookingPaymentCountdownCardProps) { + const deadlineMs = new Date(paymentDeadline).getTime(); + const [remaining, setRemaining] = useState(() => getRemaining(deadlineMs)); + + useEffect(() => { + setRemaining(getRemaining(deadlineMs)); + const interval = setInterval(() => { + const next = getRemaining(deadlineMs); + setRemaining(next); + if (next.expired) { + clearInterval(interval); + } + }, 1000); + return () => clearInterval(interval); + }, [deadlineMs]); + + const accent = remaining.expired ? "red" : "orange"; + + return ( + + {remaining.expired ? ( + + Expired + + ) : ( + + + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts index e12b7220b..c53cbccbd 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts @@ -137,6 +137,8 @@ export interface BookingDetailView { priorityScore: number; cargoTotalWeightVgm: number; pnrCode?: string | null; + /** End of the pay window once the booking is SELECTED_FOR_BATCH. */ + paymentDeadline?: string | null; createdAt: string; updatedAt: string; company?: BookingNamedRefView; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index 06f3c1e5d..36d782730 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -9,6 +9,7 @@ export * from "./BookingContainersCard"; export * from "./BookingApprovalCard"; export * from "./BookingReviewNotesCard"; export * from "./BookingPaymentCard"; +export * from "./BookingPaymentCountdownCard"; export * from "./BookingFactsCard"; export * from "./BookingDocumentsCard"; export * from "./BookingRequestHero"; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index a220c1f06..834dcee3e 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -50,6 +50,14 @@ export const BOOKING_STATUS_STYLES: Record = { label: "Payment Verification", color: "bg-amber-50 text-amber-800 border-amber-200", }, + SELECTED_FOR_BATCH: { + label: "Selected for Batch", + color: "bg-orange-50 text-orange-700 border-orange-200", + }, + EXPIRED: { + label: "Expired", + color: "bg-red-50 text-red-700 border-red-200", + }, PAID: { label: "Paid", color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]", @@ -241,6 +249,8 @@ export const BOOKING_LIST_TABS = [ "FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS", + "SELECTED_FOR_BATCH", + "EXPIRED", ], }, { @@ -270,6 +280,8 @@ export const WORKFLOW_STAGES = [ "FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS", + "SELECTED_FOR_BATCH", + "EXPIRED", ], }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx index 39040db19..42cde3d90 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -1,21 +1,21 @@ -import { useParams, useNavigate } from "react-router-dom"; -import { Container, Stack, Grid } from "@mantine/core"; +import { Container, Grid, Stack } from "@mantine/core"; +import { useNavigate, useParams } from "react-router-dom"; -import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { - detailStyles, - type BookingDetailView, - BookingDetailToolbar, - BookingDetailHeader, - BookingLifecycleStepper, - BookingRouteCard, - BookingContainersCard, BookingApprovalCard, - BookingReviewNotesCard, - BookingPaymentCard, - BookingFactsCard, + BookingContainersCard, + BookingDetailToolbar, BookingDocumentsCard, + BookingFactsCard, + BookingLifecycleStepper, + BookingPaymentCard, + BookingPaymentCountdownCard, + BookingReviewNotesCard, + BookingRouteCard, + detailStyles, + type BookingDetailView } from "@/components/bookings/detail"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; const BookingDetailPage = () => { const { id } = useParams<{ id: string }>(); @@ -25,8 +25,9 @@ const BookingDetailPage = () => { const booking: BookingDetailView = { id: id || "a61955b7-af21-4664-84d3-7e4e66293b6f", reference: "BKG-2026-001456", - status: "IN_TRANSIT", + status: "SELECTED_FOR_BATCH", scheduledDate: "2026-06-15", + paymentDeadline: "2026-06-18T17:00:00Z", totalAmount: 15750.5, paymentCurrency: "USD", paymentStatus: "PAID", @@ -138,6 +139,9 @@ const BookingDetailPage = () => { {/* RIGHT — summary sidebar */} + {booking.status === "SELECTED_FOR_BATCH" && booking.paymentDeadline && ( + + )} ; - if (customerQuery.isSuccess && !customerQuery.data) - return ; return ; } @@ -175,7 +173,7 @@ const App = () => { } /> - }> + }> `/api/bookings/${id}/cancel`, CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`, }, + + TRAIN_SCHEDULING: { + BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules", + }, }; diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 637ff04fd..06900270e 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -1,15 +1,14 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "@/services/api"; import type { LoginPayload, LoginResponse, + OtpResponse, SignupPayload, SignupResponse, - OtpResponse, } from "@/types/auth"; import type { Result } from "@/utils/result"; import { extractApiError } from "@/utils/result"; -import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; function setCookie(name: string, value: string, days: number) { const expires = new Date(); @@ -32,6 +31,8 @@ const useAuth = () => { api.auth.getMyInfo.queryOptions({ enabled: hasToken, retry: false, + refetchOnMount: false, + refetchOnReconnect: false, staleTime: 10 * 60 * 1000, refetchOnWindowFocus: false, }), @@ -46,13 +47,6 @@ const useAuth = () => { }), ); - useEffect(() => { - if (authQuery.isError) { - queryClient.clear(); - localStorage.clear(); - } - }, [authQuery.isError, queryClient]); - const isPending = authQuery.isPending && hasToken; const isAuthenticated = hasToken && !!authQuery.data && !authQuery.isError; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx index 5d6e9e808..7b488bc7d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx @@ -1,4 +1,12 @@ -import { Box, Grid, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core"; +import { + Box, + Grid, + Group, + SimpleGrid, + Skeleton, + Stack, + Text, +} from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; import { @@ -14,7 +22,7 @@ import { Zap, type LucideIcon, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { Link, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; @@ -30,7 +38,12 @@ const cv = (token: string) => { return `var(--mantine-color-${name}-${shade ?? "6"})`; }; -const ACTIVE_STATUSES = ["DRAFT", "SUBMITTED", "PENDING_APPROVAL", "IN_TRANSIT"]; +const ACTIVE_STATUSES = [ + "DRAFT", + "SUBMITTED", + "PENDING_APPROVAL", + "IN_TRANSIT", +]; interface StageConfig { stage: number; @@ -43,7 +56,11 @@ interface StageConfig { badgeBg: string; badgeText: string; badgeDot: string; - action: { label: string; kind: "dark" | "amber" | "outline"; icon?: LucideIcon }; + action: { + label: string; + kind: "dark" | "amber" | "outline"; + icon?: LucideIcon; + }; } const STATUS_CONFIG: Record = { @@ -73,19 +90,162 @@ const STATUS_CONFIG: Record = { badgeDot: "edr-blue-dot", action: { label: "View", kind: "outline" }, }, + CHANGES_REQUESTED: { + stage: 1, + icon: FilePen, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Changes requested · please update", + step: "edr-accent", + badgeLabel: "Revise", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Update", kind: "dark" }, + }, PENDING_APPROVAL: { + stage: 2, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Pending internal approval", + step: "edr-blue-dot", + badgeLabel: "Pending", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + APPROVED_PENDING_SIGNATURE: { + stage: 2, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Approved · awaiting signature", + step: "edr-blue-dot", + badgeLabel: "For Signature", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "Review", kind: "outline" }, + }, + APPROVED: { + stage: 2, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Quote approved · ready to sign", + step: "edr-green.5", + badgeLabel: "Approved", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + CONTRACT_READY: { + stage: 2, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Contract ready · awaiting signature", + step: "edr-green.5", + badgeLabel: "Contract Ready", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "Review", kind: "outline" }, + }, + SIGNED_CUSTOMER: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Signed by customer · internal processing", + step: "edr-green.5", + badgeLabel: "Signed", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + FULLY_EXECUTED: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Fully executed · generating PNR", + step: "edr-green.5", + badgeLabel: "Executed", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + PNR_GENERATED: { + stage: 3, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "PNR generated · awaiting payment verification", + step: "edr-blue-dot", + badgeLabel: "PNR Ready", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + PAYMENT_VERIFICATION_IN_PROGRESS: { + stage: 2, + icon: Clock3, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Verifying payment · please wait", + step: "edr-accent", + badgeLabel: "Verifying", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "View", kind: "outline" }, + }, + SELECTED_FOR_BATCH: { stage: 2, icon: Wallet, iconColor: "edr-amber-text", tile: "edr-amber-soft", - hint: "Quote ready · awaiting payment", + hint: "Selected for batch · payment due within 1 hour", step: "edr-accent", - badgeLabel: "Awaiting Payment", + badgeLabel: "Pay Now", badgeBg: "edr-amber-soft", badgeText: "edr-amber-text", badgeDot: "edr-accent", action: { label: "Pay now", kind: "amber", icon: ArrowRight }, }, + EXPIRED: { + stage: 1, + icon: Clock3, + iconColor: "edr-red", + tile: "edr-red-soft", + hint: "Payment window expired · contact support", + step: "edr-red", + badgeLabel: "Expired", + badgeBg: "edr-red-soft", + badgeText: "edr-red", + badgeDot: "edr-red", + action: { label: "Contact", kind: "outline" }, + }, + PAID: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Payment received · awaiting dispatch", + step: "edr-green.5", + badgeLabel: "Paid", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, IN_TRANSIT: { stage: 3, icon: Truck, @@ -100,6 +260,19 @@ const STATUS_CONFIG: Record = { action: { label: "Track", kind: "outline", icon: MapPin }, }, COMPLETED: { + stage: 4, + icon: CheckCircle2, + iconColor: "edr-slate", + tile: "edr-slate-soft2", + hint: "Completed · awaiting delivery", + step: "edr-green.5", + badgeLabel: "Completed", + badgeBg: "edr-slate-soft2", + badgeText: "edr-slate", + badgeDot: "edr-step", + action: { label: "View", kind: "outline" }, + }, + DELIVERED: { stage: 4, icon: CheckCircle2, iconColor: "edr-slate", @@ -130,12 +303,38 @@ const STATUS_CONFIG: Record = { icon: FilePen, iconColor: "edr-red", tile: "edr-red-soft", - hint: "Rejected", + hint: "Rejected · contact support", step: "edr-red", badgeLabel: "Rejected", badgeBg: "edr-red-soft", badgeText: "edr-red", badgeDot: "edr-red", + action: { label: "Contact", kind: "outline" }, + }, + PENDING_CONSOLIDATION: { + stage: 3, + icon: Clock3, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Awaiting consolidation", + step: "edr-blue-dot", + badgeLabel: "Consolidating", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + CONSOLIDATED: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Consolidated · ready for dispatch", + step: "edr-green.5", + badgeLabel: "Consolidated", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, }; @@ -146,7 +345,10 @@ const ACTION_PROPS: Record = { outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" }, }; -const INVOICE_BADGE: Record = { +const INVOICE_BADGE: Record< + InvoiceStatus, + { label: string; bg: string; text: string } +> = { Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" }, Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, @@ -157,108 +359,181 @@ const INVOICE_BADGE: Record getMyShipments(), []); const myInvoices = useMemo(() => getMyInvoices(), []); const navigate = useNavigate(); - const [tab, setTab] = useState("all"); const bookingsQuery = useQuery( - api.bookings.list.queryOptions({ input: { sortBy: "createdAt", sortOrder: "DESC" } }), + api.bookings.list.queryOptions({ + input: { sortBy: "createdAt", sortOrder: "DESC" }, + }), ); const allBookings = bookingsQuery.data?.items ?? []; - const activeBookings = allBookings.filter((b) => ACTIVE_STATUSES.includes(b.status)); + const activeBookings = allBookings.filter((b) => + ACTIVE_STATUSES.includes(b.status), + ); - const visibleBookings = allBookings + const visibleBookings = allBookings; const outstandingInvoices = myInvoices.filter( (inv) => inv.status === "Sent" || inv.status === "Overdue", ); - const totalOutstanding = outstandingInvoices.reduce((sum, inv) => sum + inv.amount, 0); - const deliveredCount = myShipments.filter((s) => s.status === "Delivered").length || 12; + const totalOutstanding = outstandingInvoices.reduce( + (sum, inv) => sum + inv.amount, + 0, + ); + const deliveredCount = + myShipments.filter((s) => s.status === "Delivered").length || 12; const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—"; const companyName = (customer as any)?.companyName ?? displayName; const hour = new Date().getHours(); - const greeting = hour < 12 ? "Good morning," : hour < 18 ? "Good afternoon," : "Good evening,"; + const greeting = + hour < 12 + ? "Good morning," + : hour < 18 + ? "Good afternoon," + : "Good evening,"; const recentInvoices = myInvoices.slice(0, 3); const maxVolume = Math.max(...VOLUME_DATA); return ( {/* ── Hello Row ─────────────────────────────────────────────────────── */} - + - {greeting} + + {greeting} + {companyName} 👋 {/* Book a shipment CTA */} - - + + - - - Book a shipment - - - - - + + + + Book a shipment + + + + + + + + {!customer&& ( + + + + + Setup your Company Profile + + + Complete your company information to unlock all features and start booking shipments. + + + + + Complete Setup + + + + + + + + + + + )} + + {/* ── Stats Strip ───────────────────────────────────────────────────── */} - - - - + + + + {/* ── Mid Row: Shipments + Invoices ─────────────────────────────────── */} - + - My Shipments - From draft to delivery — every booking in one place + + My Shipments + + + From draft to delivery — every booking in one place + - {bookingsQuery.isPending ? ( - {[1, 2, 3, 4].map((i) => )} + + {[1, 2, 3, 4].map((i) => ( + + ))} + ) : visibleBookings.length === 0 ? ( ) : ( {visibleBookings.map((booking, i) => ( - navigate(`/bookings/${booking.id}`)} /> + navigate(`/bookings/${booking.id}`)} + /> ))} )} @@ -269,22 +544,48 @@ export default function MyPortalPage() { - Invoices - - View all - - + + Invoices + + + + + View all + + + + {/* Outstanding card */} - Outstanding balance - {formatCurrency(totalOutstanding || 377500, "ETB")} - - {outstandingInvoices.length || 2} invoices unpaid - + + Outstanding balance + + + {formatCurrency(totalOutstanding || 377500, "ETB")} + + + + {outstandingInvoices.length || 2} invoices unpaid + + - Pay all + + Pay all + @@ -302,26 +603,53 @@ export default function MyPortalPage() { : invoice.status === "Overdue" ? "Overdue 3 days" : `Due ${invoice.dueDate}`; - const DueIcon = invoice.status === "Paid" ? CheckCircle2 : Clock3; - const dueIconColor = invoice.status === "Paid" ? cv("edr-green.5") : cv("edr-muted"); + const DueIcon = + invoice.status === "Paid" ? CheckCircle2 : Clock3; + const dueIconColor = + invoice.status === "Paid" + ? cv("edr-green.5") + : cv("edr-muted"); return ( {i > 0 && } - + - {invoice.number} - {invoice.bookingReference} + + {invoice.number} + + + {invoice.bookingReference} + - {formatCurrency(invoice.amount, invoice.currency)} + + {formatCurrency(invoice.amount, invoice.currency)} + - + - {dueText} + + {dueText} + - - {badge.label} + + + {badge.label} + @@ -335,27 +663,40 @@ export default function MyPortalPage() { {/* ── Bottom Row: Freight Volume + Recent Activity ──────────────────── */} - + - Freight Volume + + Freight Volume + - 4,180 t - ETB 1.24M - +16% YTD + + 4,180 t + + + ETB 1.24M + + + +16% YTD + {VOLUME_DATA.map((val, i) => { const isLast = i === VOLUME_DATA.length - 1; return ( - + - {MONTHS[i]} + + {MONTHS[i]} + ); })} @@ -366,21 +707,35 @@ export default function MyPortalPage() { - Recent Activity - - View all - - + + Recent Activity + + + + + View all + + + + {bookingsQuery.isPending ? ( - {[1, 2, 3, 4, 5].map((i) => )} + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + ) : allBookings.length === 0 ? ( ) : ( {allBookings.slice(0, 6).map((booking) => ( - navigate(`/bookings/${booking.id}`)} /> + navigate(`/bookings/${booking.id}`)} + /> ))} )} @@ -403,7 +758,10 @@ function Card({ padding?: number; }) { return ( - + {children} ); @@ -425,14 +783,25 @@ function StatKpi({ divider?: boolean; }) { return ( - + - {label} + + {label} + - {value} - {delta} + + {value} + + + {delta} + ); @@ -446,9 +815,26 @@ function Stepper({ stage, color }: { stage: number; color: string }) { const active = i === stage; const size = active ? 12 : done ? 9 : 8; return ( - - - {i < 4 && } + + + {i < 4 && ( + + )} ); })} @@ -456,43 +842,97 @@ function Stepper({ stage, color }: { stage: number; color: string }) { ); } -function BookingRow({ booking, last, onClick }: { booking: any; last: boolean; onClick: () => void }) { +function BookingRow({ + booking, + last, + onClick, +}: { + booking: any; + last: boolean; + onClick: () => void; +}) { const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT; const Icon = cfg.icon; const AIcon = cfg.action.icon; const ap = ACTION_PROPS[cfg.action.kind]; const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—"; - const dest = booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; + const dest = + booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; const commodity = - (typeof booking.cargoType === "string" ? booking.cargoType : booking.cargoType?.name) ?? + (typeof booking.cargoType === "string" + ? booking.cargoType + : booking.cargoType?.name) ?? booking.commodity ?? "Freight"; return ( - - + + - {booking.reference} - {commodity} · {origin} → {dest} + + {booking.reference} + + + {commodity} · {origin} → {dest} + - {cfg.hint} + + {cfg.hint} + - + - {cfg.badgeLabel} + + {cfg.badgeLabel} + - - {cfg.action.label} - {AIcon && } + + + {cfg.action.label} + + {AIcon && ( + + )} @@ -500,7 +940,13 @@ function BookingRow({ booking, last, onClick }: { booking: any; last: boolean; o ); } -function ActivityRow({ booking, onClick }: { booking: any; onClick: () => void }) { +function ActivityRow({ + booking, + onClick, +}: { + booking: any; + onClick: () => void; +}) { const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT; const Icon = cfg.icon; const verb = @@ -514,26 +960,49 @@ function ActivityRow({ booking, onClick }: { booking: any; onClick: () => void } ? "submitted for review" : "created"; return ( - - + + - Booking {booking.reference} {verb} + + Booking {booking.reference} {verb} + {booking.originYard?.label ?? booking.originYard?.code ?? "—"} →{" "} - {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"} + {booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + "—"} - {format(new Date(booking.createdAt), "MMM d")} + + {format(new Date(booking.createdAt), "MMM d")} + ); } function EmptyState({ message }: { message: string }) { return ( - - {message} + + + {message} + ); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx index 939088973..861fbbb75 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx @@ -1,12 +1,18 @@ -import { Box, Group, SimpleGrid, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core"; +import { + Box, + Group, + SimpleGrid, + Stack, + Text, + ThemeIcon, + UnstyledButton, +} from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowDownToLine, ArrowUpFromLine, Building2, ChevronRight, - Ship, - Truck, } from "lucide-react"; import { useState } from "react"; @@ -27,37 +33,37 @@ const USER_TYPE_CARDS: { description: string; icon: React.ReactNode; }[] = [ - { - id: "importer", - label: "Importer", - description: "Import goods into Ethiopia via the railway corridor.", - icon: , - }, - { - id: "exporter", - label: "Exporter", - description: "Export goods from Ethiopia via rail.", - icon: , - }, - { - id: "freight-forwarder-et", - label: "Freight Forwarder (Ethiopia)", - description: "Ethiopian freight forwarding company handling client cargo.", - icon: , - }, - { - id: "freight-forwarder-dj", - label: "FF Agent (Djibouti)", - description: "Djibouti-based agent coordinating cross-border logistics.", - icon: , - }, - { - id: "transporter", - label: "Transporter", - description: "Trucking company providing first/last-mile services.", - icon: , - }, -]; + { + id: "importer", + label: "Importer", + description: "Import goods into Ethiopia via the railway corridor.", + icon: , + }, + { + id: "exporter", + label: "Exporter", + description: "Export goods from Ethiopia via rail.", + icon: , + }, + { + id: "freight-forwarder-et", + label: "Freight Forwarder (Ethiopia)", + description: "Ethiopian freight forwarding company handling client cargo.", + icon: , + }, + // { + // id: "freight-forwarder-dj", + // label: "FF Agent (Djibouti)", + // description: "Djibouti-based agent coordinating cross-border logistics.", + // icon: , + // }, + // { + // id: "transporter", + // label: "Transporter", + // description: "Trucking company providing first/last-mile services.", + // icon: , + // }, + ]; const USER_TYPE_LEFT_MAP: Record< OnboardingUserType, @@ -105,7 +111,12 @@ const PREFLIGHT_LEFT = { "Freight Forwarders (Ethiopia & Djibouti)", "Transporters & Fleet Operators", ], - stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" }, + stats: { + label: "Active Customers", + value: "500+", + footer: "And growing", + progress: "w-[95%]", + }, }; const DOCUMENT_SETTING_CODE_MAP: Record = { @@ -120,7 +131,9 @@ export default function OnboardingPage() { const queryClient = useQueryClient(); const { user } = useAuth(); const [userType, setUserType] = useState(null); - const [documentFiles, setDocumentFiles] = useState>({}); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); const COMPANY_TYPE_MAP: Record = { importer: "customer", @@ -131,7 +144,8 @@ export default function OnboardingPage() { }; const createCompanyMutation = useMutation({ - mutationFn: (payload: CreateCompanyPayload) => api.companies.create.call(payload), + mutationFn: (payload: CreateCompanyPayload) => + api.companies.create.call(payload), onSuccess: async (data) => { const hasFiles = Object.values(documentFiles).some( (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), @@ -139,14 +153,19 @@ export default function OnboardingPage() { if (hasFiles) { await companiesService.uploadDocuments(data.company.id, documentFiles); } - await queryClient.invalidateQueries({ queryKey: api.companies.getInfo.queryKey() }); + await queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }); }, }); if (!user) return null; const handleSubmit = (payload: CreateCompanyPayload) => { - const enriched: CreateCompanyPayload = { ...payload, companyType: COMPANY_TYPE_MAP[userType!] }; + const enriched: CreateCompanyPayload = { + ...payload, + companyType: COMPANY_TYPE_MAP[userType!], + }; createCompanyMutation.mutate(enriched); }; @@ -209,11 +228,28 @@ export default function OnboardingPage() { ...leftConfig, features: userType === "transporter" - ? ["Vehicle & fleet registration", "TIN & FAN verification", "First-mile / Last-mile eligibility"] + ? [ + "Vehicle & fleet registration", + "TIN & FAN verification", + "First-mile / Last-mile eligibility", + ] : userType === "freight-forwarder-dj" - ? ["Company details", "Representative information", "Cross-border operations"] - : ["Company registration details", "Contact and management personnel", "Power of Attorney (optional)"], - stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" }, + ? [ + "Company details", + "Representative information", + "Cross-border operations", + ] + : [ + "Company registration details", + "Contact and management personnel", + "Power of Attorney (optional)", + ], + stats: { + label: "Active Customers", + value: "500+", + footer: "And growing", + progress: "w-[95%]", + }, }; return ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx index e69a024cc..38238680a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -71,10 +71,12 @@ export function DraftBookingView({ ).length; const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length; - const { data: generatedPricing } = useQuery({ - ...api.bookings.generatePrice.queryOptions({ input: { id: booking.id } }), - enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, - }); + const { data: generatedPricing } = useQuery( + api.bookings.generatePrice.queryOptions({ input: { id: booking.id }, + + enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, + }), + ); const pricing = booking.pricingBreakdown ?? generatedPricing ?? null; const uploadMutation = useMutation({ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 60efb704e..2bc44edab 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -12,6 +12,7 @@ import { DocRow, IconSquare } from "./components/Documents"; import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout"; import { CancelledBanner } from "./components/Notices"; import { HeaderButton, PageHeader } from "./components/PageHeader"; +import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard"; import { PaymentCard } from "./components/pricing"; import { ScheduleCard } from "./components/ScheduleCard"; import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; @@ -32,14 +33,17 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) const pricing = booking.pricingBreakdown; const canPay = - status === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID"; + status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID"; + const showCountdown = canPay && !!booking.paymentDeadline; + const isExpired = status === "EXPIRED"; return ( } @@ -70,6 +74,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) reason={booking.latestChangeRequestNote} onRebook={() => navigate("/bookings/new")} /> + ) : isExpired ? ( + navigate("/bookings/new")} + /> ) : ( )} @@ -114,6 +125,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) } right={ <> + {showCountdown && ( + payMutation.mutate()} + paying={payMutation.isPending} + /> + )} + + {String(value).padStart(2, "0")} + + + {label} + + + ); +} + +export function PaymentDeadlineCard({ + paymentDeadline, + onPay, + paying, +}: { + /** ISO timestamp marking the end of the pay window. */ + paymentDeadline: string; + onPay?: () => void; + paying?: boolean; +}) { + const deadlineMs = new Date(paymentDeadline).getTime(); + const [remaining, setRemaining] = useState(() => getRemaining(deadlineMs)); + + useEffect(() => { + setRemaining(getRemaining(deadlineMs)); + const interval = setInterval(() => { + const next = getRemaining(deadlineMs); + setRemaining(next); + if (next.expired) clearInterval(interval); + }, 1000); + return () => clearInterval(interval); + }, [deadlineMs]); + + const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0"; + const accentFg = remaining.expired ? "#C0392B" : "#9A5B00"; + + return ( + + + Payment deadline + + + {remaining.expired ? "Expired" : "Pay window open"} + + + + {remaining.expired ? ( + + The payment window has closed. Move this booking to another schedule or + contact support. + + ) : ( + <> + + + + + + + + Complete payment before the window closes to secure your slot. + + {onPay && ( + + )} + + )} + + + + Deadline:{" "} + {new Date(paymentDeadline).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 80f0b7ab2..c6e19fb9b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -32,6 +32,8 @@ export const PROGRESS_STAGES = [ label: "In Transit", icon: Train, statuses: [ + "SELECTED_FOR_BATCH", + "EXPIRED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS", "PAID", @@ -99,6 +101,18 @@ export const STATUS_MAP: Record< description: "Signed by all parties. You can now proceed to payment.", stage: 2, }, + SELECTED_FOR_BATCH: { + title: "Selected for a train — payment due", + description: + "Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.", + stage: 3, + }, + EXPIRED: { + title: "Pay window expired", + description: + "The payment window was missed. You can move this booking to another schedule or cancel it.", + stage: 3, + }, PNR_GENERATED: { title: "Payment reference generated", description: diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 5ada990d1..513b93a45 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -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 useAuth from "@/hooks/useAuth"; import { BookingFormInputValues, STEPS, @@ -22,18 +23,58 @@ import { Step2ServiceType, Step4Route, Step5CargoDetails, - StepDocuments, Step8Review, + StepDocuments, + StepScheduling, } from "./new-booking-form/steps"; export default function NewBookingPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const [step, setStep] = useState(1); + const auth = useAuth(); const { data: referenceData, isLoading: refDataLoading } = useQuery( api.bookings.referenceData.queryOptions(), ); + if (!auth.isPending && !auth.company) { + return ( + + } + radius="md" + style={{ maxWidth: "500px" }} + mb="lg" + > + + Complete Your Company Setup + + + You need to complete your company onboarding before you can create + bookings. Please follow the onboarding process to get started. + + + + + ); + } + const createMutation = useMutation({ mutationFn: async (payload: CreateBookingPayload) => { const booking = await api.bookings.create.call(payload); @@ -70,7 +111,15 @@ export default function NewBookingPage() { const destinationYard = form.watch("destinationYard"); const direction = useMemo( - () => getRouteDirection(originYard, destinationYard), + () =>{ + const origin = referenceData?.yard.find((y) => y.id === originYard); + const destination = referenceData?.yard.find((y) => y.id === destinationYard); + + const route = getRouteDirection( + origin,destination + ) + return route + }, [originYard, destinationYard], ); @@ -100,30 +149,13 @@ 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 ?? ""; - }; - 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); @@ -132,47 +164,42 @@ export default function NewBookingPage() { return ""; }; - const selectedChild = - data.cargoType !== "container" && data.bulkCommoditytype - ? cargoTree - .find((g) => g.code.toLowerCase() === data.freightType) - ?.children?.find((c) => c.name === data.bulkCommoditytype) - : undefined; + const cargoTypePath = data.cargoTypePath ?? []; + const childId = cargoTypePath[1]; + + const bulkChild = cargoTree + .flatMap((g) => g.children ?? []) + .find((c) => c.id === childId); const cargoTypeId = - data.cargoType === "container" - ? findContainerCargoTypeId() - : (selectedChild?.id ?? ""); + data.cargoType === "bulk" ? childId : undefined; - const cargoFreeText = - data.cargoType === "container" - ? undefined - : selectedChild?.show_free_text_box - ? data.bulkCommoditytype - : undefined; + const cargoFreeText = bulkChild?.show_free_text_box + ? data.cargoFreeText + : undefined; + + const serviceType = referenceData?.service.find( + (s) => s.id === data.serviceTypeId, + )!; // ── 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(), + serviceTypeId: data.serviceTypeId, equipmentReturn: data.equipmentReturn === "with_return" ? "WITH_RETURN" : "WITHOUT_RETURN", - originYardId: findYardId(data.originYard), - destinationYardId: findYardId(data.destinationYard), - tradeDirection: - direction === "export" - ? "EXPORT" - : direction === "domestic" - ? "DOMESTIC" - : "IMPORT", - cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId, + paymentCurrency: "USD", + originYardId: data.originYard, + destinationYardId: data.destinationYard, + tradeDirection: direction!, + cargoTypeId, + trainScheduleId: data.trainScheduleId, cargoTotalWeightVgm: totalWeight, isHazardous: data.isHazardous, - paymentCurrency: "USD", allowConsolidation: data.consolidationEnabled, // @ts-ignore freightType: @@ -193,10 +220,10 @@ export default function NewBookingPage() { ...(data.contractType === "renewal" && data.previousContractRef ? { pnrCode: data.previousContractRef } : {}), - ...(data.serviceType === "rail_forwarding" && data.firstMile.enabled + ...(serviceType.includesFirstMile && data.firstMile.enabled ? { firstMilePickupAddress: data.firstMile.pickUpAddress } : {}), - ...(data.serviceType === "rail_forwarding" && data.lastMile.enabled + ...(serviceType.includesLastMile && data.lastMile.enabled ? { lastMileDeliveryAddress: data.lastMile.deliveryAddress } : {}), ...(data.shippingLine @@ -248,67 +275,61 @@ export default function NewBookingPage() { Back to Bookings -
- {/* Step indicator */} - - + + - - {/* Step content */} - - - {createMutation.isError && ( - } - radius="md" - mb="lg" - > - - Failed to save draft - - - {createMutation.error instanceof Error - ? createMutation.error.message - : "An unexpected error occurred. Please try again."} - - - )} + {createMutation.isError && ( + } + radius="md" + mb="lg" + > + + Failed to save draft + + + {createMutation.error instanceof Error + ? createMutation.error.message + : "An unexpected error occurred. Please try again."} + + + )} - {step === 1 && } - {step === 2 && } - {step === 3 && ( - - )} - {step === 4 && ( - - )} - {step === 5 && } - {step === 6 && ( - - )} - + {step === 1 && } + {step === 2 && ( + + )} + {step === 3 && ( + + )} + {step === 4 && ( + + )} + {step === 5 && ( + + )} + {step === 6 && } + {step === 7 && ( + + )} {/* Navigation footer */} @@ -364,6 +385,7 @@ export default function NewBookingPage() { + {/* */}
); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 86d3571ec..05707bc16 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -2,15 +2,6 @@ import type { Freight } from "@edr/types"; import { DeepPartial, Path } from "react-hook-form"; import * as z from "zod"; -export const ETHIOPIA_STATIONS = new Set([ - "Addis Ababa", - "Adama", - "Mojo", - "Awash", - "Mieso", - "Dire Dawa", -]); - export const MOCK_VALID_CONTRACTS = [ "EDR-2024-10001", "EDR-2024-10002", @@ -23,8 +14,9 @@ export const STEPS = [ { id: 2, label: "Service Type & Mile", short: "Service" }, { id: 3, label: "Route", short: "Route" }, { id: 4, label: "Cargo Details", short: "Cargo" }, - { id: 5, label: "Documents", short: "Documents" }, - { id: 6, label: "Review & Submit", short: "Submit" }, + { id: 5, label: "Shipment Date", short: "Schedule" }, + { id: 6, label: "Documents", short: "Documents" }, + { id: 7, label: "Review & Submit", short: "Submit" }, ] as const; /** @@ -82,7 +74,7 @@ export const bookingFormSchema = z .object({ contractType: z.enum(["new", "renewal"], "Select a contract type."), previousContractRef: z.string(), - serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."), + serviceTypeId: z.string("Select a service type."), firstMile: z .object({ enabled: z.boolean().default(false), @@ -108,10 +100,12 @@ export const bookingFormSchema = z originYard: z.string().min(1, "Select an origin yard."), 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(), + cargoTypePath: z.array(z.string()).default([]), + cargoFreeText: z.string(), isHazardous: z.boolean(), isRefrigerated: z.boolean(), containers: z.array( @@ -163,19 +157,6 @@ export const bookingFormSchema = z path: ["destinationYard"], }, ) - .refine((data) => !(data.cargoType === "bulk" && !data.freightType), { - message: "Select a freight type.", - path: ["freightType"], - }) - .refine( - (data) => - !( - data.cargoType === "bulk" && - data.freightType && - !data.bulkCommoditytype - ), - { message: "Select a commodity.", path: ["bulkCommoditytype"] }, - ) .refine( (data) => { if (data.cargoType !== "bulk") return true; @@ -195,6 +176,21 @@ export const bookingFormSchema = z path: ["termsAccepted"], }) .superRefine((data, ctx) => { + if (data.cargoType === "bulk") { + if (!data.cargoTypePath[0]) { + ctx.addIssue({ + code: "custom", + path: ["cargoTypePath"], + message: "Select a freight type.", + }); + } else if (!data.cargoTypePath[1]) { + ctx.addIssue({ + code: "custom", + path: ["cargoTypePath"], + message: "Select a commodity.", + }); + } + } if (data.cargoType === "container") { data.containers.forEach((c, i) => { if (!c.qty || +c.qty < 1) { @@ -235,8 +231,11 @@ export const initialBookingFormValues: DeepPartial = { originYard: "", destinationYard: "", shippingLine: "", + scheduledDate: "", + trainScheduleId: "", cargoWeight: "", - bulkCommoditytype: "", + cargoTypePath: [], + cargoFreeText: "", isHazardous: false, isRefrigerated: false, containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }], @@ -249,7 +248,7 @@ export const initialBookingFormValues: DeepPartial = { export const stepFields: Record>> = { 1: ["contractType", "previousContractRef"], 2: [ - "serviceType", + "serviceTypeId", "firstMile", "lastMile", "equipmentReturn", @@ -265,17 +264,15 @@ export const stepFields: Record>> = { 4: [ "cargoType", "cargoWeight", - "freightType", - "bulkCommoditytype", + "cargoTypePath", "containers", "consolidationEnabled", ], - 5: ["documents"], - 6: ["notes", "termsAccepted"], + 5: ["scheduledDate", "trainScheduleId"], + 6: ["documents"], + 7: ["notes", "termsAccepted"], }; -export type RouteDirection = "import" | "export" | "domestic" | null; - export interface ContainerConfig { type: "20ft" | "40ft"; containerType: string; @@ -287,64 +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 genContractId(): string { - const yr = new Date().getFullYear(); - const n = Math.floor(10000 + Math.random() * 90000); - return `EDR-DRAFT-${yr}-${n}`; -} - export function getRouteDirection( - origin: string, - dest: string, -): RouteDirection { + origin: Freight.BookingReferenceYard | null | undefined, + dest: Freight.BookingReferenceYard | null | undefined, +): Freight.ScheduleTradeDirection | null { if (!origin || !dest) return null; - const oLocation = getStationLocation(origin); - const dLocation = getStationLocation(dest); - if (oLocation === "inside" && dLocation === "outside") return "export"; - if (oLocation === "outside" && dLocation === "inside") return "import"; - if (oLocation === "inside" && dLocation === "inside") return "domestic"; + if (origin.country === "Ethiopia" && dest.country === "Ethiopia") { + return "DOMESTIC"; + } + if (origin.country === "Ethiopia" && dest.country === "Djibouti") { + return "IMPORT"; + } + if (origin.country === "Djibouti" && dest.country === "Ethiopia") { + return "EXPORT"; + } - const oEth = ETHIOPIA_STATIONS.has(origin); - const dEth = ETHIOPIA_STATIONS.has(dest); - if (oEth && !dEth) return "export"; - if (!oEth && dEth) return "import"; - if (oEth && dEth) return "domestic"; return null; } -function getStationLocation(value: string): "inside" | "outside" | null { - const normalized = value.trim().toLowerCase(); - if (normalized.startsWith("inside")) return "inside"; - if (normalized.startsWith("outside")) return "outside"; - 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, }; } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx index 4d5f8e33a..55b6eda44 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx @@ -1,7 +1,8 @@ +import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core"; +import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react"; import type { ReactNode } from "react"; +import { useMemo } from "react"; import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form"; -import { AlertTriangle, Check, CheckCircle2, Info, XCircle } from "lucide-react"; -import { Alert, Select, Text, Title } from "@mantine/core"; import type { BookingFormInputValues } from "./schema"; export function OptionFieldError({ error }: { error?: { message?: string } }) { @@ -70,7 +71,7 @@ export function AlertBox({ export function StepLabel({ children }: { children: ReactNode }) { return ( - + {children} ); @@ -120,8 +121,96 @@ export function SelectField({ onChange={(v) => field.onChange(v ?? "")} onBlur={field.onBlur} error={error?.message} - radius="md" allowDeselect={false} /> ); } + +interface AsyncComboboxOption { + value: string; + label: string; +} + +export function AsyncComboboxField({ + field, + error, + label, + placeholder, + options, + isLoading, + searchQuery, + onSearchChange, + onSelect, + disabled, +}: { + field: ControllerRenderProps; + error?: RhfFieldError; + label: string; + placeholder: string; + options: AsyncComboboxOption[]; + isLoading?: boolean; + searchQuery: string; + onSearchChange: (query: string) => void; + onSelect: (value: string) => void; + disabled?: boolean; +}) { + const combobox = useCombobox(); + + const selectedLabel = useMemo(() => { + return options.find((opt) => opt.value === field.value)?.label || ""; + }, [field.value, options]); + + const handleSelectOption = (val: string) => { + onSelect(val); + combobox.closeDropdown(); + }; + + return ( + + + + { + onSearchChange(e.currentTarget.value); + combobox.openDropdown(); + }} + onFocus={() => combobox.openDropdown()} + onBlur={() => { + field.onBlur(); + combobox.closeDropdown(); + if (!selectedLabel) { + onSearchChange(""); + } + }} + rightSection={ + isLoading ? : + } + /> + + + + + {isLoading ? ( + Loading contracts... + ) : options.length === 0 ? ( + No contracts found + ) : ( + options.map((option) => ( + handleSelectOption(option.value)} + > + {option.label} + + )) + )} + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx index cd7db7b85..f600d10d7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx @@ -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; +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 ? : `${attached}/${total}`} + {attached === total ? ( + + ) : ( + `${attached}/${total}` + )} {attached === 0 diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx new file mode 100644 index 000000000..181e611be --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx @@ -0,0 +1,564 @@ +import { + Box, + Button, + Card, + Group, + Stack, + Text, + useMantineTheme, +} from "@mantine/core"; +import { UseFormReturn } from "react-hook-form"; +import { BookingFormInputValues, BookingFormValues } from "./schema"; +import { + ChevronLeft, + ChevronRight, + Check, + Train, + Route, + Package, + Calendar as CalendarIcon, +} from "lucide-react"; +import type { Freight } from "@edr/types"; +import React, { 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; + referenceData?: Freight.BookingReferenceData; +} + +interface DayData { + day: number; + dateString: string; + isToday: boolean; + isCurrentMonth: boolean; + isSelectedDate: boolean; + schedules: Freight.BookableScheduleItem[]; + hasSchedule: boolean; +} + +export function StepScheduling({ form, referenceData }: StepSchedulingProps) { + const theme = useMantineTheme(); + const [currentDate, setCurrentDate] = useState(new Date()); + + const selectedDate = form.watch("scheduledDate"); + const selectedScheduleId = form.watch("trainScheduleId"); + const originYardId = form.watch("originYard"); + const destinationYardId = form.watch("destinationYard"); + const cargoType = form.watch("cargoType"); + const containers = form.watch("containers"); + const cargoTypePath = form.watch("cargoTypePath"); + const cargoWeight = form.watch("cargoWeight"); + + const originName = useMemo( + () => referenceData?.yard.find((y) => y.id === originYardId)?.name ?? "—", + [referenceData, originYardId], + ); + + const destinationName = useMemo( + () => + referenceData?.yard.find((y) => y.id === destinationYardId)?.name ?? "—", + [referenceData, destinationYardId], + ); + + const { data: bookableSchedules } = useQuery( + api.bookings.getBookableSchedules.queryOptions({ + input: { originYardId, destinationYardId }, + enabled: !!originYardId && !!destinationYardId, + }), + ); + + // Group all schedules per date — multiple departures per day are allowed. + // scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to + // match the format used by the calendar day keys. + const schedulesByDate = useMemo(() => { + const map = new Map(); + if (bookableSchedules) { + for (const s of bookableSchedules) { + const dateKey = s.scheduleDate.slice(0, 10); + const existing = map.get(dateKey) ?? []; + map.set(dateKey, [...existing, s]); + } + } + return map; + }, [bookableSchedules]); + + const selectedSchedule = useMemo( + () => bookableSchedules?.find((s) => s.id === selectedScheduleId), + [bookableSchedules, selectedScheduleId], + ); + + const availableCount = useMemo(() => { + let count = 0; + schedulesByDate.forEach((schedules) => { + if (schedules.some((s) => s.remainingWagons > 0)) count++; + }); + return count; + }, [schedulesByDate]); + + const days = useMemo((): DayData[] => { + 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 schedules = schedulesByDate.get(dateString) ?? []; + + return { + day: date.getDate(), + dateString, + isToday: isToday(date), + isCurrentMonth: isSameMonth(date, currentDate), + isSelectedDate: selectedDate === dateString, + schedules, + hasSchedule: schedules.length > 0, + }; + }); + }, [currentDate, schedulesByDate, selectedDate]); + + const cargoSummary = useMemo(() => { + if (!cargoType) return "Not selected"; + if (cargoType === "container") { + const parts = (containers ?? []) + .filter((c) => Number(c.qty) > 0) + .map((c) => `${c.qty} × ${c.type}`); + return parts.length > 0 ? `Container · ${parts.join(", ")}` : "Container"; + } + const childId = cargoTypePath?.[1]; + const commodity = referenceData?.cargo_type + .flatMap((g) => g.children ?? []) + .find((c) => c.id === childId); + const weight = cargoWeight ? ` · ${cargoWeight} t` : ""; + return commodity ? `${commodity.name}${weight}` : "Bulk freight"; + }, [cargoType, containers, cargoTypePath, cargoWeight, referenceData]); + + const weeksCount = Math.ceil(days.length / 7); + + return ( + + {/* ── Calendar Card ───────────────────────────────────── */} + + + {/* Card header */} + + + + Select a shipment date + + + Confirmed train departures · {originName} → {destinationName} + + + + + + {format(currentDate, "MMMM yyyy")} + + + + + + {/* Card body */} + + + {originYardId && destinationYardId + ? `${availableCount} available departure${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick one to continue` + : "Select origin and destination to see available departures"} + + + {/* Weekday headers */} + + {["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"].map((d) => ( + + {d} + + ))} + + + {/* Date grid */} + + {Array.from({ length: weeksCount }, (_, wi) => ( + + {days.slice(wi * 7, wi * 7 + 7).map((d, di) => ( + { + form.setValue("scheduledDate", dateString, { + shouldValidate: true, + }); + form.setValue("trainScheduleId", scheduleId, { + shouldValidate: true, + }); + }} + /> + ))} + + ))} + + + + + + {/* ── Side Panel ──────────────────────────────────────── */} + + {/* Booking summary card */} + + + + Booking summary + + + + + } + label="ROUTE" + value={`${originName} → ${destinationName}`} + /> + } + label="CARGO" + value={cargoSummary} + /> + + {selectedSchedule && selectedDate && ( + + + + + + SELECTED DEPARTURE + + + + {format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")} + + + + Train + + + {selectedSchedule.trainNumber ?? selectedSchedule.id.slice(0, 8)} + + + + + Wagons available + + + {selectedSchedule.remainingWagons} / {selectedSchedule.maxWagons} + + + + + )} + + + + {/* Help card */} + + + + + + Need a different date? + + + + Our freight desk can arrange charter departures for full-train loads. + + + Contact freight desk → + + + + + + ); +} + +interface DayCellProps { + day: DayData; + selectedScheduleId: string; + onSelectSchedule: (scheduleId: string, dateString: string) => void; +} + +function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps) { + const theme = useMantineTheme(); + + if (!d.isCurrentMonth) { + return ( + + + {d.day} + + + ); + } + + const cellBg = d.isSelectedDate + ? theme.colors["edr-soft"][0] + : d.hasSchedule + ? "#FFFFFF" + : "transparent"; + + const cellBorder = d.isSelectedDate + ? `1.5px solid ${theme.colors["edr-green"][5]}` + : d.hasSchedule + ? "1px solid #E7E8E5" + : "none"; + + return ( + + {/* Day number + check icon */} + + + {d.day} + + {d.isSelectedDate && ( + + )} + + + {/* Departure chips */} + {d.hasSchedule && ( + + {d.schedules.slice(0, 2).map((s) => { + const isChipSelected = s.id === selectedScheduleId; + const isFull = s.remainingWagons <= 0; + const canSelect = !isFull; + + return ( + + canSelect && onSelectSchedule(s.id, d.dateString) + } + style={{ + display: "flex", + alignItems: "center", + gap: 4, + borderRadius: 7, + padding: "4px 6px", + cursor: canSelect ? "pointer" : "default", + backgroundColor: isChipSelected + ? theme.colors["edr-green"][5] + : isFull + ? theme.colors["edr-red-soft"][0] + : theme.colors["edr-soft"][0], + border: `1px solid ${ + isChipSelected + ? theme.colors["edr-green"][5] + : isFull + ? "#EFCFCA" + : "#BFE3D4" + }`, + }} + > + + + {isFull + ? "Full" + : s.trainNumber + ? s.trainNumber + : `${s.remainingWagons} wgn`} + + + + ); + })} + + )} + + ); +} + +function SummaryRow({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + const theme = useMantineTheme(); + return ( + + + {icon} + + + + {label} + + + {value} + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx index 1b4e91e3a..55b81a816 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx @@ -1,23 +1,96 @@ -import { Controller, type UseFormReturn } from "react-hook-form"; +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; +import { useQuery } from "@tanstack/react-query"; import { FileText, RefreshCw } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Controller, type UseFormReturn } from "react-hook-form"; import { BookingFormInputValues, - MOCK_VALID_CONTRACTS, type BookingFormValues, } from "./schema"; import { AlertBox, + AsyncComboboxField, OptionCard, OptionFieldError, - SelectField, StepHeader, } from "./shared"; type BookingForm = UseFormReturn; -export function Step1ContractType({ form }: { form: BookingForm }) { +interface PreviousContractOption { + value: string; + label: string; + booking: Freight.IBooking; +} + +export function Step1ContractType({ + form, + referenceData, +}: { + form: BookingForm; + referenceData?: Freight.BookingReferenceData; +}) { const contractType = form.watch("contractType"); const previousContractRef = form.watch("previousContractRef"); + const [searchQuery, setSearchQuery] = useState(""); + + const { data: bookings, isLoading, error } = useQuery( +api.bookings.list.queryOptions({ + input: { + + page: 1, + pageSize: 100, + sortBy: "createdAt", + sortOrder: "DESC", + } + }) + ); + + const contractOptions = useMemo(() => { + console.log("Bookings data:", bookings); + if(!bookings) return [] + + return bookings?.items + .map((booking) => { + const origin = booking.originYard?.label || "Unknown"; + const destination = booking.destinationYard?.label || "Unknown"; + return { + value: booking.id, + label: `${booking.reference} - Route: ${origin} to ${destination}`, + booking, + }; + }) + .filter((opt) => + opt.label.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [bookings, searchQuery]); + + const handleSelectContract = async (contractId: string) => { + const selected = contractOptions.find((opt) => opt.value === contractId); + if (!selected) return; + + form.setValue("previousContractRef", contractId); + + // Auto-fill from previous contract + const booking = selected.booking; + if (booking) { + form.setValue("serviceTypeId", booking.serviceTypeId); + form.setValue("cargoType", booking.freightType === "CONTAINER" ? "container" : "bulk"); + form.setValue("equipmentReturn", booking.equipmentReturn === "WITH_RETURN" ? "with_return" : "without_return"); + if (booking.isHazardous) form.setValue("isHazardous", booking.isHazardous); + + // Look up shipping line name from reference data + if (booking.shippingLineId && referenceData?.shipping_line) { + const shippingLine = referenceData.shipping_line.find( + (sl) => sl.id === booking.shippingLineId, + ); + if (shippingLine) { + form.setValue("shippingLine", shippingLine.name); + } + } + } + } return (
@@ -73,23 +146,32 @@ export function Step1ContractType({ form }: { form: BookingForm }) { {contractType === "renewal" && (
+ {error && ( + + Failed to load previous contracts. Please try again later. + + )} ( - )} /> {previousContractRef && ( - Contract found. Company details, route, and wagon - preferences will be pre-filled. + Contract found. Route, service type, and cargo + details will be pre-filled. )}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index 9c72a5356..e01e2f879 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -1,18 +1,52 @@ +import { Switch, TextInput } from "@mantine/core"; +import { FileText, Train, Truck } from "lucide-react"; import { useEffect, useRef } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; -import { FileText, Package, Train, Truck } from "lucide-react"; -import { Badge, Switch, TextInput } from "@mantine/core"; import { BookingFormInputValues, type BookingFormValues } from "./schema"; import { OptionCard, OptionFieldError, StepHeader } from "./shared"; -type BookingForm = UseFormReturn; +import type { Freight } from "@edr/types"; -export function Step2ServiceType({ form }: { form: BookingForm }) { - const serviceType = form.watch("serviceType"); +type BookingForm = UseFormReturn< + BookingFormInputValues, + any, + BookingFormValues +>; + +export function Step2ServiceType({ + form, + referenceData, +}: { + form: BookingForm; + + referenceData?: Freight.BookingReferenceData; +}) { + const serviceTypeId = form.watch("serviceTypeId"); + const serviceType = referenceData?.service.find( + (s) => s.id === serviceTypeId, + ); + + const { includesCustoms, includesFirstMile, includesLastMile } = + serviceType ?? {}; const firstMileEnabled = form.watch("firstMile.enabled"); const lastMileEnabled = form.watch("lastMile.enabled"); const prevServiceType = useRef(serviceType); + useEffect(() => { + form.setValue( + "firstMile", + { enabled: false, pickUpAddress: "" }, + { shouldValidate: true }, + ); + }, [includesFirstMile]); + + useEffect(() => { + form.setValue( + "lastMile", + { enabled: false, deliveryAddress: "" }, + { shouldValidate: true }, + ); + }, [includesLastMile]); useEffect(() => { const prev = prevServiceType.current; @@ -20,35 +54,12 @@ export function Step2ServiceType({ form }: { form: BookingForm }) { if (!prev || prev === serviceType) return; - if (serviceType === "rail") { - form.setValue( - "firstMile", - { enabled: false, pickUpAddress: "" }, - { shouldDirty: true, shouldValidate: true }, - ); - form.setValue( - "lastMile", - { enabled: false, deliveryAddress: "" }, - { shouldDirty: true, shouldValidate: true }, - ); - form.setValue("equipmentReturn", "with_return", { shouldDirty: true }); + if (!includesCustoms) form.setValue("customsClearingEnabled", false, { shouldDirty: true }); - } else if (serviceType === "rail_forwarding") { - form.setValue( - "firstMile", - { enabled: false, pickUpAddress: "" }, - { shouldDirty: false, shouldValidate: false }, - ); - form.setValue( - "lastMile", - { enabled: false, deliveryAddress: "" }, - { shouldDirty: false, shouldValidate: false }, - ); - } - }, [serviceType, form]); - - const showServiceSections = serviceType === "rail_forwarding"; + }, [serviceTypeId, form]); + const showServiceSections = + includesCustoms || includesFirstMile || includesLastMile; return (
(
- field.onChange("rail")} - > -
- -
-

Rail Transport Only

-

- Rail transport along the EDR corridor, with optional - first/last mile trucking. -

- - Option A - -
- - field.onChange("rail_forwarding")} - > -
- -
-

Logistics

-

- Rail transport plus documentation, customs liaison, and a - dedicated coordinator. -

- - Option B - -
+ {referenceData?.service + .filter((s) => s.canBeBookedAlone) + .map((s) => { + return ( + field.onChange(s.id)} + > +
+ +
+

{s.serviceName}

+

+ {s.description} +

+
+ ); + })}
@@ -104,112 +100,120 @@ export function Step2ServiceType({ form }: { form: BookingForm }) { {showServiceSections && (
{/* First Mile */} -
- ( -
-
- -
-

First Mile — Pick-up

-

- Truck pick-up from your premises (Door to Port) to the - origin rail yard. -

-
-
- { - const value = e.currentTarget.checked; - field.onChange(value); - if (!value) { - form.setValue("firstMile.pickUpAddress", "", { - shouldDirty: true, - shouldValidate: true, - }); - } - }} - color="edr-green" - /> -
- )} - /> - {firstMileEnabled && ( + {includesFirstMile && ( +
( - + render={({ field }) => ( +
+
+ +
+

+ First Mile — Pick-up +

+

+ Truck pick-up from your premises (Door to Port) to the + origin rail yard. +

+
+
+ { + const value = e.currentTarget.checked; + field.onChange(value); + if (!value) { + form.setValue("firstMile.pickUpAddress", "", { + shouldDirty: true, + shouldValidate: true, + }); + } + }} + color="edr-green" + /> +
)} /> - )} -
+ {firstMileEnabled && ( + ( + + )} + /> + )} +
+ )} {/* Last Mile */} -
- ( -
-
- -
-

Last Mile — Delivery

-

- Truck delivery from the destination rail yard to the - final address (Port to Door). -

-
-
- { - const value = e.currentTarget.checked; - field.onChange(value); - if (!value) { - form.setValue("lastMile.deliveryAddress", "", { - shouldDirty: true, - shouldValidate: true, - }); - form.setValue("equipmentReturn", "with_return", { - shouldDirty: true, - }); - } - }} - color="edr-green" - /> -
- )} - /> - {lastMileEnabled && ( + {includesLastMile && ( +
( - + render={({ field }) => ( +
+
+ +
+

+ Last Mile — Delivery +

+

+ Truck delivery from the destination rail yard to the + final address (Port to Door). +

+
+
+ { + const value = e.currentTarget.checked; + field.onChange(value); + if (!value) { + form.setValue("lastMile.deliveryAddress", "", { + shouldDirty: true, + shouldValidate: true, + }); + form.setValue("equipmentReturn", "with_return", { + shouldDirty: true, + }); + } + }} + color="edr-green" + /> +
)} /> - )} -
+ {lastMileEnabled && ( + ( + + )} + /> + )} +
+ )} {/* Equipment Return */} - {lastMileEnabled && ( + {includesLastMile && lastMileEnabled && (
{ field.onChange( - e.currentTarget.checked ? "with_return" : "without_return", + e.currentTarget.checked + ? "with_return" + : "without_return", ); }} color="edr-green" @@ -240,31 +246,35 @@ export function Step2ServiceType({ form }: { form: BookingForm }) { )} {/* Customs Clearing */} -
- ( -
-
- -
-

Customs Clearing Service

-

- EDR handles customs documentation and clearance on your - behalf. -

+ {includesCustoms && ( +
+ ( +
+
+ +
+

+ Customs Clearing Service +

+

+ EDR handles customs documentation and clearance on + your behalf. +

+
+ field.onChange(e.currentTarget.checked)} + color="edr-green" + />
- field.onChange(e.currentTarget.checked)} - color="edr-green" - /> -
- )} - /> -
+ )} + /> +
+ )}
)}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index c72d62d79..c74b3dfd1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -26,7 +26,7 @@ export function Step4Route({ const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; - return referenceData.yard.map((y) => ({ value: y.name, label: y.name })); + return referenceData.yard.map((y) => ({ value: y.id, label: y.name })); }, [referenceData]); const shippingLineOptions = useMemo(() => { @@ -35,15 +35,40 @@ export function Step4Route({ }, [referenceData]); const originData = useMemo( - () => yardOptions.filter((o) => o.value !== destinationYard), + () => { + return yardOptions.filter((o) => o.value !== destinationYard).filter((o) => { + const dest = referenceData?.yard.find((y) => y.id === destinationYard); + if(!dest) return true; + const origin = referenceData?.yard.find((y) => y.id === o.value); + + // can't go from Djibouti to Djibouti + if(dest?.country === 'Djibouti' && origin?.country == 'Djibouti') return false; + + return true; + }); + }, [yardOptions, destinationYard], ); + console.log({yardOptions,originYard, destinationYard}) const destData = useMemo( - () => yardOptions.filter((o) => o.value !== originYard), + () => { + return yardOptions.filter((o) => o.value !== originYard).filter((d) => { + + + const origin = referenceData?.yard.find((y) => y.id === originYard); + if(!origin) return true; + + const dest = referenceData?.yard.find((y) => y.id === d.value); + // can't go from Djibouti to Djibouti + // if(origin.country === 'Djibouti' && dest?.country == 'Djibouti') return false; + + return true; + }); + }, [yardOptions, originYard], ); - const direction = getRouteDirection(originYard, destinationYard); + const direction = getRouteDirection(referenceData?.yard.find((y) => y.id === originYard), referenceData?.yard.find((y) => y.name === destinationYard)); const directionStyle: Record = { export: "bg-sky-50 text-sky-800 border-sky-200", @@ -57,7 +82,7 @@ export function Step4Route({ }; useEffect(() => { - if (direction === "domestic") { + if (direction === "DOMESTIC") { form.setValue("shippingLine", "", { shouldDirty: true }); } }, [direction]); @@ -117,7 +142,7 @@ export function Step4Route({
)} - {direction && direction !== "domestic" && ( + {direction && direction !== "DOMESTIC" && ( ; +type BookingForm = UseFormReturn< + BookingFormInputValues, + any, + BookingFormValues +>; export function Step5CargoDetails({ form, @@ -27,12 +37,14 @@ export function Step5CargoDetails({ isLoading, }: { form: BookingForm; - direction: RouteDirection; + direction: Freight.ScheduleTradeDirection; referenceData?: Freight.BookingReferenceData; isLoading?: boolean; }) { const cargoType = form.watch("cargoType"); - const freightType = form.watch("freightType"); + const cargoTypePath = form.watch("cargoTypePath") ?? []; + const parentId = cargoTypePath[0]; + const childId = cargoTypePath[1]; const containers = form.watch("containers"); const { fields, append, remove } = useFieldArray({ @@ -40,32 +52,58 @@ 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(); + return new Map( + referenceData.containers.map((g) => [g.size, g.types.map((t) => t.name)]), ); }, [referenceData]); + useEffect(() => { + if (parentId) { + form.setValue("cargoTypePath", [parentId, ""], { shouldDirty: true }); + } + }, [parentId]); + + const selectedCommodity = useMemo(() => { + if (!referenceData?.cargo_type || !parentId || !childId) return null; + const group = referenceData.cargo_type.find((g) => g.id === parentId); + return group?.children?.find((c) => c.id === childId) ?? null; + }, [referenceData, parentId, childId]); + 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 freightTypeOptions = useMemo( + () => + freightTypeGroups.map((g) => ({ + value: g.id, + label: g.name, + })), + [freightTypeGroups], + ); + const commodityOptions = useMemo(() => { - if (!referenceData?.cargo_type || !freightType) return []; - const group = referenceData.cargo_type.find( - (g) => g.code.toLowerCase() === freightType, + if (!referenceData?.cargo_type || !parentId) return []; + const group = referenceData.cargo_type.find((g) => g.id === parentId); + return ( + group?.children?.map((c) => ({ + value: c.id, + label: c.name, + })) ?? [] ); - return group?.children?.map((c) => c.name) ?? []; - }, [referenceData, freightType]); + }, [referenceData, parentId]); function getOverweightAlert( type: "20ft" | "40ft", vgm: number, ): string | null { if (type === "20ft" && vgm > 0) { - const limit = direction === "export" ? 25 : 20; + const limit = direction === "EXPORT" ? 25 : 20; if (vgm > limit) { return `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`; } @@ -105,7 +143,7 @@ export function Step5CargoDetails({ {/* Cargo Type */}
- Cargo Type * + Cargo Type * { field.onChange("container"); - form.setValue("freightType", "", { shouldDirty: true }); + form.setValue("cargoTypePath", [], { shouldDirty: true }); }} >
@@ -151,7 +189,6 @@ export function Step5CargoDetails({ {/* Weight */}
- Weight - Freight Type * - ( -
-
- {freightTypeGroups.map((group) => { - const val = group.code.toLowerCase(); - return ( - { - field.onChange(val); - form.setValue("bulkCommoditytype", "", { - shouldDirty: true, - }); - }} - > -

{group.name}

-
- ); - })} -
- -
- )} - /> - - {freightType && commodityOptions.length > 0 && ( + {freightTypeOptions.length > 0 ? ( ( + )} + /> + ) : ( + + No freight types available. + + )} + + {parentId && commodityOptions.length > 0 && ( + ( + )} /> )} + + {selectedCommodity?.show_free_text_box && ( + ( + + )} + /> + )}
)} @@ -254,7 +297,13 @@ export function Step5CargoDetails({ className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-gray-50/50 p-4" >
- + Container {index + 1} {fields.length > 1 && ( @@ -282,7 +331,7 @@ export function Step5CargoDetails({ val: "20ft" as const, label: "20ft Container (TEU)", limit: - direction === "export" + direction === "EXPORT" ? "Max 25t per container" : "Max 20t per container", }, @@ -301,7 +350,9 @@ export function Step5CargoDetails({

{ct.label}

-

{ct.limit}

+

+ {ct.limit} +

))}
@@ -325,7 +376,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" @@ -334,7 +388,9 @@ export function Step5CargoDetails({ qtyField.onChange(e.target.value)} + onChange={(e) => + qtyField.onChange(e.target.value) + } onBlur={qtyField.onBlur} type="number" min={1} @@ -389,7 +445,11 @@ export function Step5CargoDetails({ error={fieldState.error} label="Container Type *" placeholder="Select type..." - data={containerTypeOptions} + data={ + containerTypeOptionsBySize.get( + containers[index]?.type ?? "20ft", + ) ?? [] + } /> )} /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx deleted file mode 100644 index c58b34241..000000000 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx +++ /dev/null @@ -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; - -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 ( -
- - - {!wagons ? ( - - Complete the container configuration in the previous step to see wagon - allocation. - - ) : ( - <> -
-
-

- {wagons.totalWagons} -

-

- Wagons Required -

-
-
-

{totalContainers}

-

- {containerSummary || "Containers"} -

-
-
-

{wagons.sharedWagons}

-

Shared Slots

-
-
- -
- Wagon Layout -
- {new Array(wagons.ft40Wagons).fill(0).map((_, index) => ( -
- 1 × 40ft -
- ))} - {new Array(wagons.sharedWagons).fill(0).map((_, index) => ( -
- 2 × 20ft -
- ))} - {wagons.hasOddUnit && ( -
- 1 × 20ft -
- )} -
-
- - {wagons.hasOddUnit && ( - <> - -
-
-

Unpaired 20ft Container

-

- One 20ft container occupies only half a wagon. The wagon - will depart once a co-loader is found to fill the - remaining slot, which may delay departure{" "} - beyond the standard lead time. -

-
-
-
- - )} - - )} -
- ); -} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index b547a47c9..53e809035 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -5,9 +5,9 @@ import { BOOKING_DOCS_SETTING, type BookingDocuments, type BookingFormValues, - type RouteDirection, } from "./schema"; import { StepHeader } from "./shared"; +import type { Freight } from "@/types"; type BookingForm = UseFormReturn; @@ -18,7 +18,7 @@ export function Step8Review({ }: { form: BookingForm; setStep: (step: number) => void; - direction: RouteDirection; + direction: Freight.ScheduleTradeDirection; }) { const values = form.watch(); const errors = form.formState.errors; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.tsx index 9f1445f91..2532da237 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.tsx @@ -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"; diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 645dc7f50..e1bd57729 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -185,6 +185,13 @@ export const api = { "checkPayment", ({ orderId }) => bookingsService.checkPayment(orderId), ), + + getBookableSchedules: endpoint< + { originYardId?: string; destinationYardId?: string }, + Freight.BookableScheduleItem[] + >("train-scheduling", "bookableSchedules", ({ originYardId, destinationYardId }) => + bookingsService.getBookableSchedules({ originYardId, destinationYardId }), + ), }, consignments: { diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index a8d36619d..f723e2730 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -151,4 +151,14 @@ export const bookingsService = { const { data } = await client.post(B.CONTRACT_SIGN(id), payload); return data.data ?? data; }, + + getBookableSchedules: async ( + query: Freight.BookableSchedulesQuery = {}, + ): Promise => { + const { data } = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.BOOKABLE_SCHEDULES, + { params: query }, + ); + return data.data; + }, }; diff --git a/apps/edr-freight-web/portal/src/theme/mantine.ts b/apps/edr-freight-web/portal/src/theme/mantine.ts index 865f6e29f..65a751db8 100644 --- a/apps/edr-freight-web/portal/src/theme/mantine.ts +++ b/apps/edr-freight-web/portal/src/theme/mantine.ts @@ -91,10 +91,10 @@ export const mantineTheme = createTheme({ fontSizes: { xs: "12px", - sm: "13px", - md: "14px", - lg: "16px", - xl: "18px", + sm: "14px", + md: "16px", + lg: "20px", + xl: "24px", }, lineHeights: { diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index e1620b41f..6672f95e9 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -1,20 +1,20 @@ import type { BaseEntity } from "../common"; -export * from "./file_upload_settings"; export * from "./dropdown_settings"; +export * from "./file_upload_settings"; export * from "./overview"; export enum TradeDirection { - IMPORT = 'IMPORT', - EXPORT = 'EXPORT', - BOTH = 'BOTH', + IMPORT = "IMPORT", + EXPORT = "EXPORT", + BOTH = "BOTH", } export enum PriorityType { - USD_PAYER = 'USD_PAYER', - RAIL_AND_FORWARDING = 'RAIL_AND_FORWARDING', - GOVERNMENT_ACCOUNT = 'GOVERNMENT_ACCOUNT', - HIGH_VOLUME_SHIPMENT = 'HIGH_VOLUME_SHIPMENT', + USD_PAYER = "USD_PAYER", + RAIL_AND_FORWARDING = "RAIL_AND_FORWARDING", + GOVERNMENT_ACCOUNT = "GOVERNMENT_ACCOUNT", + HIGH_VOLUME_SHIPMENT = "HIGH_VOLUME_SHIPMENT", } /** Bonus applied to government bookings so they outrank commercial priority. */ @@ -26,19 +26,19 @@ export interface GovernmentBookingFields { } export enum ExceededAction { - WARNING_ONLY = 'WARNING_ONLY', - HARD_BLOCK = 'HARD_BLOCK', + WARNING_ONLY = "WARNING_ONLY", + HARD_BLOCK = "HARD_BLOCK", } export enum CalculationMethod { - PER_TON = 'PER_TON', - FLAT_FEE = 'FLAT_FEE', - PERCENTAGE = 'PERCENTAGE', + PER_TON = "PER_TON", + FLAT_FEE = "FLAT_FEE", + PERCENTAGE = "PERCENTAGE", } export enum FreightType { - Container = 'CONTAINER', - Bulk = 'BULK', + Container = "CONTAINER", + Bulk = "BULK", } export enum BookingStatus { @@ -282,6 +282,9 @@ export interface IBooking extends BaseEntity { totalAmount: number; paymentStatus: PaymentStatus; + shippingLineId?: string | null; + serviceTypeId: string; + contractType: "NEW" | "RENEWAL"; previousContractId?: string | null; serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING"; @@ -311,6 +314,11 @@ export interface IBooking extends BaseEntity { endDate?: string | null; financialTerms?: string | null; + /** When the batch engine picked this booking and opened the pay window. */ + selectedForBatchAt?: string | null; + /** End of the pay window once the booking is SELECTED_FOR_BATCH. */ + paymentDeadline?: string | null; + containers?: Array<{ type: string; qty: number; vgm: number }> | null; versionNumber: number; @@ -390,6 +398,18 @@ export interface BookingReferenceService { id: string; name: string; code: string; + serviceName: string; + description?: string | null | undefined; + canBeBookedAlone: boolean; + includesFirstMile: boolean; + includesLastMile: boolean; + includesCustoms: boolean; + priorityBonusPoints: number; + isActive: boolean; + displayOrder: number; + createdAt: string; + updatedAt: string; + deletedAt?: string | null | undefined; } export interface BookingReferenceShippingLine { @@ -420,6 +440,39 @@ export interface BookingReferenceData { cargo_type: BookingReferenceCargoTypeGroup[]; } +// ── Train Scheduling (bookable schedules) ────────────────────────────────────── + +export interface BookableSchedulesQuery { + originYardId?: string; + destinationYardId?: string; +} + +export interface BookableScheduleLocomotive { + id: string; + code: string; + name: string | null; + readiness: string | null; +} + +export interface BookableScheduleItem { + id: string; + scheduleDate: string; + trainNumber: string | null; + routeName: string | null; + origin: string | null; + destination: string | null; + locomotive: BookableScheduleLocomotive | null; + wagonCount: number; + totalWeightTons: number; + totalLengthMeters: number; + bookingsCount: number; + freightType: FreightType | "MIXED" | null; + status: TrainScheduleStatus; + bookingWindowStatus: ScheduleBookingWindow; + maxWagons: number; + remainingWagons: number; +} + // ── DTOs ─────────────────────────────────────────────────────────────────────── export interface CreateBookingContainerDto { @@ -429,31 +482,34 @@ export interface CreateBookingContainerDto { } export interface CreateBookingDto { - reference?: string; - customerId?: string; - companyId?: string; - trainId?: string; + freightShapeValidation?: boolean | undefined; + reference?: string | undefined; + isGovernment?: boolean | undefined; + governmentInstitution?: string | undefined; + companyId?: string | undefined; + trainId?: string | undefined; + trainScheduleId?: string | undefined; scheduledDate: string; - contractType: "NEW" | "RENEWAL"; - previousContractId?: string; + contractType: string; + previousContractId?: string | undefined; serviceTypeId: string; - firstMilePickupAddress?: string; - lastMileDeliveryAddress?: string; - equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN" | "NA"; + firstMilePickupAddress?: string | undefined; + lastMileDeliveryAddress?: string | undefined; + equipmentReturn: string; originYardId: string; destinationYardId: string; - tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC"; - freightType: FreightType; - cargoTypeId?: string; - cargoFreeText?: string; - shippingLineId?: string; + tradeDirection: string; + freightType: string; + cargoTypeId?: string | undefined; + cargoFreeText?: string | undefined; + shippingLineId?: string | undefined; cargoTotalWeightVgm: number; - isHazardous?: boolean; - paymentCurrency: "ETB" | "USD"; - pnrCode?: string; - startDate?: string; - endDate?: string; - financialTerms?: string; + isHazardous?: boolean | undefined; + paymentCurrency: string; + pnrCode?: string | undefined; + startDate?: string | undefined; + endDate?: string | undefined; + financialTerms?: string | undefined; containers?: CreateBookingContainerDto[]; allowConsolidation?: boolean; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3a1423a9..c100f5c77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -368,6 +368,9 @@ importers: '@edr/tsconfig': specifier: workspace:* version: link:../../../packages/config/tsconfig + '@hookform/devtools': + specifier: ^4.4.0 + version: 4.4.0(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tailwindcss/vite': specifier: ^4.3.0 version: 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) @@ -1638,6 +1641,12 @@ packages: peerDependencies: hono: ^4 + '@hookform/devtools@4.4.0': + resolution: {integrity: sha512-Mtlic+uigoYBPXlfvPBfiYYUZuyMrD3pTjDpVIhL6eCZTvQkHsKBSKeZCvXWUZr8fqrkzDg27N+ZuazLKq6Vmg==} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + '@hookform/resolvers@3.10.0': resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} peerDependencies: @@ -8830,6 +8839,11 @@ packages: resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} engines: {node: '>=18.0.0'} + little-state-machine@4.8.1: + resolution: {integrity: sha512-liPHqaWMQ7rzZryQUDnbZ1Gclnnai3dIyaJ0nAgwZRXMzqbYrydrlCI0NDojRUbE5VYh5vu6hygEUZiH77nQkQ==} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + load-esm@1.0.3: resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} engines: {node: '>=13.2.0'} @@ -10556,6 +10570,11 @@ packages: '@types/react': optional: true + react-simple-animate@3.5.3: + resolution: {integrity: sha512-Ob+SmB5J1tXDEZyOe2Hf950K4M8VaWBBmQ3cS2BUnTORqHjhK0iKG8fB+bo47ZL15t8d3g/Y0roiqH05UBjG7A==} + peerDependencies: + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + react-smooth@4.0.4: resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} peerDependencies: @@ -12120,6 +12139,12 @@ packages: '@types/react': optional: true + use-deep-compare-effect@1.8.1: + resolution: {integrity: sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13' + use-isomorphic-layout-effect@1.2.1: resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} peerDependencies: @@ -13571,6 +13596,22 @@ snapshots: dependencies: hono: 4.12.23 + '@hookform/devtools@4.4.0(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) + '@types/lodash': 4.17.24 + little-state-machine: 4.8.1(react@19.2.6) + lodash: 4.18.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-simple-animate: 3.5.3(react-dom@19.2.6(react@19.2.6)) + use-deep-compare-effect: 1.8.1(react@19.2.6) + uuid: 8.3.2 + transitivePeerDependencies: + - '@types/react' + - supports-color + '@hookform/resolvers@3.10.0(react-hook-form@7.77.0(react@18.3.1))': dependencies: react-hook-form: 7.77.0(react@18.3.1) @@ -23106,6 +23147,10 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + little-state-machine@4.8.1(react@19.2.6): + dependencies: + react: 19.2.6 + load-esm@1.0.3: {} load-json-file@1.1.0: @@ -25095,6 +25140,10 @@ snapshots: '@types/prop-types': 15.7.15 '@types/react': 18.3.31 + react-simple-animate@3.5.3(react-dom@19.2.6(react@19.2.6)): + dependencies: + react-dom: 19.2.6(react@19.2.6) + react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: fast-equals: 5.4.0