feat(collection-map): drop date filter; thread ready-gate into assign dialog

This commit is contained in:
SJCoetz 2026-07-12 00:46:44 +02:00
parent b5cd061da1
commit 8c21347ecc
2 changed files with 46 additions and 103 deletions

View file

@ -68,31 +68,37 @@ describe("splitSupplierBucket", () => {
describe("collection-map toolbar shape contract", () => {
// The toolbar lives in page.tsx. We snapshot the literal labels that must
// remain visible (date + quick chips + Refresh + overlays) and the labels
// that must NOT come back (the deleted filter chips + Drivers card).
// These are static source-text assertions: a future PR that re-adds the
// filter chips won't pass the pipeline, even if the chips "look right".
// remain visible (Refresh) and the labels / state / JSX that must NOT
// come back (the deleted date picker + quick chips + filter chips +
// Drivers card). These are static source-text assertions: a future PR
// that re-adds removed controls won't pass the pipeline, even if the
// controls "look right".
const src = fs.readFileSync(path.resolve(__dirname, "./page.tsx"), "utf8");
it("renders the locked toolbar labels", () => {
// Quick-chip labels.
expect(src).toMatch(/Today/);
expect(src).toMatch(/Tomorrow/);
expect(src).toMatch(/\+3 days/);
expect(src).toMatch(/Next week/);
// Overlay buttons + manual refresh.
it("renders only the Refresh control in the page toolbar", () => {
expect(src).toMatch(/Refresh/);
// Quick-chip labels deleted in 2026-07-12 (date-filter removal).
expect(src).not.toMatch(/Tomorrow/);
expect(src).not.toMatch(/\+3 days/);
expect(src).not.toMatch(/Next week/);
// Map chrome (not page toolbar) — keep these locked in too so the
// overlay buttons don't regress.
expect(src).toMatch(/Traffic/);
expect(src).toMatch(/Fullscreen|Exit fullscreen|Minimize2/);
expect(src).toMatch(/Refresh/);
});
it("does not re-introduce the deleted filter chips", () => {
// State + JSX for the deleted chips — must not silently come back.
it("does not re-introduce the deleted filter chips, search, or Drivers card", () => {
expect(src).not.toMatch(/setStatusFilter|statusFilter|StatusFilter/);
expect(src).not.toMatch(/setReadyFilter|readyFilter/);
// Search input + placeholder.
expect(src).not.toMatch(/Search PO, supplier/);
// Drivers sidebar card heading.
expect(src).not.toMatch(/>\s*Drivers\s*</);
});
it("does not re-introduce the date state or date bounds in fetchRows", () => {
// State setter must be gone.
expect(src).not.toMatch(/useState\(ymdToday\(\)\)/);
// The day-window filter string must be gone from both queries.
expect(src).not.toMatch(/assigned_at >= "\$\{date\}T00:00:00\.000Z"/);
expect(src).not.toMatch(/assigned_at <= "\$\{date\}T23:59:59\.999Z"/);
});
});

View file

@ -36,13 +36,12 @@ import {
useMap,
useMapEvents,
} from "react-leaflet";
import { Calendar, ExternalLink, MapPin, RefreshCw, Truck, User as UserIcon, Users, X, Layers } from "lucide-react";
import { ExternalLink, MapPin, RefreshCw, Truck, User as UserIcon, Users, X, Layers } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Maximize2, Minimize2 } from "lucide-react";
import { AssignDriverDialog, type AssignDriverSubmitPayload } from "@/components/ui/assign-driver-dialog";
import { getPocketBase } from "@/lib/pocketbase";
@ -146,22 +145,6 @@ type LookupUser = Pick<UserType, "id" | "name" | "email">;
* Helpers
* ========================================================================= */
function ymdToday(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
function ymdForDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function addDays(ymd: string, days: number): string {
const [y, m, d] = ymd.split("-").map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
date.setUTCDate(date.getUTCDate() + days);
return ymdForDate(date);
}
function formatDateLabel(ymd: string): string {
const [y, m, d] = ymd.split("-").map(Number);
const date = new Date(y, m - 1, d);
@ -283,7 +266,10 @@ export default function CollectionMapPage() {
const [items, setItems] = useState<StopItem[]>([]);
const [suppliersWithoutCoords, setSuppliersWithoutCoords] = useState<string[]>([]);
const [date, setDate] = useState(ymdToday());
// The page no longer exposes a date filter; the map shows every
// assigned + collection_pool item regardless of `assigned_at`. The
// assign dialog still picks a date internally ("when is the pickup")
// — that's a per-row concern, not a page-level filter.
// Traffic overlay toggle — default OFF to conserve the TomTom
// freemium 2,500-tile/day budget. Operators can flip it on for a
// single planning session; Leaflet caches tiles client-side so
@ -307,23 +293,10 @@ export default function CollectionMapPage() {
setLoading(true);
try {
const pb = getPocketBase();
// Build a UTC ISO 8601 day window. `assigned_at` is stored at
// "YYYY-MM-DDT00:00:00.000Z" (see /api/v1/collection-map/items/[id]/assign),
// so the bounds need the same "T" and trailing "Z" or string
// comparison puts `T` (0x54) before the space (0x20) and the bound
// excludes every real row.
const dayStart = `${date}T00:00:00.000Z`;
const dayEnd = `${date}T23:59:59.999Z`;
// Union of:
// (a) assigned items whose assigned_at lands on the picked date,
// (b) un-dated pool items (collection_pool + empty assigned_at) so
// the operator can plan them visually on the map.
// Items scheduled for a *future* date stay hidden until that date
// — the map's date picker still means "the day I'm planning for".
const filter = [
`(item_status = "assigned" || item_status = "collection_pool")`,
`((assigned_at >= "${dayStart}" && assigned_at <= "${dayEnd}") || (assigned_at = "" || assigned_at = null))`,
].join(" && ");
// The map shows every assigned + collection_pool row regardless of
// `assigned_at` — operators plan across all open pickups, not just
// today's. The page no longer exposes a date filter.
const filter = `(item_status = "assigned" || item_status = "collection_pool")`;
const rows = (await pb.collection("order_items").getFullList({
filter,
@ -367,7 +340,7 @@ export default function CollectionMapPage() {
// Also collect items whose suppliers have NO coordinates — show in
// the sidebar so the operator knows they exist but can't be mapped.
const unmappedRows = (await pb.collection("order_items").getFullList({
filter: `(item_status = "assigned" || item_status = "collection_pool") && ((assigned_at >= "${date}T00:00:00.000Z" && assigned_at <= "${date}T23:59:59.999Z") || assigned_at = "" || assigned_at = null)`,
filter: `(item_status = "assigned" || item_status = "collection_pool")`,
expand: "purchase_order.supplier",
fields: "id,purchase_order",
$autoCancel: false,
@ -388,7 +361,8 @@ export default function CollectionMapPage() {
} finally {
setLoading(false);
}
}, [date]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchUsers = useCallback(async () => {
try {
@ -521,9 +495,7 @@ export default function CollectionMapPage() {
/* -- assign dialog -------------------------------------------------------- */
const openAssignDialog = useCallback(
(row: StopItem) => {
const initialDate = row.assigned_at
? row.assigned_at.slice(0, 10)
: date;
const initialDate = row.assigned_at ? row.assigned_at.slice(0, 10) : "";
setAssignDialog({
open: true,
row,
@ -531,7 +503,8 @@ export default function CollectionMapPage() {
initialDate,
});
},
[date]
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const submitAssign = useCallback(
@ -599,7 +572,7 @@ export default function CollectionMapPage() {
<CardContent>
<p className="text-3xl font-bold">{stopCount}</p>
<p className="text-xs text-muted-foreground">
For {formatDateLabel(date)} across {stopsWithCoords.length} supplier{stopsWithCoords.length === 1 ? "" : "s"}
Across {stopsWithCoords.length} supplier{stopsWithCoords.length === 1 ? "" : "s"}
</p>
</CardContent>
</Card>
@ -624,50 +597,11 @@ export default function CollectionMapPage() {
</div>
{/* Toolbar */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-2">
{/* Date picker */}
<div className="relative">
<Calendar className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="date"
value={date}
onValueChange={setDate}
className="h-9 w-[170px] pl-9 text-sm"
aria-label="Scheduled date"
/>
</div>
<div className="flex flex-wrap gap-1.5">
{[
{ label: "Today", offset: 0 },
{ label: "Tomorrow", offset: 1 },
{ label: "+3 days", offset: 3 },
{ label: "Next week", offset: 7 },
].map((q) => {
const target = addDays(ymdToday(), q.offset);
const active = target === date;
return (
<Button
key={q.label}
type="button"
size="sm"
variant={active ? "default" : "secondary"}
onClick={() => setDate(target)}
className="h-9"
>
{q.label}
</Button>
);
})}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" className="h-9" onClick={fetchRows} disabled={loading}>
<RefreshCw className={cn("mr-1.5 h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" size="sm" className="h-9" onClick={fetchRows} disabled={loading}>
<RefreshCw className={cn("mr-1.5 h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
</div>
<div className="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
@ -907,6 +841,9 @@ export default function CollectionMapPage() {
onSubmit={submitAssign}
initialDriverId={assignDialog.initialDriverId}
initialDate={assignDialog.initialDate}
requireReadyForCollection
readyForCollection={assignDialog.row?.ready_for_collection ?? false}
busy={loading}
eyebrow={
assignDialog.row ? (
<span className="inline-flex items-center gap-2">