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/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: () => (
+