Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34a5f2b775 | |||
| db11e5f2bd | |||
| 980615b8e6 | |||
| f549101962 | |||
| 62dc85b560 | |||
| bc21d6de09 | |||
| 32ef3bca4d | |||
| 2b494c01f8 | |||
| 3397767a01 | |||
| 47c29ecbc2 | |||
| f0c58c193c | |||
| de7386e47a | |||
| 4600dcf950 | |||
| ec29f71974 | |||
| bd2a0d9516 | |||
| 0e5e9d1f16 | |||
| 3b4d0f15f6 | |||
| 87939e5413 | |||
| 4e3a038bf3 | |||
| 8349ea00de | |||
| 0306c7fbd9 | |||
| 93da2f1dd8 | |||
| 62cbfe4e43 | |||
| db6a2a1bbf | |||
| 032a3796ba | |||
| cac8fc947e | |||
| 592be1301c |
+96
-6
@@ -182,22 +182,30 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
|
||||
| TC-WEB-5.12.2 | Appointment list | View client portal appointments | List of client's appointments visible |
|
||||
| TC-WEB-5.12.2 | Appointment list | View client portal appointments | List of client's appointments visible — each card shows pet name, service, formatted date/time, and groomer (no "Failed to load appointments" error, no blank screen). "Book New" button is visible and clickable. See 5.12d. |
|
||||
| TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
|
||||
| TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
|
||||
|
||||
#### 5.12b Dynamic Portal Time Slots (GRO-1793)
|
||||
#### 5.12b Dynamic Portal Time Slots (GRO-1793, GRO-2105)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | Time slots fetched from API; "Checking availability…" shown while loading |
|
||||
| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | `GET /api/book/availability?serviceId=<selected>&date=<picked>`; "Checking availability…" shown while loading; slot list rendered |
|
||||
| TC-WEB-5.12.6 | BookingFlow slots match wizard | Compare BookingFlow slot times with public booking wizard for same date | Same slots displayed |
|
||||
| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown |
|
||||
| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) |
|
||||
| TC-WEB-5.12.8 | BookingFlow no slots | Select date with no availability | "No available slots on this date" shown |
|
||||
| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | Time slots fetched from API; loading state shown |
|
||||
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown |
|
||||
| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>`; loading state shown; slot list rendered |
|
||||
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) |
|
||||
| TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown |
|
||||
|
||||
> **GRO-2105 regression note:** prior to the fix, both `BookingFlow` and
|
||||
> `RescheduleFlow` called `/api/book/availability` with only `date=…`, so the
|
||||
> API responded 400 `{error:"serviceId and date are required"}`. The React
|
||||
> handler then `.map()`'d that error object, throwing `TypeError: ee.map is
|
||||
> not a function` and wiping `<div id="root">`. The fix ensures both flows
|
||||
> include `serviceId` in the query string and surface the API's error string
|
||||
> (or "Failed to load time slots") instead of crashing.
|
||||
|
||||
#### 5.12c Waitlist/Booking Status Badges (GRO-1795)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
@@ -209,6 +217,54 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
|
||||
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
|
||||
|
||||
#### 5.12d Appointment API Shape Normalization (GRO-2180)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.18 | Portal appointments load (regression) | Sign in as `uat-customer@groombook.dev`, open `Appointments` | List renders without the "Failed to load appointments. Please try again." error; "Book New" button is visible and clickable |
|
||||
| TC-WEB-5.12.19 | Card fields populated from API | Inspect an appointment card | Pet name, service, formatted date (e.g. "Mon, Jun 1, 2026"), time (e.g. "10:00 AM"), and groomer name render — derived from the API's `startTime`/`endTime`/nested `pet`/`staff` objects |
|
||||
| TC-WEB-5.12.20 | Upcoming vs Past split | View both tabs | Future, non-cancelled/non-completed appointments appear under "Upcoming"; past/completed/cancelled under "Past" (classification uses absolute `startTime`) |
|
||||
| TC-WEB-5.12.21 | Reschedule from card | Expand an upcoming appointment, click Reschedule, pick a date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>` fires with a non-empty `serviceId` (sourced from the API's nested `service.id`) |
|
||||
|
||||
> **GRO-2180 regression note:** `/api/portal/appointments` returns ISO
|
||||
> `startTime`/`endTime` and nested `pet`/`service`/`staff` objects, but the portal
|
||||
> client `Appointment` type expected flat `date`/`time`/`petName` fields.
|
||||
> `isUpcoming()` read `appt.date`/`appt.time` (both `undefined`), so
|
||||
> `parseTimeTo24Hour(undefined)` threw `TypeError`, the `useEffect` `try/catch`
|
||||
> set the error state, and the "Book New" button (only rendered in the success
|
||||
> path) became unreachable. The fix normalizes the API response into the flat
|
||||
> `Appointment` shape at the fetch boundary (`normalizeAppointment`), prefers the
|
||||
> absolute `startTime` in `isUpcoming`, and hardens `parseTimeTo24Hour` against
|
||||
> blank/undefined input.
|
||||
|
||||
#### 5.12e Book New `preferredTime` Formatting (GRO-2211, GRO-2213)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.22 | Slot buttons show formatted label | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", select a pet and service, pick a date with availability | Each time-slot button shows a human-readable label like `10:00 AM` (UTC), never a raw ISO timestamp (e.g. not `2026-06-09T10:00:00.000Z`) |
|
||||
| TC-WEB-5.12.23 | Confirmation review shows formatted label | Continue the Book New wizard to the Review step | The "Date & Time" summary and the final confirmation both display the formatted slot label (e.g. `10:00 AM`), not a raw ISO string |
|
||||
| TC-WEB-5.12.24 | Booking submit succeeds (regression) | Complete the Book New wizard and submit the request | Request succeeds with no `500` / `invalid input syntax for type time` error; the booking POST sends `preferredTime` as `HH:MM:SS` (e.g. `10:00:00`); the new appointment appears in the Upcoming list |
|
||||
| TC-WEB-5.12.25 | Slow-wizard submit succeeds (GRO-2234) | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", then deliberately pace the wizard (pet → service → groomer → date/slot → review) so that **>2 minutes** elapse before clicking "Confirm Booking". | Submit returns success — **no** "Failed to book appointment. Please try again." error. In DevTools → Network, if the first `POST /api/portal/waitlist` returns `401`, a `POST /api/portal/session-from-auth` fires immediately after and the booking is retried once with the fresh `X-Impersonation-Session-Id`, then returns 201. The appointment appears in the Upcoming list. |
|
||||
|
||||
> **GRO-2234 note:** A deliberately-paced Book New wizard could outlive the
|
||||
> portal impersonation session, so the final `POST /api/portal/waitlist` returned
|
||||
> `401 {"error":"Unauthorized"}` ("Failed to book appointment"). The web fix adds
|
||||
> a transparent one-shot re-mint: on a `401` from the waitlist submit,
|
||||
> `BookingFlow` calls `POST /api/portal/session-from-auth` (the Better Auth
|
||||
> cookie is still valid) and retries the submit once with the fresh session id.
|
||||
> The companion API fix (groombook/api GRO-2234) adds bounded sliding expiration
|
||||
> so active sessions rarely lapse in the first place.
|
||||
|
||||
> **GRO-2211/GRO-2213 note:** The Book New wizard previously rendered the raw
|
||||
> UTC ISO slot string as the button/confirmation label and submitted that same
|
||||
> ISO value as `preferredTime`, which the API rejected with
|
||||
> `invalid input syntax for type time` (HTTP 500). The fix adds shared UTC
|
||||
> helpers `formatSlotLabel(slot)` (display → `10:00 AM`) and `slotToTime(slot)`
|
||||
> (payload → `HH:MM:SS`) in `src/portal/sections/Appointments.tsx`, so the
|
||||
> displayed label and the submitted `preferredTime` both derive from the same
|
||||
> canonical UTC ISO slot. (The sibling `RescheduleFlow` `startTime` raw-ISO issue
|
||||
> on a different endpoint is tracked separately and is out of scope here.)
|
||||
|
||||
### 5.13 Reports UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
@@ -415,6 +471,40 @@ 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. |
|
||||
|
||||
### 5.29 Route Planner — Drag-to-Reorder & Re-optimize (GRO-2159)
|
||||
|
||||
The stop-list panel is drag-sortable (`@dnd-kit`). Each stop card has a grab handle (⠿). Dropping a stop in a new position calls `PATCH /api/routes/:routeId/reorder` with `{ stopOrder: [routeStopId…] }` (full first-to-last order); the UI updates optimistically and rolls back on error. The server recomputes per-leg travel, buffers, totals and tight-schedule conflict flags, and the panel/map/summary adopt the response. A "tight schedule" warning is shown on any stop whose gap is shorter than its travel + buffer. After a manual reorder a hint with a **Re-optimize** button appears (re-runs `POST /api/routes/optimize`). Drag works via mouse (desktop), press-and-hold touch (mobile groomers), and keyboard (focus handle → Space → arrows → Space).
|
||||
|
||||
| Test Case | Description | Steps | Expected Result |
|
||||
|-----------|-------------|-------|-----------------|
|
||||
| TC-WEB-5.29.1 | Drag handle present | Open `/admin/routes` for a route with ≥2 stops. | Each stop card shows a grab handle (⠿) with an accessible label "Drag to reorder <client>". |
|
||||
| TC-WEB-5.29.2 | Reorder persists | Drag a stop to a new position and drop it. | A `PATCH /api/routes/:routeId/reorder` fires with the new `stopOrder` (every stop id once, new order). Stop numbers, the map polyline order, and travel-from-previous labels refresh to match. |
|
||||
| TC-WEB-5.29.3 | Optimistic update + rollback | Simulate a failing reorder (e.g. server returns an error / offline). | The list shows the new order immediately, then reverts to the prior order when the PATCH fails, and an error message is shown. No stuck/partial order. |
|
||||
| TC-WEB-5.29.4 | Tight-schedule warning re-evaluated | Reorder so two stops are too close together. | The affected stop card shows "⚠ Tight schedule — travel + buffer may exceed the gap" (red border) after the server recomputes; warnings clear on a roomier order. |
|
||||
| TC-WEB-5.29.5 | Re-optimize button | After a manual drag reorder, locate the hint banner. | A "Stops reordered manually…" hint with a **Re-optimize** button appears. Clicking it fires `POST /api/routes/optimize` and the hint clears once the optimized route loads. The hint is absent before any manual reorder. |
|
||||
| TC-WEB-5.29.6 | Touch / mobile drag | On a touch device (or mobile emulation), press-and-hold a stop's handle (~200ms) then drag. | The stop lifts and can be dropped in a new position; page scroll is not hijacked by a quick swipe. Reorder persists as in 5.29.2. |
|
||||
| TC-WEB-5.29.7 | Groomer reorders own route | Sign in as a groomer, reorder stops on the own route. | Reorder succeeds (groomer is authorized for their own route). |
|
||||
|
||||
## 6. Pass/Fail Criteria
|
||||
|
||||
**Pass:**
|
||||
|
||||
@@ -13,14 +13,19 @@
|
||||
"test:e2e": "playwright test -c e2e/playwright.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@groombook/types": "workspace:*",
|
||||
"@stripe/react-stripe-js": "^6.1.0",
|
||||
"@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 +35,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",
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface Pet {
|
||||
breed: string | null;
|
||||
weightKg: number | null;
|
||||
dateOfBirth: string | null;
|
||||
/** Portal-shaped serialization of weightKg (GET/PATCH /api/portal/pets). */
|
||||
weight?: string | number | null;
|
||||
/** Portal-shaped serialization of dateOfBirth (GET/PATCH /api/portal/pets). */
|
||||
birthDate?: string | null;
|
||||
healthAlerts: string | null;
|
||||
groomingNotes: string | null;
|
||||
cutStyle: string | null;
|
||||
|
||||
Generated
+138
-3
@@ -8,6 +8,15 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.6)
|
||||
'@groombook/types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/types
|
||||
@@ -23,6 +32,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 +44,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 +69,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
|
||||
@@ -739,6 +757,28 @@ packages:
|
||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.3.1':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1005,6 +1045,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 +1149,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 +1308,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 +1451,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 +2532,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 +2578,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 +2841,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:
|
||||
@@ -3123,6 +3207,9 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4208,6 +4295,31 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.6)':
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1(react@19.2.6)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.6)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.6)
|
||||
react: 19.2.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@19.2.6)':
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
@@ -4398,6 +4510,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 +4829,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 +6008,8 @@ snapshots:
|
||||
|
||||
kysely@0.28.17: {}
|
||||
|
||||
leaflet@1.9.4: {}
|
||||
|
||||
leven@3.1.0: {}
|
||||
|
||||
levn@0.4.1:
|
||||
@@ -6135,6 +6261,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 +6295,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 +6305,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
|
||||
@@ -6553,6 +6686,8 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection, StatusBadge } from "../portal/sections/Appointments.tsx";
|
||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
const UPCOMING_APPT = {
|
||||
id: "appt-1",
|
||||
@@ -42,6 +42,84 @@ describe("parseTimeTo24Hour", () => {
|
||||
expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00");
|
||||
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
|
||||
});
|
||||
|
||||
it("does not throw on undefined/null/empty input (GRO-2180)", () => {
|
||||
expect(() => parseTimeTo24Hour(undefined)).not.toThrow();
|
||||
expect(() => parseTimeTo24Hour(null)).not.toThrow();
|
||||
expect(parseTimeTo24Hour(undefined)).toBe("00:00:00");
|
||||
expect(parseTimeTo24Hour("")).toBe("00:00:00");
|
||||
});
|
||||
});
|
||||
|
||||
// GRO-2180: `/api/portal/appointments` returns ISO `startTime`/`endTime` + nested
|
||||
// pet/service/staff objects, not the flat date/time/petName shape the UI renders.
|
||||
describe("normalizeAppointment (API startTime shape — GRO-2180)", () => {
|
||||
const RAW_API_APPT = {
|
||||
id: "a0000001-0000-0000-0000-000000000001",
|
||||
startTime: "2026-06-01T10:00:00.000Z",
|
||||
endTime: "2026-06-01T10:45:00.000Z",
|
||||
status: "completed" as const,
|
||||
confirmationStatus: "confirmed" as const,
|
||||
customerNotes: "Please be gentle",
|
||||
notes: null,
|
||||
pet: { id: "c0000001-0000-0000-0000-000000000001", name: "UAT Pup Alpha", photo: null },
|
||||
service: { id: "b0000001-0000-0000-0000-000000000001", name: "Full Groom" },
|
||||
staff: { id: "00000000-0000-0000-0000-000000000004", name: "UAT Staff Groomer" },
|
||||
};
|
||||
|
||||
it("maps nested pet/service/staff and ISO startTime without throwing", () => {
|
||||
const appt = normalizeAppointment(RAW_API_APPT);
|
||||
expect(appt.id).toBe("a0000001-0000-0000-0000-000000000001");
|
||||
expect(appt.petId).toBe("c0000001-0000-0000-0000-000000000001");
|
||||
expect(appt.serviceId).toBe("b0000001-0000-0000-0000-000000000001");
|
||||
expect(appt.groomerId).toBe("00000000-0000-0000-0000-000000000004");
|
||||
expect(appt.petName).toBe("UAT Pup Alpha");
|
||||
expect(appt.serviceName).toBe("Full Groom");
|
||||
expect(appt.groomerName).toBe("UAT Staff Groomer");
|
||||
expect(appt.startTime).toBe("2026-06-01T10:00:00.000Z");
|
||||
expect(appt.customerNotes).toBe("Please be gentle");
|
||||
});
|
||||
|
||||
it("derives duration in minutes from start/end delta", () => {
|
||||
expect(normalizeAppointment(RAW_API_APPT).duration).toBe(45);
|
||||
});
|
||||
|
||||
it("produces a date/time pair that does not crash isUpcoming or formatDate", () => {
|
||||
const appt = normalizeAppointment(RAW_API_APPT);
|
||||
expect(typeof appt.date).toBe("string");
|
||||
expect(typeof appt.time).toBe("string");
|
||||
expect(() => isUpcoming(appt)).not.toThrow();
|
||||
});
|
||||
|
||||
it("classifies a past completed appointment as not upcoming", () => {
|
||||
expect(isUpcoming(normalizeAppointment(RAW_API_APPT))).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies a future scheduled appointment as upcoming via startTime", () => {
|
||||
const future = normalizeAppointment({
|
||||
...RAW_API_APPT,
|
||||
startTime: "2099-01-01T10:00:00.000Z",
|
||||
endTime: "2099-01-01T11:00:00.000Z",
|
||||
status: "confirmed",
|
||||
});
|
||||
expect(isUpcoming(future)).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates null nested objects without throwing", () => {
|
||||
const appt = normalizeAppointment({
|
||||
id: "a2",
|
||||
startTime: "2099-01-01T10:00:00.000Z",
|
||||
endTime: "2099-01-01T11:00:00.000Z",
|
||||
status: "scheduled",
|
||||
pet: null,
|
||||
service: null,
|
||||
staff: null,
|
||||
});
|
||||
expect(appt.petId).toBe("");
|
||||
expect(appt.serviceId).toBe("");
|
||||
expect(appt.groomerId).toBeNull();
|
||||
expect(appt.petName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUpcoming", () => {
|
||||
@@ -530,7 +608,7 @@ describe("RescheduleFlow dynamic time slots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("calls /api/book/availability with the selected date", async () => {
|
||||
it("calls /api/book/availability with the serviceId and selected date", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ["9:00 AM"] as string[],
|
||||
@@ -544,7 +622,7 @@ describe("RescheduleFlow dynamic time slots", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/book/availability?date=2027-02-20",
|
||||
"/api/book/availability?serviceId=service-1&date=2027-02-20",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }),
|
||||
})
|
||||
@@ -552,6 +630,41 @@ describe("RescheduleFlow dynamic time slots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when API returns a 4xx error object instead of an array", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ error: "serviceId and date are required" }),
|
||||
} as Response);
|
||||
|
||||
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/serviceId and date are required/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows generic error when API returns 200 but body is not an array", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ error: "serviceId and date are required" }),
|
||||
} as Response);
|
||||
|
||||
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("re-fetches slots when date changes", async () => {
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -577,4 +690,248 @@ describe("RescheduleFlow dynamic time slots", () => {
|
||||
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("slot helpers (GRO-2213)", () => {
|
||||
it("formatSlotLabel formats a canonical UTC ISO slot to a UTC clock label", () => {
|
||||
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).toBe("10:00 AM");
|
||||
expect(formatSlotLabel("2026-06-09T14:30:00.000Z")).toBe("2:30 PM");
|
||||
expect(formatSlotLabel("2026-06-09T09:00:00.000Z")).toBe("9:00 AM");
|
||||
});
|
||||
|
||||
it("formatSlotLabel never echoes a raw ISO string", () => {
|
||||
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("formatSlotLabel passes through an already-formatted label unchanged", () => {
|
||||
expect(formatSlotLabel("10:00 AM")).toBe("10:00 AM");
|
||||
});
|
||||
|
||||
it("slotToTime extracts the UTC HH:MM:SS time component from an ISO slot", () => {
|
||||
expect(slotToTime("2026-06-09T10:00:00.000Z")).toBe("10:00:00");
|
||||
expect(slotToTime("2026-06-09T14:30:00.000Z")).toBe("14:30:00");
|
||||
expect(slotToTime("2026-06-09T10:00:00.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("slotToTime guards a value that is already HH:MM:SS", () => {
|
||||
expect(slotToTime("10:00:00")).toBe("10:00:00");
|
||||
});
|
||||
|
||||
it("slotToTime converts a 12-hour label fallback to HH:MM:SS", () => {
|
||||
expect(slotToTime("9:00 AM")).toBe("09:00:00");
|
||||
expect(slotToTime("2:30 PM")).toBe("14:30:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BookingFlow Book New funnel (GRO-2213)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
function routedFetch(captured: { waitlistBody?: Record<string, unknown> }) {
|
||||
return (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/portal/pets")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/portal/services")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/book/availability")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ["2026-06-09T10:00:00.000Z", "2026-06-09T14:30:00.000Z"],
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/portal/waitlist")) {
|
||||
captured.waitlistBody = JSON.parse((init?.body as string) ?? "{}");
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
};
|
||||
}
|
||||
|
||||
it("renders formatted slot labels (not raw ISO) and submits preferredTime as HH:MM:SS", async () => {
|
||||
const captured: { waitlistBody?: Record<string, unknown> } = {};
|
||||
vi.mocked(global.fetch).mockImplementation(routedFetch(captured) as typeof fetch);
|
||||
|
||||
render(<BookingFlow onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
// Step 1 — pick pet (auto-advances to step 2)
|
||||
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText("Buddy"));
|
||||
|
||||
// Step 2 — pick service, then Next
|
||||
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText("Bath & Brush"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||
|
||||
// Step 3 — groomer, Next
|
||||
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||
|
||||
// Step 4 — date + slot
|
||||
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
|
||||
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
|
||||
|
||||
// Slot button shows the formatted UTC label, never the raw ISO
|
||||
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
|
||||
expect(screen.queryByText(/2026-06-09T10:00:00/)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("10:00 AM"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||
|
||||
// Step 5 — review shows the formatted label
|
||||
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/10:00 AM/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
|
||||
|
||||
await waitFor(() => expect(captured.waitlistBody).toBeDefined());
|
||||
const body = captured.waitlistBody ?? {};
|
||||
expect(body.preferredTime).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
expect(body.preferredTime).toBe("10:00:00");
|
||||
expect(body.preferredDate).toBe("2026-06-09");
|
||||
});
|
||||
|
||||
it("re-mints the portal session and retries once when waitlist returns 401 (GRO-2234)", async () => {
|
||||
const calls = { waitlist: 0, remint: 0 };
|
||||
const waitlistHeaders: string[] = [];
|
||||
const routed = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/portal/pets")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/portal/services")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/book/availability")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ["2026-06-09T10:00:00.000Z"],
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/portal/session-from-auth")) {
|
||||
calls.remint += 1;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ sessionId: "fresh-session-id", clientId: "c1", clientName: "Jane" }),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/portal/waitlist")) {
|
||||
calls.waitlist += 1;
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
waitlistHeaders.push(headers["X-Impersonation-Session-Id"] ?? "");
|
||||
// First attempt: session lapsed → 401. Retry after re-mint: success.
|
||||
if (calls.waitlist === 1) {
|
||||
return Promise.resolve({ ok: false, status: 401, json: async () => ({ error: "Unauthorized" }) } as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, status: 201, json: async () => ({}) } as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
};
|
||||
global.fetch = vi.fn().mockImplementation(routed as typeof fetch);
|
||||
|
||||
render(<BookingFlow onClose={() => {}} sessionId="stale-session-id" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText("Buddy"));
|
||||
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText("Bath & Brush"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
|
||||
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
|
||||
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText("10:00 AM"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
|
||||
|
||||
// Re-mint happened exactly once, waitlist retried with the fresh id, and the
|
||||
// booking succeeded (no error surfaced).
|
||||
await waitFor(() => expect(calls.waitlist).toBe(2));
|
||||
expect(calls.remint).toBe(1);
|
||||
expect(waitlistHeaders).toEqual(["stale-session-id", "fresh-session-id"]);
|
||||
expect(screen.queryByText(/Failed to book appointment/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeService", () => {
|
||||
it("maps API basePriceCents/durationMinutes to price (dollars)/duration", () => {
|
||||
const svc = normalizeService({
|
||||
id: "svc-1",
|
||||
name: "Full Groom",
|
||||
basePriceCents: 4500,
|
||||
durationMinutes: 60,
|
||||
});
|
||||
expect(svc.price).toBe(45);
|
||||
expect(svc.duration).toBe(60);
|
||||
});
|
||||
|
||||
it("preserves an already-normalized payload (price/duration)", () => {
|
||||
const svc = normalizeService({
|
||||
id: "svc-2",
|
||||
name: "Bath",
|
||||
price: 30,
|
||||
duration: 30,
|
||||
});
|
||||
expect(svc.price).toBe(30);
|
||||
expect(svc.duration).toBe(30);
|
||||
});
|
||||
|
||||
it("leaves price/duration undefined when both source shapes are absent", () => {
|
||||
const svc = normalizeService({ id: "svc-3", name: "Mystery" });
|
||||
expect(svc.price).toBeUndefined();
|
||||
expect(svc.duration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("coerces null fields to undefined", () => {
|
||||
const svc = normalizeService({
|
||||
id: "svc-4",
|
||||
name: "Nail Trim",
|
||||
basePriceCents: null,
|
||||
durationMinutes: null,
|
||||
description: null,
|
||||
});
|
||||
expect(svc.price).toBeUndefined();
|
||||
expect(svc.duration).toBeUndefined();
|
||||
expect(svc.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatServicePrice", () => {
|
||||
it("prefers an explicit priceRange string", () => {
|
||||
expect(formatServicePrice({ priceRange: "$40–$60", price: 45 })).toBe("$40–$60");
|
||||
});
|
||||
|
||||
it("formats integer dollars without trailing zeros", () => {
|
||||
expect(formatServicePrice({ price: 45 })).toBe("$45");
|
||||
});
|
||||
|
||||
it("formats fractional dollars to cents", () => {
|
||||
expect(formatServicePrice({ price: 45.5 })).toBe("$45.50");
|
||||
});
|
||||
|
||||
it("returns null when no price is available (never '$undefined')", () => {
|
||||
expect(formatServicePrice({})).toBeNull();
|
||||
expect(formatServicePrice({ price: undefined })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,4 +154,12 @@ describe("PetForm", () => {
|
||||
expect(screen.getByText("Anxious")).toBeTruthy();
|
||||
expect(screen.getByText("Good with kids")).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Weight pre-fill from portal `weight` key (GRO-2207) ───────────────────────
|
||||
|
||||
it("pre-fills weight from the portal `weight` key when weightKg is absent", () => {
|
||||
const portalPet: Pet = { ...BASE_PET, weightKg: null, weight: "12.50" };
|
||||
render(<PetForm pet={portalPet} onSave={onSave} onCancel={onCancel} />);
|
||||
expect(screen.getByDisplayValue(12.5)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { BasicInfoTab, formatSizeCategory } from "../portal/sections/PetProfiles.js";
|
||||
import type { Pet } from "@groombook/types";
|
||||
|
||||
// The portal endpoint (GET /api/portal/pets) serializes DB columns under
|
||||
// portal-shaped keys: weightKg→weight, dateOfBirth→birthDate. The read view
|
||||
// must surface those keys (GRO-2207), not the raw staff-side weightKg/dateOfBirth.
|
||||
const PORTAL_PET: Pet = {
|
||||
id: "pet-1",
|
||||
clientId: "client-1",
|
||||
name: "Pup Alpha",
|
||||
species: "dog",
|
||||
breed: "Poodle",
|
||||
// Staff-shaped keys intentionally null — only the portal keys are populated,
|
||||
// proving the read view reads `weight`/`birthDate`.
|
||||
weightKg: null,
|
||||
dateOfBirth: null,
|
||||
weight: "12.50",
|
||||
birthDate: "2022-03-10T00:00:00.000Z",
|
||||
petSizeCategory: "extra_large",
|
||||
healthAlerts: null,
|
||||
groomingNotes: null,
|
||||
cutStyle: null,
|
||||
shampooPreference: null,
|
||||
specialCareNotes: null,
|
||||
customFields: {},
|
||||
coatType: null,
|
||||
preferredCuts: [],
|
||||
medicalAlerts: [],
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("BasicInfoTab read view (GRO-2207)", () => {
|
||||
it("renders Weight from the portal `weight` key", () => {
|
||||
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
|
||||
expect(screen.getByText("12.50 kg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Date of Birth from the portal `birthDate` key", () => {
|
||||
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
|
||||
expect(screen.getByText("March 10, 2022")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Size Category formatted from petSizeCategory", () => {
|
||||
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
|
||||
expect(screen.getByText("Size Category")).toBeInTheDocument();
|
||||
expect(screen.getByText("Extra Large")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to staff-shaped keys when portal keys are absent", () => {
|
||||
const staffShaped: Pet = { ...PORTAL_PET, weight: null, birthDate: null, weightKg: 25, dateOfBirth: "2020-01-05T00:00:00.000Z" };
|
||||
render(<BasicInfoTab pet={staffShaped} readOnly />);
|
||||
expect(screen.getByText("25 kg")).toBeInTheDocument();
|
||||
expect(screen.getByText("January 5, 2020")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Unknown for missing weight/DoB and size", () => {
|
||||
const empty: Pet = { ...PORTAL_PET, weight: null, birthDate: null, weightKg: null, dateOfBirth: null, petSizeCategory: null };
|
||||
render(<BasicInfoTab pet={empty} readOnly />);
|
||||
// Weight, Date of Birth and Size Category rows all read "Unknown".
|
||||
expect(screen.getAllByText("Unknown").length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSizeCategory", () => {
|
||||
it("title-cases each underscore-separated segment", () => {
|
||||
expect(formatSizeCategory("extra_large")).toBe("Extra Large");
|
||||
expect(formatSizeCategory("small")).toBe("Small");
|
||||
expect(formatSizeCategory("medium")).toBe("Medium");
|
||||
expect(formatSizeCategory("large")).toBe("Large");
|
||||
});
|
||||
|
||||
it("returns Unknown for null/undefined/empty", () => {
|
||||
expect(formatSizeCategory(null)).toBe("Unknown");
|
||||
expect(formatSizeCategory(undefined)).toBe("Unknown");
|
||||
expect(formatSizeCategory("")).toBe("Unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
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);
|
||||
}
|
||||
if (/^\/api\/routes\/[^/]+\/reorder$/.test(url) && opts?.method === "PATCH") {
|
||||
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("renders a drag handle for each stop (drag-to-reorder enabled)", async () => {
|
||||
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||
render(<RoutesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||
expect(screen.getByLabelText("Drag to reorder Alice")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Drag to reorder Bob")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flags the tight-schedule conflict on the affected stop", async () => {
|
||||
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||
render(<RoutesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Bob")).toBeInTheDocument());
|
||||
expect(
|
||||
screen.getByText(/Tight schedule — travel \+ buffer may exceed the gap/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the re-optimize hint before any manual reorder", async () => {
|
||||
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||
render(<RoutesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||
expect(screen.queryByText("Re-optimize")).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,550 @@
|
||||
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" });
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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";
|
||||
|
||||
// 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]
|
||||
);
|
||||
|
||||
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 — drag-to-reorder */}
|
||||
<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.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>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,66 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
||||
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
|
||||
|
||||
// ─── Availability fetch helper ───────────────────────────────────────────────
|
||||
// Returns ISO startTime strings for the given service/date, or an error message.
|
||||
// Validates HTTP status and that the body is actually an array — the API
|
||||
// responds with `{error: "..."}` on 4xx, and we must not treat that as slots.
|
||||
const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots';
|
||||
|
||||
/**
|
||||
* Re-mint an SSO-bridge portal session from the active Better Auth session.
|
||||
* Defense-in-depth for GRO-2234: if a portal call returns 401 mid-flow (the
|
||||
* impersonation session lapsed during a slow wizard), the customer's Better
|
||||
* Auth cookie is still valid, so we can transparently obtain a fresh portal
|
||||
* session id and retry once. Returns the new session id, or null if no Better
|
||||
* Auth session is available (e.g. staff/dev impersonation paths).
|
||||
*/
|
||||
async function remintPortalSession(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch('/api/portal/session-from-auth', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json().catch(() => ({}))) as { sessionId?: string };
|
||||
return data.sessionId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAvailability(
|
||||
params: { serviceId: string; date: string },
|
||||
sessionId: string | null,
|
||||
): Promise<{ times: string[]; error: string | null }> {
|
||||
const url = `/api/book/availability?${new URLSearchParams(params).toString()}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId;
|
||||
try {
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
return { times: [], error: body.error ?? `${AVAILABILITY_ERROR_MESSAGE} (HTTP ${res.status})` };
|
||||
}
|
||||
const data: unknown = await res.json();
|
||||
if (!Array.isArray(data)) {
|
||||
return { times: [], error: AVAILABILITY_ERROR_MESSAGE };
|
||||
}
|
||||
return { times: data as string[], error: null };
|
||||
} catch {
|
||||
return { times: [], error: AVAILABILITY_ERROR_MESSAGE };
|
||||
}
|
||||
}
|
||||
|
||||
export interface Appointment {
|
||||
id: string;
|
||||
petId: string;
|
||||
serviceId: string;
|
||||
groomerId: string | null;
|
||||
// Absolute ISO instants as returned by `/api/portal/appointments`. `date`/`time`
|
||||
// below are the locally-formatted display strings derived from `startTime`.
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
date: string;
|
||||
time: string;
|
||||
status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show';
|
||||
@@ -34,8 +89,8 @@ interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
duration: number;
|
||||
price: number;
|
||||
duration?: number;
|
||||
price?: number;
|
||||
priceRange?: string;
|
||||
isAddOn?: boolean;
|
||||
}
|
||||
@@ -62,25 +117,184 @@ export function formatDate(dateStr: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function parseTimeTo24Hour(time: string): string {
|
||||
const parts = time.split(' ');
|
||||
export function parseTimeTo24Hour(time: string | null | undefined): string {
|
||||
const parts = (time ?? '').split(' ');
|
||||
const hoursMinutes = parts[0] ?? '';
|
||||
const period = parts[1] ?? '';
|
||||
const [hoursStr, minutesStr] = hoursMinutes.split(':');
|
||||
const hours = parseInt(hoursStr ?? '0', 10);
|
||||
const minutes = parseInt(minutesStr ?? '0', 10);
|
||||
// `|| '0'` (not `?? '0'`) so empty strings from blank/undefined input
|
||||
// fall back to 0 rather than parsing to NaN.
|
||||
const hours = parseInt(hoursStr || '0', 10);
|
||||
const minutes = parseInt(minutesStr || '0', 10);
|
||||
let hours24 = hours;
|
||||
if (period === 'PM' && hours !== 12) hours24 += 12;
|
||||
if (period === 'AM' && hours === 12) hours24 = 0;
|
||||
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
// A booking slot is the canonical UTC ISO instant returned by
|
||||
// `/api/book/availability` (e.g. "2026-06-09T10:00:00.000Z" is the 10:00 UTC
|
||||
// business slot — see api `src/lib/slots.ts`, which builds them with
|
||||
// `setUTCHours`). Display label and submit payload both derive from the slot via
|
||||
// these helpers so they never desync. Always format/extract in UTC: slots are
|
||||
// generated as UTC business hours, so a browser-local conversion would mislabel
|
||||
// the slot and diverge from the stored Postgres `time` column.
|
||||
export function formatSlotLabel(slot: string): string {
|
||||
const d = new Date(slot);
|
||||
// Non-ISO input (e.g. an already-formatted "10:00 AM" label) — show as-is.
|
||||
if (Number.isNaN(d.getTime())) return slot;
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZone: 'UTC',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
// Extracts the UTC `HH:MM:SS` time component the api stores in the Postgres
|
||||
// `time` column. The api inserts this verbatim, so a full ISO datetime here is
|
||||
// an `invalid input syntax for type time` 500 (GRO-2211).
|
||||
export function slotToTime(slot: string): string {
|
||||
if (/^\d{2}:\d{2}:\d{2}$/.test(slot)) return slot; // already HH:MM:SS
|
||||
const d = new Date(slot);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
const hh = String(d.getUTCHours()).padStart(2, '0');
|
||||
const mm = String(d.getUTCMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getUTCSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
// "10:00 AM"-style label fallback.
|
||||
return parseTimeTo24Hour(slot);
|
||||
}
|
||||
|
||||
export function isUpcoming(appt: Appointment): boolean {
|
||||
const now = new Date();
|
||||
const apptDate = new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`);
|
||||
// Prefer the absolute ISO `startTime` from the API; fall back to the
|
||||
// locally-formatted date/time pair for already-normalized/legacy shapes.
|
||||
const apptDate = appt.startTime
|
||||
? new Date(appt.startTime)
|
||||
: new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`);
|
||||
return apptDate > now && appt.status !== 'cancelled' && appt.status !== 'completed';
|
||||
}
|
||||
|
||||
// ─── API → UI shape normalization ────────────────────────────────────────────
|
||||
// `/api/portal/appointments` returns ISO `startTime`/`endTime` plus nested
|
||||
// pet/service/staff objects, NOT the flat `date`/`time`/`petName` shape the
|
||||
// portal UI renders. Every field below is optional so the legacy flat shape
|
||||
// (used by tests/fixtures) is tolerated unchanged.
|
||||
export interface RawApiAppointment {
|
||||
id: string;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
status: Appointment['status'];
|
||||
confirmationStatus?: Appointment['confirmationStatus'];
|
||||
customerNotes?: string | null;
|
||||
notes?: string | null;
|
||||
pet?: { id?: string | null; name?: string | null; photo?: string | null } | null;
|
||||
service?: { id?: string | null; name?: string | null } | null;
|
||||
staff?: { id?: string | null; name?: string | null } | null;
|
||||
// Legacy / already-flat fields
|
||||
petId?: string;
|
||||
serviceId?: string;
|
||||
groomerId?: string | null;
|
||||
date?: string;
|
||||
time?: string;
|
||||
petName?: string;
|
||||
serviceName?: string;
|
||||
groomerName?: string;
|
||||
duration?: number;
|
||||
price?: number;
|
||||
addOns?: string[];
|
||||
}
|
||||
|
||||
function toLocalDateString(d: Date): string {
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function toLocalTimeString(d: Date): string {
|
||||
return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
// Maps a raw API appointment into the flat `Appointment` shape the portal
|
||||
// renders. Derives display `date`/`time` from the absolute `startTime` and
|
||||
// `duration` from the start/end delta. Tolerates the legacy flat shape.
|
||||
export function normalizeAppointment(raw: RawApiAppointment): Appointment {
|
||||
const start = raw.startTime ? new Date(raw.startTime) : null;
|
||||
const end = raw.endTime ? new Date(raw.endTime) : null;
|
||||
const derivedDuration =
|
||||
start && end ? Math.round((end.getTime() - start.getTime()) / 60000) : undefined;
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
petId: raw.pet?.id ?? raw.petId ?? '',
|
||||
serviceId: raw.service?.id ?? raw.serviceId ?? '',
|
||||
groomerId: raw.staff?.id ?? raw.groomerId ?? null,
|
||||
startTime: raw.startTime ?? undefined,
|
||||
endTime: raw.endTime ?? undefined,
|
||||
date: start ? toLocalDateString(start) : raw.date ?? '',
|
||||
time: start ? toLocalTimeString(start) : raw.time ?? '',
|
||||
status: raw.status,
|
||||
petName: raw.pet?.name ?? raw.petName,
|
||||
serviceName: raw.service?.name ?? raw.serviceName,
|
||||
groomerName: raw.staff?.name ?? raw.groomerName,
|
||||
duration: raw.duration ?? derivedDuration,
|
||||
price: raw.price,
|
||||
notes: raw.notes ?? undefined,
|
||||
customerNotes: raw.customerNotes ?? undefined,
|
||||
addOns: raw.addOns,
|
||||
confirmationStatus: raw.confirmationStatus,
|
||||
};
|
||||
}
|
||||
|
||||
// Raw service shape from `GET /api/portal/services`, which projects the
|
||||
// canonical DB columns (`basePriceCents`, `durationMinutes`). Also tolerates an
|
||||
// already-normalized payload so either shape renders correctly.
|
||||
interface RawApiService {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
basePriceCents?: number | null;
|
||||
durationMinutes?: number | null;
|
||||
price?: number | null;
|
||||
duration?: number | null;
|
||||
priceRange?: string | null;
|
||||
isAddOn?: boolean | null;
|
||||
}
|
||||
|
||||
// Normalizes a raw API service into the flat `Service` shape the cards render:
|
||||
// price as dollars (from `basePriceCents`) and duration in minutes (from
|
||||
// `durationMinutes`). Leaves fields undefined when genuinely absent so the card
|
||||
// can hide them rather than print `$undefined` / empty `min`.
|
||||
export function normalizeService(raw: RawApiService): Service {
|
||||
const price =
|
||||
raw.price ?? (typeof raw.basePriceCents === 'number' ? raw.basePriceCents / 100 : undefined);
|
||||
const duration = raw.duration ?? raw.durationMinutes ?? undefined;
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
description: raw.description ?? undefined,
|
||||
duration: duration ?? undefined,
|
||||
price: price ?? undefined,
|
||||
priceRange: raw.priceRange ?? undefined,
|
||||
isAddOn: raw.isAddOn ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Renders a service price for display, preferring an explicit `priceRange`
|
||||
// string, then a numeric dollar `price` (integers without trailing zeros, e.g.
|
||||
// `$45`; fractional values to cents, e.g. `$45.50`). Returns null when neither
|
||||
// is available so the caller can omit the price line entirely.
|
||||
export function formatServicePrice(svc: Pick<Service, 'price' | 'priceRange'>): string | null {
|
||||
if (svc.priceRange) return svc.priceRange;
|
||||
if (typeof svc.price === 'number' && Number.isFinite(svc.price)) {
|
||||
return `$${Number.isInteger(svc.price) ? svc.price : svc.price.toFixed(2)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
confirmed: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-amber-100 text-amber-600',
|
||||
@@ -144,7 +358,8 @@ export const AppointmentsSection: React.FC<AppointmentsSectionProps> = ({ sessio
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const fetchedAppointments: Appointment[] = data.appointments || data || [];
|
||||
const rawAppointments: RawApiAppointment[] = data.appointments || data || [];
|
||||
const fetchedAppointments: Appointment[] = rawAppointments.map(normalizeAppointment);
|
||||
|
||||
const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt));
|
||||
const past = fetchedAppointments.filter((appt) => !isUpcoming(appt));
|
||||
@@ -595,19 +810,29 @@ export function RescheduleFlow({
|
||||
useEffect(() => {
|
||||
if (!selectedDate || !sessionId) {
|
||||
setAvailableTimes([]);
|
||||
setSlotsError(null);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ date: selectedDate });
|
||||
if (!appt.serviceId) {
|
||||
setAvailableTimes([]);
|
||||
setSlotsError('Failed to load time slots');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSlotsLoading(true);
|
||||
setSlotsError(null);
|
||||
fetch(`/api/book/availability?${params.toString()}`, {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
})
|
||||
.then((r) => r.json() as Promise<string[]>)
|
||||
.then(setAvailableTimes)
|
||||
.catch(() => setSlotsError('Failed to load time slots'))
|
||||
.finally(() => setSlotsLoading(false));
|
||||
}, [selectedDate, sessionId]);
|
||||
fetchAvailability({ serviceId: appt.serviceId, date: selectedDate }, sessionId).then(
|
||||
({ times, error }) => {
|
||||
if (cancelled) return;
|
||||
setAvailableTimes(times);
|
||||
setSlotsError(error);
|
||||
setSlotsLoading(false);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedDate, sessionId, appt.serviceId]);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedDate || !selectedTime) return;
|
||||
@@ -738,7 +963,7 @@ interface BookingFlowProps {
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
@@ -766,19 +991,30 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
useEffect(() => {
|
||||
if (!selectedDate || !sessionId) {
|
||||
setAvailableTimes([]);
|
||||
setSlotsError(null);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ date: selectedDate });
|
||||
const serviceId = selectedServices[0]?.id;
|
||||
if (!serviceId) {
|
||||
setAvailableTimes([]);
|
||||
setSlotsError('Failed to load time slots');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSlotsLoading(true);
|
||||
setSlotsError(null);
|
||||
fetch(`/api/book/availability?${params.toString()}`, {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
})
|
||||
.then((r) => r.json() as Promise<string[]>)
|
||||
.then(setAvailableTimes)
|
||||
.catch(() => setSlotsError('Failed to load time slots'))
|
||||
.finally(() => setSlotsLoading(false));
|
||||
}, [selectedDate, sessionId]);
|
||||
fetchAvailability({ serviceId, date: selectedDate }, sessionId).then(
|
||||
({ times, error }) => {
|
||||
if (cancelled) return;
|
||||
setAvailableTimes(times);
|
||||
setSlotsError(error);
|
||||
setSlotsLoading(false);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedDate, sessionId, selectedServices]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -804,7 +1040,8 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
|
||||
if (servicesRes.ok) {
|
||||
const servicesData = await servicesRes.json();
|
||||
setServices(servicesData.services || servicesData || []);
|
||||
const rawServices: RawApiService[] = servicesData.services || servicesData || [];
|
||||
setServices(rawServices.map(normalizeService));
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to load data. Please try again.');
|
||||
@@ -825,26 +1062,40 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/portal/waitlist', {
|
||||
const payload = JSON.stringify({
|
||||
petId: selectedPet.id,
|
||||
serviceId: selectedServices[0]?.id,
|
||||
serviceIds: selectedServices.map((s) => s.id),
|
||||
addOnIds: selectedAddOns.map((s) => s.id),
|
||||
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
|
||||
preferredDate: selectedDate,
|
||||
preferredTime: slotToTime(selectedTime),
|
||||
notes: notes || undefined,
|
||||
recurring: recurring || undefined,
|
||||
});
|
||||
const submitWaitlist = (id: string) =>
|
||||
fetch('/api/portal/waitlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Impersonation-Session-Id': sessionId ?? '',
|
||||
'X-Impersonation-Session-Id': id,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
petId: selectedPet.id,
|
||||
serviceId: selectedServices[0]?.id,
|
||||
serviceIds: selectedServices.map((s) => s.id),
|
||||
addOnIds: selectedAddOns.map((s) => s.id),
|
||||
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
|
||||
preferredDate: selectedDate,
|
||||
preferredTime: selectedTime,
|
||||
notes: notes || undefined,
|
||||
recurring: recurring || undefined,
|
||||
}),
|
||||
body: payload,
|
||||
});
|
||||
|
||||
try {
|
||||
let response = await submitWaitlist(sessionId);
|
||||
|
||||
// GRO-2234: a deliberately-paced wizard can outlive the portal session.
|
||||
// The customer's Better Auth session is still valid, so transparently
|
||||
// re-mint a fresh portal session and retry once before surfacing an error.
|
||||
if (response.status === 401) {
|
||||
const freshSessionId = await remintPortalSession();
|
||||
if (freshSessionId) {
|
||||
response = await submitWaitlist(freshSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
setConfirmed(true);
|
||||
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" });
|
||||
@@ -902,7 +1153,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
Appointment Requested!
|
||||
</h3>
|
||||
<p className="text-sm text-stone-500 mb-4">
|
||||
{selectedPet?.name} on {formatDate(selectedDate)} at {selectedTime}
|
||||
{selectedPet?.name} on {formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -986,10 +1237,14 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-3">
|
||||
<p className="text-sm font-medium text-stone-700">
|
||||
{svc.priceRange || `$${svc.price}`}
|
||||
</p>
|
||||
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
||||
{formatServicePrice(svc) && (
|
||||
<p className="text-sm font-medium text-stone-700">
|
||||
{formatServicePrice(svc)}
|
||||
</p>
|
||||
)}
|
||||
{typeof svc.duration === 'number' && (
|
||||
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -1022,9 +1277,11 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
<p className="text-xs text-stone-500">{svc.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-stone-600 shrink-0 ml-3">
|
||||
{svc.priceRange || `$${svc.price}`}
|
||||
</span>
|
||||
{formatServicePrice(svc) && (
|
||||
<span className="text-stone-600 shrink-0 ml-3">
|
||||
{formatServicePrice(svc)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1122,7 +1379,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
: 'border-stone-200 hover:border-stone-300'
|
||||
}`}
|
||||
>
|
||||
{time}
|
||||
{formatSlotLabel(time)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1192,7 +1449,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
<div className="flex justify-between">
|
||||
<span className="text-stone-500">Date & Time</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(selectedDate)} at {selectedTime}
|
||||
{formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
|
||||
</span>
|
||||
</div>
|
||||
{recurring && (
|
||||
|
||||
@@ -22,7 +22,7 @@ function newAlert(): Omit<MedicalAlert, "id"> {
|
||||
export function PetForm({ pet, onSave, onCancel, saving, saveError }: Props) {
|
||||
const [name, setName] = useState(pet?.name ?? "");
|
||||
const [breed, setBreed] = useState(pet?.breed ?? "");
|
||||
const [weight, setWeight] = useState(pet?.weightKg ?? 0);
|
||||
const [weight, setWeight] = useState(Number(pet?.weight ?? pet?.weightKg ?? 0));
|
||||
const [notes, setNotes] = useState(pet?.healthAlerts ?? "");
|
||||
const [coatType, setCoatType] = useState<CoatType | "">((pet?.coatType as CoatType) ?? "");
|
||||
const [petSizeCategory, setPetSizeCategory] = useState<SizeOption | "">(pet?.petSizeCategory as SizeOption ?? "");
|
||||
|
||||
@@ -176,9 +176,9 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
|
||||
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {selectedPet.weightKg ? `${selectedPet.weightKg} kg` : "Unknown weight"}</p>
|
||||
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {(() => { const w = selectedPet.weight ?? selectedPet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown weight"; })()}</p>
|
||||
<p className="text-stone-400 text-xs mt-0.5">
|
||||
Born {selectedPet.dateOfBirth ? new Date(selectedPet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
|
||||
Born {(() => { const d = selectedPet.birthDate ?? selectedPet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
@@ -222,6 +222,14 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function formatSizeCategory(size?: string | null): string {
|
||||
if (!size) return "Unknown";
|
||||
return size
|
||||
.split("_")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center py-2.5 border-b border-stone-100 last:border-0">
|
||||
@@ -244,7 +252,7 @@ function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
export function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
const score = pet.temperamentScore;
|
||||
const flags = pet.temperamentFlags ?? [];
|
||||
|
||||
@@ -252,8 +260,9 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
<div>
|
||||
<InfoRow label="Name" value={pet.name} />
|
||||
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
|
||||
<InfoRow label="Weight" value={pet.weightKg ? `${pet.weightKg} kg` : "Unknown"} />
|
||||
<InfoRow label="Date of Birth" value={pet.dateOfBirth ? new Date(pet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
|
||||
<InfoRow label="Weight" value={(() => { const w = pet.weight ?? pet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown"; })()} />
|
||||
<InfoRow label="Date of Birth" value={(() => { const d = pet.birthDate ?? pet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()} />
|
||||
<InfoRow label="Size Category" value={formatSizeCategory(pet.petSizeCategory)} />
|
||||
|
||||
{/* Temperament (staff-set, read-only) */}
|
||||
{(score != null || flags.length > 0) && (
|
||||
|
||||
Reference in New Issue
Block a user