- 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
396 lines
15 KiB
TypeScript
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 "Add Item" 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>
|
|
);
|
|
}
|