import { useEffect, useMemo, useState } from "react"; import { Alert, Badge, Box, Divider, Group, Loader, NumberInput, Select, Stack, Switch, Text, } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { Info, Moon, Sun } from "lucide-react"; import DurationField from "@/components/trainScheduling/DurationField"; import { trainSchedulingService } from "@/services/trainScheduling.service"; import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling"; /** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */ const DEFAULTS = { windowOpenHour: 8, // Equal to open ⇒ 24-hour desk (the default). windowCloseHour: 8, 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`, })); export interface WindowFormState { windowOpenHour: number; windowCloseHour: number; windowDurationHours: number | ""; docReviewMinutes: number | ""; paymentWindowMinutes: number | ""; importWindowLeadDays: number | ""; exportBookingLeadHours: number | ""; /** Blank = close exactly at departure. */ closeOffsetMinutes: number | ""; } /** * Builds the create payload from form state, or returns an error message when a * required field was left blank. The close offset is direction-scoped: only the * offset matching this schedule's direction is sent, since the other is never read. */ export function buildWindowRulePayload( form: WindowFormState, isExport: boolean, ): { payload: CreateScheduleWindowRulePayload } | { error: string } { 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 ) { return { error: "Fill every booking-window field, or turn the toggle off" }; } // Blank offset = close at departure. Sent as null (not omitted) so it wins // over a non-null global offset. const offset = form.closeOffsetMinutes === "" ? null : Number(form.closeOffsetMinutes); return { payload: { windowOpenHour: form.windowOpenHour, windowCloseHour: form.windowCloseHour, windowDurationHours: duration, docReviewMinutes: doc, paymentWindowMinutes: pay, ...(isExport ? { exportBookingLeadHours: exportLead, exportCloseOffsetMinutes: offset } : { importWindowLeadDays: lead, importCloseOffsetMinutes: offset }), }, }; } export interface CreateScheduleWindowFieldsProps { /** Direction of the selected route — picks lead/offset semantics. */ isExport: boolean; form: WindowFormState | null; onChange: (next: WindowFormState) => void; } /** * Booking-window settings for a schedule being created. Prefills from the live * global rules (so the fields show what the schedule WOULD inherit), then lets * staff tune them for this one train. Mirrors BookingWindowSettingsModal, plus * the booking-close offset. */ export default function CreateScheduleWindowFields({ isExport, form, onChange, }: CreateScheduleWindowFieldsProps) { const rulesQuery = useQuery({ queryKey: ["train-scheduling", "global-rules"], queryFn: () => trainSchedulingService.getGlobalRules(), staleTime: 5 * 60_000, }); // Seed once from the global rules, so the toggle opens on the values this // schedule would otherwise inherit rather than on hardcoded guesses. const [seeded, setSeeded] = useState(false); useEffect(() => { if (seeded || form != null) return; const r = rulesQuery.data; if (!r && rulesQuery.isLoading) return; const num = (v: unknown, fallback: number) => { const n = v == null || v === "" ? NaN : Number(v); return Number.isFinite(n) ? n : fallback; }; const offset = isExport ? (r as { exportCloseOffsetMinutes?: number | null } | undefined) ?.exportCloseOffsetMinutes : (r as { importCloseOffsetMinutes?: number | null } | undefined) ?.importCloseOffsetMinutes; onChange({ windowOpenHour: num(r?.windowOpenHour, DEFAULTS.windowOpenHour), windowCloseHour: num(r?.windowCloseHour, DEFAULTS.windowCloseHour), windowDurationHours: num(r?.windowDurationHours, DEFAULTS.windowDurationHours), docReviewMinutes: num(r?.docReviewMinutes, DEFAULTS.docReviewMinutes), paymentWindowMinutes: num( isExport ? (r as { exportPaymentWindowMinutes?: number } | undefined) ?.exportPaymentWindowMinutes : r?.paymentWindowMinutes, DEFAULTS.paymentWindowMinutes, ), importWindowLeadDays: num(r?.importWindowLeadDays, DEFAULTS.importWindowLeadDays), exportBookingLeadHours: num( r?.exportBookingLeadHours, DEFAULTS.exportBookingLeadHours, ), closeOffsetMinutes: offset == null || offset === 0 ? "" : Number(offset), }); setSeeded(true); }, [seeded, form, rulesQuery.data, rulesQuery.isLoading, isExport, onChange]); const set = (patch: Partial) => { if (form) onChange({ ...form, ...patch }); }; const is24h = form != null && form.windowOpenHour === form.windowCloseHour; // Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning). const isOvernight = form != null && form.windowCloseHour < form.windowOpenHour; const reopenSummary = useMemo(() => { if (!form) return ""; const total = (Number(form.docReviewMinutes) || 0) + (Number(form.paymentWindowMinutes) || 0); 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]); if (!form) { return ( ); } return ( {isExport ? ( }> 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 it closes. Cycle timing below doesn't apply. ) : ( }> These settings apply to this train only, and can be set only for the FIRST train on a route and departure day. Later trains that day join its booking group and share the same window. )} {/* ── Daily desk hours ─────────────────────────────────────────── */} Daily desk hours (EAT) {is24h ? ( }> 24-hour desk ) : ( }> {hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)} )} v != null && set({ windowCloseHour: Number(v) })} allowDeselect={false} comboboxProps={{ withinPortal: true }} /> {isOvernight && !is24h ? ( Overnight desk — opens {form.windowOpenHour}:00 and runs past midnight, closing {form.windowCloseHour}:00 the next morning. ) : null} set({ // On → close == open (24h desk). Off → restore a normal ~9h day. windowCloseHour: e.currentTarget.checked ? form.windowOpenHour : Math.min(23, form.windowOpenHour + 9), }) } /> {/* ── Cycle timing ─────────────────────────────────────────────── */} Cycle timing set({ windowDurationHours: v })} min={0.0166} disabled={isExport} /> set({ docReviewMinutes: v })} min={0} disabled={isExport} /> set({ paymentWindowMinutes: v })} min={1} /> {!isExport ? ( Reopen gap after each cycle = document review + payment ={" "} {reopenSummary}. ) : null} {/* ── Lead time ────────────────────────────────────────────────── */} {isExport ? ( set({ exportBookingLeadHours: v === "" ? "" : Number(v) })} min={1} clampBehavior="none" allowNegative={false} allowDecimal={false} /> ) : ( set({ importWindowLeadDays: v === "" ? "" : Number(v) })} min={0} clampBehavior="none" allowNegative={false} allowDecimal={false} /> )} {/* ── Booking close offset ─────────────────────────────────────── */} Booking close offset How long before departure this schedule stops accepting bookings. e.g. a 3-hour import offset closes a 17:00 departure's window at 14:00; a 1-day export offset closes a Jul-10 16:00 departure at Jul-9 16:00. Leave blank to close exactly at departure. set({ closeOffsetMinutes: v })} min={0} /> ); }