WFDASH-WEBAPP/src/components/supplier-form.tsx
SJCoetz 4444842ef5 feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components

Co-authored-by: Orca <help@stably.ai>
2026-08-03 09:33:45 +02:00

378 lines
14 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} 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, Mail, MapPin, Phone, Tag, Timer, Truck, ExternalLink } from "lucide-react";
import getPocketBase from "@/lib/pocketbase";
import { findSupplierDuplicates } from "@/lib/pb-helpers";
import type { Supplier } from "@/lib/types";
interface SupplierFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
supplier?: Supplier | null;
onSaved: () => void;
}
function buildSupplierFormState(supplier?: Supplier | null) {
return {
suppliername: supplier?.suppliername || "",
email: supplier?.email || "",
suppliernumber: supplier?.suppliernumber || "",
supplieraddress: supplier?.supplieraddress || "",
supply_tags: supplier?.supply_tags || "",
coordinates: supplier?.coordinates || "",
closingtime: supplier?.closingtime || "",
};
}
function FieldLabel({ icon: Icon, label }: { icon: React.ElementType; label: string }) {
return (
<Label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Icon className="h-3 w-3" />
{label}
</Label>
);
}
export function SupplierForm({ open, onOpenChange, supplier, onSaved }: SupplierFormProps) {
const [loading, setLoading] = useState(false);
const [form, setForm] = useState(() => buildSupplierFormState(supplier));
/**
* Live duplicate-detection state — see the matching comment in
* `company-form.tsx` for the rationale. Suppliers use a name-only
* duplicate check (no company / email gates — a supplier's identity
* is its name).
*/
const [duplicates, setDuplicates] = useState<Supplier[]>([]);
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
// Reset the form state when the form is bound to a different
// supplier. We skip the very first run — `useState(() => buildSupplierFormState(supplier))`
// already initialised the form, and re-running here on 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 lastSupplierId = useRef<string | undefined>(supplier?.id);
useEffect(() => {
if (lastSupplierId.current === supplier?.id) return;
lastSupplierId.current = supplier?.id;
setForm(buildSupplierFormState(supplier));
setDuplicates([]);
}, [supplier?.id, supplier]);
// 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 `supplier` prop into a Create flow.
// The `supplier` dep keeps the reset in sync if the parent switches
// the bound record while the sheet is already closed.
useEffect(() => {
if (open) return;
setForm(buildSupplierFormState(supplier));
setDuplicates([]);
}, [open, supplier]);
// Live duplicate check — 300ms after the last keystroke. Same
// cancellation dance as the company form; `supplier?.id` keeps
// edit mode from flagging the record being edited as a duplicate
// of itself. We derive the empty-name case instead of calling
// setState in the effect (React 19 cascading-render lint).
useEffect(() => {
const name = form.suppliername.trim();
if (!name) return;
let cancelled = false;
const timer = window.setTimeout(() => {
findSupplierDuplicates(name, supplier?.id)
.then((matches) => {
if (!cancelled) setDuplicates(matches);
})
.catch(() => {
// best-effort
});
}, 300);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [form.suppliername, supplier?.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.suppliername.trim() ? duplicates : []),
[form.suppliername, duplicates]
);
const duplicateMatches: DuplicateMatch[] = visibleDuplicates.map((s) => ({
id: s.id,
href: `/suppliers`,
name: s.suppliername,
secondary: s.email || s.supplieraddress || undefined,
}));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.suppliername.trim()) {
toast.error("Supplier name is required");
return;
}
// Re-check at submit time — same pattern as the company form. The
// live check is debounced, so a fast typist could submit before
// it ran.
let submitDuplicates: Supplier[] = duplicates;
if (submitDuplicates.length === 0) {
try {
submitDuplicates = await findSupplierDuplicates(form.suppliername, supplier?.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 — same one-source-of-truth pattern as the
* company form.
*/
const performSave = async () => {
setLoading(true);
try {
const pb = getPocketBase();
if (supplier) {
await pb.collection("suppliers").update(supplier.id, form);
toast.success("Supplier updated");
} else {
await pb.collection("suppliers").create(form);
toast.success("Supplier created");
}
onSaved();
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to save supplier";
toast.error(message);
} finally {
setLoading(false);
}
};
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-lg 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">
<Truck className="h-4 w-4" />
</div>
{supplier ? "Edit Supplier" : "New Supplier"}
</SheetTitle>
<SheetDescription>
{supplier ? "Update supplier details" : "Add a new supplier"}
</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>
<FieldLabel icon={Truck} label="Supplier Name *" />
<Input
value={form.suppliername}
onChange={(e) => setForm({ ...form, suppliername: e.target.value })}
required
placeholder="e.g. West Rand Industrial"
className="h-9"
aria-invalid={duplicateMatches.length > 0}
/>
<DuplicateWarning matches={duplicateMatches} entityLabel="Supplier" />
</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="sales@supplier.co.za"
className="h-9"
/>
</div>
<div>
<FieldLabel icon={Phone} label="Phone / Number" />
<Input
value={form.suppliernumber}
onChange={(e) => setForm({ ...form, suppliernumber: e.target.value })}
placeholder="Primary contact number"
className="h-9"
/>
</div>
</div>
<div>
<FieldLabel icon={MapPin} label="Address" />
<Input
value={form.supplieraddress}
onChange={(e) => setForm({ ...form, supplieraddress: e.target.value })}
placeholder="Physical or collection address"
className="h-9"
/>
</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={Tag} label="Tags" />
<Input
value={form.supply_tags}
onChange={(e) => setForm({ ...form, supply_tags: e.target.value })}
placeholder="Comma-separated tags (e.g. bolts, paint, PPE)"
className="h-9"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel icon={MapPin} label="Coordinates" />
<Input
value={form.coordinates}
onChange={(e) => setForm({ ...form, coordinates: e.target.value })}
placeholder="-26.2041, 28.0473"
className="h-9"
/>
<p className="mt-1.5 text-[11px] text-muted-foreground">
Saved as legacy text for the supplier map.
</p>
</div>
<div>
<FieldLabel icon={Timer} label="Closing Time" />
<Input
value={form.closingtime}
onChange={(e) => setForm({ ...form, closingtime: e.target.value })}
placeholder="e.g. 16:30"
className="h-9"
/>
</div>
</div>
</div>
</div>
<SheetFooter className="border-t px-6 pb-6 pt-4 bg-popover shrink-0">
<div className="flex w-full gap-3">
<Button type="submit" disabled={loading} className="flex-1">
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{supplier ? "Save Changes" : "Create Supplier"}
</Button>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
{/* Duplicate confirmation — same UX as the company form. Lists
every match the live + submit-time check turned up, with a
"View" link that opens the existing record in a new tab. The
operator can cancel and edit, or explicitly override with
"Save anyway" when the match is a different entity that
happens to share a name. */}
<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 supplier has the same name. Check the match below before saving."
: "Several existing suppliers have the same name. 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) => (
<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.suppliername}>
{d.suppliername}
</p>
{(d.email || d.supplieraddress) && (
<p className="truncate text-xs text-muted-foreground" title={d.email || d.supplieraddress}>
{d.email || d.supplieraddress}
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => window.open(`/suppliers`, "_blank", "noopener")}
aria-label={`View suppliers`}
title="Open suppliers list"
>
<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>
</>
);
}