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(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 ( Booking window settings {schedule?.route?.name ?? "This schedule only"} } > {detailQuery.isLoading || !form ? ( ) : !canEdit ? ( } title="Window already open" > Booking window settings can only be changed before the window opens. This schedule is currently{" "} {String(schedule?.windowPhase ?? "not window-managed")}. ) : ( {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 departure. Cycle timing below doesn't apply. ) : null} {/* ── Daily desk hours ─────────────────────────────────────────── */} Daily desk hours (EAT) {is24h ? ( } > 24-hour desk ) : ( } > {hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)} )} v != null && setForm((f) => f && { ...f, 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} { 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 }; }); }} /> {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."} {/* ── Cycle timing ─────────────────────────────────────────────── */} Cycle timing setForm((f) => f && { ...f, windowDurationHours: v }) } min={0.0166} disabled={isExport} /> setForm((f) => f && { ...f, docReviewMinutes: v }) } min={0} disabled={isExport} /> setForm((f) => f && { ...f, paymentWindowMinutes: v }) } min={1} disabled={isExport} /> {!isExport ? ( Reopen gap after each cycle = document review + payment ={" "} {reopenSummary}. ) : null} {/* ── Lead time ────────────────────────────────────────────────── */} {isExport ? ( setForm( (f) => f && { ...f, exportBookingLeadHours: v === "" ? "" : Number(v), }, ) } min={1} clampBehavior="none" allowDecimal={false} /> ) : ( setForm( (f) => f && { ...f, importWindowLeadDays: v === "" ? "" : Number(v), }, ) } min={0} clampBehavior="none" allowDecimal={false} /> )} )} ); }