diff --git a/public/pos/cashup.html b/public/pos/cashup.html index 7437483..a3aa0f3 100644 --- a/public/pos/cashup.html +++ b/public/pos/cashup.html @@ -231,7 +231,7 @@ button:focus-visible,input:focus-visible,select:focus-visible{outline:0;box-shad
- @@ -243,6 +243,8 @@ button:focus-visible,input:focus-visible,select:focus-visible{outline:0;box-shad + + diff --git a/public/pos/epos-print.js b/public/pos/epos-print.js index 0b929ec..27b82d7 100644 --- a/public/pos/epos-print.js +++ b/public/pos/epos-print.js @@ -162,5 +162,141 @@ window.EposPrint = (function () { return `${parts.join('')}`; } - return { getSettings, setSettings, imageToRaster, buildEposXml, printViaNetwork, buildEposDeliveryXml }; + // Mirrors the cashup page's "Print cash-up" button. Renders an + // ePOS-Print XML slip with the day's takings broken down by + // payment method + the operator's cash-counted variance. The + // cashup.html page collects the data and calls this — we just + // format the XML. + // + // `report` shape: + // { + // shiftLabel: "Today" | "Yesterday" | string, + // dateLabel: "04 Sep 2026", + // counts: { cash, card, eft, account }, + // totals: { cash, card, eft, account }, + // grand: number, + // cashCounted: number, + // variance: number, + // denoms: [{ label: "R200", value: 200, count: 2 }, ...], + // } + // `letterhead` is the same shape as buildEposXml/Xml takes. + function buildEposCashupXml(letterhead, report) { + const lh = letterhead || {}; + const r = report || {}; + const parts = []; + if (lh.logo_url) { + parts.push(`${(typeof window !== 'undefined' ? '' : '')}`); + } + parts.push(''); + parts.push('WFSALES POS '); + parts.push('CASH-UP REPORT '); + 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)} `); + } + parts.push('-------------------------------- '); + if (r.shiftLabel || r.dateLabel) { + parts.push(`Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')} `); + } + if (r.counts) { + parts.push(`Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account} `); + } + parts.push('-------------------------------- '); + parts.push('PAYMENT METHOD BREAKDOWN '); + 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)}`, + ]; + parts.push(`${lines.join(' ')} `); + parts.push('-------------------------------- '); + parts.push(`GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)} `); + parts.push('-------------------------------- '); + parts.push('CASH RECONCILIATION '); + parts.push(`Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)} `); + parts.push(`Cash counted: R ${fmtMoney(r.cashCounted || 0)} `); + parts.push(`Variance: R ${fmtMoney(r.variance || 0)} `); + if (Array.isArray(r.denoms) && r.denoms.length) { + parts.push('-------------------------------- '); + parts.push('DENOMINATIONS COUNTED '); + const dlines = r.denoms + .filter(d => (d.count || 0) > 0) + .map(d => `${d.label} x ${pad(d.count || 0, 3)} = R ${fmtMoney((d.value || 0) * (d.count || 0))}`); + if (dlines.length) { + parts.push(`${dlines.join(' ')} `); + } + } + if (lh.footer_text) { + parts.push('-------------------------------- '); + parts.push(`${xmlEscape(lh.footer_text)} `); + } + parts.push(''); + parts.push(''); + return `${parts.join('')}`; + } + + // Small helper for padded numbers in the cashup slip. + function pad(n, w) { + const s = String(n == null ? 0 : n); + return s.length >= w ? s : (' '.repeat(w - s.length) + s); + } + function fmtMoney(n) { + return (Number(n) || 0).toFixed(2); + } + + // Test print — used by the Settings page's "Test print" button to + // verify the printer is reachable + the letterhead renders. Builds + // a tiny "Sample slip" with the active letterhead and pushes it + // through whichever transport the till is currently configured to + // use. Throws on failure (caller surfaces the error). + async function printTest(letterhead) { + const settings = getSettings(); + const lh = letterhead || {}; + const now = new Date(); + const sale = { + createdAt: now.toISOString(), + localId: 'TEST-' + now.getTime().toString(36).toUpperCase(), + payment: 'cash', + cashGiven: 0, + change: 0, + gross: 0, + discount: { kind: 'none', input: 0, amount: 0 }, + sub: 0, + vat: 0, + total: 0, + items: [], + customer: null, + letterhead: lh, + }; + // Render the regular sale XML — it'll show the letterhead + + // payment line + footer, which is what the operator wants to + // eyeball when they tap "Test print". We don't need to swap in + // a custom test layout. + const logo = lh.logo_url + ? await imageToRaster(lh.logo_url, 384).catch(() => null) + : null; + const xml = buildEposXml(sale, logo); + if (settings.type === 'network') { + if (!settings.ip) { + throw new Error('Set the printer IP address in Printer settings first.'); + } + await printViaNetwork(settings.ip, xml); + return 'Printed · ' + settings.ip; + } + // Bluetooth — the existing epos-print.js doesn't speak BT, + // only the receipt page does. Surface a clear message rather + // than pretending. + throw new Error('Test print on Bluetooth is not supported here — use the till\'s Print slip button.'); + } + + return { getSettings, setSettings, imageToRaster, buildEposXml, printViaNetwork, buildEposDeliveryXml, buildEposCashupXml, printTest }; })(); diff --git a/public/pos/index.html b/public/pos/index.html index 01b2850..34629e7 100644 --- a/public/pos/index.html +++ b/public/pos/index.html @@ -115,6 +115,14 @@ h1{font-size:28px;font-weight:700;letter-spacing:-.02em;line-height:1.15;margin: Open › + + + + Settings + Pick letterhead, configure printer + + Open › +
Till
@@ -123,10 +131,11 @@ h1{font-size:28px;font-weight:700;letter-spacing:-.02em;line-height:1.15;margin: Connecting to register… -

— products · cache pending

+

— products · cache pending · letterhead —

+ diff --git a/public/pos/letterhead-config.js b/public/pos/letterhead-config.js new file mode 100644 index 0000000..bfebeec --- /dev/null +++ b/public/pos/letterhead-config.js @@ -0,0 +1,150 @@ +// Letterhead configuration for the till. +// +// The POS uses a `letterhead_settings` row from the main WFDASH +// PocketBase (shared with the PDF flow). This module: +// 1. Caches the *list* of available letterheads in localStorage +// so the settings page can show the picker offline. +// 2. Caches the *active* letterhead (the one the till is currently +// running under) in localStorage so the till can render receipts +// and compute VAT without re-fetching on every sale. +// 3. Exposes a tiny global API that other POS scripts (pos-app.js, +// receipt.html, etc.) can use without each one re-implementing +// the cache + fetch dance. +// +// Like pos-config.js, the cache key prefix is `pos.letterhead.*` +// so it's clear what's till data vs. shop data when debugging. +window.PosLetterhead = (function () { + 'use strict'; + + // Westcott Group is the canonical POS letterhead per the operator + // (per the 2026-09-04 audit). If the till has no active letterhead + // yet, we default-pick this id when the first letterhead list comes + // back. The id matches the PB row "5wezvypn6fy47p9" in the user's + // instance — see docs. + const DEFAULT_ID = '5wezvypn6fy47p9'; + const DEFAULT_DISPLAY_NAME = 'WESTSCOTT GROUP OF COMPANIES'; + + const KEY_ACTIVE_ID = 'pos.letterhead.activeId'; + const KEY_ACTIVE = 'pos.letterhead.active'; + const KEY_LIST = 'pos.letterhead.list'; + + function lsGet(key) { + try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { return null; } + } + function lsSet(key, val) { + try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) { /* quota or disabled */ } + } + function lsDel(key) { + try { localStorage.removeItem(key); } catch (e) { /* ignore */ } + } + + // -------- list (used by the settings picker) -------- + + function getCachedList() { + return lsGet(KEY_LIST) || []; + } + + function setCachedList(items) { + lsSet(KEY_LIST, items); + } + + // -------- active letterhead (used by sales + receipt) -------- + + function getActiveId() { + return lsGet(KEY_ACTIVE_ID) || null; + } + + function getActive() { + return lsGet(KEY_ACTIVE) || null; + } + + function setActive(item) { + if (!item || !item.id) return; + lsSet(KEY_ACTIVE_ID, item.id); + lsSet(KEY_ACTIVE, item); + } + + function clearActive() { + lsDel(KEY_ACTIVE_ID); + lsDel(KEY_ACTIVE); + } + + // -------- network calls -------- + + async function fetchList() { + const r = await window.posFetch('/letterhead'); + if (!r.ok) throw new Error('letterhead list returned ' + r.status); + const j = await r.json(); + const items = j.items || []; + setCachedList(items); + return items; + } + + async function fetchById(id) { + if (!id) throw new Error('letterhead id is required'); + const r = await window.posFetch('/letterhead/' + encodeURIComponent(id)); + if (!r.ok) { + if (r.status === 404) throw new Error('letterhead ' + id + ' not found'); + throw new Error('letterhead fetch returned ' + r.status); + } + const j = await r.json(); + const item = j.item; + if (!item) throw new Error('letterhead response missing item'); + setActive(item); + return item; + } + + /** + * One-shot boot helper. Called from pos-app.js and settings.html. + * - If we have a cached active letterhead, return it immediately. + * - Otherwise, list letterheads; if Westcott Group is in the list + * auto-pick it; else pick the first active one. + * - Fetch the full row in the background so the cache stays warm + * (returns the fresh row if it differs). + */ + async function ensureActive() { + const cached = getActive(); + if (cached && cached.id) { + // Refresh in the background — the till can keep using the cached + // copy while the network call is in flight. + fetchById(cached.id).catch(() => { /* keep cached on failure */ }); + return cached; + } + // No active yet — list and pick a default. + let list = getCachedList(); + if (!list.length) { + list = await fetchList(); + } + const westcott = list.find(x => + (x.display_name || '').toUpperCase().includes('WESTSCOTT') || + (x.company || '').toUpperCase().includes('WESTSCOTT') + ); + const pick = westcott || list[0]; + if (!pick) { + // No active letterheads in PB. Mark this so the UI can show + // a clear "you need to set up a letterhead" message instead of + // a generic error. + clearActive(); + return null; + } + return await fetchById(pick.id); + } + + return { + // getters + getActiveId, + getActive, + getCachedList, + // setters + setActive, + setCachedList, + clearActive, + // network + fetchList, + fetchById, + ensureActive, + // constants (for debugging / display in settings) + DEFAULT_ID, + DEFAULT_DISPLAY_NAME, + }; +})(); diff --git a/public/pos/pos-app.js b/public/pos/pos-app.js index 2a4fab2..02b6a3e 100644 --- a/public/pos/pos-app.js +++ b/public/pos/pos-app.js @@ -122,8 +122,23 @@ async function loadProducts(){ } async function loadLetterhead(){ - // Letterhead comes from the main app in a later task if needed; - // the receipt already falls back to "WFSALES POS" when absent. + // Fetch + cache the active letterhead via letterhead-config.js. + // On a fresh till, this auto-picks Westcott Group (the only + // letterhead the POS uses per the 2026-09-04 operator decision). + // The fetched row drives: + // - state.letterhead.* → shown on the on-screen receipt + // - state.letterhead.vat_percent → cart VAT math (replaces the + // hardcoded 15% so a 0% letterhead shows no VAT line) + // - sale.letterhead.* → attached to the saved sale, consumed by + // receipt.html, epos-print.js (buildEposXml), and the + // history-reprint redirect. + try{ + const item = await window.PosLetterhead.ensureActive(); + state.letterhead = item; + }catch(e){ + console.warn('Letterhead load failed; receipt will fall back to "WFSALES POS"', e); + state.letterhead = null; + } } function buildCategories(){ @@ -325,11 +340,22 @@ function cartTotals(){ discountAmount = Math.min((gross * d.input) / 100, gross); } // VAT-inclusive model: selling_price is the shelf price (incl. VAT). - // Subtotal excluding VAT = total / 1.15, VAT = total - sub + // The VAT rate is driven by the active letterhead (default 15% if + // no letterhead is loaded yet — same as the previous hardcoded + // behaviour, so a fresh till on a flaky network still works). + const vatPct = Number(state.letterhead && state.letterhead.vat_percent) || 15; const total = Math.max(0, gross - discountAmount); - const sub = total / 1.15; - const vat = total - sub; - return {count, gross, discountAmount, sub, vat, total}; + let sub, vat; + if (vatPct > 0) { + const factor = 1 + vatPct / 100; + sub = total / factor; + vat = total - sub; + } else { + // 0% VAT (e.g. CNL / ISHERWOOD) — no VAT line, sub == total. + sub = total; + vat = 0; + } + return {count, gross, discountAmount, sub, vat, total, vatPct}; } /** @@ -769,6 +795,14 @@ async function confirmSale(){ gross: totals.gross, discount: { kind: d.kind, input: d.input, amount: totals.discountAmount }, sub, vat, total, + // Attach the active letterhead so the on-screen receipt AND + // the printed slip can show the right shop name, address, + // phone, VAT number, footer, and logo. receipt.html's + // renderReceipt + epos-print.js's buildEposXml both read + // sale.letterhead.* (and fall back to "WFSALES POS" / blank + // if it's null, so a missing letterhead is a non-fatal + // degradation, not a crash). + letterhead: state.letterhead || null, items: Array.from(state.cart.values()).map(({p,qty,unitPriceOverride})=>({ sku: p.sku, description: p.description, unit: p.unit, cost_price: p.cost_price, diff --git a/public/pos/pos.html b/public/pos/pos.html index 2d1b0a7..77ce83e 100644 --- a/public/pos/pos.html +++ b/public/pos/pos.html @@ -330,7 +330,7 @@ input{font:inherit;color:inherit}
-

New sale

@@ -510,6 +510,7 @@ input{font:inherit;color:inherit} + diff --git a/public/pos/settings.html b/public/pos/settings.html new file mode 100644 index 0000000..388aa83 --- /dev/null +++ b/public/pos/settings.html @@ -0,0 +1,334 @@ + + + + + + +Settings — WFSales POS + + + + + + +
+ +

Settings

+ … + +
+ +

This till runs under the selected letterhead. Slips and receipts will use its name, address, VAT rate, and logo.

+ +
Loading…
+ +
Switch letterhead
+
+ + + +
+ + +
+ + + + + + + + + diff --git a/public/pos/sw.js b/public/pos/sw.js index 29118f1..47654b8 100644 --- a/public/pos/sw.js +++ b/public/pos/sw.js @@ -1,5 +1,5 @@ // Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static. -const CACHE = "pos-v17"; +const CACHE = "pos-v18"; const APP_SHELL = [ './', './index.html', @@ -7,8 +7,10 @@ const APP_SHELL = [ './receipt.html', './history.html', './cashup.html', + './settings.html', './pos-config.js', './pos-app.js', + './letterhead-config.js', './epos-print.js', './theme.js', './manifest.json', diff --git a/src/app/api/v1/pos/letterhead/[id]/route.ts b/src/app/api/v1/pos/letterhead/[id]/route.ts new file mode 100644 index 0000000..4fe92ef --- /dev/null +++ b/src/app/api/v1/pos/letterhead/[id]/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertPosAuth } from "@/lib/server/pos-auth"; +import { getPosLetterheadById } from "@/lib/server/pos-letterhead-service"; + +/** + * GET /api/v1/pos/letterhead/[id] + * + * Returns the full letterhead record — including address, phone, + * email, VAT number, footer text, logo URL — for the letterhead the + * till has picked. The till caches this in localStorage so it stays + * usable when the network drops mid-shift. + * + * Auth: requires `Authorization: Bearer ${POS_API_KEY}`. + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + assertPosAuth(req); + } catch (err) { + const message = err instanceof Error ? err.message : "Unauthorized"; + return NextResponse.json({ error: message }, { status: 401 }); + } + + const { id } = await params; + try { + const item = await getPosLetterheadById(id); + return NextResponse.json({ item }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to fetch letterhead"; + const status = message.includes("not found") ? 404 : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/v1/pos/letterhead/route.ts b/src/app/api/v1/pos/letterhead/route.ts new file mode 100644 index 0000000..3bac8c5 --- /dev/null +++ b/src/app/api/v1/pos/letterhead/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertPosAuth } from "@/lib/server/pos-auth"; +import { listActiveLetterheads } from "@/lib/server/pos-letterhead-service"; + +/** + * GET /api/v1/pos/letterhead + * + * Returns every `letterhead_settings` row marked `is_active = true`, + * as a lightweight list (`{id, display_name, company, vat_percent, + * accent_color, has_logo}`). The till uses this to populate its + * "pick which letterhead this device runs under" screen at first + * launch (and whenever the operator changes it). + * + * The full record (address, phone, VAT number, logo URL, footer text) + * is fetched separately via GET /api/v1/pos/letterhead/[id] so the + * picker doesn't pull logo files it isn't going to use. + * + * Auth: requires `Authorization: Bearer ${POS_API_KEY}`. + */ +export async function GET(req: NextRequest) { + try { + assertPosAuth(req); + } catch (err) { + const message = err instanceof Error ? err.message : "Unauthorized"; + return NextResponse.json({ error: message }, { status: 401 }); + } + + try { + const items = await listActiveLetterheads(); + return NextResponse.json({ items }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to list letterheads"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/lib/letterhead.ts b/src/lib/letterhead.ts index 00fa9e2..2622fcb 100644 --- a/src/lib/letterhead.ts +++ b/src/lib/letterhead.ts @@ -97,6 +97,8 @@ export interface Letterhead extends BaseRecord { company: string; /** Hex like "#738224". Set per-company so the PDF table header + accents differ. */ accent_color: string; + /** VAT rate the company charges. 15 for VAT-registered, 0 for exempt / below threshold. */ + vat_percent: number; } /** diff --git a/src/lib/server/pos-letterhead-service.ts b/src/lib/server/pos-letterhead-service.ts new file mode 100644 index 0000000..9c3bc5c --- /dev/null +++ b/src/lib/server/pos-letterhead-service.ts @@ -0,0 +1,101 @@ +import { getAdminPocketBase } from "./pocketbase-admin"; +import { letterheadLogoUrl, type Letterhead } from "../letterhead"; + +/** + * Server-side read model for the POS letterhead picker. + * + * The till needs a list of available letterheads so the operator can + * pick which one this device runs under (CNL, ISHERWOOD, Westcott + * Group, etc.), plus a "fetch by id" path so the till can grab the + * full record (address, phone, logo URL, VAT rate) at boot. + * + * The `letterhead_settings` PB collection is shared with the main + * WFDASH app (which uses it for the PDF flow). We don't change + * anything there — just expose what already exists. + */ + +export type PosLetterheadListItem = { + id: string; + display_name: string; + company: string; + vat_percent: number; + accent_color: string; + has_logo: boolean; + is_active: boolean; +}; + +export type PosLetterheadFull = PosLetterheadListItem & { + address: string; + phone: string; + email: string; + vat_number: string; + registration_number: string; + footer_text: string; + logo_url: string | null; + collection_id: string; +}; + +function toListItem(r: Letterhead): PosLetterheadListItem { + return { + id: r.id, + display_name: r.display_name, + company: r.company, + vat_percent: Number(r.vat_percent ?? 0), + accent_color: r.accent_color || "", + has_logo: !!r.logo, + is_active: !!r.is_active, + }; +} + +function toFull(r: Letterhead): PosLetterheadFull { + return { + ...toListItem(r), + address: r.address || "", + phone: r.phone || "", + email: r.email || "", + vat_number: r.vat_number || "", + registration_number: r.registration_number || "", + footer_text: r.footer_text || "", + logo_url: letterheadLogoUrl(r), + collection_id: r.collectionId, + }; +} + +/** + * List every letterhead that's marked active. The till uses this to + * populate its picker UI. Inactive rows are skipped because they + * shouldn't be selectable from the device. + * + * We don't sort here — `display_name` is already human-ordered in + * the PB admin (CNL, ISHERWOOD, WESTSCOTT, ...) so the natural + * insertion order is the order the operator set up. + */ +export async function listActiveLetterheads(): Promise { + const pb = await getAdminPocketBase(); + const rows = await pb.collection("letterhead_settings").getFullList({ + filter: "is_active = true", + }); + return rows.map(toListItem); +} + +/** + * Fetch a single letterhead by id, with the full set of fields the + * till needs at sale time (address, phone, logo URL, VAT rate, etc). + * + * Throws if the id doesn't exist — that's a config bug (the till + * cached a stale id), not a user error, so the route will surface + * a 500 and the till should re-list and re-pick. + */ +export async function getPosLetterheadById(id: string): Promise { + if (!id) { + throw new Error("letterhead id is required"); + } + const pb = await getAdminPocketBase(); + let row: Letterhead; + try { + row = await pb.collection("letterhead_settings").getOne(id); + } catch (e) { + throw new Error(`Letterhead ${id} not found: ${(e as Error).message}`); + } + return toFull(row); +}