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:
parent
698abe9fbc
commit
bbfa888cc1
8 changed files with 422 additions and 13 deletions
|
|
@ -29,6 +29,19 @@ function hasRealCoordinates(c: Customer["coordinates"]): c is { lon: number; lat
|
||||||
return !!c && (c.lon !== 0 || c.lat !== 0);
|
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:`
|
* Strip the visual formatting from a phone number for use in a `tel:`
|
||||||
* href. PB sometimes deserialises a numeric `tel` column as `number`
|
* 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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{customer.customer}</h1>
|
<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">
|
<p className="text-muted-foreground flex items-center gap-1.5">
|
||||||
{customer.expand?.company ? (
|
{customer.expand?.company ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -135,6 +164,13 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
||||||
) : (
|
) : (
|
||||||
"Customer Details"
|
"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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -207,6 +243,17 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
||||||
{customer.expand.company.company}
|
{customer.expand.company.company}
|
||||||
</p>
|
</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 && (
|
{customer.expand?.rep && (
|
||||||
<p className="text-sm flex items-center gap-2">
|
<p className="text-sm flex items-center gap-2">
|
||||||
<UserRound className="h-4 w-4 text-primary shrink-0" aria-hidden />
|
<UserRound className="h-4 w-4 text-primary shrink-0" aria-hidden />
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import { CustomerForm } from "@/components/customer-form";
|
||||||
import { DeleteDialog } from "@/components/delete-dialog";
|
import { DeleteDialog } from "@/components/delete-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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 { toast } from "sonner";
|
||||||
import getPocketBase from "@/lib/pocketbase";
|
import getPocketBase from "@/lib/pocketbase";
|
||||||
import { formatDate, exportToCSV } from "@/lib/pb-helpers";
|
import { formatDate, exportToCSV } from "@/lib/pb-helpers";
|
||||||
|
|
@ -101,7 +102,17 @@ export default function CustomersPage() {
|
||||||
className: "max-w-[200px] whitespace-normal",
|
className: "max-w-[200px] whitespace-normal",
|
||||||
render: (item: Customer) => (
|
render: (item: Customer) => (
|
||||||
<div className="min-w-0">
|
<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 && (
|
{item.expand?.company && (
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground flex items-start gap-1">
|
<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" />
|
<Building2 className="h-3 w-3 mt-0.5 shrink-0" />
|
||||||
|
|
@ -110,6 +121,15 @@ export default function CustomersPage() {
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -237,13 +257,26 @@ export default function CustomersPage() {
|
||||||
onClick={() => router.push(`/customers/${item.id}`)}
|
onClick={() => router.push(`/customers/${item.id}`)}
|
||||||
>
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="font-medium">{item.customer}</p>
|
<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 && (
|
{item.expand?.company && (
|
||||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
<Building2 className="h-3 w-3" />
|
<Building2 className="h-3 w-3 shrink-0" />
|
||||||
{item.expand.company.company}
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
37
src/app/api/v1/customers/sites/route.ts
Normal file
37
src/app/api/v1/customers/sites/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,22 @@ vi.mock("@/components/ui/user-search", () => ({
|
||||||
UserSearch: () => <div>User Search</div>,
|
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(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
getAllCompaniesMock.mockResolvedValue([]);
|
getAllCompaniesMock.mockResolvedValue([]);
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,26 @@ import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { DuplicateWarning, type DuplicateMatch } from "@/components/ui/duplicate-warning";
|
import { DuplicateWarning, type DuplicateMatch } from "@/components/ui/duplicate-warning";
|
||||||
import { toast } from "sonner";
|
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 getPocketBase from "@/lib/pocketbase";
|
||||||
import { getAllCompanies, findCustomerDuplicates } from "@/lib/pb-helpers";
|
import { getAllCompanies, findCustomerDuplicates } from "@/lib/pb-helpers";
|
||||||
import { CompanySearch } from "@/components/ui/company-search";
|
import { CompanySearch } from "@/components/ui/company-search";
|
||||||
|
|
@ -41,7 +54,27 @@ function buildCustomerFormState(customer?: Customer | null) {
|
||||||
buyer_email: customer?.buyer_email || "",
|
buyer_email: customer?.buyer_email || "",
|
||||||
number: customer?.number || "",
|
number: customer?.number || "",
|
||||||
company: customer?.company || "",
|
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 || "",
|
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) : "",
|
lat: customer && hasRealCoordinates(customer.coordinates) ? String(customer.coordinates.lat) : "",
|
||||||
lon: customer && hasRealCoordinates(customer.coordinates) ? String(customer.coordinates.lon) : "",
|
lon: customer && hasRealCoordinates(customer.coordinates) ? String(customer.coordinates.lon) : "",
|
||||||
description: customer?.description || "",
|
description: customer?.description || "",
|
||||||
|
|
@ -70,6 +103,20 @@ export function CustomerForm({ open, onOpenChange, customer, onSaved }: Customer
|
||||||
*/
|
*/
|
||||||
const [duplicates, setDuplicates] = useState<Customer[]>([]);
|
const [duplicates, setDuplicates] = useState<Customer[]>([]);
|
||||||
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
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
|
// Lazy-load companies for the company combobox the first time the
|
||||||
// sheet opens. Cheap (small table) and the user opens the form
|
// 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]);
|
}, [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
|
// Reset the form state when the form is bound to a different
|
||||||
// customer. Skip the very first run — `useState(() => buildCustomerFormState(customer))`
|
// customer. Skip the very first run — `useState(() => buildCustomerFormState(customer))`
|
||||||
// already initialised the form, and a 0ms timer would clobber
|
// 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` /
|
// strings; passing "" unsets the relation. `dateadded` /
|
||||||
// `lastvisiteddate` mirror the legacy fields (still useful for
|
// `lastvisiteddate` mirror the legacy fields (still useful for
|
||||||
// sorting / display on the list page).
|
// 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 = {
|
const data = {
|
||||||
customer: form.customer,
|
customer: form.customer,
|
||||||
email: form.email,
|
email: form.email,
|
||||||
|
|
@ -246,6 +329,8 @@ export function CustomerForm({ open, onOpenChange, customer, onSaved }: Customer
|
||||||
number: form.number,
|
number: form.number,
|
||||||
company: form.company,
|
company: form.company,
|
||||||
rep: form.rep,
|
rep: form.rep,
|
||||||
|
status: !!form.status,
|
||||||
|
site_location: form.site_location,
|
||||||
coordinates: coordinatesPayload,
|
coordinates: coordinatesPayload,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
dateadded: customer?.dateadded || new Date().toISOString(),
|
dateadded: customer?.dateadded || new Date().toISOString(),
|
||||||
|
|
@ -305,12 +390,58 @@ export function CustomerForm({ open, onOpenChange, customer, onSaved }: Customer
|
||||||
value={form.company}
|
value={form.company}
|
||||||
onValueChange={(id) => setForm({ ...form, company: id })}
|
onValueChange={(id) => setForm({ ...form, company: id })}
|
||||||
companies={companies}
|
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..."
|
placeholder="Select company..."
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground mt-1.5">
|
<p className="text-[11px] text-muted-foreground mt-1.5">
|
||||||
The company the customer works for. Delivery address
|
The company the customer works for. Already-linked
|
||||||
is captured below — different shafts may share the
|
companies stay selected when you save — you only
|
||||||
same company but deliver to different coordinates.
|
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't know yet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -400,9 +531,53 @@ export function CustomerForm({ open, onOpenChange, customer, onSaved }: Customer
|
||||||
<UserSearch
|
<UserSearch
|
||||||
value={form.rep}
|
value={form.rep}
|
||||||
onValueChange={(id) => setForm({ ...form, rep: id })}
|
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..."
|
placeholder="Select sales rep..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<FieldLabel icon={FileText} label="Description" />
|
<FieldLabel icon={FileText} label="Description" />
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,20 @@ export interface CompanySearchProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** Show the currently-selected company in the trigger. Default true. */
|
/** Show the currently-selected company in the trigger. Default true. */
|
||||||
showSelected?: boolean;
|
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. */
|
/** Trigger styling overrides. */
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Disable interaction. */
|
/** Disable interaction. */
|
||||||
|
|
@ -54,6 +68,7 @@ export function CompanySearch({
|
||||||
companies,
|
companies,
|
||||||
placeholder = "Select company...",
|
placeholder = "Select company...",
|
||||||
showSelected = true,
|
showSelected = true,
|
||||||
|
selectedLabel,
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: CompanySearchProps) {
|
}: CompanySearchProps) {
|
||||||
|
|
@ -73,6 +88,15 @@ export function CompanySearch({
|
||||||
[companies]
|
[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 (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
|
|
@ -89,7 +113,7 @@ export function CompanySearch({
|
||||||
!selected && "text-muted-foreground"
|
!selected && "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{showSelected && selected ? selected.company : placeholder}
|
{triggerText}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{selected && !disabled && (
|
{selected && !disabled && (
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,46 @@ export async function listDistinctSites(
|
||||||
.sort((a, b) => b.customerCount - a.customerCount || a.site.localeCompare(b.site));
|
.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 {
|
export interface AssignCustomersToSiteResult {
|
||||||
updated: string[];
|
updated: string[];
|
||||||
errors: { customerId: string; message: string }[];
|
errors: { customerId: string; message: string }[];
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,45 @@ export interface Customer extends BaseRecord {
|
||||||
* types the name freehand, so the batch tool offers autocomplete
|
* types the name freehand, so the batch tool offers autocomplete
|
||||||
* against distinct values already in use (`listDistinctSites`) to
|
* against distinct values already in use (`listDistinctSites`) to
|
||||||
* curb drift, but nothing enforces one canonical spelling.
|
* 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;
|
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;
|
description: string;
|
||||||
expand?: {
|
expand?: {
|
||||||
company?: Company;
|
company?: Company;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue