WFDASH-WEBAPP/src/components/quote-form.tsx
Dave 43bbdfc458 feat(tv-dashboard): enhance TV dashboard with driver stops and performance metrics
- Updated the TV dashboard to include a new section for driver stops, displaying active deliveries and pickups with GPS coordinates.
- Refactored the dashboard data structure to incorporate year-over-year performance metrics alongside existing monthly and weekly stats.
- Replaced the supplier map with a driver stops map for improved clarity on driver activities.
- Enhanced the user performance metrics to include detailed counts for quotes and orders processed by each representative.
- Removed the obsolete supplier map component to streamline the dashboard's functionality.
2026-08-19 10:16:49 +02:00

456 lines
16 KiB
TypeScript

"use client";
import { Fragment, useEffect, useMemo, useState } 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, FileText, Hash, ListOrdered, Plus, User } from "lucide-react";
import getPocketBase from "@/lib/pocketbase";
import { getAllCustomers, getAllCompanies } from "@/lib/pb-helpers";
import type { Customer, Company } from "@/lib/types";
import {
QuoteFormContactPicker,
type QuoteContactOption,
} from "@/components/quote-form-contact-picker";
import {
QuoteFormItemCard,
type DraftQuoteItem,
} from "@/components/quote-form-item-card";
interface QuoteFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
}
type CreatedQuote = {
id: string;
quote_number?: string;
};
type CreatedQuoteItem = {
id: string;
};
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 QuoteForm({ open, onOpenChange, onSaved }: QuoteFormProps) {
const [loading, setLoading] = useState(false);
const [customers, setCustomers] = useState<Customer[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [dataLoading, setDataLoading] = useState(false);
const [form, setForm] = useState({
customer: "",
company: "",
rfq_number: "",
customer_request: "",
notes: "",
});
const [items, setItems] = useState<DraftQuoteItem[]>([]);
useEffect(() => {
if (!open) return;
const load = async () => {
setDataLoading(true);
try {
const [c, co] = await Promise.all([
getAllCustomers(),
getAllCompanies(),
]);
setCustomers(c);
setCompanies(co);
} catch {
toast.error("Failed to load form data");
} finally {
setDataLoading(false);
}
};
load();
}, [open]);
// Reset the form state when the sheet closes. The data-load effect
// above only re-fetches customers/companies on open, so we don't
// pay a getFullList on every close — we just clear the in-memory
// form state (selected contact, RFQ, line items, notes). Without
// this, opening the sheet again after a cancelled draft would
// re-show the last-typed RFQ and the previously added line items.
useEffect(() => {
if (open) return;
setForm({ customer: "", company: "", rfq_number: "", customer_request: "", notes: "" });
setItems([]);
}, [open]);
const resetForm = () => {
setForm({ customer: "", company: "", rfq_number: "", customer_request: "", notes: "" });
setItems([]);
};
const addItem = () => {
setItems([
...items,
// Start with empty quantity so the operator can type the count
// directly (e.g. "22") without first deleting a "1". The submit
// path below falls back to 1 when the field is left blank.
{ id: crypto.randomUUID(), description: "", quantity: "", notes: "", files: [], sku: "" },
]);
};
const pickerOptions = useMemo<QuoteContactOption[]>(() => {
const customerOptions = customers
.map((customer) => {
const company = companies.find((row) => row.id === customer.company);
if (!company) return null;
return {
kind: "customer" as const,
customerId: customer.id,
customerName: customer.customer || "",
companyId: company.id,
companyName: company.company || "",
};
})
.filter((option): option is Extract<QuoteContactOption, { kind: "customer" }> => option !== null);
const linkedCompanyIds = new Set(customers.map((customer) => customer.company).filter(Boolean));
const companyOnlyOptions = companies
.filter((company) => !linkedCompanyIds.has(company.id))
.map((company) => ({
kind: "company" as const,
companyId: company.id,
companyName: company.company || "",
}));
return [...customerOptions, ...companyOnlyOptions];
}, [companies, customers]);
const selectedContact = useMemo<QuoteContactOption | null>(() => {
if (form.customer) {
return (
pickerOptions.find(
(option) => option.kind === "customer" && option.customerId === form.customer
) ?? null
);
}
if (form.company) {
return (
pickerOptions.find(
(option) => option.kind === "company" && option.companyId === form.company
) ?? null
);
}
return null;
}, [form.company, form.customer, pickerOptions]);
const uploadDraftItemFiles = async (itemId: string, files: File[]) => {
const failedUploads: string[] = [];
for (const file of files) {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(`/api/v1/quotes/items/${itemId}/attachments/upload`, {
method: "POST",
body: formData,
});
if (!response.ok) {
failedUploads.push(file.name);
}
} catch {
failedUploads.push(file.name);
}
}
return failedUploads;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.customer && !form.company) {
toast.error("Customer or company is required");
return;
}
if (items.length === 0 || items.some((item) => item.description.trim().length === 0)) {
toast.error("Add at least one quote item with a description");
return;
}
setLoading(true);
try {
const pb = getPocketBase();
let quoteNumber: string;
try {
// Atomic, server-incremented. Replaces the deprecated
// client-side `generateQuoteNumber` from `pb-helpers.ts` —
// that one was racy under concurrent submits because it
// read-then-wrote the last quote locally instead of going
// through `quote_counters` server-side.
// The endpoint is intentionally open (no auth) — see its
// route file for the rationale.
const res = await fetch("/api/v1/quotes/next-number", {
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`next-number HTTP ${res.status}`);
}
const data = (await res.json()) as { quote_number?: string };
if (!data.quote_number) {
throw new Error("next-number response missing quote_number");
}
quoteNumber = data.quote_number;
} catch (numErr) {
// Last-resort fallback so a transient network blip on the
// counter endpoint doesn't block the operator from saving
// a quote. Same format as `formatQuoteNumber` — operator
// may end up with a duplicate; PB `quote_number` is a
// plain text field with no uniqueness constraint.
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: CreatedQuote;
try {
quote = await pb.collection("quotes").create({
quote_number: quoteNumber,
customer: form.customer || null,
company: form.company || null,
rfq_number: form.rfq_number || null,
description: form.customer_request || "",
notes: form.notes || "",
status: "new",
company_letterhead: "CNL",
});
} catch (createErr) {
console.error("Quote create failed:", createErr);
throw createErr;
}
const createdItems: Array<{ draft: DraftQuoteItem; created: CreatedQuoteItem }> = [];
for (const item of items) {
try {
const created = await pb.collection("quote_items").create({
quote: quote.id,
description: item.description.trim(),
quantity: item.quantity || 1,
notes: item.notes || "",
// Part number the operator stamped on the item. Empty
// string when unknown — the catalog lookup feature
// (separate, n8n-driven) can backfill it later without
// us having to special-case null vs "" in PB.
sku: item.sku || "",
// 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,
});
createdItems.push({ draft: item, created });
} catch (itemErr) {
console.error("Item create failed:", item.id, itemErr);
throw new Error("QUOTE_PARTIALLY_CREATED");
}
}
const failedUploads: string[] = [];
for (const item of createdItems) {
const itemFailures = await uploadDraftItemFiles(item.created.id, item.draft.files);
failedUploads.push(...itemFailures);
}
if (failedUploads.length > 0) {
toast.error(`Quote created, but some attachments failed: ${failedUploads.join(", ")}`);
} else {
toast.success(`Quote ${quote.quote_number || quoteNumber} created`);
}
onSaved();
onOpenChange(false);
resetForm();
} catch (err: unknown) {
if (err instanceof Error && err.message === "QUOTE_PARTIALLY_CREATED") {
toast.error("Quote partially created. Review the quote before continuing.");
setLoading(false);
return;
}
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((field) => field.message ?? "")
.filter(Boolean)
.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);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl 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 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto space-y-6 px-6 py-5">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<FieldLabel icon={User} label="Customer or Company *" />
<QuoteFormContactPicker
disabled={dataLoading}
options={pickerOptions}
value={selectedContact}
onSelect={(option) => {
if (option.kind === "customer") {
setForm((current) => ({
...current,
customer: option.customerId,
company: option.companyId,
}));
return;
}
setForm((current) => ({
...current,
customer: "",
company: option.companyId,
}));
}}
/>
</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" />
{items.length === 0 && (
<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>
) : (
/*
* Each card is followed by its own "Add another item" button.
* The operator can keep typing line after line without ever
* scrolling back to the top of the items section — the next
* button is always immediately below the last card they
* touched. The ghost + dashed-border styling keeps it from
* being mistaken for a submit/finish affordance.
*/
<div className="space-y-3">
{items.map((item) => (
<Fragment key={item.id}>
<QuoteFormItemCard
item={item}
onChange={(patch) =>
setItems((current) =>
current.map((row) => (row.id === item.id ? { ...row, ...patch } : row))
)
}
onRemove={() =>
setItems((current) => current.filter((row) => row.id !== item.id))
}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addItem}
className="h-9 w-full border border-dashed text-muted-foreground hover:text-foreground"
aria-label="Add another item"
>
<Plus className="mr-1 h-3 w-3" />
Add another item
</Button>
</Fragment>
))}
</div>
)}
</div>
<Separator />
<div>
<FieldLabel icon={FileText} label="Customer Request" />
<Textarea
value={form.customer_request}
onChange={(e) => setForm({ ...form, customer_request: e.target.value })}
rows={2}
placeholder="Overall RFQ context, urgency, 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="border-t px-6 pb-6 pt-4 bg-popover 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>
);
}