feat(pos): letterhead service + 2 API endpoints + vat_percent on Letterhead type
This commit is contained in:
parent
3b5891f0c5
commit
46d5d540a0
12 changed files with 954 additions and 11 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 </text>');
|
||||
parts.push('<text align="center" width="1" height="1">CASH-UP REPORT </text>');
|
||||
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>`);
|
||||
}
|
||||
parts.push('<text align="left">-------------------------------- </text>');
|
||||
if (r.shiftLabel || r.dateLabel) {
|
||||
parts.push(`<text align="left">Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')} </text>`);
|
||||
}
|
||||
if (r.counts) {
|
||||
parts.push(`<text align="left">Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account} </text>`);
|
||||
}
|
||||
parts.push('<text align="left">-------------------------------- </text>');
|
||||
parts.push('<text align="left">PAYMENT METHOD BREAKDOWN </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(' ')} </text>`);
|
||||
parts.push('<text align="left">-------------------------------- </text>');
|
||||
parts.push(`<text align="left" em="true">GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)} </text>`);
|
||||
parts.push('<text align="left">-------------------------------- </text>');
|
||||
parts.push('<text align="left">CASH RECONCILIATION </text>');
|
||||
parts.push(`<text align="left">Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)} </text>`);
|
||||
parts.push(`<text align="left">Cash counted: R ${fmtMoney(r.cashCounted || 0)} </text>`);
|
||||
parts.push(`<text align="left" em="${((r.variance || 0) === 0) ? 'true' : 'false'}">Variance: R ${fmtMoney(r.variance || 0)} </text>`);
|
||||
if (Array.isArray(r.denoms) && r.denoms.length) {
|
||||
parts.push('<text align="left">-------------------------------- </text>');
|
||||
parts.push('<text align="left">DENOMINATIONS COUNTED </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(' ')} </text>`);
|
||||
}
|
||||
}
|
||||
if (lh.footer_text) {
|
||||
parts.push('<text align="left">-------------------------------- </text>');
|
||||
parts.push(`<text align="center">${xmlEscape(lh.footer_text)} </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 };
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
150
public/pos/letterhead-config.js
Normal file
150
public/pos/letterhead-config.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
334
public/pos/settings.html
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
35
src/app/api/v1/pos/letterhead/[id]/route.ts
Normal file
35
src/app/api/v1/pos/letterhead/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
35
src/app/api/v1/pos/letterhead/route.ts
Normal file
35
src/app/api/v1/pos/letterhead/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
101
src/lib/server/pos-letterhead-service.ts
Normal file
101
src/lib/server/pos-letterhead-service.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue