-
- 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;