feat(pos): letterhead service + 2 API endpoints + vat_percent on Letterhead type

This commit is contained in:
Dave 2026-09-04 09:13:53 +02:00
parent 3b5891f0c5
commit 46d5d540a0
12 changed files with 954 additions and 11 deletions

View file

@ -231,7 +231,7 @@ button:focus-visible,input:focus-visible,select:focus-visible{outline:0;box-shad
</div>
<div class="actions">
<button class="btn primary" data-od-id="print-cashup" onclick="window.print()">
<button class="btn primary" data-od-id="print-cashup" onclick="printCashup()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M6 9V3h12v6M6 18H4v-7h16v7h-2M6 14h12v7H6z"/></svg>
Print cash-up
</button>
@ -243,6 +243,8 @@ button:focus-visible,input:focus-visible,select:focus-visible{outline:0;box-shad
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>
<script src="epos-print.js"></script>
<script>
(function(){
'use strict';
@ -379,6 +381,92 @@ async function load(){
paintDenoms();
load();
// ---------------- Print cash-up slip ----------------
// Sends the cashup report to the configured printer using the
// ePOS-Print transport (Network or Bluetooth — whatever the till
// is set to in Printer settings). Replaces the old `window.print()`
// which went to the OS print dialog instead of the thermal printer.
async function printCashup(){
const btn = document.querySelector('[data-od-id="print-cashup"]');
const status = btn ? btn.querySelector('span') : null;
const oldLabel = btn ? btn.textContent : '';
if(btn){ btn.disabled = true; btn.textContent = 'Printing…'; }
try{
// Letterhead must be loaded — same as the sale slip.
const letterhead = await window.PosLetterhead.ensureActive();
if(!letterhead){
alert('No letterhead selected. Open Settings to pick one.');
return;
}
// Read the operator-typed denomination counts from the DOM.
// (They live in the <input> children of .denom — the rest of
// the page never needs them as JS state, just for display.)
const denoms = Array.from(document.querySelectorAll('.denom')).map(row => ({
label: row.querySelector('.lbl').firstChild ? row.querySelector('.lbl').childNodes[0].textContent.trim() : '',
value: Number(row.dataset.v) || 0,
count: Math.max(0, Math.floor(Number(row.querySelector('input').value) || 0)),
}));
const dateLabel = document.getElementById('kRange').textContent || '';
const counts = {
cash: (totals && totals.count) ? Math.round(totals.cash > 0 ? cashSales / Math.max(1, totals.cash) * totals.count : 0) : 0,
card: 0,
eft: 0,
account: 0,
};
// The page already tallies totals but not counts per method; the
// simplest fix is to read them from the "X sales" labels we
// already paint. Fallback to 0 if the labels aren't ready.
const numFromLabel = id => {
const el = document.getElementById(id);
if(!el) return 0;
const m = (el.textContent || '').match(/(\d+)/);
return m ? Number(m[1]) : 0;
};
counts.cash = numFromLabel('mCashN');
counts.card = numFromLabel('mCardN');
counts.eft = numFromLabel('mEftN');
counts.account = numFromLabel('mAccN');
const report = {
shiftLabel: shift === 'yesterday' ? 'Yesterday' : 'Today',
dateLabel,
counts,
totals: {
cash: cashSales,
card: totals.card,
eft: totals.eft,
account: totals.account,
},
grand,
cashCounted,
variance: cashCounted - cashSales,
denoms,
};
const settings = window.EposPrint.getSettings();
const xml = window.EposPrint.buildEposCashupXml(letterhead, report);
if(settings.type === 'network'){
if(!settings.ip){
alert('Set the printer IP address in Printer settings first.');
return;
}
await window.EposPrint.printViaNetwork(settings.ip, xml);
} else {
// Bluetooth — re-use the receipt page's printThermalBluetooth
// would couple cashup to receipt.html, so for the cashup slip
// we keep things simple: fall back to the OS print dialog
// (which is what the till was doing before this whole change).
// Operators using Bluetooth need a separate "print from the
// browser" workflow for the cashup slip; the sale slip on the
// till still uses Bluetooth via the receipt page.
window.print();
return;
}
}catch(e){
alert('Cash-up print failed: ' + (e.message || e));
}finally{
if(btn){ btn.disabled = false; btn.textContent = oldLabel || 'Print cash-up'; }
}
}
})();
</script>
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>

View file

@ -162,5 +162,141 @@ window.EposPrint = (function () {
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">${parts.join('')}</epos-print></s:Body></s:Envelope>`;
}
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(`<image width="384" height="auto" color="color_1" mode="mono" align="center">${(typeof window !== 'undefined' ? '' : '')}</image>`);
}
parts.push('<feed line="1"/>');
parts.push('<text align="center" width="2" height="2">WFSALES POS&#10;</text>');
parts.push('<text align="center" width="1" height="1">CASH-UP REPORT&#10;</text>');
if (lh.display_name) {
parts.push(`<text align="center">${xmlEscape(lh.display_name)}&#10;</text>`);
}
if (lh.address) {
parts.push(`<text align="center">${xmlEscape(lh.address.split('\n')[0])}&#10;</text>`);
}
if (lh.phone) {
parts.push(`<text align="center">Tel: ${xmlEscape(lh.phone)}&#10;</text>`);
}
if (lh.vat_number) {
parts.push(`<text align="center">VAT: ${xmlEscape(lh.vat_number)}&#10;</text>`);
}
parts.push('<text align="left">--------------------------------&#10;</text>');
if (r.shiftLabel || r.dateLabel) {
parts.push(`<text align="left">Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')}&#10;</text>`);
}
if (r.counts) {
parts.push(`<text align="left">Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account}&#10;</text>`);
}
parts.push('<text align="left">--------------------------------&#10;</text>');
parts.push('<text align="left">PAYMENT METHOD BREAKDOWN&#10;</text>');
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(`<text align="left">${lines.join('&#10;')}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
parts.push(`<text align="left" em="true">GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
parts.push('<text align="left">CASH RECONCILIATION&#10;</text>');
parts.push(`<text align="left">Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)}&#10;</text>`);
parts.push(`<text align="left">Cash counted: R ${fmtMoney(r.cashCounted || 0)}&#10;</text>`);
parts.push(`<text align="left" em="${((r.variance || 0) === 0) ? 'true' : 'false'}">Variance: R ${fmtMoney(r.variance || 0)}&#10;</text>`);
if (Array.isArray(r.denoms) && r.denoms.length) {
parts.push('<text align="left">--------------------------------&#10;</text>');
parts.push('<text align="left">DENOMINATIONS COUNTED&#10;</text>');
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(`<text align="left">${dlines.join('&#10;')}&#10;</text>`);
}
}
if (lh.footer_text) {
parts.push('<text align="left">--------------------------------&#10;</text>');
parts.push(`<text align="center">${xmlEscape(lh.footer_text)}&#10;</text>`);
}
parts.push('<feed line="3"/>');
parts.push('<cut type="feed"/>');
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">${parts.join('')}</epos-print></s:Body></s:Envelope>`;
}
// 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 };
})();

View file

@ -115,6 +115,14 @@ h1{font-size:28px;font-weight:700;letter-spacing:-.02em;line-height:1.15;margin:
</span>
<span class="go">Open ›</span>
</a>
<a class="card" data-od-id="launcher-card-settings" href="settings.html">
<span class="ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<span class="body">
<span class="title">Settings</span>
<span class="desc">Pick letterhead, configure printer</span>
</span>
<span class="go">Open ›</span>
</a>
</div>
<div class="section-label">Till</div>
@ -123,10 +131,11 @@ h1{font-size:28px;font-weight:700;letter-spacing:-.02em;line-height:1.15;margin:
<span id="statusMsg">Connecting to register…</span>
</div>
<p class="foot"><b id="prodCount">—</b> products · cache <span id="cacheState">pending</span></p>
<p class="foot"><b id="prodCount">—</b> products · cache <span id="cacheState">pending</span> · <span id="activeLetterhead">letterhead —</span></p>
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>
<script>
(async function(){
// Product count via the same catalog endpoint pos.html uses.
@ -167,6 +176,22 @@ h1{font-size:28px;font-weight:700;letter-spacing:-.02em;line-height:1.15;margin:
window.addEventListener('offline', paintNet);
paintNet();
if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(()=>{});
// Active letterhead — show a one-word status in the footer so the
// operator notices if the till somehow has no letterhead picked
// (the settings page will gate sales in that case).
try{
const active = await window.PosLetterhead.ensureActive();
const lh = document.getElementById('activeLetterhead');
if(lh){
lh.textContent = active
? ('letterhead ' + active.display_name.split(' ')[0].toLowerCase())
: 'letterhead none';
if(!active) lh.style.color = '#c92a2a';
}
}catch(e){
const lh = document.getElementById('activeLetterhead');
if(lh){ lh.textContent = 'letterhead error'; lh.style.color = '#c92a2a'; }
}
})();
</script>
</body>

View file

@ -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,
};
})();

View file

@ -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,

View file

@ -330,7 +330,7 @@ input{font:inherit;color:inherit}
<body>
<header class="topbar" id="topbar">
<button class="back" data-od-id="back-btn" onclick="if(history.length>1)history.back();else location.href='index.html'" aria-label="Back">
<button class="back" data-od-id="back-btn" onclick="location.href='index.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>New sale</h1>
@ -510,6 +510,7 @@ input{font:inherit;color:inherit}
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>
<script src="pos-app.js"></script>
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
</body>

334
public/pos/settings.html Normal file
View file

@ -0,0 +1,334 @@
<!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>Settings — 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 .back:active{background:var(--bg)}
.topbar h1{font-size:17px;font-weight:600;letter-spacing:-.01em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.topbar .netpill{font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:600;color:#717171;padding:4px 8px;border:1px solid var(--muted);border-radius:999px;flex:0 0 auto;white-space:nowrap}
.topbar .netpill.off{color:var(--danger);border-color:var(--danger);background:#fff5f5}
.topbar .theme-btn{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:transparent;border:0;cursor:pointer;flex:0 0 auto}
.topbar .theme-btn:active{background:var(--surface)}
.topbar .theme-btn svg{width:18px;height:18px}
.topbar .theme-btn .i-moon{display:none}
:root[data-theme="dark"] .topbar .theme-btn .i-sun{display:none}
:root[data-theme="dark"] .topbar .theme-btn .i-moon{display:block}
.lede{color:#717171;font-size:14px;line-height:1.55;margin:6px 0 16px;max-width:38ch}
/* ---- Active letterhead card ---- */
.active{padding:16px;border:1.5px solid var(--accent);border-radius:14px;background:#fff8fa;display:flex;align-items:center;gap:14px;box-shadow:var(--shadow)}
.active .logo{width:64px;height:64px;border-radius:10px;background:#fff;border:1px solid var(--border);display:grid;place-items:center;overflow:hidden;flex:0 0 auto}
.active .logo img{max-width:100%;max-height:100%;object-fit:contain}
.active .logo .placeholder{font-weight:700;font-size:22px;color:var(--accent)}
.active .body{flex:1;min-width:0}
.active .name{font-size:17px;font-weight:700;letter-spacing:-.01em;color:var(--fg);line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.active .sub{font-size:12px;color:var(--ink-soft, #717171);margin-top:4px;letter-spacing:.01em;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.active .pill{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:var(--accent);color:#fff;padding:3px 8px;border-radius:999px}
.active .pill.muted{background:var(--surface);color:var(--fg);border:1px solid var(--border)}
/* ---- Letterhead list (for switching) ---- */
.section-label{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#717171;margin:24px 4px 10px}
.lh-list{display:flex;flex-direction:column;gap:8px}
.lh-card{display:flex;align-items:center;gap:12px;padding:12px;border:1.5px solid var(--muted);border-radius:12px;background:#fff;text-align:left;width:100%;cursor:pointer;transition:border-color .12s, background .12s}
.lh-card:active{background:var(--surface)}
.lh-card.selected{border-color:var(--accent);background:#fff8fa}
.lh-card .logo{width:48px;height:48px;border-radius:8px;background:#fff;border:1px solid var(--border);display:grid;place-items:center;overflow:hidden;flex:0 0 auto}
.lh-card .logo img{max-width:100%;max-height:100%;object-fit:contain}
.lh-card .logo .placeholder{font-weight:700;font-size:16px;color:var(--accent)}
.lh-card .body{flex:1;min-width:0}
.lh-card .name{font-size:15px;font-weight:600;letter-spacing:-.01em;color:var(--fg);line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lh-card .meta{font-size:11px;color:#717171;margin-top:2px;letter-spacing:.01em}
.lh-card .check{width:18px;height:18px;border-radius:50%;background:var(--accent);color:#fff;display:grid;place-items:center;flex:0 0 auto;font-size:11px;font-weight:700;line-height:1}
.lh-card:not(.selected) .check{background:transparent;border:1.5px solid var(--muted);color:transparent}
/* ---- Actions ---- */
.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);box-shadow:0 4px 14px rgba(255,56,92,.25)}
.btn.primary:active{background:#e5324f;box-shadow:none}
.btn svg{width:18px;height:18px;stroke-width:1.7}
.btn:disabled{opacity:.5;pointer-events:none}
/* ---- Empty / error states ---- */
.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 .ic{flex:0 0 auto;width:16px;height:16px;margin-top:1px}
.banner .t{flex:1;min-width:0}
.skeleton{padding:24px 16px;text-align:center;color:#999;font-size:13px}
/* ---- Dark theme ---- */
@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"]) body{background:var(--bg);color:var(--fg)}
:root:not([data-theme="light"]) .topbar{background:var(--bg)}
:root:not([data-theme="light"]) .lede{color:#a0a0a0}
:root:not([data-theme="light"]) .topbar .back{background:var(--surface);border-color:var(--border);color:var(--fg)}
:root:not([data-theme="light"]) .topbar .netpill{color:#a0a0a0;border-color:var(--muted)}
:root:not([data-theme="light"]) .topbar .netpill.off{color:#ff6680;border-color:#ff6680;background:#2a1a1a}
:root:not([data-theme="light"]) .topbar .theme-btn{color:var(--fg)}
:root:not([data-theme="light"]) .section-label{color:#a0a0a0}
:root:not([data-theme="light"]) .active{background:#2a1a1a;border-color:#ff385c}
:root:not([data-theme="light"]) .active .logo{background:#181818;border-color:var(--muted)}
:root:not([data-theme="light"]) .active .name{color:var(--fg)}
:root:not([data-theme="light"]) .lh-card{background:#181818;border-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .lh-card:active{background:var(--surface)}
:root:not([data-theme="light"]) .lh-card.selected{background:#2a1a1a;border-color:#ff385c}
:root:not([data-theme="light"]) .lh-card .logo{background:#181818;border-color:var(--muted)}
:root:not([data-theme="light"]) .lh-card .meta{color:#a0a0a0}
:root:not([data-theme="light"]) .lh-card:not(.selected) .check{border-color:var(--muted)}
:root:not([data-theme="light"]) .btn{background:#181818;border-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .btn:active{background:var(--surface)}
:root:not([data-theme="light"]) .banner.warn{background:#2a1a1a;border-color:#5a3a1a;color:#ffb060}
:root:not([data-theme="light"]) .banner.err{background:#2a1a1a;border-color:#5a2429;color:#ff6680}
:root:not([data-theme="light"]) .skeleton{color:#666}
}
:root[data-theme="dark"]{
--bg:#0f0f0f;--surface:#1c1416;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429;
background:var(--bg);color:var(--fg);
}
:root[data-theme="dark"] body{background:var(--bg);color:var(--fg)}
:root[data-theme="dark"] .topbar{background:var(--bg)}
:root[data-theme="dark"] .lede{color:#a0a0a0}
:root[data-theme="dark"] .topbar .back{background:var(--surface);border-color:var(--border);color:var(--fg)}
:root[data-theme="dark"] .topbar .netpill{color:#a0a0a0;border-color:var(--muted)}
:root[data-theme="dark"] .topbar .netpill.off{color:#ff6680;border-color:#ff6680;background:#2a1a1a}
:root[data-theme="dark"] .topbar .theme-btn{color:var(--fg)}
:root[data-theme="dark"] .section-label{color:#a0a0a0}
:root[data-theme="dark"] .active{background:#2a1a1a;border-color:#ff385c}
:root[data-theme="dark"] .active .logo{background:#181818;border-color:var(--muted)}
:root[data-theme="dark"] .active .name{color:var(--fg)}
:root[data-theme="dark"] .lh-card{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .lh-card:active{background:var(--surface)}
:root[data-theme="dark"] .lh-card.selected{background:#2a1a1a;border-color:#ff385c}
:root[data-theme="dark"] .lh-card .logo{background:#181818;border-color:var(--muted)}
:root[data-theme="dark"] .lh-card .meta{color:#a0a0a0}
:root[data-theme="dark"] .lh-card:not(.selected) .check{border-color:var(--muted)}
:root[data-theme="dark"] .btn{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .btn:active{background:var(--surface)}
:root[data-theme="dark"] .banner.warn{background:#2a1a1a;border-color:#5a3a1a;color:#ffb060}
:root[data-theme="dark"] .banner.err{background:#2a1a1a;border-color:#5a2429;color:#ff6680}
:root[data-theme="dark"] .skeleton{color:#666}
@media (prefers-reduced-motion: reduce){*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}}
</style>
</head>
<body>
<header class="topbar">
<button class="back" data-od-id="back-btn" onclick="location.href='index.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>Settings</h1>
<span class="netpill" id="netpill" data-od-id="netpill">…</span>
<button class="theme-btn" data-theme-toggle data-od-id="theme-toggle" aria-label="Toggle theme" title="Toggle theme" onclick="(window.WFTheme&&window.WFTheme.cycle())||(window.__wfCycle&&window.__wfCycle())">
<svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<svg class="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</header>
<p class="lede">This till runs under the selected letterhead. Slips and receipts will use its name, address, VAT rate, and logo.</p>
<div id="activeBlock" class="skeleton">Loading…</div>
<div class="section-label">Switch letterhead</div>
<div class="lh-list" id="lhList"></div>
<div id="banner" hidden></div>
<div class="actions">
<button class="btn primary" data-od-id="test-print" onclick="testPrint()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M6 9V3h12v6M6 18H4v-7h16v7h-2M6 14h12v7H6z"/></svg>
Test print
</button>
<button class="btn" data-od-id="done-btn" onclick="location.href='index.html'">
Done
</button>
</div>
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>
<script src="epos-print.js"></script>
<script>
'use strict';
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);}
function fmtVat(n){return (Number(n)||0).toString().replace(/\.0$/,'') + '% VAT';}
function initialsOf(name){
const s = String(name||'').trim();
if(!s) return '?';
return s.split(/\s+/).slice(0,2).map(w => w[0]||'').join('').toUpperCase();
}
function logoHtml(item, klass){
if(item && item.has_logo && item.id){
// We don't have the full logo URL on the list view (it lives on
// the full record). The list cards have a placeholder; once the
// till picks one and we refetch the full record, the active card
// shows the real logo.
return '<div class="logo '+klass+'"><span class="placeholder">'+esc(initialsOf(item.display_name))+'</span></div>';
}
return '<div class="logo '+klass+'"><span class="placeholder">'+esc(initialsOf(item.display_name))+'</span></div>';
}
function paintNet(){
const p = document.getElementById('netpill');
if(!p) return;
p.textContent = navigator.onLine ? 'Online' : 'Offline';
p.classList.toggle('off', !navigator.onLine);
}
window.addEventListener('online', paintNet);
window.addEventListener('offline', paintNet);
paintNet();
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="ic">'+(kind==='err'
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v4M12 17v.5M3 18l9-15 9 15z"/></svg>'
)+'</span><span class="t">'+esc(text)+'</span></div>';
}
function paintActive(item){
const el = document.getElementById('activeBlock');
if(!item){
el.className = 'banner err';
el.innerHTML = '<span class="ic">'
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg>'
+ '</span><span class="t">No active letterhead. Pick one below to start ringing up sales.</span>';
return;
}
el.className = 'active';
const logo = item.logo_url
? '<img src="'+esc(item.logo_url)+'" alt="">'
: '<span class="placeholder">'+esc(initialsOf(item.display_name))+'</span>';
const vatPill = item.vat_percent > 0
? '<span class="pill">'+esc(fmtVat(item.vat_percent))+'</span>'
: '<span class="pill muted">No VAT</span>';
const acc = item.accent_color
? '<span class="pill muted" style="color:'+esc(item.accent_color)+';border-color:'+esc(item.accent_color)+'">Accent '+esc(item.accent_color)+'</span>'
: '';
el.innerHTML = ''
+ '<div class="logo">'+logo+'</div>'
+ '<div class="body">'
+ '<div class="name">'+esc(item.display_name)+'</div>'
+ '<div class="sub">'
+ '<span>'+esc(item.company || '')+'</span>'
+ vatPill
+ acc
+ '</div>'
+ '</div>';
}
function paintList(list, activeId){
const wrap = document.getElementById('lhList');
if(!list.length){
wrap.innerHTML = '<div class="skeleton">No active letterheads configured in PocketBase.</div>';
return;
}
wrap.innerHTML = list.map(item => {
const isSel = item.id === activeId;
const meta = [
item.vat_percent > 0 ? fmtVat(item.vat_percent) : 'No VAT',
item.accent_color ? 'Accent ' + item.accent_color : null,
].filter(Boolean).join(' · ');
return ''
+ '<button type="button" class="lh-card'+(isSel ? ' selected' : '')+'" data-id="'+esc(item.id)+'" onclick="pickLetterhead(\''+esc(item.id)+'\')">'
+ logoHtml(item, '')
+ '<div class="body">'
+ '<div class="name">'+esc(item.display_name)+'</div>'
+ '<div class="meta">'+esc(meta)+'</div>'
+ '</div>'
+ '<div class="check">✓</div>'
+ '</button>';
}).join('');
}
async function pickLetterhead(id){
showBanner(null);
try{
const item = await window.PosLetterhead.fetchById(id);
paintActive(item);
// Re-render the list so the new "selected" check shows up.
const list = window.PosLetterhead.getCachedList();
paintList(list, item.id);
}catch(e){
showBanner('err', e.message || 'Could not pick letterhead');
}
}
async function testPrint(){
showBanner(null);
const btn = document.querySelector('[data-od-id="test-print"]');
if(btn) btn.disabled = true;
try{
const letterhead = await window.PosLetterhead.ensureActive();
if(!letterhead){
showBanner('warn', 'Pick a letterhead first.');
return;
}
const where = await window.EposPrint.printTest(letterhead);
showBanner(null); // success — no banner
if(btn){
// Briefly flash the success into the button label.
const orig = btn.textContent;
btn.textContent = '✓ ' + where;
setTimeout(() => { btn.textContent = orig; }, 1800);
}
}catch(e){
showBanner('err', e.message || 'Test print failed');
}finally{
if(btn) btn.disabled = false;
}
}
async function boot(){
const wrap = document.getElementById('activeBlock');
wrap.textContent = 'Loading…';
let active = null;
try{
active = await window.PosLetterhead.ensureActive();
}catch(e){
showBanner('err', 'Could not load letterhead: ' + (e.message || e));
}
paintActive(active);
// Always show the list so the operator can switch.
let list = window.PosLetterhead.getCachedList();
if(!list.length){
try{ list = await window.PosLetterhead.fetchList(); }
catch(e){
// Cached list unavailable (offline + first run). Render a hint
// rather than nothing.
document.getElementById('lhList').innerHTML = '<div class="skeleton">Letterhead list unavailable offline. Reconnect to set up.</div>';
return;
}
}
paintList(list, active ? active.id : null);
}
boot();
</script>
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
</body>
</html>

View file

@ -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',

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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;
}
/**

View file

@ -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<PosLetterheadListItem[]> {
const pb = await getAdminPocketBase();
const rows = await pb.collection("letterhead_settings").getFullList<Letterhead>({
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<PosLetterheadFull> {
if (!id) {
throw new Error("letterhead id is required");
}
const pb = await getAdminPocketBase();
let row: Letterhead;
try {
row = await pb.collection("letterhead_settings").getOne<Letterhead>(id);
} catch (e) {
throw new Error(`Letterhead ${id} not found: ${(e as Error).message}`);
}
return toFull(row);
}