- Add Supplier Map component to suppliers page and reorganize sidebar navigation - Implement picking slip PDF generation with supplier per line item and picked-by signature footer - Add invoice bulk print tracking API and view models to track printed invoice state - Extend company search and form to support email/telephone fields and improve searchability - Fix data table debounce race condition by using useRef for onSearch handler - Fix supplier page fetch race condition with debounced timeout - Reduce list page item counts to 10 per page for customers, companies, and suppliers - Update company search filter to include email and telephone in search queries - Add comprehensive test suite for new and existing components and APIs - Add pdf-lib dependency for PDF generation and update related tooling - Clean up unused code and fix minor linting issues
14 KiB
Invoice Print Bundle (2x Invoice + 1x Picking Slip) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a server-side print bundle endpoint that returns a single PDF containing 2 copies of the invoice (all pages) and 1 picking slip, generated by invoice number.
Architecture: Generate bundles on demand in a Next.js API route. The route looks up all invoiced order_items for a given invoice number, downloads the saved PocketBase invoice PDF once, duplicates it twice with pdf-lib, generates a modern picking slip PDF with @react-pdf/renderer, appends it, then streams one PDF back to the browser. The UI Print button calls this endpoint and opens the returned PDF blob.
Tech Stack: Next.js 16 route handlers (nodejs runtime), PocketBase admin client (getAdminPocketBase), pdf-lib for merging/duplicating, @react-pdf/renderer for picking slip PDF generation, Vitest.
File Structure
- Add:
src/app/api/invoicing/print-bundle/route.ts— new route handler that returns the combined PDF. - Add:
src/app/api/invoicing/print-bundle/route.test.ts— route-level tests with mocked PB fetch and mocked PDF generation. - Add:
src/app/(app)/invoicing/picking-slip.tsx— React PDF component that renders the picking slip layout. - Add:
src/app/(app)/invoicing/print-bundle.ts— pure helpers for building the bundle (download invoice PDF, duplicate pages, append slip). - Add:
src/app/(app)/invoicing/print-bundle.test.ts— unit tests for helper behavior (page counts, error cases). - Modify:
src/app/(app)/invoicing/page.tsx— change the Print button to call the bundle endpoint withinvoiceNumber. - Modify:
package.json— addpdf-libdependency.
Task 1: Add pdf-lib dependency
Files:
-
Modify:
package.json -
Step 1: Add dependency
Add to dependencies:
"pdf-lib": "^1.17.1"
- Step 2: Install
Run:
npm install
Expected: installs without peer dependency errors.
- Step 3: Verify
Run:
node -e "require('pdf-lib'); console.log('ok')"
Expected: prints ok.
Task 2: Implement picking slip React PDF component
Files:
-
Add:
src/app/(app)/invoicing/picking-slip.tsx -
Test:
src/app/(app)/invoicing/picking-slip.test.ts(optional if you prefer snapshot tests; otherwise covered via bundle test) -
Step 1: Create the data types
In src/app/(app)/invoicing/picking-slip.tsx define:
import React from "react";
import { Document, Page, StyleSheet, Text, View } from "@react-pdf/renderer";
export type PickingSlipHeader = {
orderNumber: string;
companyName: string;
customerName: string;
invoiceNumber: string;
date: string;
};
export type PickingSlipLine = {
id: string;
description: string;
ordered: number;
collected: number;
pickQty: number;
outstanding: number;
toStock: number;
};
export function PickingSlipPdf(props: { header: PickingSlipHeader; lines: PickingSlipLine[] }) {
const styles = StyleSheet.create({
page: { padding: 28, fontSize: 10 },
titleRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 10 },
title: { fontSize: 14, fontWeight: 700 },
meta: { color: "#475569" },
box: { borderWidth: 1, borderColor: "#e2e8f0", borderRadius: 8, padding: 10, marginBottom: 12 },
gridHeader: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: "#e2e8f0", paddingBottom: 6, marginBottom: 6 },
row: { flexDirection: "row", paddingVertical: 4, borderBottomWidth: 1, borderBottomColor: "#f1f5f9" },
colDesc: { width: "48%" },
colNum: { width: "10%", textAlign: "right" },
colNumWide: { width: "12%", textAlign: "right" },
headerCell: { color: "#64748b", fontWeight: 600 },
badge: { fontSize: 9, color: "#0f172a" },
});
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.titleRow}>
<View>
<Text style={styles.title}>Picking Slip</Text>
<Text style={styles.meta}>{`Order: ${props.header.orderNumber}`}</Text>
<Text style={styles.meta}>{`Company: ${props.header.companyName}`}</Text>
<Text style={styles.meta}>{`Customer: ${props.header.customerName}`}</Text>
</View>
<View>
<Text style={styles.meta}>{`Invoice: ${props.header.invoiceNumber}`}</Text>
<Text style={styles.meta}>{`Date: ${props.header.date}`}</Text>
</View>
</View>
<View style={styles.box}>
<View style={styles.gridHeader}>
<Text style={[styles.colDesc, styles.headerCell]}>Item</Text>
<Text style={[styles.colNum, styles.headerCell]}>Ordered</Text>
<Text style={[styles.colNum, styles.headerCell]}>Collected</Text>
<Text style={[styles.colNum, styles.headerCell]}>Pick</Text>
<Text style={[styles.colNumWide, styles.headerCell]}>Outstanding</Text>
<Text style={[styles.colNum, styles.headerCell]}>To Stock</Text>
</View>
{props.lines.map((line) => (
<View key={line.id} style={styles.row}>
<Text style={styles.colDesc}>{line.description}</Text>
<Text style={styles.colNum}>{line.ordered}</Text>
<Text style={styles.colNum}>{line.collected}</Text>
<Text style={styles.colNum}>{line.pickQty}</Text>
<Text style={styles.colNumWide}>{line.outstanding}</Text>
<Text style={styles.colNum}>{line.toStock}</Text>
</View>
))}
</View>
</Page>
</Document>
);
}
- Step 2: Add minimal validation helpers
Still in the same file, add:
export function computePickingSlipLine(input: {
id: string;
description: string;
ordered: number;
collected: number;
pickQty: number;
}): PickingSlipLine {
const ordered = Number.isFinite(input.ordered) ? input.ordered : 0;
const collected = Number.isFinite(input.collected) ? input.collected : 0;
const pickQty = Number.isFinite(input.pickQty) ? input.pickQty : 0;
return {
id: input.id,
description: input.description || "(no description)",
ordered,
collected,
pickQty,
outstanding: Math.max(ordered - pickQty, 0),
toStock: Math.max(collected - pickQty, 0),
};
}
Task 3: Create pure bundling helpers (download invoice, duplicate twice, append slip)
Files:
-
Add:
src/app/(app)/invoicing/print-bundle.ts -
Test:
src/app/(app)/invoicing/print-bundle.test.ts -
Step 1: Write failing tests for PDF page count math
Create src/app/(app)/invoicing/print-bundle.test.ts:
import { PDFDocument } from "pdf-lib";
import { duplicateAllPagesTwice } from "./print-bundle";
async function makePdfWithPages(count: number) {
const pdf = await PDFDocument.create();
for (let i = 0; i < count; i += 1) pdf.addPage();
return pdf.save();
}
it("duplicates all invoice pages twice", async () => {
const input = await makePdfWithPages(2);
const output = await duplicateAllPagesTwice(input);
const outDoc = await PDFDocument.load(output);
expect(outDoc.getPageCount()).toBe(4);
});
- Step 2: Run test to verify fail
Run:
npx vitest run "src/app/(app)/invoicing/print-bundle.test.ts"
Expected: FAIL because duplicateAllPagesTwice doesn’t exist.
- Step 3: Implement helpers
Create src/app/(app)/invoicing/print-bundle.ts:
import { PDFDocument } from "pdf-lib";
export async function duplicateAllPagesTwice(invoicePdfBytes: Uint8Array) {
const invoiceDoc = await PDFDocument.load(invoicePdfBytes);
const out = await PDFDocument.create();
const invoicePages = await out.copyPages(invoiceDoc, invoiceDoc.getPageIndices());
for (const p of invoicePages) out.addPage(p);
const invoicePages2 = await out.copyPages(invoiceDoc, invoiceDoc.getPageIndices());
for (const p of invoicePages2) out.addPage(p);
return out.save();
}
export async function appendPdf(outPdfBytes: Uint8Array, appendPdfBytes: Uint8Array) {
const outDoc = await PDFDocument.load(outPdfBytes);
const appendDoc = await PDFDocument.load(appendPdfBytes);
const pages = await outDoc.copyPages(appendDoc, appendDoc.getPageIndices());
for (const p of pages) outDoc.addPage(p);
return outDoc.save();
}
- Step 4: Run tests
Run:
npx vitest run "src/app/(app)/invoicing/print-bundle.test.ts"
Expected: PASS.
Task 4: Add /api/invoicing/print-bundle route handler
Files:
-
Add:
src/app/api/invoicing/print-bundle/route.ts -
Test:
src/app/api/invoicing/print-bundle/route.test.ts -
Step 1: Write failing route test for 400/404/409
Create src/app/api/invoicing/print-bundle/route.test.ts:
import { GET } from "./route";
it("returns 400 when invoiceNumber is missing", async () => {
const req = new Request("http://localhost/api/invoicing/print-bundle");
const res = await GET(req as any);
expect(res.status).toBe(400);
});
- Step 2: Implement route skeleton
Create src/app/api/invoicing/print-bundle/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { duplicateAllPagesTwice, appendPdf } from "@/app/(app)/invoicing/print-bundle";
import { renderToBuffer } from "@react-pdf/renderer";
import { PickingSlipPdf, computePickingSlipLine } from "@/app/(app)/invoicing/picking-slip";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const invoiceNumber = url.searchParams.get("invoiceNumber")?.trim() ?? "";
if (!invoiceNumber) {
return NextResponse.json({ error: "Missing invoiceNumber" }, { status: 400 });
}
const adminPb = await getAdminPocketBase();
const items = await adminPb.collection("order_items").getFullList({
filter: `order_item_details.invoice.number = "${invoiceNumber}" && item_status = "invoiced"`,
});
if (!items.length) {
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
}
const withPdf = items.find((it: any) => Boolean(it.qb_invoice_pdf));
if (!withPdf) {
return NextResponse.json({ error: "Invoice PDF not stored yet" }, { status: 409 });
}
const pbBase = (process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "").replace(/\/$/, "");
const collectionId = withPdf.collectionId ?? "order_items";
const recordId = withPdf.id;
const fileName = withPdf.qb_invoice_pdf;
const fileUrl = `${pbBase}/api/v1/files/${collectionId}/${recordId}/${fileName}`;
const invoiceRes = await fetch(fileUrl);
if (!invoiceRes.ok) {
return NextResponse.json({ error: `Failed to download invoice PDF (${invoiceRes.status})` }, { status: 502 });
}
const invoiceBytes = new Uint8Array(await invoiceRes.arrayBuffer());
const doubled = await duplicateAllPagesTwice(invoiceBytes);
const first = items[0] as any;
const header = {
orderNumber: first.order_number ?? first.orderNumber ?? "",
companyName: first.order_item_details?.invoice?.invoiceCompany ?? "",
customerName: first.customer_name ?? first.customerName ?? "",
invoiceNumber,
date: first.order_item_details?.invoice?.date ?? "",
};
const lines = items.map((it: any) => {
const ordered = Number(it.order_qty ?? 0);
const collected = Number(it.qty_collected ?? 0);
const pickQty = Math.min(collected, ordered);
return computePickingSlipLine({
id: it.id,
description: it.description ?? "",
ordered,
collected,
pickQty,
});
});
const slipBytes = await renderToBuffer(<PickingSlipPdf header={header} lines={lines} />);
const merged = await appendPdf(doubled, new Uint8Array(slipBytes));
return new NextResponse(Buffer.from(merged), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${invoiceNumber}-bundle.pdf"`,
"Cache-Control": "private, no-cache",
},
});
}
- Step 3: Run route test
Run:
npx vitest run "src/app/api/invoicing/print-bundle/route.test.ts"
Expected: PASS for the 400 test.
Task 5: Wire the Print button to call the bundle endpoint by invoice number
Files:
-
Modify:
src/app/(app)/invoicing/page.tsx -
Test:
src/app/(app)/invoicing/page.test.tsx -
Step 1: Add failing test
In src/app/(app)/invoicing/page.test.tsx, add a test asserting Print calls the route with invoice number and opens the returned blob.
- Step 2: Implement client handler
In page.tsx, update the print handler to:
- read invoice number from
order_item_details.invoice.number fetch(/api/invoicing/print-bundle?invoiceNumber=...)const blob = await res.blob()thenURL.createObjectURL(blob)thenwindow.open(...)
Task 6: Self-review + focused test run
- Step 1: Run targeted suite
Run:
npx vitest run "src/app/(app)/invoicing/print-bundle.test.ts" "src/app/api/invoicing/print-bundle/route.test.ts"
- Step 2: Run invoicing page tests
Run:
npx vitest run "src/app/(app)/invoicing/page.test.tsx"
Expected: PASS.
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-07-09-invoice-print-bundle.md.
Two execution options:
- Subagent-Driven (recommended) — dispatch a fresh subagent per task, review between tasks
- Inline Execution — execute tasks in this session with checkpoints
Which approach?