mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Add contract booking windows feature
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
Reference in New Issue
Block a user