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 && (