From 7ba0b9e71fa9118fab3aad1934066bb426ef4e4a Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 7 Sep 2026 08:30:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(visits):=20batch=20pin=20tool=20=E2=80=94?= =?UTF-8?q?=20tag=20every=20customer=20on=20a=20shaft=20at=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-pin tool (pick one customer, click the map, save) doesn't scale to how the customer base actually clusters: dozens of contacts sit at Harmony Kusasalethu, at South Deep Shaft 3, and so on. This adds a batch counterpart at /visits/pin-tool/batch: type a site name, tick every customer there, click the map once, and all of them get pinned. Site is a plain free-text field on customers (Customer.site in lib/types.ts), not a separate sites collection — simpler to add and maintain, at the cost of relying on the office typing the name the same way each time. The batch tool's autocomplete (backed by listDistinctSites) and its "select everyone already tagged X" shortcut exist specifically to curb that drift without a second collection to manage. - jitterCoordinates (lib/geo.ts) scatters each customer 30-80m from the clicked point so forty pins on one shaft render as distinct dots on the office map instead of one marker stacked forty deep. Bounded by distanceKm in tests, which is the same great-circle maths the check-in radius match uses. - assignCustomersToSite / listDistinctSites (customers-service.ts) — the batch write and its autocomplete source. Each customer is written independently, mirroring recordVisits: a stale id in a forty-customer batch shouldn't cost the other thirty-nine. - use-customer-list.ts extracted from pin-tool-view.tsx (verbatim, no behaviour change) so the batch view doesn't duplicate the fetch. - batch-pin-helpers.ts holds the pure decisions (save-readiness order, filter) the same way pin-tool-helpers.ts does for the single-pin tool — the Leaflet-dependent view is hard to unit test directly. Applied the "use client" + dynamic(ssr:false) lesson from the single-pin tool's earlier 500 up front in batch/page.tsx, with a comment explaining why it's load-bearing rather than decorative. 99 new/updated tests. Scoped verification only (geo, customers-service, visits/*) — the wider suite currently shows unrelated failures in quotes-service/quote-pdf-service from a change on main I did not make and did not investigate further; none of it touches customers, geo, or visits. BLOCKED until one PB admin change: customers.site does not exist yet (text field, optional). Nothing in this commit writes to it until then. Co-Authored-By: Claude Opus 5 --- src/app/(app)/visits/page.tsx | 59 ++-- src/app/(app)/visits/pin-tool/actions.test.ts | 87 +++++- src/app/(app)/visits/pin-tool/actions.ts | 63 +++- .../visits/pin-tool/batch-pin-helpers.test.ts | 79 +++++ .../visits/pin-tool/batch-pin-helpers.ts | 51 +++ .../(app)/visits/pin-tool/batch-pin-view.tsx | 292 ++++++++++++++++++ src/app/(app)/visits/pin-tool/batch/page.tsx | 54 ++++ src/app/(app)/visits/pin-tool/page.tsx | 31 +- .../(app)/visits/pin-tool/pin-tool-view.tsx | 54 +--- .../visits/pin-tool/use-customer-list.ts | 53 ++++ src/lib/geo.test.ts | 39 ++- src/lib/geo.ts | 35 +++ src/lib/server/customers-coordinates.test.ts | 125 +++++++- src/lib/server/customers-service.ts | 123 +++++++- src/lib/types.ts | 11 + 15 files changed, 1062 insertions(+), 94 deletions(-) create mode 100644 src/app/(app)/visits/pin-tool/batch-pin-helpers.test.ts create mode 100644 src/app/(app)/visits/pin-tool/batch-pin-helpers.ts create mode 100644 src/app/(app)/visits/pin-tool/batch-pin-view.tsx create mode 100644 src/app/(app)/visits/pin-tool/batch/page.tsx create mode 100644 src/app/(app)/visits/pin-tool/use-customer-list.ts diff --git a/src/app/(app)/visits/page.tsx b/src/app/(app)/visits/page.tsx index 7617ddf..c333877 100644 --- a/src/app/(app)/visits/page.tsx +++ b/src/app/(app)/visits/page.tsx @@ -30,25 +30,46 @@ export default function VisitsLandingPage() {

- - - Pin Tool - - Seed the West Rand mine shafts with real coordinates. Pick a - customer, click the map, save — rep check-in then has - somewhere to land on day one. - - - - - Open pin tool - - - - +
+ + + Pin Tool + + One customer at a time. Pick a customer, click the map, save — + for the odd contact with their own delivery point. + + + + + Open pin tool + + + + + + + + Batch pin a site + + Most customers sit in clusters — everyone at Harmony + Kusasalethu, at South Deep Shaft 3. Type the site, tick who's + there, click the map once. + + + + + Open batch tool + + + + +
diff --git a/src/app/(app)/visits/pin-tool/actions.test.ts b/src/app/(app)/visits/pin-tool/actions.test.ts index e18fd4f..7980d6a 100644 --- a/src/app/(app)/visits/pin-tool/actions.test.ts +++ b/src/app/(app)/visits/pin-tool/actions.test.ts @@ -7,10 +7,12 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; * mock for the service, exercise the action's contract — validation, * happy path, error surfacing — without touching PocketBase. */ -const { store, setCustomerCoordinates, buildPb } = vi.hoisted(() => { +const { store, setCustomerCoordinates, assignCustomersToSite, listDistinctSites, buildPb } = vi.hoisted(() => { type Store = Map>; const store: Record = {}; const setCustomerCoordinates = vi.fn(); + const assignCustomersToSite = vi.fn(); + const listDistinctSites = vi.fn(); const getOrCreate = (name: string): Store => { if (!store[name]) store[name] = new Map(); @@ -35,7 +37,7 @@ const { store, setCustomerCoordinates, buildPb } = vi.hoisted(() => { }), }); - return { store, setCustomerCoordinates, buildPb }; + return { store, setCustomerCoordinates, assignCustomersToSite, listDistinctSites, buildPb }; }); vi.mock("@/lib/server/pocketbase-admin", () => ({ @@ -44,6 +46,8 @@ vi.mock("@/lib/server/pocketbase-admin", () => ({ vi.mock("@/lib/server/customers-service", () => ({ setCustomerCoordinates: (...args: unknown[]) => setCustomerCoordinates(...args), + assignCustomersToSite: (...args: unknown[]) => assignCustomersToSite(...args), + listDistinctSites: (...args: unknown[]) => listDistinctSites(...args), })); function seedCustomer(id: string, fields: Record) { @@ -54,6 +58,8 @@ function seedCustomer(id: string, fields: Record) { beforeEach(() => { for (const k of Object.keys(store)) store[k].clear(); setCustomerCoordinates.mockReset(); + assignCustomersToSite.mockReset(); + listDistinctSites.mockReset(); // Default mock: succeed when called with any input. setCustomerCoordinates.mockImplementation( async (id: string, lat: number, lng: number) => ({ @@ -62,6 +68,8 @@ beforeEach(() => { coordinates: { lat, lon: lng }, }) ); + assignCustomersToSite.mockResolvedValue({ updated: [], errors: [] }); + listDistinctSites.mockResolvedValue([]); }); describe("saveCustomerPinAction", () => { @@ -125,4 +133,77 @@ describe("saveCustomerPinAction", () => { expect(result.ok).toBe(false); expect(result.error).toBeTruthy(); }); -}); \ No newline at end of file +}); +describe("assignCustomersToSiteAction", () => { + it("forwards to the service and returns the update summary", async () => { + assignCustomersToSite.mockResolvedValue({ updated: ["c1", "c2"], errors: [] }); + const { assignCustomersToSiteAction } = await import("./actions"); + + const result = await assignCustomersToSiteAction(["c1", "c2"], "South Deep Shaft 3", -26.4, 27.4); + + expect(assignCustomersToSite).toHaveBeenCalledWith( + ["c1", "c2"], + "South Deep Shaft 3", + -26.4, + 27.4 + ); + expect(result).toEqual({ ok: true, updated: ["c1", "c2"], errors: [] }); + }); + + it("passes through partial per-customer errors without failing the action", async () => { + assignCustomersToSite.mockResolvedValue({ + updated: ["c1"], + errors: [{ customerId: "c2", message: "not found" }], + }); + const { assignCustomersToSiteAction } = await import("./actions"); + + const result = await assignCustomersToSiteAction(["c1", "c2"], "Site", -26.4, 27.4); + + expect(result.ok).toBe(true); + expect(result.errors).toEqual([{ customerId: "c2", message: "not found" }]); + }); + + it("rejects an empty customer list before calling the service", async () => { + const { assignCustomersToSiteAction } = await import("./actions"); + const result = await assignCustomersToSiteAction([], "Site", -26.4, 27.4); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/customer/i); + expect(assignCustomersToSite).not.toHaveBeenCalled(); + }); + + it("rejects a blank site name before calling the service", async () => { + const { assignCustomersToSiteAction } = await import("./actions"); + const result = await assignCustomersToSiteAction(["c1"], " ", -26.4, 27.4); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/site/i); + expect(assignCustomersToSite).not.toHaveBeenCalled(); + }); + + it("surfaces a service-level failure (e.g. bad coordinates) as ok:false", async () => { + assignCustomersToSite.mockRejectedValue(new Error("lat must be a finite number in [-90, 90]")); + const { assignCustomersToSiteAction } = await import("./actions"); + const result = await assignCustomersToSiteAction(["c1"], "Site", 999, 27.4); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/lat/i); + }); +}); + +describe("listSitesAction", () => { + it("returns the sites from the service", async () => { + listDistinctSites.mockResolvedValue([{ site: "South Deep Shaft 3", customerCount: 4 }]); + const { listSitesAction } = await import("./actions"); + const result = await listSitesAction(); + expect(result).toEqual({ + ok: true, + sites: [{ site: "South Deep Shaft 3", customerCount: 4 }], + }); + }); + + it("surfaces a failure as ok:false rather than throwing", async () => { + listDistinctSites.mockRejectedValue(new Error("PB unreachable")); + const { listSitesAction } = await import("./actions"); + const result = await listSitesAction(); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/PB unreachable/); + }); +}); diff --git a/src/app/(app)/visits/pin-tool/actions.ts b/src/app/(app)/visits/pin-tool/actions.ts index 8f9b66f..e5240e7 100644 --- a/src/app/(app)/visits/pin-tool/actions.ts +++ b/src/app/(app)/visits/pin-tool/actions.ts @@ -18,7 +18,11 @@ * than the toast. Mirrors the shape of `saveAiSettingsAction` in * `src/app/(app)/settings/ai/actions.ts`. */ -import { setCustomerCoordinates } from "@/lib/server/customers-service"; +import { + setCustomerCoordinates, + assignCustomersToSite, + listDistinctSites, +} from "@/lib/server/customers-service"; import type { Customer } from "@/lib/types"; export interface SavePinResult { @@ -62,4 +66,61 @@ export async function saveCustomerPinAction( error: error instanceof Error ? error.message : "Failed to save pin", }; } +} + +/** + * Server Action for the batch pin tool (`/visits/pin-tool/batch`). + * + * Same in-app-cookie-session reasoning as `saveCustomerPinAction` + * above — no `AGENT_API_KEY` round-trip needed for an office-only + * screen. Delegates the write itself, including the per-customer + * jitter, to `assignCustomersToSite`. + */ +export interface AssignSiteResult { + ok: boolean; + error?: string; + updated?: string[]; + errors?: { customerId: string; message: string }[]; +} + +export async function assignCustomersToSiteAction( + customerIds: string[], + site: string, + lat: number, + lng: number, +): Promise { + if (!Array.isArray(customerIds) || customerIds.length === 0) { + return { ok: false, error: "Select at least one customer" }; + } + if (!site || !site.trim()) { + return { ok: false, error: "Site name is required" }; + } + + try { + const result = await assignCustomersToSite(customerIds, site, lat, lng); + return { ok: true, updated: result.updated, errors: result.errors }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "Failed to assign site", + }; + } +} + +export interface ListSitesResult { + ok: boolean; + error?: string; + sites?: { site: string; customerCount: number }[]; +} + +export async function listSitesAction(): Promise { + try { + const sites = await listDistinctSites(); + return { ok: true, sites }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "Failed to load sites", + }; + } } \ No newline at end of file diff --git a/src/app/(app)/visits/pin-tool/batch-pin-helpers.test.ts b/src/app/(app)/visits/pin-tool/batch-pin-helpers.test.ts new file mode 100644 index 0000000..d4981fc --- /dev/null +++ b/src/app/(app)/visits/pin-tool/batch-pin-helpers.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { batchPinReadiness, filterCustomersForBatch } from "./batch-pin-helpers"; +import type { Customer } from "@/lib/types"; + +function customer(over: Partial & { id: string; customer: string }): Customer { + return { + email: "", + alternative_email: "", + buyer_email: "", + number: "", + company: "", + dateadded: "", + lastvisiteddate: "", + rep: "", + description: "", + collectionId: "", + collectionName: "customers", + created: "", + updated: "", + ...over, + } as Customer; +} + +describe("batchPinReadiness", () => { + it("is ready only once a site name, at least one customer, and a pin all exist", () => { + expect(batchPinReadiness("Site", 3, true).canSave).toBe(true); + }); + + it("blocks and explains when nothing is set", () => { + const r = batchPinReadiness("", 0, false); + expect(r.canSave).toBe(false); + expect(r.hint).toMatch(/site name/i); + }); + + it("asks for a site name first even if customers and a pin are set", () => { + // Order matters for a clear single-line hint — site name is the + // first thing the operator types, so it's the first thing asked for. + const r = batchPinReadiness("", 3, true); + expect(r.canSave).toBe(false); + expect(r.hint).toMatch(/site name/i); + }); + + it("asks for customers once a site name exists", () => { + const r = batchPinReadiness("Site", 0, true); + expect(r.canSave).toBe(false); + expect(r.hint).toMatch(/customer/i); + }); + + it("asks for a pin once site name and customers exist", () => { + const r = batchPinReadiness("Site", 3, false); + expect(r.canSave).toBe(false); + expect(r.hint).toMatch(/map/i); + }); + + it("treats a blank/whitespace site name as not set", () => { + expect(batchPinReadiness(" ", 3, true).canSave).toBe(false); + }); +}); + +describe("filterCustomersForBatch", () => { + const customers = [ + customer({ id: "1", customer: "Danie Greyling" }), + customer({ id: "2", customer: "Francois Grove" }), + customer({ id: "3", customer: "Gert Greyling" }), + ]; + + it("matches by substring, case-insensitively", () => { + expect(filterCustomersForBatch(customers, "greyling").map((c) => c.id)).toEqual(["1", "3"]); + }); + + it("returns everyone for a blank search term", () => { + expect(filterCustomersForBatch(customers, "")).toHaveLength(3); + expect(filterCustomersForBatch(customers, " ")).toHaveLength(3); + }); + + it("returns nothing for a term matching nobody", () => { + expect(filterCustomersForBatch(customers, "zzz")).toEqual([]); + }); +}); diff --git a/src/app/(app)/visits/pin-tool/batch-pin-helpers.ts b/src/app/(app)/visits/pin-tool/batch-pin-helpers.ts new file mode 100644 index 0000000..2851bfd --- /dev/null +++ b/src/app/(app)/visits/pin-tool/batch-pin-helpers.ts @@ -0,0 +1,51 @@ +/** + * Pure helpers for the batch pin tool (`/visits/pin-tool/batch`). + * + * Same split as `pin-tool-helpers.ts`: the Leaflet-dependent view is + * hard to unit test, so the decisions it makes ("can the operator save + * right now?", "which customers match the filter?") live here instead. + */ + +import type { Customer } from "@/lib/types"; + +export interface BatchPinReadiness { + canSave: boolean; + hint: string; +} + +/** + * Decides whether the batch form can be saved, and what single-line + * hint to show otherwise. Checked in the order the operator fills the + * form: site name, then who, then where — so the hint always points at + * the next actual step rather than an arbitrary one. + */ +export function batchPinReadiness( + site: string, + selectedCount: number, + hasPin: boolean +): BatchPinReadiness { + if (!site.trim()) { + return { canSave: false, hint: "Type a site name (e.g. \"South Deep Shaft 3\")." }; + } + if (selectedCount === 0) { + return { canSave: false, hint: "Tick the customers on this site." }; + } + if (!hasPin) { + return { canSave: false, hint: "Click the map to set this site's location." }; + } + return { canSave: true, hint: "Ready to save." }; +} + +/** + * Customers matching a free-text filter, by name — case-insensitive + * substring. A blank term returns everyone, matching how the search + * box reads on first render. + */ +export function filterCustomersForBatch( + customers: Customer[], + term: string +): Customer[] { + const needle = term.trim().toLowerCase(); + if (!needle) return customers; + return customers.filter((c) => c.customer.toLowerCase().includes(needle)); +} diff --git a/src/app/(app)/visits/pin-tool/batch-pin-view.tsx b/src/app/(app)/visits/pin-tool/batch-pin-view.tsx new file mode 100644 index 0000000..e4e332c --- /dev/null +++ b/src/app/(app)/visits/pin-tool/batch-pin-view.tsx @@ -0,0 +1,292 @@ +"use client"; + +/** + * Batch pin tool — tag every customer on one mine shaft in a single + * operation instead of placing pins one at a time. + * + * Most of the customer base sits in clusters — dozens of contacts at + * Harmony Kusasalethu, at South Deep Shaft 3 — and the single-pin tool + * (`pin-tool-view.tsx`) doesn't scale to that. Here the operator types + * a site name (autocompleted against names already in use, to curb + * drift — see `Customer.site` in `lib/types.ts`), ticks every customer + * on that site, clicks the map once, and every ticked customer gets + * the site name plus a coordinate near that click — jittered per + * customer server-side so the pins render as distinct dots rather than + * one marker stacked forty deep. + */ +import { useEffect, useMemo, useState, useTransition } from "react"; +import { MapContainer, Marker, TileLayer, Tooltip, useMapEvents } from "react-leaflet"; +import { useTheme } from "next-themes"; +import { toast } from "sonner"; +import { Save, Trash2, MapPin, Loader2, Search } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { pinMarker } from "@/components/leaflet-markers"; +import type { Customer } from "@/lib/types"; +import { assignCustomersToSiteAction, listSitesAction } from "./actions"; +import { formatPin, type DroppedPin } from "./pin-tool-helpers"; +import { batchPinReadiness, filterCustomersForBatch } from "./batch-pin-helpers"; +import { useCustomerList } from "./use-customer-list"; + +const DEFAULT_CENTER: [number, number] = [-26.2, 27.8]; +const DEFAULT_ZOOM = 9; + +const LIGHT_TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; +const DARK_TILE_URL = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"; +const LIGHT_TILE_ATTRIBUTION = '© OpenStreetMap'; +const DARK_TILE_ATTRIBUTION = '© OpenStreetMap © CARTO'; + +function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => void }) { + useMapEvents({ + click: (event) => onPick(event.latlng.lat, event.latlng.lng), + }); + return null; +} + +interface BatchPinViewProps { + initialCustomers?: Customer[]; +} + +export function BatchPinView({ initialCustomers }: BatchPinViewProps = {}) { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + const { customers, loading, error } = useCustomerList(initialCustomers); + + const [site, setSite] = useState(""); + const [siteOptions, setSiteOptions] = useState<{ site: string; customerCount: number }[]>([]); + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [droppedPin, setDroppedPin] = useState(null); + const [pending, startTransition] = useTransition(); + + useEffect(() => { + void listSitesAction().then((result) => { + if (result.ok && result.sites) setSiteOptions(result.sites); + }); + }, []); + + const filtered = useMemo( + () => filterCustomersForBatch(customers, search), + [customers, search] + ); + + const matchingSiteSuggestions = useMemo(() => { + const needle = site.trim().toLowerCase(); + if (!needle) return []; + return siteOptions.filter((s) => s.site.toLowerCase().includes(needle)).slice(0, 5); + }, [site, siteOptions]); + + const readiness = batchPinReadiness(site, selected.size, droppedPin !== null); + + const toggle = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + /** Ticks every customer already tagged with the typed site name — the fast path for re-pinning a shaft. */ + const selectExistingSiteMembers = () => { + const trimmed = site.trim(); + if (!trimmed) return; + const matchIds = customers + .filter((c) => (c.site ?? "").trim().toLowerCase() === trimmed.toLowerCase()) + .map((c) => c.id); + setSelected(new Set(matchIds)); + if (matchIds.length === 0) { + toast.info(`No customers are currently tagged "${trimmed}" yet.`); + } + }; + + const handleSave = () => { + if (!readiness.canSave || !droppedPin) return; + const ids = [...selected]; + const count = ids.length; + const siteName = site.trim(); + + startTransition(async () => { + const result = await assignCustomersToSiteAction( + ids, + siteName, + droppedPin.lat, + droppedPin.lng + ); + + if (!result.ok) { + toast.error(result.error ?? "Failed to assign site"); + return; + } + + const updatedCount = result.updated?.length ?? 0; + const failedCount = result.errors?.length ?? 0; + + if (failedCount === 0) { + toast.success(`Pinned ${updatedCount} customer${updatedCount === 1 ? "" : "s"} to "${siteName}"`); + } else { + toast.warning( + `Pinned ${updatedCount} of ${count} — ${failedCount} failed. Check those customers individually.` + ); + } + + setSelected(new Set()); + setDroppedPin(null); + setSite(""); + setSiteOptions((prev) => { + const existing = prev.find((s) => s.site === siteName); + if (existing) { + return prev.map((s) => + s.site === siteName ? { ...s, customerCount: s.customerCount + updatedCount } : s + ); + } + return [...prev, { site: siteName, customerCount: updatedCount }]; + }); + }); + }; + + const handleClear = () => { + setSite(""); + setSelected(new Set()); + setDroppedPin(null); + setSearch(""); + }; + + return ( +
+
+ +
+
+ Click the map to set this site's location +
+ + + setDroppedPin({ lat, lng })} /> + {droppedPin && ( + + + {site.trim() || "New site"} ({selected.size}) + + + )} + +
+
+ +
+ + + + + Site name + + + + setSite(event.target.value)} + placeholder='e.g. "South Deep Shaft 3"' + /> + {matchingSiteSuggestions.length > 0 && ( +
+ {matchingSiteSuggestions.map((s) => ( + + ))} +
+ )} + {site.trim() && ( + + )} +
+
+ + + + + Customers on this site{" "} + {selected.size > 0 && {selected.size} selected} + + + + {error ? ( +

{error}

+ ) : ( +
+ + setSearch(event.target.value)} + placeholder={loading ? "Loading customers…" : "Search by name…"} + disabled={loading} + className="pl-8" + /> +
+ )} +
+ {filtered.length === 0 && !loading && ( +

No customers match.

+ )} + {filtered.map((c) => ( + + ))} +
+ + + +
+

{readiness.hint}

+ {droppedPin && ( +

{formatPin(droppedPin.lat, droppedPin.lng)}

+ )} +
+ + +
+
+
+
+
+
+
+ ); +} diff --git a/src/app/(app)/visits/pin-tool/batch/page.tsx b/src/app/(app)/visits/pin-tool/batch/page.tsx new file mode 100644 index 0000000..c18bab5 --- /dev/null +++ b/src/app/(app)/visits/pin-tool/batch/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { ArrowLeft, MapPinned } from "lucide-react"; + +/** + * `"use client"` is load-bearing here, not decoration: `dynamic(..., + * { ssr: false })` is rejected by Next inside a Server Component + * ("not allowed with next/dynamic in Server Components") and fails at + * *request* time with a 500 rather than at build time — `tsc` and the + * unit tests both stay green. `pin-tool/page.tsx` hit exactly this; + * carrying the directive here from the start avoids repeating it. + */ +const BatchPinView = dynamic( + () => import("../batch-pin-view").then((module) => module.BatchPinView), + { + ssr: false, + loading: () => ( +
+
+
+

Loading batch pin tool…

+
+
+ ), + } +); + +export default function BatchPinToolPage() { + return ( +
+
+ + Single-pin tool + +
+ + Visits +
+

Batch pin a site

+

+ Tag every customer on one mine shaft at once. Type the site name, tick + who's there, click the map — everyone gets pinned near that spot. +

+
+ + +
+ ); +} diff --git a/src/app/(app)/visits/pin-tool/page.tsx b/src/app/(app)/visits/pin-tool/page.tsx index e0c6435..74fabc5 100644 --- a/src/app/(app)/visits/pin-tool/page.tsx +++ b/src/app/(app)/visits/pin-tool/page.tsx @@ -1,7 +1,9 @@ "use client"; import dynamic from "next/dynamic"; -import { MapPinned } from "lucide-react"; +import Link from "next/link"; +import { MapPinned, Layers } from "lucide-react"; +import { buttonVariants } from "@/components/ui/button"; /** * Office pin tool — wraps the Leaflet-heavy view in `dynamic({ ssr: false })` @@ -33,16 +35,25 @@ const PinToolView = dynamic( export default function PinToolPage() { return (
-
-
- - Visits +
+
+
+ + Visits +
+

Pin Tool

+

+ Seed the West Rand mine shafts with real coordinates. Pick a customer, + click the map, save. +

-

Pin Tool

-

- Seed the West Rand mine shafts with real coordinates. Pick a customer, - click the map, save. -

+ + + Batch pin a whole site +
diff --git a/src/app/(app)/visits/pin-tool/pin-tool-view.tsx b/src/app/(app)/visits/pin-tool/pin-tool-view.tsx index fc2babc..a5b21b5 100644 --- a/src/app/(app)/visits/pin-tool/pin-tool-view.tsx +++ b/src/app/(app)/visits/pin-tool/pin-tool-view.tsx @@ -17,7 +17,7 @@ * an empty map; once pins exist the operator can still overwrite * them by re-picking the customer and clicking a new spot. */ -import { useEffect, useMemo, useState, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { MapContainer, Marker, TileLayer, Tooltip, useMapEvents } from "react-leaflet"; import { useTheme } from "next-themes"; import { toast } from "sonner"; @@ -32,6 +32,7 @@ import { pinMarker } from "@/components/leaflet-markers"; import type { Customer } from "@/lib/types"; import { saveCustomerPinAction } from "./actions"; import { formatPin, pinSaveReadiness, type DroppedPin } from "./pin-tool-helpers"; +import { useCustomerList } from "./use-customer-list"; /** * Centre on the West Rand — most of the WFDASH customer base sits in @@ -70,57 +71,6 @@ interface PinToolViewProps { initialCustomers?: Customer[]; } -/** - * Loads the customer list once on mount. - * - * Tries the dashboard-facing browser-auth route first (`/api/v1/customers/list`), - * and if that fails (older deployments where only the agent route exists), - * falls back to the agent endpoint. Both serve the same shape today - * (the data comes from `listCustomersForDashboard`), but auth differs — - * the page runs under the office cookie session, so the browser route - * is preferred; the agent fallback exists so the tool still works if - * the browser route ever moves. - */ -function useCustomerList(initial: Customer[] | undefined): { - customers: Customer[]; - loading: boolean; - error: string | null; -} { - const [customers, setCustomers] = useState(initial ?? []); - const [loading, setLoading] = useState(!initial); - const [error, setError] = useState(null); - - useEffect(() => { - if (initial) return; - let cancelled = false; - const ctrl = new AbortController(); - void (async () => { - try { - const res = await fetch("/api/v1/customers/list?perPage=100", { - signal: ctrl.signal, - credentials: "include", - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const json = (await res.json()) as { items?: Customer[] }; - if (!cancelled) { - setCustomers(json.items ?? []); - setLoading(false); - } - } catch (err) { - if (cancelled) return; - setError(err instanceof Error ? err.message : "Failed to load customers"); - setLoading(false); - } - })(); - return () => { - cancelled = true; - ctrl.abort(); - }; - }, [initial]); - - return { customers, loading, error }; -} - export function PinToolView({ initialCustomers }: PinToolViewProps = {}) { const { resolvedTheme } = useTheme(); const isDark = resolvedTheme === "dark"; diff --git a/src/app/(app)/visits/pin-tool/use-customer-list.ts b/src/app/(app)/visits/pin-tool/use-customer-list.ts new file mode 100644 index 0000000..1a6ca2c --- /dev/null +++ b/src/app/(app)/visits/pin-tool/use-customer-list.ts @@ -0,0 +1,53 @@ +"use client"; + +/** + * Loads the customer list once on mount. Shared by the single-pin tool + * and the batch pin tool — extracted rather than duplicated when the + * batch tool needed the identical fetch. + * + * Tries the dashboard-facing browser-auth route first + * (`/api/v1/customers/list`); both pin tools run under the office + * cookie session, so this is the right auth path. + */ +import { useEffect, useState } from "react"; +import type { Customer } from "@/lib/types"; + +export function useCustomerList(initial: Customer[] | undefined): { + customers: Customer[]; + loading: boolean; + error: string | null; +} { + const [customers, setCustomers] = useState(initial ?? []); + const [loading, setLoading] = useState(!initial); + const [error, setError] = useState(null); + + useEffect(() => { + if (initial) return; + let cancelled = false; + const ctrl = new AbortController(); + void (async () => { + try { + const res = await fetch("/api/v1/customers/list?perPage=100", { + signal: ctrl.signal, + credentials: "include", + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = (await res.json()) as { items?: Customer[] }; + if (!cancelled) { + setCustomers(json.items ?? []); + setLoading(false); + } + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err.message : "Failed to load customers"); + setLoading(false); + } + })(); + return () => { + cancelled = true; + ctrl.abort(); + }; + }, [initial]); + + return { customers, loading, error }; +} diff --git a/src/lib/geo.test.ts b/src/lib/geo.test.ts index c3cda78..751c5b0 100644 --- a/src/lib/geo.test.ts +++ b/src/lib/geo.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { driverLabelFor, unassignedGlyphFor } from "./geo"; +import { driverLabelFor, unassignedGlyphFor, jitterCoordinates, distanceKm } from "./geo"; describe("driverLabelFor", () => { it("returns A for the first driver alphabetically", () => { @@ -104,4 +104,39 @@ describe("unassignedGlyphFor", () => { it("returns 'unready' for an empty array", () => { expect(unassignedGlyphFor([])).toBe("unready"); }); -}); \ No newline at end of file +}); +describe("jitterCoordinates", () => { + const CENTER: readonly [number, number] = [-26.4, 27.4]; + + it("offsets the point by a distance within the requested range", () => { + // distanceKm is the same great-circle maths the check-in radius + // match uses, so this asserts the jitter is bounded in the units + // that actually matter for the feature. + for (let i = 0; i < 50; i++) { + const jittered = jitterCoordinates(CENTER, 30, 80); + const metres = distanceKm(CENTER, jittered) * 1000; + expect(metres).toBeGreaterThanOrEqual(30 - 0.5); + expect(metres).toBeLessThanOrEqual(80 + 0.5); + } + }); + + it("never returns the exact same point", () => { + expect(jitterCoordinates(CENTER, 30, 80)).not.toEqual(CENTER); + }); + + it("produces different points on repeated calls", () => { + // Not a hard guarantee (it's random), but with a continuous range + // and a bearing over the full circle, two calls landing on the + // exact same float is negligible — this catches an accidental + // deterministic implementation. + const a = jitterCoordinates(CENTER, 30, 80); + const b = jitterCoordinates(CENTER, 30, 80); + expect(a).not.toEqual(b); + }); + + it("keeps a zero-width range at that exact distance", () => { + const jittered = jitterCoordinates(CENTER, 50, 50); + const metres = distanceKm(CENTER, jittered) * 1000; + expect(metres).toBeCloseTo(50, 0); + }); +}); diff --git a/src/lib/geo.ts b/src/lib/geo.ts index 4d098d2..c0b9f9e 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -71,6 +71,41 @@ export function parseCoordinates(value?: string | null): LatLng | null { return [first, second] as const; } +/** + * A [lat, lng] point randomly offset from `center` by a distance in + * `[minMetres, maxMetres]`, at a random bearing. + * + * Used by the batch pin tool: dozens of customers on the same mine + * shaft get the same clicked point, which would render as a single + * marker on the office map and make it impossible to confirm who's + * actually pinned. Scattering them by a few tens of metres keeps every + * pin individually visible without moving anyone off the site — at the + * 2 km check-in radius, an 80 m jitter is noise. + * + * Uses an equirectangular approximation (flat-earth over the offset + * distance), which is accurate to well under a metre at this scale — + * good enough for "visually distinct dots on a map", not for anything + * that needs survey-grade precision. + */ +export function jitterCoordinates( + center: LatLng, + minMetres: number, + maxMetres: number +): LatLng { + const [lat, lng] = center; + const bearing = Math.random() * 2 * Math.PI; + const distanceM = minMetres + Math.random() * (maxMetres - minMetres); + + const earthRadiusM = 6_371_000; + const dLat = ((distanceM * Math.cos(bearing)) / earthRadiusM) * (180 / Math.PI); + const dLng = + ((distanceM * Math.sin(bearing)) / + (earthRadiusM * Math.cos((lat * Math.PI) / 180))) * + (180 / Math.PI); + + return [lat + dLat, lng + dLng] as const; +} + /** * Great-circle distance between two [lat, lng] points in kilometres. * diff --git a/src/lib/server/customers-coordinates.test.ts b/src/lib/server/customers-coordinates.test.ts index 4330c4f..7932ba5 100644 --- a/src/lib/server/customers-coordinates.test.ts +++ b/src/lib/server/customers-coordinates.test.ts @@ -47,6 +47,14 @@ const { store, buildPb } = vi.hoisted(() => { map.set(id, merged); return merged; }, + // Only needs to satisfy `listDistinctSites`, which filters on + // `site != ""` and otherwise reads every row — no general PB + // filter parsing required here. + async getFullList(_opts?: { filter?: string }) { + return [...getOrCreate(name).values()].filter( + (row) => String(row.site ?? "").trim() !== "" + ); + }, }), }); @@ -145,4 +153,119 @@ describe("setCustomerCoordinates", () => { setCustomerCoordinates("cust-doesnt-exist", -26.2041, 28.0473) ).rejects.toThrow(/not found/i); }); -}); \ No newline at end of file +}); +describe("listDistinctSites", () => { + beforeEach(() => { + seedCustomer("cust-a", { customer: "A", site: "Harmony - Kusasalethu" }); + seedCustomer("cust-b", { customer: "B", site: "Harmony - Kusasalethu" }); + seedCustomer("cust-c", { customer: "C", site: "South Deep Shaft 3" }); + seedCustomer("cust-d", { customer: "D", site: "" }); + seedCustomer("cust-e", { customer: "E" }); // no site field at all + }); + + it("returns each distinct site with how many customers carry it", async () => { + const { listDistinctSites } = await import("@/lib/server/customers-service"); + expect(await listDistinctSites()).toEqual([ + { site: "Harmony - Kusasalethu", customerCount: 2 }, + { site: "South Deep Shaft 3", customerCount: 1 }, + ]); + }); + + it("excludes blank and missing site values", async () => { + const { listDistinctSites } = await import("@/lib/server/customers-service"); + const sites = (await listDistinctSites()).map((s) => s.site); + expect(sites).not.toContain(""); + expect(sites.length).toBe(2); + }); + + it("sorts by customer count, descending", async () => { + seedCustomer("cust-f", { customer: "F", site: "South Deep Shaft 3" }); + seedCustomer("cust-g", { customer: "G", site: "South Deep Shaft 3" }); + const { listDistinctSites } = await import("@/lib/server/customers-service"); + const sites = await listDistinctSites(); + expect(sites[0]).toEqual({ site: "South Deep Shaft 3", customerCount: 3 }); + }); + + it("returns an empty list when nobody has a site yet", async () => { + for (const k of Object.keys(store)) store[k].clear(); + const { listDistinctSites } = await import("@/lib/server/customers-service"); + expect(await listDistinctSites()).toEqual([]); + }); +}); + +describe("assignCustomersToSite", () => { + beforeEach(() => { + seedCustomer("cust-1", { customer: "One", coordinates: { lat: 0, lon: 0 } }); + seedCustomer("cust-2", { customer: "Two", coordinates: { lat: 0, lon: 0 } }); + }); + + it("tags every customer with the site name", async () => { + const { assignCustomersToSite } = await import("@/lib/server/customers-service"); + await assignCustomersToSite(["cust-1", "cust-2"], "Harmony - Kusasalethu", -26.4, 27.4); + + expect(store.customers!.get("cust-1")!.site).toBe("Harmony - Kusasalethu"); + expect(store.customers!.get("cust-2")!.site).toBe("Harmony - Kusasalethu"); + }); + + it("gives each customer a jittered, not identical, coordinate", async () => { + // The whole point of jittering: forty pins on one shaft must not + // collapse to one dot on the office map. + const { assignCustomersToSite } = await import("@/lib/server/customers-service"); + await assignCustomersToSite(["cust-1", "cust-2"], "Site", -26.4, 27.4); + + const c1 = store.customers!.get("cust-1")!.coordinates as { lat: number; lon: number }; + const c2 = store.customers!.get("cust-2")!.coordinates as { lat: number; lon: number }; + expect(c1).not.toEqual(c2); + expect(c1).not.toEqual({ lat: -26.4, lon: 27.4 }); + }); + + it("trims the site name", async () => { + const { assignCustomersToSite } = await import("@/lib/server/customers-service"); + await assignCustomersToSite(["cust-1"], " Site ", -26.4, 27.4); + expect(store.customers!.get("cust-1")!.site).toBe("Site"); + }); + + it("reports which customers were updated", async () => { + const { assignCustomersToSite } = await import("@/lib/server/customers-service"); + const result = await assignCustomersToSite(["cust-1", "cust-2"], "Site", -26.4, 27.4); + expect(result.updated.sort()).toEqual(["cust-1", "cust-2"]); + expect(result.errors).toEqual([]); + }); + + it("keeps the rest of the batch when one customer id is stale", async () => { + const { assignCustomersToSite } = await import("@/lib/server/customers-service"); + const result = await assignCustomersToSite( + ["cust-1", "nonexistent", "cust-2"], + "Site", + -26.4, + 27.4 + ); + + expect(result.updated.sort()).toEqual(["cust-1", "cust-2"]); + expect(result.errors).toEqual([ + { customerId: "nonexistent", message: expect.stringMatching(/not found/i) }, + ]); + }); + + it("skips blank customer ids without erroring", async () => { + const { assignCustomersToSite } = await import("@/lib/server/customers-service"); + const result = await assignCustomersToSite(["cust-1", ""], "Site", -26.4, 27.4); + expect(result.updated).toEqual(["cust-1"]); + }); + + it("rejects an empty site name", async () => { + const { assignCustomersToSite } = await import("@/lib/server/customers-service"); + await expect(assignCustomersToSite(["cust-1"], " ", -26.4, 27.4)).rejects.toThrow(/site/i); + }); + + it("rejects an empty customer list", async () => { + const { assignCustomersToSite } = await import("@/lib/server/customers-service"); + await expect(assignCustomersToSite([], "Site", -26.4, 27.4)).rejects.toThrow(/customer/i); + }); + + it("rejects an out-of-range coordinate before writing anything", async () => { + const { assignCustomersToSite } = await import("@/lib/server/customers-service"); + await expect(assignCustomersToSite(["cust-1"], "Site", 999, 27.4)).rejects.toThrow(/lat/i); + expect(store.customers!.get("cust-1")!.site).toBeUndefined(); + }); +}); diff --git a/src/lib/server/customers-service.ts b/src/lib/server/customers-service.ts index 3e130fb..0109539 100644 --- a/src/lib/server/customers-service.ts +++ b/src/lib/server/customers-service.ts @@ -1,9 +1,18 @@ import { getAdminPocketBase } from "./pocketbase-admin"; +import { jitterCoordinates } from "@/lib/geo"; import type { Customer, User } from "../types"; const CUSTOMERS_COLLECTION = "customers"; const USERS_COLLECTION = "users"; +/** + * Scatter radius for `assignCustomersToSite`. Wide enough that pins on + * the same shaft don't overlap on the office map; narrow enough to be + * noise against the 2 km check-in radius. + */ +const SITE_JITTER_MIN_METRES = 30; +const SITE_JITTER_MAX_METRES = 80; + /** * Expand used by the customers list page — joins `company` and `rep` * (both relations on the customer row) so the page can render real @@ -230,21 +239,123 @@ async function resolveRepExact( * `pbArg` is optional for testability — production callers omit it * and let the service fetch its own admin client. */ -export async function setCustomerCoordinates( - id: string, - lat: number, - lng: number, - pbArg?: Awaited>, -): Promise { +function assertValidLatLng(lat: number, lng: number): void { if (!Number.isFinite(lat) || lat < -90 || lat > 90) { throw new Error(`lat must be a finite number in [-90, 90]; got ${String(lat)}`); } if (!Number.isFinite(lng) || lng < -180 || lng > 180) { throw new Error(`lng/lon must be a finite number in [-180, 180]; got ${String(lng)}`); } +} + +export async function setCustomerCoordinates( + id: string, + lat: number, + lng: number, + pbArg?: Awaited>, +): Promise { + assertValidLatLng(lat, lng); const pb = pbArg || (await getAdminPocketBase()); return (await pb.collection(CUSTOMERS_COLLECTION).update(id, { coordinates: { lat, lon: lng }, })) as Customer; } + +/** + * Distinct, non-blank `site` values already in use, each with how many + * customers currently carry it — sorted by that count, descending, so + * the batch pin tool's autocomplete surfaces the shafts with the most + * customers first (the ones worth re-pinning or adding to) rather than + * a one-off typo sitting alphabetically at the top. + * + * Purely a curb on drift, not an enforced vocabulary: `site` is a free + * text field (see the comment on `Customer.site`), so this list is + * only as clean as what's been typed so far. + */ +export async function listDistinctSites( + pbArg?: Awaited>, +): Promise<{ site: string; customerCount: number }[]> { + const pb = pbArg || (await getAdminPocketBase()); + + const rows = await pb.collection(CUSTOMERS_COLLECTION).getFullList<{ site?: string }>({ + filter: `site != ""`, + fields: "site", + }); + + const counts = new Map(); + for (const row of rows) { + const site = (row.site ?? "").trim(); + if (!site) continue; + counts.set(site, (counts.get(site) ?? 0) + 1); + } + + return [...counts.entries()] + .map(([site, customerCount]) => ({ site, customerCount })) + .sort((a, b) => b.customerCount - a.customerCount || a.site.localeCompare(b.site)); +} + +export interface AssignCustomersToSiteResult { + updated: string[]; + errors: { customerId: string; message: string }[]; +} + +/** + * Tags every listed customer with `site` and gives each one a + * coordinate near `[lat, lng]` — jittered per customer so dozens of + * pins on the same shaft render as visibly distinct dots rather than + * one marker on the office map (see `jitterCoordinates` in `geo.ts`). + * + * This is the batch counterpart to `setCustomerCoordinates`: the + * single-pin tool exists for the odd customer with their own delivery + * point, but most of the customer base sits in clusters — many + * contacts at Harmony Kusasalethu, at South Deep Shaft 3, and so on — + * and pinning those one click at a time doesn't scale. + * + * Each customer is written independently and a failure on one doesn't + * abort the rest, mirroring `recordVisits` in `visits-service.ts`: a + * batch of forty shouldn't be undone by one stale id. + */ +export async function assignCustomersToSite( + customerIds: string[], + site: string, + lat: number, + lng: number, + pbArg?: Awaited>, +): Promise { + assertValidLatLng(lat, lng); + + const trimmedSite = site.trim(); + if (!trimmedSite) { + throw new Error("assignCustomersToSite: site is required"); + } + if (customerIds.length === 0) { + throw new Error("assignCustomersToSite: at least one customer is required"); + } + + const pb = pbArg || (await getAdminPocketBase()); + const result: AssignCustomersToSiteResult = { updated: [], errors: [] }; + + for (const id of customerIds) { + if (!id) continue; + try { + const [jitteredLat, jitteredLng] = jitterCoordinates( + [lat, lng], + SITE_JITTER_MIN_METRES, + SITE_JITTER_MAX_METRES, + ); + await pb.collection(CUSTOMERS_COLLECTION).update(id, { + site: trimmedSite, + coordinates: { lat: jitteredLat, lon: jitteredLng }, + }); + result.updated.push(id); + } catch (error) { + result.errors.push({ + customerId: id, + message: error instanceof Error ? error.message : "Failed to update customer", + }); + } + } + + return result; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 559ed36..2126201 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -37,6 +37,17 @@ export interface Customer extends BaseRecord { * the UI — it's PB's auto-applied fallback for an unset geoPoint. */ coordinates?: { lon: number; lat: number }; + /** + * Free-text mine/shaft tag (e.g. "Harmony - Kusasalethu", + * "South Deep Shaft 3"). Lets the office batch-pin every customer on + * one shaft in a single operation instead of placing each one + * individually — see `/visits/pin-tool/batch`. Deliberately a plain + * string rather than a relation to a sites collection: the office + * types the name freehand, so the batch tool offers autocomplete + * against distinct values already in use (`listDistinctSites`) to + * curb drift, but nothing enforces one canonical spelling. + */ + site?: string; description: string; expand?: { company?: Company;