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);
|
||||
}
|
||||
|
||||
/**
|
||||
* `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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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>,
|
||||
}));
|
||||
|
||||
// `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([]);
|
||||
|
|
|
|||
|
|
@ -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'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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 }[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue