Universal search returned 401 "Not authenticated." on every query. Cause: dashboard auth is a PocketBase token in the `Authorization` header, NOT a cookie — `assertBrowserSession` reads the header and refreshes it against PB. The page used a bare `fetch()`, which sends neither. The service underneath was always correct. Worth being explicit about what was missed: the service was verified end-to-end against live PB, and the route compiles, but curl against the dev server returns the same auth-gated shell as /orders does — so the authenticated path was never actually exercised until a human opened it in a browser. Green tests plus a working backend is not the same as a working feature. The cache-warm call had the same defect, which mattered more than it looks: an unauthenticated warm is a server no-op, so the first real search would have quietly paid the full ~4.5s cold build every session. SECOND INSTANCE, same root cause `customer-form.tsx` fetched `/api/v1/customers/sites` — a guarded route — with no Authorization header. The `.catch` swallowed the 401, so the site-location autocomplete has always silently returned an empty list, which is indistinguishable from "no sites in use yet". Fixed in the same pass. Audited the rest: cross-referenced every route that calls assertBrowserSession against every client caller, and this is the only other real one. The `x-wfdash-auth` header installed by `installAuditFetch` is attribution for the audit trail, not auth, which is probably what makes this easy to get wrong. New: 5 tests on the search page covering the auth header on both the search and the warm call, the truncated-group "showing N of TOTAL" rendering, the failed-source row, and session expiry. Verified the two auth tests fail when the header is removed while the other three keep passing. Also completed the customer-form pocketbase test mock, which was missing `authStore` — the real SDK always has one.
721 lines
30 KiB
TypeScript
721 lines
30 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", {
|
|
// `/api/v1/*` browser routes are gated by `assertBrowserSession`,
|
|
// which reads the PocketBase token from `Authorization` — auth
|
|
// here is NOT a cookie. Without it the route 401s, the `.catch`
|
|
// below swallows it, and the autocomplete silently degrades to an
|
|
// empty list, which looks exactly like "no sites in use yet".
|
|
headers: { Authorization: getPocketBase().authStore.token },
|
|
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'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>
|
|
</>
|
|
);
|
|
}
|