diff --git a/app/Http/Controllers/Pos/KitchenController.php b/app/Http/Controllers/Pos/KitchenController.php new file mode 100644 index 0000000..48d7013 --- /dev/null +++ b/app/Http/Controllers/Pos/KitchenController.php @@ -0,0 +1,93 @@ +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]); + } +} diff --git a/app/Http/Controllers/Pos/SettingsController.php b/app/Http/Controllers/Pos/SettingsController.php index 3e4a8e9..70cb9c4 100644 --- a/app/Http/Controllers/Pos/SettingsController.php +++ b/app/Http/Controllers/Pos/SettingsController.php @@ -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 { diff --git a/app/Http/Controllers/Pos/TableController.php b/app/Http/Controllers/Pos/TableController.php new file mode 100644 index 0000000..8c69714 --- /dev/null +++ b/app/Http/Controllers/Pos/TableController.php @@ -0,0 +1,45 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Pos/TicketController.php b/app/Http/Controllers/Pos/TicketController.php new file mode 100644 index 0000000..eeb6029 --- /dev/null +++ b/app/Http/Controllers/Pos/TicketController.php @@ -0,0 +1,204 @@ +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> */ + 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(); + } +} diff --git a/app/Models/PosLocation.php b/app/Models/PosLocation.php index 9d5886f..833d5ad 100644 --- a/app/Models/PosLocation.php +++ b/app/Models/PosLocation.php @@ -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'); diff --git a/app/Models/PosSale.php b/app/Models/PosSale.php index d9e5618..c4261a6 100644 --- a/app/Models/PosSale.php +++ b/app/Models/PosSale.php @@ -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'); diff --git a/app/Models/PosSaleLine.php b/app/Models/PosSaleLine.php index 472c731..f8346b6 100644 --- a/app/Models/PosSaleLine.php +++ b/app/Models/PosSaleLine.php @@ -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 diff --git a/app/Models/PosTable.php b/app/Models/PosTable.php new file mode 100644 index 0000000..ba13aa2 --- /dev/null +++ b/app/Models/PosTable.php @@ -0,0 +1,56 @@ + '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); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 15ebe6a..9b6ed26 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); + }); } } diff --git a/app/Services/Pos/PosSaleService.php b/app/Services/Pos/PosSaleService.php index cfd8e2a..2d35fe2 100644 --- a/app/Services/Pos/PosSaleService.php +++ b/app/Services/Pos/PosSaleService.php @@ -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); diff --git a/database/migrations/2026_06_27_000000_add_pos_restaurant_mode.php b/database/migrations/2026_06_27_000000_add_pos_restaurant_mode.php new file mode 100644 index 0000000..ae18517 --- /dev/null +++ b/database/migrations/2026_06_27_000000_add_pos_restaurant_mode.php @@ -0,0 +1,66 @@ +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'); + } +}; diff --git a/resources/js/app.js b/resources/js/app.js index 90dfca5..8d594c1 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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); diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index e2ab3b9..605d7fe 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -15,6 +15,16 @@ ['name' => 'Sales', 'route' => route('pos.sales.index'), 'active' => request()->routeIs('pos.sales.*'), 'icon' => ''], ]; + + // 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' => ''], + ['name' => 'Kitchen', 'route' => route('pos.kitchen'), 'active' => request()->routeIs('pos.kitchen'), + 'icon' => ''], + ]); + } @endphp