feat(quotes): add no-quote toggle to Quote Details card

Switch + Label inside the Quote Details card on the process page.
Optimistic update with rollback on error. When flipped on, scrolls
the quote_notes editor into view so the operator can type the
reason in the same flow.
This commit is contained in:
Dave 2026-08-26 23:43:15 +02:00
parent 6796dbea06
commit 0b6226cdf1

View file

@ -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<QuoteAttachment[]>([]);
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 `<CustomerSearch>`
* dropdown in the header. PATCHes the quote on the server, refreshes
@ -886,6 +933,30 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
</div>
<p className="text-sm"><span className="text-muted-foreground">Created:</span> {formatDate(quote.created)}</p>
<p className="text-sm"><span className="text-muted-foreground">Description:</span> {(quote.description || "—").slice(0, 60)}</p>
<div
className={cn(
"mt-3 flex items-center justify-between rounded-md border p-3",
isNoQuote
? "border-amber-200 bg-amber-50"
: "border-transparent bg-transparent",
)}
>
<div className="space-y-0.5">
<Label htmlFor="is_no_quote" className="text-sm font-medium">
No-quote letter
</Label>
<p className="text-xs text-muted-foreground">
Send this as an official decline. Prices will be ignored.
</p>
</div>
<Switch
id="is_no_quote"
checked={isNoQuote}
onCheckedChange={onToggleNoQuote}
disabled={noQuoteSaving}
size="sm"
/>
</div>
</CardContent>
</Card>
<Card>
@ -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. */}
<QuoteNoteCard
quoteId={quote.id}
initialNotes={quote.quote_notes ?? ""}
/>
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. */}
<div id="quote-notes-card">
<QuoteNoteCard
quoteId={quote.id}
initialNotes={quote.quote_notes ?? ""}
/>
</div>
{/* Generate PDFs */}
<Card>