feat(delivery-notes): one linear flow control, and fix the quote lookup
The operator asked for one action at the end of the row that walks her through the work, instead of a strip of unlabelled icons. [ Create quote ] [ Send ] [ Create invoice ] Once a quote exists the FIRST slot becomes the quote number itself, clickable, so she never hunts for where the quote went. Clicking it opens the quote PDF in a popup with Print and Download — she stays on the delivery-notes list, which is the whole point. Deliberately not a fixed step 1 -> 2 -> 3: the counter also has customer-to-customer work that never needs a quote, so "Create invoice" is always offered and never depends on the quote having happened. A fixed sequence would strand that path. Send is mounted in place rather than by navigating to the quote page, using the existing purpose-built modal (loud To field, alt-email toggle, rep auto-CC, PDF attached, retry banner on a bounce). The quote fetch now expands `customer,company` because that modal reads `quote.expand.customer.rep` to pre-fill CC — without it, sends went out silently with no CC and the office never saw a copy. Everything else (signed copy, capture PO, mark invoiced, cancel, regenerate PDF) stays on the detail page. That is a deliberate hierarchy, not the drift this component was extracted to prevent: the list is for scanning plus the three things you do to every note. --- the bug this uncovered --- The flow control could never show a quote, because the quote map was always empty. `getQuotesForDeliveryNotes` built ONE PocketBase filter of `delivery_note = "x" || ... ` across every id. The list fetches 500 notes, so that filter carried ~250 clauses, and PocketBase rejects it with a bare `400 Something went wrong`. Measured on this instance: 75 clauses succeed, 100 fail. The caller treats that fetch as non-fatal and swallows the error, so it was completely silent. For the life of the feature: - 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. A whole session of 3676 tests passed without catching it — they all use a handful of ids, far under the limit. Only driving the real app surfaced it. Now chunked at 50 ids per request. Five sequential requests for a 250-note list is nothing next to the full delivery_notes page already fetched alongside it. Verified in the browser as David Els: DN26100599 now shows Q202610102 in slot 1, Send is enabled, and clicking the quote opens the generated PDF in a popup showing the priced line (1/2" X 2 1/4" UNF BOLT, qty 10, R150.00, total R1 500.00) with Print and Download. Full suite 3676 passing, tsc clean.
This commit is contained in:
parent
7ce23e04f3
commit
afae9cdd99
3 changed files with 288 additions and 108 deletions
|
|
@ -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() {
|
|||
<DeliveryNoteActions
|
||||
note={note}
|
||||
hasQuote={!!quote}
|
||||
variant="icons"
|
||||
quoteNumber={quote?.quote_number}
|
||||
variant="flow"
|
||||
busy={busyIds.has(note.id)}
|
||||
primary={
|
||||
note.status === "sent"
|
||||
? "capture-po"
|
||||
: note.status === "po_received"
|
||||
? "mark-invoiced"
|
||||
: undefined
|
||||
}
|
||||
onAction={{
|
||||
"preview-dn": () => 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}`),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
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 && (
|
||||
<SendQuoteModal
|
||||
key={`dn-send-${sendModal.quoteId}`}
|
||||
open={sendModal.open}
|
||||
onOpenChange={(v) => 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 && (
|
||||
<QuoteReviewDialog
|
||||
key={quoteReview.noteId}
|
||||
|
|
|
|||
|
|
@ -208,10 +208,127 @@ export const DN_ACTIONS: DnActionSpec[] = [
|
|||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The LINEAR FLOW control — the one the counter operator uses on
|
||||
* every note.
|
||||
*
|
||||
* Three labelled buttons, left to right, in the order the work
|
||||
* happens:
|
||||
*
|
||||
* [ Create quote ] [ Send ] [ Create invoice ]
|
||||
*
|
||||
* Once a quote exists the first button IS the quote — it becomes
|
||||
* the quote number, clickable, so the operator never has to hunt for
|
||||
* "where did that quote go". The same slot, the same place, every
|
||||
* time; it just says the next useful thing.
|
||||
*
|
||||
* Deliberately NOT a fixed sequence: the counter also has
|
||||
* customer-to-customer work that never needs a quote, so "Invoice"
|
||||
* is always offered and never depends on the quote step having
|
||||
* happened. A fixed "step 1 → 2 → 3" would strand that path.
|
||||
*
|
||||
* Everything else (signed copy, capture PO, mark invoiced, cancel,
|
||||
* regenerate PDF) stays on the detail page. That is a deliberate
|
||||
* hierarchy, not the drift this component was extracted to prevent:
|
||||
* the list is for scanning and the three things you do to every
|
||||
* note; the detail page is where you do everything to one note.
|
||||
* The full set is still reachable in one click, and is still the same
|
||||
* component deciding what applies.
|
||||
*/
|
||||
function FlowControl({
|
||||
ctx,
|
||||
note,
|
||||
hasQuote,
|
||||
quoteNumber,
|
||||
onAction,
|
||||
busy,
|
||||
disabled,
|
||||
}: {
|
||||
ctx: DnActionContext;
|
||||
note: Pick<DeliveryNote, "id" | "status" | "signed_copy">;
|
||||
hasQuote: boolean;
|
||||
quoteNumber?: string;
|
||||
onAction: Partial<Record<DnActionId, () => void>>;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const isLive = ctx.status !== "completed" && ctx.status !== "cancelled";
|
||||
const off = busy || disabled;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5 whitespace-nowrap">
|
||||
{/* Slot 1 — the thing to do next. Quote link once it exists. */}
|
||||
{hasQuote ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAction["preview-quote"]}
|
||||
disabled={off}
|
||||
data-testid={`dn-flow-quote-${note.id}`}
|
||||
title={`Open quote ${quoteNumber ?? ""} — click to preview the PDF`}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-info/40 bg-info/10 px-2.5 py-1 font-mono text-xs font-medium text-info transition-colors hover:bg-info/20 disabled:opacity-50"
|
||||
>
|
||||
{quoteNumber}
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onAction["create-quote"]}
|
||||
disabled={off || !isLive}
|
||||
data-testid={`dn-flow-create-quote-${note.id}`}
|
||||
title="Review the pricing, then create the quote"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
>
|
||||
Create quote
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onAction.send}
|
||||
disabled={off || !hasQuote || !isLive}
|
||||
data-testid={`dn-flow-send-${note.id}`}
|
||||
title={
|
||||
hasQuote
|
||||
? "Send the delivery note and quote to the customer"
|
||||
: "Create a quote first — there is nothing to send yet"
|
||||
}
|
||||
className="h-7 px-2.5 text-xs"
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
|
||||
{/* Slot 3 — the customer-to-customer path, always available
|
||||
while the note is live. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onAction["invoice-qb"]}
|
||||
disabled={off || !isLive}
|
||||
data-testid={`dn-flow-invoice-${note.id}`}
|
||||
title="Create the invoice in QuickBooks"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
>
|
||||
Create invoice
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DeliveryNoteActionsProps {
|
||||
note: Pick<DeliveryNote, "id" | "status" | "signed_copy">;
|
||||
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<Record<DnActionId, () => 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 (
|
||||
<FlowControl
|
||||
ctx={ctx}
|
||||
note={note}
|
||||
hasQuote={hasQuote}
|
||||
quoteNumber={quoteNumber}
|
||||
onAction={onAction}
|
||||
busy={busy}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const shown = DN_ACTIONS.filter(
|
||||
(a) => a.available(ctx) && typeof onAction[a.id] === "function",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1129,37 +1129,67 @@ export async function getQuotesForDeliveryNotes(
|
|||
const out = new Map<string, Quote>();
|
||||
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<Quote>(
|
||||
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<Quote>(
|
||||
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<string> {
|
||||
const pb = getPocketBase();
|
||||
const now = new Date();
|
||||
|
|
|
|||
Loading…
Reference in a new issue