refactor: update pool UI, fix tiles and config

overhaul the collection pool page's ready status UI: split the clickable chip into a static status indicator and dedicated toggle button with loading state, improving user clarity between status and actionable elements
update all related copy (toast messages, filter labels, button text) to use consistent "awaiting supplier confirmation" terminology across the interface
remove invalid `transparent` prop from traffic tile layers in both the collection map and traffic tiles components, update comments to reflect Leaflet does not support this option
add wildcard to allowedDevOrigins in next.config.ts to simplify local development testing across different network interfaces
add a comprehensive database backfill script to fix assigned order items missing the `ready_for_collection` flag, ensuring drivers see their correct assigned stops in the driver app
This commit is contained in:
SJCoetz 2026-07-07 18:01:43 +02:00
parent 7f924ccc45
commit 17ae928331
5 changed files with 188 additions and 27 deletions

View file

@ -6,7 +6,7 @@ const nextConfig: NextConfig = {
// app is normally loaded on `localhost`, but we also load it on
// `127.0.0.1` (and on the LAN IP for phone testing). Listing both keeps
// the HMR client from hanging on the first load.
allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.122", "192.168.1.128"],
allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.122", "192.168.1.128", "*"],
};
export default nextConfig;

View file

@ -0,0 +1,126 @@
/**
* Backfill — flip `ready_for_collection` to `true` on every row that is
* already `item_status = "assigned"` but never had the flag set.
*
* Why this exists as a sibling to the original migration:
*
* `2026-07-07-order-items-ready-for-collection.ts` introduced the
* `ready_for_collection` bool AND backfilled every existing
* `collection_pool` row to `true`. It deliberately did NOT touch
* rows that were already `assigned` — at the time the migration
* ran, the theory was "assigned items have already been hand-vetted
* by the operator, so the flag should already be on them, so we
* don't need to backfill".
*
* That theory was wrong. In practice, several `assigned` rows had
* been created via paths that did not set the flag (direct PB admin
* edits, an older assignment workflow that pre-dated the column).
* The driver app's `getMyAssignedItems` filter requires
* `ready_for_collection = true`, so the driver sees an empty
* Assigned tab even though they have real stops in the DB.
*
* This script mirrors the original pool-backfill shape so the gap
* closes without forcing the operator to re-toggle every row in the
* dashboard.
*
* Idempotent — only flips rows where `ready_for_collection != true`.
*
* Run with (from WFDASH_NEXTJS so .env.local is found):
* npx tsx scripts/migrations/2026-07-07-backfill-assigned-rfc.ts
*/
/* eslint-disable */
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function loadEnvFile(name: string) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
/* fine — rely on shell env */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
async function main() {
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
const email = process.env.PB_ADMIN_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD;
if (!url || !email || !password) {
throw new Error(
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local).",
);
}
const pb = new PocketBase(url);
await pb.collection("_superusers").authWithPassword(email, password);
console.log(`[backfill-assigned-rfc] Authenticated against ${url}`);
// Sanity check — make sure the column exists before we query on it.
const col = await pb.collections.getOne("order_items");
const hasField = (col.fields as Array<{ name: string }>).some(
(f) => f.name === "ready_for_collection",
);
if (!hasField) {
throw new Error(
"ready_for_collection field missing on order_items — run 2026-07-07-order-items-ready-for-collection.ts first.",
);
}
// Find every assigned row that isn't already ready.
// `!= true` matches false AND null — null is the default for rows
// created before the column existed, plus any rows where the
// boolean was never written.
const rows = await pb.collection("order_items").getFullList({
filter: 'item_status = "assigned" && ready_for_collection != true',
fields: "id,item_status,ready_for_collection,drv_id",
});
console.log(
`[backfill-assigned-rfc] candidates (assigned, rfc!=true): ${rows.length}`,
);
let flipped = 0;
let failed = 0;
for (const row of rows) {
try {
await pb.collection("order_items").update(row.id, {
ready_for_collection: true,
});
flipped++;
} catch (err) {
failed++;
console.error(
`[backfill-assigned-rfc] update failed for ${row.id}:`,
err instanceof Error ? err.message : err,
);
}
}
// Post-state snapshot — confirm the filter now returns 0.
const remaining = await pb.collection("order_items").getFullList({
filter: 'item_status = "assigned" && ready_for_collection != true',
fields: "id",
});
console.log(`[backfill-assigned-rfc] flipped: ${flipped}`);
console.log(`[backfill-assigned-rfc] failed: ${failed}`);
console.log(
`[backfill-assigned-rfc] post-state: ${remaining.length} assigned rows still have rfc!=true`,
);
console.log("[backfill-assigned-rfc] Done.");
}
main().catch((err) => {
console.error("[backfill-assigned-rfc] failed:", err);
process.exit(1);
});

View file

@ -1196,7 +1196,6 @@ function TrafficOverlay({ enabled }: { enabled: boolean }) {
maxZoom: 19,
tileSize: 256,
opacity: 0.55,
transparent: true,
// Silent error — broken tiles fade to the OSM base. If we
// ever start 429-ing en masse, this is where we'd surface it.
errorTileUrl:

View file

@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Truck, FileText, AlertTriangle, ExternalLink, Search, Users, Calendar, Building2, UserRound, Check } from "lucide-react";
import { Truck, FileText, AlertTriangle, ExternalLink, Search, Users, Calendar, Building2, UserRound, Check, Loader2 } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
@ -464,7 +464,7 @@ export default function CollectionPoolPage() {
const text = await res.text().catch(() => "");
throw new Error(text || `HTTP ${res.status}`);
}
toast.success(ready ? "Marked ready for collection." : "Marked unready.");
toast.success(ready ? "Marked ready for collection." : "Marked awaiting supplier confirmation.");
await load();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@ -506,7 +506,7 @@ export default function CollectionPoolPage() {
setBusyId(null);
}
}
if (ok > 0) toast.success(`${ok} item${ok === 1 ? "" : "s"} marked ${ready ? "ready" : "unready"}.`);
if (ok > 0) toast.success(`${ok} item${ok === 1 ? "" : "s"} marked ${ready ? "ready" : "awaiting supplier confirmation"}.`);
if (failed > 0) toast.error(`${failed} failed to update.`);
setSelected(new Set());
await load();
@ -576,7 +576,7 @@ export default function CollectionPoolPage() {
: "text-muted-foreground hover:bg-muted"
)}
>
{opt === "all" ? "All" : opt === "ready" ? "Ready only" : "Unready only"}
{opt === "all" ? "All" : opt === "ready" ? "Ready only" : "Awaiting only"}
</button>
))}
</div>
@ -853,25 +853,30 @@ function PoolRow({
<span className={cn("h-1.5 w-1.5 rounded-full", ageChip.dot)} aria-hidden />
{ageChip.label}
</span>
{/* Ready-for-collection chip — supplier-confirmed gate.
Click toggles the row's ready state. Green = the supplier
has confirmed the goods are ready to pick up (drivers see
this row); amber = still waiting on the supplier quote. */}
<button
type="button"
disabled={busy}
onClick={() => onToggleReady(row.id, !row.ready_for_collection)}
{/* Ready-for-collection: a status indicator (left) + a dedicated
toggle action (right). Previously this was a single button
styled as a meta chip — users read it as read-only state and
didn't realise it was clickable. Now the state reads as state
(no hover affordance, no cursor-pointer, plain dot+label like
the days-in-pool chip), and the action sits beside it as a
ghost button so it reads as an action. The inline spinner
during the PATCH makes the round trip visible. */}
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium transition-opacity",
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium",
row.ready_for_collection
? "bg-emerald-100 text-emerald-800 ring-1 ring-emerald-200/60"
: "bg-amber-100 text-amber-800 ring-1 ring-amber-200/60",
busy ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:opacity-80"
: "bg-amber-100 text-amber-800 ring-1 ring-amber-200/60"
)}
title={
row.ready_for_collection
? "Supplier confirmed — driver app surfaces this row. Click to mark unready."
: "Waiting on supplier confirmation. Click to mark ready."
? "Supplier confirmed — driver app surfaces this row."
: "Waiting on supplier confirmation. Use the action beside to mark ready."
}
aria-label={
row.ready_for_collection
? "Ready for collection"
: "Awaiting supplier confirmation"
}
>
<span
@ -881,8 +886,8 @@ function PoolRow({
)}
aria-hidden
/>
{row.ready_for_collection ? "Ready" : "Unready"}
</button>
{row.ready_for_collection ? "Ready" : "Awaiting"}
</span>
</div>
{isAssigned && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
@ -920,7 +925,7 @@ function PoolRow({
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-col items-stretch gap-2">
<Button
size="sm"
variant={isAssigned ? "outline" : "default"}
@ -930,6 +935,35 @@ function PoolRow({
<Truck className="mr-1 h-3.5 w-3.5" />
{isAssigned ? "Reschedule" : "Assign driver"}
</Button>
<Button
size="sm"
variant={row.ready_for_collection ? "outline" : "default"}
onClick={() => onToggleReady(row.id, !row.ready_for_collection)}
disabled={busy}
className={
row.ready_for_collection
? undefined
: "bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
}
aria-label={
row.ready_for_collection
? "Mark this item as awaiting supplier confirmation"
: "Mark this item as ready for collection"
}
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Check
className={cn(
"mr-1 h-3.5 w-3.5",
row.ready_for_collection ? "text-muted-foreground" : "text-white"
)}
aria-hidden
/>
)}
{row.ready_for_collection ? "Mark awaiting" : "Mark ready"}
</Button>
</div>
</div>
</li>
@ -967,8 +1001,9 @@ function BulkAssignDriverButton({
/**
* Supplier-header bulk action that flips `ready_for_collection` for every
* selected row at once. Two buttons side-by-side: "Mark ready" (default)
* and "Mark unready". Same visual treatment as the other bulk action so
* the supplier header reads as a small toolbar.
* and "Mark awaiting". Same visual treatment as the other bulk action so
* the supplier header reads as a small toolbar. Vocabulary mirrors the
* row-level status indicator so operators learn one set of labels.
*/
function BulkToggleReadyButton({
rows,
@ -997,7 +1032,7 @@ function BulkToggleReadyButton({
variant="ghost"
onClick={() => onBulkToggleReady(rows.map((r) => r.id), false)}
>
Unready ({rows.length})
Mark awaiting ({rows.length})
</Button>
</div>
);

View file

@ -73,7 +73,8 @@ export function TrafficTiles({
opacity,
// Clear pixel where TomTom has no traffic data, so the OSM base
// map shows through instead of rendering a grey block.
transparent: true,
// (Leaflet has no `transparent` option — empty PNG tiles are
// already transparent on render, so this is handled implicitly.)
// Fade broken tiles into the OSM base so a single 429 doesn't
// black out a quarter of the map.
errorTileUrl:
@ -131,7 +132,7 @@ export function createTrafficLayer({
maxZoom: 19,
tileSize: 256,
opacity,
transparent: true,
// No `transparent` option — Leaflet tiles are whatever the source PNG is.
errorTileUrl:
"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==",
attribution: