From 4d8382c87f78c8b58201726e77d36241669bbf54 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 27 Aug 2026 08:00:17 +0200 Subject: [PATCH] fix(quotes): wire no-quote toggle into process-page UI gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/(app)/quotes/[id]/process/page.tsx | 66 ++++++++++++++-------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/src/app/(app)/quotes/[id]/process/page.tsx b/src/app/(app)/quotes/[id]/process/page.tsx index b473296..99ade94 100644 --- a/src/app/(app)/quotes/[id]/process/page.tsx +++ b/src/app/(app)/quotes/[id]/process/page.tsx @@ -690,15 +690,12 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str const applyPricing = () => { if (!pricingModal) return; - // Markup must be present and strictly positive. A zero markup - // (cost = selling) is a giveaway, not a quote — the office - // can't run a business on it. A negative markup means we're - // selling below cost, which the supplier PO and accounting - // both reject. Surface a clear error and refuse to save; the - // operator can either type a positive value in the Markup - // field or adjust the cost/selling pair so the recomputed - // markup lands above zero. - if (!Number.isFinite(pricingForm.markup) || pricingForm.markup <= 0) { + // Markup must be present and strictly positive — UNLESS this is a + // no-quote (decline-letter). In no-quote mode the PDF route + // renders "Not quoted" cells and "—" totals, so a row at 0/0/0 + // is exactly the target. Forcing markup > 0 would block the + // operator from saving the row, which is the bug Bug B fixes. + if (!isNoQuote && (!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."); return; } @@ -728,9 +725,14 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str ) => { setPricingForm((prev) => { // 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") { const next = { ...prev, markup: value }; + if (isNoQuote) return next; if (pricingLock === "cost") { next.selling_price = round2(prev.cost_price * (1 + value / 100)); } 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. if (field === "cost_price") { if (pricingLock === "selling") setPricingLock("cost"); - return { - ...prev, - cost_price: value, - selling_price: round2(value * (1 + prev.markup / 100)), - }; + return isNoQuote + ? { ...prev, cost_price: value } + : { + ...prev, + cost_price: value, + selling_price: round2(value * (1 + prev.markup / 100)), + }; } // Typing into selling — if cost is locked, switch the lock to selling. // selling: field === "selling_price" if (pricingLock === "cost") setPricingLock("selling"); - return { - ...prev, - selling_price: value, - cost_price: prev.markup > -100 ? round2(value / (1 + prev.markup / 100)) : 0, - }; + return isNoQuote + ? { ...prev, selling_price: value } + : { + ...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( (i) => !(i.cost_price && i.selling_price), ).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 (
@@ -969,6 +980,11 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str

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

+

+ When on: the pricing form stays editable but isn't + required, and the PDF renders "Not quoted" + cells with no totals. +

Generate Quote PDF

- {itemsReady - ? "Select a letterhead template" - : `Price every item + assign a supplier before generating (${missingPricing} unpriced, ${missingSupplier} missing supplier)`} + {isNoQuote + ? "No-quote letter — prices ignored on PDF (renders 'Not quoted' cells)" + : itemsReady + ? "Select a letterhead template" + : `Price every item + assign a supplier before generating (${missingPricing} unpriced, ${missingSupplier} missing supplier)`}