This commit overhauls the order management system to use item-level status tracking instead of a single order-level status: - Remove deprecated `orders.status` field and add `invoiced` and quote match audit fields - Add `isOrderComplete` utility with tests to validate fully delivered orders - Add purchase order PDF rendering support and tooling - Add geo coordinate fields for companies/customers with map link UI - Add REST management APIs for customers, companies, and suppliers - Update customer/company services to use relation fields instead of free text - Add status pill and tab filter components for item status visualization - Add quote-to-order matching logic and unit tests - Update all pages, components, and tests to match the new schema - Include all required PocketBase migrations and schema updates
333 lines
No EOL
12 KiB
TypeScript
333 lines
No EOL
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } 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 { toast } from "sonner";
|
|
import { Loader2, User, Building2, Mail, Phone, AtSign, MapPin, FileText } from "lucide-react";
|
|
import getPocketBase from "@/lib/pocketbase";
|
|
import { getAllCompanies } 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);
|
|
}
|
|
|
|
export function CustomerForm({ open, onOpenChange, customer, onSaved }: CustomerFormProps) {
|
|
const [loading, setLoading] = useState(false);
|
|
const [companies, setCompanies] = useState<Company[]>([]);
|
|
const [form, setForm] = useState({
|
|
customer: "",
|
|
email: "",
|
|
alternative_email: "",
|
|
buyer_email: "",
|
|
number: "",
|
|
company: "",
|
|
rep: "",
|
|
lat: "",
|
|
lon: "",
|
|
description: "",
|
|
});
|
|
|
|
// 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]);
|
|
|
|
useEffect(() => {
|
|
if (customer) {
|
|
setForm({
|
|
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: hasRealCoordinates(customer.coordinates) ? String(customer.coordinates.lat) : "",
|
|
lon: hasRealCoordinates(customer.coordinates) ? String(customer.coordinates.lon) : "",
|
|
description: customer.description || "",
|
|
});
|
|
} else {
|
|
setForm({
|
|
customer: "",
|
|
email: "",
|
|
alternative_email: "",
|
|
buyer_email: "",
|
|
number: "",
|
|
company: "",
|
|
rep: "",
|
|
lat: "",
|
|
lon: "",
|
|
description: "",
|
|
});
|
|
}
|
|
}, [customer, open]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!form.customer.trim()) {
|
|
toast.error("Customer name is required");
|
|
return;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
};
|
|
|
|
const FieldLabel = ({ icon: Icon, label }: { icon: React.ElementType; label: string }) => (
|
|
<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>
|
|
);
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className="w-full sm:max-w-lg overflow-y-auto p-0 flex flex-col">
|
|
<SheetHeader className="px-6 pt-6 pb-2 border-b">
|
|
<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 overflow-y-auto">
|
|
<div className="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"
|
|
/>
|
|
</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>
|
|
|
|
<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={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>
|
|
<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="px-6 pb-6 border-t pt-4">
|
|
<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>
|
|
);
|
|
} |