Add restaurant/café mode: floor, open tabs, and kitchen display (Phase 1)
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:
isaacclad
2026-06-24 08:38:18 +00:00
co-authored by Claude Opus 4.8
parent e9a0c92308
commit d9e4b6e06e
19 changed files with 1299 additions and 1 deletions
@@ -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();
}
}
+15
View File
@@ -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');
+44
View File
@@ -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');
+15
View File
@@ -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
+56
View File
@@ -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);
}
}
+13
View File
@@ -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);
});
}
}
+162
View File
@@ -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');
}
};
+99
View File
@@ -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)
+87
View File
@@ -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>
+46
View File
@@ -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>
+19
View File
@@ -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');
});
+131
View File
@@ -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());
}
}