diff --git a/src/app/(app)/delivery-notes/page.tsx b/src/app/(app)/delivery-notes/page.tsx index 5ab3510..d2724f2 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 { SendQuoteModal } from "@/components/quotes/send-quote-modal"; import { QuoteReviewDialog } from "@/components/delivery-notes/quote-review-dialog"; import { PdfPreviewDialog, @@ -219,6 +220,34 @@ export default function DeliveryNotesPage() { customer: string; }>({ open: false, noteId: "", number: "", customer: "" }); + // Send DN + quote, in a modal on THIS screen. The operator's whole + // point is not to leave the list. The quote must carry its customer + // expanded — `SendQuoteModal` reads `quote.expand.customer.rep` to + // pre-fill the rep on CC. + const [sendModal, setSendModal] = useState<{ + open: boolean; + noteId: string; + quoteId: string; + quoteNumber: string; + letterhead: string; + }>({ open: false, noteId: "", quoteId: "", quoteNumber: "", letterhead: "" }); + const [sendSubmitting, setSendSubmitting] = useState(false); + + const openSendModal = useCallback( + (note: DeliveryNote, quote: Quote) => { + setSendModal({ + open: true, + noteId: note.id, + quoteId: quote.id, + quoteNumber: quote.quote_number, + letterhead: quote.company_letterhead || "CNL", + }); + }, + [], + ); + + + // PDF preview dialog state. The D icon on every row opens // this instead of opening the PDF in a new tab — operator // stays in the list, can compare two DNs side by side, doesn't @@ -732,20 +761,6 @@ export default function DeliveryNotesPage() { [fetchNotes, markBusy, markIdle, quoteByDnId], ); - // ── Action: Capture Customer PO ───────────────────────────────── - // Opens the modal pre-filled with any existing PO (so a re-open - // to fix a typo is one-keystroke-friendly). Submission calls the - // API route, which stamps the PO and flips status to - // "po_received" (or just stamps if already past that status). - const openPoModal = useCallback((note: DeliveryNote) => { - setPoModal({ - open: true, - noteId: note.id, - value: note.customer_po ?? "", - saving: false, - }); - }, []); - const submitPoModal = useCallback(async () => { if (!poModal.noteId) return; const trimmed = poModal.value.trim(); @@ -790,48 +805,6 @@ export default function DeliveryNotesPage() { } }, [fetchNotes, markBusy, markIdle, poModal]); - // ── Action: Mark Invoiced ─────────────────────────────────────── - // Flips the order_items to "ready_invoice" so they show up in - // the Invoicing page, and flips the delivery note's status to - // "invoiced". The actual invoice creation still happens through - // the Invoicing page. - const handleMarkInvoiced = useCallback( - async (note: DeliveryNote) => { - markBusy(note.id); - try { - const res = await fetch(`/api/v1/delivery-notes/${note.id}/mark-invoiced`, { - method: "POST", - }); - if (!res.ok) { - let message = `Failed to mark as invoiced (${res.status})`; - try { - const data: unknown = await res.json(); - if ( - data && - typeof data === "object" && - "error" in data && - typeof (data as { error?: unknown }).error === "string" - ) { - message = (data as { error: string }).error; - } - } catch { - // ignore non-json - } - toast.error(message); - return; - } - toast.success( - `Delivery note ${note.delivery_note_number} marked as invoiced. The items now appear in the Invoicing page.`, - ); - await fetchNotes(); - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : "Failed to mark as invoiced"); - } finally { - markIdle(note.id); - } - }, - [fetchNotes, markBusy, markIdle], - ); // ── Status-driven action button ───────────────────────────────── // One button per row, swapped by the delivery note's status. The @@ -889,42 +862,33 @@ export default function DeliveryNotesPage() { setPreviewDnNumber(note.delivery_note_number), - "preview-quote": () => quote && setPreviewQuote(quote), + // Slot 1 — "Create quote", or the quote itself once it + // exists. The popup means she never leaves this list to + // read a quote. "create-quote": () => openQuoteReview(note), - send: () => { - if (quote) router.push(`/quotes/${quote.id}?sendNow=true`); - }, - "capture-po": () => openPoModal(note), - "mark-invoiced": () => void handleMarkInvoiced(note), + "preview-quote": () => quote && setPreviewQuote(quote), + // Slot 2 — send, in a modal on this screen. + send: () => quote && openSendModal(note, quote), + // Slot 3 — the customer-to-customer path, always + // available; no quote required. Opens the QuickBooks + // modal on the note's own page via ?invoice=1, which is + // one click and lands ready to use. "invoice-qb": () => router.push(`/delivery-notes/${note.id}?invoice=1`), - "mark-complete": () => - router.push(`/delivery-notes/${note.id}`), - cancel: () => router.push(`/delivery-notes/${note.id}`), }} /> ); }, - [ - busyIds, - openPoModal, - openQuoteReview, - handleMarkInvoiced, - quoteByDnId, - router, - ], + // `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. + [busyIds, openQuoteReview, openSendModal, quoteByDnId, router], ); const columns = useMemo( @@ -1721,6 +1685,59 @@ 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. */} + {/* Send DN + quote, in place. This is the existing + purpose-built modal (loud To field, alt-email toggle, rep + auto-CC, PDF attached, retry banner on a bounce) mounted + here so the operator never leaves the list to use it. */} + {sendModal.quoteId && ( + setSendModal((prev) => ({ ...prev, open: v }))} + quote={quoteByDnId.get(sendModal.noteId) as Quote} + outboundAttachments={[]} + claimerName={null} + submitting={sendSubmitting} + onSend={async (payload) => { + setSendSubmitting(true); + try { + const token = getPocketBase().authStore.token; + if (!token) + throw new Error( + "Your session has expired — please sign in again.", + ); + const res = await fetch( + `/api/v1/quotes/${sendModal.quoteId}/send-email`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token, + }, + body: JSON.stringify(payload), + }, + ); + const body = (await res.json().catch(() => ({}))) as { + error?: string; + }; + if (!res.ok) { + throw new Error( + body.error || `Failed to send (${res.status})`, + ); + } + toast.success(`Sending ${sendModal.quoteNumber} to the customer…`); + setSendModal((prev) => ({ ...prev, open: false })); + } catch (err: unknown) { + toast.error( + err instanceof Error ? err.message : "Failed to send the quote", + ); + } finally { + setSendSubmitting(false); + } + }} + /> + )} + {quoteReview.noteId && ( ; + hasQuote: boolean; + quoteNumber?: string; + onAction: Partial void>>; + busy: boolean; + disabled: boolean; +}) { + const isLive = ctx.status !== "completed" && ctx.status !== "cancelled"; + const off = busy || disabled; + + return ( +
+ {/* Slot 1 — the thing to do next. Quote link once it exists. */} + {hasQuote ? ( + + ) : ( + + )} + + {/* Slot 2 — send it. Needs a quote; there is nothing to attach + without one, so it is disabled rather than hidden, which + keeps the three slots in fixed positions. */} + + + {/* Slot 3 — the customer-to-customer path, always available + while the note is live. */} + +
+ ); +} + export interface DeliveryNoteActionsProps { note: Pick; hasQuote: boolean; - variant: "icons" | "buttons"; + /** Shown on the flow variant's quote link. Falls back to nothing. */ + quoteNumber?: string; + /** + * - "flow" — the three-button linear control (list rows) + * - "icons" — compact icon row + * - "buttons" — labelled full set (detail page) + */ + variant: "flow" | "icons" | "buttons"; /** Per-action handlers. An action with no handler is not rendered. */ onAction: Partial void>>; /** Actions currently in flight, for the spinner / disabled state. */ @@ -230,6 +347,7 @@ export interface DeliveryNoteActionsProps { export function DeliveryNoteActions({ note, hasQuote, + quoteNumber, variant, onAction, busy = false, @@ -239,6 +357,21 @@ export function DeliveryNoteActions({ testIdPrefix = "dn-action", }: DeliveryNoteActionsProps) { const ctx = dnActionContext(note, hasQuote); + + if (variant === "flow") { + return ( + + ); + } + const shown = DN_ACTIONS.filter( (a) => a.available(ctx) && typeof onAction[a.id] === "function", ); diff --git a/src/lib/pb-helpers.ts b/src/lib/pb-helpers.ts index 17cc65a..17262f5 100644 --- a/src/lib/pb-helpers.ts +++ b/src/lib/pb-helpers.ts @@ -1129,37 +1129,67 @@ export async function getQuotesForDeliveryNotes( const out = new Map(); if (deliveryNoteIds.length === 0) return out; const pb = getPocketBase(); - const clauses = deliveryNoteIds - .map((id) => id.replace(/"/g, '\\"')) - .map((id) => `delivery_note = "${id}"`) - .join(" || "); - // PB pages the result — for the realistic list size (≤50 - // rows on a 20-per-page table) one page is enough. If we - // ever hit a "no rows" response we still want the caller - // to get an empty map, not throw. - let page = 1; - // Generous cap: list pages won't show > 200 DNs; the cap - // exists so a malformed input doesn't loop forever. - while (page < 20) { - const result = await pb.collection("quotes").getList( - page, - 200, - { - filter: clauses, - sort: "-created", - }, - ); - for (const q of result.items) { - if (q.delivery_note && !out.has(q.delivery_note)) { - out.set(q.delivery_note, q); + + // CHUNKED, deliberately. + // + // This used to build ONE filter of `delivery_note = "x" || ... ` + // across every id. The list page fetches 500 delivery notes, so + // that filter carried ~250 clauses — and PocketBase rejects it + // with a bare `400 {"message":"Something went wrong..."}`. Measured + // against this instance: 75 clauses succeed, 100 fail. + // + // The caller treats this fetch as non-fatal and swallows the error, + // so the symptom was completely silent: `quoteByDnId` came back + // EMPTY, and therefore + // - the Q icon never switched to "open quote" mode, + // - `hasQuote` was always false, + // - the blue "quoted" row tint never appeared, and + // - the Send action could never be enabled. + // Found in the browser, after a whole session of tests passing: + // the unit tests all use a handful of ids, well under the limit. + // + // 50 is comfortably under the measured ceiling. Five sequential + // requests for a 250-note list is nothing next to the full + // delivery_notes page already being fetched alongside it. + const CHUNK = 50; + const ids = deliveryNoteIds.map((id) => id.replace(/"/g, '\\"')); + + for (let start = 0; start < ids.length; start += CHUNK) { + const slice = ids.slice(start, start + CHUNK); + const clauses = slice.map((id) => `delivery_note = "${id}"`).join(" || "); + + // PB pages the result — the caller only ever needs the most + // recent quote per note, and one page covers a 50-note chunk. + // Generous cap so a malformed input can't loop forever. + let page = 1; + while (page < 20) { + const result = await pb.collection("quotes").getList( + page, + 200, + { + filter: clauses, + sort: "-created", + // The delivery-notes list mounts `SendQuoteModal` directly, + // and that modal reads `quote.expand.customer.rep` to + // pre-fill the rep on CC. Without this expand it silently + // sends with no CC, so the office never sees a copy of what + // went out. + expand: "customer,company", + }, + ); + for (const q of result.items) { + if (q.delivery_note && !out.has(q.delivery_note)) { + out.set(q.delivery_note, q); + } } + if (result.items.length < 200) break; + page++; } - if (result.items.length < 200) break; - page++; } return out; } + export async function generateDeliveryNoteNumber(): Promise { const pb = getPocketBase(); const now = new Date();