- 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.
456 lines
16 KiB
TypeScript
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 "Add Item" 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>
|
|
);
|
|
}
|