Add contract booking windows feature

This commit is contained in:
Marshal
2026-07-03 15:01:16 +00:00
parent c977deb460
commit 7eae1a920e
18 changed files with 637 additions and 80 deletions

View File

@@ -58,6 +58,25 @@ import {
StepLabel,
} from "./gl-booking-form/form-ui";
/** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa";
function fmtWindowOpensAt(iso: string): string {
const date = new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: EAT_TZ,
});
const time = new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: EAT_TZ,
});
return `${date} · ${time}`;
}
interface UnitDraft {
containerNumber: string;
sealNumber: string;
@@ -106,6 +125,33 @@ export default function GlCreateBookingForm() {
enabled: Boolean(requestId),
});
// Same window-gating the customer sees: GL may only create a booking while a
// booking window is OPEN for one of the contract's routes.
const contractId = contract?.id ?? id;
const { data: bookingWindows, isLoading: windowsLoading } = useQuery({
...api.trainScheduling.contractBookingWindows.queryOptions({
input: { contractId: contractId ?? "" },
}),
enabled: Boolean(contractId),
});
const windowOpen = useMemo(
() => (bookingWindows ?? []).some((w) => w.isOpenNow),
[bookingWindows],
);
// Soonest future window across all routes, used for the "next window" notice.
const nextWindow = useMemo(() => {
const now = Date.now();
return (bookingWindows ?? [])
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() -
new Date(b.windowOpensAt!).getTime(),
)[0];
}, [bookingWindows]);
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
@@ -314,12 +360,13 @@ export default function GlCreateBookingForm() {
);
const canSubmit =
windowOpen &&
Boolean(scheduledDate) &&
(!needsRouteSelect || Boolean(contractRouteId)) &&
(isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0);
const handleSubmit = () => {
if (!scheduledDate || !contract) return;
if (!scheduledDate || !contract || !windowOpen) return;
const payload: Freight.CreateBookingUnderContractDto = {
scheduledDate,
@@ -451,6 +498,33 @@ export default function GlCreateBookingForm() {
</Alert>
) : null}
{!windowsLoading && !windowOpen ? (
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Booking window is closed"
mb="lg"
>
GL can create a booking only while a window is open.{" "}
{nextWindow?.windowOpensAt ? (
<>
Next window: <b>{fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT</b>{" "}
for{" "}
<b>
{nextWindow.origin ?? "Origin"} {nextWindow.destination ?? "Destination"}
</b>
.
</>
) : (
<>No upcoming booking window scheduled.</>
)}
</Alert>
) : null}
{windowsLoading || windowOpen ? (
<>
<Stack gap="lg" maw={896} mx="auto">
<StepCard>
<StepHeader
@@ -846,6 +920,8 @@ export default function GlCreateBookingForm() {
</Stack>
) : null}
</Modal>
</>
) : null}
</PageContainer>
);
}

View File

@@ -286,6 +286,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
BOOKING_WINDOW: (id: string) =>
`/train-scheduling/schedules/${id}/booking-window`,
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
`/train-scheduling/contracts/${contractId}/booking-windows`,
MARK_BOOKING_PAID: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/mark-paid`,
EXPIRE_BOOKING: (bookingId: string) =>

View File

@@ -135,9 +135,11 @@ export default function CustomerDetailPage() {
}),
);
const bookings = bookingsQuery.data ?? [];
const documents = documentsQuery.data ?? [];
const payments = paymentsQuery.data ?? [];
const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : [];
const documents = Array.isArray(documentsQuery.data)
? documentsQuery.data
: [];
const payments = Array.isArray(paymentsQuery.data) ? paymentsQuery.data : [];
const invoices = invoicesQuery.data?.items ?? [];
const invoiceTotal = invoicesQuery.data?.total ?? 0;
const invoicePageCount = Math.max(

View File

@@ -29,22 +29,40 @@ export default function TrainSchedulingGlobalRulesPage() {
}, [toast]);
const handleSave = async () => {
// Every field must hold a real number — an empty box (cleared but not
// refilled) must not silently save as 0. Collect the numeric payload and
// reject if any value is blank or NaN.
const fields: (keyof TrainSchedulingGlobalRules)[] = [
"maxTrainLengthMeters",
"maxTrainWeightTons",
"maxWagonsPerTrain",
"max20ftContainerWeightTons",
"max20ftPairWeightDiffTons",
"importWindowLeadDays",
"exportBookingLeadHours",
"windowOpenHour",
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
"reopenDelayMinutes",
];
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
for (const key of fields) {
const raw = form[key];
const num = raw === "" || raw == null ? NaN : Number(raw);
if (!Number.isFinite(num)) {
toast({
title: "All fields are required — fill every value before saving.",
variant: "destructive",
});
return;
}
payload[key] = num;
}
setSaving(true);
try {
const updated = await trainSchedulingService.updateGlobalRules({
maxTrainLengthMeters: Number(form.maxTrainLengthMeters),
maxTrainWeightTons: Number(form.maxTrainWeightTons),
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
importWindowLeadDays: Number(form.importWindowLeadDays),
exportBookingLeadHours: Number(form.exportBookingLeadHours),
windowOpenHour: Number(form.windowOpenHour),
windowDurationHours: Number(form.windowDurationHours),
docReviewMinutes: Number(form.docReviewMinutes),
paymentWindowMinutes: Number(form.paymentWindowMinutes),
reopenDelayMinutes: Number(form.reopenDelayMinutes),
});
const updated = await trainSchedulingService.updateGlobalRules(payload);
setForm(updated);
toast({ title: "Train scheduling rules saved" });
} catch {
@@ -71,6 +89,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
}
min={1}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -81,6 +100,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
}
min={1}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -90,6 +110,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
}
min={1}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -103,6 +124,7 @@ export default function TrainSchedulingGlobalRulesPage() {
}))
}
min={0.001}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -116,6 +138,7 @@ export default function TrainSchedulingGlobalRulesPage() {
}))
}
min={0}
clampBehavior="strict"
disabled={loading}
/>
</Stack>
@@ -135,6 +158,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, importWindowLeadDays: value }))
}
min={0}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -145,6 +169,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
}
min={1}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -156,6 +181,7 @@ export default function TrainSchedulingGlobalRulesPage() {
}
min={0}
max={23}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -167,6 +193,7 @@ export default function TrainSchedulingGlobalRulesPage() {
min={0.25}
max={12}
step={0.25}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -177,6 +204,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, docReviewMinutes: value }))
}
min={0}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -187,6 +215,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
}
min={1}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -197,6 +226,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
}
min={1}
clampBehavior="strict"
disabled={loading}
/>
<Group justify="flex-end">

View File

@@ -44,6 +44,7 @@ import type {
BatchBoardSchedule,
BatchBoardScheduleDetail,
BookableSchedule,
BookingWindow,
CompositionRemovalEntry,
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
@@ -281,6 +282,18 @@ export const api = {
],
),
contractBookingWindows: endpoint<{ contractId: string }, BookingWindow[]>(
"train-scheduling",
"contract-booking-windows",
({ contractId }) =>
trainSchedulingService.getContractBookingWindows(contractId),
({ contractId }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"contract-booking-windows",
contractId,
],
),
availableDays: endpoint<
{ originYardId?: string | null; destinationYardId?: string | null },
string[]

View File

@@ -6,6 +6,7 @@ import type {
BatchBoardSchedule,
BatchBoardScheduleDetail,
BookableSchedule,
BookingWindow,
AssignBookingsPayload,
CompositionRemovalEntry,
UnassignedBookingsResponse,
@@ -106,6 +107,19 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/**
* Booking windows for every route/schedule of a contract. A window with
* `isOpenNow === true` means GL may create a booking right now for that route.
*/
getContractBookingWindows: async (
contractId: string,
): Promise<BookingWindow[]> => {
const response = await client.get<BookingWindow[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId),
);
return unwrap(response.data);
},
getBookableSchedules: async (
originYardId?: string,
destinationYardId?: string,

View File

@@ -325,6 +325,25 @@ export interface BatchBoardScheduleDetail {
allocationViolations: string[];
}
/**
* A booking window for one of a contract's routes/schedules. `isOpenNow === true`
* means a booking may be created right now for that route. Times are ISO strings;
* render them in EAT (Africa/Addis_Ababa).
*/
export interface BookingWindow {
scheduleId: string;
direction: string | null;
windowPhase: BookingWindowPhase | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
origin: string | null;
destination: string | null;
}
export interface WagonAllocationAttemptResult {
assignedBookingIds: string[];
deferred: Array<{ id: string; reference: string; reason: string }>;