Two new fields added to the customer add/edit form, both
operator-facing for the active/inactive customer workflow:
- customers.status (bool): flips the customer active/inactive.
Legacy rows pre-date the field and load as undefined; both
the form and the read side collapse that to active=true so
the migration date doesn't silently flip the existing base.
- customers.site_location (text): free-text site/shaft label
keyed off the customer's company. Distinct from the existing
'site' free-text mine tag owned by the pin tool. Storage is
plain text on purpose, with a /api/v1/customers/sites
endpoint returning distinct existing values for an input
+ datalist autocomplete under the company combobox.
surfaces:
- Active/Inactive badge on customer detail and list rows
- site_location row in the customer Details card and under
the company line on the list (desktop + mobile card)
- Switch under the sales-rep section of the form to flip
status; existing customers stay active by default
also fixes a UX trap on edit: previously opening the edit
form showed 'Select company...' / 'Select rep...' as placeholders
for an instant until the lookup lists resolved, making a real,
saved company/rep look unlinked. CompanySearch and UserSearch
now accept a selectedLabel fallback; CustomerForm passes the
resolved customer.expand.company / customer.expand.rep names
through so the triggers display the linked names immediately.
listDistinctSiteLocations helper added to customers-service.ts
alongside the existing listDistinctSites.
401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
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
|
|
* names instead of opaque PB ids. PB's expand syntax is comma-separated
|
|
* field names; nested relations use dotted paths.
|
|
*/
|
|
export const CUSTOMER_LIST_EXPAND = "company,rep";
|
|
|
|
/**
|
|
* Safety cap on the user-lookup side of rep search. The WFDASH user
|
|
* roster is bounded (a few dozen sales reps + office users), so any
|
|
* one search that matches > 100 users is almost certainly a mistake
|
|
* (operator typed a single character, etc.). We trim to 100 and trust
|
|
* that no operator is hunting for customers owned by 100+ reps at once.
|
|
*
|
|
* The cap also keeps the `rep = "id1" || rep = "id2" || ...` OR-chain
|
|
* well under PB's filter-length cap (200 ids lands at ~4.6 KB; 100
|
|
* ids is ~2.3 KB — comfortable).
|
|
*/
|
|
const REP_SEARCH_USER_CAP = 100;
|
|
|
|
export interface ListCustomersParams {
|
|
page?: number;
|
|
perPage?: number;
|
|
search?: string;
|
|
sort?: string;
|
|
/** Optional exact-filter on the rep relation. Accepts a 15-char PB
|
|
* user id (verbatim) OR a user name / email (case-insensitive
|
|
* lookup). Unresolvable input is silently dropped so a bad
|
|
* client param doesn't 500 the whole list call. */
|
|
rep?: string;
|
|
}
|
|
|
|
export interface ListCustomersResult {
|
|
items: Customer[];
|
|
totalItems: number;
|
|
totalPages: number;
|
|
page: number;
|
|
perPage: number;
|
|
}
|
|
|
|
/**
|
|
* Read model for the `/customers` page.
|
|
*
|
|
* Mirrors `getOrdersDashboardData` (orders-service.ts) and
|
|
* `getQuotesDashboardData` (quotes-service.ts): the service owns the
|
|
* PocketBase filter assembly, the page owns the layout. Replaces the
|
|
* legacy `pb-helpers.getCustomers` that lived in the client-only layer
|
|
* — see AGENTS.md "data-layer discipline".
|
|
*
|
|
* Search semantics: a single free-text box that hits
|
|
*
|
|
* - `customer` (contact name)
|
|
* - `email` (primary email)
|
|
* - `number` (phone)
|
|
* - rep name OR rep email → every customer whose `rep` relation
|
|
* points at a matching user
|
|
*
|
|
* The rep branch exists because the previous implementation
|
|
* (`pb-helpers.getCustomers`) silently skipped the `rep` field, even
|
|
* though the column is in the schema and the page renders a rep pill
|
|
* on every row. Operators who wanted "all of David's customers" had
|
|
* to scroll every page and eyeball it — that workflow was the
|
|
* motivation for this module.
|
|
*
|
|
* PB filter syntax doesn't let us filter on `expand.rep.name ~ "..."`
|
|
* directly, so the implementation resolves the rep branch in two
|
|
* steps:
|
|
*
|
|
* 1. Query the `users` collection (cheap — bounded by
|
|
* REP_SEARCH_USER_CAP) for matching names / emails.
|
|
* 2. Build `rep = "id1" || rep = "id2" || ...` and OR it into the
|
|
* main customer filter.
|
|
*
|
|
* If the user-lookup returns zero matches, the rep branch is omitted
|
|
* entirely — the customer/email/phone branches still match on their
|
|
* own. That keeps a search for "Alice" hitting customers named
|
|
* "Alice" even if no rep matches.
|
|
*
|
|
* Expand payload carries both `company` and `rep` so the row renders
|
|
* without a second round-trip — same `expand` parameter that the
|
|
* legacy helper used. The expand here is for DISPLAY only (PB's
|
|
* expand never affects filtering), so the search path has to walk
|
|
* users explicitly.
|
|
*/
|
|
export async function listCustomersForDashboard(
|
|
params: ListCustomersParams = {},
|
|
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
): Promise<ListCustomersResult> {
|
|
const pb = pbArg || (await getAdminPocketBase());
|
|
pb.autoCancellation(false);
|
|
|
|
const page = Math.max(1, params.page || 1);
|
|
const perPage = Math.min(100, Math.max(1, params.perPage || 20));
|
|
const search = (params.search || "").trim();
|
|
const sort = params.sort || "-created";
|
|
|
|
let filter = "";
|
|
if (search) {
|
|
const escaped = search.replace(/"/g, '\\"');
|
|
const customerFilter = `(customer ~ "${escaped}" || email ~ "${escaped}" || number ~ "${escaped}")`;
|
|
const repFilter = await buildRepSearchClause(pb, search);
|
|
filter = repFilter ? `(${customerFilter} || ${repFilter})` : customerFilter;
|
|
}
|
|
|
|
// Exact rep filter — separate from the search-built rep OR-chain.
|
|
// Accepts either a 15-char PB id (verbatim) or a name/email of a
|
|
// user. Unresolvable input is silently dropped so a bad param
|
|
// doesn't 500 the whole list call.
|
|
if (params.rep) {
|
|
const repId = await resolveRepExact(pb, params.rep);
|
|
if (repId) {
|
|
const repClause = `rep = "${repId}"`;
|
|
filter = filter ? `(${filter}) && ${repClause}` : repClause;
|
|
}
|
|
}
|
|
|
|
const result = await pb
|
|
.collection(CUSTOMERS_COLLECTION)
|
|
.getList<Customer>(page, perPage, {
|
|
filter,
|
|
sort,
|
|
expand: CUSTOMER_LIST_EXPAND,
|
|
});
|
|
|
|
return {
|
|
items: result.items as Customer[],
|
|
totalItems: result.totalItems,
|
|
totalPages: result.totalPages,
|
|
page: result.page,
|
|
perPage: result.perPage,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve a rep-name search into a PB filter clause of the form
|
|
* `rep = "id1" || rep = "id2" || ...`. Returns an empty string when
|
|
* the user lookup finds no matches — caller omits the clause from
|
|
* the OR-chain in that case.
|
|
*
|
|
* Capped at `REP_SEARCH_USER_CAP` ids; the user roster is small enough
|
|
* that this never bites in practice, but the cap keeps the OR-chain
|
|
* well under PB's filter-length limit and gives us a graceful
|
|
* degradation when someone searches for a single character.
|
|
*/
|
|
async function buildRepSearchClause(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
search: string,
|
|
): Promise<string> {
|
|
const escaped = search.replace(/"/g, '\\"');
|
|
const usersFilter = `name ~ "${escaped}" || email ~ "${escaped}"`;
|
|
const matchingUsers = await pb
|
|
.collection(USERS_COLLECTION)
|
|
.getFullList<User>({
|
|
filter: usersFilter,
|
|
fields: "id",
|
|
// PB defaults to 500; we cap lower so a typo doesn't drag the
|
|
// whole user table over the wire just to build the filter.
|
|
batch: Math.max(REP_SEARCH_USER_CAP, 1),
|
|
});
|
|
if (matchingUsers.length === 0) return "";
|
|
|
|
const ids = matchingUsers
|
|
.map((u) => u.id)
|
|
.filter((id) => typeof id === "string" && id.length > 0)
|
|
.slice(0, REP_SEARCH_USER_CAP);
|
|
if (ids.length === 0) return "";
|
|
|
|
return ids.map((id) => `rep = "${id}"`).join(" || ");
|
|
}
|
|
|
|
/** PB ids are 15 alphanumeric chars. Detect that and treat the
|
|
* input as a verbatim id rather than a name lookup. */
|
|
const PB_ID_PATTERN = /^[A-Za-z0-9]{15}$/;
|
|
|
|
/**
|
|
* Resolve a `rep` filter param to a single user id. Accepts either a
|
|
* 15-char PB id (used verbatim) or a free-text name / email (matched
|
|
* case-insensitively against `users.name` / `users.email`). Returns
|
|
* "" when nothing matches — caller omits the filter clause in that
|
|
* case so a bad client doesn't 500 the whole list call.
|
|
*/
|
|
async function resolveRepExact(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
input: string,
|
|
): Promise<string> {
|
|
const trimmed = (input || "").trim();
|
|
if (!trimmed) return "";
|
|
if (PB_ID_PATTERN.test(trimmed)) return trimmed;
|
|
|
|
// Match by name OR email, case-insensitive. PB's `=` is
|
|
// case-sensitive on stored values, so we use `~` (substring) and
|
|
// then de-dup to the first hit. For a 1-1 unique name this is
|
|
// fine; for ambiguous names the first match wins.
|
|
const escaped = trimmed.replace(/"/g, '\\"');
|
|
const matches = await pb.collection(USERS_COLLECTION).getList<User>(1, 1, {
|
|
filter: `(name = "${escaped}" || email = "${escaped}" || name ~ "${escaped}" || email ~ "${escaped}")`,
|
|
fields: "id",
|
|
});
|
|
return matches.items[0]?.id ?? "";
|
|
}
|
|
|
|
/**
|
|
* Write `customers.coordinates` for a single customer.
|
|
*
|
|
* The office pin tool (see `/visits/pin-tool`) is the first consumer —
|
|
* it seeds real coordinates onto the ~20 main shafts so the rep
|
|
* check-in flow has somewhere to land on day one. The rep flow will
|
|
* call it too, via `recordVisits`, to stamp a customer's position on
|
|
* first visit.
|
|
*
|
|
* This matters more than it looks: **0 of 616 customers had real
|
|
* coordinates** when this was written — every `coordinates` geoPoint
|
|
* was PB's `{lon: 0, lat: 0}` default. Radius matching returns nothing
|
|
* until rows start flowing through here.
|
|
*
|
|
* Validates lat ∈ [-90, 90] and lng ∈ [-180, 180] — PB itself will
|
|
* reject out-of-range values, but with a less specific 400 message;
|
|
* this guard gives the route layer a clean "lat must be in [-90, 90]"
|
|
* error to surface to the operator. `Number.isFinite` also rejects
|
|
* non-numbers, so a JS caller passing `"26.2"` (a string straight off
|
|
* a form input) can't silently write a poisoned row.
|
|
*
|
|
* `pbArg` is optional for testability — production callers omit it
|
|
* and let the service fetch its own admin client.
|
|
*/
|
|
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));
|
|
}
|
|
|
|
/**
|
|
* Distinct, non-blank `site_location` values already in use, each with
|
|
* how many customers currently carry it — sorted by that count,
|
|
* descending, so the customer form's autocomplete suggests the
|
|
* locations the office is actually using today rather than a one-off
|
|
* typo sitting alphabetically at the top.
|
|
*
|
|
* Field added to PB on 2026-09 as the operator's per-customer
|
|
* shaft/site tag (distinct from the legacy `site` free-text mine tag
|
|
* used by the pin tool — see the JSDoc on `Customer.site_location`).
|
|
* Storage is plain text on purpose, so this list is purely a curb on
|
|
* drift rather than an enforced vocabulary.
|
|
*/
|
|
export async function listDistinctSiteLocations(
|
|
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
): Promise<{ site_location: string; customerCount: number }[]> {
|
|
const pb = pbArg || (await getAdminPocketBase());
|
|
|
|
const rows = await pb
|
|
.collection(CUSTOMERS_COLLECTION)
|
|
.getFullList<{ site_location?: string }>({
|
|
filter: `site_location != ""`,
|
|
fields: "site_location",
|
|
});
|
|
|
|
const counts = new Map<string, number>();
|
|
for (const row of rows) {
|
|
const loc = (row.site_location ?? "").trim();
|
|
if (!loc) continue;
|
|
counts.set(loc, (counts.get(loc) ?? 0) + 1);
|
|
}
|
|
|
|
return [...counts.entries()]
|
|
.map(([site_location, customerCount]) => ({ site_location, customerCount }))
|
|
.sort(
|
|
(a, b) =>
|
|
b.customerCount - a.customerCount || a.site_location.localeCompare(b.site_location),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|