Add restaurant/café mode: floor, open tabs, and kitchen display (Phase 1)
Deploy Ladill POS / deploy (push) Successful in 30s
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:
co-authored by
Claude Opus 4.8
parent
e9a0c92308
commit
d9e4b6e06e
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user