From 58831683d474ffc17ebca6709c7542d5089c3b6d Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 9 Oct 2026 09:27:09 +0200 Subject: [PATCH] feat(delivery-notes): edit the note before quoting, and fix the QuickBooks error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things, three of them the operator asked for and one a bug I introduced earlier in this session. ## Delivery note editor, first in the flow New "Delivery note" button ahead of "Create quote": quantity, cost, markup and selling price per line, one Save for the whole note. Sits FIRST deliberately. The quote is priced from the DN's lines, so a wrong quantity or a stale cost here becomes a wrong number on a customer-facing document. Fixing it at the source is the point. It saves through the existing `POST /api/v1/delivery-notes/items/[id]/pricing`, which takes cost + selling + quantity in one call and re-renders the DN PDF. No new endpoint. One Save rather than per-line saves, so a half-typed cost can't be left behind on one line while the others commit. Cost and markup are the inputs; selling is derived, same rule as the quote review modal. A line that already carries a selling price KEEPS it and shows the markup that price actually implies — verified live: a line at R259.74 selling on R200 cost opened showing 29.87%, not the 25% default, so a deliberate price is never overwritten. ## The QuickBooks invoice error The modal refused to send when the order number was empty, and a counter note has no parent order, so it opened blank and blocked. The operator could not tell that an empty field was the only thing wrong. The field now seeds with the DELIVERY NOTE number (which is what `order_number` did before, so nothing changes for notes that have one). QuickBooks needs a number and the DN number is real, unique and customer-recognisable. Still editable. Verified live: a note with no order now opens with DN26100814. ## The signed copy — stated, not toggled The operator asked for an option to include the signed copy. There isn't one to offer: when a signed copy exists it REPLACES the rendered PDF at quote-creation time, so the attachment was already the signed document. A checkbox there would be a control that cannot change anything — the exact complaint that started this work. Instead the send modal now says so plainly when the outgoing PDF is the customer's signed copy. ## My bug The editor's first fetch was a bare `fetch()` with no Authorization header. `assertBrowserSession` 401s without it — the same mistake as earlier this session, caught in the browser because the dialog hung on "Loading items…". Fixed and re-verified. Full suite 3817 passing, tsc clean, no lint warnings. --- src/app/(app)/delivery-notes/[id]/page.tsx | 8 +- src/app/(app)/delivery-notes/page.tsx | 76 +++- .../delivery-notes/action-buttons.tsx | 17 + .../delivery-note-editor-dialog.tsx | 385 ++++++++++++++++++ src/components/quotes/send-quote-modal.tsx | 30 ++ 5 files changed, 507 insertions(+), 9 deletions(-) create mode 100644 src/components/delivery-notes/delivery-note-editor-dialog.tsx diff --git a/src/app/(app)/delivery-notes/[id]/page.tsx b/src/app/(app)/delivery-notes/[id]/page.tsx index 68dccf7..0f650ff 100644 --- a/src/app/(app)/delivery-notes/[id]/page.tsx +++ b/src/app/(app)/delivery-notes/[id]/page.tsx @@ -1953,7 +1953,13 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) { open={invoiceModal.open} onOpenChange={(v) => setInvoiceModal({ open: v, saving: false })} deliveryNoteId={deliveryNote.id} - orderNumber={deliveryNote.expand?.order?.order_number ?? ""} + // Same fallback as the list: a counter note has no parent + // order, so seed with the DN number rather than opening the + // field empty and blocking the send. + orderNumber={ + deliveryNote.expand?.order?.order_number ?? + deliveryNote.delivery_note_number + } customerQuerySeed={ deliveryNote.expand?.company?.company ?? deliveryNote.expand?.customer?.customer ?? diff --git a/src/app/(app)/delivery-notes/page.tsx b/src/app/(app)/delivery-notes/page.tsx index 826ad03..fb43279 100644 --- a/src/app/(app)/delivery-notes/page.tsx +++ b/src/app/(app)/delivery-notes/page.tsx @@ -20,6 +20,7 @@ import { import { CreateAdHocDeliveryNoteDialog } from "@/components/delivery-notes/create-adhoc-dialog"; import { DeliveryNoteItemsExpanded } from "@/components/delivery-notes/items-expanded"; import { DeliveryNoteActions } from "@/components/delivery-notes/action-buttons"; +import { DeliveryNoteEditorDialog } from "@/components/delivery-notes/delivery-note-editor-dialog"; import { LinkInvoiceDialog } from "@/components/delivery-notes/link-invoice-dialog"; import { QbInvoiceDialog } from "@/components/delivery-notes/qb-invoice-dialog"; import { SendQuoteModal } from "@/components/quotes/send-quote-modal"; @@ -44,6 +45,7 @@ import { getQuotesForDeliveryNotes, } from "@/lib/pb-helpers"; import getPocketBase from "@/lib/pocketbase"; +import { hasFile } from "@/lib/file-fields"; import { useAuth } from "@/components/auth-provider"; import { isRepUser } from "@/lib/rep-visibility"; import { fileNames } from "@/lib/file-fields"; @@ -234,6 +236,15 @@ export default function DeliveryNotesPage() { letterhead: string; }>({ open: false, noteId: "", quoteId: "", quoteNumber: "", letterhead: "" }); const [sendSubmitting, setSendSubmitting] = useState(false); + // Which notes have a signed copy on file, so the send modal can say + // so. Cheap to compute from data already fetched. + const signedCopyDnIds = useMemo(() => { + const ids = new Set(); + for (const n of notes) { + if (hasFile(n.signed_copy)) ids.add(n.id); + } + return ids; + }, [notes]); const openSendModal = useCallback( (note: DeliveryNote, quote: Quote) => { @@ -844,6 +855,20 @@ export default function DeliveryNotesPage() { dnNumber: string; } | null>(null); + const [noteEditor, setNoteEditor] = useState<{ + open: boolean; + noteId: string; + number: string; + }>({ open: false, noteId: "", number: "" }); + + const openNoteEditor = useCallback((note: DeliveryNote) => { + setNoteEditor({ + open: true, + noteId: note.id, + number: note.delivery_note_number, + }); + }, []); + const openLinkModal = useCallback((note: DeliveryNote) => { setLinkModal({ open: true, @@ -927,6 +952,7 @@ export default function DeliveryNotesPage() { // Slot 1 — "Create quote", or the quote itself once it // exists. The popup means she never leaves this list to // read a quote. + "delivery-note": () => openNoteEditor(note), "create-quote": () => openQuoteReview(note), "preview-quote": () => quote && setPreviewQuote(quote), // Slot 2 — send, in a modal on this screen. @@ -944,14 +970,18 @@ export default function DeliveryNotesPage() { ); }, - // `openPoModal` / `handleMarkInvoiced` are no longer referenced - // here: the flow control offers quote / send / invoice only. Those - // two actions live on the detail page, which still renders the - // full set from the same component. // `router` is no longer referenced: every action now happens in - // place (quote review, send, invoice, link-invoice) rather than - // by navigating. - [busyIds, openInvoiceModal, openLinkModal, openQuoteReview, openSendModal, quoteByDnId], + // place (edit note, quote review, send, invoice, link-invoice) + // rather than by navigating away from the list. + [ + busyIds, + openInvoiceModal, + openLinkModal, + openNoteEditor, + openQuoteReview, + openSendModal, + quoteByDnId, + ], ); const columns = useMemo( @@ -1748,6 +1778,23 @@ export default function DeliveryNotesPage() { on any row that doesn't have a quote yet. The dialog POSTs the create itself with the operator's confirmed pricing; `handleQuoteCreated` only refreshes the row behind it. */} + {/* Edit the note's own lines before quoting. */} + {noteEditor.noteId && ( + setNoteEditor((prev) => ({ ...prev, open: v }))} + deliveryNoteId={noteEditor.noteId} + deliveryNoteNumber={noteEditor.number} + onSaved={() => { + setNoteEditor((prev) => ({ ...prev, open: false })); + // Refresh so the next quote review starts from what was + // just saved rather than the values fetched at page load. + void fetchNotes(); + }} + /> + )} + {/* Link an existing QuickBooks invoice (the backlog path). */} {linkModal.noteId && ( ({ ...prev, open: v })) } deliveryNoteId={invoiceModal.noteId} - orderNumber={invoiceModal.orderNumber} + // Fall back to the DELIVERY NOTE number. A counter note has + // no parent order, so the field opened empty and the modal's + // guard refused the send with "Order number is empty" — + // the operator had no way to tell that was the only thing + // wrong. QuickBooks invoices need a number, and the DN + // number is a real, unique, customer-recognisable one. She + // can still change it. + orderNumber={ + invoiceModal.orderNumber || invoiceModal.number + } customerQuerySeed={invoiceModal.customerQuery} onInvoiced={(updated) => { setInvoiceModal((prev) => ({ ...prev, open: false })); @@ -1845,6 +1901,10 @@ export default function DeliveryNotesPage() { onOpenChange={(v) => setSendModal((prev) => ({ ...prev, open: v }))} quote={quoteByDnId.get(sendModal.noteId) as Quote} outboundAttachments={[]} + // A signed copy REPLACES the rendered PDF when the quote is + // created, so tell her which document is going out rather + // than offering a checkbox that cannot change anything. + pdfIsSignedCopy={signedCopyDnIds.has(sendModal.noteId)} claimerName={null} submitting={sendSubmitting} onSend={async (payload) => { diff --git a/src/components/delivery-notes/action-buttons.tsx b/src/components/delivery-notes/action-buttons.tsx index 7a87fff..44a0661 100644 --- a/src/components/delivery-notes/action-buttons.tsx +++ b/src/components/delivery-notes/action-buttons.tsx @@ -49,6 +49,7 @@ import type { DeliveryNote, DeliveryNoteStatus } from "@/lib/types"; * icon or a labelled button. */ export type DnActionId = + | "delivery-note" | "create-quote" | "open-quote" | "preview-dn" @@ -258,6 +259,22 @@ function FlowControl({ return (
+ {/* Slot 0 — edit the delivery note itself: quantities, costs, + margins. Sits FIRST on purpose: the quote is priced from the + DN's lines, so anything wrong here becomes a wrong number on + a customer-facing document. Fix it at the source. */} + + {/* Slot 1 — the thing to do next. Quote link once it exists. */} {hasQuote ? ( + + + + + ); +} \ No newline at end of file diff --git a/src/components/quotes/send-quote-modal.tsx b/src/components/quotes/send-quote-modal.tsx index 0d1a1b7..0601cb9 100644 --- a/src/components/quotes/send-quote-modal.tsx +++ b/src/components/quotes/send-quote-modal.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { CheckCircle, + CheckCircle2, FileText, Image as ImageIcon, File as FileIcon, @@ -74,6 +75,18 @@ type Props = { quote: Quote; /** Outbound attachments (operator-uploaded proofs + per-item files). */ outboundAttachments: QuoteAttachment[]; + /** + * Set when the PDF attached to this quote is the CUSTOMER'S SIGNED + * COPY rather than a freshly rendered one. + * + * There is deliberately no toggle here. When a signed copy exists it + * replaces the rendered PDF at quote-creation time, so there is no + * choice to offer — a checkbox would be a control that cannot change + * anything, which is the exact thing the operator complained about + * on this screen. What they need is to KNOW which document is going + * out, so we say so plainly instead. + */ + pdfIsSignedCopy?: boolean; /** Rep's display name (used in the default signature). Pass null if unknown. */ claimerName: string | null; /** True while a request is in flight. Disables the footer button + all inputs. */ @@ -130,6 +143,7 @@ export function SendQuoteModal({ onOpenChange, quote, outboundAttachments, + pdfIsSignedCopy = false, claimerName, submitting, submitLabel = "Send", @@ -358,6 +372,19 @@ export function SendQuoteModal({ (!!quote.email_error && quote.status !== "sending"); const showSendingBanner = quote.status === "sending"; + // Say which document is actually going out when it's the signed + // copy, so "the PDF" isn't a guess. + const signedCopyNote = pdfIsSignedCopy ? ( +

+ + + The attached PDF is the customer's signed copy, + not a re-rendered one. Sending it attaches the document they + actually signed. + +

+ ) : null; + // Resolve which attachments the rep currently has selected. The PDF // is always included (can't uncheck the actual quote PDF). The // outbound files default-on; unchecking removes from the set. @@ -644,6 +671,9 @@ export function SendQuoteModal({

+ {/* Which document is going out, when it's the signed one. */} + {signedCopyNote} + {/* ── Attachments (PDF + outbound proofs) ─────────────── */} {includedAttachments.length > 0 && (