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

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