diff --git a/src/app/(app)/quotes/[id]/process/page.tsx b/src/app/(app)/quotes/[id]/process/page.tsx index 4adabda..803fb76 100644 --- a/src/app/(app)/quotes/[id]/process/page.tsx +++ b/src/app/(app)/quotes/[id]/process/page.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { Table, TableBody, @@ -136,6 +137,18 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str const [inboundAttachments, setInboundAttachments] = useState([]); const [addItemOpen, setAddItemOpen] = useState(false); + /** + * No-quote toggle — operator flips this when the customer declines or + * the RFQ is out-of-scope, and we want to send an official decline + * letter instead of a priced quote. PATCHes `quotes.is_no_quote` via + * `/api/v1/quotes/${quote.id}`. When flipped on, scrolls the + * `quote_notes` editor into view so the operator can type the reason + * in the same flow (the PDF route bypasses pricing gates when this + * flag is set — see Task 7). + */ + const [isNoQuote, setIsNoQuote] = useState(!!quote?.is_no_quote); + const [noQuoteSaving, setNoQuoteSaving] = useState(false); + /** * Per-row "🔍 Lookup" — the inline catalogue matcher. The * operator types a SKU into the row's SKU input, hits the small @@ -743,6 +756,40 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str } }; + /** + * No-quote toggle handler. Optimistic update with rollback on error. + * When flipped on (true), scrolls the `quote_notes` editor into view + * so the operator can type the reason in the same flow. + */ + const onToggleNoQuote = useCallback(async (next: boolean) => { + if (!quote) return; + const previous = isNoQuote; + setIsNoQuote(next); // optimistic + setNoQuoteSaving(true); + try { + const res = await fetch(`/api/v1/quotes/${quote.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ is_no_quote: next }), + }); + if (!res.ok) { + setIsNoQuote(previous); // rollback + toast.error("Couldn't update — try again."); + } else if (next) { + // Scroll the quote_notes editor into view so the operator can + // type the reason immediately. + document + .getElementById("quote-notes-card") + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } catch { + setIsNoQuote(previous); + toast.error("Couldn't update — try again."); + } finally { + setNoQuoteSaving(false); + } + }, [isNoQuote, quote]); + /** * Swap the customer on this quote. Called by the `` * dropdown in the header. PATCHes the quote on the server, refreshes @@ -886,6 +933,30 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str

Created: {formatDate(quote.created)}

Description: {(quote.description || "—").slice(0, 60)}

+
+
+ +

+ Send this as an official decline. Prices will be ignored. +

+
+ +
@@ -1526,11 +1597,15 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str {/* Operator note — saved to `quote.quote_notes` and rendered on the customer PDF (blue callout below the totals). Sits above the Generate PDF card so the operator can edit and then - generate in one continuous flow. */} - + generate in one continuous flow. The wrapping div carries the + `quote-notes-card` id so the no-quote toggle can scrollIntoView + here when the operator flips the toggle on. */} +
+ +
{/* Generate PDFs */}