"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 `` 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 || "", }; } function FieldLabel({ icon: Icon, label }: { icon: React.ElementType; label: string }) { return ( ); } export function CustomerForm({ open, onOpenChange, customer, onSaved }: CustomerFormProps) { const [loading, setLoading] = useState(false); const [companies, setCompanies] = useState([]); 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([]); 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 // 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(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 ( <>
{customer ? "Edit Customer" : "New Customer"}
{customer ? "Update customer details" : "Add a new customer to your CRM"}
setForm({ ...form, customer: e.target.value })} required placeholder="Full name or business name" className="h-9" aria-invalid={duplicateMatches.length > 0} />
{/* 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. */}
{form.status ? ( ) : ( )}

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.

setForm({ ...form, status: checked === true }) } aria-label={form.status ? "Active customer" : "Inactive customer"} />
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. 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.

Contact

setForm({ ...form, email: e.target.value })} placeholder="email@example.com" className="h-9" />
setForm({ ...form, number: e.target.value })} placeholder="Phone number" className="h-9" />
setForm({ ...form, alternative_email: e.target.value })} placeholder="alternative@example.com" className="h-9" />
setForm({ ...form, buyer_email: e.target.value })} placeholder="buyer@example.com" className="h-9" />
setForm({ ...form, lat: e.target.value })} type="number" step="any" placeholder="Latitude" className="h-9" aria-label="Latitude" /> setForm({ ...form, lon: e.target.value })} type="number" step="any" placeholder="Longitude" className="h-9" aria-label="Longitude" />

Leave both blank if unknown. Used later for routing / delivery. Example: -26.2041, 28.0473 (Johannesburg).

Details

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/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. */}