mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
add bookings management and settings
This commit is contained in:
@@ -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 0–23, 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>
|
||||
);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user