Add restaurant/café mode: floor, open tabs, and kitchen display (Phase 1)
Deploy Ladill POS / deploy (push) Successful in 30s

Gated by a per-location service_style (retail | restaurant). Restaurant mode adds:
- Floor screen with tables (areas, seats, status) — tap a free table to open a
  dine-in tab; takeaway/counter orders open from the same screen.
- Open tickets (tabs): a sale stays pending while items are added; lines persist
  as you go (posTicket Alpine component posts each change to the server).
- Send-to-kitchen fires un-sent lines; a polling Kitchen Display (KDS) shows
  active tickets and bumps items queued → preparing → ready → served.
- Settlement reuses the existing cash / Ladill Pay flow; paying closes the tab
  and frees the table (PosSaleService::closeTicket, wired into both pay paths).
- Settings gains the mode toggle and a tables manager.

Schema is additive (new pos_tables; service_style on locations; order/kitchen
columns on sales + lines). Retail flow is untouched. Sidebar surfaces Floor +
Kitchen only in restaurant mode. New PosRestaurantTest covers the dine-in
lifecycle end to end; suite green (10 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-24 08:38:18 +00:00
co-authored by Claude Opus 4.8
parent e9a0c92308
commit d9e4b6e06e
19 changed files with 1299 additions and 1 deletions
+99
View File
@@ -322,6 +322,105 @@ Alpine.data('walletWidget', (config = {}) => ({
},
}));
// Restaurant ticket (open tab) — persists every change to the server and
// re-renders from the authoritative response. config: { base, lines, products, totalMinor, currency }.
Alpine.data('posTicket', (config = {}) => ({
lines: config.lines || [],
products: config.products || [],
totalMinor: config.totalMinor || 0,
currency: config.currency || 'GHS',
base: config.base || '',
csrf: config.csrf || document.querySelector('meta[name="csrf-token"]')?.content || '',
search: '',
busy: false,
flash: '',
money(minor) { return this.currency + ' ' + ((minor || 0) / 100).toFixed(2); },
get hasNew() { return this.lines.some((l) => ! l.fired); },
get filteredProducts() {
const q = this.search.trim().toLowerCase();
return q ? this.products.filter((p) => p.name.toLowerCase().includes(q)) : this.products;
},
async req(method, url, body) {
if (this.busy) return null;
this.busy = true;
this.flash = '';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrf, Accept: 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
if (! res.ok) {
const e = await res.json().catch(() => ({}));
this.flash = e.message || 'Something went wrong.';
return null;
}
return await res.json();
} catch (e) {
this.flash = 'Network error — try again.';
return null;
} finally {
this.busy = false;
}
},
apply(data) { if (data) { this.lines = data.lines; this.totalMinor = data.total_minor; } },
async addProduct(p) {
this.apply(await this.req('POST', this.base + '/lines', {
product_id: p.id, name: p.name, unit_price_minor: p.price_minor, quantity: 1,
}));
},
async setQty(line, qty) { this.apply(await this.req('PATCH', this.base + '/lines/' + line.id, { quantity: qty })); },
inc(line) { this.setQty(line, line.quantity + 1); },
dec(line) { this.setQty(line, line.quantity - 1); },
async remove(line) { this.apply(await this.req('DELETE', this.base + '/lines/' + line.id)); },
async send() {
const data = await this.req('POST', this.base + '/send', {});
this.apply(data);
if (data) this.flash = data.fired > 0 ? ('Sent ' + data.fired + ' item(s) to the kitchen.') : 'Nothing new to send.';
},
}));
// Kitchen Display (KDS) — polls the active-ticket feed and bumps line states.
// config: { feedUrl, bumpUrl } where bumpUrl contains __ID__.
Alpine.data('posKitchen', (config = {}) => ({
tickets: [],
feedUrl: config.feedUrl || '/kitchen/feed',
bumpUrl: config.bumpUrl || '/kitchen/lines/__ID__/bump',
csrf: config.csrf || document.querySelector('meta[name="csrf-token"]')?.content || '',
now: Date.now(),
loaded: false,
init() {
this.load();
setInterval(() => this.load(), 4000);
setInterval(() => { this.now = Date.now(); }, 1000);
},
async load() {
try {
const res = await fetch(this.feedUrl, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
const data = await res.json();
this.tickets = data.tickets || [];
} catch (e) { /* keep last view on transient errors */ }
this.loaded = true;
},
elapsed(iso) {
if (! iso) return '';
return Math.max(0, Math.floor((this.now - new Date(iso).getTime()) / 60000)) + 'm';
},
nextLabel(state) { return ({ queued: 'Start', preparing: 'Ready', ready: 'Served' })[state] || 'Bump'; },
async bump(line) {
try {
await fetch(this.bumpUrl.replace('__ID__', line.id), {
method: 'POST', headers: { 'X-CSRF-TOKEN': this.csrf, Accept: 'application/json' },
});
await this.load();
} catch (e) { /* ignore */ }
},
}));
window.Alpine = Alpine;
registerLadillConfirmStore(Alpine);