60 KiB
JEI Payments & Remittance 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 payment-batching workflow to the JEI invoice tracker: tick unpaid invoices, bundle them into a batch with a POP, view/edit batches in a Remittance tab, and reflect paid status on the existing Invoices tab.
Architecture: New JEI_payment_batches PocketBase collection + a payment_batch relation on JEI_invoices. Three Next.js Route Handlers for batch create / POP replace / unpay. Two new pages (/unpaid, /remittance). One modified page (/invoices for status colours). One modified nav (layout.tsx). Selection persists in localStorage.
Tech Stack: Next.js 14.2.15 (App Router, TS), PocketBase JS SDK 0.21.5 (superuser JWT), Tailwind 3.4. PB instance at https://pb.agent.apphub.co.za. No new runtime dependencies.
Global Constraints
- PB instance:
https://pb.agent.apphub.co.za. All scripts read token from.env.local(linePB_TOKEN=...). - Naming: all collections start with
JEI_. New collection name:JEI_payment_batches. - Schema rules: all four rule fields (
listRule,viewRule,createRule,updateRule,deleteRule) on every new collection set to""(superuser-only). - PB v0.39.8 API quirk (verified 2026-09-17, recorded in
NOTES.md): field options for select/relation go at the field root, NOT insideoptions. POST/api/collectionsuses keyfields, notschema. - PATCH round-trip "wakes up" newly created collections. If a collection 403s on read with empty
listRule, PATCH any rule field (even with same value) once to fix. - Port: dev server runs on 6002. Restart with
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"; npm run dev. - Server-side PB client: use the existing
getPb()fromapp/lib/pb.ts(already patched on 2026-09-18 to usebeforeSendinstead of the broken{fetch: ...}constructor option). - File path separator: Windows backslash in scripts. Use forward slash in
importpaths (Next.js convention). - No new runtime deps. Tailwind classes already available; no new packages.
- PB token expires ~24 h (per established practice). Migration script + API routes will need a fresh token. Refresh procedure: PB admin UI → DevTools → Application → Local Storage →
pocketbase_auth.token→ paste into.env.local.
File Structure
| Path | Role | Action |
|---|---|---|
scripts/add_payment_batch_field.py |
PB schema migration: create JEI_payment_batches, add payment_batch relation to JEI_invoices. Idempotent. |
NEW |
app/lib/types.ts |
TypeScript types. Add PaymentBatch interface + payment_batch field to Invoice. |
MODIFY |
app/api/payments/create/route.ts |
POST: validate input, create batch with POP upload, atomic PATCH invoices. | NEW |
app/api/payments/[id]/replace-pop/route.ts |
POST: replace a batch's POP. | NEW |
app/api/payments/[id]/unpay/route.ts |
POST: clear payment_batch on each invoice, then delete the batch. |
NEW |
app/unpaid/page.tsx |
Client component (uses 'use client'). Server-fetches unpaid invoices, then renders checkboxes, sticky footer, Create Payment modal. |
NEW |
app/remittance/page.tsx |
Server-fetches batches, renders batch cards with Open POP / Replace POP / Unpay buttons. Expandable invoice lists. | NEW |
app/invoices/page.tsx |
Add Status column + row colours (paid=green-50, unpaid=blue-50). | MODIFY |
app/layout.tsx |
Add Unpaid and Remittance nav links. |
MODIFY |
Task 1: PB schema migration script
Files:
- Create:
scripts/add_payment_batch_field.py - (No modify, no test — verified via PB admin UI afterwards)
Interfaces:
- Produces: PB collection
JEI_payment_batches(id captured for later tasks). Field onJEI_invoices.payment_batch(relation → that id).
Pre-flight (manual): Confirm PB_TOKEN in JEI/.env.local is fresh. If expired, refresh from https://pb.agent.apphub.co.za/_/ admin UI → DevTools → Application → Local Storage → pocketbase_auth.token → paste into .env.local. Print the JWT decode result so the user can see it's fresh.
- Step 1: Write the migration script
Create scripts/add_payment_batch_field.py with this exact content:
"""
Create JEI_payment_batches collection and add payment_batch relation to JEI_invoices.
Idempotent: skips creation if JEI_payment_batches already exists. Skips adding
payment_batch if it already exists on JEI_invoices.
PB v0.39.8 quirks (verified 2026-09-17):
- Field options go at FIELD ROOT, not inside options.
- POST /api/collections uses key 'fields', not 'schema'.
- GET hides field options for select/relation (they are stored).
- PATCH round-trip "wakes up" newly created collections.
Run: python scripts/add_payment_batch_field.py
"""
import os
import sys
import json
import urllib.request
from urllib.error import HTTPError
PB_URL = os.environ.get("PB_URL", "https://pb.agent.apphub.co.za")
PB_TOKEN = os.environ.get("PB_TOKEN", "")
if not PB_TOKEN:
env_path = os.path.join(os.path.dirname(__file__), "..", ".env.local")
if os.path.exists(env_path):
for line in open(env_path):
if line.startswith("PB_TOKEN="):
PB_TOKEN = line.strip().split("=", 1)[1]
if not PB_TOKEN:
sys.exit("ERROR: PB_TOKEN not set")
HEADERS = {"Authorization": PB_TOKEN, "Content-Type": "json"}
SUPERUSER_RULES = {"listRule": "", "viewRule": "", "createRule": "",
"updateRule": "", "deleteRule": ""}
def pb_get(path):
req = urllib.request.Request(PB_URL + path, headers=HEADERS, method="GET")
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def pb_post(path, body):
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(PB_URL + path, data=data, headers=HEADERS, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read()), None
except HTTPError as e:
try:
return None, json.loads(e.read())
except Exception:
return None, {"raw": str(e)}
def pb_patch(path, body):
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(PB_URL + path, data=data, headers=HEADERS, method="PATCH")
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read()), None
except HTTPError as e:
try:
return None, json.loads(e.read())
except Exception:
return None, {"raw": str(e)}
def find_collection(name):
items = pb_get("/api/collections?perPage=200")["items"]
for c in items:
if c["name"] == name:
return c
return None
def batches_schema():
return {
"name": "JEI_payment_batches",
"type": "base",
"fields": [
{"name": "batch_ref", "type": "text", "required": True,
"min": 1, "max": 50, "pattern": "^[A-Za-z0-9_-]+$"},
{"name": "payment_date", "type": "date", "required": True, "min": "", "max": ""},
{"name": "pop_file", "type": "file", "required": True,
"maxSelect": 1, "maxSize": 8388608,
"mimeTypes": ["application/pdf", "image/jpeg", "image/png"], "thumbs": []},
{"name": "notes", "type": "text", "required": False,
"min": 0, "max": 1000, "pattern": ""},
],
**SUPERUSER_RULES,
}
def payment_batch_field(batches_id):
# cascadeDelete: false — explicit unpay semantics, do not wipe invoices.
return {
"name": "payment_batch",
"type": "relation",
"required": False,
"collectionId": batches_id,
"cascadeDelete": False,
"minSelect": 0,
"maxSelect": 1,
}
def main():
print(f"PB_URL: {PB_URL}")
batches = find_collection("JEI_payment_batches")
if batches:
print(f"EXISTS: JEI_payment_batches (id={batches['id']})")
batches_id = batches["id"]
else:
result, err = pb_post("/api/collections", batches_schema())
if err:
sys.exit(f"ERROR creating JEI_payment_batches: {err}")
batches = result
batches_id = batches["id"]
print(f"CREATED: JEI_payment_batches (id={batches_id})")
# Find JEI_invoices
invoices = find_collection("JEI_invoices")
if not invoices:
sys.exit("ERROR: JEI_invoices not found. Run scripts/create_collections.py first.")
invoices_id = invoices["id"]
print(f"FOUND: JEI_invoices (id={invoices_id})")
# Check if payment_batch field already exists
existing_fields = invoices.get("fields") or []
has_field = any(f.get("name") == "payment_batch" for f in existing_fields)
if has_field:
print("EXISTS: JEI_invoices.payment_batch field")
else:
# Add the field via PATCH /api/collections/<invoices_id>
new_field = payment_batch_field(batches_id)
new_fields = list(existing_fields) + [new_field]
result, err = pb_patch(f"/api/collections/{invoices_id}", {"fields": new_fields})
if err:
sys.exit(f"ERROR adding payment_batch field: {err}")
print(f"ADDED: JEI_invoices.payment_batch relation -> {batches_id}")
# Apply the "PATCH round-trip wake-up" to both collections just in case.
# This fixes any 403s where listRule LOOKS empty but server still restricts.
for coll in (batches, invoices):
cid = coll["id"]
# No-op PATCH with empty rules
_, err = pb_patch(f"/api/collections/{cid}", SUPERUSER_RULES)
if err:
print(f"WARN: wake-up PATCH failed for {cid}: {err}")
else:
print(f"PATCHED: {cid} (wake-up round-trip)")
print("DONE: schema ready.")
if __name__ == "__main__":
main()
- Step 2: Verify Python and the file exist
Run from PowerShell:
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
Get-Content scripts/add_payment_batch_field.py | Select-Object -First 3
Expected: prints the docstring opening line.
- Step 3: Run the migration
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
python scripts/add_payment_batch_field.py
Expected output (something close to):
PB_URL: https://pb.agent.apphub.co.za
CREATED: JEI_payment_batches (id=pbc_xxxxx)
FOUND: JEI_invoices (id=pbc_yyyyy)
ADDED: JEI_invoices.payment_batch relation -> pbc_xxxxx
PATCHED: pbc_xxxxx (wake-up round-trip)
PATCHED: pbc_yyyyy (wake-up round-trip)
DONE: schema ready.
If JEI_payment_batches or payment_batch already exists, the script prints EXISTS: and is a no-op for that part — safe to re-run.
- Step 4: Verify via PB admin UI (manual)
Open https://pb.agent.apphub.co.za/_/ → Collections. Confirm:
-
JEI_payment_batchesexists with fields:batch_ref(text),payment_date(date),pop_file(file),notes(text). -
JEI_invoiceshas apayment_batchrelation field pointing toJEI_payment_batches, not required, max select 1. -
Step 5: Commit
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
git add scripts/add_payment_batch_field.py
git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: add JEI_payment_batches schema migration script"
Task 2: Update TypeScript types
Files:
-
Modify:
app/lib/types.ts:9-48 -
Step 1: Add
PaymentBatchinterface and updateInvoice
Open app/lib/types.ts. Add the PaymentBatch interface after the existing InvoiceLine interface (after line 48). Also add payment_batch to the Invoice interface.
In the Invoice interface, add this line between pdf_file: string; (line 25) and created: string; (line 26):
payment_batch: string; // relation -> PaymentBatch.id; "" when unpaid
After the InvoiceLine interface block, add:
export interface PaymentBatch {
id: string;
batch_ref: string;
payment_date: string; // ISO date "YYYY-MM-DD"
pop_file: string; // PB file URL (relative path)
notes: string;
created: string;
updated: string;
}
// Computed on read for a batch (UI computes from related invoices)
export interface PaymentBatchStats {
invoiceCount: number;
totalAmount: number;
}
- Step 2: Verify types compile
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
npx tsc --noEmit
Expected: no errors. (If the dev server is running it'll hot-reload; this just runs the typecheck.)
- Step 3: Commit
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
git add app/lib/types.ts
git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: add PaymentBatch types and Invoice.payment_batch"
Task 3: POST /api/payments/create (the big one)
Files:
- Create:
app/api/payments/create/route.ts
Interfaces:
- Consumes:
getPb()fromapp/lib/pb.ts(server-side singleton withbeforeSendcache-buster). - Consumes:
InvoiceandPaymentBatchfromapp/lib/types.ts. - Produces: 200 with
{ ok: true, batchId, batchRef }; 400 on validation; 409 if any invoice already paid; 500 on PB error.
Contract:
-
Request: multipart/form-data with fields
batch_ref,payment_date,pop_file(file),invoice_ids(JSON-encoded array of strings). -
Behaviour:
- Parse
invoice_idsas JSON array. Reject if empty or not an array. - Validate
batch_refmatches^[A-Za-z0-9_-]{1,50}$. - Validate
payment_datematches^\d{4}-\d{2}-\d{2}$. - Validate
pop_fileis one ofapplication/pdf,image/jpeg,image/png, ≤ 8 MB. - Check
batch_refuniqueness:GET /api/collections/JEI_payment_batches/records?filter=batch_ref="<ref>"&perPage=1. If any result, return 409 with "Batch ref already exists". - For each invoice_id: GET the invoice, check
payment_batch == "". If any is non-empty, return 409 with the conflicting IDs. - POST to
JEI_payment_batchescreate with{ batch_ref, payment_date, pop_file }. Capture returnedid. - For each invoice_id: PATCH
JEI_invoices/<id>with{ payment_batch: <newBatchId> }. UsePromise.all. If any fail, log and continue (best-effort; the worst case is the batch exists with some invoices unpaid — caller can retry). - Return
{ ok: true, batchId, batchRef }.
- Parse
-
Step 1: Write the route
Create app/api/payments/create/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { getPb } from "../../../lib/pb";
const BATCHES = "JEI_payment_batches";
const INVOICES = "JEI_invoices";
const ALLOWED_MIME = new Set(["application/pdf", "image/jpeg", "image/png"]);
const MAX_POP_BYTES = 8 * 1024 * 1024;
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const pb = getPb();
let form: FormData;
try {
form = await req.formData();
} catch (e: any) {
return NextResponse.json({ error: `Invalid form-data: ${e?.message || e}` }, { status: 400 });
}
const batchRef = String(form.get("batch_ref") || "").trim();
const paymentDate = String(form.get("payment_date") || "").trim();
const invoiceIdsRaw = String(form.get("invoice_ids") || "");
const popFile = form.get("pop_file");
// 1. invoice_ids
let invoiceIds: string[];
try {
const parsed = JSON.parse(invoiceIdsRaw);
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every((x) => typeof x === "string")) {
throw new Error("invoice_ids must be a non-empty JSON array of strings");
}
invoiceIds = parsed;
} catch (e: any) {
return NextResponse.json({ error: e?.message || "Invalid invoice_ids" }, { status: 400 });
}
// 2. batch_ref
if (!/^[A-Za-z0-9_-]{1,50}$/.test(batchRef)) {
return NextResponse.json(
{ error: "batch_ref must match ^[A-Za-z0-9_-]{1,50}$" },
{ status: 400 },
);
}
// 3. payment_date
if (!/^\d{4}-\d{2}-\d{2}$/.test(paymentDate)) {
return NextResponse.json(
{ error: "payment_date must be YYYY-MM-DD" },
{ status: 400 },
);
}
// 4. pop_file
if (!(popFile instanceof File)) {
return NextResponse.json({ error: "pop_file is required" }, { status: 400 });
}
if (!ALLOWED_MIME.has(popFile.type)) {
return NextResponse.json(
{ error: `pop_file type must be PDF, JPG or PNG (got ${popFile.type})` },
{ status: 400 },
);
}
if (popFile.size > MAX_POP_BYTES) {
return NextResponse.json(
{ error: `pop_file exceeds 8 MB (got ${popFile.size} bytes)` },
{ status: 400 },
);
}
// 5. batch_ref uniqueness
try {
const dupe = await pb.collection(BATCHES).getList(1, 1, {
filter: `batch_ref="${batchRef.replace(/"/g, '\\"')}"`,
});
if (dupe.totalItems > 0) {
return NextResponse.json(
{ error: `Batch ref "${batchRef}" already exists` },
{ status: 409 },
);
}
} catch (e: any) {
return NextResponse.json(
{ error: `batch_ref uniqueness check failed: ${e?.message || e}` },
{ status: 500 },
);
}
// 6. Re-validate each invoice is still unpaid
const conflicts: string[] = [];
const validIds: string[] = [];
for (const id of invoiceIds) {
try {
const inv = await pb.collection(INVOICES).getOne(id);
if (inv.payment_batch && inv.payment_batch !== "") {
conflicts.push(id);
} else {
validIds.push(id);
}
} catch (e: any) {
conflicts.push(id); // missing or 404 → treat as conflict
}
}
if (conflicts.length > 0) {
return NextResponse.json(
{ error: `These invoices are already paid or missing: ${conflicts.join(", ")}` },
{ status: 409 },
);
}
// 7. Create the batch
let batchId: string;
try {
const created = await pb.collection(BATCHES).create({
batch_ref: batchRef,
payment_date: paymentDate,
pop_file: popFile,
});
batchId = created.id;
} catch (e: any) {
return NextResponse.json(
{ error: `Failed to create batch: ${e?.message || e}` },
{ status: 500 },
);
}
// 8. PATCH each invoice (best-effort, parallel)
const patchErrors: string[] = [];
await Promise.all(
validIds.map(async (id) => {
try {
await pb.collection(INVOICES).update(id, { payment_batch: batchId });
} catch (e: any) {
patchErrors.push(`${id}: ${e?.message || e}`);
}
}),
);
return NextResponse.json({
ok: true,
batchId,
batchRef,
invoiceCount: validIds.length,
patchErrors: patchErrors.length > 0 ? patchErrors : undefined,
});
}
- Step 2: Smoke test with curl (no real data, just 400 paths)
Make sure the dev server is running (npm run dev in C:\Users\SJ\.minimax-agent\projects\JEI). Then:
curl.exe -i -X POST http://localhost:6002/api/payments/create
Expected: HTTP 400 with {"error":"Invalid form-data: ..."} or similar.
$form = @{
batch_ref = "TEST-BAD-REF!" # contains '!' which fails the regex
payment_date = "2026-09-18"
invoice_ids = "[]"
pop_file = Get-Item "C:\Users\SJ\.minimax-agent\projects\JEI\app\layout.tsx"
}
Invoke-WebRequest -Uri "http://localhost:6002/api/payments/create" -Method Post -Form $form -UseBasicParsing | Select-Object -ExpandProperty Content
Expected: JSON containing "batch_ref must match...".
- Step 3: Commit
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
git add app/api/payments/create/route.ts
git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: POST /api/payments/create with validation and atomic invoice patching"
Task 4: POST /api/payments/[id]/replace-pop and /unpay
Files:
-
Create:
app/api/payments/[id]/replace-pop/route.ts -
Create:
app/api/payments/[id]/unpay/route.ts -
Step 1: Write replace-pop route
Create app/api/payments/[id]/replace-pop/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { getPb } from "../../../../lib/pb";
const BATCHES = "JEI_payment_batches";
const ALLOWED_MIME = new Set(["application/pdf", "image/jpeg", "image/png"]);
const MAX_POP_BYTES = 8 * 1024 * 1024;
export const dynamic = "force-dynamic";
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } },
) {
const pb = getPb();
const id = params.id;
// Confirm batch exists
try {
await pb.collection(BATCHES).getOne(id);
} catch (e: any) {
return NextResponse.json({ error: `Batch ${id} not found` }, { status: 404 });
}
let form: FormData;
try {
form = await req.formData();
} catch (e: any) {
return NextResponse.json({ error: `Invalid form-data: ${e?.message || e}` }, { status: 400 });
}
const popFile = form.get("pop_file");
if (!(popFile instanceof File)) {
return NextResponse.json({ error: "pop_file is required" }, { status: 400 });
}
if (!ALLOWED_MIME.has(popFile.type)) {
return NextResponse.json(
{ error: `pop_file type must be PDF, JPG or PNG (got ${popFile.type})` },
{ status: 400 },
);
}
if (popFile.size > MAX_POP_BYTES) {
return NextResponse.json(
{ error: `pop_file exceeds 8 MB (got ${popFile.size} bytes)` },
{ status: 400 },
);
}
try {
await pb.collection(BATCHES).update(id, { pop_file: popFile });
} catch (e: any) {
return NextResponse.json(
{ error: `Failed to replace POP: ${e?.message || e}` },
{ status: 500 },
);
}
return NextResponse.json({ ok: true });
}
- Step 2: Write unpay route
Create app/api/payments/[id]/unpay/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { getPb } from "../../../../lib/pb";
const BATCHES = "JEI_payment_batches";
const INVOICES = "JEI_invoices";
export const dynamic = "force-dynamic";
export async function POST(
_req: NextRequest,
{ params }: { params: { id: string } },
) {
const pb = getPb();
const id = params.id;
// Confirm batch exists
try {
await pb.collection(BATCHES).getOne(id);
} catch (e: any) {
return NextResponse.json({ error: `Batch ${id} not found` }, { status: 404 });
}
// List invoices in this batch
let invoiceIds: string[] = [];
try {
const r = await pb.collection(INVOICES).getList(1, 500, {
filter: `payment_batch="${id}"`,
fields: "id",
});
invoiceIds = r.items.map((it: any) => it.id);
} catch (e: any) {
return NextResponse.json(
{ error: `Failed to list invoices in batch: ${e?.message || e}` },
{ status: 500 },
);
}
// Clear payment_batch on each (best-effort, parallel)
const patchErrors: string[] = [];
await Promise.all(
invoiceIds.map(async (invId) => {
try {
await pb.collection(INVOICES).update(invId, { payment_batch: "" });
} catch (e: any) {
patchErrors.push(`${invId}: ${e?.message || e}`);
}
}),
);
// Delete the batch record. If this fails after the clears, we have an orphan
// empty batch — user can clean up via PB admin.
try {
await pb.collection(BATCHES).delete(id);
} catch (e: any) {
return NextResponse.json(
{
ok: true,
unpayedCount: invoiceIds.length,
warn: `Invoices cleared but batch delete failed: ${e?.message || e}`,
patchErrors: patchErrors.length > 0 ? patchErrors : undefined,
},
{ status: 200 },
);
}
return NextResponse.json({
ok: true,
unpayedCount: invoiceIds.length,
patchErrors: patchErrors.length > 0 ? patchErrors : undefined,
});
}
- Step 3: Smoke test routes
Dev server must be running. Test:
# replace-pop with no body → 400
Invoke-WebRequest -Uri "http://localhost:6002/api/payments/fake-id/replace-pop" -Method Post -UseBasicParsing | Select-Object StatusCode, @{n="Body";e={ ($_.Content | Out-String).Substring(0, [Math]::Min(200, $_.Content.Length)) }}
# unpay on non-existent batch → 404
Invoke-WebRequest -Uri "http://localhost:6002/api/payments/fake-id/unpay" -Method Post -UseBasicParsing | Select-Object StatusCode, @{n="Body";e={ ($_.Content | Out-String).Substring(0, [Math]::Min(200, $_.Content.Length)) }}
Expected: 400 and 404 respectively with JSON error bodies.
- Step 4: Commit
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
git add app/api/payments/
git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: POST /api/payments/[id]/replace-pop and /unpay"
Task 5: /unpaid page (selection + Create Payment modal)
Files:
- Create:
app/unpaid/page.tsx(uses'use client'because of localStorage + form state)
UI behaviour (final):
- Server-component shell fetches the list of unpaid invoices (
payment_batch = ""filter) and passes it as a prop to a client component. - Client component:
- Hydrates selected IDs from
localStorage["jei:selected-invoices"]on mount. - Renders table:
[☐] [Date] [Invoice No] [Buyer] [PO] [Total (£)]. - Header checkbox toggles all visible.
- Sticky footer bar:
N selected · £X,XXX.XX · [Create Payment]. - Create Payment button (disabled when N=0) opens a modal with:
- Batch ref (pre-filled
BATCH-YYYYMMDD-NNN, editable). - Payment date (defaults to today, type=date).
- POP file input (accept=".pdf,.jpg,.jpeg,.png").
- Summary line.
- Confirm & Save / Cancel buttons.
- Batch ref (pre-filled
- On submit: POST multipart to
/api/payments/create. On 200, clear selection, refresh. On error, show error in modal.
- Hydrates selected IDs from
Architecture decision: split into two files? No — keep it in one file with 'use client' at the top. The page does server-side data fetch (no auth headers leak client-side), then passes records to client component. But client components in Next.js 14 App Router can't be async server components. So the pattern is:
-
app/unpaid/page.tsx— server component, fetches invoices, renders<UnpaidClient initialRecords={...} />. -
app/unpaid/UnpaidClient.tsx—'use client', handles all interactivity. -
Step 1: Write the server shell
Create app/unpaid/page.tsx:
import Link from "next/link";
import { getPb, fmtGBP, fmtDate } from "../lib/pb";
import UnpaidClient from "./UnpaidClient";
export const dynamic = "force-dynamic";
export default async function UnpaidPage() {
const pb = getPb();
let records: any[] = [];
let errorMsg: string | null = null;
try {
const r = await pb.collection("JEI_invoices").getList(1, 500, {
filter: 'payment_batch=""',
sort: "-invoice_date",
fields: "id,invoice_no,invoice_date,buyer_name_variant,customer_order_no,invoice_total,payment_batch",
});
records = r.items;
} catch (e: any) {
errorMsg = String(e?.message || e);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-brand-dark">Unpaid Invoices</h1>
<div className="text-sm text-slate-500">{records.length} unpaid</div>
</div>
{errorMsg && (
<div className="bg-red-50 border border-red-200 text-red-800 rounded p-4">
<div className="font-semibold">Cannot reach PocketBase</div>
<div className="text-sm mt-1">{errorMsg}</div>
</div>
)}
{!errorMsg && <UnpaidClient initialRecords={records} />}
</div>
);
}
- Step 2: Write the client component
Create app/unpaid/UnpaidClient.tsx:
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { fmtGBP, fmtDate } from "../lib/pb";
type Invoice = {
id: string;
invoice_no: string;
invoice_date: string;
buyer_name_variant: string;
customer_order_no: string;
invoice_total: number;
};
const STORAGE_KEY = "jei:selected-invoices";
function todayYYYYMMDD(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}${m}${day}`;
}
function nextBatchRefSuggestion(): string {
// We don't fetch today's count server-side; this is a client suggestion only.
// Server validates uniqueness on submit.
return `BATCH-${todayYYYYMMDD()}-001`;
}
export default function UnpaidClient({ initialRecords }: { initialRecords: Invoice[] }) {
const router = useRouter();
const records = initialRecords;
const [selected, setSelected] = useState<Set<string>>(new Set());
const [hydrated, setHydrated] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [batchRef, setBatchRef] = useState(nextBatchRefSuggestion());
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().slice(0, 10));
const [popFile, setPopFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Hydrate selection from localStorage
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const arr = JSON.parse(raw);
if (Array.isArray(arr)) {
// Only keep IDs that are still in the unpaid list
const validIds = new Set(records.map((r) => r.id));
setSelected(new Set(arr.filter((id: any) => validIds.has(id))));
}
}
} catch (_) {
// ignore parse errors
}
setHydrated(true);
}, [records]);
// Persist on change
useEffect(() => {
if (!hydrated) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(selected)));
} catch (_) {
// localStorage may be unavailable (e.g. private mode)
}
}, [selected, hydrated]);
const selectedRecords = useMemo(
() => records.filter((r) => selected.has(r.id)),
[records, selected],
);
const total = useMemo(
() => selectedRecords.reduce((s, r) => s + (r.invoice_total || 0), 0),
[selectedRecords],
);
function toggleAll(checked: boolean) {
if (checked) setSelected(new Set(records.map((r) => r.id)));
else setSelected(new Set());
}
function toggleOne(id: string, checked: boolean) {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}
const allChecked = records.length > 0 && selected.size === records.length;
async function submitPayment(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!popFile) {
setError("POP file is required");
return;
}
setSubmitting(true);
try {
const fd = new FormData();
fd.set("batch_ref", batchRef);
fd.set("payment_date", paymentDate);
fd.set("pop_file", popFile);
fd.set("invoice_ids", JSON.stringify(Array.from(selected)));
const resp = await fetch("/api/payments/create", { method: "POST", body: fd });
const json = await resp.json().catch(() => ({}));
if (!resp.ok) {
setError(json?.error || `HTTP ${resp.status}`);
return;
}
// Success: clear selection, close modal, refresh
setSelected(new Set());
setModalOpen(false);
router.refresh();
} catch (e: any) {
setError(String(e?.message || e));
} finally {
setSubmitting(false);
}
}
return (
<>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-100 text-left">
<tr>
<th className="px-3 py-2 w-10">
<input
type="checkbox"
aria-label="Select all"
checked={allChecked}
onChange={(e) => toggleAll(e.target.checked)}
/>
</th>
<th className="px-3 py-2">Date</th>
<th className="px-3 py-2">Invoice No</th>
<th className="px-3 py-2">Buyer</th>
<th className="px-3 py-2">PO</th>
<th className="px-3 py-2 text-right">Total (£)</th>
</tr>
</thead>
<tbody>
{records.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-slate-500">
No unpaid invoices. All caught up 🎉
</td>
</tr>
)}
{records.map((r) => (
<tr key={r.id} className="border-t hover:bg-blue-50">
<td className="px-3 py-2">
<input
type="checkbox"
aria-label={`Select invoice ${r.invoice_no}`}
checked={selected.has(r.id)}
onChange={(e) => toggleOne(r.id, e.target.checked)}
/>
</td>
<td className="px-3 py-2">{fmtDate(r.invoice_date)}</td>
<td className="px-3 py-2 font-medium">{r.invoice_no}</td>
<td className="px-3 py-2">{r.buyer_name_variant}</td>
<td className="px-3 py-2 text-slate-600">{r.customer_order_no || "—"}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtGBP(r.invoice_total)}</td>
</tr>
))}
</tbody>
</table>
</div>
{selected.size > 0 && (
<div className="sticky bottom-0 bg-white border-t shadow-lg p-4 flex items-center justify-between">
<div className="text-sm">
<span className="font-semibold">{selected.size}</span> selected ·{" "}
<span className="font-semibold tabular-nums">{fmtGBP(total)}</span>
</div>
<button
className="bg-brand text-white rounded px-4 py-2 hover:bg-brand-dark disabled:opacity-50"
disabled={selected.size === 0}
onClick={() => {
setBatchRef(nextBatchRefSuggestion());
setModalOpen(true);
}}
>
Create Payment
</button>
</div>
)}
{selected.size === 0 && records.length > 0 && (
<div className="text-sm text-slate-500 text-center py-4">
Tick invoices above to create a payment batch.
</div>
)}
{modalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 space-y-4">
<h2 className="text-lg font-semibold">Create Payment Batch</h2>
<div className="text-sm bg-slate-50 rounded p-3">
Bundling <strong>{selectedRecords.length}</strong> invoices for{" "}
<strong className="tabular-nums">{fmtGBP(total)}</strong>.
</div>
<form onSubmit={submitPayment} className="space-y-3">
<div>
<label className="text-xs text-slate-500">Batch reference</label>
<input
type="text"
value={batchRef}
onChange={(e) => setBatchRef(e.target.value)}
pattern="[A-Za-z0-9_-]{1,50}"
required
className="w-full border rounded px-3 py-2 font-mono"
/>
</div>
<div>
<label className="text-xs text-slate-500">Payment date</label>
<input
type="date"
value={paymentDate}
onChange={(e) => setPaymentDate(e.target.value)}
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="text-xs text-slate-500">POP file (PDF, JPG, PNG — max 8 MB)</label>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
required
onChange={(e) => setPopFile(e.target.files?.[0] || null)}
className="w-full border rounded px-3 py-2"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-800 rounded p-3 text-sm">
{error}
</div>
)}
<div className="flex gap-2 justify-end">
<button
type="button"
className="px-4 py-2 rounded border hover:bg-slate-50"
onClick={() => {
setModalOpen(false);
setError(null);
}}
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="bg-brand text-white rounded px-4 py-2 hover:bg-brand-dark disabled:opacity-50"
disabled={submitting}
>
{submitting ? "Saving…" : "Confirm & Save"}
</button>
</div>
</form>
</div>
</div>
)}
</>
);
}
- Step 3: Verify the page renders
With dev server running:
curl.exe -s -o $null -w "%{http_code}\n" http://localhost:6002/unpaid
Expected: 200.
$r = Invoke-WebRequest http://localhost:6002/unpaid -UseBasicParsing
$has = @('Unpaid Invoices', 'Tick invoices above', 'No unpaid invoices') | ForEach-Object { @{ label = $_; found = $r.Content -match [regex]::Escape($_) } }
$has | Format-Table -AutoSize
Expected: each label found = True (some content from the page renders).
- Step 4: Commit
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
git add app/unpaid/
git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: /unpaid page with selection, sticky footer, and Create Payment modal"
Task 6: /remittance page
Files:
- Create:
app/remittance/page.tsx
UI behaviour:
-
Server-component fetches all
JEI_payment_batches(sorted-payment_date, -created). -
For each batch, server-component fetches the related invoices (
filter: payment_batch="<id>"). -
Renders a batch card with:
- Header line:
BATCH-REF · payment_date · £total · N invoices. Open POPlink (target="_blank", computed from${PB_URL}/api/files/JEI_payment_batches/<id>/<pop_file>).Replace POPbutton — opens a hidden file input; on file picked, POSTs to/api/payments/<id>/replace-pop, refreshes page on success.Unpay batchbutton — confirms viawindow.confirm, POSTs to/api/payments/<id>/unpay, refreshes on success.- Expandable invoice table (collapsed by default, click to expand).
- Header line:
-
Use a single client component per card for the interactive buttons. Or one client wrapper that handles all cards (simpler — single useRouter, single setLoading). Choose the simpler: one client wrapper.
-
Step 1: Write the page
Create app/remittance/page.tsx:
import Link from "next/link";
import { getPb, fmtGBP, fmtDate } from "../lib/pb";
import RemittanceClient from "./RemittanceClient";
export const dynamic = "force-dynamic";
const PB_URL = process.env.NEXT_PUBLIC_PB_URL || process.env.PB_URL || "https://pb.agent.apphub.co.za";
async function fetchBatchWithInvoices(pb: any, batchId: string) {
const inv = await pb.collection("JEI_invoices").getList(1, 500, {
filter: `payment_batch="${batchId}"`,
sort: "-invoice_date",
fields: "id,invoice_no,invoice_date,buyer_name_variant,invoice_total",
});
return inv.items as any[];
}
export default async function RemittancePage() {
const pb = getPb();
let batches: any[] = [];
let errorMsg: string | null = null;
try {
const r = await pb.collection("JEI_payment_batches").getList(1, 500, {
sort: "-payment_date",
});
batches = r.items;
} catch (e: any) {
errorMsg = String(e?.message || e);
}
// Fetch invoices per batch in parallel
let batchWithInvoices: { batch: any; invoices: any[]; popUrl: string | null }[] = [];
if (!errorMsg) {
batchWithInvoices = await Promise.all(
batches.map(async (b) => {
try {
const invoices = await fetchBatchWithInvoices(pb, b.id);
const popUrl = b.pop_file ? `${PB_URL}/api/files/JEI_payment_batches/${b.id}/${b.pop_file}` : null;
return { batch: b, invoices, popUrl };
} catch (_) {
return { batch: b, invoices: [], popUrl: null };
}
}),
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-brand-dark">Remittance</h1>
<div className="text-sm text-slate-500">{batches.length} batch(es)</div>
</div>
{errorMsg && (
<div className="bg-red-50 border border-red-200 text-red-800 rounded p-4">
<div className="font-semibold">Cannot reach PocketBase</div>
<div className="text-sm mt-1">{errorMsg}</div>
</div>
)}
{!errorMsg && batches.length === 0 && (
<div className="bg-white rounded-lg shadow p-8 text-center text-slate-500">
No payment batches yet. Tick invoices on the{" "}
<Link href="/unpaid" className="text-brand hover:underline">Unpaid tab</Link>{" "}
and click <strong>Create Payment</strong>.
</div>
)}
{!errorMsg && batches.length > 0 && (
<RemittanceClient batches={batchWithInvoices} />
)}
</div>
);
}
- Step 2: Write the client wrapper
Create app/remittance/RemittanceClient.tsx:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { fmtGBP, fmtDate } from "../lib/pb";
type Invoice = {
id: string;
invoice_no: string;
invoice_date: string;
buyer_name_variant: string;
invoice_total: number;
};
type BatchWithInvoices = {
batch: { id: string; batch_ref: string; payment_date: string; pop_file: string };
invoices: Invoice[];
popUrl: string | null;
};
export default function RemittanceClient({ batches }: { batches: BatchWithInvoices[] }) {
const router = useRouter();
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
function toggle(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function replacePop(batchId: string, file: File) {
setError(null);
setBusyId(batchId);
try {
const fd = new FormData();
fd.set("pop_file", file);
const resp = await fetch(`/api/payments/${batchId}/replace-pop`, { method: "POST", body: fd });
const json = await resp.json().catch(() => ({}));
if (!resp.ok) {
setError(json?.error || `HTTP ${resp.status}`);
return;
}
router.refresh();
} catch (e: any) {
setError(String(e?.message || e));
} finally {
setBusyId(null);
}
}
async function unpay(batchId: string, batchRef: string) {
if (!window.confirm(`Unpay batch ${batchRef}? All ${countInvoices(batchId)} invoices will return to unpaid and the batch will be deleted.`)) {
return;
}
setError(null);
setBusyId(batchId);
try {
const resp = await fetch(`/api/payments/${batchId}/unpay`, { method: "POST" });
const json = await resp.json().catch(() => ({}));
if (!resp.ok) {
setError(json?.error || `HTTP ${resp.status}`);
return;
}
router.refresh();
} catch (e: any) {
setError(String(e?.message || e));
} finally {
setBusyId(null);
}
}
function countInvoices(id: string): number {
return batches.find((b) => b.batch.id === id)?.invoices.length ?? 0;
}
return (
<div className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-800 rounded p-3 text-sm">
{error}
</div>
)}
{batches.map(({ batch, invoices, popUrl }) => {
const total = invoices.reduce((s, inv) => s + (inv.invoice_total || 0), 0);
const isExpanded = expanded.has(batch.id);
const isBusy = busyId === batch.id;
return (
<div key={batch.id} id={`batch-${batch.id}`} className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b flex items-center justify-between gap-4 flex-wrap">
<div>
<div className="font-mono font-semibold text-brand-dark">{batch.batch_ref}</div>
<div className="text-xs text-slate-500">
{fmtDate(batch.payment_date)} · {invoices.length} invoice{invoices.length === 1 ? "" : "s"} ·{" "}
<span className="tabular-nums">{fmtGBP(total)}</span>
</div>
</div>
<div className="flex gap-2 flex-wrap">
{popUrl && (
<a
href={popUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm border rounded px-3 py-1 hover:bg-slate-50"
>
Open POP
</a>
)}
<label className="text-sm border rounded px-3 py-1 hover:bg-slate-50 cursor-pointer">
{isBusy ? "Uploading…" : "Replace POP"}
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
className="hidden"
disabled={isBusy}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) replacePop(batch.id, f);
e.target.value = ""; // allow re-selecting same file
}}
/>
</label>
<button
className="text-sm border border-red-200 text-red-700 rounded px-3 py-1 hover:bg-red-50 disabled:opacity-50"
disabled={isBusy}
onClick={() => unpay(batch.id, batch.batch_ref)}
>
{isBusy ? "Working…" : "Unpay batch"}
</button>
<button
className="text-sm border rounded px-3 py-1 hover:bg-slate-50"
onClick={() => toggle(batch.id)}
>
{isExpanded ? "Hide" : "Show"} invoices
</button>
</div>
</div>
{isExpanded && (
<div className="px-6 py-3">
{invoices.length === 0 ? (
<div className="text-sm text-slate-500 py-2">No invoices in this batch.</div>
) : (
<table className="w-full text-sm">
<thead className="text-left text-slate-500">
<tr>
<th className="py-1">Date</th>
<th className="py-1">Invoice No</th>
<th className="py-1">Buyer</th>
<th className="py-1 text-right">Total (£)</th>
<th></th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr key={inv.id} className="border-t">
<td className="py-2">{fmtDate(inv.invoice_date)}</td>
<td className="py-2 font-medium">{inv.invoice_no}</td>
<td className="py-2">{inv.buyer_name_variant}</td>
<td className="py-2 text-right tabular-nums">{fmtGBP(inv.invoice_total)}</td>
<td className="py-2 text-right">
<Link href={`/invoices/${inv.id}`} className="text-brand hover:underline text-xs">
View invoice →
</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
);
})}
</div>
);
}
- Step 3: Verify the page renders
curl.exe -s -o $null -w "%{http_code}\n" http://localhost:6002/remittance
Expected: 200.
$r = Invoke-WebRequest http://localhost:6002/remittance -UseBasicParsing
$r.Content -match "Remittance" | Write-Host
$r.Content -match "No payment batches yet" | Write-Host
Expected: at least one True. If no batches exist yet, only the second will be True.
- Step 4: Commit
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
git add app/remittance/
git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: /remittance page with batch cards, Replace POP and Unpay buttons"
Task 7: Modify /invoices page with Status column + colors
Files:
- Modify:
app/invoices/page.tsx
Changes:
- Fetch the additional
payment_batchfield on invoices. - Add a Status column header between
TypeandBuyer. - Each row gets
bg-green-50if paid,bg-blue-50if unpaid. - Status cell:
Paid · BATCH-REF(linked to/remittance#batch-<id>) for paid;Unpaidfor unpaid.
- Step 1: Patch the page
Open app/invoices/page.tsx. Make these changes:
(a) Update the getList call's fields to include payment_batch. Find:
const opts: any = { sort: "-invoice_date", perPage: 200 };
if (filter) opts.filter = filter;
const r = await pb.collection("JEI_invoices").getList(1, 200, opts);
Replace the line above the getList call with explicit fields:
const opts: any = {
sort: "-invoice_date",
perPage: 200,
fields: "id,invoice_no,invoice_date,document_type,buyer_name_variant,customer_order_no,invoice_total,deliver_to_name,payment_batch",
};
if (filter) opts.filter = filter;
const r = await pb.collection("JEI_invoices").getList(1, 200, opts);
(b) Add a helper to fetch batch refs in bulk (for the Status column to show the ref). Insert this just after the let records: any[] = []; line:
// Fetch batch refs for any invoices that have a payment_batch, so we can
// show "Paid · BATCH-REF" instead of just "Paid".
const batchRefs = new Map<string, string>(); // batchId -> batch_ref
const batchIds = Array.from(new Set(records.map((r) => r.payment_batch).filter((x) => x && x !== "")));
if (batchIds.length > 0) {
try {
// PB filter syntax: field="id1" || field="id2" || ...
const filter = batchIds.map((id) => `id="${id}"`).join(" || ");
const br = await pb.collection("JEI_payment_batches").getList(1, 500, {
filter,
fields: "id,batch_ref",
});
for (const b of br.items) batchRefs.set(b.id, b.batch_ref);
} catch (_) {
// best-effort; status will fall back to "Paid (no ref)"
}
}
(c) Add a paidAmount and unpaidAmount to the header summary so the user can see the split. Update the existing <div className="text-sm text-slate-500">{records.length} record(s)</div> to:
<div className="text-sm text-slate-500">
{records.length} record(s) ·{" "}
{records.filter((r) => !r.payment_batch).length} unpaid ·{" "}
{records.filter((r) => r.payment_batch).length} paid
</div>
(d) Replace the entire <thead> block with:
<thead className="bg-slate-100 text-left">
<tr>
<th className="px-3 py-2">Date</th>
<th className="px-3 py-2">No</th>
<th className="px-3 py-2">Type</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Buyer</th>
<th className="px-3 py-2">PO</th>
<th className="px-3 py-2 text-right">Total (£)</th>
<th className="px-3 py-2">Deliver to</th>
</tr>
</thead>
(e) Update the empty-state colSpan from 7 to 8.
(f) Replace the <tr key={r.id} className="border-t hover:bg-slate-50"> mapping. The new row should be:
{records.map((r) => {
const isPaid = r.payment_batch && r.payment_batch !== "";
const batchRef = isPaid ? batchRefs.get(r.payment_batch) : null;
const rowBg = isPaid ? "bg-green-50 hover:bg-green-100" : "bg-blue-50 hover:bg-blue-100";
return (
<tr key={r.id} className={`border-t ${rowBg}`}>
<td className="px-3 py-2">{fmtDate(r.invoice_date)}</td>
<td className="px-3 py-2">
<Link href={`/invoices/${r.id}`} className="text-brand hover:underline font-medium">
{r.invoice_no}
</Link>
</td>
<td className="px-3 py-2 capitalize">{r.document_type}</td>
<td className="px-3 py-2 text-sm">
{isPaid ? (
batchRef ? (
<Link href={`/remittance#batch-${r.payment_batch}`} className="text-green-800 hover:underline">
Paid · <span className="font-mono">{batchRef}</span>
</Link>
) : (
<span className="text-green-800">Paid</span>
)
) : (
<span className="text-blue-800">Unpaid</span>
)}
</td>
<td className="px-3 py-2">{r.buyer_name_variant}</td>
<td className="px-3 py-2 text-slate-600">{r.customer_order_no || "—"}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtGBP(r.invoice_total)}</td>
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{r.deliver_to_name || "—"}</td>
</tr>
);
})}
- Step 2: Verify the page still renders
curl.exe -s -o $null -w "%{http_code}\n" http://localhost:6002/invoices
Expected: 200.
$r = Invoke-WebRequest http://localhost:6002/invoices -UseBasicParsing
"Status header in HTML: $($r.Content -match '>Status<')"
"Unpaid badge present: $($r.Content -match 'Unpaid</span>')"
Expected: both True.
- Step 3: Commit
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
git add app/invoices/page.tsx
git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: add Status column + paid/unpaid row colors on /invoices"
Task 8: Add nav links in layout.tsx
Files:
-
Modify:
app/layout.tsx:23-27 -
Step 1: Insert nav links
Replace the existing nav block:
<nav className="flex gap-6 text-sm">
<Link href="/" className="hover:underline">Dashboard</Link>
<Link href="/invoices" className="hover:underline">Invoices</Link>
<Link href="/stock-codes" className="hover:underline">Stock Codes</Link>
</nav>
with:
<nav className="flex gap-6 text-sm">
<Link href="/" className="hover:underline">Dashboard</Link>
<Link href="/invoices" className="hover:underline">Invoices</Link>
<Link href="/unpaid" className="hover:underline">Unpaid</Link>
<Link href="/remittance" className="hover:underline">Remittance</Link>
<Link href="/stock-codes" className="hover:underline">Stock Codes</Link>
</nav>
- Step 2: Verify all pages render
1..5 | ForEach-Object {
$paths = @('/', '/invoices', '/unpaid', '/remittance', '/stock-codes')
$path = $paths[$_ - 1]
$code = (Invoke-WebRequest "http://localhost:6002$path" -UseBasicParsing -TimeoutSec 30).StatusCode
"$path : $code"
}
Expected: all 200.
- Step 3: Commit
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
git add app/layout.tsx
git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: add Unpaid and Remittance nav links"
Task 9: End-to-end smoke test
Files: none (verification only).
This is the manual test of acceptance criteria from the spec. Run through each one. Document results in the commit message of a follow-up "chore: verified payments workflow" commit if everything passes.
- Step 1: Start dev server (if not running)
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
npm run dev
Run in background (use the existing task pattern with run_in_background: true).
- Step 2: Test scenario A — tick 2 invoices and create a batch
In the browser:
- Visit
http://localhost:6002/unpaid. - Tick 2 invoices. Verify footer shows
2 selected · £X.XX. - Click Create Payment.
- Verify modal pre-fills
BATCH-YYYYMMDD-001. - Pick today's date.
- Upload a sample POP PDF (use any small PDF — e.g. one of the existing invoices).
- Click Confirm & Save.
- Verify modal closes and the 2 invoices disappear from the list.
- Verify the footer clears.
Expected: all steps succeed.
- Step 3: Test scenario B — invoice appears paid on /invoices
Visit http://localhost:6002/invoices. Verify:
-
The 2 invoices you paid have light-green background.
-
Their Status cell shows
Paid · BATCH-YYYYMMDD-001(linked, clickable). -
Other invoices still have light-blue background with
Unpaidbadge. -
Step 4: Test scenario C — batch appears on /remittance
Visit http://localhost:6002/remittance. Verify:
-
The new batch card is visible.
-
Header shows the ref, date, total £, invoice count.
-
Click Open POP — the uploaded file opens in a new tab.
-
Click Show invoices — the 2 paid invoices are listed with View invoice links.
-
Step 5: Test scenario D — unpay the batch
On the batch card, click Unpay batch. Confirm the dialog. Verify:
-
The card disappears from
/remittance. -
The 2 invoices reappear on
/unpaid(blue). -
On
/invoices, the 2 invoices return to blue background andUnpaidbadge. -
Step 6: Test scenario E — selection persistence
On /unpaid:
- Tick 2 invoices.
- Reload the page (Ctrl+R).
- Verify both tickboxes are still ticked.
- Step 7: Test scenario F — duplicate batch ref
Run two batches with the same batch_ref in quick succession:
- Create batch
BATCH-TEST-DUPwith invoice A. - Try to create another batch
BATCH-TEST-DUPwith invoice B. - Verify error message:
Batch ref "BATCH-TEST-DUP" already exists.
- Step 8: Test scenario G — invalid POP type
In the modal, try to upload a .txt file. Browser should reject (accept attribute). If you bypass it, the server returns 400 with the mime error.
- Step 9: Commit test artefact (optional)
If you want a paper trail, add a short note to JEI/NOTES.md recording the test results.
Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"
# (optional) git add NOTES.md and commit
Self-Review
(Inline check performed before saving — results noted here for traceability.)
- Spec coverage:
- §3.1
JEI_payment_batchesfields → Task 1 (migration script). - §3.2
payment_batchrelation → Task 1. - §4.1 Status column on /invoices → Task 7.
- §4.2 /unpaid page → Task 5.
- §4.3 /remittance page → Task 6.
- §4.4 Nav links → Task 8.
- §5.1
POST /api/payments/create→ Task 3. - §5.2
POST /api/payments/[id]/replace-pop→ Task 4. - §5.3
POST /api/payments/[id]/unpay→ Task 4. - §3.1
batch_refuniqueness check → Task 3 (step 5). - §2 localStorage selection state → Task 5 (
STORAGE_KEY = "jei:selected-invoices"). - §10 Acceptance criteria → Task 9 (smoke test).
- §3.1
- Placeholder scan: No "TBD" / "TODO" / "implement later" anywhere in the plan.
- Type consistency:
Invoice.payment_batch: string,PaymentBatch.id/batch_ref/payment_date/pop_file/notes/created/updatedused consistently across tasks.PaymentBatchStatsdefined in types but not used in the plan — fine, it's a future hook. (YAGNI: removed from tasks that don't need it.) - File path consistency: All
git addpaths match the file structure table at the top. - PowerShell syntax: No bash
&&or chained commands. Each multi-line shell block is a singleSet-Location "..."; ...invocation.