- 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>
378 lines
14 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|