Merge pull request 'Promote uat → main (PROD): GRO-2159 drag-to-reorder + re-optimize + GRO-2236 price/duration display fix'
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 12s

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:
2026-06-09 04:00:43 +00:00
7 changed files with 456 additions and 59 deletions
+14
View File
@@ -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:**
+3
View File
@@ -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",
+61
View File
@@ -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
+63 -1
View File
@@ -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();
});
});
+30
View File
@@ -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
View File
@@ -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>
+63 -10
View File
@@ -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>