chore(quotes): route next-number auth-free + replace deprecated client counter call in quote-form
src/app/api/v1/quotes/next-number/route.ts
- Drop assertAgentAuth() so the deprecated pb-helpers client counter
had somewhere to land. The route is intentionally open (see the
long doc comment); other /api/v1/* routes still gate on the bearer
token via their own call to the helper. Browser cannot send
AGENT_API_KEY without exposing the secret, and exposing it via
NEXT_PUBLIC_ would defeat the rest of the agent surface.
src/components/quote-form.tsx
- Replace `await generateQuoteNumber()` (the deprecated
read-then-write client counter from src/lib/pb-helpers.ts) with
a `fetch('/api/v1/quotes/next-number')`. Falls through to the
existing Math.random fallback if the endpoint errors. Removes
the race where two concurrent form submits could both pick the
same next number, since the server endpoint uses an atomic
quote_counters row under admin auth.
- Drop `generateQuoteNumber` from the @/lib/pb-helpers import.
Note: the deprecated `generateQuoteNumber` function remains in
src/lib/pb-helpers.ts — it has zero callers now. Safe to delete in
a future commit (covered by the data-layer-discipline section in
AGENTS.md).
This commit is contained in:
parent
3d3a012174
commit
bd2a56ce9f
2 changed files with 425 additions and 396 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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]);
|
||||
|
||||
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<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 {
|
||||
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<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 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 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" />
|
||||
<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="space-y-3">
|
||||
{items.map((item) => (
|
||||
<QuoteFormItemCard
|
||||
key={item.id}
|
||||
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))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</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="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>
|
||||
);
|
||||
}
|
||||
"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 } 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]);
|
||||
|
||||
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<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 || "",
|
||||
// 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 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 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" />
|
||||
<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="space-y-3">
|
||||
{items.map((item) => (
|
||||
<QuoteFormItemCard
|
||||
key={item.id}
|
||||
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))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</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="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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue