Promote dev → uat: GRO-2158 route planner page #61

Merged
Flea Flicker merged 1 commits from flea/dev-to-uat-gro-2158 into uat 2026-06-09 02:00:56 +00:00
7 changed files with 718 additions and 3 deletions
+20
View File
@@ -471,6 +471,26 @@ These cases guard against the regression where an SSO-bridge customer (no `?sess
| TC-WEB-5.26.3 | Impersonation flow reschedule is unchanged (no regression) | 1. With an active impersonation session (`?sessionId=<active>`), load `/`. 2. Click **Reschedule** on an appointment. 3. Pick a date. | `GET /api/book/availability` includes `X-Impersonation-Session-Id` equal to the impersonation `sessionId` (not `portalSessionId`). Returns 200. Behaves identically to the pre-fix build. |
| TC-WEB-5.26.4 | No `X-Impersonation-Session-Id` is empty / null | From TC-WEB-5.26.1, inspect every `/api/portal/*` and `/api/book/*` request. | No request has an empty or `null` `X-Impersonation-Session-Id` header. |
### 5.28 Route Planner Page (GRO-2158)
The admin Route Planner lives at `/admin/routes`. It shows a groomer's geocoded appointment stops for a chosen date on a `react-leaflet` / OpenStreetMap map (numbered pins + a connecting polyline), a stop-list panel, a travel-time/distance summary, a route status badge, and an **Optimize** button wired to `POST /api/routes/optimize`. Leaflet is loaded via a dynamic import so it ships as a separate code-split chunk. Groomers are auto-filtered to their own route (no groomer selector); managers/receptionists pick a groomer.
**Pre-conditions:**
- Sign in to `/admin` as a manager (e.g. uat-manager) and, separately, as a groomer (uat-groomer).
- At least one groomer has appointments on the test date whose clients have geocoded addresses.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.28.1 | Page loads and is reachable from nav | 1. Sign in as a manager. 2. Click **Routes** in the admin nav. | URL is `/admin/routes`. The "Route Planner" heading, a Date picker, a Groomer selector, and an **Optimize** button render. No console errors. |
| TC-WEB-5.28.2 | Leaflet map is code-split | 1. Open DevTools → Network (JS filter). 2. Load `/admin/reports` first, confirm no `RouteMap` chunk loads. 3. Navigate to `/admin/routes`. | A separate `RouteMap-*.js` chunk (and `RouteMap-*.css`) is fetched only when the Routes page renders, not on other admin pages. |
| TC-WEB-5.28.3 | Map shows numbered pins + polyline | Select a groomer + date that has a built route with ≥2 geocoded stops. | The OSM map renders with one numbered pin per stop (1, 2, 3…) and a polyline connecting them in order. Tile attribution to OpenStreetMap is visible. |
| TC-WEB-5.28.4 | Stop-list panel cards | Inspect the panel beside the map. | Each stop card shows the stop number, client name, appointment time, address, and travel time from the previous stop (stop 1 shows "Start of route"). |
| TC-WEB-5.28.5 | Summary + status badge | Inspect the summary bar and badge. | Stops count, total travel time, and total distance (km) are shown. A status badge reads one of Draft / Optimized / In progress / Completed matching the route's status. |
| TC-WEB-5.28.6 | Optimize button | Click **Optimize**. | A `POST /api/routes/optimize` with `{ staffId, date }` fires. On success the map, stop order, summary, and status badge refresh. Any skipped (non-geocoded) clients surface as a warning. |
| TC-WEB-5.28.7 | Groomer role auto-filter | Sign in as a groomer and open `/admin/routes`. | No groomer selector is shown. The page loads the signed-in groomer's own route for the selected date. The groomer cannot view another groomer's route. |
| TC-WEB-5.28.8 | Empty / no-route state | Select a date with no appointments. | The map area and stop panel show a friendly empty state ("No stops…"). No crash; **Optimize** is still clickable. |
## 6. Pass/Fail Criteria
**Pass:**
+3
View File
@@ -18,9 +18,11 @@
"@stripe/stripe-js": "^9.1.0",
"@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.5.6",
"leaflet": "^1.9.4",
"lucide-react": "^0.577.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.1.2",
"recharts": "^3.8.0",
"tailwindcss": "^4.2.2"
@@ -30,6 +32,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/leaflet": "^1.9.12",
"@types/node": "^25.6.0",
"@types/react": "^19.0.6",
"@types/react-dom": "^19.0.3",
+77 -3
View File
@@ -23,6 +23,9 @@ importers:
better-auth:
specifier: ^1.5.6
version: 1.6.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@3.2.4(@types/node@25.6.2)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.47.1))
leaflet:
specifier: ^1.9.4
version: 1.9.4
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.6)
@@ -32,12 +35,15 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.6(react@19.2.6)
react-leaflet:
specifier: ^5.0.0
version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom:
specifier: ^7.1.2
version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
recharts:
specifier: ^3.8.0
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@16.13.1)(react@19.2.6)(redux@5.0.1)
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1)
tailwindcss:
specifier: ^4.2.2
version: 4.3.0
@@ -54,6 +60,9 @@ importers:
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/leaflet':
specifier: ^1.9.12
version: 1.9.21
'@types/node':
specifier: ^25.6.0
version: 25.6.2
@@ -1005,6 +1014,13 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@react-leaflet/core@3.0.0':
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
peerDependencies:
leaflet: ^1.9.0
react: ^19.0.0
react-dom: ^19.0.0
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
@@ -1102,66 +1118,79 @@ packages:
resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.3':
resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.3':
resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.3':
resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.3':
resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.3':
resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.3':
resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.3':
resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.3':
resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.3':
resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.3':
resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.3':
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.3':
resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.3':
resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
@@ -1248,24 +1277,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
@@ -1387,9 +1420,15 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/node@25.6.2':
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
@@ -2462,6 +2501,9 @@ packages:
resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==}
engines: {node: '>=20.0.0'}
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@@ -2505,24 +2547,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -2764,6 +2810,13 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-leaflet@5.0.0:
resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
peerDependencies:
leaflet: ^1.9.0
react: ^19.0.0
react-dom: ^19.0.0
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@@ -4398,6 +4451,12 @@ snapshots:
dependencies:
playwright: 1.59.1
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
leaflet: 1.9.4
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -4711,8 +4770,14 @@ snapshots:
'@types/estree@1.0.9': {}
'@types/geojson@7946.0.16': {}
'@types/json-schema@7.0.15': {}
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
'@types/node@25.6.2':
dependencies:
undici-types: 7.19.2
@@ -5884,6 +5949,8 @@ snapshots:
kysely@0.28.17: {}
leaflet@1.9.4: {}
leven@3.1.0: {}
levn@0.4.1:
@@ -6135,6 +6202,13 @@ snapshots:
react-is@17.0.2: {}
react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
leaflet: 1.9.4
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
@@ -6162,7 +6236,7 @@ snapshots:
react@19.2.6: {}
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@16.13.1)(react@19.2.6)(redux@5.0.1):
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
clsx: 2.1.1
@@ -6172,7 +6246,7 @@ snapshots:
immer: 10.2.0
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-is: 16.13.1
react-is: 17.0.2
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
+3
View File
@@ -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>
+142
View File
@@ -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" })
)
);
});
});
+97
View File
@@ -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='&copy; <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>
);
}
+376
View File
@@ -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>
);
}