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);
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
['name' => 'Sales', 'route' => route('pos.sales.index'), 'active' => request()->routeIs('pos.sales.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />'],
|
||||
];
|
||||
|
||||
// Restaurant mode adds the floor + kitchen display, right after Register.
|
||||
if ($posRestaurant ?? false) {
|
||||
array_splice($nav, 2, 0, [
|
||||
['name' => 'Floor', 'route' => route('pos.floor'), 'active' => request()->routeIs('pos.floor') || request()->routeIs('pos.tickets.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />'],
|
||||
['name' => 'Kitchen', 'route' => route('pos.kitchen'), 'active' => request()->routeIs('pos.kitchen'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 0 0 .495-7.468 5.99 5.99 0 0 0-1.925 3.547 5.975 5.975 0 0 1-2.133-1.001A3.75 3.75 0 0 0 12 18Z" />'],
|
||||
]);
|
||||
}
|
||||
@endphp
|
||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
@foreach($nav as $item)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<x-app-layout title="Floor">
|
||||
@php $cur = $location->currency; @endphp
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-slate-900">Floor</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Open a tab on a table, or start a takeaway order.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<form method="post" action="{{ route('pos.tickets.open') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="order_type" value="takeaway">
|
||||
<button class="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">+ Takeaway</button>
|
||||
</form>
|
||||
<form method="post" action="{{ route('pos.tickets.open') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="order_type" value="counter">
|
||||
<button class="btn-primary">+ Counter order</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session('error'))
|
||||
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Open non-dine-in tickets --}}
|
||||
@php $loose = $openTickets->whereNull('table_id'); @endphp
|
||||
@if($loose->isNotEmpty())
|
||||
<div>
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Open orders</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach($loose as $t)
|
||||
<a href="{{ route('pos.tickets.show', $t) }}" class="rounded-2xl border border-slate-200 bg-white p-4 transition hover:border-indigo-200 hover:shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium capitalize text-slate-600">{{ str_replace('_', ' ', $t->order_type) }}</span>
|
||||
@if($t->kitchen_status === \App\Models\PosSale::KITCHEN_ACTIVE)
|
||||
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700">In kitchen</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-2 text-sm font-semibold text-slate-900">{{ $cur }} {{ number_format($t->total_minor / 100, 2) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">{{ $t->lines_count }} item(s) · {{ $t->opened_at?->diffForHumans() }}</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Tables --}}
|
||||
@if($tables->isEmpty())
|
||||
<div class="rounded-2xl border border-dashed border-slate-200 bg-white px-6 py-12 text-center">
|
||||
<p class="text-sm text-slate-500">No tables yet.</p>
|
||||
<a href="{{ route('pos.settings') }}" class="mt-3 inline-block text-sm font-semibold text-indigo-600 hover:text-indigo-800">Add tables in Settings</a>
|
||||
</div>
|
||||
@else
|
||||
@foreach($tablesByArea as $area => $areaTables)
|
||||
<div>
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">{{ $area }}</h2>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||
@foreach($areaTables as $table)
|
||||
@php $occupied = ! $table->isFree() && $table->currentSale; @endphp
|
||||
@if($occupied)
|
||||
<a href="{{ route('pos.tickets.show', $table->currentSale) }}"
|
||||
class="flex flex-col rounded-2xl border-2 border-amber-300 bg-amber-50 p-4 text-left transition hover:border-amber-400">
|
||||
<span class="text-sm font-semibold text-slate-900">{{ $table->label }}</span>
|
||||
<span class="mt-1 text-xs text-amber-700">Occupied</span>
|
||||
<span class="mt-2 text-sm font-semibold text-slate-900">{{ $cur }} {{ number_format($table->currentSale->total_minor / 100, 2) }}</span>
|
||||
</a>
|
||||
@else
|
||||
<form method="post" action="{{ route('pos.tickets.open') }}" class="contents">
|
||||
@csrf
|
||||
<input type="hidden" name="order_type" value="dine_in">
|
||||
<input type="hidden" name="table_id" value="{{ $table->id }}">
|
||||
<button class="flex w-full flex-col rounded-2xl border-2 border-slate-200 bg-white p-4 text-left transition hover:border-indigo-300 hover:bg-indigo-50/40">
|
||||
<span class="text-sm font-semibold text-slate-900">{{ $table->label }}</span>
|
||||
<span class="mt-1 text-xs text-slate-400">{{ $table->seats }} seats · Free</span>
|
||||
<span class="mt-2 text-xs font-semibold text-indigo-600">Open tab →</span>
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,53 @@
|
||||
<x-app-layout title="Kitchen">
|
||||
<div x-data="posKitchen(@js([
|
||||
'feedUrl' => route('pos.kitchen.feed'),
|
||||
'bumpUrl' => route('pos.kitchen.bump', ['line' => '__ID__']),
|
||||
]))" class="space-y-4">
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-slate-900">Kitchen</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Live tickets — tap an item to advance it. Refreshes automatically.</p>
|
||||
</div>
|
||||
<button type="button" @click="load()" class="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">Refresh</button>
|
||||
</div>
|
||||
|
||||
<template x-if="loaded && tickets.length === 0">
|
||||
<div class="rounded-2xl border border-dashed border-slate-200 bg-white px-6 py-16 text-center">
|
||||
<p class="text-sm text-slate-500">No active tickets. Fired orders will appear here.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<template x-for="t in tickets" :key="t.id">
|
||||
<div class="flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 bg-slate-50 px-4 py-2.5">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-900" x-text="t.table ? t.table : t.order_type.replace('_',' ')"></p>
|
||||
<p class="text-[11px] text-slate-400" x-text="t.reference"></p>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-900/5 px-2 py-0.5 text-xs font-medium text-slate-600" x-text="elapsed(t.sent_at)"></span>
|
||||
</div>
|
||||
<div class="flex-1 divide-y divide-slate-100">
|
||||
<template x-for="line in t.lines" :key="line.id">
|
||||
<div class="flex items-center justify-between gap-2 px-4 py-2.5"
|
||||
:class="line.state === 'ready' ? 'bg-emerald-50' : (line.state === 'preparing' ? 'bg-amber-50/60' : '')">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-slate-900">
|
||||
<span x-text="line.quantity + '×'"></span>
|
||||
<span x-text="line.name"></span>
|
||||
</p>
|
||||
<p x-show="line.notes" x-cloak class="text-xs text-rose-600" x-text="line.notes"></p>
|
||||
<p class="text-[11px] uppercase tracking-wide text-slate-400" x-text="line.state"></p>
|
||||
</div>
|
||||
<button type="button" @click="bump(line)"
|
||||
class="shrink-0 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50"
|
||||
x-text="nextLabel(line.state)"></button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -19,6 +19,15 @@
|
||||
<input type="text" id="currency" name="currency" value="{{ old('currency', $location->currency) }}" maxlength="3" required
|
||||
class="mt-1.5 block w-full rounded-xl border-slate-300 text-sm uppercase shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="service_style" class="text-sm font-medium text-slate-700">Service style</label>
|
||||
<select id="service_style" name="service_style"
|
||||
class="mt-1.5 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="retail" @selected(old('service_style', $location->service_style) === 'retail')>Retail — quick register checkout</option>
|
||||
<option value="restaurant" @selected(old('service_style', $location->service_style) === 'restaurant')>Restaurant / café — tables, tabs & kitchen display</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-400">Restaurant mode adds the Floor and Kitchen screens to the sidebar.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="receipt_footer" class="text-sm font-medium text-slate-700">Receipt footer</label>
|
||||
<textarea id="receipt_footer" name="receipt_footer" rows="3"
|
||||
@@ -49,5 +58,42 @@
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if ($location->isRestaurant())
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Tables</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Dine-in tables shown on the Floor screen.</p>
|
||||
|
||||
<form method="POST" action="{{ route('pos.settings.tables.store') }}" class="mt-4 grid gap-2 sm:grid-cols-[1fr_1fr_5rem_auto]">
|
||||
@csrf
|
||||
<input type="text" name="area" placeholder="Area (e.g. Patio)"
|
||||
class="rounded-xl border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<input type="text" name="label" placeholder="Label (e.g. T1)" required
|
||||
class="rounded-xl border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<input type="number" name="seats" value="2" min="1" max="99" placeholder="Seats"
|
||||
class="rounded-xl border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<button type="submit" class="btn-primary">Add</button>
|
||||
</form>
|
||||
|
||||
@if ($tables->isNotEmpty())
|
||||
<ul class="mt-4 divide-y divide-slate-100">
|
||||
@foreach ($tables as $table)
|
||||
<li class="flex items-center justify-between py-2.5 text-sm">
|
||||
<span>
|
||||
<span class="font-medium text-slate-900">{{ $table->label }}</span>
|
||||
<span class="text-slate-400">· {{ $table->area ?: 'Floor' }} · {{ $table->seats }} seats · {{ $table->isFree() ? 'free' : 'occupied' }}</span>
|
||||
</span>
|
||||
<form method="POST" action="{{ route('pos.settings.tables.destroy', $table) }}"
|
||||
onsubmit="return confirm('Remove this table?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="text-sm font-medium text-red-500 hover:text-red-700">Remove</button>
|
||||
</form>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<x-app-layout title="Ticket">
|
||||
<div x-data="posTicket(@js([
|
||||
'base' => route('pos.tickets.show', $sale),
|
||||
'lines' => $lines,
|
||||
'products' => $products->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'price_minor' => $p->price_minor])->values()->all(),
|
||||
'totalMinor' => $sale->total_minor,
|
||||
'currency' => $sale->currency,
|
||||
]))" class="space-y-4">
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<a href="{{ route('pos.floor') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||
Floor
|
||||
</a>
|
||||
<h1 class="mt-1 text-lg font-semibold text-slate-900">
|
||||
{{ $sale->table?->label ?? ucfirst(str_replace('_', ' ', $sale->order_type)) }}
|
||||
</h1>
|
||||
<p class="text-sm text-slate-500 capitalize">{{ str_replace('_', ' ', $sale->order_type) }}{{ $sale->covers ? ' · '.$sale->covers.' covers' : '' }} · {{ $sale->reference }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p x-show="flash" x-text="flash" x-cloak class="rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-2 text-sm text-indigo-700"></p>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-[1fr_22rem]">
|
||||
{{-- Catalog --}}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<input type="search" x-model="search" placeholder="Search items…"
|
||||
class="mb-3 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<template x-for="p in filteredProducts" :key="p.id">
|
||||
<button type="button" @click="addProduct(p)" :disabled="busy"
|
||||
class="rounded-xl border border-slate-200 p-3 text-left transition hover:border-indigo-300 hover:bg-indigo-50/50 disabled:opacity-50">
|
||||
<span class="block text-sm font-medium text-slate-900" x-text="p.name"></span>
|
||||
<span class="mt-1 block text-xs text-slate-500" x-text="money(p.price_minor)"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="filteredProducts.length === 0">
|
||||
<p class="col-span-full py-6 text-center text-sm text-slate-400">No products. Add some under Products.</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ticket --}}
|
||||
<div class="flex flex-col rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex-1 space-y-2 p-4">
|
||||
<template x-if="lines.length === 0">
|
||||
<p class="py-8 text-center text-sm text-slate-400">No items yet. Tap a product to add it.</p>
|
||||
</template>
|
||||
<template x-for="line in lines" :key="line.id">
|
||||
<div class="rounded-xl border border-slate-100 p-2.5">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-slate-900" x-text="line.name"></p>
|
||||
<p class="text-xs text-slate-400" x-text="money(line.unit_price_minor)"></p>
|
||||
<span x-show="line.fired" x-cloak class="mt-1 inline-block rounded-full bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">In kitchen</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-semibold text-slate-900" x-text="money(line.line_total_minor)"></p>
|
||||
<div class="mt-1 flex items-center justify-end gap-1.5">
|
||||
<button type="button" @click="dec(line)" :disabled="busy" class="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 text-slate-600 hover:bg-slate-50">−</button>
|
||||
<span class="w-5 text-center text-sm" x-text="line.quantity"></span>
|
||||
<button type="button" @click="inc(line)" :disabled="busy" class="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 text-slate-600 hover:bg-slate-50">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 p-4">
|
||||
<div class="mb-3 flex items-center justify-between text-base font-semibold text-slate-900">
|
||||
<span>Total</span>
|
||||
<span x-text="money(totalMinor)"></span>
|
||||
</div>
|
||||
|
||||
<button type="button" @click="send()" :disabled="busy || ! hasNew"
|
||||
class="mb-2 w-full rounded-xl border border-amber-300 bg-amber-50 px-4 py-2.5 text-sm font-semibold text-amber-800 transition hover:bg-amber-100 disabled:opacity-40">
|
||||
Send to kitchen
|
||||
</button>
|
||||
|
||||
<form method="post" action="{{ route('pos.tickets.settle', $sale) }}" class="space-y-2">
|
||||
@csrf
|
||||
<input type="text" name="customer_name" placeholder="Customer name (optional)"
|
||||
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button name="payment_method" value="cash" :disabled="lines.length === 0"
|
||||
class="rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50 disabled:opacity-40">Cash</button>
|
||||
<button name="payment_method" value="pay" :disabled="lines.length === 0"
|
||||
class="btn-primary disabled:opacity-40">Ladill Pay</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
Reference in New Issue
Block a user