WFDASH-WEBAPP/docs/superpowers/plans/2026-07-09-invoice-print-bundle.md
SJCoetz 47b01bddfa feat: add picking slips, bulk print tracking, supplier map
- 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
2026-07-10 10:47:11 +02:00

14 KiB
Raw Permalink Blame History

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 with invoiceNumber.
  • Modify: package.json — add pdf-lib dependency.

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:

  1. read invoice number from order_item_details.invoice.number
  2. fetch(/api/invoicing/print-bundle?invoiceNumber=...)
  3. const blob = await res.blob() then URL.createObjectURL(blob) then window.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:

  1. Subagent-Driven (recommended) — dispatch a fresh subagent per task, review between tasks
  2. Inline Execution — execute tasks in this session with checkpoints

Which approach?