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
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Pos;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||
use App\Models\PosSale;
|
||||
use App\Models\PosSaleLine;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class KitchenController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
return view('pos.kitchen.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Active kitchen tickets — open sales with fired-but-not-served lines.
|
||||
*/
|
||||
public function feed(Request $request): JsonResponse
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
$sales = PosSale::owned($owner)
|
||||
->openTickets()
|
||||
->where('kitchen_status', PosSale::KITCHEN_ACTIVE)
|
||||
->with(['table', 'lines' => fn ($q) => $q->orderBy('position')])
|
||||
->orderBy('kitchen_sent_at')
|
||||
->get();
|
||||
|
||||
$tickets = $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,
|
||||
'state' => $l->kitchen_state,
|
||||
])->all(),
|
||||
];
|
||||
})->filter()->values();
|
||||
|
||||
return response()->json(['tickets' => $tickets, 'server_time' => now()->toIso8601String()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance one line to the next kitchen state (queued → preparing → ready → served).
|
||||
*/
|
||||
public function bump(Request $request, PosSaleLine $line): JsonResponse
|
||||
{
|
||||
$owner = $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]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Pos;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||
use App\Models\PosLocation;
|
||||
use App\Models\PosTable;
|
||||
use App\Services\Import\CrmProductImportService;
|
||||
use App\Services\Import\MerchantCatalogImportService;
|
||||
use App\Services\Pos\PosLocationService;
|
||||
@@ -20,11 +22,13 @@ class SettingsController extends Controller
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$location = $this->locations->ensureDefault($this->ownerRef($request));
|
||||
$owner = $this->ownerRef($request);
|
||||
$location = $this->locations->ensureDefault($owner);
|
||||
|
||||
return view('pos.settings', [
|
||||
'location' => $location,
|
||||
'merchantImportEnabled' => (bool) config('pos.merchant_import_enabled', true),
|
||||
'tables' => PosTable::owned($owner)->orderBy('area')->orderBy('position')->orderBy('label')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -33,6 +37,7 @@ class SettingsController extends Controller
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'currency' => ['required', 'string', 'size:3'],
|
||||
'service_style' => ['required', 'in:retail,restaurant'],
|
||||
'receipt_footer' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
@@ -40,12 +45,50 @@ class SettingsController extends Controller
|
||||
$location->update([
|
||||
'name' => $data['name'],
|
||||
'currency' => strtoupper($data['currency']),
|
||||
'service_style' => $data['service_style'],
|
||||
'receipt_footer' => $data['receipt_footer'] ?? null,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Settings saved.');
|
||||
}
|
||||
|
||||
public function storeTable(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'area' => ['nullable', 'string', 'max:60'],
|
||||
'label' => ['required', 'string', 'max:60'],
|
||||
'seats' => ['nullable', 'integer', 'min:1', 'max:99'],
|
||||
]);
|
||||
|
||||
$owner = $this->ownerRef($request);
|
||||
$location = $this->locations->ensureDefault($owner);
|
||||
|
||||
PosTable::create([
|
||||
'owner_ref' => $owner,
|
||||
'location_id' => $location->id,
|
||||
'area' => $data['area'] ?? null,
|
||||
'label' => $data['label'],
|
||||
'seats' => $data['seats'] ?? 2,
|
||||
'status' => PosTable::STATUS_FREE,
|
||||
'position' => (int) PosTable::owned($owner)->max('position') + 1,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Table added.');
|
||||
}
|
||||
|
||||
public function destroyTable(Request $request, PosTable $table): RedirectResponse
|
||||
{
|
||||
$this->authorizeOwner($request, $table);
|
||||
|
||||
if (! $table->isFree()) {
|
||||
return back()->with('error', 'Settle the open ticket before removing this table.');
|
||||
}
|
||||
|
||||
$table->delete();
|
||||
|
||||
return back()->with('success', 'Table removed.');
|
||||
}
|
||||
|
||||
public function importCrm(Request $request, CrmProductImportService $import): RedirectResponse
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Pos;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||
use App\Models\PosSale;
|
||||
use App\Models\PosTable;
|
||||
use App\Services\Pos\PosLocationService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class TableController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function __construct(private PosLocationService $locations) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
$location = $this->locations->ensureDefault($owner);
|
||||
|
||||
$tables = PosTable::owned($owner)
|
||||
->with('currentSale')
|
||||
->orderBy('area')
|
||||
->orderBy('position')
|
||||
->orderBy('label')
|
||||
->get();
|
||||
|
||||
$openTickets = PosSale::owned($owner)
|
||||
->openTickets()
|
||||
->with('table')
|
||||
->withCount('lines')
|
||||
->latest('opened_at')
|
||||
->get();
|
||||
|
||||
return view('pos.floor', [
|
||||
'location' => $location,
|
||||
'tables' => $tables,
|
||||
'tablesByArea' => $tables->groupBy(fn (PosTable $t) => $t->area ?: 'Floor'),
|
||||
'openTickets' => $openTickets,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Pos;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||
use App\Models\PosProduct;
|
||||
use App\Models\PosSale;
|
||||
use App\Models\PosSaleLine;
|
||||
use App\Services\Pos\PosLocationService;
|
||||
use App\Services\Pos\PosSaleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
|
||||
class TicketController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function __construct(private PosSaleService $sales, private PosLocationService $locations) {}
|
||||
|
||||
public function open(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'order_type' => ['required', 'in:dine_in,takeaway,counter'],
|
||||
'table_id' => ['nullable', 'integer'],
|
||||
'covers' => ['nullable', 'integer', 'min:1', 'max:99'],
|
||||
]);
|
||||
|
||||
$merchant = ladill_account() ?? $request->user();
|
||||
$location = $this->locations->ensureDefault($this->ownerRef($request));
|
||||
|
||||
try {
|
||||
$sale = $this->sales->openTicket($merchant, [
|
||||
'order_type' => $data['order_type'],
|
||||
'table_id' => $data['table_id'] ?? null,
|
||||
'covers' => $data['covers'] ?? null,
|
||||
'location_id' => $location->id,
|
||||
'currency' => $location->currency,
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->route('pos.tickets.show', $sale);
|
||||
}
|
||||
|
||||
public function show(Request $request, PosSale $sale): View
|
||||
{
|
||||
$this->authorizeOwner($request, $sale);
|
||||
|
||||
if (! $sale->isOpen()) {
|
||||
return redirect()->route('pos.sales.show', $sale);
|
||||
}
|
||||
|
||||
$owner = $this->ownerRef($request);
|
||||
$sale->load('lines', 'table');
|
||||
|
||||
$products = PosProduct::owned($owner)->active()->orderBy('name')->get();
|
||||
|
||||
return view('pos.tickets.show', [
|
||||
'sale' => $sale,
|
||||
'products' => $products,
|
||||
'lines' => $this->lineData($sale),
|
||||
]);
|
||||
}
|
||||
|
||||
public function addLine(Request $request, PosSale $sale): JsonResponse
|
||||
{
|
||||
$this->authorizeOpen($request, $sale);
|
||||
|
||||
$data = $request->validate([
|
||||
'product_id' => ['nullable', 'integer'],
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'unit_price_minor' => ['required', 'integer', 'min:1'],
|
||||
'quantity' => ['nullable', 'integer', 'min:1', 'max:999'],
|
||||
'notes' => ['nullable', 'string', 'max:200'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->sales->addLine($sale, $data);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return $this->ticketJson($sale);
|
||||
}
|
||||
|
||||
public function updateLine(Request $request, PosSale $sale, PosSaleLine $line): JsonResponse
|
||||
{
|
||||
$this->authorizeOpen($request, $sale);
|
||||
abort_unless($line->pos_sale_id === $sale->id, 404);
|
||||
|
||||
$data = $request->validate([
|
||||
'quantity' => ['required', 'integer', 'min:0', 'max:999'],
|
||||
'notes' => ['nullable', 'string', 'max:200'],
|
||||
]);
|
||||
|
||||
$this->sales->updateLine($line, $data['quantity'], $data['notes'] ?? null);
|
||||
|
||||
return $this->ticketJson($sale);
|
||||
}
|
||||
|
||||
public function removeLine(Request $request, PosSale $sale, PosSaleLine $line): JsonResponse
|
||||
{
|
||||
$this->authorizeOpen($request, $sale);
|
||||
abort_unless($line->pos_sale_id === $sale->id, 404);
|
||||
|
||||
$this->sales->removeLine($line);
|
||||
|
||||
return $this->ticketJson($sale);
|
||||
}
|
||||
|
||||
public function sendToKitchen(Request $request, PosSale $sale): JsonResponse
|
||||
{
|
||||
$this->authorizeOpen($request, $sale);
|
||||
|
||||
$fired = $this->sales->sendToKitchen($sale);
|
||||
$sale->refresh();
|
||||
|
||||
return response()->json([
|
||||
'lines' => $this->lineData($sale),
|
||||
'subtotal_minor' => $sale->subtotal_minor,
|
||||
'total_minor' => $sale->total_minor,
|
||||
'kitchen_status' => $sale->kitchen_status,
|
||||
'fired' => $fired,
|
||||
]);
|
||||
}
|
||||
|
||||
public function settle(Request $request, PosSale $sale): RedirectResponse
|
||||
{
|
||||
$this->authorizeOpen($request, $sale);
|
||||
|
||||
$data = $request->validate([
|
||||
'payment_method' => ['required', 'in:pay,cash'],
|
||||
'customer_name' => ['nullable', 'string', 'max:120'],
|
||||
'customer_phone' => ['nullable', 'string', 'max:40'],
|
||||
]);
|
||||
|
||||
$sale->loadCount('lines');
|
||||
if ($sale->lines_count === 0) {
|
||||
return back()->with('error', 'Add at least one item before settling.');
|
||||
}
|
||||
|
||||
if (! empty($data['customer_name']) || ! empty($data['customer_phone'])) {
|
||||
$sale->forceFill([
|
||||
'customer_name' => $data['customer_name'] ?? $sale->customer_name,
|
||||
'customer_phone' => $data['customer_phone'] ?? $sale->customer_phone,
|
||||
])->save();
|
||||
}
|
||||
|
||||
$merchant = ladill_account() ?? $request->user();
|
||||
|
||||
try {
|
||||
if ($data['payment_method'] === 'cash') {
|
||||
$this->sales->recordCashPayment($sale);
|
||||
|
||||
return redirect()->route('pos.sales.show', $sale)->with('success', 'Ticket settled (cash).');
|
||||
}
|
||||
|
||||
$result = $this->sales->initiatePayCheckout($sale, $merchant);
|
||||
|
||||
return redirect()->away($result['checkout_url']);
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizeOpen(Request $request, PosSale $sale): void
|
||||
{
|
||||
$this->authorizeOwner($request, $sale);
|
||||
abort_unless($sale->isOpen(), 409, 'This ticket is already settled.');
|
||||
}
|
||||
|
||||
private function ticketJson(PosSale $sale): JsonResponse
|
||||
{
|
||||
$sale->refresh();
|
||||
|
||||
return response()->json([
|
||||
'lines' => $this->lineData($sale),
|
||||
'subtotal_minor' => $sale->subtotal_minor,
|
||||
'total_minor' => $sale->total_minor,
|
||||
'kitchen_status' => $sale->kitchen_status,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function lineData(PosSale $sale): array
|
||||
{
|
||||
return $sale->lines()->orderBy('position')->get()->map(fn (PosSaleLine $l) => [
|
||||
'id' => $l->id,
|
||||
'product_id' => $l->product_id,
|
||||
'name' => $l->name,
|
||||
'unit_price_minor' => $l->unit_price_minor,
|
||||
'quantity' => $l->quantity,
|
||||
'line_total_minor' => $l->line_total_minor,
|
||||
'kitchen_state' => $l->kitchen_state,
|
||||
'notes' => $l->notes,
|
||||
'fired' => $l->kitchen_state !== PosSaleLine::KITCHEN_NEW,
|
||||
])->all();
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,33 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class PosLocation extends Model
|
||||
{
|
||||
public const STYLE_RETAIL = 'retail';
|
||||
|
||||
public const STYLE_RESTAURANT = 'restaurant';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref',
|
||||
'name',
|
||||
'currency',
|
||||
'service_style',
|
||||
'receipt_footer',
|
||||
];
|
||||
|
||||
public function isRestaurant(): bool
|
||||
{
|
||||
return $this->service_style === self::STYLE_RESTAURANT;
|
||||
}
|
||||
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(PosProduct::class, 'location_id');
|
||||
}
|
||||
|
||||
public function tables(): HasMany
|
||||
{
|
||||
return $this->hasMany(PosTable::class, 'location_id');
|
||||
}
|
||||
|
||||
public function sales(): HasMany
|
||||
{
|
||||
return $this->hasMany(PosSale::class, 'location_id');
|
||||
|
||||
@@ -21,12 +21,29 @@ class PosSale extends Model
|
||||
|
||||
public const METHOD_CASH = 'cash';
|
||||
|
||||
public const ORDER_COUNTER = 'counter';
|
||||
|
||||
public const ORDER_DINE_IN = 'dine_in';
|
||||
|
||||
public const ORDER_TAKEAWAY = 'takeaway';
|
||||
|
||||
public const KITCHEN_NONE = 'none';
|
||||
|
||||
public const KITCHEN_ACTIVE = 'active';
|
||||
|
||||
public const KITCHEN_SERVED = 'served';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref',
|
||||
'location_id',
|
||||
'table_id',
|
||||
'reference',
|
||||
'status',
|
||||
'payment_method',
|
||||
'order_type',
|
||||
'kitchen_status',
|
||||
'covers',
|
||||
'notes',
|
||||
'pay_order_id',
|
||||
'payment_reference',
|
||||
'customer_name',
|
||||
@@ -37,6 +54,9 @@ class PosSale extends Model
|
||||
'total_minor',
|
||||
'currency',
|
||||
'paid_at',
|
||||
'opened_at',
|
||||
'kitchen_sent_at',
|
||||
'closed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -46,7 +66,11 @@ class PosSale extends Model
|
||||
'total_minor' => 'integer',
|
||||
'pay_order_id' => 'integer',
|
||||
'crm_customer_id' => 'integer',
|
||||
'covers' => 'integer',
|
||||
'paid_at' => 'datetime',
|
||||
'opened_at' => 'datetime',
|
||||
'kitchen_sent_at' => 'datetime',
|
||||
'closed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -55,6 +79,26 @@ class PosSale extends Model
|
||||
return $this->belongsTo(PosLocation::class, 'location_id');
|
||||
}
|
||||
|
||||
public function table(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PosTable::class, 'table_id');
|
||||
}
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isDineIn(): bool
|
||||
{
|
||||
return $this->order_type === self::ORDER_DINE_IN;
|
||||
}
|
||||
|
||||
public function scopeOpenTickets(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
public function lines(): HasMany
|
||||
{
|
||||
return $this->hasMany(PosSaleLine::class)->orderBy('position');
|
||||
|
||||
@@ -7,6 +7,19 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PosSaleLine extends Model
|
||||
{
|
||||
public const KITCHEN_NEW = 'new';
|
||||
|
||||
public const KITCHEN_QUEUED = 'queued';
|
||||
|
||||
public const KITCHEN_PREPARING = 'preparing';
|
||||
|
||||
public const KITCHEN_READY = 'ready';
|
||||
|
||||
public const KITCHEN_SERVED = 'served';
|
||||
|
||||
/** States the KDS bump button cycles through, in order. */
|
||||
public const KITCHEN_FLOW = [self::KITCHEN_QUEUED, self::KITCHEN_PREPARING, self::KITCHEN_READY, self::KITCHEN_SERVED];
|
||||
|
||||
protected $fillable = [
|
||||
'pos_sale_id',
|
||||
'product_id',
|
||||
@@ -15,6 +28,8 @@ class PosSaleLine extends Model
|
||||
'quantity',
|
||||
'line_total_minor',
|
||||
'position',
|
||||
'kitchen_state',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PosTable extends Model
|
||||
{
|
||||
public const STATUS_FREE = 'free';
|
||||
|
||||
public const STATUS_OCCUPIED = 'occupied';
|
||||
|
||||
public const STATUS_BILL_REQUESTED = 'bill_requested';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref',
|
||||
'location_id',
|
||||
'area',
|
||||
'label',
|
||||
'seats',
|
||||
'status',
|
||||
'current_sale_id',
|
||||
'position',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'seats' => 'integer',
|
||||
'current_sale_id' => 'integer',
|
||||
'position' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function location(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PosLocation::class, 'location_id');
|
||||
}
|
||||
|
||||
public function currentSale(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PosSale::class, 'current_sale_id');
|
||||
}
|
||||
|
||||
public function isFree(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FREE;
|
||||
}
|
||||
|
||||
public function scopeOwned(Builder $query, string $ownerRef): Builder
|
||||
{
|
||||
return $query->where('owner_ref', $ownerRef);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\PosLocation;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Support\MobileTopbar;
|
||||
use Illuminate\Support\Facades\View;
|
||||
@@ -18,5 +19,17 @@ class AppServiceProvider extends ServiceProvider
|
||||
View::composer(['partials.topbar'], function ($view) {
|
||||
$view->with(MobileTopbar::resolve());
|
||||
});
|
||||
|
||||
// Expose whether the signed-in account runs in restaurant mode so the
|
||||
// sidebar can surface the Floor + Kitchen nav only when relevant.
|
||||
View::composer('partials.sidebar', function ($view) {
|
||||
$restaurant = false;
|
||||
if ($account = ladill_account()) {
|
||||
$restaurant = PosLocation::owned((string) $account->public_id)
|
||||
->where('service_style', PosLocation::STYLE_RESTAURANT)
|
||||
->exists();
|
||||
}
|
||||
$view->with('posRestaurant', $restaurant);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services\Pos;
|
||||
|
||||
use App\Models\PosSale;
|
||||
use App\Models\PosSaleLine;
|
||||
use App\Models\PosTable;
|
||||
use App\Models\User;
|
||||
use App\Services\Pay\PayClient;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -64,6 +65,163 @@ class PosSaleService
|
||||
return $sale->fresh('lines');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an empty restaurant ticket (a tab) that stays pending while items are
|
||||
* added. Occupies the table for dine-in orders.
|
||||
*
|
||||
* @param array{order_type?: string, table_id?: ?int, location_id?: ?int, currency?: string, covers?: ?int, customer_name?: ?string} $meta
|
||||
*/
|
||||
public function openTicket(User $merchant, array $meta = []): PosSale
|
||||
{
|
||||
$orderType = in_array($meta['order_type'] ?? '', [PosSale::ORDER_DINE_IN, PosSale::ORDER_TAKEAWAY, PosSale::ORDER_COUNTER], true)
|
||||
? $meta['order_type']
|
||||
: PosSale::ORDER_COUNTER;
|
||||
|
||||
$table = null;
|
||||
if ($orderType === PosSale::ORDER_DINE_IN && ! empty($meta['table_id'])) {
|
||||
$table = PosTable::owned($merchant->public_id)->find($meta['table_id']);
|
||||
if ($table && ! $table->isFree()) {
|
||||
throw new RuntimeException('That table already has an open ticket.');
|
||||
}
|
||||
}
|
||||
|
||||
$sale = PosSale::create([
|
||||
'owner_ref' => $merchant->public_id,
|
||||
'location_id' => $meta['location_id'] ?? null,
|
||||
'table_id' => $table?->id,
|
||||
'reference' => 'POS-'.strtoupper(Str::random(12)),
|
||||
'status' => PosSale::STATUS_PENDING,
|
||||
'payment_method' => PosSale::METHOD_PAY,
|
||||
'order_type' => $orderType,
|
||||
'kitchen_status' => PosSale::KITCHEN_NONE,
|
||||
'covers' => $meta['covers'] ?? null,
|
||||
'customer_name' => $meta['customer_name'] ?? null,
|
||||
'subtotal_minor' => 0,
|
||||
'total_minor' => 0,
|
||||
'currency' => strtoupper((string) ($meta['currency'] ?? config('pos.default_currency', 'GHS'))),
|
||||
'opened_at' => now(),
|
||||
]);
|
||||
|
||||
if ($table) {
|
||||
$table->forceFill([
|
||||
'status' => PosTable::STATUS_OCCUPIED,
|
||||
'current_sale_id' => $sale->id,
|
||||
])->save();
|
||||
}
|
||||
|
||||
return $sale->fresh('lines');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{product_id?: ?int, name: string, unit_price_minor: int, quantity?: int, notes?: ?string} $line
|
||||
*/
|
||||
public function addLine(PosSale $sale, array $line): PosSaleLine
|
||||
{
|
||||
$name = trim((string) ($line['name'] ?? ''));
|
||||
$unit = max(0, (int) ($line['unit_price_minor'] ?? 0));
|
||||
$qty = max(1, (int) ($line['quantity'] ?? 1));
|
||||
|
||||
if ($name === '' || $unit <= 0) {
|
||||
throw new RuntimeException('Choose an item with a price.');
|
||||
}
|
||||
|
||||
$position = (int) $sale->lines()->max('position') + 1;
|
||||
|
||||
$created = PosSaleLine::create([
|
||||
'pos_sale_id' => $sale->id,
|
||||
'product_id' => $line['product_id'] ?? null,
|
||||
'name' => $name,
|
||||
'unit_price_minor' => $unit,
|
||||
'quantity' => $qty,
|
||||
'line_total_minor' => $unit * $qty,
|
||||
'position' => $position,
|
||||
'kitchen_state' => PosSaleLine::KITCHEN_NEW,
|
||||
'notes' => isset($line['notes']) ? trim((string) $line['notes']) ?: null : null,
|
||||
]);
|
||||
|
||||
$this->recomputeTotals($sale);
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
public function updateLine(PosSaleLine $line, int $quantity, ?string $notes = null): void
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
$this->removeLine($line);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$line->forceFill([
|
||||
'quantity' => $quantity,
|
||||
'line_total_minor' => $line->unit_price_minor * $quantity,
|
||||
'notes' => $notes !== null ? (trim($notes) ?: null) : $line->notes,
|
||||
])->save();
|
||||
|
||||
$this->recomputeTotals($line->sale);
|
||||
}
|
||||
|
||||
public function removeLine(PosSaleLine $line): void
|
||||
{
|
||||
$sale = $line->sale;
|
||||
$line->delete();
|
||||
$this->recomputeTotals($sale);
|
||||
}
|
||||
|
||||
public function recomputeTotals(PosSale $sale): PosSale
|
||||
{
|
||||
$subtotal = (int) $sale->lines()->sum('line_total_minor');
|
||||
|
||||
$sale->forceFill([
|
||||
'subtotal_minor' => $subtotal,
|
||||
'total_minor' => $subtotal,
|
||||
])->save();
|
||||
|
||||
return $sale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire all not-yet-sent lines to the kitchen.
|
||||
*
|
||||
* @return int number of lines fired
|
||||
*/
|
||||
public function sendToKitchen(PosSale $sale): int
|
||||
{
|
||||
$fired = $sale->lines()
|
||||
->where('kitchen_state', PosSaleLine::KITCHEN_NEW)
|
||||
->update(['kitchen_state' => PosSaleLine::KITCHEN_QUEUED]);
|
||||
|
||||
if ($fired > 0) {
|
||||
$sale->forceFill([
|
||||
'kitchen_status' => PosSale::KITCHEN_ACTIVE,
|
||||
'kitchen_sent_at' => $sale->kitchen_sent_at ?? now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
return $fired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a ticket once it's settled: free its table and mark kitchen done.
|
||||
*/
|
||||
public function closeTicket(PosSale $sale): void
|
||||
{
|
||||
$sale->forceFill([
|
||||
'kitchen_status' => $sale->kitchen_status === PosSale::KITCHEN_NONE ? PosSale::KITCHEN_NONE : PosSale::KITCHEN_SERVED,
|
||||
'closed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$sale->lines()
|
||||
->whereNotIn('kitchen_state', [PosSaleLine::KITCHEN_NEW, PosSaleLine::KITCHEN_SERVED])
|
||||
->update(['kitchen_state' => PosSaleLine::KITCHEN_SERVED]);
|
||||
|
||||
if ($sale->table_id) {
|
||||
PosTable::where('id', $sale->table_id)
|
||||
->where('current_sale_id', $sale->id)
|
||||
->update(['status' => PosTable::STATUS_FREE, 'current_sale_id' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sale: PosSale, checkout_url: string}
|
||||
*/
|
||||
@@ -125,6 +283,8 @@ class PosSaleService
|
||||
'paid_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->closeTicket($sale);
|
||||
|
||||
$sale = $sale->fresh('lines');
|
||||
$this->timeline->pushPaidSale($sale);
|
||||
|
||||
@@ -147,6 +307,8 @@ class PosSaleService
|
||||
'paid_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->closeTicket($sale);
|
||||
|
||||
$sale = $sale->fresh('lines');
|
||||
$this->timeline->pushPaidSale($sale);
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Floor plan tables for dine-in service. current_sale_id is a plain
|
||||
// column (not an FK) to avoid a circular constraint with pos_sales.
|
||||
Schema::create('pos_tables', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('owner_ref')->index();
|
||||
$table->foreignId('location_id')->nullable()->constrained('pos_locations')->nullOnDelete();
|
||||
$table->string('area')->nullable();
|
||||
$table->string('label');
|
||||
$table->unsignedSmallInteger('seats')->default(2);
|
||||
$table->string('status')->default('free'); // free | occupied | bill_requested
|
||||
$table->unsignedBigInteger('current_sale_id')->nullable();
|
||||
$table->unsignedInteger('position')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['owner_ref', 'status']);
|
||||
});
|
||||
|
||||
Schema::table('pos_locations', function (Blueprint $table) {
|
||||
$table->string('service_style')->default('retail')->after('currency'); // retail | restaurant
|
||||
});
|
||||
|
||||
Schema::table('pos_sales', function (Blueprint $table) {
|
||||
$table->string('order_type')->default('counter')->after('payment_method'); // counter | dine_in | takeaway
|
||||
// Plain column (no ALTER-time FK — SQLite can't add one); the table is freed in app logic.
|
||||
$table->unsignedBigInteger('table_id')->nullable()->after('location_id')->index();
|
||||
$table->string('kitchen_status')->default('none')->after('order_type'); // none | active | served
|
||||
$table->unsignedSmallInteger('covers')->nullable()->after('kitchen_status');
|
||||
$table->text('notes')->nullable()->after('covers');
|
||||
$table->timestamp('opened_at')->nullable()->after('paid_at');
|
||||
$table->timestamp('kitchen_sent_at')->nullable()->after('opened_at');
|
||||
$table->timestamp('closed_at')->nullable()->after('kitchen_sent_at');
|
||||
});
|
||||
|
||||
Schema::table('pos_sale_lines', function (Blueprint $table) {
|
||||
$table->string('kitchen_state')->default('new')->after('position'); // new | queued | preparing | ready | served
|
||||
$table->string('notes')->nullable()->after('kitchen_state');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('pos_sale_lines', function (Blueprint $table) {
|
||||
$table->dropColumn(['kitchen_state', 'notes']);
|
||||
});
|
||||
|
||||
Schema::table('pos_sales', function (Blueprint $table) {
|
||||
$table->dropColumn(['order_type', 'table_id', 'kitchen_status', 'covers', 'notes', 'opened_at', 'kitchen_sent_at', 'closed_at']);
|
||||
});
|
||||
|
||||
Schema::table('pos_locations', function (Blueprint $table) {
|
||||
$table->dropColumn('service_style');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('pos_tables');
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -3,10 +3,13 @@
|
||||
use App\Http\Controllers\Auth\SsoLoginController;
|
||||
use App\Http\Controllers\Pos\AiController;
|
||||
use App\Http\Controllers\Pos\DashboardController;
|
||||
use App\Http\Controllers\Pos\KitchenController;
|
||||
use App\Http\Controllers\Pos\ProductController;
|
||||
use App\Http\Controllers\Pos\RegisterController;
|
||||
use App\Http\Controllers\Pos\SaleController;
|
||||
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\WalletBalanceController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -44,6 +47,20 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
Route::get('/sales', [SaleController::class, 'index'])->name('pos.sales.index');
|
||||
Route::get('/sales/{sale}', [SaleController::class, 'show'])->name('pos.sales.show');
|
||||
|
||||
// Restaurant mode — floor, open tickets (tabs), and the kitchen display.
|
||||
Route::get('/floor', [TableController::class, 'index'])->name('pos.floor');
|
||||
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');
|
||||
Route::patch('/tickets/{sale}/lines/{line}', [TicketController::class, 'updateLine'])->name('pos.tickets.lines.update');
|
||||
Route::delete('/tickets/{sale}/lines/{line}', [TicketController::class, 'removeLine'])->name('pos.tickets.lines.remove');
|
||||
Route::post('/tickets/{sale}/send', [TicketController::class, 'sendToKitchen'])->name('pos.tickets.send');
|
||||
Route::post('/tickets/{sale}/settle', [TicketController::class, 'settle'])->name('pos.tickets.settle');
|
||||
|
||||
Route::get('/kitchen', [KitchenController::class, 'index'])->name('pos.kitchen');
|
||||
Route::get('/kitchen/feed', [KitchenController::class, 'feed'])->name('pos.kitchen.feed');
|
||||
Route::post('/kitchen/lines/{line}/bump', [KitchenController::class, 'bump'])->name('pos.kitchen.bump');
|
||||
|
||||
Route::get('/products', [ProductController::class, 'index'])->name('pos.products.index');
|
||||
Route::get('/products/create', [ProductController::class, 'create'])->name('pos.products.create');
|
||||
Route::post('/products', [ProductController::class, 'store'])->name('pos.products.store');
|
||||
@@ -55,6 +72,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
Route::put('/settings', [SettingsController::class, 'update'])->name('pos.settings.update');
|
||||
Route::post('/settings/import-crm', [SettingsController::class, 'importCrm'])->name('pos.settings.import-crm');
|
||||
Route::post('/settings/import-merchant', [SettingsController::class, 'importMerchant'])->name('pos.settings.import-merchant');
|
||||
Route::post('/settings/tables', [SettingsController::class, 'storeTable'])->name('pos.settings.tables.store');
|
||||
Route::delete('/settings/tables/{table}', [SettingsController::class, 'destroyTable'])->name('pos.settings.tables.destroy');
|
||||
|
||||
Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('pos.wallet');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Models\PosLocation;
|
||||
use App\Models\PosProduct;
|
||||
use App\Models\PosSale;
|
||||
use App\Models\PosSaleLine;
|
||||
use App\Models\PosTable;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PosRestaurantTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||
$this->withoutVite();
|
||||
}
|
||||
|
||||
private function user(): User
|
||||
{
|
||||
return User::create([
|
||||
'public_id' => 'u-'.uniqid(),
|
||||
'name' => 'Server',
|
||||
'email' => uniqid().'@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
private function restaurant(User $user): array
|
||||
{
|
||||
$location = PosLocation::create([
|
||||
'owner_ref' => $user->public_id,
|
||||
'name' => 'Café',
|
||||
'currency' => 'GHS',
|
||||
'service_style' => PosLocation::STYLE_RESTAURANT,
|
||||
]);
|
||||
$table = PosTable::create([
|
||||
'owner_ref' => $user->public_id,
|
||||
'location_id' => $location->id,
|
||||
'label' => 'T1',
|
||||
'seats' => 4,
|
||||
'status' => PosTable::STATUS_FREE,
|
||||
]);
|
||||
$product = PosProduct::create([
|
||||
'owner_ref' => $user->public_id,
|
||||
'name' => 'Latte',
|
||||
'price_minor' => 2500,
|
||||
'currency' => 'GHS',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return [$location, $table, $product];
|
||||
}
|
||||
|
||||
public function test_dine_in_ticket_lifecycle(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
[, $table, $product] = $this->restaurant($user);
|
||||
|
||||
// Open a dine-in tab on the table.
|
||||
$this->actingAs($user)->post(route('pos.tickets.open'), [
|
||||
'order_type' => 'dine_in',
|
||||
'table_id' => $table->id,
|
||||
])->assertRedirect();
|
||||
|
||||
$sale = PosSale::where('owner_ref', $user->public_id)->firstOrFail();
|
||||
$this->assertSame(PosSale::ORDER_DINE_IN, $sale->order_type);
|
||||
$this->assertSame(PosSale::STATUS_PENDING, $sale->status);
|
||||
$this->assertTrue($sale->table_id === $table->id);
|
||||
$this->assertSame(PosTable::STATUS_OCCUPIED, $table->fresh()->status);
|
||||
|
||||
// Add an item to the tab.
|
||||
$this->actingAs($user)->postJson(route('pos.tickets.lines.add', $sale), [
|
||||
'product_id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'unit_price_minor' => $product->price_minor,
|
||||
'quantity' => 2,
|
||||
])->assertOk()->assertJsonPath('total_minor', 5000);
|
||||
|
||||
// Fire it to the kitchen.
|
||||
$this->actingAs($user)->postJson(route('pos.tickets.send', $sale))
|
||||
->assertOk()->assertJsonPath('fired', 1);
|
||||
|
||||
$line = $sale->lines()->firstOrFail();
|
||||
$this->assertSame(PosSaleLine::KITCHEN_QUEUED, $line->fresh()->kitchen_state);
|
||||
$this->assertSame(PosSale::KITCHEN_ACTIVE, $sale->fresh()->kitchen_status);
|
||||
|
||||
// Kitchen feed shows the active ticket.
|
||||
$this->actingAs($user)->getJson(route('pos.kitchen.feed'))
|
||||
->assertOk()->assertJsonCount(1, 'tickets');
|
||||
|
||||
// Bump the line through to served.
|
||||
foreach (['preparing', 'ready', 'served'] as $expected) {
|
||||
$this->actingAs($user)->postJson(route('pos.kitchen.bump', $line))
|
||||
->assertOk()->assertJsonPath('state', $expected);
|
||||
}
|
||||
$this->assertSame(PosSale::KITCHEN_SERVED, $sale->fresh()->kitchen_status);
|
||||
|
||||
// Settle in cash — ticket paid, table freed.
|
||||
$this->actingAs($user)->post(route('pos.tickets.settle', $sale), [
|
||||
'payment_method' => 'cash',
|
||||
])->assertRedirect(route('pos.sales.show', $sale));
|
||||
|
||||
$this->assertSame(PosSale::STATUS_PAID, $sale->fresh()->status);
|
||||
$this->assertNotNull($sale->fresh()->closed_at);
|
||||
$this->assertSame(PosTable::STATUS_FREE, $table->fresh()->status);
|
||||
$this->assertNull($table->fresh()->current_sale_id);
|
||||
}
|
||||
|
||||
public function test_cannot_open_two_tabs_on_one_table(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
[, $table] = $this->restaurant($user);
|
||||
|
||||
$this->actingAs($user)->post(route('pos.tickets.open'), [
|
||||
'order_type' => 'dine_in', 'table_id' => $table->id,
|
||||
])->assertRedirect();
|
||||
|
||||
$this->actingAs($user)->post(route('pos.tickets.open'), [
|
||||
'order_type' => 'dine_in', 'table_id' => $table->id,
|
||||
])->assertRedirect();
|
||||
|
||||
$this->assertSame(1, PosSale::where('owner_ref', $user->public_id)->count());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user