feat(bookings): add estimated shipment date handling and validation for binding shipment day

This commit is contained in:
Marshal
2026-06-25 00:28:54 +00:00
parent 6ab9699c94
commit 08977fcd19
11 changed files with 452 additions and 27 deletions

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS estimated_shipment_date timestamptz NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS estimated_shipment_date;
`);
}
}

View File

@@ -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,

View File

@@ -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<boolean> {
return this.trainSchedulingService.existsOpenScheduleOnRouteDay(
originYardId,
destinationYardId,
day,
);
}
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,

View File

@@ -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;

View File

@@ -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

View File

@@ -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<Array<{ name: string; file: File | null }>>(
[],
);
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
const [scheduledDate, setScheduledDate] = useState<string>("");
const refresh = () => {
queryClient.invalidateQueries({
@@ -339,6 +356,32 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
</Alert>
)}
{isReady && (
<Box mt="lg">
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Choose your shipment day
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Only days with a scheduled departure on your route can be selected.
The operations team assigns the specific train for that day.
</Text>
<OperationDatePicker
originYardId={booking.originYard?.id}
destinationYardId={booking.destinationYard?.id}
value={scheduledDate}
onChange={setScheduledDate}
/>
</Box>
)}
{proceedMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{proceedMutation.error instanceof Error
? proceedMutation.error.message
: "Could not request the operation. Please try again."}
</Alert>
)}
<Group justify="flex-end" mt="lg" gap="sm">
{canUpload && (
<Button
@@ -362,11 +405,12 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
leftSection={<CheckCircle2 size={16} />}
onClick={() =>
proceedMutation.mutate(
{ id: booking.id },
{ id: booking.id, scheduledDate },
{ onSuccess: () => navigate(`/bookings/${booking.id}`) },
)
}
loading={proceedMutation.isPending}
disabled={!scheduledDate}
>
Proceed to operation
</Button>
@@ -375,3 +419,202 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
</SectionCard>
);
}
/**
* 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 (
<Box
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
padding: 14,
maxWidth: 340,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, -1))}
>
<ChevronLeft size={15} />
</Button>
<Text fz="13px" fw={700} c="#10202F">
{format(month, "MMMM yyyy")}
</Text>
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, 1))}
>
<ChevronRight size={15} />
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="md" gap={8}>
<CalendarIcon size={15} color="#9AA8B5" />
<Text fz="12px" c="dimmed">
Loading available days
</Text>
</Group>
) : (
<>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
marginBottom: 6,
}}
>
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
<Text
key={i}
ta="center"
fz="10px"
fw={700}
c="#9AA8B5"
>
{d}
</Text>
))}
</Box>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
}}
>
{cells.map((c) => {
const clickable = c.hasDeparture && c.inMonth;
return (
<button
key={c.dateString}
type="button"
disabled={!clickable}
onClick={() => clickable && onChange(c.dateString)}
style={{
position: "relative",
height: 34,
borderRadius: 8,
fontSize: 12.5,
fontWeight: c.selected ? 800 : 600,
cursor: clickable ? "pointer" : "default",
border: c.selected
? "1.5px solid #12B981"
: clickable
? "1px solid #CDEBDD"
: "1px solid transparent",
background: c.selected
? "#12B981"
: clickable
? "#F4FBF7"
: "transparent",
color: c.selected
? "#fff"
: !c.inMonth
? "#CBD5E1"
: clickable
? "#0A6F4D"
: "#C4CDD6",
transition: "all 120ms ease",
}}
>
{c.day}
{c.hasDeparture && c.inMonth && !c.selected && (
<span
style={{
position: "absolute",
bottom: 4,
left: "50%",
transform: "translateX(-50%)",
width: 4,
height: 4,
borderRadius: "50%",
background: "#12B981",
}}
/>
)}
{c.selected && (
<Check
size={11}
color="#fff"
strokeWidth={3}
style={{
position: "absolute",
bottom: 3,
left: "50%",
transform: "translateX(-50%)",
}}
/>
)}
</button>
);
})}
</Box>
{value && (
<Text fz="12px" c="#0A6F4D" fw={600} mt="sm">
Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
)}
{!isLoading && departureDays.size === 0 && (
<Text fz="12px" c="orange.7" mt="sm">
No scheduled departures found for this route yet.
</Text>
)}
</>
)}
</Box>
);
}

View File

@@ -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"],

View File

@@ -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 }) => (
<div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-2">
{referenceData?.service
.filter((s) => s.canBeBookedAlone)
.map((s) => (
<OptionCard
<ServiceTypeCard
key={s.id}
selected={field.value === s.id}
onClick={() => field.onChange(s.id)}
icon={<Train className="h-5 w-5" />}
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 (
<button
type="button"
onClick={onClick}
style={{
width: "100%",
textAlign: "left",
cursor: "pointer",
borderRadius: 12,
padding: "12px 14px",
transition: "all 140ms ease",
border: `1.5px solid ${selected ? "#12B981" : "#E6ECF2"}`,
background: selected ? "#F4FBF7" : "#fff",
boxShadow: selected
? "0 0 0 1px #12B981, 0 4px 12px rgba(14,163,113,0.10)"
: "0 1px 2px rgba(16,24,40,0.04)",
}}
onMouseEnter={(e) => {
if (!selected) e.currentTarget.style.borderColor = "#BFE3D2";
}}
onMouseLeave={(e) => {
if (!selected) e.currentTarget.style.borderColor = "#E6ECF2";
}}
>
<Group gap={11} align="center" wrap="nowrap">
<Box
style={{
width: 34,
height: 34,
flexShrink: 0,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: selected ? "#E3F4EC" : "#EEF0FB",
color: selected ? "#0A6F4D" : "#4F46E5",
}}
>
<Train size={17} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz={13.5} fw={700} c="#10202F" truncate>
{title}
</Text>
{description && (
<Text fz={11.5} c="#6B7C8E" truncate style={{ lineHeight: 1.35 }}>
{description}
</Text>
)}
</Box>
<Box
style={{
width: 18,
height: 18,
flexShrink: 0,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: selected ? "none" : "1.5px solid #CBD5E1",
background: selected ? "#12B981" : "transparent",
}}
>
{selected && <Check size={11} color="#fff" strokeWidth={3} />}
</Box>
</Group>
</button>
);
}
function ServiceToggle({
icon,
title,

View File

@@ -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 }>(

View File

@@ -206,8 +206,14 @@ export const bookingsService = {
return data.data;
},
proceedToOperation: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`);
proceedToOperation: async (
id: string,
scheduledDate: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/proceed`,
{ scheduledDate },
);
return data.data;
},