diff --git a/app/Http/Controllers/Pos/KitchenController.php b/app/Http/Controllers/Pos/KitchenController.php index 8e5c2cf..d500648 100644 --- a/app/Http/Controllers/Pos/KitchenController.php +++ b/app/Http/Controllers/Pos/KitchenController.php @@ -63,6 +63,7 @@ class KitchenController extends Controller 'course' => $l->course, 'station' => $l->station?->name, 'station_id' => $l->station_id, + 'source' => $l->source, 'modifiers' => $l->modifiers->map(fn ($m) => $m->name)->all(), 'state' => $l->kitchen_state, ])->all(), diff --git a/app/Http/Controllers/Pos/TableController.php b/app/Http/Controllers/Pos/TableController.php index 8c69714..e4dab7a 100644 --- a/app/Http/Controllers/Pos/TableController.php +++ b/app/Http/Controllers/Pos/TableController.php @@ -42,4 +42,14 @@ class TableController extends Controller 'openTickets' => $openTickets, ]); } + + public function qr(Request $request, PosTable $table): View + { + $this->authorizeOwner($request, $table); + + return view('pos.tables.qr', [ + 'table' => $table, + 'url' => route('pos.table.menu', $table->ensureShortCode()), + ]); + } } diff --git a/app/Http/Controllers/Pos/TicketController.php b/app/Http/Controllers/Pos/TicketController.php index 7296f5e..0ff593d 100644 --- a/app/Http/Controllers/Pos/TicketController.php +++ b/app/Http/Controllers/Pos/TicketController.php @@ -107,29 +107,19 @@ class TicketController extends Controller ]; if (! empty($data['product_id'])) { - $product = PosProduct::owned($this->ownerRef($request)) - ->with('modifierGroups.modifiers') - ->find($data['product_id']); + $resolved = $this->sales->buildProductLine( + $this->ownerRef($request), + (int) $data['product_id'], + $data['modifier_ids'] ?? [], + $payload['quantity'], + $payload['notes'], + $payload['course'], + ); - if (! $product) { + if ($resolved === null) { return response()->json(['message' => 'Unknown product.'], 422); } - - // Only modifiers that belong to this product's groups count. - $allowed = $product->modifierGroups->flatMap->modifiers->keyBy('id'); - $chosen = collect($data['modifier_ids'] ?? []) - ->map(fn ($id) => $allowed->get((int) $id)) - ->filter() - ->values(); - - $payload['product_id'] = $product->id; - $payload['name'] = $product->name; - $payload['station_id'] = $product->station_id; - $payload['course'] = $payload['course'] ?? $product->course; - $payload['unit_price_minor'] = max(1, $product->price_minor + (int) $chosen->sum('price_delta_minor')); - $payload['modifiers'] = $chosen->map(fn ($m) => [ - 'modifier_id' => $m->id, 'name' => $m->name, 'price_delta_minor' => $m->price_delta_minor, - ])->all(); + $payload = $resolved; } else { // Custom (open-price) line. $payload['name'] = (string) ($data['name'] ?? ''); @@ -260,6 +250,7 @@ class TicketController extends Controller 'kitchen_state' => $l->kitchen_state, 'notes' => $l->notes, 'course' => $l->course, + 'source' => $l->source, 'modifiers' => $l->modifiers->map(fn ($m) => $m->name)->all(), 'fired' => $l->kitchen_state !== PosSaleLine::KITCHEN_NEW, ])->all(); diff --git a/app/Http/Controllers/Public/TableOrderController.php b/app/Http/Controllers/Public/TableOrderController.php new file mode 100644 index 0000000..5f9afd8 --- /dev/null +++ b/app/Http/Controllers/Public/TableOrderController.php @@ -0,0 +1,139 @@ +firstOrFail(); + $owner = (string) $table->owner_ref; + $location = PosLocation::owned($owner)->first(); + + $products = PosProduct::owned($owner)->active() + ->with(['category', 'modifierGroups.modifiers']) + ->orderBy('name')->get(); + + return view('public.table-menu', [ + 'table' => $table, + 'storeName' => $location?->name ?: 'Menu', + 'currency' => $location?->currency ?: config('pos.default_currency', 'GHS'), + 'products' => $products->map(fn (PosProduct $p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'price_minor' => $p->price_minor, + 'category' => $p->category?->name, + 'modifier_groups' => $p->modifierGroups->map(fn ($g) => [ + 'id' => $g->id, 'name' => $g->name, 'min' => $g->min_select, 'max' => $g->max_select, + 'modifiers' => $g->modifiers->map(fn ($m) => [ + 'id' => $m->id, 'name' => $m->name, 'price_delta_minor' => $m->price_delta_minor, + ])->values()->all(), + ])->values()->all(), + ])->values()->all(), + ]); + } + + public function store(Request $request, string $code): JsonResponse + { + $table = PosTable::where('short_code', $code)->firstOrFail(); + $owner = (string) $table->owner_ref; + + $data = $request->validate([ + 'items' => ['required', 'array', 'min:1', 'max:50'], + 'items.*.product_id' => ['required', 'integer'], + 'items.*.quantity' => ['required', 'integer', 'min:1', 'max:50'], + 'items.*.modifier_ids' => ['nullable', 'array'], + 'items.*.modifier_ids.*' => ['integer'], + 'items.*.notes' => ['nullable', 'string', 'max:200'], + 'customer_name' => ['nullable', 'string', 'max:120'], + ]); + + $sale = $this->resolveTab($table, $owner); + if ($sale === null) { + return response()->json(['message' => 'This table is not available for ordering right now.'], 422); + } + + if (! empty($data['customer_name']) && ! $sale->customer_name) { + $sale->forceFill(['customer_name' => $data['customer_name']])->save(); + } + + $added = 0; + foreach ($data['items'] as $item) { + $payload = $this->sales->buildProductLine( + $owner, + (int) $item['product_id'], + $item['modifier_ids'] ?? [], + (int) $item['quantity'], + $item['notes'] ?? null, + null, + 'guest', + ); + if ($payload !== null) { + $this->sales->addLine($sale, $payload); + $added++; + } + } + + if ($added === 0) { + return response()->json(['message' => 'Those items are no longer available.'], 422); + } + + // Guest orders go straight to the kitchen. + $this->sales->sendToKitchen($sale); + + return response()->json(['redirect' => route('pos.table.confirmed', $code)]); + } + + public function confirmed(string $code): View + { + $table = PosTable::where('short_code', $code)->firstOrFail(); + $location = PosLocation::owned((string) $table->owner_ref)->first(); + + return view('public.table-confirmed', [ + 'table' => $table, + 'storeName' => $location?->name ?: 'Thanks', + ]); + } + + private function resolveTab(PosTable $table, string $owner): ?PosSale + { + if ($table->current_sale_id) { + $existing = PosSale::owned($owner)->openTickets()->find($table->current_sale_id); + if ($existing) { + return $existing; + } + } + + $merchant = User::where('public_id', $owner)->first(); + if (! $merchant) { + return null; + } + + $location = PosLocation::owned($owner)->first(); + + return $this->sales->openTicket($merchant, [ + 'order_type' => PosSale::ORDER_DINE_IN, + 'table_id' => $table->id, + 'location_id' => $location?->id, + 'currency' => $location?->currency, + ]); + } +} diff --git a/app/Models/PosSaleLine.php b/app/Models/PosSaleLine.php index 96c49b5..1641394 100644 --- a/app/Models/PosSaleLine.php +++ b/app/Models/PosSaleLine.php @@ -38,6 +38,7 @@ class PosSaleLine extends Model 'line_total_minor', 'position', 'kitchen_state', + 'source', 'notes', 'course', ]; diff --git a/app/Models/PosTable.php b/app/Models/PosTable.php index ba13aa2..5921631 100644 --- a/app/Models/PosTable.php +++ b/app/Models/PosTable.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Str; class PosTable extends Model { @@ -21,10 +22,25 @@ class PosTable extends Model 'label', 'seats', 'status', + 'short_code', 'current_sale_id', 'position', ]; + /** Ensure the table has a unique public short code for QR ordering. */ + public function ensureShortCode(): string + { + if (! $this->short_code) { + do { + $code = strtolower(Str::random(8)); + } while (static::where('short_code', $code)->exists()); + + $this->forceFill(['short_code' => $code])->save(); + } + + return $this->short_code; + } + protected function casts(): array { return [ diff --git a/app/Services/Pos/PosSaleService.php b/app/Services/Pos/PosSaleService.php index 080791d..66cae2a 100644 --- a/app/Services/Pos/PosSaleService.php +++ b/app/Services/Pos/PosSaleService.php @@ -2,6 +2,7 @@ namespace App\Services\Pos; +use App\Models\PosProduct; use App\Models\PosSale; use App\Models\PosSaleLine; use App\Models\PosSaleLineModifier; @@ -114,7 +115,39 @@ class PosSaleService } /** - * @param array{product_id?: ?int, station_id?: ?int, course?: ?string, name: string, unit_price_minor: int, quantity?: int, notes?: ?string, modifiers?: list} $line + * Resolve a catalog product (owner-scoped) + chosen modifiers into an addLine + * payload. Effective price = base + modifier deltas; null if product unknown. + * + * @param list $modifierIds + * @return array|null + */ + public function buildProductLine(string $owner, int $productId, array $modifierIds = [], int $quantity = 1, ?string $notes = null, ?string $course = null, string $source = 'staff'): ?array + { + $product = PosProduct::owned($owner)->with('modifierGroups.modifiers')->find($productId); + if (! $product) { + return null; + } + + $allowed = $product->modifierGroups->flatMap->modifiers->keyBy('id'); + $chosen = collect($modifierIds)->map(fn ($id) => $allowed->get((int) $id))->filter()->values(); + + return [ + 'product_id' => $product->id, + 'name' => $product->name, + 'station_id' => $product->station_id, + 'course' => $course ?: $product->course, + 'quantity' => max(1, $quantity), + 'notes' => $notes, + 'source' => $source, + 'unit_price_minor' => max(1, $product->price_minor + (int) $chosen->sum('price_delta_minor')), + 'modifiers' => $chosen->map(fn ($m) => [ + 'modifier_id' => $m->id, 'name' => $m->name, 'price_delta_minor' => $m->price_delta_minor, + ])->all(), + ]; + } + + /** + * @param array{product_id?: ?int, station_id?: ?int, course?: ?string, source?: string, name: string, unit_price_minor: int, quantity?: int, notes?: ?string, modifiers?: list} $line */ public function addLine(PosSale $sale, array $line): PosSaleLine { @@ -138,6 +171,7 @@ class PosSaleService 'line_total_minor' => $unit * $qty, 'position' => $position, 'kitchen_state' => PosSaleLine::KITCHEN_NEW, + 'source' => $line['source'] ?? 'staff', 'notes' => isset($line['notes']) ? trim((string) $line['notes']) ?: null : null, 'course' => $line['course'] ?? null, ]); diff --git a/database/migrations/2026_06_29_000000_add_pos_table_qr_ordering.php b/database/migrations/2026_06_29_000000_add_pos_table_qr_ordering.php new file mode 100644 index 0000000..862a147 --- /dev/null +++ b/database/migrations/2026_06_29_000000_add_pos_table_qr_ordering.php @@ -0,0 +1,29 @@ +string('short_code', 16)->nullable()->after('status')->index(); + }); + + Schema::table('pos_sale_lines', function (Blueprint $table) { + $table->string('source')->default('staff')->after('kitchen_state'); // staff | guest + }); + } + + public function down(): void + { + Schema::table('pos_sale_lines', function (Blueprint $table) { + $table->dropColumn('source'); + }); + Schema::table('pos_tables', function (Blueprint $table) { + $table->dropColumn('short_code'); + }); + } +}; diff --git a/resources/js/app.js b/resources/js/app.js index 4b91159..5726cd9 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -485,6 +485,120 @@ Alpine.data('posKitchen', (config = {}) => ({ }, })); +// Render a QR code into the element. config: { data, size }. +Alpine.data('posQr', (config = {}) => ({ + init() { + if (! window.QRCodeStyling || ! config.data) return; + const qr = new window.QRCodeStyling({ + width: config.size || 220, height: config.size || 220, margin: 6, + data: config.data, + dotsOptions: { type: 'rounded', color: '#0f172a' }, + backgroundOptions: { color: '#ffffff' }, + }); + qr.append(this.$el); + }, +})); + +// Public table-QR ordering — a client-side cart that submits a guest order. +// config: { products, currency, orderUrl }. +Alpine.data('posTableOrder', (config = {}) => ({ + products: config.products || [], + currency: config.currency || 'GHS', + orderUrl: config.orderUrl || '', + csrf: config.csrf || document.querySelector('meta[name="csrf-token"]')?.content || '', + search: '', + activeCategory: '', + cart: [], + cartOpen: false, + busy: false, + flash: '', + customerName: '', + picker: { open: false, product: null, groups: [], notes: '' }, + + money(minor) { return this.currency + ' ' + ((minor || 0) / 100).toFixed(2); }, + get categories() { + const set = []; + this.products.forEach((p) => { if (p.category && ! set.includes(p.category)) set.push(p.category); }); + return set; + }, + get filteredProducts() { + const q = this.search.trim().toLowerCase(); + return this.products.filter((p) => + (! this.activeCategory || p.category === this.activeCategory) + && (! q || p.name.toLowerCase().includes(q))); + }, + get cartCount() { return this.cart.reduce((n, i) => n + i.quantity, 0); }, + get cartTotal() { return this.cart.reduce((s, i) => s + i.unit_minor * i.quantity, 0); }, + + tap(p) { + if (p.modifier_groups && p.modifier_groups.length) this.openPicker(p); + else this.addToCart(p, { ids: [], names: [] }); + }, + openPicker(p) { + this.flash = ''; + this.picker = { open: true, product: p, notes: '', groups: p.modifier_groups.map((g) => ({ ...g, selected: [] })) }; + }, + closePicker() { this.picker.open = false; }, + isSelected(g, m) { return g.selected.includes(m.id); }, + toggleModifier(g, m) { + const i = g.selected.indexOf(m.id); + if (i >= 0) { g.selected.splice(i, 1); return; } + if (g.max === 1) { g.selected = [m.id]; return; } + if (g.max && g.selected.length >= g.max) return; + g.selected.push(m.id); + }, + get pickerPrice() { + if (! this.picker.product) return 0; + let total = this.picker.product.price_minor; + this.picker.groups.forEach((g) => g.modifiers.forEach((m) => { if (g.selected.includes(m.id)) total += m.price_delta_minor; })); + return total; + }, + get pickerValid() { return this.picker.groups.every((g) => g.selected.length >= (g.min || 0)); }, + confirmPicker() { + if (! this.pickerValid) { this.flash = 'Choose the required options.'; return; } + const ids = this.picker.groups.flatMap((g) => g.selected); + const names = this.picker.groups.flatMap((g) => g.modifiers.filter((m) => g.selected.includes(m.id)).map((m) => m.name)); + this.addToCart(this.picker.product, { ids, names, notes: this.picker.notes }); + this.closePicker(); + }, + addToCart(p, opts) { + let unit = p.price_minor; + (opts.ids || []).forEach((id) => p.modifier_groups.forEach((g) => g.modifiers.forEach((m) => { if (m.id === id) unit += m.price_delta_minor; }))); + this.cart.push({ + key: Date.now() + '-' + Math.random(), + product_id: p.id, name: p.name, quantity: 1, + modifier_ids: opts.ids || [], modifier_names: opts.names || [], + notes: opts.notes || '', unit_minor: unit, + }); + this.cartOpen = true; + }, + inc(i) { i.quantity++; }, + dec(i) { i.quantity--; if (i.quantity <= 0) this.remove(i); }, + remove(i) { this.cart = this.cart.filter((x) => x.key !== i.key); if (! this.cart.length) this.cartOpen = false; }, + + async submit() { + if (! this.cart.length || this.busy) return; + this.busy = true; + this.flash = ''; + try { + const res = await fetch(this.orderUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrf, Accept: 'application/json' }, + body: JSON.stringify({ + customer_name: this.customerName || null, + items: this.cart.map((i) => ({ product_id: i.product_id, quantity: i.quantity, modifier_ids: i.modifier_ids, notes: i.notes || null })), + }), + }); + const data = await res.json().catch(() => ({})); + if (! res.ok) { this.flash = data.message || 'Could not place the order.'; this.busy = false; return; } + window.location.href = data.redirect; + } catch (e) { + this.flash = 'Network error — try again.'; + this.busy = false; + } + }, +})); + window.Alpine = Alpine; registerLadillConfirmStore(Alpine); diff --git a/resources/views/pos/kitchen/index.blade.php b/resources/views/pos/kitchen/index.blade.php index adbff6f..074aebf 100644 --- a/resources/views/pos/kitchen/index.blade.php +++ b/resources/views/pos/kitchen/index.blade.php @@ -54,6 +54,7 @@
+ guest
diff --git a/resources/views/pos/settings.blade.php b/resources/views/pos/settings.blade.php index 80e6e8a..ebc90a8 100644 --- a/resources/views/pos/settings.blade.php +++ b/resources/views/pos/settings.blade.php @@ -83,12 +83,15 @@ {{ $table->label }} · {{ $table->area ?: 'Floor' }} · {{ $table->seats }} seats · {{ $table->isFree() ? 'free' : 'occupied' }} -
- @csrf - @method('DELETE') - -
+
+ QR +
+ @csrf + @method('DELETE') + +
+
@endforeach diff --git a/resources/views/pos/tables/qr.blade.php b/resources/views/pos/tables/qr.blade.php new file mode 100644 index 0000000..bca7aa3 --- /dev/null +++ b/resources/views/pos/tables/qr.blade.php @@ -0,0 +1,20 @@ + +
+ + + Floor + + +
+

Scan to order

+

{{ $table->label }}

+
+

{{ $url }}

+
+ +
+ + Open menu +
+
+
diff --git a/resources/views/pos/tickets/show.blade.php b/resources/views/pos/tickets/show.blade.php index 33391a5..3635f28 100644 --- a/resources/views/pos/tickets/show.blade.php +++ b/resources/views/pos/tickets/show.blade.php @@ -68,6 +68,7 @@

+ Guest In kitchen
diff --git a/resources/views/public/table-confirmed.blade.php b/resources/views/public/table-confirmed.blade.php new file mode 100644 index 0000000..a2df651 --- /dev/null +++ b/resources/views/public/table-confirmed.blade.php @@ -0,0 +1,26 @@ + + + + + + + Order received · {{ $storeName }} + @include('partials.favicon') + + + @vite(['resources/css/app.css']) + + +
+
+ +
+

Order sent to the kitchen

+

Thanks! Your order for {{ $table->label }} is being prepared. Pay at the table when you're done.

+ + Order more + +
+ + diff --git a/resources/views/public/table-menu.blade.php b/resources/views/public/table-menu.blade.php new file mode 100644 index 0000000..8d1f1f9 --- /dev/null +++ b/resources/views/public/table-menu.blade.php @@ -0,0 +1,154 @@ + + + + + + + + {{ $storeName }} · {{ $table->label }} + @include('partials.favicon') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+ +
+

{{ $storeName }}

+

{{ $table->label }} · order & we'll bring it over

+
+ + + +
+ + +
+ +

+ +
+ + +
+ + {{-- Floating cart bar --}} +
+
+ +
+
+ + {{-- Modifier picker --}} +
+
+
+

+ +
+
+ +
+ + +
+
+
+ + +
+
+
+ + {{-- Cart sheet --}} +
+
+
+

Your order · {{ $table->label }}

+ +
+
+ +
+
+ +
+ Total +
+ +

Pay at the table when you're done.

+
+
+
+
+ + diff --git a/routes/web.php b/routes/web.php index 54f03c7..efab49f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,6 +12,7 @@ use App\Http\Controllers\Pos\SettingsController; use App\Http\Controllers\Pos\TableController; use App\Http\Controllers\Pos\TicketController; use App\Http\Controllers\NotificationController; +use App\Http\Controllers\Public\TableOrderController; use App\Http\Controllers\WalletBalanceController; use Illuminate\Support\Facades\Route; @@ -30,6 +31,11 @@ Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('pos.dash Route::get('/sales/{sale}/callback', [SaleController::class, 'callback'])->name('pos.sales.callback'); +// Public table-QR ordering (no auth — scoped by the table short_code). +Route::get('/t/{code}', [TableOrderController::class, 'menu'])->name('pos.table.menu'); +Route::post('/t/{code}/order', [TableOrderController::class, 'store'])->middleware('throttle:20,1')->name('pos.table.order'); +Route::get('/t/{code}/done', [TableOrderController::class, 'confirmed'])->name('pos.table.confirmed'); + Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance'); @@ -50,6 +56,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { // Restaurant mode — floor, open tickets (tabs), and the kitchen display. Route::get('/floor', [TableController::class, 'index'])->name('pos.floor'); + Route::get('/floor/tables/{table}/qr', [TableController::class, 'qr'])->name('pos.tables.qr'); Route::post('/tickets', [TicketController::class, 'open'])->name('pos.tickets.open'); Route::get('/tickets/{sale}', [TicketController::class, 'show'])->name('pos.tickets.show'); Route::post('/tickets/{sale}/lines', [TicketController::class, 'addLine'])->name('pos.tickets.lines.add'); diff --git a/tests/Feature/PosRestaurantTest.php b/tests/Feature/PosRestaurantTest.php index c90668f..6fff281 100644 --- a/tests/Feature/PosRestaurantTest.php +++ b/tests/Feature/PosRestaurantTest.php @@ -156,6 +156,37 @@ class PosRestaurantTest extends TestCase ->assertJsonPath('tickets.0.lines.0.modifiers.0', 'Large'); } + public function test_guest_table_qr_order_fires_to_kitchen(): void + { + $user = $this->user(); + [, $table, $product] = $this->restaurant($user); + $code = $table->ensureShortCode(); + + // Public menu loads with no auth. + $this->get(route('pos.table.menu', $code))->assertOk()->assertSee($product->name); + + // Guest submits an order from the table. + $this->postJson(route('pos.table.order', $code), [ + 'customer_name' => 'Ama', + 'items' => [ + ['product_id' => $product->id, 'quantity' => 2, 'modifier_ids' => [], 'notes' => 'extra hot'], + ], + ])->assertOk()->assertJsonStructure(['redirect']); + + $sale = PosSale::where('owner_ref', $user->public_id)->firstOrFail(); + $this->assertSame(PosSale::ORDER_DINE_IN, $sale->order_type); + $this->assertSame($table->id, $sale->table_id); + $this->assertSame('Ama', $sale->customer_name); + $this->assertSame(PosSale::KITCHEN_ACTIVE, $sale->kitchen_status); + $this->assertSame(PosTable::STATUS_OCCUPIED, $table->fresh()->status); + + $line = $sale->lines()->firstOrFail(); + $this->assertSame('guest', $line->source); + $this->assertSame(2, $line->quantity); + $this->assertSame('extra hot', $line->notes); + $this->assertSame(PosSaleLine::KITCHEN_QUEUED, $line->kitchen_state); + } + public function test_cannot_open_two_tabs_on_one_table(): void { $user = $this->user();