Add public token-gated kitchen display for POS devices.
Deploy Ladill POS / deploy (push) Successful in 46s
Deploy Ladill POS / deploy (push) Successful in 46s
Kitchen display devices open a full-screen KDS without staff login, with feed/stream/bump over the device token. Shared kitchen board logic is extracted so the signed-in Kitchen page stays in sync.
This commit is contained in:
@@ -4,12 +4,10 @@ namespace App\Http\Controllers\Pos;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||||
use App\Models\PosSale;
|
|
||||||
use App\Models\PosSaleLine;
|
use App\Models\PosSaleLine;
|
||||||
use App\Models\PosStation;
|
use App\Services\Pos\KitchenBoardService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
@@ -17,10 +15,12 @@ class KitchenController extends Controller
|
|||||||
{
|
{
|
||||||
use ScopesToAccount;
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(private KitchenBoardService $board) {}
|
||||||
|
|
||||||
public function index(Request $request): View
|
public function index(Request $request): View
|
||||||
{
|
{
|
||||||
return view('pos.kitchen.index', [
|
return view('pos.kitchen.index', [
|
||||||
'stations' => PosStation::owned($this->ownerRef($request))->orderBy('name')->get(['id', 'name']),
|
'stations' => $this->board->stations($this->ownerRef($request)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ class KitchenController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function feed(Request $request): JsonResponse
|
public function feed(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json($this->payload($this->ownerRef($request)));
|
return response()->json($this->board->payload($this->ownerRef($request)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +54,7 @@ class KitchenController extends Controller
|
|||||||
$lastHash = null;
|
$lastHash = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
$payload = $this->payload($owner);
|
$payload = $this->board->payload($owner);
|
||||||
$json = json_encode($payload);
|
$json = json_encode($payload);
|
||||||
$hash = md5((string) $json);
|
$hash = md5((string) $json);
|
||||||
|
|
||||||
@@ -82,80 +82,11 @@ class KitchenController extends Controller
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{tickets: \Illuminate\Support\Collection, server_time: string} */
|
|
||||||
private function payload(string $owner): array
|
|
||||||
{
|
|
||||||
return ['tickets' => $this->buildTickets($owner), 'server_time' => now()->toIso8601String()];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildTickets(string $owner): Collection
|
|
||||||
{
|
|
||||||
// Payment-agnostic: dine-in tabs (pending) and paid online orders both
|
|
||||||
// sit on the board while the kitchen is still working them.
|
|
||||||
$sales = PosSale::owned($owner)
|
|
||||||
->where('kitchen_status', PosSale::KITCHEN_ACTIVE)
|
|
||||||
->with(['table', 'lines' => fn ($q) => $q->orderBy('position')->with('modifiers', 'station')])
|
|
||||||
->orderBy('kitchen_sent_at')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $sales->map(function (PosSale $sale) {
|
|
||||||
$lines = $sale->lines
|
|
||||||
->filter(fn (PosSaleLine $l) => in_array($l->kitchen_state, [
|
|
||||||
PosSaleLine::KITCHEN_QUEUED,
|
|
||||||
PosSaleLine::KITCHEN_PREPARING,
|
|
||||||
PosSaleLine::KITCHEN_READY,
|
|
||||||
], true))
|
|
||||||
->values();
|
|
||||||
|
|
||||||
if ($lines->isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $sale->id,
|
|
||||||
'reference' => $sale->reference,
|
|
||||||
'order_type' => $sale->order_type,
|
|
||||||
'table' => $sale->table?->label,
|
|
||||||
'sent_at' => optional($sale->kitchen_sent_at)->toIso8601String(),
|
|
||||||
'lines' => $lines->map(fn (PosSaleLine $l) => [
|
|
||||||
'id' => $l->id,
|
|
||||||
'name' => $l->name,
|
|
||||||
'quantity' => $l->quantity,
|
|
||||||
'notes' => $l->notes,
|
|
||||||
'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(),
|
|
||||||
];
|
|
||||||
})->filter()->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance one line to the next kitchen state (queued → preparing → ready → served).
|
* Advance one line to the next kitchen state (queued → preparing → ready → served).
|
||||||
*/
|
*/
|
||||||
public function bump(Request $request, PosSaleLine $line): JsonResponse
|
public function bump(Request $request, PosSaleLine $line): JsonResponse
|
||||||
{
|
{
|
||||||
$owner = $this->ownerRef($request);
|
return response()->json($this->board->bumpLine($line, $this->ownerRef($request)));
|
||||||
abort_unless($line->sale && $line->sale->owner_ref === $owner, 404);
|
|
||||||
|
|
||||||
$flow = PosSaleLine::KITCHEN_FLOW;
|
|
||||||
$idx = array_search($line->kitchen_state, $flow, true);
|
|
||||||
$next = $idx === false ? PosSaleLine::KITCHEN_PREPARING : ($flow[$idx + 1] ?? PosSaleLine::KITCHEN_SERVED);
|
|
||||||
|
|
||||||
$line->forceFill(['kitchen_state' => $next])->save();
|
|
||||||
|
|
||||||
// When every fired line is served, the ticket leaves the kitchen board.
|
|
||||||
$sale = $line->sale;
|
|
||||||
$remaining = $sale->lines()
|
|
||||||
->whereIn('kitchen_state', [PosSaleLine::KITCHEN_QUEUED, PosSaleLine::KITCHEN_PREPARING, PosSaleLine::KITCHEN_READY])
|
|
||||||
->count();
|
|
||||||
if ($remaining === 0) {
|
|
||||||
$sale->forceFill(['kitchen_status' => PosSale::KITCHEN_SERVED])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(['state' => $next]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Public;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PosDevice;
|
||||||
|
use App\Models\PosSaleLine;
|
||||||
|
use App\Services\Pos\DeviceService;
|
||||||
|
use App\Services\Pos\KitchenBoardService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token-gated kitchen display for unattended tablets (no staff login).
|
||||||
|
*/
|
||||||
|
class KitchenDisplayController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DeviceService $devices,
|
||||||
|
private KitchenBoardService $board,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(string $token): View
|
||||||
|
{
|
||||||
|
$device = $this->findKitchenDevice($token);
|
||||||
|
$this->devices->recordHeartbeat($device);
|
||||||
|
|
||||||
|
return view('pos.kitchen.public', [
|
||||||
|
'device' => $device,
|
||||||
|
'locationName' => $device->location?->name ?? 'Kitchen',
|
||||||
|
'stations' => $this->board->stations($device->owner_ref),
|
||||||
|
'feedUrl' => route('pos.kitchen-display.feed', ['token' => $token]),
|
||||||
|
'streamUrl' => route('pos.kitchen-display.stream', ['token' => $token]),
|
||||||
|
'bumpUrl' => route('pos.kitchen-display.bump', ['token' => $token, 'line' => '__ID__']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function feed(string $token): JsonResponse
|
||||||
|
{
|
||||||
|
$device = $this->findKitchenDevice($token);
|
||||||
|
$this->devices->recordHeartbeat($device);
|
||||||
|
|
||||||
|
return response()->json($this->board->payload($device->owner_ref));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream(Request $request, string $token): StreamedResponse
|
||||||
|
{
|
||||||
|
$device = $this->findKitchenDevice($token);
|
||||||
|
$this->devices->recordHeartbeat($device);
|
||||||
|
$owner = $device->owner_ref;
|
||||||
|
$deviceId = $device->id;
|
||||||
|
$request->session()->save();
|
||||||
|
|
||||||
|
$response = new StreamedResponse(function () use ($owner, $deviceId) {
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
@ob_end_flush();
|
||||||
|
}
|
||||||
|
@set_time_limit(0);
|
||||||
|
|
||||||
|
$start = time();
|
||||||
|
$lastHash = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if ($deviceId) {
|
||||||
|
$live = PosDevice::query()->find($deviceId);
|
||||||
|
if ($live) {
|
||||||
|
app(DeviceService::class)->recordHeartbeat($live);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->board->payload($owner);
|
||||||
|
$json = json_encode($payload);
|
||||||
|
$hash = md5((string) $json);
|
||||||
|
|
||||||
|
if ($hash !== $lastHash) {
|
||||||
|
echo 'data: '.$json."\n\n";
|
||||||
|
$lastHash = $hash;
|
||||||
|
} else {
|
||||||
|
echo ": ping\n\n";
|
||||||
|
}
|
||||||
|
@ob_flush();
|
||||||
|
@flush();
|
||||||
|
|
||||||
|
if (connection_aborted() || (time() - $start) >= 25) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$response->headers->set('Content-Type', 'text/event-stream');
|
||||||
|
$response->headers->set('Cache-Control', 'no-cache');
|
||||||
|
$response->headers->set('Connection', 'keep-alive');
|
||||||
|
$response->headers->set('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bump(string $token, PosSaleLine $line): JsonResponse
|
||||||
|
{
|
||||||
|
$device = $this->findKitchenDevice($token);
|
||||||
|
$this->devices->recordHeartbeat($device);
|
||||||
|
|
||||||
|
return response()->json($this->board->bumpLine($line, $device->owner_ref));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findKitchenDevice(string $token): PosDevice
|
||||||
|
{
|
||||||
|
$device = $this->devices->findByToken($token);
|
||||||
|
abort_unless($device && $device->type === 'kitchen_display', 404);
|
||||||
|
|
||||||
|
return $device->loadMissing('location');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,7 +122,7 @@ class DeviceService
|
|||||||
|
|
||||||
return match ($device->type) {
|
return match ($device->type) {
|
||||||
'customer_display' => route('pos.customer-display.show', ['token' => $device->device_token]),
|
'customer_display' => route('pos.customer-display.show', ['token' => $device->device_token]),
|
||||||
'kitchen_display' => route('pos.kitchen'),
|
'kitchen_display' => route('pos.kitchen-display.show', ['token' => $device->device_token]),
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Pos;
|
||||||
|
|
||||||
|
use App\Models\PosSale;
|
||||||
|
use App\Models\PosSaleLine;
|
||||||
|
use App\Models\PosStation;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class KitchenBoardService
|
||||||
|
{
|
||||||
|
/** @return array{tickets: Collection, server_time: string} */
|
||||||
|
public function payload(string $ownerRef): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tickets' => $this->buildTickets($ownerRef),
|
||||||
|
'server_time' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stations(string $ownerRef): Collection
|
||||||
|
{
|
||||||
|
return PosStation::owned($ownerRef)->orderBy('name')->get(['id', 'name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTickets(string $owner): Collection
|
||||||
|
{
|
||||||
|
// Payment-agnostic: dine-in tabs (pending) and paid online orders both
|
||||||
|
// sit on the board while the kitchen is still working them.
|
||||||
|
$sales = PosSale::owned($owner)
|
||||||
|
->where('kitchen_status', PosSale::KITCHEN_ACTIVE)
|
||||||
|
->with(['table', 'lines' => fn ($q) => $q->orderBy('position')->with('modifiers', 'station')])
|
||||||
|
->orderBy('kitchen_sent_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $sales->map(function (PosSale $sale) {
|
||||||
|
$lines = $sale->lines
|
||||||
|
->filter(fn (PosSaleLine $l) => in_array($l->kitchen_state, [
|
||||||
|
PosSaleLine::KITCHEN_QUEUED,
|
||||||
|
PosSaleLine::KITCHEN_PREPARING,
|
||||||
|
PosSaleLine::KITCHEN_READY,
|
||||||
|
], true))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($lines->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $sale->id,
|
||||||
|
'reference' => $sale->reference,
|
||||||
|
'order_type' => $sale->order_type,
|
||||||
|
'table' => $sale->table?->label,
|
||||||
|
'sent_at' => optional($sale->kitchen_sent_at)->toIso8601String(),
|
||||||
|
'lines' => $lines->map(fn (PosSaleLine $l) => [
|
||||||
|
'id' => $l->id,
|
||||||
|
'name' => $l->name,
|
||||||
|
'quantity' => $l->quantity,
|
||||||
|
'notes' => $l->notes,
|
||||||
|
'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(),
|
||||||
|
];
|
||||||
|
})->filter()->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance one line to the next kitchen state (queued → preparing → ready → served).
|
||||||
|
*
|
||||||
|
* @return array{state: string}
|
||||||
|
*/
|
||||||
|
public function bumpLine(PosSaleLine $line, string $ownerRef): array
|
||||||
|
{
|
||||||
|
abort_unless($line->sale && $line->sale->owner_ref === $ownerRef, 404);
|
||||||
|
|
||||||
|
$flow = PosSaleLine::KITCHEN_FLOW;
|
||||||
|
$idx = array_search($line->kitchen_state, $flow, true);
|
||||||
|
$next = $idx === false ? PosSaleLine::KITCHEN_PREPARING : ($flow[$idx + 1] ?? PosSaleLine::KITCHEN_SERVED);
|
||||||
|
|
||||||
|
$line->forceFill(['kitchen_state' => $next])->save();
|
||||||
|
|
||||||
|
$sale = $line->sale;
|
||||||
|
$remaining = $sale->lines()
|
||||||
|
->whereIn('kitchen_state', [PosSaleLine::KITCHEN_QUEUED, PosSaleLine::KITCHEN_PREPARING, PosSaleLine::KITCHEN_READY])
|
||||||
|
->count();
|
||||||
|
if ($remaining === 0) {
|
||||||
|
$sale->forceFill(['kitchen_status' => PosSale::KITCHEN_SERVED])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['state' => $next];
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -74,7 +74,7 @@ return [
|
|||||||
'device_registration_hints' => [
|
'device_registration_hints' => [
|
||||||
'register' => 'Tracked for your team. Staff sign in on this machine — no device token is issued.',
|
'register' => 'Tracked for your team. Staff sign in on this machine — no device token is issued.',
|
||||||
'customer_display' => 'Creates (or reuses) the branch customer-display token. Open the URL on a second screen facing the customer.',
|
'customer_display' => 'Creates (or reuses) the branch customer-display token. Open the URL on a second screen facing the customer.',
|
||||||
'kitchen_display' => 'Token for kitchen screen provisioning. Kitchen currently opens the signed-in Kitchen page for this account.',
|
'kitchen_display' => 'Creates a device token. Open the kitchen URL on a wall tablet — no staff login required. Bump items from the board.',
|
||||||
'receipt_printer' => 'Token for a future print agent. Paper size still comes from branch receipt settings.',
|
'receipt_printer' => 'Token for a future print agent. Paper size still comes from branch receipt settings.',
|
||||||
'barcode_scanner' => 'Tracked for inventory. USB scanners work as keyboard wedges on the register today.',
|
'barcode_scanner' => 'Tracked for inventory. USB scanners work as keyboard wedges on the register today.',
|
||||||
'tablet' => 'Tracked for inventory. Use Customer display or Kitchen display if the tablet should run unattended.',
|
'tablet' => 'Tracked for inventory. Use Customer display or Kitchen display if the tablet should run unattended.',
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
<title>Kitchen · {{ $locationName }}</title>
|
||||||
|
@include('partials.favicon')
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
|
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
|
||||||
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
<style>
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-full bg-slate-100 font-sans text-slate-900 antialiased">
|
||||||
|
<div class="fixed inset-x-0 top-0 z-20 h-1.5 bg-gradient-to-r from-amber-500 to-orange-600"></div>
|
||||||
|
|
||||||
|
<div x-data="posKitchen(@js([
|
||||||
|
'feedUrl' => $feedUrl,
|
||||||
|
'streamUrl' => $streamUrl,
|
||||||
|
'bumpUrl' => $bumpUrl,
|
||||||
|
'csrf' => csrf_token(),
|
||||||
|
]))"
|
||||||
|
class="mx-auto min-h-dvh max-w-[1600px] space-y-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-amber-700">Kitchen display</p>
|
||||||
|
<h1 class="mt-0.5 text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl">{{ $locationName }}</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Live tickets — tap an item to advance it. No sign-in required on this screen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="rounded-full bg-white px-3 py-1 text-xs font-medium text-slate-500 ring-1 ring-slate-200"
|
||||||
|
x-text="loaded ? 'Live' : 'Connecting…'"></span>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($stations->isNotEmpty())
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<button type="button" @click="activeStation = ''" :class="activeStation === '' ? 'bg-amber-600 text-white' : 'bg-white text-slate-600 ring-1 ring-slate-200'"
|
||||||
|
class="rounded-full px-3 py-1 text-xs font-medium">All stations</button>
|
||||||
|
@foreach($stations as $station)
|
||||||
|
<button type="button" @click="activeStation = '{{ $station->id }}'"
|
||||||
|
:class="activeStation === '{{ $station->id }}' ? 'bg-amber-600 text-white' : 'bg-white text-slate-600 ring-1 ring-slate-200'"
|
||||||
|
class="rounded-full px-3 py-1 text-xs font-medium">{{ $station->name }}</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<template x-if="loaded && visibleTickets.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 visibleTickets" :key="t.id">
|
||||||
|
<div class="flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<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.modifiers.length" x-cloak class="text-xs text-slate-500" x-text="line.modifiers.join(', ')"></p>
|
||||||
|
<p x-show="line.notes" x-cloak class="text-xs text-rose-600" x-text="line.notes"></p>
|
||||||
|
<div class="mt-0.5 flex items-center gap-1.5">
|
||||||
|
<span x-show="line.course" x-cloak class="rounded bg-slate-100 px-1.5 text-[10px] font-medium text-slate-500" x-text="line.course"></span>
|
||||||
|
<span x-show="line.station" x-cloak class="rounded bg-indigo-50 px-1.5 text-[10px] font-medium text-indigo-600" x-text="line.station"></span>
|
||||||
|
<span x-show="line.source === 'guest'" x-cloak class="rounded bg-violet-50 px-1.5 text-[10px] font-medium text-violet-700">guest</span>
|
||||||
|
<span x-show="line.source === 'online'" x-cloak class="rounded bg-blue-50 px-1.5 text-[10px] font-medium text-blue-700">online</span>
|
||||||
|
<span class="text-[11px] uppercase tracking-wide text-slate-400" x-text="line.state"></span>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -19,6 +19,7 @@ use App\Http\Controllers\Pos\TableController;
|
|||||||
use App\Http\Controllers\Pos\TicketController;
|
use App\Http\Controllers\Pos\TicketController;
|
||||||
use App\Http\Controllers\NotificationController;
|
use App\Http\Controllers\NotificationController;
|
||||||
use App\Http\Controllers\Public\CustomerDisplayController as PublicCustomerDisplayController;
|
use App\Http\Controllers\Public\CustomerDisplayController as PublicCustomerDisplayController;
|
||||||
|
use App\Http\Controllers\Public\KitchenDisplayController as PublicKitchenDisplayController;
|
||||||
use App\Http\Controllers\Public\TableOrderController;
|
use App\Http\Controllers\Public\TableOrderController;
|
||||||
use App\Http\Controllers\WalletBalanceController;
|
use App\Http\Controllers\WalletBalanceController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -61,6 +62,23 @@ Route::post('/customer-display/{token}/action', [PublicCustomerDisplayController
|
|||||||
->where('token', '[A-Za-z0-9]{32,64}')
|
->where('token', '[A-Za-z0-9]{32,64}')
|
||||||
->name('pos.customer-display.action');
|
->name('pos.customer-display.action');
|
||||||
|
|
||||||
|
// Token-gated kitchen display (unattended tablet; no operator chrome).
|
||||||
|
Route::get('/kitchen-display/{token}', [PublicKitchenDisplayController::class, 'show'])
|
||||||
|
->where('token', '[A-Za-z0-9]{32,64}')
|
||||||
|
->name('pos.kitchen-display.show');
|
||||||
|
Route::get('/kitchen-display/{token}/feed', [PublicKitchenDisplayController::class, 'feed'])
|
||||||
|
->middleware('throttle:120,1')
|
||||||
|
->where('token', '[A-Za-z0-9]{32,64}')
|
||||||
|
->name('pos.kitchen-display.feed');
|
||||||
|
Route::get('/kitchen-display/{token}/stream', [PublicKitchenDisplayController::class, 'stream'])
|
||||||
|
->middleware('throttle:60,1')
|
||||||
|
->where('token', '[A-Za-z0-9]{32,64}')
|
||||||
|
->name('pos.kitchen-display.stream');
|
||||||
|
Route::post('/kitchen-display/{token}/lines/{line}/bump', [PublicKitchenDisplayController::class, 'bump'])
|
||||||
|
->middleware('throttle:120,1')
|
||||||
|
->where('token', '[A-Za-z0-9]{32,64}')
|
||||||
|
->name('pos.kitchen-display.bump');
|
||||||
|
|
||||||
Route::middleware(['auth', 'platform.session'])->group(function () {
|
Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||||
Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance');
|
Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance');
|
||||||
|
|
||||||
|
|||||||
@@ -154,4 +154,89 @@ class PosDeviceTest extends TestCase
|
|||||||
$this->assertSame('online', $device->status);
|
$this->assertSame('online', $device->status);
|
||||||
$this->assertNotNull($device->last_online_at);
|
$this->assertNotNull($device->last_online_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_kitchen_display_device_opens_public_url_without_auth(): void
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$location = $this->location($user);
|
||||||
|
$device = app(DeviceService::class)->register([
|
||||||
|
'owner_ref' => $user->public_id,
|
||||||
|
'name' => 'Pass tablet',
|
||||||
|
'type' => 'kitchen_display',
|
||||||
|
'location_id' => $location->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotEmpty($device->device_token);
|
||||||
|
|
||||||
|
$this->get(route('pos.kitchen-display.show', ['token' => $device->device_token]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Kitchen display')
|
||||||
|
->assertSee('Main register');
|
||||||
|
|
||||||
|
$this->getJson(route('pos.kitchen-display.feed', ['token' => $device->device_token]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure(['tickets', 'server_time']);
|
||||||
|
|
||||||
|
$device->refresh();
|
||||||
|
$this->assertSame('online', $device->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_kitchen_display_bump_advances_line_state(): void
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$location = $this->location($user);
|
||||||
|
$device = app(DeviceService::class)->register([
|
||||||
|
'owner_ref' => $user->public_id,
|
||||||
|
'name' => 'KDS',
|
||||||
|
'type' => 'kitchen_display',
|
||||||
|
'location_id' => $location->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sale = \App\Models\PosSale::create([
|
||||||
|
'owner_ref' => $user->public_id,
|
||||||
|
'location_id' => $location->id,
|
||||||
|
'reference' => 'POS-KDS-1',
|
||||||
|
'currency' => 'GHS',
|
||||||
|
'status' => 'pending',
|
||||||
|
'payment_method' => 'cash',
|
||||||
|
'order_type' => 'dine_in',
|
||||||
|
'kitchen_status' => \App\Models\PosSale::KITCHEN_ACTIVE,
|
||||||
|
'kitchen_sent_at' => now(),
|
||||||
|
'total_minor' => 1000,
|
||||||
|
'subtotal_minor' => 1000,
|
||||||
|
]);
|
||||||
|
$line = \App\Models\PosSaleLine::create([
|
||||||
|
'pos_sale_id' => $sale->id,
|
||||||
|
'name' => 'Jollof',
|
||||||
|
'unit_price_minor' => 1000,
|
||||||
|
'quantity' => 1,
|
||||||
|
'line_total_minor' => 1000,
|
||||||
|
'position' => 0,
|
||||||
|
'kitchen_state' => \App\Models\PosSaleLine::KITCHEN_QUEUED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson(route('pos.kitchen-display.bump', [
|
||||||
|
'token' => $device->device_token,
|
||||||
|
'line' => $line->id,
|
||||||
|
]))->assertOk()->assertJsonPath('state', 'preparing');
|
||||||
|
|
||||||
|
$this->assertSame('preparing', $line->fresh()->kitchen_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_device_service_open_url_for_kitchen_is_public(): void
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$location = $this->location($user);
|
||||||
|
$device = app(DeviceService::class)->register([
|
||||||
|
'owner_ref' => $user->public_id,
|
||||||
|
'name' => 'KDS',
|
||||||
|
'type' => 'kitchen_display',
|
||||||
|
'location_id' => $location->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$url = app(DeviceService::class)->openUrl($device);
|
||||||
|
$this->assertNotNull($url);
|
||||||
|
$this->assertStringContainsString('/kitchen-display/', $url);
|
||||||
|
$this->assertStringContainsString($device->device_token, $url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user