mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
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 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 | "";
|
||
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="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"
|
||
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"
|
||
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>
|
||
);
|
||
}
|