add bookings management and settings

This commit is contained in:
Marshal
2026-07-05 10:22:58 +00:00
parent dfdf7a025d
commit 3447394e32
13 changed files with 1617 additions and 9 deletions

View File

@@ -0,0 +1,409 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Divider,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Switch,
Text,
ThemeIcon,
} from "@mantine/core";
import { isAxiosError } from "axios";
import { Clock, Info, Moon, Sun } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import DurationField from "@/components/trainScheduling/DurationField";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling";
/** Fallbacks matching the API's global-rules defaults (used when a field is null). */
const DEFAULTS = {
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
importWindowLeadDays: 3,
};
/** 12-hour label for an EAT hour 023, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
function hourLabel(hour: number): string {
const period = hour < 12 ? "AM" : "PM";
const h12 = hour % 12 === 0 ? 12 : hour % 12;
return `${h12}:00 ${period}`;
}
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
value: String(h),
label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`,
}));
interface FormState {
windowOpenHour: number;
windowCloseHour: number;
windowDurationHours: number | "";
docReviewMinutes: number | "";
paymentWindowMinutes: number | "";
importWindowLeadDays: number | "";
}
function parseError(error: unknown, fallback: string): string {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
}
export interface BookingWindowSettingsModalProps {
scheduleId: string | null;
opened: boolean;
onClose: () => void;
/** Called after a successful save (e.g. to refetch a list). */
onSaved?: () => void;
}
/**
* Per-schedule booking-window settings editor. Prefills from the schedule's own
* rule snapshot, lets staff tune the daily desk hours / durations for just that
* train, and saves an override. Only editable before the window opens.
*/
export default function BookingWindowSettingsModal({
scheduleId,
opened,
onClose,
onSaved,
}: BookingWindowSettingsModalProps) {
const { toast } = useToast();
const detailQuery = useQuery({
...api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "" },
}),
enabled: opened && Boolean(scheduleId),
});
const schedule = detailQuery.data;
const save = useMutation(
api.trainScheduling.updateScheduleWindowRule.mutationOptions(),
);
const [form, setForm] = useState<FormState | null>(null);
// Seed the form from the schedule's snapshot once it loads (or when reopened).
useEffect(() => {
if (!opened || !schedule) return;
const r = schedule.windowRule;
setForm({
windowOpenHour: r?.windowOpenHour ?? DEFAULTS.windowOpenHour,
windowCloseHour: r?.windowCloseHour ?? DEFAULTS.windowCloseHour,
windowDurationHours: r?.windowDurationHours ?? DEFAULTS.windowDurationHours,
docReviewMinutes: r?.docReviewMinutes ?? DEFAULTS.docReviewMinutes,
paymentWindowMinutes:
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
importWindowLeadDays:
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
});
}, [opened, schedule]);
const isExport = schedule?.direction === "EXPORT";
const canEdit = schedule?.windowPhase === "PRE_WINDOW";
const is24h =
form != null && form.windowOpenHour === form.windowCloseHour;
const closeBeforeOpen =
form != null && form.windowCloseHour < form.windowOpenHour;
const reopenSummary = useMemo(() => {
if (!form) return "";
const doc = Number(form.docReviewMinutes) || 0;
const pay = Number(form.paymentWindowMinutes) || 0;
const total = doc + pay;
const h = Math.floor(total / 60);
const m = total % 60;
const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean);
return parts.length ? parts.join(" ") : "0m";
}, [form]);
const handleSave = async () => {
if (!scheduleId || !form) return;
// Numeric fields must hold real values.
const duration = Number(form.windowDurationHours);
const doc = Number(form.docReviewMinutes);
const pay = Number(form.paymentWindowMinutes);
const lead = Number(form.importWindowLeadDays);
if (
form.windowDurationHours === "" ||
form.docReviewMinutes === "" ||
form.paymentWindowMinutes === "" ||
form.importWindowLeadDays === "" ||
!Number.isFinite(duration) ||
!Number.isFinite(doc) ||
!Number.isFinite(pay) ||
!Number.isFinite(lead)
) {
toast({
title: "Fill every field before saving",
variant: "destructive",
});
return;
}
if (closeBeforeOpen) {
toast({
title: "Close hour must be on or after the open hour",
description: "Set them equal for a 24-hour desk.",
variant: "destructive",
});
return;
}
const payload: UpdateScheduleWindowRulePayload = {
windowOpenHour: form.windowOpenHour,
windowCloseHour: form.windowCloseHour,
windowDurationHours: duration,
docReviewMinutes: doc,
paymentWindowMinutes: pay,
importWindowLeadDays: lead,
};
try {
await save.mutateAsync({ id: scheduleId, payload });
toast({ title: "Booking window settings updated" });
onSaved?.();
onClose();
} catch (err) {
toast({
title: "Update failed",
description: parseError(err, "Could not update booking window"),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="lg"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Clock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Booking window settings
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{schedule?.route?.name ?? "This schedule only"}
</Text>
</Box>
</Group>
}
>
{detailQuery.isLoading || !form ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
) : !canEdit ? (
<Alert
variant="light"
color="yellow"
icon={<Info size={16} />}
title="Window already open"
>
Booking window settings can only be changed before the window opens.
This schedule is currently{" "}
<b>{String(schedule?.windowPhase ?? "not window-managed")}</b>.
</Alert>
) : (
<Stack gap="lg">
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single FCFS lead window the daily desk
hours below don't apply, only the lead time does.
</Alert>
) : null}
{/* ── Daily desk hours ─────────────────────────────────────────── */}
<Box>
<Group justify="space-between" align="center" mb={6}>
<Text size="sm" fw={600}>
Daily desk hours (EAT)
</Text>
{is24h ? (
<Badge
variant="light"
color="grape"
leftSection={<Moon size={12} />}
>
24-hour desk
</Badge>
) : (
<Badge
variant="light"
color="edr-green"
leftSection={<Sun size={12} />}
>
{hourLabel(form.windowOpenHour)} {hourLabel(form.windowCloseHour)}
</Badge>
)}
</Group>
<Group grow align="flex-start">
<Select
label="Opens"
data={HOUR_OPTIONS}
value={String(form.windowOpenHour)}
onChange={(v) =>
v != null &&
setForm((f) => f && { ...f, windowOpenHour: Number(v) })
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
disabled={isExport}
/>
<Select
label="Closes"
data={HOUR_OPTIONS}
value={String(form.windowCloseHour)}
onChange={(v) =>
v != null &&
setForm((f) => f && { ...f, windowCloseHour: Number(v) })
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
error={closeBeforeOpen ? "Must be ≥ open hour" : undefined}
disabled={isExport}
/>
</Group>
<Switch
mt="sm"
size="sm"
color="grape"
label="Run 24 hours a day (never pause overnight)"
checked={is24h}
disabled={isExport}
onChange={(e) =>
setForm((f) => {
if (!f) return f;
// On → close == open (24h desk). Off → restore a normal ~9h
// day, always kept ≥ open hour so it never lands invalid.
const close = e.currentTarget.checked
? f.windowOpenHour
: Math.min(23, f.windowOpenHour + 9);
return { ...f, windowCloseHour: close };
})
}
/>
{!isExport ? (
<Text size="xs" c="dimmed" mt={6}>
A not-yet-full train pauses at the close hour and resumes the next
morning at the open hour, every day until it fills or departs.
</Text>
) : null}
</Box>
<Divider />
{/* ── Cycle timing ─────────────────────────────────────────────── */}
<Box>
<Text size="sm" fw={600} mb={6}>
Cycle timing
</Text>
<Stack gap="sm">
<DurationField
label="Window duration"
description="How long each booking cycle stays open before it closes for review"
value={form.windowDurationHours}
nativeUnit="hours"
onChange={(v) =>
setForm((f) => f && { ...f, windowDurationHours: v })
}
min={0.0166}
disabled={isExport}
/>
<Group grow align="flex-start">
<DurationField
label="Document review"
description="Staff time to accept documents after the window closes"
value={form.docReviewMinutes}
nativeUnit="minutes"
onChange={(v) =>
setForm((f) => f && { ...f, docReviewMinutes: v })
}
min={0}
disabled={isExport}
/>
<DurationField
label="Payment window"
description="Time a selected customer has to pay"
value={form.paymentWindowMinutes}
nativeUnit="minutes"
onChange={(v) =>
setForm((f) => f && { ...f, paymentWindowMinutes: v })
}
min={1}
disabled={isExport}
/>
</Group>
{!isExport ? (
<Text size="xs" c="dimmed">
Reopen gap after each cycle = document review + payment ={" "}
<b>{reopenSummary}</b>.
</Text>
) : null}
</Stack>
</Box>
<Divider />
{/* ── Lead time ────────────────────────────────────────────────── */}
<NumberInput
label={isExport ? "Booking lead (days)" : "Window lead (days)"}
description={
isExport
? "How many days before departure export booking opens"
: "How many days before departure the booking window starts"
}
value={form.importWindowLeadDays}
onChange={(v) =>
setForm(
(f) =>
f && {
...f,
importWindowLeadDays: v === "" ? "" : Number(v),
},
)
}
min={0}
clampBehavior="none"
allowDecimal={false}
/>
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button
onClick={handleSave}
loading={save.isPending}
disabled={closeBeforeOpen}
>
Save settings
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

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

View File

@@ -20,6 +20,7 @@ import {
ArrowLeft,
CalendarClock,
CheckCircle2,
Clock,
Container as ContainerIcon,
Eye,
FileText,
@@ -47,7 +48,8 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
@@ -95,6 +97,7 @@ export default function TrainScheduleV2DetailPage() {
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
@@ -886,6 +889,18 @@ export default function TrainScheduleV2DetailPage() {
Track train
</Button>
) : null}
{schedule.windowPhase === "PRE_WINDOW" ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<Clock size={16} />}
onClick={() => setWindowSettingsOpen(true)}
>
Window settings
</Button>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Button
variant="default"
@@ -1126,7 +1141,7 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Paper>
<ScheduleBatchPanel schedule={schedule} />
{/* <ScheduleBatchPanel schedule={schedule} /> */}
</Stack>
</Tabs.Panel>
@@ -1150,6 +1165,13 @@ export default function TrainScheduleV2DetailPage() {
onComplete={() => void detailQuery.refetch()}
/>
) : null}
<BookingWindowSettingsModal
scheduleId={scheduleId ?? null}
opened={windowSettingsOpen}
onClose={() => setWindowSettingsOpen(false)}
onSaved={() => void detailQuery.refetch()}
/>
</PageContainer>
);
}

View File

@@ -20,6 +20,7 @@ import {
ArrowRight,
Ban,
CalendarClock,
Clock,
Eye,
MoreHorizontal,
Navigation,
@@ -36,6 +37,7 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import {
locomotiveOption,
showScheduleWarnings,
@@ -86,6 +88,7 @@ export default function TrainScheduleV2ListPage() {
const [statusFilter, setStatusFilter] = useState("ALL");
const [freightFilter, setFreightFilter] = useState("ALL");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
@@ -315,6 +318,14 @@ export default function TrainScheduleV2ListPage() {
Track
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item
leftSection={<Clock size={15} />}
onClick={() => setWindowSettingsId(schedule.id)}
>
Booking window settings
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item
color="red"
@@ -583,6 +594,13 @@ export default function TrainScheduleV2ListPage() {
</Group>
</Stack>
</Modal>
<BookingWindowSettingsModal
scheduleId={windowSettingsId}
opened={windowSettingsId != null}
onClose={() => setWindowSettingsId(null)}
onSaved={() => void schedulesQuery.refetch()}
/>
</PageContainer>
);
}

View File

@@ -58,6 +58,7 @@ export default function TrainSchedulingGlobalRulesPage() {
"importWindowLeadDays",
"exportBookingLeadHours",
"windowOpenHour",
"windowCloseHour",
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
@@ -196,7 +197,7 @@ export default function TrainSchedulingGlobalRulesPage() {
/>
<NumberInput
label="Window open hour (EAT)"
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
description="Local hour the booking desk opens each day (e.g. 8 = 08:00)"
value={form.windowOpenHour ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, windowOpenHour: value }))
@@ -207,6 +208,19 @@ export default function TrainSchedulingGlobalRulesPage() {
max={23}
disabled={loading}
/>
<NumberInput
label="Window close hour (EAT)"
description="Local hour the booking desk shuts each day; a not-yet-full window resumes next morning at the open hour. Set equal to the open hour for a 24-hour desk."
value={form.windowCloseHour ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, windowCloseHour: value }))
}
clampBehavior="none"
allowDecimal
min={0}
max={23}
disabled={loading}
/>
<DurationField
label="Window duration"
description="How long the import booking window stays open"

View File

@@ -58,6 +58,7 @@ import type {
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
UpdateScheduleWindowRulePayload,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
TrainTrackResponse,
@@ -438,6 +439,18 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
updateScheduleWindowRule: endpoint<
{ id: string; payload: UpdateScheduleWindowRulePayload },
TrainScheduleDetail
>(
"train-scheduling",
"update-schedule-window-rule",
({ id, payload }) =>
trainSchedulingService.updateScheduleWindowRule(id, payload),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
markBookingPaid: endpoint<string, void>(
"train-scheduling",
"mark-booking-paid",

View File

@@ -23,6 +23,7 @@ import type {
RecordCheckpointPayload,
StaffBookingWindow,
TrainScheduleDetail,
UpdateScheduleWindowRulePayload,
TrainScheduleFilters,
TrainScheduleListItem,
TrainSchedulePreviewPayload,
@@ -210,6 +211,17 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
updateScheduleWindowRule: async (
scheduleId: string,
payload: UpdateScheduleWindowRulePayload,
): Promise<TrainScheduleDetail> => {
const response = await client.patch<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.WINDOW_RULE(scheduleId),
payload,
);
return unwrap(response.data);
},
markBookingPaid: async (bookingId: string): Promise<void> => {
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId),

View File

@@ -108,6 +108,7 @@ export interface TrainSchedulingGlobalRules {
importWindowLeadDays: number;
exportBookingLeadHours: number;
windowOpenHour: number;
windowCloseHour: number;
windowDurationHours: number;
docReviewMinutes: number;
paymentWindowMinutes: number;
@@ -399,6 +400,28 @@ export interface TrainScheduleWagonAllocation {
} | null;
}
/** Per-schedule booking-window rule snapshot (null fields fall back to global config). */
export interface ScheduleWindowRule {
windowOpenHour: number | null;
windowCloseHour: number | null;
windowDurationHours: number | null;
reopenDelayMinutes: number | null;
importWindowLeadDays: number | null;
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
docReviewMinutes: number;
paymentWindowMinutes: number;
}
/** Editable window-rule override for one schedule; every field optional. */
export interface UpdateScheduleWindowRulePayload {
windowOpenHour?: number;
windowCloseHour?: number;
windowDurationHours?: number;
docReviewMinutes?: number;
paymentWindowMinutes?: number;
importWindowLeadDays?: number;
}
export interface TrainScheduleDetail {
id: string;
status: TrainScheduleStatus | string;
@@ -411,6 +434,8 @@ export interface TrainScheduleDetail {
windowClosesAt?: string | null;
docReviewEndsAt?: string | null;
paymentPhaseEndsAt?: string | null;
/** Booking-window rule snapshot — prefills the per-schedule settings editor. */
windowRule?: ScheduleWindowRule | null;
route?: {
id: string;
name: string;