feat(GRO-2159): drag-to-reorder + re-optimize on route planner
Add @dnd-kit drag-and-drop reorder to the /admin/routes stop list.
- Install @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities
- Sortable stop cards with a grab handle; pointer + touch (press-and-hold)
+ keyboard sensors so mobile groomers and a11y users can reorder
- On drop, PATCH /api/routes/:routeId/reorder { stopOrder } (full order)
- Optimistic UI update with rollback + error message on failure
- Server recomputes legs/buffers/conflicts; response adopted authoritatively
- Tight-schedule conflict warnings retained (server-flagged, gap < travel+buffer)
- "Re-optimize" hint+button after a manual reorder (re-runs POST /optimize)
- Tests: drag handles, conflict flag, no pre-reorder hint, reorder mock
- Updated UAT_PLAYBOOK.md §5.29 — drag-to-reorder & re-optimize cases
Co-Authored-By: Paperclip <noreply@paperclip.ing>
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.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,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",
|
||||
|
||||
Generated
+61
@@ -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
|
||||
|
||||
@@ -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(<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;
|
||||
|
||||
+222
-48
@@ -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 (
|
||||
<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() {
|
||||
@@ -124,6 +236,8 @@ export function RoutesPage() {
|
||||
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";
|
||||
@@ -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() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stop list panel */}
|
||||
{/* 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.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: `1px solid ${s.conflict?.hasConflict ? "#fca5a5" : "#e2e8f0"}`,
|
||||
borderRadius: 8,
|
||||
padding: "0.7rem 0.85rem",
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: "50%",
|
||||
background: primaryColor,
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
{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}
|
||||
>
|
||||
{s.stopOrder}
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
|
||||
<strong style={{ fontSize: 14, color: "#1a202c" }}>{s.clientName}</strong>
|
||||
<span style={{ fontSize: 13, color: "#4b5563", whiteSpace: "nowrap" }}>{fmtTime(s.appointmentStartTime)}</span>
|
||||
</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>
|
||||
))}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user