WFDASH-WEBAPP/src/components/customer-form.tsx
Dave 1bc0da40e7 feat(customers): lift active/inactive toggle to top of customer form
The active/inactive customer switch was buried under the
'Details' section after the sales rep and description — easy
to miss when the only thing the operator wants to do is mark
a customer as inactive.

Moved it to the second row of the form, immediately after
the customer name field, where it's the first decision the
operator makes. Visual treatment bumped to match the new
prominence: a green-tinted container when active, muted
grey when inactive, so the state is readable from across
the room instead of requiring the operator to read the
trigger label.

  state semantics (unchanged from bbfa888):
  - 'status' is a PB bool, true = active, false = inactive
  - new customers default to true via the form's
    'status: customer?.status ?? true' initializer
  - legacy rows with status undefined render as Active on
    both the detail and list pages (same default-active
    contract on read) — no migration needed
  - the only way to render an inactive customer is to flip
    the switch and save it; PB then stores false
2026-09-16 11:04:39 +02:00

713 lines
29 KiB
TypeScript

"use client";
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,
CheckCircle2,
XCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import getPocketBase from "@/lib/pocketbase";
import { getAllCompanies, findCustomerDuplicates } from "@/lib/pb-helpers";
import { CompanySearch } from "@/components/ui/company-search";
import { UserSearch } from "@/components/ui/user-search";
import type { Company, Customer } from "@/lib/types";
interface CustomerFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
customer?: Customer | null;
onSaved: () => void;
}
/**
* Treat PB's `{ lon: 0, lat: 0 }` (Null Island) as "no coordinates".
* PB geoPoint is non-nullable with this zero default — masking it
* keeps every unset customer from showing up at sea.
*/
function hasRealCoordinates(c: Customer["coordinates"]): c is { lon: number; lat: number } {
return !!c && (c.lon !== 0 || c.lat !== 0);
}
function buildCustomerFormState(customer?: Customer | null) {
return {
customer: customer?.customer || "",
email: customer?.email || "",
alternative_email: customer?.alternative_email || "",
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 || "",
};
}
function FieldLabel({ icon: Icon, label }: { icon: React.ElementType; label: string }) {
return (
<Label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-1">
<Icon className="h-3 w-3" />
{label}
</Label>
);
}
export function CustomerForm({ open, onOpenChange, customer, onSaved }: CustomerFormProps) {
const [loading, setLoading] = useState(false);
const [companies, setCompanies] = useState<Company[]>([]);
const [form, setForm] = useState(() => buildCustomerFormState(customer));
/**
* Live duplicate-detection state. The customer check is multi-field
* (name + company + email when set), so the debounced effect below
* re-runs whenever any of those change. The save-block dialog uses
* the same `duplicates` snapshot — re-checked at submit time so a
* fast typer can't slip a duplicate past us.
*/
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
// infrequently, so we don't bother with a context or page-level
// prefetch.
useEffect(() => {
if (!open || companies.length > 0) return;
let cancelled = false;
getAllCompanies()
.then((data) => {
if (!cancelled) setCompanies(data);
})
.catch(() => {
// best-effort — combobox will just show empty list
});
return () => {
cancelled = true;
};
}, [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
// anything the operator typed in the gap between mount and the
// effect's first run. The ref compares by id (not the whole
// record) so the effect only fires when the operator binds the
// form to a new row.
const lastCustomerId = useRef<string | undefined>(customer?.id);
useEffect(() => {
if (lastCustomerId.current === customer?.id) return;
lastCustomerId.current = customer?.id;
setForm(buildCustomerFormState(customer));
setDuplicates([]);
}, [customer?.id, customer]);
// Reset the form state when the sheet closes. The parent only flips
// `open` to `false` once the operator is done with the dialog (X
// button, outside click, or successful save). Without this, opening
// the sheet again — e.g. clicking "Add" after a cancelled edit —
// inherits the previous session's typed values, which is confusing
// and risks leaking a stale `customer` prop into a Create flow.
// The `customer` dep keeps the reset in sync if the parent switches
// the bound record while the sheet is already closed.
useEffect(() => {
if (open) return;
setForm(buildCustomerFormState(customer));
setDuplicates([]);
}, [open, customer]);
// Live duplicate check — 300ms after the last change to name,
// company, or email. Customer dupes are multi-field, so we key on
// all three: same name AND same company, AND (when both sides have
// an email) the emails also match. `customer?.id` excludes the
// current record when editing. We derive the empty-name case
// instead of calling setState in the effect (React 19 lint).
useEffect(() => {
const name = form.customer.trim();
if (!name) return;
let cancelled = false;
const timer = window.setTimeout(() => {
findCustomerDuplicates(name, form.company, form.email, customer?.id)
.then((matches) => {
if (!cancelled) setDuplicates(matches);
})
.catch(() => {
// best-effort — save-block re-checks anyway
});
}, 300);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [form.customer, form.company, form.email, customer?.id]);
// Derived view: clear the warning when the name is empty so stale
// matches from a previous keystroke don't linger.
const visibleDuplicates = useMemo(
() => (form.customer.trim() ? duplicates : []),
[form.customer, duplicates]
);
// Resolve the company name for the secondary line on each match. The
// customer row stores `company` as a relation id, and the helper
// already fetches with `expand: "company"`, so the id → name lookup
// is a single pass over the already-loaded companies array.
const companyNameById = new Map(companies.map((c) => [c.id, c.company] as const));
const duplicateMatches: DuplicateMatch[] = visibleDuplicates.map((c) => ({
id: c.id,
href: `/customers/${c.id}`,
name: c.customer,
secondary: c.expand?.company?.company ?? companyNameById.get(c.company) ?? c.email,
}));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.customer.trim()) {
toast.error("Customer name is required");
return;
}
// Re-check at submit time. The live effect is debounced, so a fast
// typist could submit before it ran. If we find any duplicate we
// pop a modal that lists them and asks the operator to confirm or
// cancel — they can also click "View" to open the existing record
// in a new tab.
let submitDuplicates: Customer[] = duplicates;
if (submitDuplicates.length === 0) {
try {
submitDuplicates = await findCustomerDuplicates(
form.customer,
form.company,
form.email,
customer?.id
);
setDuplicates(submitDuplicates);
} catch {
submitDuplicates = [];
}
}
if (submitDuplicates.length > 0) {
setDuplicateDialogOpen(true);
return;
}
await performSave();
};
/**
* The actual PB write. Extracted so the "Save anyway" path inside the
* duplicate dialog can re-run validation + write after the operator
* confirms the override. Identical to the company-form pattern.
*/
const performSave = async () => {
// Coordinates: blank → PB default {0,0}; both-or-neither, range-checked.
const latTrim = form.lat.trim();
const lonTrim = form.lon.trim();
let coordinatesPayload: { lon: number; lat: number };
if (!latTrim && !lonTrim) {
coordinatesPayload = { lon: 0, lat: 0 };
} else {
if (!latTrim || !lonTrim) {
toast.error("Enter both latitude and longitude, or leave both blank.");
return;
}
const lat = Number(latTrim);
const lon = Number(lonTrim);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
toast.error("Coordinates must be numbers.");
return;
}
if (lat < -90 || lat > 90) {
toast.error("Latitude must be between -90 and 90.");
return;
}
if (lon < -180 || lon > 180) {
toast.error("Longitude must be between -180 and 180.");
return;
}
coordinatesPayload = { lon, lat };
}
setLoading(true);
try {
const pb = getPocketBase();
// `company` and `rep` are relation ids — PB stores them as
// 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,
alternative_email: form.alternative_email,
buyer_email: form.buyer_email,
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(),
lastvisiteddate: new Date().toISOString(),
};
if (customer) {
await pb.collection("customers").update(customer.id, data);
toast.success("Customer updated");
} else {
await pb.collection("customers").create(data);
toast.success("Customer created");
}
onSaved();
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to save customer";
toast.error(message);
} finally {
setLoading(false);
}
};
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-md p-0 flex flex-col">
<SheetHeader className="px-6 pt-6 pb-2 border-b shrink-0">
<SheetTitle className="text-lg flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<User className="h-4 w-4" />
</div>
{customer ? "Edit Customer" : "New Customer"}
</SheetTitle>
<SheetDescription>
{customer ? "Update customer details" : "Add a new customer to your CRM"}
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto space-y-6 px-6 py-5">
<div className="space-y-4">
<div>
<FieldLabel icon={User} label="Customer Name *" />
<Input
value={form.customer}
onChange={(e) => setForm({ ...form, customer: e.target.value })}
required
placeholder="Full name or business name"
className="h-9"
aria-invalid={duplicateMatches.length > 0}
/>
<DuplicateWarning matches={duplicateMatches} entityLabel="Customer" />
</div>
{/*
Active/Inactive customer toggle — lifted to the top of the
form (2026-09) so the operator can flip the flag without
scrolling past company / sales rep / description. State
semantics: `status` is a PB bool, true = active, false =
inactive; legacy rows that pre-date the field come back
as `undefined` and collapse to `true` via `?? true` in
`buildCustomerFormState`, so untouched existing customers
read as Active on first edit.
Visual: the switch has a coloured background that flips
with state — green when active, muted when inactive — so
the answer is one glance from across the room, not "did
I read the trigger label right?". The icon (Check /
X-circle) reinforces the same answer for screen-reader
users.
*/}
<div
className={cn(
"flex items-center justify-between gap-4 rounded-md border px-3 py-3 transition-colors",
form.status
? "border-success/40 bg-success/10"
: "border-muted-foreground/30 bg-muted/40"
)}
>
<div className="min-w-0 flex items-start gap-2.5">
{form.status ? (
<CheckCircle2
className="h-4 w-4 text-success shrink-0 mt-0.5"
aria-hidden
/>
) : (
<XCircle
className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5"
aria-hidden
/>
)}
<div className="min-w-0">
<Label
htmlFor="customer-status"
className="text-sm font-medium leading-none"
>
{form.status ? "Active customer" : "Inactive customer"}
</Label>
<p className="mt-1 text-[11px] text-muted-foreground">
New customers are active by default. Flip this
off when the customer stops working for the
mine — historical quotes and orders stay
attached, but the customer drops off the
active pickers.
</p>
</div>
</div>
<Switch
id="customer-status"
checked={!!form.status}
onCheckedChange={(checked) =>
setForm({ ...form, status: checked === true })
}
aria-label={form.status ? "Active customer" : "Inactive customer"}
/>
</div>
<div>
<FieldLabel icon={Building2} label="Company" />
<CompanySearch
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. 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>
<Separator />
<div className="space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Contact</p>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel icon={Mail} label="Email" />
<Input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="email@example.com"
className="h-9"
/>
</div>
<div>
<FieldLabel icon={Phone} label="Phone" />
<Input
value={form.number}
onChange={(e) => setForm({ ...form, number: e.target.value })}
placeholder="Phone number"
className="h-9"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel icon={AtSign} label="Alt Email" />
<Input
type="email"
value={form.alternative_email}
onChange={(e) => setForm({ ...form, alternative_email: e.target.value })}
placeholder="alternative@example.com"
className="h-9"
/>
</div>
<div>
<FieldLabel icon={AtSign} label="Buyer Email" />
<Input
type="email"
value={form.buyer_email}
onChange={(e) => setForm({ ...form, buyer_email: e.target.value })}
placeholder="buyer@example.com"
className="h-9"
/>
</div>
</div>
<div>
<FieldLabel icon={MapPin} label="Delivery Coordinates" />
<div className="grid grid-cols-2 gap-3">
<Input
value={form.lat}
onChange={(e) => setForm({ ...form, lat: e.target.value })}
type="number"
step="any"
placeholder="Latitude"
className="h-9"
aria-label="Latitude"
/>
<Input
value={form.lon}
onChange={(e) => setForm({ ...form, lon: e.target.value })}
type="number"
step="any"
placeholder="Longitude"
className="h-9"
aria-label="Longitude"
/>
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
Leave both blank if unknown. Used later for routing /
delivery. Example: -26.2041, 28.0473 (Johannesburg).
</p>
</div>
</div>
<Separator />
<div className="space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Details</p>
<div>
<FieldLabel icon={User} label="Sales Rep" />
<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/Inactive toggle used to live here, but it was
buried past company / rep / description and easy to
miss. It's now the second row of the form (right
under the customer name) so flipping the flag is one
tap. Keeping the Save layer in `performSave` unchanged
— `status: !!form.status` already shipped 2026-09.
*/}
<div>
<FieldLabel icon={FileText} label="Description" />
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={2}
placeholder="Optional notes..."
className="resize-none text-sm"
/>
</div>
</div>
</div>
<SheetFooter className="border-t px-6 pb-6 pt-4 bg-popover shrink-0">
<Button type="submit" disabled={loading} className="w-full">
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{customer ? "Save Changes" : "Create Customer"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
{/* Duplicate confirmation. The customer check is multi-field
(name + company + email when set), so a match here means a
real collision — same person, same company, same email. The
operator can cancel and edit, click "View" to inspect the
existing record in a new tab, or override with "Save anyway"
when the match is genuinely a different person. */}
<Dialog open={duplicateDialogOpen} onOpenChange={setDuplicateDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{duplicates.length === 1 ? "Possible duplicate" : `${duplicates.length} possible duplicates`}
</DialogTitle>
<DialogDescription>
{duplicates.length === 1
? "An existing customer has the same name at the same company. Check the match below before saving."
: "Several existing customers have the same name and company. Confirm none of them is the one you're adding."}
</DialogDescription>
</DialogHeader>
<ul className="max-h-60 space-y-1.5 overflow-y-auto">
{duplicates.map((d) => {
const secondary = d.expand?.company?.company ?? companyNameById.get(d.company) ?? d.email;
return (
<li
key={d.id}
className="flex items-start justify-between gap-2 rounded-md border bg-background/60 px-3 py-2 text-sm"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium" title={d.customer}>
{d.customer}
</p>
{secondary && (
<p className="truncate text-xs text-muted-foreground" title={secondary}>
{secondary}
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => window.open(`/customers/${d.id}`, "_blank", "noopener")}
aria-label={`Open ${d.customer}`}
title="Open in new tab"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</li>
);
})}
</ul>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDuplicateDialogOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={async () => {
setDuplicateDialogOpen(false);
await performSave();
}}
disabled={loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save anyway
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}