diff --git a/docs/superpowers/plans/2026-09-04-pos-slip-layout-editor.md b/docs/superpowers/plans/2026-09-04-pos-slip-layout-editor.md new file mode 100644 index 0000000..5be87ce --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-pos-slip-layout-editor.md @@ -0,0 +1,1953 @@ +# POS Slip Layout Editor 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:** Let each letterhead have its own reorderable/toggleable block layout for the three thermal slips (sale, delivery note, cash-up), editable from a new Settings screen with a live preview, and have both print transports (network ePOS-Print XML and Bluetooth ESC/POS) honor it. + +**Architecture:** A new `pos_slip_layouts` PocketBase collection (already created — see `scripts/migrations/2026-09-04-create-pos-slip-layouts.ts`) holds one row per letterhead. A thin service + `GET`/`PUT` route exposes it. A new client module, `public/pos/slip-layout.js`, owns the block catalog, defaults, and `resolveBlocks()` — the single source of truth for "what blocks exist, in what order, enabled or not, with what text" that both the editor's preview and both print transports consume. The five existing hardcoded print builders (`buildEposXml`, `buildEposDeliveryXml`, `buildEposCashupXml` in `epos-print.js`; `buildEscPos`, `buildEscPosDelivery` in `receipt.html`) are refactored from a fixed sequence of `push(...)` calls into a dispatch table keyed by block id, iterated in the resolved order — the byte/XML content of each block is unchanged, just extracted and made conditional. + +**Tech Stack:** PocketBase (relation + JSON fields), Next.js API routes, vanilla JS (no build step for `public/pos/`). + +**Important correction from the design spec:** while re-reading the actual current code for this plan, two of the spec's example default block lists turned out not to match what's actually printed today. The spec's non-goals explicitly rule out adding new content, so this plan follows the real current output instead of the spec's illustrative lists: +- **Delivery note** does not currently print letterhead business info at all (no address/phone/VAT) — only a hardcoded "WFSALES POS" title and "Delivery Note" label. So there is no `business_info` block for delivery notes; the title becomes a `header_title` block instead (same as sale/cash-up). +- **Cash-up** already prints a title line and letterhead info, but the spec's block list omitted the title line as its own block. This plan adds `header_title` there too, consistent with the other two slip types. + +--- + +### Task 1: `pos-slip-layout-service.ts` — server read/write + +**Files:** +- Create: `src/lib/server/pos-slip-layout-service.ts` +- Create: `src/lib/server/pos-slip-layout-service.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// src/lib/server/pos-slip-layout-service.test.ts +import { describe, it, expect, vi } from "vitest"; +import { getSlipLayout, saveSlipLayout } from "./pos-slip-layout-service"; + +const mockGetAdminPocketBase = vi.fn(); +vi.mock("./pocketbase-admin", () => ({ + getAdminPocketBase: () => mockGetAdminPocketBase(), +})); + +describe("getSlipLayout", () => { + it("returns null when the letterhead has no saved layout", async () => { + mockGetAdminPocketBase.mockResolvedValue({ + collection: () => ({ + getList: async () => ({ items: [] }), + }), + }); + const result = await getSlipLayout("lh-1"); + expect(result).toBeNull(); + }); + + it("returns the saved layout, defaulting delivery_show_pricing to true when unset", async () => { + mockGetAdminPocketBase.mockResolvedValue({ + collection: () => ({ + getList: async () => ({ + items: [{ + id: "row-1", + letterhead: "lh-1", + sale_blocks: [{ id: "logo", enabled: false }], + delivery_blocks: [], + cashup_blocks: [], + delivery_show_pricing: undefined, + }], + }), + }), + }); + const result = await getSlipLayout("lh-1"); + expect(result).toEqual({ + sale_blocks: [{ id: "logo", enabled: false }], + delivery_blocks: [], + cashup_blocks: [], + delivery_show_pricing: true, + }); + }); +}); + +describe("saveSlipLayout", () => { + it("creates a new row when none exists yet, only writing the fields given", async () => { + const create = vi.fn().mockResolvedValue({ + id: "row-1", + letterhead: "lh-1", + sale_blocks: [{ id: "logo", enabled: false }], + delivery_blocks: [], + cashup_blocks: [], + delivery_show_pricing: true, + }); + mockGetAdminPocketBase.mockResolvedValue({ + collection: () => ({ + getList: async () => ({ items: [] }), + create, + }), + }); + const result = await saveSlipLayout("lh-1", { sale_blocks: [{ id: "logo", enabled: false }] }); + expect(create).toHaveBeenCalledWith({ letterhead: "lh-1", sale_blocks: [{ id: "logo", enabled: false }] }); + expect(result.sale_blocks).toEqual([{ id: "logo", enabled: false }]); + }); + + it("updates the existing row when one exists", async () => { + const update = vi.fn().mockResolvedValue({ + id: "row-1", + letterhead: "lh-1", + sale_blocks: [], + delivery_blocks: [], + cashup_blocks: [], + delivery_show_pricing: false, + }); + mockGetAdminPocketBase.mockResolvedValue({ + collection: () => ({ + getList: async () => ({ items: [{ id: "row-1", letterhead: "lh-1" }] }), + update, + }), + }); + const result = await saveSlipLayout("lh-1", { delivery_show_pricing: false }); + expect(update).toHaveBeenCalledWith("row-1", { letterhead: "lh-1", delivery_show_pricing: false }); + expect(result.delivery_show_pricing).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/lib/server/pos-slip-layout-service.test.ts` +Expected: FAIL — `Cannot find module './pos-slip-layout-service'` + +- [ ] **Step 3: Write the service** + +```ts +// src/lib/server/pos-slip-layout-service.ts +import { getAdminPocketBase } from "./pocketbase-admin"; + +/** + * A single row in a slip's block list. `text` is present only on + * blocks that carry editable text (header_title, footer); it's + * null/absent to mean "use the default text". + */ +export type PosSlipBlock = { + id: string; + enabled: boolean; + text?: string | null; +}; + +type PosSlipLayoutRow = { + id: string; + letterhead: string; + sale_blocks: PosSlipBlock[]; + delivery_blocks: PosSlipBlock[]; + cashup_blocks: PosSlipBlock[]; + delivery_show_pricing?: boolean; +}; + +export type PosSlipLayout = { + sale_blocks: PosSlipBlock[]; + delivery_blocks: PosSlipBlock[]; + cashup_blocks: PosSlipBlock[]; + delivery_show_pricing: boolean; +}; + +export type PosSlipLayoutInput = Partial; + +function escapePbValue(value: string): string { + return value.replace(/'/g, "\\'"); +} + +async function findRow( + pb: Awaited>, + letterheadId: string, +): Promise { + const page = await pb.collection("pos_slip_layouts").getList(1, 1, { + filter: `letterhead = '${escapePbValue(letterheadId)}'`, + }); + return page.items[0] || null; +} + +function toLayout(row: PosSlipLayoutRow): PosSlipLayout { + return { + sale_blocks: row.sale_blocks || [], + delivery_blocks: row.delivery_blocks || [], + cashup_blocks: row.cashup_blocks || [], + delivery_show_pricing: row.delivery_show_pricing !== false, + }; +} + +/** + * Fetch the saved slip layout for a letterhead. Returns null when + * nothing has been saved yet — callers fall back to the hardcoded + * defaults in `public/pos/slip-layout.js` (kept there, not here, + * since the till needs the same defaults available when it's offline + * and can't reach this endpoint at all). + */ +export async function getSlipLayout(letterheadId: string): Promise { + const pb = await getAdminPocketBase(); + const row = await findRow(pb, letterheadId); + return row ? toLayout(row) : null; +} + +/** + * Create-or-update the slip layout row for a letterhead. Only the + * fields present in `input` are written — saving the sale slip's + * blocks from the editor doesn't touch delivery_blocks/cashup_blocks. + */ +export async function saveSlipLayout( + letterheadId: string, + input: PosSlipLayoutInput, +): Promise { + const pb = await getAdminPocketBase(); + const existing = await findRow(pb, letterheadId); + const data: Record = { letterhead: letterheadId }; + if (input.sale_blocks !== undefined) data.sale_blocks = input.sale_blocks; + if (input.delivery_blocks !== undefined) data.delivery_blocks = input.delivery_blocks; + if (input.cashup_blocks !== undefined) data.cashup_blocks = input.cashup_blocks; + if (input.delivery_show_pricing !== undefined) data.delivery_show_pricing = input.delivery_show_pricing; + + const row = existing + ? ((await pb.collection("pos_slip_layouts").update(existing.id, data)) as PosSlipLayoutRow) + : ((await pb.collection("pos_slip_layouts").create(data)) as PosSlipLayoutRow); + return toLayout(row); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/lib/server/pos-slip-layout-service.test.ts` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/pos-slip-layout-service.ts src/lib/server/pos-slip-layout-service.test.ts +git commit -m "feat(pos): add pos-slip-layout-service for reading/writing per-letterhead slip layouts" +``` + +--- + +### Task 2: `GET`/`PUT /api/v1/pos/slip-layout/[letterheadId]` + +**Files:** +- Create: `src/app/api/v1/pos/slip-layout/[letterheadId]/route.ts` +- Create: `src/app/api/v1/pos/slip-layout/[letterheadId]/route.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// src/app/api/v1/pos/slip-layout/[letterheadId]/route.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { GET, PUT } from "./route"; +import { getSlipLayout, saveSlipLayout } from "@/lib/server/pos-slip-layout-service"; + +vi.mock("@/lib/server/pos-slip-layout-service", () => ({ + getSlipLayout: vi.fn(), + saveSlipLayout: vi.fn(), +})); + +const POS_API_KEY = "test-pos-key"; + +function req(method: string, body?: unknown, authHeader: string | null = `Bearer ${POS_API_KEY}`): NextRequest { + const headers: Record = { "content-type": "application/json" }; + if (authHeader !== null) headers.authorization = authHeader; + return new NextRequest("http://localhost/api/v1/pos/slip-layout/lh-1", { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +const params = Promise.resolve({ letterheadId: "lh-1" }); + +describe("GET /api/v1/pos/slip-layout/[letterheadId]", () => { + beforeEach(() => { + process.env.POS_API_KEY = POS_API_KEY; + vi.mocked(getSlipLayout).mockReset(); + vi.mocked(saveSlipLayout).mockReset(); + }); + + it("401s without a valid key", async () => { + const res = await GET(req("GET", undefined, null), { params }); + expect(res.status).toBe(401); + }); + + it("returns { layout: null } when nothing is saved yet", async () => { + vi.mocked(getSlipLayout).mockResolvedValue(null); + const res = await GET(req("GET"), { params }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.layout).toBeNull(); + }); + + it("returns the saved layout", async () => { + vi.mocked(getSlipLayout).mockResolvedValue({ + sale_blocks: [], delivery_blocks: [], cashup_blocks: [], delivery_show_pricing: true, + }); + const res = await GET(req("GET"), { params }); + const body = await res.json(); + expect(body.layout.delivery_show_pricing).toBe(true); + }); +}); + +describe("PUT /api/v1/pos/slip-layout/[letterheadId]", () => { + beforeEach(() => { + process.env.POS_API_KEY = POS_API_KEY; + vi.mocked(saveSlipLayout).mockReset(); + }); + + it("saves and returns the updated layout", async () => { + vi.mocked(saveSlipLayout).mockResolvedValue({ + sale_blocks: [{ id: "logo", enabled: false }], + delivery_blocks: [], cashup_blocks: [], delivery_show_pricing: true, + }); + const res = await PUT(req("PUT", { sale_blocks: [{ id: "logo", enabled: false }] }), { params }); + expect(res.status).toBe(200); + expect(saveSlipLayout).toHaveBeenCalledWith("lh-1", { sale_blocks: [{ id: "logo", enabled: false }] }); + const body = await res.json(); + expect(body.layout.sale_blocks).toEqual([{ id: "logo", enabled: false }]); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/app/api/v1/pos/slip-layout` +Expected: FAIL — route module not found + +- [ ] **Step 3: Write the route** + +```ts +// src/app/api/v1/pos/slip-layout/[letterheadId]/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { assertPosAuth } from "@/lib/server/pos-auth"; +import { + getSlipLayout, + saveSlipLayout, + type PosSlipLayoutInput, +} from "@/lib/server/pos-slip-layout-service"; + +/** + * GET /api/v1/pos/slip-layout/[letterheadId] + * + * Returns `{ layout: null }` when this letterhead has never had a + * layout saved — the till falls back to its own hardcoded defaults + * in that case (see public/pos/slip-layout.js). + * + * Auth: requires `Authorization: Bearer ${POS_API_KEY}`. + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ letterheadId: string }> }, +) { + try { + assertPosAuth(req); + } catch (err) { + const message = err instanceof Error ? err.message : "Unauthorized"; + return NextResponse.json({ error: message }, { status: 401 }); + } + const { letterheadId } = await params; + const layout = await getSlipLayout(letterheadId); + return NextResponse.json({ layout }); +} + +/** + * PUT /api/v1/pos/slip-layout/[letterheadId] + * + * Body: any subset of `{ sale_blocks, delivery_blocks, cashup_blocks, + * delivery_show_pricing }` — only the fields given are written. + * Creates the row on first save. + * + * Auth: requires `Authorization: Bearer ${POS_API_KEY}`. + */ +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ letterheadId: string }> }, +) { + try { + assertPosAuth(req); + } catch (err) { + const message = err instanceof Error ? err.message : "Unauthorized"; + return NextResponse.json({ error: message }, { status: 401 }); + } + const { letterheadId } = await params; + const body = (await req.json()) as PosSlipLayoutInput; + const layout = await saveSlipLayout(letterheadId, body); + return NextResponse.json({ layout }); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/app/api/v1/pos/slip-layout` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/v1/pos/slip-layout +git commit -m "feat(pos): add GET/PUT /api/v1/pos/slip-layout/[letterheadId]" +``` + +--- + +### Task 3: Shared client module `public/pos/slip-layout.js` + +**Files:** +- Create: `public/pos/slip-layout.js` + +This is the single source of truth for block catalogs, defaults, and `resolveBlocks()`. No test framework exists for `public/pos/*.js` (confirmed: no `.test.ts`/`.test.js` anywhere under it) — verification here is `node --check` for syntax plus a manual browser check, matching how `epos-print.js` was built. + +- [ ] **Step 1: Create the file** + +```js +// Per-letterhead slip layout: which blocks print, in what order, +// enabled or not, with what custom text. Single source of truth for +// the editor (slip-layout.html), its live preview, and both print +// transports (epos-print.js's XML builders, receipt.html's ESC/POS +// builders) — all three must resolve the same way or the editor +// would silently not apply to whichever transport a till is using. +window.PosSlipLayout = (function () { + 'use strict'; + + // Blocks that can be reordered but never disabled — a slip missing + // these stops being a valid record of the transaction (e.g. a + // receipt with no total). + const REQUIRED = { + sale: ['items', 'totals'], + delivery: ['items', 'totals'], + cashup: ['payment_breakdown', 'grand_total'], + }; + + const LABELS = { + header_title: 'Header title', + logo: 'Logo', + business_info: 'Business info', + date_slip: 'Date & slip number', + customer: 'Customer', + items: 'Item list', + totals: 'Totals & VAT', + payment: 'Payment & change', + footer: 'Footer message', + date: 'Date', + signature: 'Signature line', + shift_date: 'Shift & date', + payment_breakdown: 'Payment breakdown', + grand_total: 'Grand total', + cash_reconciliation: 'Cash reconciliation', + denominations: 'Denominations counted', + }; + + // Matches what each builder actually prints today (verified by + // reading buildEposXml/buildEposDeliveryXml/buildEposCashupXml and + // their Bluetooth ESC/POS equivalents) — this formalizes existing + // content into blocks, it doesn't add anything new. Notably, + // delivery notes don't print business info today (only a title), + // and cash-up's title line becomes its own header_title block for + // consistency with the other two slip types. + const DEFAULTS = { + sale: [ + { id: 'header_title', enabled: true, text: null }, + { id: 'logo', enabled: true }, + { id: 'business_info', enabled: true }, + { id: 'date_slip', enabled: true }, + { id: 'customer', enabled: true }, + { id: 'items', enabled: true }, + { id: 'totals', enabled: true }, + { id: 'payment', enabled: true }, + { id: 'footer', enabled: true, text: null }, + ], + delivery: [ + { id: 'header_title', enabled: true, text: null }, + { id: 'customer', enabled: true }, + { id: 'date', enabled: true }, + { id: 'items', enabled: true }, + { id: 'totals', enabled: true }, + { id: 'signature', enabled: true }, + ], + cashup: [ + { id: 'header_title', enabled: true, text: null }, + { id: 'business_info', enabled: true }, + { id: 'shift_date', enabled: true }, + { id: 'payment_breakdown', enabled: true }, + { id: 'grand_total', enabled: true }, + { id: 'cash_reconciliation', enabled: true }, + { id: 'denominations', enabled: true }, + { id: 'footer', enabled: true, text: null }, + ], + }; + + const DEFAULT_SHOW_PRICING = true; + const DEFAULT_HEADER_TITLE = 'WFSALES POS'; + + function isRequired(slipType, blockId) { + return (REQUIRED[slipType] || []).includes(blockId); + } + + function labelFor(blockId) { + return LABELS[blockId] || blockId; + } + + // Merge saved blocks with the default catalog: saved order/enabled/ + // text wins. Any default block missing from `saved` (first time + // this slip type has been customized, or a new block id shipped + // after the letterhead's layout was last saved) is appended so + // nothing silently disappears. Unknown/retired ids in `saved` are + // dropped. Required blocks are forced enabled regardless of what + // was saved, defensively. + function resolveBlocks(slipType, saved) { + const defaults = DEFAULTS[slipType] || []; + const base = Array.isArray(saved) && saved.length ? saved.slice() : defaults.map(function (b) { return Object.assign({}, b); }); + const seen = {}; + base.forEach(function (b) { seen[b.id] = true; }); + defaults.forEach(function (def) { + if (!seen[def.id]) base.push(Object.assign({}, def)); + }); + return base + .filter(function (b) { return defaults.some(function (d) { return d.id === b.id; }); }) + .map(function (b) { + return { + id: b.id, + enabled: isRequired(slipType, b.id) ? true : !!b.enabled, + text: b.text != null ? b.text : null, + }; + }); + } + + // -------- cache (till-local, mirrors letterhead-config.js) -------- + + function cacheKey(letterheadId) { return 'pos.slipLayout.' + letterheadId; } + + function getCached(letterheadId) { + try { return JSON.parse(localStorage.getItem(cacheKey(letterheadId)) || 'null'); } catch (e) { return null; } + } + + function setCached(letterheadId, layout) { + try { localStorage.setItem(cacheKey(letterheadId), JSON.stringify(layout)); } catch (e) { /* quota or disabled */ } + } + + // -------- network -------- + + async function fetchLayout(letterheadId) { + const r = await window.posFetch('/slip-layout/' + encodeURIComponent(letterheadId)); + if (!r.ok) throw new Error('slip-layout fetch returned ' + r.status); + const j = await r.json(); + if (j.layout) setCached(letterheadId, j.layout); + return j.layout; // null when nothing saved yet + } + + async function saveLayout(letterheadId, input) { + const r = await window.posFetch('/slip-layout/' + encodeURIComponent(letterheadId), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!r.ok) throw new Error('slip-layout save returned ' + r.status); + const j = await r.json(); + if (j.layout) setCached(letterheadId, j.layout); + return j.layout; + } + + // -------- print-time helpers -------- + // Printing must never block on a settings-sync failure: use the + // cached layout if we have one (refreshing it in the background for + // next time), else the hardcoded defaults. + + function getBlocksForPrint(letterheadId, slipType) { + const cached = letterheadId ? getCached(letterheadId) : null; + if (letterheadId) { + fetchLayout(letterheadId).catch(function () { /* keep cached/defaults on failure */ }); + } + const saved = cached ? cached[slipType + '_blocks'] : null; + return resolveBlocks(slipType, saved); + } + + function getShowPricingForPrint(letterheadId) { + const cached = letterheadId ? getCached(letterheadId) : null; + return cached && cached.delivery_show_pricing != null ? !!cached.delivery_show_pricing : DEFAULT_SHOW_PRICING; + } + + return { + DEFAULTS: DEFAULTS, + DEFAULT_SHOW_PRICING: DEFAULT_SHOW_PRICING, + DEFAULT_HEADER_TITLE: DEFAULT_HEADER_TITLE, + isRequired: isRequired, + labelFor: labelFor, + resolveBlocks: resolveBlocks, + getCached: getCached, + setCached: setCached, + fetchLayout: fetchLayout, + saveLayout: saveLayout, + getBlocksForPrint: getBlocksForPrint, + getShowPricingForPrint: getShowPricingForPrint, + }; +})(); +``` + +- [ ] **Step 2: Verify it loads without error** + +Run: `node --check public/pos/slip-layout.js` +Expected: no output, exit code 0 + +- [ ] **Step 3: Manual browser check** + +Serve `public/` locally and check in a console: + +```js +PosSlipLayout.resolveBlocks('sale', null).map(b => b.id) +// ["header_title","logo","business_info","date_slip","customer","items","totals","payment","footer"] +PosSlipLayout.resolveBlocks('sale', [{id:'items',enabled:false},{id:'logo',enabled:false}]) +// items stays enabled:true (required); logo enabled:false; footer/etc appended from defaults +``` + +- [ ] **Step 4: Commit** + +```bash +git add public/pos/slip-layout.js +git commit -m "feat(pos): add shared slip-layout block catalog and resolver" +``` + +--- + +### Task 4: Wire `slip-layout.js` into every page that needs it, bump SW cache + +**Files:** +- Modify: `public/pos/receipt.html` +- Modify: `public/pos/cashup.html` +- Modify: `public/pos/settings.html` +- Modify: `public/pos/sw.js` + +`slip-layout.js` must load **before** `epos-print.js` on every page that includes it, since Task 5-7 make `epos-print.js`'s builders call into `window.PosSlipLayout`. + +- [ ] **Step 1: `receipt.html`** — find (around line 317-318 based on current content, verify by searching): + +```html + + + +``` + +Change to: + +```html + + + + +``` + +- [ ] **Step 2: `cashup.html`** — find: + +```html + + + + +``` + +Change to: + +```html + + + + + +``` + +- [ ] **Step 3: `settings.html`** — find: + +```html + + + + +``` + +Change to: + +```html + + + + + +``` + +- [ ] **Step 4: `sw.js`** — add the new files to the precache list and bump the version. Find: + +```js +const CACHE = "pos-v22"; +const APP_SHELL = [ + './', + './index.html', + './pos.html', + './receipt.html', + './history.html', + './cashup.html', + './pos-config.js', + './pos-app.js', + './theme.js', + './manifest.json', + // Font Awesome 6 Free (solid) — bundled so the till still renders icons offline. + './fa/all.min.css', + './fa/webfonts/fa-solid-900.woff2', +]; +``` + +Change to (adjust to match whatever the actual current list is if it differs — read the file first; the key additions are `settings.html`, `letterhead-config.js`, `epos-print.js`, `slip-layout.js`, and the new `slip-layout.html` from Task 11): + +```js +const CACHE = "pos-v23"; +const APP_SHELL = [ + './', + './index.html', + './pos.html', + './receipt.html', + './history.html', + './cashup.html', + './settings.html', + './slip-layout.html', + './pos-config.js', + './pos-app.js', + './theme.js', + './letterhead-config.js', + './epos-print.js', + './slip-layout.js', + './manifest.json', + // Font Awesome 6 Free (solid) — bundled so the till still renders icons offline. + './fa/all.min.css', + './fa/webfonts/fa-solid-900.woff2', +]; +``` + +- [ ] **Step 5: Verify** + +Run: `node --check public/pos/sw.js` +Expected: no output, exit code 0 + +- [ ] **Step 6: Commit** + +```bash +git add public/pos/receipt.html public/pos/cashup.html public/pos/settings.html public/pos/sw.js +git commit -m "chore(pos): wire slip-layout.js into receipt/cashup/settings, bump SW cache" +``` + +--- + +### Task 5: Refactor `buildEposXml` (sale slip, network) to be block-driven + +**Files:** +- Modify: `public/pos/epos-print.js` +- Modify: `public/pos/receipt.html` + +- [ ] **Step 1: Replace `buildEposXml` in `epos-print.js`** + +Find the existing `buildEposXml` function (currently ~lines 77-121) and replace it entirely with: + +```js + // Per-block XML fragment renderers for the sale slip. Each function + // returns the exact XML this block contributes; `block` is the + // resolved block ({id, enabled, text}) so header_title/footer can + // read their custom text. Dividers are owned by whichever block + // came after them in the original fixed sequence — reordering a + // block takes its leading divider with it. + function saleBlockRenderers(s, logo) { + const sub = s.sub != null ? s.sub : (s.total - (s.vat || 0)); + const vat = s.vat != null ? s.vat : (s.total * 15 / 115); + const discount = s.discount || { kind: 'none', input: 0, amount: 0 }; + const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0; + return { + header_title: function (block) { + const title = block.text || 'WFSALES POS'; + return '' + xmlEscape(title) + ' '; + }, + logo: function () { + return logo ? `${logo.base64}` : ''; + }, + business_info: function () { + const parts = []; + if (s.letterhead && s.letterhead.name) parts.push(`${xmlEscape(s.letterhead.name)} `); + const addrLines = []; + if (s.letterhead && s.letterhead.address) addrLines.push(xmlEscape(s.letterhead.address.split('\n')[0])); + if (s.letterhead && s.letterhead.phone) addrLines.push('Tel: ' + xmlEscape(s.letterhead.phone)); + if (s.letterhead && s.letterhead.vat_number) addrLines.push('VAT: ' + xmlEscape(s.letterhead.vat_number)); + if (addrLines.length) parts.push(`${addrLines.join(' ')} `); + return parts.join(''); + }, + date_slip: function () { + const d = new Date(s.createdAt); + return '-------------------------------- ' + + `Date: ${xmlEscape(d.toLocaleString('en-ZA'))} Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())} `; + }, + customer: function () { + return s.customer ? `Customer: ${xmlEscape(s.customer.name)} ` : ''; + }, + items: function () { + const itemLines = s.items.map(function (it) { + const priceLine = ` ${it.qty} x ${(it.selling_price || 0).toFixed(2)} ${(it.selling_price * it.qty).toFixed(2)}`; + return `${xmlEscape(it.description || it.sku)} ${xmlEscape(priceLine)} `; + }).join(''); + return `-------------------------------- ${itemLines}`; + }, + totals: function () { + const parts = ['-------------------------------- ']; + if (hasDiscount) { + const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price) || 0) * (Number(it.qty) || 0), 0); + const grossSub = gross / 1.15; + const grossVat = gross - grossSub; + parts.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)} VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)} Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)} Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)} `); + } + parts.push(`Subtotal${' '.repeat(22)}R ${sub.toFixed(2)} VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)} `); + parts.push(`TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)} `); + return parts.join(''); + }, + payment: function () { + const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase(); + let out = `Paid: ${xmlEscape(paidLabel)} `; + if (s.payment === 'cash') out += `Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)} `; + return out; + }, + footer: function (block) { + const text = block.text || (s.letterhead && s.letterhead.footer_text); + let out = '-------------------------------- '; + if (text) out += `${xmlEscape(text)} `; + return out; + }, + }; + } + + // `blocks` is the resolved block list from PosSlipLayout.resolveBlocks('sale', ...) + // (or getBlocksForPrint). Renders each enabled block in order, then + // the fixed closing feed+cut every sale slip ends with. + function buildEposXml(s, logo, blocks) { + const renderers = saleBlockRenderers(s, logo); + const body = blocks + .filter(function (b) { return b.enabled; }) + .map(function (b) { return renderers[b.id] ? renderers[b.id](b) : ''; }) + .join(''); + return `${body}`; + } +``` + +- [ ] **Step 2: Update the two callers in `epos-print.js` itself — `printTest`** + +Find: + +```js + const logo = lh.logo_url + ? await imageToRaster(lh.logo_url, 384).catch(() => null) + : null; + const xml = buildEposXml(sale, logo); +``` + +Change to: + +```js + const logo = lh.logo_url + ? await imageToRaster(lh.logo_url, 384).catch(() => null) + : null; + const blocks = window.PosSlipLayout.getBlocksForPrint(lh.id, 'sale'); + const xml = buildEposXml(sale, logo, blocks); +``` + +- [ ] **Step 3: Update the caller in `receipt.html`'s `printThermalNetwork`** + +Find: + +```js + } else { + const logo = await window.EposPrint.imageToRaster('/wfdash-logo.png', 384).catch(()=>null); + xml = window.EposPrint.buildEposXml(sale, logo); + successLabel = 'Printed · '; + } +``` + +Change to: + +```js + } else { + const logo = await window.EposPrint.imageToRaster('/wfdash-logo.png', 384).catch(()=>null); + const letterheadId = sale.letterhead && sale.letterhead.id; + const blocks = window.PosSlipLayout.getBlocksForPrint(letterheadId, 'sale'); + xml = window.EposPrint.buildEposXml(sale, logo, blocks); + successLabel = 'Printed · '; + } +``` + +- [ ] **Step 4: Verify syntax** + +Run: `node --check public/pos/epos-print.js` +Expected: no output, exit code 0 + +- [ ] **Step 5: Manual regression check — default blocks produce identical output** + +In a browser console with a fake sale (reuse the `fakeSale` shape from earlier manual testing in this project, or build one inline): + +```js +const blocks = PosSlipLayout.resolveBlocks('sale', null); +const xml = EposPrint.buildEposXml(fakeSale, null, blocks); +console.log(xml); +``` + +Expected: identical structure to what `buildEposXml` produced before this change (same tags, same order) — no `` tag since `logo` is `null` here, everything else present. + +- [ ] **Step 6: Manual test against the real printer** + +With Printer type set to Network, print a real (or test) sale slip. Expected: identical output to before this change. + +- [ ] **Step 7: Commit** + +```bash +git add public/pos/epos-print.js public/pos/receipt.html +git commit -m "refactor(pos): make buildEposXml block-driven" +``` + +--- + +### Task 6: Refactor `buildEposDeliveryXml` (delivery note, network) to be block-driven + +**Files:** +- Modify: `public/pos/epos-print.js` +- Modify: `public/pos/receipt.html` + +- [ ] **Step 1: Replace `buildEposDeliveryXml` in `epos-print.js`** + +Find the existing function and replace it with: + +```js + function deliveryBlockRenderers(s, showPricing) { + return { + header_title: function (block) { + const title = block.text || 'WFSALES POS'; + return `${xmlEscape(title)} Delivery Note `; + }, + customer: function () { + let out = ''; + if (s.customer && s.customer.company) out += `Company: ${xmlEscape(s.customer.company)} `; + out += `Customer: ${xmlEscape((s.customer && s.customer.name) || '—')} `; + return out; + }, + date: function () { + const d = new Date(s.createdAt); + return `Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))} `; + }, + items: function () { + const itemLines = s.items.map(function (it) { + const qtyLine = ` ${it.qty} ${it.unit || 'ea'}`; + const priceSuffix = showPricing ? ` R ${((it.selling_price || 0) * it.qty).toFixed(2)}` : ''; + return `${xmlEscape(it.description || it.sku)} ${xmlEscape(qtyLine + priceSuffix)} `; + }).join(''); + return `-------------------------------- ${itemLines}`; + }, + totals: function () { + let out = '-------------------------------- '; + if (showPricing) out += `TOTAL: R ${s.total.toFixed(2)} `; + return out; + }, + signature: function () { + return 'Received by: __________ Driver: __________ '; + }, + }; + } + + // `blocks` is the resolved block list for 'delivery'. `showPricing` + // is the combined "Show pricing" setting — it gates the per-item + // price and the totals block together (see slip-layout.js). + function buildEposDeliveryXml(s, blocks, showPricing) { + const renderers = deliveryBlockRenderers(s, showPricing); + const body = blocks + .filter(function (b) { return b.enabled; }) + .map(function (b) { return renderers[b.id] ? renderers[b.id](b) : ''; }) + .join(''); + return `${body}`; + } +``` + +- [ ] **Step 2: Update the two call sites in `receipt.html`** + +Find (in `printThermalNetwork`): + +```js + if (sale.payment === 'account') { + // Logos don't always render cleanly on the TM-T20II at small + // sizes; the delivery note skips the logo so the address + items + // get the most space. If you want the logo back, set `logo` + // here and pass it to buildEposDeliveryXml the same way + // buildEposXml takes it. + xml = window.EposPrint.buildEposDeliveryXml(sale); + successLabel = 'Delivery note printed · '; +``` + +Change to: + +```js + if (sale.payment === 'account') { + const letterheadId = sale.letterhead && sale.letterhead.id; + const deliveryBlocks = window.PosSlipLayout.getBlocksForPrint(letterheadId, 'delivery'); + const dnToggle = document.getElementById('dnShowPrices'); + const showPricing = dnToggle ? !!dnToggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadId); + xml = window.EposPrint.buildEposDeliveryXml(sale, deliveryBlocks, showPricing); + successLabel = 'Delivery note printed · '; +``` + +Find (in `printDelivery`'s network branch): + +```js + const dnToggleNet = document.getElementById('dnShowPrices'); + const showPricesNet = dnToggleNet ? !!dnToggleNet.checked : true; + try{ + const xml = window.EposPrint.buildEposDeliveryXml(sale, showPricesNet); +``` + +Change to: + +```js + const letterheadIdNet = sale.letterhead && sale.letterhead.id; + const dnToggleNet = document.getElementById('dnShowPrices'); + const showPricesNet = dnToggleNet ? !!dnToggleNet.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdNet); + try{ + const deliveryBlocksNet = window.PosSlipLayout.getBlocksForPrint(letterheadIdNet, 'delivery'); + const xml = window.EposPrint.buildEposDeliveryXml(sale, deliveryBlocksNet, showPricesNet); +``` + +- [ ] **Step 3: Verify syntax** + +Run: `node --check public/pos/epos-print.js` +Expected: no output, exit code 0 + +- [ ] **Step 4: Manual test against the real printer** + +Print a delivery note over network with defaults (pricing on) and with the on-screen "Include prices" checkbox unchecked. Expected: identical layout to before this task in the first case; no per-item prices or TOTAL line in the second. + +- [ ] **Step 5: Commit** + +```bash +git add public/pos/epos-print.js public/pos/receipt.html +git commit -m "refactor(pos): make buildEposDeliveryXml block-driven" +``` + +--- + +### Task 7: Refactor `buildEposCashupXml` to be block-driven, fix the broken logo tag + +**Files:** +- Modify: `public/pos/epos-print.js` +- Modify: `public/pos/cashup.html` + +The existing logo handling in `buildEposCashupXml` (`if (lh.logo_url) { parts.push('' + '' + '') }`) always emits an empty, invalid `` tag — a no-op today. This task fixes it by rasterizing the logo the same way `buildEposXml`/`printTest` already do, and makes the function `async` to do so (its one caller, `cashup.html`'s `printCashup()`, is already `async` and can `await` it). + +- [ ] **Step 1: Replace `buildEposCashupXml` in `epos-print.js`** + +Find the existing function (and its `pad`/`fmtMoney` helpers stay as-is) and replace the function itself with: + +```js + function cashupBlockRenderers(lh, r, logo) { + return { + header_title: function (block) { + const title = block.text || 'WFSALES POS'; + return '' + + `${xmlEscape(title)} ` + + 'CASH-UP REPORT '; + }, + logo: function () { + return logo ? `${logo.base64}` : ''; + }, + business_info: function () { + const parts = []; + if (lh.display_name) parts.push(`${xmlEscape(lh.display_name)} `); + if (lh.address) parts.push(`${xmlEscape(lh.address.split('\n')[0])} `); + if (lh.phone) parts.push(`Tel: ${xmlEscape(lh.phone)} `); + if (lh.vat_number) parts.push(`VAT: ${xmlEscape(lh.vat_number)} `); + return parts.join(''); + }, + shift_date: function () { + let out = '-------------------------------- '; + if (r.shiftLabel || r.dateLabel) out += `Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')} `; + if (r.counts) out += `Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account} `; + return out; + }, + payment_breakdown: function () { + const lines = [ + `Cash ${pad((r.counts && r.counts.cash) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.cash) || 0)}`, + `Card ${pad((r.counts && r.counts.card) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.card) || 0)}`, + `EFT ${pad((r.counts && r.counts.eft) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.eft) || 0)}`, + `Acct ${pad((r.counts && r.counts.account) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.account) || 0)}`, + ]; + return '-------------------------------- ' + + 'PAYMENT METHOD BREAKDOWN ' + + `${lines.join(' ')} `; + }, + grand_total: function () { + return '-------------------------------- ' + + `GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)} `; + }, + cash_reconciliation: function () { + return '-------------------------------- ' + + 'CASH RECONCILIATION ' + + `Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)} ` + + `Cash counted: R ${fmtMoney(r.cashCounted || 0)} ` + + `Variance: R ${fmtMoney(r.variance || 0)} `; + }, + denominations: function () { + if (!Array.isArray(r.denoms) || !r.denoms.length) return ''; + const dlines = r.denoms + .filter(function (d) { return (d.count || 0) > 0; }) + .map(function (d) { return `${d.label} x ${pad(d.count || 0, 3)} = R ${fmtMoney((d.value || 0) * (d.count || 0))}`; }); + if (!dlines.length) return ''; + return '-------------------------------- ' + + 'DENOMINATIONS COUNTED ' + + `${dlines.join(' ')} `; + }, + footer: function (block) { + const text = block.text || lh.footer_text; + if (!text) return ''; + return '-------------------------------- ' + + `${xmlEscape(text)} `; + }, + }; + } + + // `blocks` is the resolved block list for 'cashup'. `logo` is a + // pre-rasterized {width,height,base64} (see imageToRaster) or null — + // rasterizing is async, so the caller does it before calling this. + function buildEposCashupXml(letterhead, report, blocks, logo) { + const lh = letterhead || {}; + const r = report || {}; + const renderers = cashupBlockRenderers(lh, r, logo || null); + const body = blocks + .filter(function (b) { return b.enabled; }) + .map(function (b) { return renderers[b.id] ? renderers[b.id](b) : ''; }) + .join(''); + return `${body}`; + } +``` + +- [ ] **Step 2: Update `cashup.html`'s call site** + +Find: + +```js + const settings = window.EposPrint.getSettings(); + const xml = window.EposPrint.buildEposCashupXml(letterhead, report); + if(settings.type === 'network'){ +``` + +Change to: + +```js + const settings = window.EposPrint.getSettings(); + const cashupBlocks = window.PosSlipLayout.getBlocksForPrint(letterhead.id, 'cashup'); + const cashupLogo = letterhead.logo_url + ? await window.EposPrint.imageToRaster(letterhead.logo_url, 384).catch(() => null) + : null; + const xml = window.EposPrint.buildEposCashupXml(letterhead, report, cashupBlocks, cashupLogo); + if(settings.type === 'network'){ +``` + +- [ ] **Step 3: Verify syntax** + +Run: `node --check public/pos/epos-print.js && node --check public/pos/cashup.html 2>&1 | head -5 || true` + +(cashup.html is HTML, not pure JS — `node --check` will fail to parse it as a whole file; instead just confirm the inline script block still parses:) + +Run: `sed -n '/', content, re.S) +open('/tmp/receipt-inline.js','w').write(m.group(0)[len('')]) +"` +Run: `node --check /tmp/receipt-inline.js` +Expected: no output, exit code 0 + +- [ ] **Step 4: Manual regression check — default blocks produce identical output** + +In a browser console on `receipt.html` with a real/fake `sale` in scope: + +```js +buildEscPos(sale, PosSlipLayout.resolveBlocks('sale', null)) +buildEscPosDelivery(sale, true, PosSlipLayout.resolveBlocks('delivery', null)) +``` + +Expected: both return byte arrays / strings matching the pre-refactor output structure (same ESC/POS control codes and text in the same order). + +- [ ] **Step 5: Manual test against a real Bluetooth printer** + +Pair a Bluetooth thermal printer, print a sale slip and (for an account sale) a delivery note. Expected: identical output to before Tasks 8-9. + +- [ ] **Step 6: Commit** + +```bash +git add public/pos/receipt.html +git commit -m "refactor(pos): make buildEscPosDelivery (Bluetooth delivery note) block-driven" +``` + +--- + +### Task 10: Delivery note's on-screen pricing checkbox defaults from the saved setting + +**Files:** +- Modify: `public/pos/receipt.html` + +Today `renderDeliveryNote()` always defaults its "Include prices" checkbox to checked. This wires it to the per-letterhead `delivery_show_pricing` default instead, while keeping the checkbox as a per-print override. + +- [ ] **Step 1: Update `renderDeliveryNote`** + +Find: + +```js +function renderDeliveryNote(s){ + const date = new Date(s.createdAt); + const dateStr = date.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}); + // Some customers must not see prices on the slip the driver carries; + // others want them. Default to showing, toggle re-renders in place. + const toggle = document.getElementById('dnShowPrices'); + const showPrices = toggle ? !!toggle.checked : true; +``` + +Change to: + +```js +function renderDeliveryNote(s){ + const date = new Date(s.createdAt); + const dateStr = date.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}); + // Some customers must not see prices on the slip the driver carries; + // others want them. Defaults from the letterhead's saved setting + // (Settings → Edit slip layout); the checkbox is a per-print override. + const toggle = document.getElementById('dnShowPrices'); + const letterheadIdDn = s.letterhead && s.letterhead.id; + const showPrices = toggle ? !!toggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdDn); +``` + +- [ ] **Step 2: Verify the inline script still parses** + +Run the same extraction + `node --check` command from Task 9 Step 3. +Expected: no output, exit code 0 + +- [ ] **Step 3: Manual check** + +Set `delivery_show_pricing: false` for the active letterhead (via a direct `PUT` call, since the editor UI doesn't exist until Task 11 — see Task 11 Step 6 for the curl command), then open an account sale's receipt page. Expected: "Include prices" is unchecked by default. + +- [ ] **Step 4: Commit** + +```bash +git add public/pos/receipt.html +git commit -m "feat(pos): delivery note pricing checkbox defaults from the letterhead's saved setting" +``` + +--- + +### Task 11: `slip-layout.html` — the editor + preview page + +**Files:** +- Create: `public/pos/slip-layout.html` +- Modify: `public/pos/settings.html` (remove the preview section, add a link) + +This moves the "Slip preview" section (tabs + `receipt-preview` pane + `previewSale`/`previewDelivery`/`previewCashup`) out of `settings.html` into this new page, and adds the block-editing UI around it. The preview functions become block-aware. + +- [ ] **Step 1: Create `slip-layout.html`** + +```html + + + + + + +Slip layout — WFSales POS + + + + + + +
+ +

Slip layout

+
+ +

Choose what prints on each slip, in what order, for the active letterhead.

+ + + +
+ + + +
+
+
Loading preview…
+
+ + +
+ + + +
+ +
+ + + + + + + + + +``` + +- [ ] **Step 2: Verify syntax** + +Run: `python3 -c " +import re +content = open('public/pos/slip-layout.html').read() +m = re.search(r'', content, re.S) +open('/tmp/slip-layout-inline.js','w').write(m.group(0)[len('')]) +"` +Run: `node --check /tmp/slip-layout-inline.js` +Expected: no output, exit code 0 + +- [ ] **Step 3: Remove the preview section from `settings.html`, add a link instead** + +Find: + +```html + +

A preview of what each slip type will print with the active letterhead. Sample data — not from a real sale.

+
+ + + +
+
+
Loading preview…
+
+

+``` + +Change to: + +```html + + +``` + +- [ ] **Step 4: Remove the now-moved preview JS from `settings.html`** + +Delete the `// ---------------- Slip preview ----------------` section and everything through the end of `previewCashup` (from `let currentPreviewType = 'sale';` through the closing of `function previewCashup(lh){...}`), and delete the `DEMO_LETTERHEAD` constant along with it (it's only used by the preview). Also remove the `renderPreview()` call and the `previewNote` handling from `boot()` — find: + +```js + paintActive(active); + // Always render the slip preview, even if the list fetch fails. + // The preview uses a demo letterhead fallback so the operator can + // sanity-check the layout before committing to a real letterhead. + renderPreview(); + const note = document.getElementById('previewNote'); + if(note){ + note.textContent = active + ? 'Preview uses your selected letterhead.' + : 'Preview only — pick a letterhead above to see your real one.'; + } +``` + +Change to: + +```js + paintActive(active); +``` + +- [ ] **Step 5: Verify `settings.html`'s inline script still parses** + +Run: `python3 -c " +import re +content = open('public/pos/settings.html').read() +m = re.search(r'', content, re.S) +open('/tmp/settings-inline.js','w').write(m.group(0)[len('')]) +"` +Run: `node --check /tmp/settings-inline.js` +Expected: no output, exit code 0 + +- [ ] **Step 6: Manual end-to-end check** + +Open `settings.html`, click "Edit slip layout", confirm it navigates to `slip-layout.html` with the preview rendering the sample sale slip. Switch tabs, toggle a block off, reorder two blocks with the ▲/▼ buttons, edit the footer text — confirm the preview updates live each time. Click "Save layout", reload the page, confirm the edits persisted (fetched back from the server). To verify the pricing toggle without waiting on UI polish: `curl -X PUT https:///api/v1/pos/slip-layout/ -H "Authorization: Bearer $POS_API_KEY" -H "Content-Type: application/json" -d '{"delivery_show_pricing": false}'`. + +- [ ] **Step 7: Add `slip-layout.html` to the SW precache list (should already be in Task 4's edit — verify)** + +Run: `grep -n "slip-layout" public/pos/sw.js` +Expected: both `./slip-layout.html` and `./slip-layout.js` present. If not, add them and bump `CACHE` again. + +- [ ] **Step 8: Commit** + +```bash +git add public/pos/slip-layout.html public/pos/settings.html public/pos/sw.js +git commit -m "feat(pos): add slip-layout.html block editor, move preview out of settings.html" +``` + +--- + +### Task 12: Full end-to-end verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full server-side test suite** + +Run: `npx vitest run` +Expected: all tests pass, including the new `pos-slip-layout-service.test.ts` and `slip-layout/[letterheadId]/route.test.ts`. + +- [ ] **Step 2: Type-check** + +Run: `npx tsc --noEmit -p .` +Expected: no errors. + +- [ ] **Step 3: Default-layout regression on real hardware** + +With no layout ever saved for the active letterhead (or after resetting one), print a sale slip, a delivery note, and a cash-up report on both Network and Bluetooth transports. Expected: all six outputs match what the till printed before this plan, byte-for-byte in content (whitespace/line arrangement may shift only where explicitly changed, e.g. the cash-up logo now actually printing). + +- [ ] **Step 4: Customized-layout check on real hardware** + +In `slip-layout.html`, disable the logo and footer on the sale slip, move "Payment & change" above "Totals & VAT", save. Print a real sale slip on whichever transport the till currently uses. Expected: printed slip has no logo, no footer, and Paid/Change lines print before Subtotal/VAT/TOTAL. + +- [ ] **Step 5: Delivery pricing default check** + +Set "Show pricing by default" off for the active letterhead in `slip-layout.html`, save. Ring up a fresh account sale, open its receipt page. Expected: the on-screen "Include prices" checkbox is unchecked, and printing (either transport) omits per-item prices and the TOTAL line unless the checkbox is manually re-checked.