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;