WFDASH-WEBAPP/src/components/customer-form.tsx
Dave 8ed0952364 refactor(forms): update layout and structure of form components
- Adjusted the `SheetContent` width in multiple form components to improve responsiveness.
- Enhanced the `CompanyForm` layout by reorganizing fields for better clarity and usability, including the addition of coordinates input.
- Updated the `CustomerForm` and `SupplierForm` to maintain consistency in design and structure.
- Improved the `DataTable` component's card styling for better visual appeal and user interaction.
- Refined the `QuotesPage` card styling to enhance the user experience when selecting quotes.
2026-08-17 23:34:30 +02:00

504 lines
20 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 { 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 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 || "",
rep: customer?.rep || "",
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);
// 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]);
// 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).
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,
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>
<div>
<FieldLabel icon={Building2} label="Company" />
<CompanySearch
value={form.company}
onValueChange={(id) => setForm({ ...form, company: id })}
companies={companies}
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.
</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 })}
placeholder="Select sales rep..."
/>
</div>
<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>
</>
);
}