fix(quotes): wire no-quote toggle into process-page UI gates

The server-side PDF route was already no-quote-aware — when
`quote.is_no_quote === true` it bypasses the pricing gate
(`pdf/route.ts:156, 238, 240`) and the PDF component renders "Not
quoted" cells + "—" totals (`quote-document.tsx:239, 242, 276/280/285`).
But the client side was ignoring the flag, so flipping the toggle
looked like a no-op. Five one-file fixes to close the gap:

1. `itemsReady` now short-circuits to true when no-quote is on.
   Before: `itemsReady = items.length > 0 && missingSupplier === 0 &&
   missingPricing === 0;` (always evaluated pricing). After: prepend
   `isNoQuote ||` so the Generate Quote button enables regardless of
   cost/selling prices. (Bug A)

2. `applyPricing` markup>0 guard now bypassed when no-quote is on.
   Before: toast "Markup must be greater than 0" + refuse save when
   markup ≤ 0 — blocked the operator from saving a 0/0/0 row. After:
   `if (!isNoQuote && ...)` so the operator can save the row. (Bug B)

3. `handlePricingChange` auto-derive now skipped when no-quote is on.
   The cost↔selling recompute on every keystroke (`value * (1 + markup
   / 100)` and inverse) was firing even when no-quote was set, which
   is what produced the "I set 0/0/1% markup and it shows 1 cent"
   artefact. Three branches (markup/cost/selling) now short-circuit
   to pass-through under no-quote. (Bug C)

4. Helper text under the Generate Quote PDF button now explains
   "No-quote letter — prices ignored on PDF (renders 'Not quoted'
   cells)" when the toggle is on, instead of the misleading
   "Price every item + assign a supplier before generating".

5. Second helper line under the toggle's label explaining the full
   UI flow when on: "the pricing form stays editable but isn't
   required, and the PDF renders 'Not quoted' cells with no totals."

Operator workflow now: flip toggle ON → enter description on a row
at 0/0/0 → save → click Generate Quote → PDF renders decline letter
with "Not quoted" cells and no totals. No PB migration needed
(`is_no_quote` boolean column already present per `pb-exports/pb_schema-2.json:2178`).

Build: clean. Typecheck: 0 errors. No tests added (manual flow; no
existing test exercises this toggle path).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-27 08:00:17 +02:00
parent a861de1fb6
commit 4d8382c87f

View file

@ -690,15 +690,12 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
const applyPricing = () => { const applyPricing = () => {
if (!pricingModal) return; if (!pricingModal) return;
// Markup must be present and strictly positive. A zero markup // Markup must be present and strictly positive — UNLESS this is a
// (cost = selling) is a giveaway, not a quote — the office // no-quote (decline-letter). In no-quote mode the PDF route
// can't run a business on it. A negative markup means we're // renders "Not quoted" cells and "—" totals, so a row at 0/0/0
// selling below cost, which the supplier PO and accounting // is exactly the target. Forcing markup > 0 would block the
// both reject. Surface a clear error and refuse to save; the // operator from saving the row, which is the bug Bug B fixes.
// operator can either type a positive value in the Markup if (!isNoQuote && (!Number.isFinite(pricingForm.markup) || pricingForm.markup <= 0)) {
// field or adjust the cost/selling pair so the recomputed
// markup lands above zero.
if (!Number.isFinite(pricingForm.markup) || pricingForm.markup <= 0) {
toast.error("Markup must be greater than 0 — adjust the cost or selling price so the markup is positive."); toast.error("Markup must be greater than 0 — adjust the cost or selling price so the markup is positive.");
return; return;
} }
@ -728,9 +725,14 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
) => { ) => {
setPricingForm((prev) => { setPricingForm((prev) => {
// Markup is always editable; it doesn't get locked. Recompute // Markup is always editable; it doesn't get locked. Recompute
// whichever side isn't locked. // whichever side isn't locked — UNLESS this is a no-quote
// (decline-letter). In no-quote mode the prices don't matter
// for the PDF render, so we let the operator type whatever
// they want without the form auto-recomputing the other side
// (the Bug C fix).
if (field === "markup") { if (field === "markup") {
const next = { ...prev, markup: value }; const next = { ...prev, markup: value };
if (isNoQuote) return next;
if (pricingLock === "cost") { if (pricingLock === "cost") {
next.selling_price = round2(prev.cost_price * (1 + value / 100)); next.selling_price = round2(prev.cost_price * (1 + value / 100));
} else { } else {
@ -742,21 +744,25 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
// Typing into cost — if selling is locked, switch the lock to cost. // Typing into cost — if selling is locked, switch the lock to cost.
if (field === "cost_price") { if (field === "cost_price") {
if (pricingLock === "selling") setPricingLock("cost"); if (pricingLock === "selling") setPricingLock("cost");
return { return isNoQuote
...prev, ? { ...prev, cost_price: value }
cost_price: value, : {
selling_price: round2(value * (1 + prev.markup / 100)), ...prev,
}; cost_price: value,
selling_price: round2(value * (1 + prev.markup / 100)),
};
} }
// Typing into selling — if cost is locked, switch the lock to selling. // Typing into selling — if cost is locked, switch the lock to selling.
// selling: field === "selling_price" // selling: field === "selling_price"
if (pricingLock === "cost") setPricingLock("selling"); if (pricingLock === "cost") setPricingLock("selling");
return { return isNoQuote
...prev, ? { ...prev, selling_price: value }
selling_price: value, : {
cost_price: prev.markup > -100 ? round2(value / (1 + prev.markup / 100)) : 0, ...prev,
}; selling_price: value,
cost_price: prev.markup > -100 ? round2(value / (1 + prev.markup / 100)) : 0,
};
}); });
}; };
@ -864,7 +870,12 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
const missingPricing = items.filter( const missingPricing = items.filter(
(i) => !(i.cost_price && i.selling_price), (i) => !(i.cost_price && i.selling_price),
).length; ).length;
const itemsReady = items.length > 0 && missingSupplier === 0 && missingPricing === 0; // No-quote (decline-letter) mode bypasses the pricing gate entirely
// — the PDF route renders every line with "Not quoted" cells and
// "—" totals, so a row with cost=0 / selling=0 / markup=0 is
// exactly what we want. See Bug A in the no-quote findings.
const itemsReady =
isNoQuote || (items.length > 0 && missingSupplier === 0 && missingPricing === 0);
return ( return (
<div className="space-y-4 max-w-7xl mx-auto"> <div className="space-y-4 max-w-7xl mx-auto">
@ -969,6 +980,11 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Send this as an official decline. Prices will be ignored. Send this as an official decline. Prices will be ignored.
</p> </p>
<p className="text-xs text-muted-foreground">
When on: the pricing form stays editable but isn&apos;t
required, and the PDF renders &quot;Not quoted&quot;
cells with no totals.
</p>
</div> </div>
<Switch <Switch
id="is_no_quote" id="is_no_quote"
@ -1636,9 +1652,11 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
<div> <div>
<p className="text-sm font-medium">Generate Quote PDF</p> <p className="text-sm font-medium">Generate Quote PDF</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{itemsReady {isNoQuote
? "Select a letterhead template" ? "No-quote letter — prices ignored on PDF (renders 'Not quoted' cells)"
: `Price every item + assign a supplier before generating (${missingPricing} unpriced, ${missingSupplier} missing supplier)`} : itemsReady
? "Select a letterhead template"
: `Price every item + assign a supplier before generating (${missingPricing} unpriced, ${missingSupplier} missing supplier)`}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">