Merge pull request 'Promote uat → main (PROD): GRO-2159 drag-to-reorder + re-optimize + GRO-2236 price/duration display fix'
CEO prod merge. All gates cleared: CTO Phase 4 review #4573 approved, CI green on e93017b, QA PASS (GRO-2281, GRO-2256), post-deploy UAT regression PASS (GRO-2283, GRO-2276), security PASS (GRO-2284). Merging GRO-2159 + GRO-2236 to main.
This commit was merged in pull request #65.
This commit is contained in:
@@ -491,6 +491,20 @@ The admin Route Planner lives at `/admin/routes`. It shows a groomer's geocoded
|
|||||||
| 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.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. |
|
| 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
|
## 6. Pass/Fail Criteria
|
||||||
|
|
||||||
**Pass:**
|
**Pass:**
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
"test:e2e": "playwright test -c e2e/playwright.config.ts"
|
"test:e2e": "playwright test -c e2e/playwright.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@groombook/types": "workspace:*",
|
"@groombook/types": "workspace:*",
|
||||||
"@stripe/react-stripe-js": "^6.1.0",
|
"@stripe/react-stripe-js": "^6.1.0",
|
||||||
"@stripe/stripe-js": "^9.1.0",
|
"@stripe/stripe-js": "^9.1.0",
|
||||||
|
|||||||
Generated
+61
@@ -8,6 +8,15 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
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':
|
'@groombook/types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:packages/types
|
version: link:packages/types
|
||||||
@@ -748,6 +757,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||||
engines: {node: '>=18'}
|
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':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3176,6 +3207,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4'
|
typescript: '>=4.8.4'
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -4261,6 +4295,31 @@ snapshots:
|
|||||||
|
|
||||||
'@csstools/css-tokenizer@3.0.4': {}
|
'@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':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6627,6 +6686,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } 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 = {
|
const UPCOMING_APPT = {
|
||||||
id: "appt-1",
|
id: "appt-1",
|
||||||
@@ -873,3 +873,65 @@ describe("BookingFlow Book New funnel (GRO-2213)", () => {
|
|||||||
expect(screen.queryByText(/Failed to book appointment/i)).not.toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ function mockFetch(meRole: "manager" | "groomer") {
|
|||||||
if (url === "/api/routes/optimize" && opts?.method === "POST") {
|
if (url === "/api/routes/optimize" && opts?.method === "POST") {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response);
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(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);
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -124,6 +127,33 @@ describe("RoutesPage", () => {
|
|||||||
expect(screen.queryByText("Groomer")).not.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 () => {
|
it("calls the optimize endpoint when Optimize is clicked", async () => {
|
||||||
const fetchMock = mockFetch("manager");
|
const fetchMock = mockFetch("manager");
|
||||||
global.fetch = fetchMock as unknown as typeof fetch;
|
global.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|||||||
+222
-48
@@ -1,4 +1,22 @@
|
|||||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";
|
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 { useBranding } from "../BrandingContext.js";
|
||||||
import type { RouteMapStop } from "../components/RouteMap.js";
|
import type { RouteMapStop } from "../components/RouteMap.js";
|
||||||
|
|
||||||
@@ -109,6 +127,100 @@ const inputStyle: React.CSSProperties = {
|
|||||||
fontSize: 14,
|
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 ───────────────────────────────────────────────────────────────────────
|
// ─── Page ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function RoutesPage() {
|
export function RoutesPage() {
|
||||||
@@ -124,6 +236,8 @@ export function RoutesPage() {
|
|||||||
const [data, setData] = useState<RouteResponse | null>(null);
|
const [data, setData] = useState<RouteResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [optimizing, setOptimizing] = 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 [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const isGroomer = me?.role === "groomer";
|
const isGroomer = me?.role === "groomer";
|
||||||
@@ -166,6 +280,7 @@ export function RoutesPage() {
|
|||||||
throw new Error(body.error || `Failed to load route (${r.status})`);
|
throw new Error(body.error || `Failed to load route (${r.status})`);
|
||||||
}
|
}
|
||||||
setData(await r.json());
|
setData(await r.json());
|
||||||
|
setManuallyReordered(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setData(null);
|
setData(null);
|
||||||
setError(e instanceof Error ? e.message : "Failed to load route");
|
setError(e instanceof Error ? e.message : "Failed to load route");
|
||||||
@@ -193,6 +308,7 @@ export function RoutesPage() {
|
|||||||
throw new Error(body.error || `Optimization failed (${r.status})`);
|
throw new Error(body.error || `Optimization failed (${r.status})`);
|
||||||
}
|
}
|
||||||
setData(await r.json());
|
setData(await r.json());
|
||||||
|
setManuallyReordered(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Optimization failed");
|
setError(e instanceof Error ? e.message : "Optimization failed");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -200,6 +316,71 @@ export function RoutesPage() {
|
|||||||
}
|
}
|
||||||
}, [staffId, date]);
|
}, [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(
|
const mapStops: RouteMapStop[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(data?.stops ?? []).map((s) => ({
|
(data?.stops ?? []).map((s) => ({
|
||||||
@@ -299,59 +480,52 @@ export function RoutesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stop list panel */}
|
{/* Stop list panel — drag-to-reorder */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: 540, overflowY: "auto" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: 540, overflowY: "auto" }}>
|
||||||
{stops.length === 0 && !loading && (
|
{stops.length === 0 && !loading && (
|
||||||
<div style={{ color: "#6b7280", fontSize: 14, padding: "1rem" }}>No stops for this day.</div>
|
<div style={{ color: "#6b7280", fontSize: 14, padding: "1rem" }}>No stops for this day.</div>
|
||||||
)}
|
)}
|
||||||
{stops.map((s) => (
|
{stops.length > 0 && (
|
||||||
<div
|
<>
|
||||||
key={s.id}
|
{manuallyReordered && (
|
||||||
style={{
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 8, padding: "0.55rem 0.7rem" }}>
|
||||||
background: "#fff",
|
<span style={{ fontSize: 12, color: "#92400e" }}>
|
||||||
border: `1px solid ${s.conflict?.hasConflict ? "#fca5a5" : "#e2e8f0"}`,
|
Stops reordered manually. Re-optimize to recompute the best route.
|
||||||
borderRadius: 8,
|
</span>
|
||||||
padding: "0.7rem 0.85rem",
|
<button
|
||||||
display: "flex",
|
type="button"
|
||||||
gap: 10,
|
onClick={optimize}
|
||||||
}}
|
disabled={optimizing || reordering || !staffId}
|
||||||
>
|
style={{
|
||||||
<div
|
flexShrink: 0,
|
||||||
style={{
|
padding: "0.35rem 0.7rem",
|
||||||
flexShrink: 0,
|
borderRadius: 6,
|
||||||
width: 26,
|
border: "none",
|
||||||
height: 26,
|
background: primaryColor,
|
||||||
borderRadius: "50%",
|
color: "#fff",
|
||||||
background: primaryColor,
|
fontSize: 13,
|
||||||
color: "#fff",
|
fontWeight: 600,
|
||||||
fontSize: 13,
|
cursor: optimizing || reordering || !staffId ? "not-allowed" : "pointer",
|
||||||
fontWeight: 700,
|
opacity: optimizing || reordering || !staffId ? 0.6 : 1,
|
||||||
display: "flex",
|
}}
|
||||||
alignItems: "center",
|
>
|
||||||
justifyContent: "center",
|
{optimizing ? "Optimizing…" : "Re-optimize"}
|
||||||
}}
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
{s.stopOrder}
|
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
||||||
</div>
|
{stops.map((s) => (
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
<SortableStop key={s.id} stop={s} primaryColor={primaryColor} disabled={reordering} />
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
|
))}
|
||||||
<strong style={{ fontSize: 14, color: "#1a202c" }}>{s.clientName}</strong>
|
</SortableContext>
|
||||||
<span style={{ fontSize: 13, color: "#4b5563", whiteSpace: "nowrap" }}>{fmtTime(s.appointmentStartTime)}</span>
|
</DndContext>
|
||||||
</div>
|
</>
|
||||||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>{s.clientAddress || "No address on file"}</div>
|
)}
|
||||||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 4 }}>
|
|
||||||
{s.stopOrder === 1 || s.travelMinsFromPrev == null
|
|
||||||
? "Start of route"
|
|
||||||
: `${fmtDuration(s.travelMinsFromPrev)} travel from previous`}
|
|
||||||
</div>
|
|
||||||
{s.conflict?.hasConflict && (
|
|
||||||
<div style={{ fontSize: 12, color: "#b91c1c", marginTop: 4, fontWeight: 600 }}>
|
|
||||||
⚠ Tight schedule — travel + buffer may exceed the gap
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ interface Service {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
duration: number;
|
duration?: number;
|
||||||
price: number;
|
price?: number;
|
||||||
priceRange?: string;
|
priceRange?: string;
|
||||||
isAddOn?: boolean;
|
isAddOn?: boolean;
|
||||||
}
|
}
|
||||||
@@ -249,6 +249,52 @@ export function normalizeAppointment(raw: RawApiAppointment): Appointment {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
confirmed: 'bg-green-100 text-green-700',
|
confirmed: 'bg-green-100 text-green-700',
|
||||||
pending: 'bg-amber-100 text-amber-600',
|
pending: 'bg-amber-100 text-amber-600',
|
||||||
@@ -994,7 +1040,8 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
|
|
||||||
if (servicesRes.ok) {
|
if (servicesRes.ok) {
|
||||||
const servicesData = await servicesRes.json();
|
const servicesData = await servicesRes.json();
|
||||||
setServices(servicesData.services || servicesData || []);
|
const rawServices: RawApiService[] = servicesData.services || servicesData || [];
|
||||||
|
setServices(rawServices.map(normalizeService));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load data. Please try again.');
|
setError('Failed to load data. Please try again.');
|
||||||
@@ -1190,10 +1237,14 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0 ml-3">
|
<div className="text-right shrink-0 ml-3">
|
||||||
<p className="text-sm font-medium text-stone-700">
|
{formatServicePrice(svc) && (
|
||||||
{svc.priceRange || `$${svc.price}`}
|
<p className="text-sm font-medium text-stone-700">
|
||||||
</p>
|
{formatServicePrice(svc)}
|
||||||
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
</p>
|
||||||
|
)}
|
||||||
|
{typeof svc.duration === 'number' && (
|
||||||
|
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -1226,9 +1277,11 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
<p className="text-xs text-stone-500">{svc.description}</p>
|
<p className="text-xs text-stone-500">{svc.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-stone-600 shrink-0 ml-3">
|
{formatServicePrice(svc) && (
|
||||||
{svc.priceRange || `$${svc.price}`}
|
<span className="text-stone-600 shrink-0 ml-3">
|
||||||
</span>
|
{formatServicePrice(svc)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user