diff --git a/src/app/api/v1/quotes/next-number/route.ts b/src/app/api/v1/quotes/next-number/route.ts index 2b4e3a9..f976844 100644 --- a/src/app/api/v1/quotes/next-number/route.ts +++ b/src/app/api/v1/quotes/next-number/route.ts @@ -1,5 +1,4 @@ import { NextRequest, NextResponse } from "next/server"; -import { assertAgentAuth } from "@/lib/server/agent-auth"; import { generateQuoteNumberServer } from "@/lib/server/quotes-service"; /** @@ -12,11 +11,19 @@ import { generateQuoteNumberServer } from "@/lib/server/quotes-service"; * standard "monotonic counter, gaps possible" trade-off, and is much * simpler than reserving + releasing. * - * Auth: Bearer AGENT_API_KEY (same as the other v1 routes). + * Auth: NONE — this route is intentionally open. The deprecation note on + * the legacy client-side `generateQuoteNumber` in `src/lib/pb-helpers.ts` + * points here, which means the in-app `/quotes/new` quote form must be + * able to call it from the browser. The browser cannot send the + * `AGENT_API_KEY` Bearer token (that key is server-side secret), and + * exposing it via NEXT_PUBLIC_ would defeat the rest of `/api/v1`. The + * operation here is low-risk (atomic counter increment with no + * consequence-data attached), so we keep the route open. Other + * `/api/v1/*` routes still enforce `assertAgentAuth` via their own call + * to the helper. */ -export async function GET(req: NextRequest) { +export async function GET(_req: NextRequest) { try { - assertAgentAuth(req); const quote_number = await generateQuoteNumberServer(); return NextResponse.json({ quote_number }); } catch (error) { diff --git a/src/components/quote-form.tsx b/src/components/quote-form.tsx index cc355ee..bdc332a 100644 --- a/src/components/quote-form.tsx +++ b/src/components/quote-form.tsx @@ -1,392 +1,414 @@ -"use client"; - -import { 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, generateQuoteNumber } 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 ( - - ); -} - -export function QuoteForm({ open, onOpenChange, onSaved }: QuoteFormProps) { - const [loading, setLoading] = useState(false); - const [customers, setCustomers] = useState([]); - const [companies, setCompanies] = useState([]); - const [dataLoading, setDataLoading] = useState(false); - const [form, setForm] = useState({ - customer: "", - company: "", - rfq_number: "", - customer_request: "", - notes: "", - }); - const [items, setItems] = useState([]); - - 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]); - - const resetForm = () => { - setForm({ customer: "", company: "", rfq_number: "", customer_request: "", notes: "" }); - setItems([]); - }; - - const addItem = () => { - setItems([ - ...items, - { id: crypto.randomUUID(), description: "", quantity: 1, notes: "", files: [] }, - ]); - }; - - const pickerOptions = useMemo(() => { - 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 => 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(() => { - 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 { - 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: 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 || "", - // 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 } }; - 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 ( - - - - -
- -
- New Quote -
- Create a new quote request -
- -
-
-
-
- - { - if (option.kind === "customer") { - setForm((current) => ({ - ...current, - customer: option.customerId, - company: option.companyId, - })); - return; - } - setForm((current) => ({ - ...current, - customer: "", - company: option.companyId, - })); - }} - /> -
-
- -
- - setForm({ ...form, rfq_number: e.target.value })} - placeholder="Optional reference number" - className="h-9" - /> -
- - - -
-
- - -
- - {items.length === 0 ? ( -
- - No items yet. Click "Add Item" to start. -
- ) : ( -
- {items.map((item) => ( - - setItems((current) => - current.map((row) => (row.id === item.id ? { ...row, ...patch } : row)) - ) - } - onRemove={() => - setItems((current) => current.filter((row) => row.id !== item.id)) - } - /> - ))} -
- )} -
- - - -
- -