From 08977fcd19783576ee45f34f006e82dd0a290f16 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 00:28:54 +0000 Subject: [PATCH] feat(bookings): add estimated shipment date handling and validation for binding shipment day --- .../1820000000012-AddEstimatedShipmentDate.ts | 26 ++ .../bookings/booking-transition.service.ts | 14 + .../src/modules/bookings/bookings.service.ts | 36 ++- .../bookings/dto/create-booking.dto.ts | 16 +- .../bookings/entities/booking.entity.ts | 13 + .../components/ClearanceCard.tsx | 245 +++++++++++++++++- .../src/pages/bookings/NewBookingPage.tsx | 11 +- .../new-booking-form/step2-service-type.tsx | 96 ++++++- .../portal/src/services/api.ts | 8 +- .../portal/src/services/bookings.service.ts | 10 +- packages/types/src/freight/index.ts | 4 +- 11 files changed, 452 insertions(+), 27 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts diff --git a/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts b/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts new file mode 100644 index 000000000..6b77f53a3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * The booking wizard now captures a NON-BINDING estimated shipment date instead + * of the binding scheduledDate. The binding scheduledDate (validated against + * open train departures) is set later, at the operation-request step. + */ +export class AddEstimatedShipmentDate1820000000012 + implements MigrationInterface +{ + name = 'AddEstimatedShipmentDate1820000000012'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS estimated_shipment_date timestamptz NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS estimated_shipment_date; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 2c2e0ce0e..b85f55206 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -795,6 +795,20 @@ export class BookingTransitionService { throw new BadRequestException('A valid schedule date is required'); } + // The binding shipment day must have at least one OPEN departure on the + // route — only schedule-backed days are selectable. The batch engine + // assigns the specific train within that (route, day) pool later. + const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay( + booking.originYardId, + booking.destinationYardId, + eatDay(date), + ); + if (!hasDeparture) { + throw new BadRequestException( + 'No departures available on the selected day for this route', + ); + } + await this.bookingsRepository.update(bookingId, { status: 'OPERATION_REQUEST_PENDING', scheduledDate: date, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 486e7eae3..699d6ac22 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -317,12 +317,14 @@ export class BookingsService { ) { throw new BadRequestException('Selected schedule is not on the booking route'); } - } else if (!isGeneralContract) { - // Day-level pool: the customer picked a DAY — require that the route has at - // least one OPEN departure on that EAT day. The batch engine assigns the - // train later. General contracts skip this — they have no shipment date at - // creation; each drawdown order validates its own day. - const day = eatDay(new Date(dto.scheduledDate!)); + } else if (dto.scheduledDate) { + // A real (binding) scheduledDate was supplied (e.g. staff pinning a day + // directly). Require that the route has at least one OPEN departure on + // that EAT day. The booking wizard does NOT send scheduledDate at creation + // — it captures a non-binding estimatedShipmentDate instead, and the + // binding day is chosen later at the operation-request step. General + // contracts also skip this (each drawdown order validates its own day). + const day = eatDay(new Date(dto.scheduledDate)); const hasDeparture = await this.trainSchedulingService.existsOpenScheduleOnRouteDay( dto.originYardId, @@ -428,6 +430,9 @@ export class BookingsService { financialTerms: dto.financialTerms, bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME', scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, + estimatedShipmentDate: dto.estimatedShipmentDate + ? new Date(dto.estimatedShipmentDate) + : null, startDate: dto.startDate ? new Date(dto.startDate) : undefined, endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: 'DRAFT', @@ -621,6 +626,8 @@ export class BookingsService { ); } if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); + if (dto.estimatedShipmentDate) + updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); if (dto.startDate) updates.startDate = new Date(dto.startDate); if (dto.endDate) updates.endDate = new Date(dto.endDate); delete updates.containers; @@ -696,6 +703,23 @@ export class BookingsService { } /** Return a paginated list of bookings matching the filter. */ + /** + * Whether a route has at least one OPEN train departure on the given EAT day. + * Used to validate the binding shipment day chosen at the operation-request + * step (only days with a schedule are selectable). + */ + async hasOpenDepartureOnDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + return this.trainSchedulingService.existsOpenScheduleOnRouteDay( + originYardId, + destinationYardId, + day, + ); + } + async findAll( filter: FilterBookingDto, forceCompanyId?: string, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index e66c85522..7f420d3f1 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -155,14 +155,24 @@ export class CreateBookingDto { bookingType?: string; /** - * The day the customer wants to ship (the pool day key). Required for one-time - * bookings; omitted for general contracts, which pick the date per order. + * The BINDING shipment day (the pool day key), validated against open train + * departures. Set later at the operation-request step — NOT at booking + * creation. Optional here; staff may still pin it directly. */ @ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' }) - @ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT') + @IsOptional() @IsDateString() scheduledDate?: string; + /** + * Non-binding shipment-date estimate captured in the booking wizard. Purely + * informational — NOT validated against train departures. + */ + @ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' }) + @IsOptional() + @IsDateString() + estimatedShipmentDate?: string; + @ApiProperty({ enum: CONTRACT_TYPES }) @IsIn([...CONTRACT_TYPES]) contractType!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 5693cfaf5..00f6e41c1 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -155,10 +155,23 @@ export class Booking extends BaseEntity { /** * Nullable: general contracts have no shipment date at creation — the date is * chosen per drawdown order. One-time bookings always set this (the pool day key). + * + * NOTE: this is the BINDING shipment day, validated against actual open train + * departures. It is set later, when the customer requests the operation — NOT + * at booking creation. See estimatedShipmentDate for the non-binding estimate + * captured in the booking wizard. */ @Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true }) scheduledDate?: Date | null; + /** + * Non-binding shipment-date estimate captured in the booking wizard. Purely + * informational — NOT validated against train departures. The binding + * scheduledDate is chosen later at the operation-request step. + */ + @Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true }) + estimatedShipmentDate?: Date | null; + /** * General contracts only: when the ordering window closes, computed from the * global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx index 59ab71ff9..e54b03be1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -9,9 +9,24 @@ import { TextInput, } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + addMonths, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + isSameMonth, + isToday, + startOfMonth, + startOfWeek, +} from "date-fns"; import { AlertCircle, + Calendar as CalendarIcon, + Check, CheckCircle2, + ChevronLeft, + ChevronRight, Clock, Download, FileText, @@ -86,6 +101,8 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { const [adHoc, setAdHoc] = useState>( [], ); + // Binding shipment day chosen for the operation request (yyyy-MM-dd). + const [scheduledDate, setScheduledDate] = useState(""); const refresh = () => { queryClient.invalidateQueries({ @@ -339,6 +356,32 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { )} + {isReady && ( + + + Choose your shipment day + + + Only days with a scheduled departure on your route can be selected. + The operations team assigns the specific train for that day. + + + + )} + + {proceedMutation.isError && ( + } mt="md"> + {proceedMutation.error instanceof Error + ? proceedMutation.error.message + : "Could not request the operation. Please try again."} + + )} + {canUpload && ( @@ -375,3 +419,202 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { ); } + +/** + * Compact month calendar for picking the binding shipment day at the + * operation-request step. Only days that have an OPEN scheduled departure on the + * booking route are selectable; all other days are disabled. + */ +function OperationDatePicker({ + originYardId, + destinationYardId, + value, + onChange, +}: { + originYardId?: string; + destinationYardId?: string; + value: string; + onChange: (date: string) => void; +}) { + const [month, setMonth] = useState(() => startOfMonth(new Date())); + + const { data: availableDays, isLoading } = useQuery( + api.bookings.getAvailableDays.queryOptions({ + input: { originYardId, destinationYardId }, + enabled: !!originYardId && !!destinationYardId, + }), + ); + + const departureDays = useMemo( + () => new Set(availableDays ?? []), + [availableDays], + ); + + const cells = useMemo(() => { + const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 }); + const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 }); + return eachDayOfInterval({ start, end }).map((date) => { + const dateString = format(date, "yyyy-MM-dd"); + return { + date, + dateString, + day: date.getDate(), + inMonth: isSameMonth(date, month), + today: isToday(date), + selected: value === dateString, + hasDeparture: departureDays.has(dateString), + }; + }); + }, [month, departureDays, value]); + + return ( + + + + + {format(month, "MMMM yyyy")} + + + + + {isLoading ? ( + + + + Loading available days… + + + ) : ( + <> + + {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => ( + + {d} + + ))} + + + {cells.map((c) => { + const clickable = c.hasDeparture && c.inMonth; + return ( + + ); + })} + + {value && ( + + Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")} + + )} + {!isLoading && departureDays.size === 0 && ( + + No scheduled departures found for this route yet. + + )} + + )} + + ); +} 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 53fff0f1b..9ce4f4194 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -422,13 +422,14 @@ export default function NewBookingPage() { bookingType: isContract ? Freight.BookingType.GeneralContract : Freight.BookingType.OneTime, - // General contracts omit the shipment date — chosen per order later. - ...(isContract + // The wizard captures a NON-BINDING estimate only — never the binding + // scheduledDate (that is chosen later at the operation-request step and + // validated against open departures). General contracts omit even the + // estimate; the date is chosen per order later. + ...(isContract || !data.scheduledDate ? {} : { - scheduledDate: data.scheduledDate - ? new Date(data.scheduledDate).toISOString() - : new Date().toISOString(), + estimatedShipmentDate: new Date(data.scheduledDate).toISOString(), }), contractType: data.contractType.toUpperCase() as CreateBookingPayload["contractType"], 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 d11d09415..2b72393f6 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,12 +1,11 @@ import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core"; import type { ReactNode } from "react"; -import { FileText, Info, Layers, Train, Truck } from "lucide-react"; +import { Check, FileText, Info, Layers, Train, Truck } from "lucide-react"; import { useEffect, useRef } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; import { BookingFormInputValues, type BookingFormValues } from "./schema"; import { fieldStyles, - OptionCard, OptionFieldError, StepCard, StepHeader, @@ -88,17 +87,14 @@ export function Step2ServiceType({ control={form.control} render={({ field, fieldState }) => (
-
+
{referenceData?.service .filter((s) => s.canBeBookedAlone) .map((s) => ( - field.onChange(s.id)} - icon={} - iconBg="#EEF0FB" - iconColor="#4F46E5" title={s.serviceName} description={s.description} /> @@ -365,6 +361,92 @@ export function Step2ServiceType({ ); } +/** + * Compact service-type selection card. A single horizontal row (icon · text · + * radio) — deliberately smaller than the shared OptionCard so the service list + * stays scannable. + */ +function ServiceTypeCard({ + selected, + onClick, + title, + description, +}: { + selected: boolean; + onClick: () => void; + title?: ReactNode; + description?: ReactNode; +}) { + return ( + + ); +} + function ServiceToggle({ icon, title, diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index a8f76a1bf..6ba3aa84b 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -269,10 +269,14 @@ export const api = { bookingsService.submitClearanceDocuments(id, files), ), - proceedToOperation: endpoint<{ id: string }, Freight.IBooking>( + proceedToOperation: endpoint< + { id: string; scheduledDate: string }, + Freight.IBooking + >( "bookings", "proceedToOperation", - ({ id }) => bookingsService.proceedToOperation(id), + ({ id, scheduledDate }) => + bookingsService.proceedToOperation(id, scheduledDate), ), checkPayment: endpoint<{ orderId: string }, { status: string }>( 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 2cebf6fea..6a8c76795 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -206,8 +206,14 @@ export const bookingsService = { return data.data; }, - proceedToOperation: async (id: string): Promise => { - const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`); + proceedToOperation: async ( + id: string, + scheduledDate: string, + ): Promise => { + const { data } = await client.post( + `/api/bookings/${id}/clearance/proceed`, + { scheduledDate }, + ); return data.data; }, diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index adb1e85a5..f9b5ac92f 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -646,8 +646,10 @@ export interface CreateBookingDto { companyId?: string | undefined; trainId?: string | undefined; trainScheduleId?: string | undefined; - /** Optional for general contracts — they pick the date per order, not at creation. */ + /** Binding shipment day — set at the operation-request step, not at creation. */ scheduledDate?: string | undefined; + /** Non-binding shipment-date estimate captured in the booking wizard. */ + estimatedShipmentDate?: string | undefined; /** Defaults to ONE_TIME. GENERAL_CONTRACT creates an umbrella contract. */ bookingType?: BookingType | undefined; contractType: string;