feat(visits): batch pin tool — tag every customer on a shaft at once
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 <noreply@anthropic.com>
This commit is contained in:
parent
85913f5869
commit
7ba0b9e71f
15 changed files with 1062 additions and 94 deletions
|
|
@ -30,25 +30,46 @@ export default function VisitsLandingPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-primary/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Pin Tool</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link
|
||||
href="/visits/pin-tool"
|
||||
className={buttonVariants({ variant: "default" })}
|
||||
>
|
||||
Open pin tool
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="border-primary/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Pin Tool</CardTitle>
|
||||
<CardDescription>
|
||||
One customer at a time. Pick a customer, click the map, save —
|
||||
for the odd contact with their own delivery point.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link
|
||||
href="/visits/pin-tool"
|
||||
className={buttonVariants({ variant: "default" })}
|
||||
>
|
||||
Open pin tool
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-primary/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Batch pin a site</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link
|
||||
href="/visits/pin-tool/batch"
|
||||
className={buttonVariants({ variant: "default" })}
|
||||
>
|
||||
Open batch tool
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
|
|
|
|||
|
|
@ -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<string, Record<string, unknown>>;
|
||||
const store: Record<string, Store> = {};
|
||||
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<string, unknown>) {
|
||||
|
|
@ -54,6 +58,8 @@ function seedCustomer(id: string, fields: Record<string, unknown>) {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<AssignSiteResult> {
|
||||
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<ListSitesResult> {
|
||||
try {
|
||||
const sites = await listDistinctSites();
|
||||
return { ok: true, sites };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "Failed to load sites",
|
||||
};
|
||||
}
|
||||
}
|
||||
79
src/app/(app)/visits/pin-tool/batch-pin-helpers.test.ts
Normal file
79
src/app/(app)/visits/pin-tool/batch-pin-helpers.test.ts
Normal file
|
|
@ -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<Customer> & { 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([]);
|
||||
});
|
||||
});
|
||||
51
src/app/(app)/visits/pin-tool/batch-pin-helpers.ts
Normal file
51
src/app/(app)/visits/pin-tool/batch-pin-helpers.ts
Normal file
|
|
@ -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));
|
||||
}
|
||||
292
src/app/(app)/visits/pin-tool/batch-pin-view.tsx
Normal file
292
src/app/(app)/visits/pin-tool/batch-pin-view.tsx
Normal file
|
|
@ -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 = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
|
||||
const DARK_TILE_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>';
|
||||
|
||||
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<Set<string>>(new Set());
|
||||
const [droppedPin, setDroppedPin] = useState<DroppedPin | null>(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 (
|
||||
<div className="space-y-5">
|
||||
<div className="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_400px]">
|
||||
<Card className="overflow-hidden border-primary/10 py-0">
|
||||
<div className="h-[68vh] min-h-[520px] relative">
|
||||
<div className="absolute right-3 top-3 z-[30] rounded-full border bg-background/95 px-3 py-2 text-xs font-medium shadow-sm backdrop-blur">
|
||||
Click the map to set this site's location
|
||||
</div>
|
||||
<MapContainer center={DEFAULT_CENTER} zoom={DEFAULT_ZOOM} scrollWheelZoom className="h-full w-full">
|
||||
<TileLayer
|
||||
key={isDark ? "dark" : "light"}
|
||||
attribution={isDark ? DARK_TILE_ATTRIBUTION : LIGHT_TILE_ATTRIBUTION}
|
||||
url={isDark ? DARK_TILE_URL : LIGHT_TILE_URL}
|
||||
/>
|
||||
<MapClickHandler onPick={(lat, lng) => setDroppedPin({ lat, lng })} />
|
||||
{droppedPin && (
|
||||
<Marker position={[droppedPin.lat, droppedPin.lng]} icon={pinMarker("#ED8C31", "")}>
|
||||
<Tooltip direction="top" offset={[0, -12]} permanent className="supplier-map-label">
|
||||
{site.trim() || "New site"} ({selected.size})
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="border-primary/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<MapPin className="h-4 w-4 text-primary" />
|
||||
Site name
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Input
|
||||
value={site}
|
||||
onChange={(event) => setSite(event.target.value)}
|
||||
placeholder='e.g. "South Deep Shaft 3"'
|
||||
/>
|
||||
{matchingSiteSuggestions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{matchingSiteSuggestions.map((s) => (
|
||||
<button
|
||||
key={s.site}
|
||||
type="button"
|
||||
onClick={() => setSite(s.site)}
|
||||
className="rounded-full border bg-muted/40 px-2.5 py-1 text-xs hover:bg-muted"
|
||||
>
|
||||
{s.site} <span className="text-muted-foreground">· {s.customerCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{site.trim() && (
|
||||
<Button variant="link" className="h-auto p-0 text-xs" onClick={selectExistingSiteMembers}>
|
||||
Select everyone already tagged "{site.trim()}"
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-primary/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Customers on this site{" "}
|
||||
{selected.size > 0 && <Badge variant="secondary">{selected.size} selected</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={loading ? "Loading customers…" : "Search by name…"}
|
||||
disabled={loading}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-64 space-y-0.5 overflow-y-auto rounded-lg border">
|
||||
{filtered.length === 0 && !loading && (
|
||||
<p className="px-3 py-4 text-center text-sm text-muted-foreground">No customers match.</p>
|
||||
)}
|
||||
{filtered.map((c) => (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex cursor-pointer items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox checked={selected.has(c.id)} onCheckedChange={() => toggle(c.id)} />
|
||||
<span className="min-w-0 flex-1 truncate">{c.customer}</span>
|
||||
{c.site && (
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{c.site}
|
||||
</Badge>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">{readiness.hint}</p>
|
||||
{droppedPin && (
|
||||
<p className="font-mono text-xs text-muted-foreground">{formatPin(droppedPin.lat, droppedPin.lng)}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleSave} disabled={!readiness.canSave || pending}>
|
||||
{pending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Pin {selected.size || ""} customer{selected.size === 1 ? "" : "s"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleClear} disabled={pending}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/app/(app)/visits/pin-tool/batch/page.tsx
Normal file
54
src/app/(app)/visits/pin-tool/batch/page.tsx
Normal file
|
|
@ -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: () => (
|
||||
<div className="flex min-h-[520px] items-center justify-center rounded-xl border bg-card">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
|
||||
<p className="text-sm text-muted-foreground">Loading batch pin tool…</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
export default function BatchPinToolPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Link
|
||||
href="/visits/pin-tool"
|
||||
className="mb-2 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Single-pin tool
|
||||
</Link>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/8 px-3 py-1 text-xs font-medium text-primary">
|
||||
<MapPinned className="h-3.5 w-3.5" />
|
||||
Visits
|
||||
</div>
|
||||
<h1 className="mt-3 text-2xl font-bold tracking-tight">Batch pin a site</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BatchPinView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/8 px-3 py-1 text-xs font-medium text-primary">
|
||||
<MapPinned className="h-3.5 w-3.5" />
|
||||
Visits
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/8 px-3 py-1 text-xs font-medium text-primary">
|
||||
<MapPinned className="h-3.5 w-3.5" />
|
||||
Visits
|
||||
</div>
|
||||
<h1 className="mt-3 text-2xl font-bold tracking-tight">Pin Tool</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
Seed the West Rand mine shafts with real coordinates. Pick a customer,
|
||||
click the map, save.
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="mt-3 text-2xl font-bold tracking-tight">Pin Tool</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
Seed the West Rand mine shafts with real coordinates. Pick a customer,
|
||||
click the map, save.
|
||||
</p>
|
||||
<Link
|
||||
href="/visits/pin-tool/batch"
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
Batch pin a whole site
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<PinToolView />
|
||||
|
|
|
|||
|
|
@ -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<Customer[]>(initial ?? []);
|
||||
const [loading, setLoading] = useState<boolean>(!initial);
|
||||
const [error, setError] = useState<string | null>(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";
|
||||
|
|
|
|||
53
src/app/(app)/visits/pin-tool/use-customer-list.ts
Normal file
53
src/app/(app)/visits/pin-tool/use-customer-list.ts
Normal file
|
|
@ -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<Customer[]>(initial ?? []);
|
||||
const [loading, setLoading] = useState<boolean>(!initial);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof getAdminPocketBase>>,
|
||||
): Promise<Customer> {
|
||||
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<ReturnType<typeof getAdminPocketBase>>,
|
||||
): Promise<Customer> {
|
||||
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<ReturnType<typeof getAdminPocketBase>>,
|
||||
): 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<string, number>();
|
||||
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<ReturnType<typeof getAdminPocketBase>>,
|
||||
): Promise<AssignCustomersToSiteResult> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue