diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 582f81e..8992ad7 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -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=`), 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:** diff --git a/package.json b/package.json index 0bffbb3..ec73670 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be1a567..03075fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/App.tsx b/src/App.tsx index b97da60..d15f9eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/src/__tests__/Routes.test.tsx b/src/__tests__/Routes.test.tsx new file mode 100644 index 0000000..935a697 --- /dev/null +++ b/src/__tests__/Routes.test.tsx @@ -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[] }) => ( +
map:{stops.length}
+ ), +})); + +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(); + + 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(); + 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(); + 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(); + + await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Optimize")); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/routes/optimize", + expect.objectContaining({ method: "POST" }) + ) + ); + }); +}); diff --git a/src/components/RouteMap.tsx b/src/components/RouteMap.tsx new file mode 100644 index 0000000..05e6cf4 --- /dev/null +++ b/src/components/RouteMap.tsx @@ -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: `
+ + + + ${order} +
`, + 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 ( + + + {line.length >= 2 && ( + + )} + {stops.map((s) => ( + + + {s.stopOrder}. {s.clientName} + + + ))} + + + ); +} diff --git a/src/pages/Routes.tsx b/src/pages/Routes.tsx new file mode 100644 index 0000000..5d62a30 --- /dev/null +++ b/src/pages/Routes.tsx @@ -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 = { + 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} +
+ ); +}