Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx

441 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
exportBookingLeadHours: 24,
};
/** 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 | "";
exportBookingLeadHours: 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,
exportBookingLeadHours:
r?.exportBookingLeadHours ?? DEFAULTS.exportBookingLeadHours,
});
}, [opened, schedule]);
const isExport = schedule?.direction === "EXPORT";
const canEdit = schedule?.windowPhase === "PRE_WINDOW";
const is24h =
form != null && form.windowOpenHour === form.windowCloseHour;
// Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning),
// not an error — the engine wraps it across midnight.
const isOvernight =
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);
const exportLead = Number(form.exportBookingLeadHours);
const leadInvalid = isExport
? form.exportBookingLeadHours === "" ||
!Number.isFinite(exportLead) ||
exportLead < 1
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
if (
form.windowDurationHours === "" ||
form.docReviewMinutes === "" ||
form.paymentWindowMinutes === "" ||
!Number.isFinite(duration) ||
!Number.isFinite(doc) ||
!Number.isFinite(pay) ||
leadInvalid
) {
toast({
title: "Fill every field before saving",
variant: "destructive",
});
return;
}
const payload: UpdateScheduleWindowRulePayload = {
windowOpenHour: form.windowOpenHour,
windowCloseHour: form.windowCloseHour,
windowDurationHours: duration,
docReviewMinutes: doc,
paymentWindowMinutes: pay,
...(isExport
? { exportBookingLeadHours: exportLead }
: { 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="orange" icon={<Info size={16} />}>
These settings apply to every train on this route (same origin and
destination) departing the same day they all share one booking
window, so it opens, moves to document review, opens for payment,
and closes at the same time for all of them.
</Alert>
) : null}
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single first-come-first-served window: it
opens the export lead time before departure shifted to the next
desk opening if that lands outside desk hours and stays open
until departure. Cycle timing below doesn't apply.
</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 }}
/>
<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 }}
/>
</Group>
{isOvernight && !is24h ? (
<Text size="xs" c="dimmed" mt={4}>
Overnight desk opens {form.windowOpenHour}:00 and runs past
midnight, closing {form.windowCloseHour}:00 the next morning.
</Text>
) : null}
<Switch
mt="sm"
size="sm"
color="grape"
label="Run 24 hours a day (never pause overnight)"
checked={is24h}
onChange={(e) => {
const checked = e.currentTarget.checked;
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 = checked
? f.windowOpenHour
: Math.min(23, f.windowOpenHour + 9);
return { ...f, windowCloseHour: close };
});
}}
/>
<Text size="xs" c="dimmed" mt={6}>
{isExport
? "If the export lead time lands while the desk is shut, booking opens at the next desk opening instead."
: "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>
</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 ────────────────────────────────────────────────── */}
{isExport ? (
<NumberInput
label="Export booking lead (hours)"
description="How many hours before departure the export booking window opens"
value={form.exportBookingLeadHours}
onChange={(v) =>
setForm(
(f) =>
f && {
...f,
exportBookingLeadHours: v === "" ? "" : Number(v),
},
)
}
min={1}
clampBehavior="none"
allowNegative={false}
allowDecimal={false}
/>
) : (
<NumberInput
label="Window lead (days)"
description="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"
allowNegative={false}
allowDecimal={false}
/>
)}
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button onClick={handleSave} loading={save.isPending}>
Save settings
</Button>
</Group>
</Stack>
)}
</Modal>
);
}