diff --git a/src/app/(app)/customers/[id]/page.tsx b/src/app/(app)/customers/[id]/page.tsx index 30e2b90..3b17134 100644 --- a/src/app/(app)/customers/[id]/page.tsx +++ b/src/app/(app)/customers/[id]/page.tsx @@ -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
-

{customer.customer}

+
+

{customer.customer}

+ {/* + Use the helper for default-on semantics — see + `isActiveCustomer` JSDoc. The form treats undefined as + active to match. + */} + {isActiveCustomer(customer.status) ? ( + + Active + + ) : ( + + Inactive + + )} +

{customer.expand?.company ? ( <> @@ -135,6 +164,13 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s ) : ( "Customer Details" )} + {customer.site_location ? ( + <> + · + + {customer.site_location} + + ) : null}

@@ -207,6 +243,17 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s {customer.expand.company.company}

)} + {/* `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 && ( +

+ + {customer.site_location} +

+ )} {customer.expand?.rep && (

diff --git a/src/app/(app)/customers/page.tsx b/src/app/(app)/customers/page.tsx index a051468..38ca5c9 100644 --- a/src/app/(app)/customers/page.tsx +++ b/src/app/(app)/customers/page.tsx @@ -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) => (

-

{item.customer}

+
+

{item.customer}

+ {/* 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 ? ( + + Inactive + + ) : null} +
{item.expand?.company && (

@@ -110,6 +121,15 @@ export default function CustomersPage() {

)} + {item.site_location && ( +

+ + {item.site_location} +

+ )}
), }, @@ -237,13 +257,26 @@ export default function CustomersPage() { onClick={() => router.push(`/customers/${item.id}`)} > -
-
-

{item.customer}

+
+
+
+

{item.customer}

+ {item.status === false ? ( + + Inactive + + ) : null} +
{item.expand?.company && (

- - {item.expand.company.company} + + {item.expand.company.company} +

+ )} + {item.site_location && ( +

+ + {item.site_location}

)}
diff --git a/src/app/api/v1/customers/sites/route.ts b/src/app/api/v1/customers/sites/route.ts new file mode 100644 index 0000000..d2ebcea --- /dev/null +++ b/src/app/api/v1/customers/sites/route.ts @@ -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 `` 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 }); + } +} diff --git a/src/components/customer-form.test.tsx b/src/components/customer-form.test.tsx index 45b0c01..92d5ded 100644 --- a/src/components/customer-form.test.tsx +++ b/src/components/customer-form.test.tsx @@ -40,6 +40,22 @@ vi.mock("@/components/ui/user-search", () => ({ UserSearch: () =>
User Search
, })); +// `CustomerForm` now fetches `/api/v1/customers/sites` (added 2026-09 +// for the `site_location` `` 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([]); diff --git a/src/components/customer-form.tsx b/src/components/customer-form.tsx index 09708af..c4c4050 100644 --- a/src/components/customer-form.tsx +++ b/src/components/customer-form.tsx @@ -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 `` 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 `` 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([]); 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 + * `` 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 `` + // 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..." />

- 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. +

+
+
+ + {/* + `site_location` (added 2026-09) is the shaft/site label + specific to this customer. PB storage is plain text, so + we wrap a regular with a 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 lets the bind to + it via `list=`. cmdk-style filtering not needed — + this is a free-text field, not a constrained picker. + */} + setForm({ ...form, site_location: e.target.value })} + placeholder="e.g. Kusasalethu shaft" + className="h-9" + aria-label="Site or location" + /> + + {siteLocationSuggestions.map((s) => ( + + ))} + +

+ 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.

@@ -400,9 +531,53 @@ export function CustomerForm({ open, onOpenChange, customer, onSaved }: Customer 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..." />
+ {/* + 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. + */} +
+
+ +

+ Turn off when this customer no longer works for + the mine. Historical quotes and orders are kept; + the customer stops appearing on active pickers. +

+
+ + setForm({ ...form, status: checked === true }) + } + aria-label="Active customer" + /> +