433 lines
14 KiB
Markdown
433 lines
14 KiB
Markdown
|
|
# 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`:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
"pdf-lib": "^1.17.1"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Install**
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm install
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: installs without peer dependency errors.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Verify**
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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`:
|
|||
|
|
|
|||
|
|
```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:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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?
|
|||
|
|
|