81 KiB
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_infoblock for delivery notes; the title becomes aheader_titleblock 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_titlethere 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
// 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
// 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<PosSlipLayout>;
function escapePbValue(value: string): string {
return value.replace(/'/g, "\\'");
}
async function findRow(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
letterheadId: string,
): Promise<PosSlipLayoutRow | null> {
const page = await pb.collection("pos_slip_layouts").getList<PosSlipLayoutRow>(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<PosSlipLayout | null> {
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<PosSlipLayout> {
const pb = await getAdminPocketBase();
const existing = await findRow(pb, letterheadId);
const data: Record<string, unknown> = { 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
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
// 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<string, string> = { "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
// 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
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
// 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:
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
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):
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="epos-print.js"></script>
Change to:
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="slip-layout.js"></script>
<script src="epos-print.js"></script>
- Step 2:
cashup.html— find:
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>
<script src="epos-print.js"></script>
Change to:
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>
<script src="slip-layout.js"></script>
<script src="epos-print.js"></script>
- Step 3:
settings.html— find:
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>
<script src="epos-print.js"></script>
Change to:
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>
<script src="slip-layout.js"></script>
<script src="epos-print.js"></script>
- Step 4:
sw.js— add the new files to the precache list and bump the version. Find:
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):
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
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
buildEposXmlinepos-print.js
Find the existing buildEposXml function (currently ~lines 77-121) and replace it entirely with:
// 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 '<feed line="1"/><text align="center" width="2" height="2">' + xmlEscape(title) + ' </text>';
},
logo: function () {
return logo ? `<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>` : '';
},
business_info: function () {
const parts = [];
if (s.letterhead && s.letterhead.name) parts.push(`<text align="center">${xmlEscape(s.letterhead.name)} </text>`);
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(`<text align="center">${addrLines.join(' ')} </text>`);
return parts.join('');
},
date_slip: function () {
const d = new Date(s.createdAt);
return '<text align="left">-------------------------------- </text>'
+ `<text align="left">Date: ${xmlEscape(d.toLocaleString('en-ZA'))} Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())} </text>`;
},
customer: function () {
return s.customer ? `<text align="left">Customer: ${xmlEscape(s.customer.name)} </text>` : '';
},
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 `<text align="left">-------------------------------- ${itemLines}</text>`;
},
totals: function () {
const parts = ['<text align="left">-------------------------------- </text>'];
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(`<text align="left">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)} </text>`);
}
parts.push(`<text align="left">Subtotal${' '.repeat(22)}R ${sub.toFixed(2)} VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)} </text>`);
parts.push(`<text align="left" em="true">TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)} </text>`);
return parts.join('');
},
payment: function () {
const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase();
let out = `<text align="left">Paid: ${xmlEscape(paidLabel)} </text>`;
if (s.payment === 'cash') out += `<text align="left">Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)} </text>`;
return out;
},
footer: function (block) {
const text = block.text || (s.letterhead && s.letterhead.footer_text);
let out = '<text align="left">-------------------------------- </text>';
if (text) out += `<text align="center">${xmlEscape(text)} </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 `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${body}<feed line="3"/><cut type="feed"/></epos-print></s:Body></s:Envelope>`;
}
- Step 2: Update the two callers in
epos-print.jsitself —printTest
Find:
const logo = lh.logo_url
? await imageToRaster(lh.logo_url, 384).catch(() => null)
: null;
const xml = buildEposXml(sale, logo);
Change to:
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'sprintThermalNetwork
Find:
} else {
const logo = await window.EposPrint.imageToRaster('/wfdash-logo.png', 384).catch(()=>null);
xml = window.EposPrint.buildEposXml(sale, logo);
successLabel = 'Printed · ';
}
Change to:
} 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):
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 <image> 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
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
buildEposDeliveryXmlinepos-print.js
Find the existing function and replace it with:
function deliveryBlockRenderers(s, showPricing) {
return {
header_title: function (block) {
const title = block.text || 'WFSALES POS';
return `<text align="center" width="1" height="2">${xmlEscape(title)} </text><text align="center">Delivery Note </text>`;
},
customer: function () {
let out = '';
if (s.customer && s.customer.company) out += `<text align="left">Company: ${xmlEscape(s.customer.company)} </text>`;
out += `<text align="left">Customer: ${xmlEscape((s.customer && s.customer.name) || '—')} </text>`;
return out;
},
date: function () {
const d = new Date(s.createdAt);
return `<text align="left">Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))} </text>`;
},
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 `<text align="left">-------------------------------- ${itemLines}</text>`;
},
totals: function () {
let out = '<text align="left">-------------------------------- </text>';
if (showPricing) out += `<text align="left">TOTAL: R ${s.total.toFixed(2)} </text>`;
return out;
},
signature: function () {
return '<text align="left">Received by: __________ Driver: __________ </text>';
},
};
}
// `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 `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${body}<feed line="3"/><cut type="feed"/></epos-print></s:Body></s:Envelope>`;
}
- Step 2: Update the two call sites in
receipt.html
Find (in printThermalNetwork):
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:
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):
const dnToggleNet = document.getElementById('dnShowPrices');
const showPricesNet = dnToggleNet ? !!dnToggleNet.checked : true;
try{
const xml = window.EposPrint.buildEposDeliveryXml(sale, showPricesNet);
Change to:
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
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('<image ... >' + '' + '</image>') }) always emits an empty, invalid <image> 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
buildEposCashupXmlinepos-print.js
Find the existing function (and its pad/fmtMoney helpers stay as-is) and replace the function itself with:
function cashupBlockRenderers(lh, r, logo) {
return {
header_title: function (block) {
const title = block.text || 'WFSALES POS';
return '<feed line="1"/>'
+ `<text align="center" width="2" height="2">${xmlEscape(title)} </text>`
+ '<text align="center" width="1" height="1">CASH-UP REPORT </text>';
},
logo: function () {
return logo ? `<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>` : '';
},
business_info: function () {
const parts = [];
if (lh.display_name) parts.push(`<text align="center">${xmlEscape(lh.display_name)} </text>`);
if (lh.address) parts.push(`<text align="center">${xmlEscape(lh.address.split('\n')[0])} </text>`);
if (lh.phone) parts.push(`<text align="center">Tel: ${xmlEscape(lh.phone)} </text>`);
if (lh.vat_number) parts.push(`<text align="center">VAT: ${xmlEscape(lh.vat_number)} </text>`);
return parts.join('');
},
shift_date: function () {
let out = '<text align="left">-------------------------------- </text>';
if (r.shiftLabel || r.dateLabel) out += `<text align="left">Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')} </text>`;
if (r.counts) out += `<text align="left">Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account} </text>`;
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 '<text align="left">-------------------------------- </text>'
+ '<text align="left">PAYMENT METHOD BREAKDOWN </text>'
+ `<text align="left">${lines.join(' ')} </text>`;
},
grand_total: function () {
return '<text align="left">-------------------------------- </text>'
+ `<text align="left" em="true">GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)} </text>`;
},
cash_reconciliation: function () {
return '<text align="left">-------------------------------- </text>'
+ '<text align="left">CASH RECONCILIATION </text>'
+ `<text align="left">Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)} </text>`
+ `<text align="left">Cash counted: R ${fmtMoney(r.cashCounted || 0)} </text>`
+ `<text align="left" em="${((r.variance || 0) === 0) ? 'true' : 'false'}">Variance: R ${fmtMoney(r.variance || 0)} </text>`;
},
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 '<text align="left">-------------------------------- </text>'
+ '<text align="left">DENOMINATIONS COUNTED </text>'
+ `<text align="left">${dlines.join(' ')} </text>`;
},
footer: function (block) {
const text = block.text || lh.footer_text;
if (!text) return '';
return '<text align="left">-------------------------------- </text>'
+ `<text align="center">${xmlEscape(text)} </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 `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${body}<feed line="3"/><cut type="feed"/></epos-print></s:Body></s:Envelope>`;
}
- Step 2: Update
cashup.html's call site
Find:
const settings = window.EposPrint.getSettings();
const xml = window.EposPrint.buildEposCashupXml(letterhead, report);
if(settings.type === 'network'){
Change to:
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 '/<script>$/,/^<\/script>$/p' public/pos/cashup.html | sed '1d;$d' > /tmp/cashup-inline.js && node --check /tmp/cashup-inline.js && echo OK
Expected: OK
- Step 4: Manual test against the real printer
Set a letterhead with a logo, print a cash-up report over network. Expected: the logo now actually prints (previously a no-op), everything else matches the prior cash-up layout.
- Step 5: Commit
git add public/pos/epos-print.js public/pos/cashup.html
git commit -m "refactor(pos): make buildEposCashupXml block-driven, fix broken logo image tag"
Task 8: Refactor buildEscPos (sale slip, Bluetooth) to be block-driven
Files:
- Modify:
public/pos/receipt.html
Bluetooth has never supported printing a raster logo image in this app (no GATT-based image transfer exists here) — the logo block simply renders as empty for this transport. This is the only intentional content difference between the two transports for the same block catalog.
- Step 1: Replace
buildEscPos
Find the existing function and replace it with:
function saleEscPosRenderers(s){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B), GS = E(0x1D);
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: (block) => ESC+'a'+E(1) + ESC+'!'+E(0x30) + (block.text||'WFSALES POS') + '\n' + ESC+'!'+E(0x00),
// Web Bluetooth GATT printing has no raster-image support in this
// app — this block intentionally prints nothing on this transport.
logo: () => '',
business_info: () => {
let out = ESC+'a'+E(0);
if(s.letterhead?.name) out += s.letterhead.name+'\n';
if(s.letterhead){
if(s.letterhead.address) out += s.letterhead.address.split('\n')[0]+'\n';
if(s.letterhead.phone) out += 'Tel: '+s.letterhead.phone+'\n';
if(s.letterhead.vat_number) out += 'VAT: '+s.letterhead.vat_number+'\n';
}
return out;
},
date_slip: () => {
const d = new Date(s.createdAt);
return '--------------------------------\n'
+ 'Date: ' + d.toLocaleString('en-ZA')+'\n'
+ 'Slip: ' + (s.localId||'').slice(-8).toUpperCase()+'\n';
},
customer: () => s.customer ? ('Customer: ' + s.customer.name + '\n') : '',
items: () => {
let out = '--------------------------------\n';
for(const it of s.items){
out += (it.description||it.sku) + '\n';
out += ` ${it.qty} x ${(it.selling_price||0).toFixed(2)} ${(it.selling_price*it.qty).toFixed(2)}\n`;
}
return out;
},
totals: () => {
let out = '--------------------------------\n';
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;
out += `Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`;
out += `VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}\n`;
out += `Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`;
out += `Discount${discount.kind==='percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}\n`;
}
out += `Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}\n`;
out += `VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}\n`;
out += ESC+'!'+E(0x08) + `TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}\n` + ESC+'!'+E(0x00);
return out;
},
payment: () => {
let out = 'Paid: ' + (s.payment==='cash' ? `CASH R ${(s.cashGiven||0).toFixed(2)}` : s.payment.toUpperCase()) + '\n';
if(s.payment==='cash') out += `Change${' '.repeat(24)}R ${(s.change||0).toFixed(2)}\n`;
return out;
},
footer: (block) => {
const text = block.text || s.letterhead?.footer_text;
let out = '--------------------------------\n';
if(text) out += text+'\n';
return out;
},
};
}
// `blocks` is the resolved block list for 'sale'.
function buildEscPos(s, blocks){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B), GS = E(0x1D);
const renderers = saleEscPosRenderers(s);
const body = blocks.filter(b=>b.enabled).map(b => renderers[b.id] ? renderers[b.id](b) : '').join('');
const text = ESC+'@' + body + '\n\n' + GS+'V'+E(0x41)+E(0x10);
const bytes = new Uint8Array(text.length);
for(let i=0;i<text.length;i++) bytes[i] = text.charCodeAt(i) & 0xff;
return bytes;
}
- Step 2: Update the caller in
printThermalBluetooth
Find:
const text = sale.payment === 'account'
? buildEscPosDelivery(sale, showPrices)
: buildEscPos(sale);
Change to:
const letterheadIdBt = sale.letterhead && sale.letterhead.id;
const text = sale.payment === 'account'
? buildEscPosDelivery(sale, showPrices, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'delivery'))
: buildEscPos(sale, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'sale'));
(Note: buildEscPosDelivery's new signature is defined in Task 9 — this call site is updated once for both changes since it calls both functions.)
- Step 3: Verify the script still parses
Run: sed -n '/^<script>$/,/^<\/script>$/p' public/pos/receipt.html | sed '1d;$d' | node --check /dev/stdin
(If the file has multiple <script> blocks this grabs the first inline one that starts at column 0 — verify against the actual current line numbers instead if this doesn't isolate the right block: sed -n '<start>,<end>p' public/pos/receipt.html | node --check /dev/stdin using the line range of the big IIFE script.)
Expected: no output, exit code 0 (this will fail until Task 9's buildEscPosDelivery signature change also lands, since this call site references it — land Task 8 and Task 9 together if running them as separate agent dispatches, or do Task 9 immediately after before verifying)
- Step 4: Commit
git add public/pos/receipt.html
git commit -m "refactor(pos): make buildEscPos (Bluetooth sale slip) block-driven"
Task 9: Refactor buildEscPosDelivery (delivery note, Bluetooth) to be block-driven
Files:
-
Modify:
public/pos/receipt.html -
Step 1: Replace
buildEscPosDelivery
Find the existing function and replace it with:
function deliveryEscPosRenderers(s, showPrices){
return {
header_title: (block) => {
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B);
const title = block.text || 'WFSALES POS';
return ESC+'a'+E(1) + ESC+'!'+E(0x10) + title + '\n' + ESC+'!'+E(0x00) + 'Delivery Note\n' + ESC+'a'+E(0);
},
customer: () => {
let out = '';
if(s.customer && s.customer.company) out += 'Company: ' + s.customer.company + '\n';
out += 'Customer: ' + (s.customer && s.customer.name || '—') + '\n';
return out;
},
date: () => {
const d = new Date(s.createdAt);
return 'Date: ' + d.toLocaleDateString('en-ZA')+'\n';
},
items: () => {
let out = '--------------------------------\n';
for(const it of s.items){
out += (it.description||it.sku)+'\n';
const qtyLine = ` ${it.qty} ${it.unit||'ea'}`;
out += showPrices ? `${qtyLine} ${fmt((it.selling_price||0)*it.qty)}\n` : `${qtyLine}\n`;
}
return out;
},
totals: () => {
let out = '--------------------------------\n';
if(showPrices) out += 'TOTAL: ' + fmt(s.total) + '\n';
return out;
},
signature: () => 'Received by: __________ Driver: __________\n',
};
}
// `blocks` is the resolved block list for 'delivery'.
function buildEscPosDelivery(s, showPrices, blocks){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B);
const renderers = deliveryEscPosRenderers(s, showPrices);
const body = blocks.filter(b=>b.enabled).map(b => renderers[b.id] ? renderers[b.id](b) : '').join('');
return ESC+'@' + body + '\n\n' + E(0x1D)+'V'+E(0x41)+E(0x10);
}
- Step 2: Update the two remaining call sites
printThermalBluetooth's call was already updated in Task 8 Step 2. Update printDelivery's Bluetooth branch — find:
const dnToggle = document.getElementById('dnShowPrices');
const showPrices = dnToggle ? !!dnToggle.checked : true;
const text = buildEscPosDelivery(sale, showPrices);
Change to:
const letterheadIdBtDn = sale.letterhead && sale.letterhead.id;
const dnToggle = document.getElementById('dnShowPrices');
const showPrices = dnToggle ? !!dnToggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdBtDn);
const text = buildEscPosDelivery(sale, showPrices, window.PosSlipLayout.getBlocksForPrint(letterheadIdBtDn, 'delivery'));
- Step 3: Verify the whole inline script parses
Run: python3 -c " import re content = open('public/pos/receipt.html').read() m = re.search(r'<script>\n\(function\(\)\{.*?\n\}\)\(\);\n</script>', content, re.S) open('/tmp/receipt-inline.js','w').write(m.group(0)[len('<script>'):-len('</script>')]) "
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:
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
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:
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:
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
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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#ff385c">
<title>Slip layout — WFSales POS</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%23ff385c'/><text x='16' y='22' font-family='-apple-system,sans-serif' font-size='17' font-weight='700' text-anchor='middle' fill='white'>W</text></svg>">
<style>
:root{
--bg:#ffffff;--surface:#fff7f8;--fg:#222222;--muted:#dddddd;--border:#ffe3e8;--accent:#ff385c;
--ok:#0f8a3f;--warn:#b14a00;--danger:#c92a2a;
--shadow:0 1px 2px rgba(34,34,34,.04), 0 4px 12px rgba(34,34,34,.06);
--font:'Inter',system-ui,-apple-system,Segoe UI,Helvetica Neue,Arial,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{height:100%;background:var(--bg);color:var(--fg);font:16px/1.5 var(--font);-webkit-font-smoothing:antialiased}
body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin:0 auto;padding:0 20px 32px;background:var(--bg)}
.topbar{display:flex;align-items:center;gap:10px;padding:14px 0 10px;background:var(--bg);position:sticky;top:0;z-index:10}
.topbar .back{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:var(--surface);border:1px solid var(--border);flex:0 0 auto}
.topbar h1{font-size:17px;font-weight:600;letter-spacing:-.01em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lede{color:#717171;font-size:14px;line-height:1.55;margin:6px 0 16px;max-width:38ch}
.section-label{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#717171;margin:24px 4px 10px}
.banner{padding:12px 14px;border-radius:12px;font-size:13px;display:flex;align-items:flex-start;gap:10px;line-height:1.4}
.banner.warn{background:#fdf2e6;border:1px solid #f5d9b3;color:#8a4a00}
.banner.err{background:#fff0f0;border:1px solid #f5c2c7;color:#9b1c1c}
.banner .t{flex:1;min-width:0}
.skeleton{padding:24px 16px;text-align:center;color:#999;font-size:13px}
.actions{padding:0;display:flex;flex-direction:column;gap:10px;margin-top:24px}
.btn{display:flex;align-items:center;justify-content:center;gap:8px;min-height:54px;padding:0 18px;border-radius:12px;font-size:15px;font-weight:600;letter-spacing:-.005em;border:1.5px solid var(--muted);background:#fff;color:var(--fg)}
.btn:active{background:var(--surface)}
.btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
.btn:disabled{opacity:.5;pointer-events:none}
.preview-tabs{display:flex;gap:6px;padding:4px 4px 10px}
.preview-tab{flex:1;padding:8px 10px;border:1px solid var(--muted);border-radius:10px;background:#fff;font-size:13px;font-weight:600;color:var(--fg)}
.preview-tab.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
.receipt-preview{font-family:monospace;font-size:10px;line-height:1.35;background:#fdfdfa;color:#1a1a1a;border:1px solid #999;border-radius:4px;padding:10px 10px;width:260px;max-width:100%;margin:0 auto 4px;box-shadow:0 2px 6px rgba(0,0,0,.08);white-space:pre;overflow-x:auto;overflow-wrap:normal;word-break:keep-all;position:relative}
.receipt-preview .center{text-align:center;display:block}
.receipt-preview .em{font-weight:700;color:#000}
/* ---- Block list ---- */
.block-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--muted);border-radius:10px;background:#fff;margin-bottom:8px}
.block-row .name{flex:1;min-width:0;font-size:14px;font-weight:600}
.block-row .reorder{display:flex;flex-direction:column;gap:2px}
.block-row .reorder button{width:28px;height:22px;border:1px solid var(--muted);border-radius:6px;background:#fff;font-size:11px;color:var(--fg)}
.block-row .reorder button:disabled{opacity:.3}
.block-row .toggle{width:40px;height:24px;border-radius:999px;border:1.5px solid var(--muted);background:#eee;position:relative;flex:0 0 auto}
.block-row .toggle.on{background:var(--accent);border-color:var(--accent)}
.block-row .toggle .dot{position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#fff;transition:left .12s}
.block-row .toggle.on .dot{left:18px}
.block-row .toggle.required{opacity:.6;pointer-events:none}
.block-row .edit-text{width:100%;margin-top:6px;padding:8px 10px;border-radius:8px;border:1px solid var(--muted);font:inherit}
.pricing-row{display:flex;align-items:center;justify-content:space-between;padding:12px;border:1px solid var(--muted);border-radius:10px;background:#fff;margin:4px 0 16px}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){--bg:#0f0f0f;--surface:#1c1416;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429}
:root:not([data-theme="light"]) .block-row, :root:not([data-theme="light"]) .pricing-row, :root:not([data-theme="light"]) .btn, :root:not([data-theme="light"]) .preview-tab{background:#181818;border-color:var(--muted);color:var(--fg)}
}
:root[data-theme="dark"]{--bg:#0f0f0f;--surface:#1c1416;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429}
:root[data-theme="dark"] .block-row, :root[data-theme="dark"] .pricing-row, :root[data-theme="dark"] .btn, :root[data-theme="dark"] .preview-tab{background:#181818;border-color:var(--muted);color:var(--fg)}
</style>
</head>
<body>
<header class="topbar">
<button class="back" data-od-id="back-btn" onclick="location.href='settings.html'" aria-label="Back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 6l-6 6 6 6"/></svg>
</button>
<h1>Slip layout</h1>
</header>
<p class="lede">Choose what prints on each slip, in what order, for the active letterhead.</p>
<div id="banner" hidden></div>
<div class="preview-tabs" role="tablist">
<button class="preview-tab active" data-type="sale" onclick="switchTab('sale')" type="button">Sale slip</button>
<button class="preview-tab" data-type="delivery" onclick="switchTab('delivery')" type="button">Delivery note</button>
<button class="preview-tab" data-type="cashup" onclick="switchTab('cashup')" type="button">Cash-up</button>
</div>
<div class="receipt-preview" id="receiptPreview" aria-live="polite">
<div class="skeleton" style="padding:24px 8px">Loading preview…</div>
</div>
<div class="section-label">Blocks</div>
<div id="blockList"></div>
<div id="pricingSection" style="display:none">
<div class="section-label">Delivery note pricing</div>
<div class="pricing-row">
<span>Show pricing by default</span>
<button class="toggle" id="pricingToggle" onclick="togglePricing()"><span class="dot"></span></button>
</div>
</div>
<div class="actions">
<button class="btn primary" data-od-id="save-layout" onclick="saveCurrentLayout()">Save layout</button>
</div>
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>
<script src="slip-layout.js"></script>
<script>
'use strict';
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);}
function showBanner(kind, text){
const el = document.getElementById('banner');
if(!text){ el.hidden = true; el.innerHTML = ''; return; }
el.hidden = false;
el.innerHTML = '<div class="banner '+kind+'"><span class="t">'+esc(text)+'</span></div>';
}
let letterhead = null;
let currentTab = 'sale';
// Working copies — edited in place, only written to PosSlipLayout on Save.
let workingBlocks = { sale: [], delivery: [], cashup: [] };
let workingShowPricing = true;
function money(n){ return 'R ' + (Number(n) || 0).toFixed(2); }
async function boot(){
letterhead = await window.PosLetterhead.ensureActive();
if(!letterhead){
showBanner('warn', 'Pick a letterhead in Settings first.');
document.querySelector('[data-od-id="save-layout"]').disabled = true;
return;
}
let saved = null;
try{ saved = await window.PosSlipLayout.fetchLayout(letterhead.id); }
catch(e){ saved = window.PosSlipLayout.getCached(letterhead.id); }
workingBlocks.sale = window.PosSlipLayout.resolveBlocks('sale', saved && saved.sale_blocks);
workingBlocks.delivery = window.PosSlipLayout.resolveBlocks('delivery', saved && saved.delivery_blocks);
workingBlocks.cashup = window.PosSlipLayout.resolveBlocks('cashup', saved && saved.cashup_blocks);
workingShowPricing = saved && saved.delivery_show_pricing != null ? !!saved.delivery_show_pricing : window.PosSlipLayout.DEFAULT_SHOW_PRICING;
renderAll();
}
function switchTab(type){
currentTab = type;
document.querySelectorAll('.preview-tab').forEach(t => t.classList.toggle('active', t.dataset.type === type));
renderAll();
}
function renderAll(){
renderBlockList();
renderPricingSection();
renderPreview();
}
function renderBlockList(){
const list = workingBlocks[currentTab];
const wrap = document.getElementById('blockList');
wrap.innerHTML = list.map((b, i) => {
const required = window.PosSlipLayout.isRequired(currentTab, b.id);
const hasText = b.id === 'header_title' || b.id === 'footer';
const placeholder = b.id === 'header_title' ? window.PosSlipLayout.DEFAULT_HEADER_TITLE : (letterhead.footer_text || 'Thank you for your business.');
return '<div class="block-row" style="flex-wrap:wrap">'
+ '<div class="reorder"><button type="button" '+(i===0?'disabled':'')+' onclick="moveBlock('+i+',-1)">▲</button><button type="button" '+(i===list.length-1?'disabled':'')+' onclick="moveBlock('+i+',1)">▼</button></div>'
+ '<div class="name">'+esc(window.PosSlipLayout.labelFor(b.id))+(required?' <small style="font-weight:400;color:#999">(required)</small>':'')+'</div>'
+ '<button type="button" class="toggle '+(b.enabled?'on':'')+' '+(required?'required':'')+'" onclick="toggleBlock('+i+')"><span class="dot"></span></button>'
+ (hasText ? '<input class="edit-text" data-idx="'+i+'" placeholder="'+esc(placeholder)+'" value="'+esc(b.text||'')+'" oninput="editBlockText('+i+', this.value)">' : '')
+ '</div>';
}).join('');
}
function renderPricingSection(){
document.getElementById('pricingSection').style.display = currentTab === 'delivery' ? 'block' : 'none';
document.getElementById('pricingToggle').classList.toggle('on', workingShowPricing);
}
function moveBlock(i, dir){
const list = workingBlocks[currentTab];
const j = i + dir;
if(j < 0 || j >= list.length) return;
const tmp = list[i]; list[i] = list[j]; list[j] = tmp;
renderBlockList();
renderPreview();
}
function toggleBlock(i){
const block = workingBlocks[currentTab][i];
if(window.PosSlipLayout.isRequired(currentTab, block.id)) return;
block.enabled = !block.enabled;
renderBlockList();
renderPreview();
}
function editBlockText(i, value){
workingBlocks[currentTab][i].text = value || null;
renderPreview();
}
function togglePricing(){
workingShowPricing = !workingShowPricing;
renderPricingSection();
renderPreview();
}
async function saveCurrentLayout(){
const btn = document.querySelector('[data-od-id="save-layout"]');
btn.disabled = true;
showBanner(null);
try{
await window.PosSlipLayout.saveLayout(letterhead.id, {
sale_blocks: workingBlocks.sale,
delivery_blocks: workingBlocks.delivery,
cashup_blocks: workingBlocks.cashup,
delivery_show_pricing: workingShowPricing,
});
const orig = btn.textContent;
btn.textContent = '✓ Saved';
setTimeout(() => { btn.textContent = orig; }, 1500);
}catch(e){
showBanner('err', e.message || 'Save failed');
}finally{
btn.disabled = false;
}
}
// ---------------- Preview rendering (block-aware) ----------------
// Same sample data as the old settings.html preview; now walks the
// resolved+edited block list instead of a fixed sequence.
function renderPreview(){
const el = document.getElementById('receiptPreview');
const blocks = workingBlocks[currentTab].filter(b => b.enabled);
if(currentTab === 'sale') el.innerHTML = previewSale(blocks);
else if(currentTab === 'delivery') el.innerHTML = previewDelivery(blocks);
else el.innerHTML = previewCashup(blocks);
}
function previewSale(blocks){
const vatPct = Number(letterhead.vat_percent) || 15;
const sampleItems = [
{ description: 'Widget A', qty: 2, selling_price: 45.00 },
{ description: 'Widget B', qty: 1, selling_price: 89.99 },
];
const total = sampleItems.reduce((s, it) => s + it.qty * it.selling_price, 0);
const factor = vatPct > 0 ? (1 + vatPct / 100) : 1;
const sub = total / factor, vat = total - sub;
const cashGiven = 200, change = cashGiven - total;
const dateStr = new Date().toLocaleString('en-ZA', { day:'2-digit', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit' });
const W = 32;
const center = s => { s = String(s); const p = Math.max(0, Math.floor((W - s.length) / 2)); return ' '.repeat(p) + s; };
const row = (l, r) => { const sp = Math.max(1, W - String(l).length - String(r).length); return l + ' '.repeat(sp) + r; };
const divider = '-'.repeat(W);
const renderers = {
header_title: b => [center(b.text || window.PosSlipLayout.DEFAULT_HEADER_TITLE)],
logo: () => [],
business_info: () => {
const out = [];
if(letterhead.display_name) out.push(center(letterhead.display_name));
if(letterhead.address) out.push(center(String(letterhead.address).split('\n')[0]));
if(letterhead.phone) out.push(center('Tel: ' + letterhead.phone));
if(letterhead.vat_number) out.push(center('VAT: ' + letterhead.vat_number));
return out;
},
date_slip: () => [divider, row('Date: ' + dateStr, 'Slip: SAMPLE')],
customer: () => [],
items: () => {
const out = [divider];
sampleItems.forEach(it => { out.push(it.description); out.push(' ' + it.qty + ' x ' + money(it.selling_price) + ' ' + money(it.qty*it.selling_price)); });
return out;
},
totals: () => {
const out = [divider, row('Subtotal', money(sub))];
if(vatPct > 0) out.push(row('VAT ' + vatPct + '%', money(vat)));
out.push(row('TOTAL', money(total)));
return out;
},
payment: () => [row('Paid: CASH ' + money(cashGiven), ''), row('Change', money(change))],
footer: b => [divider, center(b.text || letterhead.footer_text || 'Thank you for your purchase.')],
};
const out = [];
blocks.forEach(b => { if(renderers[b.id]) out.push(...renderers[b.id](b)); });
return out.map(line => '<span>' + esc(line) + '</span>').join('');
}
function previewDelivery(blocks){
const sampleItems = [
{ description: 'Widget A', qty: 5, unit: 'ea', selling_price: 45.00 },
{ description: 'Widget C', qty: 3, unit: 'ea', selling_price: 22.50 },
];
const total = sampleItems.reduce((s, it) => s + it.qty * it.selling_price, 0);
const dateStr = new Date().toLocaleDateString('en-ZA', { day:'2-digit', month:'short', year:'numeric' });
const W = 32;
const center = s => { s = String(s); const p = Math.max(0, Math.floor((W - s.length) / 2)); return ' '.repeat(p) + s; };
const divider = '-'.repeat(W);
const showPricing = workingShowPricing;
const renderers = {
header_title: b => [center(b.text || window.PosSlipLayout.DEFAULT_HEADER_TITLE), center('Delivery Note')],
customer: () => ['Company: Acme Mining (Pty) Ltd', 'Customer: Joe Example'],
date: () => ['Date: ' + dateStr],
items: () => {
const out = [divider];
sampleItems.forEach(it => {
out.push(it.description);
const qtyLine = ' ' + it.qty + ' ' + it.unit;
out.push(showPricing ? qtyLine + ' ' + money(it.qty * it.selling_price) : qtyLine);
});
return out;
},
totals: () => showPricing ? [divider, 'TOTAL: ' + money(total)] : [divider],
signature: () => ['Received by: __________ Driver: __________'],
};
const out = [];
blocks.forEach(b => { if(renderers[b.id]) out.push(...renderers[b.id](b)); });
return out.map(line => '<span>' + esc(line) + '</span>').join('');
}
function previewCashup(blocks){
const counts = { cash: 12, card: 7, eft: 3, account: 2 };
const totals = { cash: 1234.50, card: 890.00, eft: 215.75, account: 1450.00 };
const grand = totals.cash + totals.card + totals.eft + totals.account;
const cashCounted = 1230.00;
const variance = cashCounted - totals.cash;
const denoms = [ { label: 'R200', value: 200, count: 3 }, { label: 'R100', value: 100, count: 4 } ];
const dateLabel = new Date().toLocaleDateString('en-ZA', { day:'2-digit', month:'short', year:'numeric' });
const W = 32;
const center = s => { s = String(s); const p = Math.max(0, Math.floor((W - s.length) / 2)); return ' '.repeat(p) + s; };
const row = (l, r) => { const sp = Math.max(1, W - String(l).length - String(r).length); return l + ' '.repeat(sp) + r; };
const pad = (n, w) => { const s = String(n); return s.length >= w ? s : ' '.repeat(w - s.length) + s; };
const divider = '-'.repeat(W);
const renderers = {
header_title: b => [center(b.text || window.PosSlipLayout.DEFAULT_HEADER_TITLE), center('CASH-UP REPORT')],
logo: () => [],
business_info: () => {
const out = [];
if(letterhead.display_name) out.push(center(letterhead.display_name));
if(letterhead.phone) out.push(center('Tel: ' + letterhead.phone));
return out;
},
shift_date: () => [divider, row('Shift: Today', 'Date: ' + dateLabel), 'Total sales: ' + (counts.cash+counts.card+counts.eft+counts.account)],
payment_breakdown: () => [
divider, 'PAYMENT METHOD BREAKDOWN',
row('Cash ' + pad(counts.cash,3) + ' sales', money(totals.cash)),
row('Card ' + pad(counts.card,3) + ' sales', money(totals.card)),
row('EFT ' + pad(counts.eft,3) + ' sales', money(totals.eft)),
row('Acct ' + pad(counts.account,3) + ' sales', money(totals.account)),
],
grand_total: () => [divider, row('GRAND TOTAL', money(grand))],
cash_reconciliation: () => [divider, 'CASH RECONCILIATION', row('Cash sales:', money(totals.cash)), row('Cash counted:', money(cashCounted)), row('Variance:', money(variance))],
denominations: () => {
const out = [divider, 'DENOMINATIONS COUNTED'];
denoms.forEach(d => out.push(d.label + ' x ' + pad(d.count,3) + ' = ' + money(d.value*d.count)));
return out;
},
footer: b => [divider, center(b.text || letterhead.footer_text || '')],
};
const out = [];
blocks.forEach(b => { if(renderers[b.id]) out.push(...renderers[b.id](b)); });
return out.map(line => '<span>' + esc(line) + '</span>').join('');
}
boot();
</script>
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
</body>
</html>
- Step 2: Verify syntax
Run: python3 -c " import re content = open('public/pos/slip-layout.html').read() m = re.search(r'<script>\n.strict.;.*?\nboot\(\);\n</script>', content, re.S) open('/tmp/slip-layout-inline.js','w').write(m.group(0)[len('<script>'):-len('</script>')]) "
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:
<div class="section-label">Slip preview</div>
<p class="lede" style="margin:0 4px 10px">A preview of what each slip type will print with the active letterhead. Sample data — not from a real sale.</p>
<div class="preview-tabs" role="tablist">
<button class="preview-tab active" data-type="sale" onclick="switchPreview('sale')" type="button">Sale slip</button>
<button class="preview-tab" data-type="delivery" onclick="switchPreview('delivery')" type="button">Delivery note</button>
<button class="preview-tab" data-type="cashup" onclick="switchPreview('cashup')" type="button">Cash-up</button>
</div>
<div class="receipt-preview" id="receiptPreview" aria-live="polite">
<div class="skeleton" style="padding:24px 8px">Loading preview…</div>
</div>
<p class="preview-note" id="previewNote"></p>
Change to:
<div class="section-label">Slip layout</div>
<button class="lh-card" style="width:100%" onclick="location.href='slip-layout.html'" type="button">
<div class="body">
<div class="name">Edit slip layout</div>
<div class="meta">Choose what prints on each slip, in what order</div>
</div>
</button>
- 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:
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:
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'<script>\n.strict.;.*?\nboot\(\);\n</script>', content, re.S) open('/tmp/settings-inline.js','w').write(m.group(0)[len('<script>'):-len('</script>')]) "
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://<host>/api/v1/pos/slip-layout/<letterheadId> -H "Authorization: Bearer $POS_API_KEY" -H "Content-Type: application/json" -d '{"delivery_show_pricing": false}'.
- Step 7: Add
slip-layout.htmlto 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
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.