feat(GRO-2158): route planner page at /admin/routes (#60)
This commit was merged in pull request #60.
This commit is contained in:
@@ -8,6 +8,7 @@ import { StaffPage } from "./pages/Staff.js";
|
||||
import { InvoicesPage } from "./pages/Invoices.js";
|
||||
import { BookPage } from "./pages/Book.js";
|
||||
import { ReportsPage } from "./pages/Reports.js";
|
||||
import { RoutesPage } from "./pages/Routes.js";
|
||||
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
||||
import { SettingsPage } from "./pages/Settings.js";
|
||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||
@@ -175,6 +176,7 @@ const NAV_LINKS = [
|
||||
{ to: "/admin/staff", label: "Staff" },
|
||||
{ to: "/admin/invoices", label: "Invoices" },
|
||||
{ to: "/admin/group-bookings", label: "Group Bookings" },
|
||||
{ to: "/admin/routes", label: "Routes" },
|
||||
{ to: "/admin/reports", label: "Reports" },
|
||||
{ to: "/admin/settings", label: "Settings" },
|
||||
{ to: "/", label: "Customer Portal" },
|
||||
@@ -303,6 +305,7 @@ function AdminLayout() {
|
||||
<Route path="/invoices" element={<InvoicesPage />} />
|
||||
<Route path="/book" element={<BookPage />} />
|
||||
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
||||
<Route path="/routes" element={<RoutesPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { RoutesPage } from "../pages/Routes.tsx";
|
||||
|
||||
// Leaflet does not render in jsdom — replace the lazily-loaded map with a stub
|
||||
// that just reports the stop count so we can assert it received the route data.
|
||||
vi.mock("../components/RouteMap.js", () => ({
|
||||
default: ({ stops }: { stops: unknown[] }) => (
|
||||
<div data-testid="route-map">map:{stops.length}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const MANAGER = { id: "m1", name: "Manager", role: "manager", active: true };
|
||||
const GROOMER = { id: "g1", name: "Sam Groomer", role: "groomer", active: true };
|
||||
|
||||
const ROUTE_RESPONSE = {
|
||||
route: {
|
||||
id: "r1",
|
||||
staffId: "g1",
|
||||
routeDate: "2026-06-09",
|
||||
status: "optimized",
|
||||
totalTravelMins: 95,
|
||||
totalDistanceKm: "42.50",
|
||||
},
|
||||
stops: [
|
||||
{
|
||||
id: "s1",
|
||||
appointmentId: "a1",
|
||||
stopOrder: 1,
|
||||
latitude: 51.5,
|
||||
longitude: -0.1,
|
||||
travelMinsFromPrev: null,
|
||||
travelDistanceKmFromPrev: null,
|
||||
bufferMins: 15,
|
||||
appointmentStartTime: "2026-06-09T09:00:00.000Z",
|
||||
appointmentEndTime: "2026-06-09T10:00:00.000Z",
|
||||
appointmentStatus: "confirmed",
|
||||
clientId: "c1",
|
||||
clientName: "Alice",
|
||||
clientAddress: "1 High St",
|
||||
conflict: { hasConflict: false },
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
appointmentId: "a2",
|
||||
stopOrder: 2,
|
||||
latitude: 51.52,
|
||||
longitude: -0.12,
|
||||
travelMinsFromPrev: 20,
|
||||
travelDistanceKmFromPrev: "8.00",
|
||||
bufferMins: 15,
|
||||
appointmentStartTime: "2026-06-09T11:00:00.000Z",
|
||||
appointmentEndTime: "2026-06-09T12:00:00.000Z",
|
||||
appointmentStatus: "confirmed",
|
||||
clientId: "c2",
|
||||
clientName: "Bob",
|
||||
clientAddress: "2 Low St",
|
||||
conflict: { hasConflict: true },
|
||||
},
|
||||
],
|
||||
hasConflicts: true,
|
||||
conflictCount: 1,
|
||||
};
|
||||
|
||||
function mockFetch(meRole: "manager" | "groomer") {
|
||||
return vi.fn((url: string, opts?: RequestInit) => {
|
||||
if (url === "/api/staff/me") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(meRole === "manager" ? MANAGER : GROOMER),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/staff") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([MANAGER, GROOMER]) } as Response);
|
||||
}
|
||||
if (url.startsWith("/api/routes/daily")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response);
|
||||
}
|
||||
if (url === "/api/routes/optimize" && opts?.method === "POST") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("RoutesPage", () => {
|
||||
it("renders stop cards, summary, status badge and map for a manager", async () => {
|
||||
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||
render(<RoutesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 High St")).toBeInTheDocument();
|
||||
|
||||
// Summary: travel time formatted, distance shown
|
||||
expect(screen.getByText("1 h 35 min")).toBeInTheDocument();
|
||||
expect(screen.getByText("42.50 km")).toBeInTheDocument();
|
||||
|
||||
// Status badge
|
||||
expect(screen.getByText("Optimized")).toBeInTheDocument();
|
||||
|
||||
// First stop is start-of-route; second shows travel from previous
|
||||
expect(screen.getByText("Start of route")).toBeInTheDocument();
|
||||
expect(screen.getByText("20 min travel from previous")).toBeInTheDocument();
|
||||
|
||||
// Map received both stops (lazy chunk resolves asynchronously)
|
||||
expect(await screen.findByTestId("route-map")).toHaveTextContent("map:2");
|
||||
});
|
||||
|
||||
it("shows the groomer selector for managers", async () => {
|
||||
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||
render(<RoutesPage />);
|
||||
await waitFor(() => expect(screen.getByText("Groomer")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("hides the groomer selector for groomer role (auto-filtered)", async () => {
|
||||
global.fetch = mockFetch("groomer") as unknown as typeof fetch;
|
||||
render(<RoutesPage />);
|
||||
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||
expect(screen.queryByText("Groomer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls the optimize endpoint when Optimize is clicked", async () => {
|
||||
const fetchMock = mockFetch("manager");
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
render(<RoutesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText("Optimize"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/routes/optimize",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
MapContainer,
|
||||
TileLayer,
|
||||
Marker,
|
||||
Polyline,
|
||||
Tooltip,
|
||||
useMap,
|
||||
} from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// This component is loaded via React.lazy from the route planner page so that
|
||||
// Leaflet + react-leaflet land in a separate code-split chunk and never weigh
|
||||
// down the main admin bundle.
|
||||
|
||||
export interface RouteMapStop {
|
||||
id: string;
|
||||
stopOrder: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
clientName: string;
|
||||
}
|
||||
|
||||
interface RouteMapProps {
|
||||
stops: RouteMapStop[];
|
||||
primaryColor: string;
|
||||
}
|
||||
|
||||
/** A numbered teardrop pin rendered as an inline-SVG divIcon (no image assets). */
|
||||
function numberedIcon(order: number, color: string): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: "route-stop-pin",
|
||||
html: `<div style="position:relative;width:28px;height:40px">
|
||||
<svg width="28" height="40" viewBox="0 0 28 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 0C6.27 0 0 6.27 0 14c0 9.5 14 26 14 26s14-16.5 14-26C28 6.27 21.73 0 14 0z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/>
|
||||
</svg>
|
||||
<span style="position:absolute;top:5px;left:0;width:28px;text-align:center;color:#fff;font-size:13px;font-weight:700;font-family:system-ui,sans-serif">${order}</span>
|
||||
</div>`,
|
||||
iconSize: [28, 40],
|
||||
iconAnchor: [14, 40],
|
||||
tooltipAnchor: [0, -34],
|
||||
});
|
||||
}
|
||||
|
||||
/** Keeps the viewport framed around all stops whenever the route changes. */
|
||||
function FitBounds({ stops }: { stops: RouteMapStop[] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (stops.length === 0) return;
|
||||
const latlngs = stops.map((s) => [s.latitude, s.longitude] as [number, number]);
|
||||
if (latlngs.length === 1) {
|
||||
map.setView(latlngs[0]!, 14);
|
||||
} else {
|
||||
map.fitBounds(L.latLngBounds(latlngs), { padding: [40, 40] });
|
||||
}
|
||||
}, [map, stops]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RouteMap({ stops, primaryColor }: RouteMapProps) {
|
||||
// Fallback centre (London) only used briefly before FitBounds runs or when the
|
||||
// route has no geocoded stops.
|
||||
const center: [number, number] = stops[0]
|
||||
? [stops[0].latitude, stops[0].longitude]
|
||||
: [51.505, -0.09];
|
||||
const line = stops.map((s) => [s.latitude, s.longitude] as [number, number]);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={12}
|
||||
scrollWheelZoom
|
||||
style={{ height: "100%", width: "100%", borderRadius: 8 }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{line.length >= 2 && (
|
||||
<Polyline positions={line} color={primaryColor} weight={4} opacity={0.7} />
|
||||
)}
|
||||
{stops.map((s) => (
|
||||
<Marker
|
||||
key={s.id}
|
||||
position={[s.latitude, s.longitude]}
|
||||
icon={numberedIcon(s.stopOrder, primaryColor)}
|
||||
>
|
||||
<Tooltip>
|
||||
{s.stopOrder}. {s.clientName}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
<FitBounds stops={stops} />
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
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<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,
|
||||
};
|
||||
|
||||
// ─── 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 [error, setError] = useState<string | null>(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 (
|
||||
<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>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) minmax(280px, 1fr)", gap: 16, alignItems: "stretch" }}>
|
||||
{/* Map */}
|
||||
<div style={{ height: 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 */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: 540, overflowY: "auto" }}>
|
||||
{stops.length === 0 && !loading && (
|
||||
<div style={{ color: "#6b7280", fontSize: 14, padding: "1rem" }}>No stops for this day.</div>
|
||||
)}
|
||||
{stops.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: `1px solid ${s.conflict?.hasConflict ? "#fca5a5" : "#e2e8f0"}`,
|
||||
borderRadius: 8,
|
||||
padding: "0.7rem 0.85rem",
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: "50%",
|
||||
background: primaryColor,
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{s.stopOrder}
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
|
||||
<strong style={{ fontSize: 14, color: "#1a202c" }}>{s.clientName}</strong>
|
||||
<span style={{ fontSize: 13, color: "#4b5563", whiteSpace: "nowrap" }}>{fmtTime(s.appointmentStartTime)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>{s.clientAddress || "No address on file"}</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 4 }}>
|
||||
{s.stopOrder === 1 || s.travelMinsFromPrev == null
|
||||
? "Start of route"
|
||||
: `${fmtDuration(s.travelMinsFromPrev)} travel from previous`}
|
||||
</div>
|
||||
{s.conflict?.hasConflict && (
|
||||
<div style={{ fontSize: 12, color: "#b91c1c", marginTop: 4, fontWeight: 600 }}>
|
||||
⚠ Tight schedule — travel + buffer may exceed the gap
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user