import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react"; import { useBranding } from "../BrandingContext.js"; import type { RouteMapStop } from "../components/RouteMap.js"; // Leaflet is heavy and only needed on this page — load it as a separate chunk. const RouteMap = lazy(() => import("../components/RouteMap.js")); // ─── Types (mirror groombook/api /api/routes responses) ───────────────────────── type RouteStatus = "draft" | "optimized" | "in_progress" | "completed"; interface RouteRow { id: string; staffId: string; routeDate: string; status: RouteStatus; totalTravelMins: number | null; totalDistanceKm: string | null; } interface ConflictFlags { hasConflict: boolean; } interface RouteStop { id: string; appointmentId: string; stopOrder: number; latitude: number; longitude: number; travelMinsFromPrev: number | null; travelDistanceKmFromPrev: string | null; bufferMins: number; appointmentStartTime: string; appointmentEndTime: string; appointmentStatus: string; clientId: string; clientName: string; clientAddress: string | null; conflict: ConflictFlags; } interface RouteResponse { route: RouteRow; stops: RouteStop[]; hasConflicts: boolean; conflictCount: number; warnings?: string[]; skipped?: Array<{ appointmentId: string; clientName: string; reason: string }>; } interface StaffMember { id: string; name: string; role: "groomer" | "receptionist" | "manager"; active: boolean; } // ─── Helpers ──────────────────────────────────────────────────────────────────── function todayIso(): string { return new Date().toISOString().slice(0, 10); } function fmtDuration(mins: number | null | undefined): string { if (mins == null) return "—"; if (mins < 60) return `${mins} min`; const h = Math.floor(mins / 60); const m = mins % 60; return m === 0 ? `${h} h` : `${h} h ${m} min`; } function fmtTime(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } const STATUS_STYLES: Record = { draft: { bg: "#f1f5f9", fg: "#475569", label: "Draft" }, optimized: { bg: "#ecfdf5", fg: "#047857", label: "Optimized" }, in_progress: { bg: "#eff6ff", fg: "#1d4ed8", label: "In progress" }, completed: { bg: "#f5f3ff", fg: "#6d28d9", label: "Completed" }, }; function StatusBadge({ status }: { status: RouteStatus }) { const s = STATUS_STYLES[status] ?? STATUS_STYLES.draft; return ( {s.label} ); } const inputStyle: React.CSSProperties = { padding: "0.4rem 0.6rem", borderRadius: 6, border: "1px solid #cbd5e1", fontSize: 14, }; // ─── Page ─────────────────────────────────────────────────────────────────────── export function RoutesPage() { const { branding } = useBranding(); const primaryColor = branding.primaryColor || "#4f8a6f"; const [me, setMe] = useState(null); const [meLoaded, setMeLoaded] = useState(false); const [groomers, setGroomers] = useState([]); const [staffId, setStaffId] = useState(""); const [date, setDate] = useState(todayIso()); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [optimizing, setOptimizing] = useState(false); const [error, setError] = useState(null); const isGroomer = me?.role === "groomer"; // Resolve the current staff member; groomers are pinned to their own route. useEffect(() => { fetch("/api/staff/me") .then((r) => (r.ok ? r.json() : null)) .then((row: StaffMember | null) => { setMe(row); if (row?.role === "groomer") setStaffId(row.id); }) .catch(() => setMe(null)) .finally(() => setMeLoaded(true)); }, []); // Managers / receptionists pick a groomer; groomers never see the selector. useEffect(() => { if (!meLoaded || isGroomer) return; fetch("/api/staff") .then((r) => (r.ok ? r.json() : [])) .then((rows: StaffMember[]) => { const gs = rows.filter((s) => s.active && s.role === "groomer"); setGroomers(gs); setStaffId((cur) => cur || gs[0]?.id || ""); }) .catch(() => setGroomers([])); }, [meLoaded, isGroomer]); const loadRoute = useCallback(async () => { if (!staffId || !date) return; setLoading(true); setError(null); try { const r = await fetch( `/api/routes/daily?staffId=${encodeURIComponent(staffId)}&date=${encodeURIComponent(date)}` ); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `Failed to load route (${r.status})`); } setData(await r.json()); } catch (e) { setData(null); setError(e instanceof Error ? e.message : "Failed to load route"); } finally { setLoading(false); } }, [staffId, date]); useEffect(() => { void loadRoute(); }, [loadRoute]); const optimize = useCallback(async () => { if (!staffId || !date) return; setOptimizing(true); setError(null); try { const r = await fetch("/api/routes/optimize", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ staffId, date }), }); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `Optimization failed (${r.status})`); } setData(await r.json()); } catch (e) { setError(e instanceof Error ? e.message : "Optimization failed"); } finally { setOptimizing(false); } }, [staffId, date]); const mapStops: RouteMapStop[] = useMemo( () => (data?.stops ?? []).map((s) => ({ id: s.id, stopOrder: s.stopOrder, latitude: s.latitude, longitude: s.longitude, clientName: s.clientName, })), [data] ); const stops = data?.stops ?? []; const route = data?.route ?? null; return (

Route Planner

{route && }
{/* Controls */}
{!isGroomer && ( )}
{error && (
{error}
)} {data?.warnings && data.warnings.length > 0 && (
{data.warnings.map((w, i) => (
{w}
))}
)} {/* Summary */}
{/* Map */}
{mapStops.length > 0 ? ( Loading map…}> ) : ( {loading ? "Loading route…" : "No stops to display. Click Optimize to build the route."} )}
{/* Stop list panel */}
{stops.length === 0 && !loading && (
No stops for this day.
)} {stops.map((s) => (
{s.stopOrder}
{s.clientName} {fmtTime(s.appointmentStartTime)}
{s.clientAddress || "No address on file"}
{s.stopOrder === 1 || s.travelMinsFromPrev == null ? "Start of route" : `${fmtDuration(s.travelMinsFromPrev)} travel from previous`}
{s.conflict?.hasConflict && (
⚠ Tight schedule — travel + buffer may exceed the gap
)}
))}
); } function Summary({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function Centered({ children }: { children: React.ReactNode }) { return (
{children}
); }