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:
parent
6796dbea06
commit
0b6226cdf1
1 changed files with 80 additions and 5 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue