WFDASH-WEBAPP/src/components/quote-form.tsx
SJCoetz 2c6155abbb feat: add AI quote item rewriter and supporting UI/UX changes
- Add `user_edited` field to QuoteItem type to gate AI rewrites on user-edited content
- Add idempotent migration script to add and backfill the `user_edited` field across quote items
- Replace plain Select components with searchable SupplierSearch combobox across order and quote pages
- Add working days calculation utility and aged quote warning badges for overdue quotes
- Implement expandable row previews in the quotes list view
- Add full AI rewrite workflow: backend API endpoint, frontend modal, and n8n workflow definition
- Update package scripts for the new migration
2026-06-22 18:29:59 +02:00

396 lines
15 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } 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 { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
SheetFooter,
} from "@/components/ui/sheet";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SupplierSearch } from "@/components/ui/supplier-search";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "sonner";
import { Loader2, FileText, User, Building2, Hash, Plus, Trash2, ListOrdered, Banknote } from "lucide-react";
import getPocketBase from "@/lib/pocketbase";
import { getAllCustomers, getAllCompanies, getAllSuppliers, generateQuoteNumber } from "@/lib/pb-helpers";
import type { Customer, Company, Supplier } from "@/lib/types";
interface QuoteItemInput {
id: string;
description: string;
quantity: number;
supplier: string;
cost_price: number;
selling_price: number;
notes: string;
}
interface QuoteFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
}
export function QuoteForm({ open, onOpenChange, onSaved }: QuoteFormProps) {
const [loading, setLoading] = useState(false);
const [customers, setCustomers] = useState<Customer[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [dataLoading, setDataLoading] = useState(false);
const [form, setForm] = useState({
customer: "",
company: "",
rfq_number: "",
description: "",
notes: "",
});
const [items, setItems] = useState<QuoteItemInput[]>([]);
useEffect(() => {
if (!open) return;
const load = async () => {
setDataLoading(true);
try {
const [c, co, s] = await Promise.all([
getAllCustomers(),
getAllCompanies(),
getAllSuppliers(),
]);
setCustomers(c);
setCompanies(co);
setSuppliers(s);
} catch {
toast.error("Failed to load form data");
} finally {
setDataLoading(false);
}
};
load();
}, [open]);
const resetForm = () => {
setForm({ customer: "", company: "", rfq_number: "", description: "", notes: "" });
setItems([]);
};
const addItem = () => {
setItems([
...items,
{ id: crypto.randomUUID(), description: "", quantity: 1, supplier: "", cost_price: 0, selling_price: 0, notes: "" },
]);
};
const removeItem = (id: string) => {
setItems(items.filter((i) => i.id !== id));
};
const updateItem = (id: string, field: keyof QuoteItemInput, value: string | number) => {
setItems(items.map((i) => (i.id === id ? { ...i, [field]: value } : i)));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.customer) {
toast.error("Customer is required");
return;
}
setLoading(true);
try {
const pb = getPocketBase();
let quoteNumber: string;
try {
quoteNumber = await generateQuoteNumber();
} catch (numErr) {
console.error("Quote number gen failed:", numErr);
const now = new Date();
quoteNumber = `Q-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(Math.floor(Math.random() * 999) + 1).padStart(3, "0")}`;
}
let quote: any;
try {
quote = await pb.collection("quotes").create({
quote_number: quoteNumber,
customer: form.customer,
company: form.company || null,
rfq_number: form.rfq_number || null,
description: form.description || "",
notes: form.notes || "",
status: "new",
company_letterhead: "CNL",
});
} catch (createErr) {
console.error("Quote create failed:", createErr);
throw createErr;
}
for (const item of items) {
if (!item.description.trim()) continue;
try {
await pb.collection("quote_items").create({
quote: quote.id,
description: item.description,
quantity: item.quantity || 1,
supplier: item.supplier || null,
cost_price: item.cost_price || null,
selling_price: item.selling_price || null,
notes: item.notes || "",
// Items added through the new-quote form are user-typed
// from the start — flag user_edited so the AI rewriter
// is allowed to touch them on first open.
user_edited: true,
});
} catch (itemErr) {
console.error("Item create failed:", item.id, itemErr);
}
}
toast.success(`Quote ${quoteNumber} created`);
onSaved();
onOpenChange(false);
resetForm();
} catch (err: unknown) {
let message = "Failed to create quote";
if (err && typeof err === "object" && "data" in err) {
const pbErr = err as { data?: { message?: string; data?: Record<string, { message?: string }> } };
if (pbErr.data?.message) message = pbErr.data.message;
else if (pbErr.data?.data) {
const fieldErrors = Object.values(pbErr.data.data).map((f: any) => f.message).join(", ");
if (fieldErrors) message = fieldErrors;
}
} else if (err instanceof Error) {
message = err.message;
}
toast.error(message);
console.error("Create quote error:", err);
} 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-2xl overflow-y-auto p-0 flex flex-col max-h-[100dvh]">
<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">
<FileText className="h-4 w-4" />
</div>
New Quote
</SheetTitle>
<SheetDescription>Create a new quote request</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="space-y-6 px-6 py-5">
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel icon={User} label="Customer *" />
<Select
value={form.customer}
onValueChange={(v) => setForm({ ...form, customer: v || "" })}
disabled={dataLoading}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select customer..." />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.customer || c.company}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<FieldLabel icon={Building2} label="Company" />
<Select
value={form.company}
onValueChange={(v) => setForm({ ...form, company: v || "" })}
disabled={dataLoading}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select company..." />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
<SelectItem value="none">None</SelectItem>
{companies.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.company}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<FieldLabel icon={Hash} label="RFQ Number" />
<Input
value={form.rfq_number}
onChange={(e) => setForm({ ...form, rfq_number: e.target.value })}
placeholder="Optional reference number"
className="h-9"
/>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<FieldLabel icon={ListOrdered} label="Quote Items" />
<Button type="button" variant="outline" size="sm" onClick={addItem} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" />
Add Item
</Button>
</div>
{items.length === 0 ? (
<div className="rounded-md border border-dashed py-8 text-center text-sm text-muted-foreground">
<ListOrdered className="h-6 w-6 mx-auto mb-2 opacity-50" />
No items yet. Click &quot;Add Item&quot; to start.
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40%]">Description</TableHead>
<TableHead className="w-[60px] text-center">Qty</TableHead>
<TableHead>Supplier</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Sell</TableHead>
<TableHead className="w-[40px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="p-1">
<Input
value={item.description}
onChange={(e) => updateItem(item.id, "description", e.target.value)}
placeholder="Item description"
className="h-7 text-xs border-0 shadow-none focus-visible:ring-0 px-1"
/>
</TableCell>
<TableCell className="p-1">
<Input
type="number"
value={item.quantity}
onChange={(e) => updateItem(item.id, "quantity", Number(e.target.value))}
className="h-7 text-xs text-center border-0 shadow-none focus-visible:ring-0 px-0"
min={1}
/>
</TableCell>
<TableCell className="p-1">
<SupplierSearch
value={item.supplier}
onValueChange={(v) => updateItem(item.id, "supplier", v)}
suppliers={suppliers}
placeholder="Select..."
className="h-7 text-xs border-0 shadow-none focus-visible:ring-0 px-2"
/>
</TableCell>
<TableCell className="p-1">
<Input
type="number"
value={item.cost_price || ""}
onChange={(e) => updateItem(item.id, "cost_price", Number(e.target.value))}
placeholder="0"
className="h-7 text-xs text-right border-0 shadow-none focus-visible:ring-0 px-1 tabular-nums"
/>
</TableCell>
<TableCell className="p-1">
<Input
type="number"
value={item.selling_price || ""}
onChange={(e) => updateItem(item.id, "selling_price", Number(e.target.value))}
placeholder="0"
className="h-7 text-xs text-right border-0 shadow-none focus-visible:ring-0 px-1 tabular-nums"
/>
</TableCell>
<TableCell className="p-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => removeItem(item.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<Separator />
<div>
<FieldLabel icon={FileText} label="Description" />
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={2}
placeholder="Brief description or customer message..."
className="resize-none text-sm"
/>
</div>
<div>
<FieldLabel icon={FileText} label="Notes" />
<Textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
rows={2}
placeholder="Internal notes..."
className="resize-none text-sm"
/>
</div>
</div>
<SheetFooter className="px-6 pb-6 border-t pt-4 shrink-0">
<Button type="submit" disabled={loading || dataLoading} className="w-full">
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Quote
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}