From f5491019626db92f99247b080221f0f89a9c379f Mon Sep 17 00:00:00 2001 From: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Date: Mon, 8 Jun 2026 23:30:30 +0000 Subject: [PATCH 1/2] fix(GRO-2236): portal Book New service cards show price + duration (#57) Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net> --- src/__tests__/Appointments.test.tsx | 64 +++++++++++++++++++++++- src/portal/sections/Appointments.tsx | 73 ++++++++++++++++++++++++---- 2 files changed, 126 insertions(+), 11 deletions(-) diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx index 72c4678..c878b46 100644 --- a/src/__tests__/Appointments.test.tsx +++ b/src/__tests__/Appointments.test.tsx @@ -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, 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 = { id: "appt-1", @@ -873,3 +873,65 @@ describe("BookingFlow Book New funnel (GRO-2213)", () => { 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(); + }); +}); diff --git a/src/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx index fd1b4fc..bf55d90 100644 --- a/src/portal/sections/Appointments.tsx +++ b/src/portal/sections/Appointments.tsx @@ -89,8 +89,8 @@ interface Service { id: string; name: string; description?: string; - duration: number; - price: number; + duration?: number; + price?: number; priceRange?: string; 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): 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 = { confirmed: 'bg-green-100 text-green-700', pending: 'bg-amber-100 text-amber-600', @@ -994,7 +1040,8 @@ export 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.'); @@ -1190,10 +1237,14 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) { )}
-

- {svc.priceRange || `$${svc.price}`} -

-

{svc.duration} min

+ {formatServicePrice(svc) && ( +

+ {formatServicePrice(svc)} +

+ )} + {typeof svc.duration === 'number' && ( +

{svc.duration} min

+ )}
))} @@ -1226,9 +1277,11 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {

{svc.description}

)} - - {svc.priceRange || `$${svc.price}`} - + {formatServicePrice(svc) && ( + + {formatServicePrice(svc)} + + )} ))} From e93017b279bbfa7b67693bf989f5c1adef8f0a2a Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 03:10:17 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Promote=20dev=20=E2=86=92=20uat:=20GRO-2159?= =?UTF-8?q?=20drag-to-reorder=20+=20re-optimize=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UAT_PLAYBOOK.md | 14 ++ package.json | 3 + pnpm-lock.yaml | 61 ++++++++ src/__tests__/Routes.test.tsx | 30 ++++ src/pages/Routes.tsx | 270 ++++++++++++++++++++++++++++------ 5 files changed, 330 insertions(+), 48 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 8992ad7..f2f0e3b 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -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.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 ". | +| 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:** diff --git a/package.json b/package.json index ec73670..4be9039 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03075fc..1736644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -748,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'} @@ -3176,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'} @@ -4261,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 @@ -6627,6 +6686,8 @@ snapshots: dependencies: typescript: 5.9.3 + tslib@2.8.1: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/src/__tests__/Routes.test.tsx b/src/__tests__/Routes.test.tsx index 935a697..a20c49d 100644 --- a/src/__tests__/Routes.test.tsx +++ b/src/__tests__/Routes.test.tsx @@ -79,6 +79,9 @@ function mockFetch(meRole: "manager" | "groomer") { 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); }); } @@ -124,6 +127,33 @@ describe("RoutesPage", () => { 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(); + + 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(); + + 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(); + + 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; diff --git a/src/pages/Routes.tsx b/src/pages/Routes.tsx index 5d62a30..d0185e0 100644 --- a/src/pages/Routes.tsx +++ b/src/pages/Routes.tsx @@ -1,4 +1,22 @@ 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"; @@ -109,6 +127,100 @@ const inputStyle: React.CSSProperties = { 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 ( +
+ +
+ {stop.stopOrder} +
+
+
+ {stop.clientName} + {fmtTime(stop.appointmentStartTime)} +
+
{stop.clientAddress || "No address on file"}
+
+ {stop.stopOrder === 1 || stop.travelMinsFromPrev == null + ? "Start of route" + : `${fmtDuration(stop.travelMinsFromPrev)} travel from previous`} +
+ {stop.conflict?.hasConflict && ( +
+ ⚠ Tight schedule — travel + buffer may exceed the gap +
+ )} +
+
+ ); +} + // ─── Page ─────────────────────────────────────────────────────────────────────── export function RoutesPage() { @@ -124,6 +236,8 @@ export function RoutesPage() { const [data, setData] = useState(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(null); const isGroomer = me?.role === "groomer"; @@ -166,6 +280,7 @@ export function RoutesPage() { 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"); @@ -193,6 +308,7 @@ export function RoutesPage() { 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 { @@ -200,6 +316,71 @@ export function RoutesPage() { } }, [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) => ({ @@ -299,59 +480,52 @@ export function RoutesPage() { )} - {/* Stop list panel */} + {/* Stop list panel — drag-to-reorder */}
{stops.length === 0 && !loading && (
No stops for this day.
)} - {stops.map((s) => ( -
-
0 && ( + <> + {manuallyReordered && ( +
+ + Stops reordered manually. Re-optimize to recompute the best route. + + +
+ )} + - {s.stopOrder} -
-
-
- {s.clientName} - {fmtTime(s.appointmentStartTime)} -
-
{s.clientAddress || "No address on file"}
-
- {s.stopOrder === 1 || s.travelMinsFromPrev == null - ? "Start of route" - : `${fmtDuration(s.travelMinsFromPrev)} travel from previous`} -
- {s.conflict?.hasConflict && ( -
- ⚠ Tight schedule — travel + buffer may exceed the gap -
- )} -
-
- ))} + s.id)} strategy={verticalListSortingStrategy}> + {stops.map((s) => ( + + ))} + + + + )}