782 lines
27 KiB
TypeScript
782 lines
27 KiB
TypeScript
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
DndContext,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
TouchSensor,
|
|
closestCenter,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
arrayMove,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
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" });
|
|
}
|
|
|
|
// ─── Navigation export ────────────────────────────────────────────────────────
|
|
|
|
/** Navigation target platforms supported by the API export endpoints. */
|
|
type NavigationPlatform = "google-maps" | "apple-maps";
|
|
|
|
type DevicePlatform = "ios" | "android" | "other";
|
|
|
|
/**
|
|
* Best-effort mobile-OS detection so we can surface the most useful navigation
|
|
* app first. Apple Maps deep links (`maps://`) only resolve on iOS; everywhere
|
|
* else Google Maps is the safe default. iPadOS 13+ reports a desktop UA, so we
|
|
* also treat a touch-capable "MacIntel" device as iOS.
|
|
*/
|
|
function detectPlatform(): DevicePlatform {
|
|
if (typeof navigator === "undefined") return "other";
|
|
const ua = navigator.userAgent || "";
|
|
if (/iphone|ipad|ipod/i.test(ua)) return "ios";
|
|
if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) return "ios";
|
|
if (/android/i.test(ua)) return "android";
|
|
return "other";
|
|
}
|
|
|
|
// ─── Offline map-tile pre-warming ────────────────────────────────────────────
|
|
|
|
/** OSM tile zoom levels pre-fetched around a route so the map renders offline. */
|
|
const PREWARM_ZOOM_LEVELS = [12, 13, 14] as const;
|
|
/** Hard cap on tiles fetched per pre-warm pass — keeps us friendly to OSM. */
|
|
const MAX_PREWARM_TILES = 80;
|
|
/** Subdomains Leaflet's default OSM TileLayer rotates through (`{s}`). */
|
|
const TILE_SUBDOMAINS = ["a", "b", "c"] as const;
|
|
|
|
/** Web-Mercator longitude → tile X index at the given zoom. */
|
|
function lonToTileX(lon: number, z: number): number {
|
|
return Math.floor(((lon + 180) / 360) * 2 ** z);
|
|
}
|
|
|
|
/** Web-Mercator latitude → tile Y index at the given zoom. */
|
|
function latToTileY(lat: number, z: number): number {
|
|
const rad = (lat * Math.PI) / 180;
|
|
return Math.floor(
|
|
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * 2 ** z
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Warm the browser/service-worker cache with the OSM tiles covering the route's
|
|
* bounding box (plus a one-tile margin) across a few zoom levels. Tiles are
|
|
* fetched via `new Image()` so they hit the same URLs Leaflet later requests and
|
|
* land in the CacheFirst tile cache, making the map viewable offline. Bounded by
|
|
* MAX_PREWARM_TILES so a sprawling route never floods the network.
|
|
*/
|
|
function prewarmRouteTiles(
|
|
stops: Array<{ latitude: number; longitude: number }>
|
|
): void {
|
|
if (typeof window === "undefined" || stops.length === 0) return;
|
|
const lats = stops.map((s) => s.latitude);
|
|
const lons = stops.map((s) => s.longitude);
|
|
const minLat = Math.min(...lats);
|
|
const maxLat = Math.max(...lats);
|
|
const minLon = Math.min(...lons);
|
|
const maxLon = Math.max(...lons);
|
|
|
|
const urls: string[] = [];
|
|
for (const z of PREWARM_ZOOM_LEVELS) {
|
|
const x0 = lonToTileX(minLon, z) - 1;
|
|
const x1 = lonToTileX(maxLon, z) + 1;
|
|
// Tile Y grows as latitude decreases, so maxLat → smaller Y.
|
|
const y0 = latToTileY(maxLat, z) - 1;
|
|
const y1 = latToTileY(minLat, z) + 1;
|
|
for (let x = x0; x <= x1; x++) {
|
|
for (let y = y0; y <= y1; y++) {
|
|
if (x < 0 || y < 0 || x >= 2 ** z || y >= 2 ** z) continue;
|
|
const s = TILE_SUBDOMAINS[(x + y) % TILE_SUBDOMAINS.length];
|
|
urls.push(`https://${s}.tile.openstreetmap.org/${z}/${x}/${y}.png`);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const url of urls.slice(0, MAX_PREWARM_TILES)) {
|
|
const img = new Image();
|
|
img.src = url;
|
|
}
|
|
}
|
|
|
|
// ─── Responsive layout ────────────────────────────────────────────────────────
|
|
|
|
/** Tracks a `max-width` media query so the page can adapt to phone widths. */
|
|
function useIsMobile(maxWidthPx = 768): boolean {
|
|
const query = `(max-width: ${maxWidthPx}px)`;
|
|
const [isMobile, setIsMobile] = useState(
|
|
() => typeof window !== "undefined" && typeof window.matchMedia === "function"
|
|
? window.matchMedia(query).matches
|
|
: false
|
|
);
|
|
useEffect(() => {
|
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
const mq = window.matchMedia(query);
|
|
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
|
setIsMobile(mq.matches);
|
|
mq.addEventListener("change", onChange);
|
|
return () => mq.removeEventListener("change", onChange);
|
|
}, [query]);
|
|
return isMobile;
|
|
}
|
|
|
|
const STATUS_STYLES: Record<RouteStatus, { bg: string; fg: string; label: string }> = {
|
|
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 (
|
|
<span
|
|
style={{
|
|
background: s.bg,
|
|
color: s.fg,
|
|
borderRadius: 999,
|
|
padding: "0.2rem 0.7rem",
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
textTransform: "none",
|
|
}}
|
|
>
|
|
{s.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
padding: "0.4rem 0.6rem",
|
|
borderRadius: 6,
|
|
border: "1px solid #cbd5e1",
|
|
fontSize: 14,
|
|
};
|
|
|
|
/**
|
|
* A single draggable stop card. The drag handle (⠿) carries the dnd-kit
|
|
* listeners so the rest of the card stays scrollable/selectable; the handle is
|
|
* sized for touch and works with pointer, touch and keyboard sensors.
|
|
*/
|
|
function SortableStop({
|
|
stop,
|
|
primaryColor,
|
|
disabled,
|
|
}: {
|
|
stop: RouteStop;
|
|
primaryColor: string;
|
|
disabled: boolean;
|
|
}) {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
|
useSortable({ id: stop.id, disabled });
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{
|
|
background: "#fff",
|
|
border: `1px solid ${stop.conflict?.hasConflict ? "#fca5a5" : "#e2e8f0"}`,
|
|
borderRadius: 8,
|
|
padding: "0.7rem 0.85rem",
|
|
display: "flex",
|
|
gap: 10,
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.6 : 1,
|
|
boxShadow: isDragging ? "0 6px 16px rgba(0,0,0,0.18)" : "none",
|
|
touchAction: "none",
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label={`Drag to reorder ${stop.clientName}`}
|
|
disabled={disabled}
|
|
{...attributes}
|
|
{...listeners}
|
|
style={{
|
|
flexShrink: 0,
|
|
alignSelf: "stretch",
|
|
width: 28,
|
|
border: "none",
|
|
background: "transparent",
|
|
color: "#94a3b8",
|
|
fontSize: 18,
|
|
lineHeight: 1,
|
|
cursor: disabled ? "not-allowed" : "grab",
|
|
touchAction: "none",
|
|
padding: 0,
|
|
}}
|
|
>
|
|
⠿
|
|
</button>
|
|
<div
|
|
style={{
|
|
flexShrink: 0,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: "50%",
|
|
background: primaryColor,
|
|
color: "#fff",
|
|
fontSize: 13,
|
|
fontWeight: 700,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
{stop.stopOrder}
|
|
</div>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
|
|
<strong style={{ fontSize: 14, color: "#1a202c" }}>{stop.clientName}</strong>
|
|
<span style={{ fontSize: 13, color: "#4b5563", whiteSpace: "nowrap" }}>{fmtTime(stop.appointmentStartTime)}</span>
|
|
</div>
|
|
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>{stop.clientAddress || "No address on file"}</div>
|
|
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 4 }}>
|
|
{stop.stopOrder === 1 || stop.travelMinsFromPrev == null
|
|
? "Start of route"
|
|
: `${fmtDuration(stop.travelMinsFromPrev)} travel from previous`}
|
|
</div>
|
|
{stop.conflict?.hasConflict && (
|
|
<div style={{ fontSize: 12, color: "#b91c1c", marginTop: 4, fontWeight: 600 }}>
|
|
⚠ Tight schedule — travel + buffer may exceed the gap
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Navigation export controls. Fetches a platform deep-link from the API and opens
|
|
* it. The button matching the detected device OS is shown prominently (filled);
|
|
* the other is offered as a secondary outline button. On desktop both are
|
|
* secondary and Google Maps leads.
|
|
*/
|
|
function NavExportButtons({
|
|
routeId,
|
|
primaryColor,
|
|
fullWidth,
|
|
}: {
|
|
routeId: string;
|
|
primaryColor: string;
|
|
fullWidth: boolean;
|
|
}) {
|
|
const [busy, setBusy] = useState<NavigationPlatform | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const platform = useMemo(detectPlatform, []);
|
|
|
|
const openIn = useCallback(
|
|
async (target: NavigationPlatform) => {
|
|
setBusy(target);
|
|
setError(null);
|
|
// Pre-open a tab synchronously: mobile Safari/Chrome block window.open()
|
|
// calls that happen after an await (no longer in the user-gesture turn).
|
|
const win = window.open("", "_blank");
|
|
try {
|
|
const r = await fetch(`/api/routes/${encodeURIComponent(routeId)}/export/${target}`);
|
|
if (!r.ok) {
|
|
const body = await r.json().catch(() => ({}));
|
|
throw new Error(body.error || `Export failed (${r.status})`);
|
|
}
|
|
const { url } = (await r.json()) as { url: string };
|
|
if (win) win.location.href = url;
|
|
else window.location.href = url;
|
|
} catch (e) {
|
|
win?.close();
|
|
setError(e instanceof Error ? e.message : "Export failed");
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
},
|
|
[routeId]
|
|
);
|
|
|
|
const baseBtn: React.CSSProperties = {
|
|
padding: "0.55rem 1rem",
|
|
borderRadius: 6,
|
|
fontWeight: 600,
|
|
fontSize: 14,
|
|
cursor: busy ? "wait" : "pointer",
|
|
flex: fullWidth ? "1 1 0" : "0 0 auto",
|
|
};
|
|
const primaryBtn: React.CSSProperties = {
|
|
...baseBtn,
|
|
border: "none",
|
|
background: primaryColor,
|
|
color: "#fff",
|
|
};
|
|
const secondaryBtn: React.CSSProperties = {
|
|
...baseBtn,
|
|
border: `1px solid ${primaryColor}`,
|
|
background: "#fff",
|
|
color: primaryColor,
|
|
};
|
|
|
|
const label = (p: NavigationPlatform) =>
|
|
busy === p
|
|
? "Opening…"
|
|
: p === "google-maps"
|
|
? "Open in Google Maps"
|
|
: "Open in Apple Maps";
|
|
|
|
const google = (
|
|
<button
|
|
key="google"
|
|
type="button"
|
|
onClick={() => openIn("google-maps")}
|
|
disabled={busy !== null}
|
|
style={platform === "ios" ? secondaryBtn : primaryBtn}
|
|
>
|
|
{label("google-maps")}
|
|
</button>
|
|
);
|
|
const apple = (
|
|
<button
|
|
key="apple"
|
|
type="button"
|
|
onClick={() => openIn("apple-maps")}
|
|
disabled={busy !== null}
|
|
style={platform === "ios" ? primaryBtn : secondaryBtn}
|
|
>
|
|
{label("apple-maps")}
|
|
</button>
|
|
);
|
|
// Prominent (filled) button first; secondary second.
|
|
const ordered = platform === "ios" ? [apple, google] : [google, apple];
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
|
<span style={{ fontSize: 12, color: "#4b5563", fontWeight: 600, marginRight: 4 }}>
|
|
Navigate
|
|
</span>
|
|
{ordered}
|
|
</div>
|
|
{error && <div style={{ fontSize: 12, color: "#991b1b" }}>{error}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Page ───────────────────────────────────────────────────────────────────────
|
|
|
|
export function RoutesPage() {
|
|
const { branding } = useBranding();
|
|
const primaryColor = branding.primaryColor || "#4f8a6f";
|
|
|
|
const [me, setMe] = useState<StaffMember | null>(null);
|
|
const [meLoaded, setMeLoaded] = useState(false);
|
|
const [groomers, setGroomers] = useState<StaffMember[]>([]);
|
|
const [staffId, setStaffId] = useState<string>("");
|
|
const [date, setDate] = useState<string>(todayIso());
|
|
|
|
const [data, setData] = useState<RouteResponse | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [optimizing, setOptimizing] = useState(false);
|
|
const [reordering, setReordering] = useState(false);
|
|
const [manuallyReordered, setManuallyReordered] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const isGroomer = me?.role === "groomer";
|
|
const isMobile = useIsMobile();
|
|
|
|
// 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());
|
|
setManuallyReordered(false);
|
|
} 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());
|
|
setManuallyReordered(false);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Optimization failed");
|
|
} finally {
|
|
setOptimizing(false);
|
|
}
|
|
}, [staffId, date]);
|
|
|
|
// Drag-to-reorder: pointer for desktop, touch (press-and-hold) for mobile
|
|
// groomers, keyboard for accessibility. Touch uses a short delay so vertical
|
|
// scrolling of the stop list still works without triggering a drag.
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
);
|
|
|
|
// Persist a manually reordered stop list. Optimistic: the UI is updated
|
|
// immediately from the dropped order and rolled back if the PATCH fails.
|
|
const reorder = useCallback(
|
|
async (orderedIds: string[]) => {
|
|
const routeId = data?.route?.id;
|
|
if (!routeId) return;
|
|
const previous = data;
|
|
// Optimistic local update: renumber stopOrder to match the new order so
|
|
// the list and the map reflect the drop before the server responds.
|
|
const byId = new Map((data?.stops ?? []).map((s) => [s.id, s]));
|
|
const optimisticStops = orderedIds
|
|
.map((id, i) => {
|
|
const s = byId.get(id);
|
|
return s ? { ...s, stopOrder: i + 1 } : null;
|
|
})
|
|
.filter((s): s is RouteStop => s !== null);
|
|
setData((cur) => (cur ? { ...cur, stops: optimisticStops } : cur));
|
|
setManuallyReordered(true);
|
|
setReordering(true);
|
|
setError(null);
|
|
try {
|
|
const r = await fetch(`/api/routes/${encodeURIComponent(routeId)}/reorder`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ stopOrder: orderedIds }),
|
|
});
|
|
if (!r.ok) {
|
|
const body = await r.json().catch(() => ({}));
|
|
throw new Error(body.error || `Reorder failed (${r.status})`);
|
|
}
|
|
// Server recomputes travel legs, buffers and conflict flags — adopt its
|
|
// authoritative response over the optimistic guess.
|
|
setData(await r.json());
|
|
} catch (e) {
|
|
setData(previous); // rollback
|
|
setError(e instanceof Error ? e.message : "Reorder failed");
|
|
} finally {
|
|
setReordering(false);
|
|
}
|
|
},
|
|
[data]
|
|
);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
const ids = (data?.stops ?? []).map((s) => s.id);
|
|
const from = ids.indexOf(String(active.id));
|
|
const to = ids.indexOf(String(over.id));
|
|
if (from === -1 || to === -1) return;
|
|
void reorder(arrayMove(ids, from, to));
|
|
},
|
|
[data, reorder]
|
|
);
|
|
|
|
const mapStops: RouteMapStop[] = useMemo(
|
|
() =>
|
|
(data?.stops ?? []).map((s) => ({
|
|
id: s.id,
|
|
stopOrder: s.stopOrder,
|
|
latitude: s.latitude,
|
|
longitude: s.longitude,
|
|
clientName: s.clientName,
|
|
})),
|
|
[data]
|
|
);
|
|
|
|
// Pre-warm OSM map tiles for the route area whenever a route (re)loads or is
|
|
// re-optimized, so the map stays viewable offline. Runs after today's route is
|
|
// fetched on page load and after every optimize/reorder that yields new stops.
|
|
useEffect(() => {
|
|
if (mapStops.length > 0) prewarmRouteTiles(mapStops);
|
|
}, [mapStops]);
|
|
|
|
const stops = data?.stops ?? [];
|
|
const route = data?.route ?? null;
|
|
|
|
return (
|
|
<div style={{ padding: "1.25rem", maxWidth: 1280, margin: "0 auto" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", marginBottom: "1rem" }}>
|
|
<h1 style={{ fontSize: 22, fontWeight: 700, color: "#1a202c", margin: 0 }}>Route Planner</h1>
|
|
{route && <StatusBadge status={route.status} />}
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-end", marginBottom: "1rem" }}>
|
|
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 12, color: "#4b5563", fontWeight: 600 }}>
|
|
Date
|
|
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} style={inputStyle} />
|
|
</label>
|
|
|
|
{!isGroomer && (
|
|
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 12, color: "#4b5563", fontWeight: 600 }}>
|
|
Groomer
|
|
<select
|
|
value={staffId}
|
|
onChange={(e) => setStaffId(e.target.value)}
|
|
style={{ ...inputStyle, minWidth: 180 }}
|
|
>
|
|
{groomers.length === 0 && <option value="">No groomers</option>}
|
|
{groomers.map((g) => (
|
|
<option key={g.id} value={g.id}>
|
|
{g.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
|
|
<button
|
|
onClick={optimize}
|
|
disabled={optimizing || !staffId}
|
|
style={{
|
|
padding: "0.5rem 1.1rem",
|
|
borderRadius: 6,
|
|
border: "none",
|
|
background: primaryColor,
|
|
color: "#fff",
|
|
fontWeight: 600,
|
|
fontSize: 14,
|
|
cursor: optimizing || !staffId ? "wait" : "pointer",
|
|
opacity: optimizing || !staffId ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{optimizing ? "Optimizing…" : "Optimize"}
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.6rem 0.8rem", color: "#991b1b", fontSize: 13, marginBottom: "1rem" }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{data?.warnings && data.warnings.length > 0 && (
|
|
<div style={{ background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 6, padding: "0.6rem 0.8rem", color: "#92400e", fontSize: 13, marginBottom: "1rem" }}>
|
|
{data.warnings.map((w, i) => (
|
|
<div key={i}>{w}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Summary */}
|
|
<div style={{ display: "flex", gap: 24, flexWrap: "wrap", marginBottom: "1rem", padding: "0.8rem 1rem", background: "#fff", borderRadius: 8, border: "1px solid #e2e8f0" }}>
|
|
<Summary label="Stops" value={String(stops.length)} />
|
|
<Summary label="Total travel time" value={fmtDuration(route?.totalTravelMins)} />
|
|
<Summary label="Total distance" value={route?.totalDistanceKm != null ? `${route.totalDistanceKm} km` : "—"} />
|
|
</div>
|
|
|
|
{/* Navigation export — open the route in the device's maps app */}
|
|
{route && stops.length > 0 && (
|
|
<div style={{ marginBottom: "1rem", padding: "0.8rem 1rem", background: "#fff", borderRadius: 8, border: "1px solid #e2e8f0" }}>
|
|
<NavExportButtons routeId={route.id} primaryColor={primaryColor} fullWidth={isMobile} />
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "minmax(0, 1.5fr) minmax(280px, 1fr)", gap: 16, alignItems: "stretch" }}>
|
|
{/* Map */}
|
|
<div style={{ height: isMobile ? 340 : 540, background: "#e5e7eb", borderRadius: 8, overflow: "hidden", border: "1px solid #e2e8f0" }}>
|
|
{mapStops.length > 0 ? (
|
|
<Suspense fallback={<Centered>Loading map…</Centered>}>
|
|
<RouteMap stops={mapStops} primaryColor={primaryColor} />
|
|
</Suspense>
|
|
) : (
|
|
<Centered>{loading ? "Loading route…" : "No stops to display. Click Optimize to build the route."}</Centered>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stop list panel — drag-to-reorder */}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: isMobile ? "none" : 540, overflowY: isMobile ? "visible" : "auto" }}>
|
|
{stops.length === 0 && !loading && (
|
|
<div style={{ color: "#6b7280", fontSize: 14, padding: "1rem" }}>No stops for this day.</div>
|
|
)}
|
|
{stops.length > 0 && (
|
|
<>
|
|
{manuallyReordered && (
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 8, padding: "0.55rem 0.7rem" }}>
|
|
<span style={{ fontSize: 12, color: "#92400e" }}>
|
|
Stops reordered manually. Re-optimize to recompute the best route.
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={optimize}
|
|
disabled={optimizing || reordering || !staffId}
|
|
style={{
|
|
flexShrink: 0,
|
|
padding: "0.35rem 0.7rem",
|
|
borderRadius: 6,
|
|
border: "none",
|
|
background: primaryColor,
|
|
color: "#fff",
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
cursor: optimizing || reordering || !staffId ? "not-allowed" : "pointer",
|
|
opacity: optimizing || reordering || !staffId ? 0.6 : 1,
|
|
}}
|
|
>
|
|
{optimizing ? "Optimizing…" : "Re-optimize"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
|
{stops.map((s) => (
|
|
<SortableStop key={s.id} stop={s} primaryColor={primaryColor} disabled={reordering} />
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Summary({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div>
|
|
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 600 }}>{label}</div>
|
|
<div style={{ fontSize: 18, color: "#1a202c", fontWeight: 700 }}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Centered({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#6b7280", fontSize: 14, textAlign: "center", padding: "1rem" }}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|