feat(customers): add status toggle and site/location field

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.
This commit is contained in:
Dave 2026-09-16 09:47:09 +02:00
parent 698abe9fbc
commit bbfa888cc1
8 changed files with 422 additions and 13 deletions

View file

@ -29,6 +29,19 @@ function hasRealCoordinates(c: Customer["coordinates"]): c is { lon: number; lat
return !!c && (c.lon !== 0 || c.lat !== 0);
}
/**
* `customers.status` (added 2026-09) is a PB bool — but PB returns
* `undefined` for legacy rows written before the field existed.
* Treat those as active: we deliberately don't backfill on the
* server (the migration leaves existing rows untouched), so the
* first generation of read sites has to coerce. Returning `true`
* for `undefined` matches the form's `?? true` default — same
* rationale in both places.
*/
function isActiveCustomer(status: boolean | undefined): boolean {
return status ?? true;
}
/**
* Strip the visual formatting from a phone number for use in a `tel:`
* href. PB sometimes deserialises a numeric `tel` column as `number`
@ -125,7 +138,23 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold tracking-tight">{customer.customer}</h1>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight">{customer.customer}</h1>
{/*
Use the helper for default-on semantics — see
`isActiveCustomer` JSDoc. The form treats undefined as
active to match.
*/}
{isActiveCustomer(customer.status) ? (
<Badge className="bg-success text-success-foreground hover:bg-success/90">
Active
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
Inactive
</Badge>
)}
</div>
<p className="text-muted-foreground flex items-center gap-1.5">
{customer.expand?.company ? (
<>
@ -135,6 +164,13 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
) : (
"Customer Details"
)}
{customer.site_location ? (
<>
<span className="mx-1.5 text-muted-foreground/50">·</span>
<MapPin className="h-3.5 w-3.5" />
{customer.site_location}
</>
) : null}
</p>
</div>
<div className="flex gap-2">
@ -207,6 +243,17 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
{customer.expand.company.company}
</p>
)}
{/* `site_location` (added 2026-09) — the shaft/site label
keyed off the customer's company. Distinct from the
older `site` free-text mine tag owned by the pin
tool. Render only when set so an empty cell doesn't
add visual noise. */}
{customer.site_location && (
<p className="text-sm flex items-center gap-2">
<MapPin className="h-4 w-4 text-primary shrink-0" aria-hidden />
{customer.site_location}
</p>
)}
{customer.expand?.rep && (
<p className="text-sm flex items-center gap-2">
<UserRound className="h-4 w-4 text-primary shrink-0" aria-hidden />

View file

@ -7,7 +7,8 @@ import { CustomerForm } from "@/components/customer-form";
import { DeleteDialog } from "@/components/delete-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Plus, Download, Pencil, Trash2, Mail, Phone, Building2, UserRound } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Plus, Download, Pencil, Trash2, Mail, Phone, Building2, UserRound, MapPin } from "lucide-react";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { formatDate, exportToCSV } from "@/lib/pb-helpers";
@ -101,7 +102,17 @@ export default function CustomersPage() {
className: "max-w-[200px] whitespace-normal",
render: (item: Customer) => (
<div className="min-w-0">
<p className="font-medium truncate" title={item.customer}>{item.customer}</p>
<div className="flex items-center gap-1.5">
<p className="font-medium truncate min-w-0" title={item.customer}>{item.customer}</p>
{/* Inactive pill (added 2026-09). Active rows just show
the name — the default needs no ornament. Same
`?? true` semantics as the detail page. */}
{item.status === false ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-muted-foreground">
Inactive
</Badge>
) : null}
</div>
{item.expand?.company && (
<p className="mt-0.5 text-xs text-muted-foreground flex items-start gap-1">
<Building2 className="h-3 w-3 mt-0.5 shrink-0" />
@ -110,6 +121,15 @@ export default function CustomersPage() {
</span>
</p>
)}
{item.site_location && (
<p
className="mt-0.5 text-xs text-muted-foreground flex items-center gap-1"
title={item.site_location}
>
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{item.site_location}</span>
</p>
)}
</div>
),
},
@ -237,13 +257,26 @@ export default function CustomersPage() {
onClick={() => router.push(`/customers/${item.id}`)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div>
<p className="font-medium">{item.customer}</p>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<p className="font-medium truncate min-w-0">{item.customer}</p>
{item.status === false ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-muted-foreground">
Inactive
</Badge>
) : null}
</div>
{item.expand?.company && (
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Building2 className="h-3 w-3" />
{item.expand.company.company}
<Building2 className="h-3 w-3 shrink-0" />
<span className="truncate">{item.expand.company.company}</span>
</p>
)}
{item.site_location && (
<p className="text-sm text-muted-foreground flex items-center gap-1">
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{item.site_location}</span>
</p>
)}
</div>

View file

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { assertBrowserSession } from "@/lib/server/browser-session";
import { listDistinctSiteLocations } from "@/lib/server/customers-service";
/**
* GET /api/v1/customers/sites
*
* Returns the distinct, non-blank `site_location` values currently in
* use across the customers collection, each with how many customers
* carry it. Powers the customer form's `<datalist>` autocomplete for
* the `site_location` field (added 2026-09) — the operator types the
* site name freehand, the datalist nudges them toward values other
* customers already use, and storage ends up consistent enough that
* downstream filtering works.
*
* Auth: browser session. The data here is derived from the customers
* collection so any logged-in operator can see it.
*
* Caching hint: small list, regenerates whenever a new site_location
* value is typed. We force-dynamic so the form always sees the
* freshest distinct values when the operator opens it.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
await assertBrowserSession(req);
const sites = await listDistinctSiteLocations();
return NextResponse.json({ items: sites });
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load site locations";
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -40,6 +40,22 @@ vi.mock("@/components/ui/user-search", () => ({
UserSearch: () => <div>User Search</div>,
}));
// `CustomerForm` now fetches `/api/v1/customers/sites` (added 2026-09
// for the `site_location` `<datalist>` autocomplete). jsdom has no
// global `fetch` — stub one so the effect's no-result promise
// resolves cleanly without each test having to wire fetch up.
const stubFetch = () =>
Promise.resolve(new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
})) as unknown as Response;
beforeAll(() => {
if (typeof globalThis.fetch !== "function") {
globalThis.fetch = stubFetch as unknown as typeof fetch;
}
});
afterEach(() => {
vi.clearAllMocks();
getAllCompaniesMock.mockResolvedValue([]);

View file

@ -4,13 +4,26 @@ import { useState, useEffect, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { DuplicateWarning, type DuplicateMatch } from "@/components/ui/duplicate-warning";
import { toast } from "sonner";
import { Loader2, User, Building2, Mail, Phone, AtSign, MapPin, FileText, ExternalLink } from "lucide-react";
import {
Loader2,
User,
Building2,
Mail,
Phone,
AtSign,
MapPin,
FileText,
ExternalLink,
CheckCircle2,
XCircle,
} from "lucide-react";
import getPocketBase from "@/lib/pocketbase";
import { getAllCompanies, findCustomerDuplicates } from "@/lib/pb-helpers";
import { CompanySearch } from "@/components/ui/company-search";
@ -41,7 +54,27 @@ function buildCustomerFormState(customer?: Customer | null) {
buyer_email: customer?.buyer_email || "",
number: customer?.number || "",
company: customer?.company || "",
// Pass the resolved rep name/email through as a fallback label so the
// user-search trigger shows the rep on edit even before its popover
// has been opened and the user roster has been fetched. See
// `selectedLabel={...}` on `<UserSearch>` below.
rep: customer?.rep || "",
/**
* Active/inactive customer flag (added 2026-09). We default new
* customers to `true` (active) but collapse legacy rows — which
* pre-date the PB field entirely and come back as `undefined` —
* to `true` as well. The PB admin migration deliberately leaves
* existing rows untouched, so the office doesn't wake up to a
* customer base that's been silently flipped to "inactive".
*/
status: customer?.status ?? true,
/**
* Site/location name (added 2026-09). Distinct from the older
* `site` free-text mine tag — see `Customer.site_location`
* JSDoc in `types.ts`. Always initialised to "" so the
* autocomplete `<datalist>` has a string to compare against.
*/
site_location: customer?.site_location || "",
lat: customer && hasRealCoordinates(customer.coordinates) ? String(customer.coordinates.lat) : "",
lon: customer && hasRealCoordinates(customer.coordinates) ? String(customer.coordinates.lon) : "",
description: customer?.description || "",
@ -70,6 +103,20 @@ export function CustomerForm({ open, onOpenChange, customer, onSaved }: Customer
*/
const [duplicates, setDuplicates] = useState<Customer[]>([]);
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
/**
* Suggestions for the `site_location` autocomplete. Fetched once
* when the sheet opens via `/api/v1/customers/sites`; the field is
* plain-text on the PB side (see `Customer.site_location`), so the
* `<datalist>` only nudges the operator toward values already used
* by other customers — it doesn't restrict input.
*
* Stays an empty array on network failure so the input still works
* as a free-text field. Distinct from the older `site` (mine/shaft
* tag) autocomplete used by the batch pin tool.
*/
const [siteLocationSuggestions, setSiteLocationSuggestions] = useState<
{ site_location: string; customerCount: number }[]
>([]);
// Lazy-load companies for the company combobox the first time the
// sheet opens. Cheap (small table) and the user opens the form
@ -90,6 +137,29 @@ export function CustomerForm({ open, onOpenChange, customer, onSaved }: Customer
};
}, [open, companies.length]);
// Lazy-load distinct `site_location` values for the datalist the
// first time the sheet opens. Once loaded, the datalist shows
// every distinct value with how many customers carry it.
// Re-fetching on every open would be wasteful; the endpoint is
// `dynamic = "force-dynamic"` on the server side, but the data
// here changes at human-typing pace, so once-per-session is fine.
useEffect(() => {
if (!open || siteLocationSuggestions.length > 0) return;
let cancelled = false;
fetch("/api/v1/customers/sites", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((data: { items?: { site_location: string; customerCount: number }[] }) => {
if (!cancelled) setSiteLocationSuggestions(data.items ?? []);
})
.catch(() => {
// best-effort — datalist just stays empty, the field still
// accepts free-text
});
return () => {
cancelled = true;
};
}, [open, siteLocationSuggestions.length]);
// Reset the form state when the form is bound to a different
// customer. Skip the very first run — `useState(() => buildCustomerFormState(customer))`
// already initialised the form, and a 0ms timer would clobber
@ -238,6 +308,19 @@ export function CustomerForm({ open, onOpenChange, customer, onSaved }: Customer
// strings; passing "" unsets the relation. `dateadded` /
// `lastvisiteddate` mirror the legacy fields (still useful for
// sorting / display on the list page).
//
// `status` (added 2026-09) flips the customer active/inactive.
// We coerce through `!!` so an accidental falsy value (string,
// undefined) becomes a deterministic bool in PB.
//
// `site_location` (added 2026-09) is a free-text site label —
// see `Customer.site_location` JSDoc. Empty string is fine;
// PB treats the field as plain text.
//
// `customer?.site` (the older free-text mine/shaft tag) is NOT
// touched by this form — it's owned by the batch pin tool at
// `/visits/pin-tool`. Two separate fields, two separate edit
// surfaces.
const data = {
customer: form.customer,
email: form.email,
@ -246,6 +329,8 @@ export function CustomerForm({ open, onOpenChange, customer, onSaved }: Customer
number: form.number,
company: form.company,
rep: form.rep,
status: !!form.status,
site_location: form.site_location,
coordinates: coordinatesPayload,
description: form.description,
dateadded: customer?.dateadded || new Date().toISOString(),
@ -305,12 +390,58 @@ export function CustomerForm({ open, onOpenChange, customer, onSaved }: Customer
value={form.company}
onValueChange={(id) => setForm({ ...form, company: id })}
companies={companies}
// Same pattern as `selectedLabel` on `<UserSearch>`
// below — the form passes the resolved company name
// through so the trigger shows the linked company
// immediately on edit, before the companies list has
// finished loading. Without this the trigger falls
// through to "Select company..." and looks like the
// existing company relationship is gone — which is
// a UX trap when all the operator wants to do is
// flip the Active toggle and save.
selectedLabel={customer?.expand?.company?.company}
placeholder="Select company..."
/>
<p className="text-[11px] text-muted-foreground mt-1.5">
The company the customer works for. Delivery address
is captured below — different shafts may share the
same company but deliver to different coordinates.
The company the customer works for. Already-linked
companies stay selected when you save — you only
need to touch this field to change or clear it.
</p>
</div>
<div>
<FieldLabel icon={MapPin} label="Site / Location" />
{/*
`site_location` (added 2026-09) is the shaft/site label
specific to this customer. PB storage is plain text, so
we wrap a regular <input> with a <datalist> that
pre-fills suggestions from other customers already
typed — see `listDistinctSiteLocations`. The operator
can still type anything; the datalist just nudges them
toward consistency.
Stable ID on the <input> lets the <datalist> bind to
it via `list=`. cmdk-style filtering not needed —
this is a free-text field, not a constrained picker.
*/}
<Input
list="customer-site-location-options"
value={form.site_location}
onChange={(e) => setForm({ ...form, site_location: e.target.value })}
placeholder="e.g. Kusasalethu shaft"
className="h-9"
aria-label="Site or location"
/>
<datalist id="customer-site-location-options">
{siteLocationSuggestions.map((s) => (
<option key={s.site_location} value={s.site_location}>
{s.customerCount > 0 ? `${s.site_location} (${s.customerCount})` : s.site_location}
</option>
))}
</datalist>
<p className="text-[11px] text-muted-foreground mt-1.5">
The shaft or site within the company (each company
usually has more than one). Leave blank if the
customer is office-only or you don&apos;t know yet.
</p>
</div>
</div>
@ -400,9 +531,53 @@ export function CustomerForm({ open, onOpenChange, customer, onSaved }: Customer
<UserSearch
value={form.rep}
onValueChange={(id) => setForm({ ...form, rep: id })}
// Prop the rep's resolved name/email into the trigger so
// it renders immediately on edit, before the user-search
// popover has been opened and the user roster fetched
// (see UserSearch JSDoc — `selectedLabel` fills the gap).
selectedLabel={
customer?.expand?.rep?.name || customer?.expand?.rep?.email
}
placeholder="Select sales rep..."
/>
</div>
{/*
Active-customer toggle (added 2026-09). PB `status` is a
bool: true = active, false = inactive (e.g. the mine
stopped trading, customer moved to a different mine).
Existing customers the operator creates without touching
this row will land as `true` — see `?? true` in
`buildCustomerFormState`. The icon flips with state so
the meaning is clear even without the label colour.
*/}
<div className="flex items-start justify-between gap-4 rounded-md border border-input/40 px-3 py-2.5">
<div className="min-w-0">
<Label
htmlFor="customer-status"
className="flex items-center gap-1.5 text-sm font-medium"
>
{form.status ? (
<CheckCircle2 className="h-3.5 w-3.5 text-success" aria-hidden />
) : (
<XCircle className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
)}
{form.status ? "Active customer" : "Inactive customer"}
</Label>
<p className="mt-1 text-[11px] text-muted-foreground">
Turn off when this customer no longer works for
the mine. Historical quotes and orders are kept;
the customer stops appearing on active pickers.
</p>
</div>
<Switch
id="customer-status"
checked={!!form.status}
onCheckedChange={(checked) =>
setForm({ ...form, status: checked === true })
}
aria-label="Active customer"
/>
</div>
<div>
<FieldLabel icon={FileText} label="Description" />
<Textarea

View file

@ -42,6 +42,20 @@ export interface CompanySearchProps {
placeholder?: string;
/** Show the currently-selected company in the trigger. Default true. */
showSelected?: boolean;
/**
* Fallback label rendered in the trigger when `value` is set but the
* matching company isn't in `companies` yet (e.g. the list is still
* loading, or the row references an id that's been deleted upstream).
* Without this the trigger falls through to the placeholder, which
* makes a real, already-saved selection look like nothing's picked.
*
* Same fallback pattern as `selectedLabel` on `UserSearch` — see
* the CustomerForm where the two fields are populated from
* `customer.expand.company` / `customer.expand.rep` so the
* stored company / rep names render immediately on edit, before
* the lookup list resolves.
*/
selectedLabel?: string;
/** Trigger styling overrides. */
className?: string;
/** Disable interaction. */
@ -54,6 +68,7 @@ export function CompanySearch({
companies,
placeholder = "Select company...",
showSelected = true,
selectedLabel,
className,
disabled = false,
}: CompanySearchProps) {
@ -73,6 +88,15 @@ export function CompanySearch({
[companies]
);
// Render priority for the trigger label:
// 1. Resolved company from the loaded list — exact match
// 2. `selectedLabel` fallback — id is set but list is still loading
// or doesn't include this row; keeps the existing selection visible
// 3. Placeholder — value is empty
const triggerText = showSelected
? selected?.company || (value ? selectedLabel : undefined) || placeholder
: placeholder;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
@ -89,7 +113,7 @@ export function CompanySearch({
!selected && "text-muted-foreground"
)}
>
{showSelected && selected ? selected.company : placeholder}
{triggerText}
</span>
<div className="flex items-center gap-1 shrink-0">
{selected && !disabled && (

View file

@ -295,6 +295,46 @@ export async function listDistinctSites(
.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 }[];

View file

@ -46,8 +46,45 @@ export interface Customer extends BaseRecord {
* 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.
*
* Operator-added 2026-09 alongside `site_location` below. The two
* fields coexist intentionally: `site` is the legacy free-text
* shaft tag (also used by the pin tool to drop pins), while
* `site_location` is the newer site/location name keyed off the
* customer's company. They're not the same concept, so we don't
* auto-backfill one from the other.
*/
site?: string;
/**
* Free-text site/location name (e.g. "Kusasalethu shaft",
* "South Deep - surface workshop"). Operator-added in PB admin on
* 2026-09 because each company operates from multiple shafts/sites
* and the office wants to label the customer with the shaft, not
* just the company. Storage is plain text on purpose — there's no
* `sites` collection and we don't want one for this; the UI
* autocomplete surfaces distinct values already typed by other
* customers (see `listDistinctSiteLocations` in
* `customers-service.ts`).
*/
site_location?: string;
/**
* Whether the customer is currently an active account. Added in
* the PB admin on 2026-09 because a customer can stop working for
* a mine without the office wanting to lose the historical
* orders/quotes attached to their row.
*
* True → active customer (the default for new customers).
* False → inactive: same row, but the office wants the customer
* to disappear from the active-customer pickers and the
* rep dashboards. The historical record is preserved.
*
* Pre-existing rows written before this field landed don't have
* the value at all (`undefined` in PB → falsy in JS), so every
* read site treats `undefined` as `true` via `?? true` on read —
* we don't want the migration date to silently turn the entire
* legacy customer base "inactive".
*/
status?: boolean;
description: string;
expand?: {
company?: Company;