From 50b170b0afb3a92f78b1913c74c83543480aea29 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 24 Jun 2026 19:58:57 +0000 Subject: [PATCH] =?UTF-8?q?Restaurant=20mode=20Phase=202:=20menu=20depth?= =?UTF-8?q?=20=E2=80=94=20categories,=20stations,=20modifiers,=20courses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on Phase 1 with the menu structure restaurants need: - Menu setup page (restaurant mode): categories, kitchen stations, and modifier groups with options (price deltas). - Products gain category, kitchen station, default course, and attached modifier groups (managed on the product form). - Ordering: the ticket groups the catalog by category and, when a product has modifier groups, opens a picker (respects min/max select) for options + notes + course before adding. Effective price = base + modifier deltas (resolved server-side; client prices are never trusted). - Fire-by-course: send the whole tab or just one course to the kitchen. - KDS: filter by station; each item shows its station, course, modifiers and notes. Schema additive (pos_categories, pos_stations, pos_modifier_groups, pos_modifiers, product↔group pivot, pos_sale_line_modifiers; category/station/course columns on products and lines). PosRestaurantTest covers modifiers, course and station routing; suite green (11 passed). Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Pos/KitchenController.php | 11 +- app/Http/Controllers/Pos/MenuController.php | 113 ++++++++++++++++ .../Controllers/Pos/ProductController.php | 65 ++++++++- app/Http/Controllers/Pos/TicketController.php | 77 ++++++++++- app/Models/PosCategory.php | 22 +++ app/Models/PosModifier.php | 27 ++++ app/Models/PosModifierGroup.php | 33 +++++ app/Models/PosProduct.php | 19 +++ app/Models/PosSaleLine.php | 20 +++ app/Models/PosSaleLineModifier.php | 21 +++ app/Models/PosStation.php | 16 +++ app/Services/Pos/PosSaleService.php | 31 ++++- .../2026_06_28_000000_add_pos_menu_depth.php | 91 +++++++++++++ resources/js/app.js | 74 +++++++++- resources/views/partials/sidebar.blade.php | 2 + resources/views/pos/kitchen/index.blade.php | 23 +++- resources/views/pos/menu/index.blade.php | 128 ++++++++++++++++++ resources/views/pos/products/_form.blade.php | 54 ++++++++ resources/views/pos/tickets/show.blade.php | 96 +++++++++++-- routes/web.php | 12 ++ tests/Feature/PosRestaurantTest.php | 43 ++++++ 21 files changed, 940 insertions(+), 38 deletions(-) create mode 100644 app/Http/Controllers/Pos/MenuController.php create mode 100644 app/Models/PosCategory.php create mode 100644 app/Models/PosModifier.php create mode 100644 app/Models/PosModifierGroup.php create mode 100644 app/Models/PosSaleLineModifier.php create mode 100644 app/Models/PosStation.php create mode 100644 database/migrations/2026_06_28_000000_add_pos_menu_depth.php create mode 100644 resources/views/pos/menu/index.blade.php diff --git a/app/Http/Controllers/Pos/KitchenController.php b/app/Http/Controllers/Pos/KitchenController.php index 48d7013..8e5c2cf 100644 --- a/app/Http/Controllers/Pos/KitchenController.php +++ b/app/Http/Controllers/Pos/KitchenController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\Pos\Concerns\ScopesToAccount; use App\Models\PosSale; use App\Models\PosSaleLine; +use App\Models\PosStation; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -16,7 +17,9 @@ class KitchenController extends Controller public function index(Request $request): View { - return view('pos.kitchen.index'); + return view('pos.kitchen.index', [ + 'stations' => PosStation::owned($this->ownerRef($request))->orderBy('name')->get(['id', 'name']), + ]); } /** @@ -29,7 +32,7 @@ class KitchenController extends Controller $sales = PosSale::owned($owner) ->openTickets() ->where('kitchen_status', PosSale::KITCHEN_ACTIVE) - ->with(['table', 'lines' => fn ($q) => $q->orderBy('position')]) + ->with(['table', 'lines' => fn ($q) => $q->orderBy('position')->with('modifiers', 'station')]) ->orderBy('kitchen_sent_at') ->get(); @@ -57,6 +60,10 @@ class KitchenController extends Controller 'name' => $l->name, 'quantity' => $l->quantity, 'notes' => $l->notes, + 'course' => $l->course, + 'station' => $l->station?->name, + 'station_id' => $l->station_id, + 'modifiers' => $l->modifiers->map(fn ($m) => $m->name)->all(), 'state' => $l->kitchen_state, ])->all(), ]; diff --git a/app/Http/Controllers/Pos/MenuController.php b/app/Http/Controllers/Pos/MenuController.php new file mode 100644 index 0000000..5b08cd6 --- /dev/null +++ b/app/Http/Controllers/Pos/MenuController.php @@ -0,0 +1,113 @@ +ownerRef($request); + + return view('pos.menu.index', [ + 'categories' => PosCategory::owned($owner)->orderBy('position')->orderBy('name')->get(), + 'stations' => PosStation::owned($owner)->orderBy('position')->orderBy('name')->get(), + 'groups' => PosModifierGroup::owned($owner)->with('modifiers')->orderBy('position')->orderBy('name')->get(), + ]); + } + + public function storeCategory(Request $request): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:80']]); + PosCategory::create(['owner_ref' => $this->ownerRef($request), 'name' => $data['name']]); + + return back()->with('success', 'Category added.'); + } + + public function destroyCategory(Request $request, PosCategory $category): RedirectResponse + { + $this->authorizeOwner($request, $category); + $category->delete(); + + return back()->with('success', 'Category removed.'); + } + + public function storeStation(Request $request): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:80']]); + PosStation::create(['owner_ref' => $this->ownerRef($request), 'name' => $data['name']]); + + return back()->with('success', 'Station added.'); + } + + public function destroyStation(Request $request, PosStation $station): RedirectResponse + { + $this->authorizeOwner($request, $station); + $station->delete(); + + return back()->with('success', 'Station removed.'); + } + + public function storeGroup(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:80'], + 'min_select' => ['nullable', 'integer', 'min:0', 'max:20'], + 'max_select' => ['nullable', 'integer', 'min:0', 'max:20'], + ]); + + PosModifierGroup::create([ + 'owner_ref' => $this->ownerRef($request), + 'name' => $data['name'], + 'min_select' => $data['min_select'] ?? 0, + 'max_select' => $data['max_select'] ?: null, + ]); + + return back()->with('success', 'Modifier group added.'); + } + + public function destroyGroup(Request $request, PosModifierGroup $group): RedirectResponse + { + $this->authorizeOwner($request, $group); + $group->delete(); + + return back()->with('success', 'Modifier group removed.'); + } + + public function storeModifier(Request $request, PosModifierGroup $group): RedirectResponse + { + $this->authorizeOwner($request, $group); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:80'], + 'price_delta' => ['nullable', 'numeric', 'min:-9999', 'max:9999'], + ]); + + $group->modifiers()->create([ + 'owner_ref' => $group->owner_ref, + 'name' => $data['name'], + 'price_delta_minor' => (int) round(((float) ($data['price_delta'] ?? 0)) * 100), + ]); + + return back()->with('success', 'Option added.'); + } + + public function destroyModifier(Request $request, PosModifier $modifier): RedirectResponse + { + $this->authorizeOwner($request, $modifier); + $modifier->delete(); + + return back()->with('success', 'Option removed.'); + } +} diff --git a/app/Http/Controllers/Pos/ProductController.php b/app/Http/Controllers/Pos/ProductController.php index 91d613d..d73a8e6 100644 --- a/app/Http/Controllers/Pos/ProductController.php +++ b/app/Http/Controllers/Pos/ProductController.php @@ -4,7 +4,12 @@ namespace App\Http\Controllers\Pos; use App\Http\Controllers\Controller; use App\Http\Controllers\Pos\Concerns\ScopesToAccount; +use App\Models\PosCategory; +use App\Models\PosLocation; +use App\Models\PosModifierGroup; use App\Models\PosProduct; +use App\Models\PosSaleLine; +use App\Models\PosStation; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -16,6 +21,7 @@ class ProductController extends Controller public function index(Request $request): View { $products = PosProduct::owned($this->ownerRef($request)) + ->with('category') ->orderBy('name') ->paginate(30) ->withQueryString(); @@ -23,20 +29,25 @@ class ProductController extends Controller return view('pos.products.index', compact('products')); } - public function create(): View + public function create(Request $request): View { - return view('pos.products.create', ['product' => new PosProduct([ - 'currency' => config('pos.default_currency', 'GHS'), - 'is_active' => true, - ])]); + return view('pos.products.create', [ + 'product' => new PosProduct([ + 'currency' => config('pos.default_currency', 'GHS'), + 'is_active' => true, + ]), + 'selectedGroups' => [], + ...$this->formOptions($request), + ]); } public function store(Request $request): RedirectResponse { - PosProduct::create([ + $product = PosProduct::create([ ...$this->validated($request), 'owner_ref' => $this->ownerRef($request), ]); + $product->modifierGroups()->sync($this->groupIds($request)); return redirect()->route('pos.products.index')->with('success', 'Product added.'); } @@ -45,13 +56,18 @@ class ProductController extends Controller { $this->authorizeOwner($request, $product); - return view('pos.products.edit', compact('product')); + return view('pos.products.edit', [ + 'product' => $product, + 'selectedGroups' => $product->modifierGroups()->pluck('pos_modifier_groups.id')->all(), + ...$this->formOptions($request), + ]); } public function update(Request $request, PosProduct $product): RedirectResponse { $this->authorizeOwner($request, $product); $product->update($this->validated($request)); + $product->modifierGroups()->sync($this->groupIds($request)); return redirect()->route('pos.products.index')->with('success', 'Product updated.'); } @@ -64,23 +80,58 @@ class ProductController extends Controller return redirect()->route('pos.products.index')->with('success', 'Product removed.'); } + /** @return array */ + private function formOptions(Request $request): array + { + $owner = $this->ownerRef($request); + + return [ + 'restaurant' => PosLocation::owned($owner)->where('service_style', PosLocation::STYLE_RESTAURANT)->exists(), + 'categories' => PosCategory::owned($owner)->orderBy('name')->get(), + 'stations' => PosStation::owned($owner)->orderBy('name')->get(), + 'modifierGroups' => PosModifierGroup::owned($owner)->orderBy('name')->get(), + ]; + } + + /** @return list */ + private function groupIds(Request $request): array + { + $owner = $this->ownerRef($request); + $ids = array_map('intval', (array) $request->input('modifier_group_ids', [])); + + return PosModifierGroup::owned($owner)->whereIn('id', $ids)->pluck('id')->all(); + } + /** @return array */ private function validated(Request $request): array { + $owner = $this->ownerRef($request); + $data = $request->validate([ 'name' => ['required', 'string', 'max:200'], 'sku' => ['nullable', 'string', 'max:80'], 'price' => ['required', 'numeric', 'min:0.01'], 'currency' => ['nullable', 'string', 'size:3'], 'is_active' => ['sometimes', 'boolean'], + 'category_id' => ['nullable', 'integer'], + 'station_id' => ['nullable', 'integer'], + 'course' => ['nullable', 'in:'.implode(',', array_keys(PosSaleLine::COURSES))], ]); + $categoryId = ($data['category_id'] ?? null) && PosCategory::owned($owner)->whereKey($data['category_id'])->exists() + ? (int) $data['category_id'] : null; + $stationId = ($data['station_id'] ?? null) && PosStation::owned($owner)->whereKey($data['station_id'])->exists() + ? (int) $data['station_id'] : null; + return [ 'name' => $data['name'], 'sku' => $data['sku'] ?? null, 'price_minor' => (int) round(((float) $data['price']) * 100), 'currency' => strtoupper($data['currency'] ?? config('pos.default_currency', 'GHS')), 'is_active' => $request->boolean('is_active', true), + 'category_id' => $categoryId, + 'station_id' => $stationId, + 'course' => $data['course'] ?? null, ]; } } diff --git a/app/Http/Controllers/Pos/TicketController.php b/app/Http/Controllers/Pos/TicketController.php index eeb6029..7296f5e 100644 --- a/app/Http/Controllers/Pos/TicketController.php +++ b/app/Http/Controllers/Pos/TicketController.php @@ -58,11 +58,29 @@ class TicketController extends Controller $owner = $this->ownerRef($request); $sale->load('lines', 'table'); - $products = PosProduct::owned($owner)->active()->orderBy('name')->get(); + $products = PosProduct::owned($owner)->active() + ->with(['category', 'modifierGroups.modifiers']) + ->orderBy('name')->get(); return view('pos.tickets.show', [ 'sale' => $sale, - 'products' => $products, + 'products' => $products->map(fn (PosProduct $p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'price_minor' => $p->price_minor, + 'category' => $p->category?->name, + 'modifier_groups' => $p->modifierGroups->map(fn ($g) => [ + 'id' => $g->id, + 'name' => $g->name, + 'min' => $g->min_select, + 'max' => $g->max_select, + 'modifiers' => $g->modifiers->map(fn ($m) => [ + 'id' => $m->id, 'name' => $m->name, 'price_delta_minor' => $m->price_delta_minor, + ])->values()->all(), + ])->values()->all(), + ])->values()->all(), + 'categories' => $products->pluck('category.name')->filter()->unique()->values()->all(), + 'courses' => PosSaleLine::COURSES, 'lines' => $this->lineData($sale), ]); } @@ -73,14 +91,53 @@ class TicketController extends Controller $data = $request->validate([ 'product_id' => ['nullable', 'integer'], - 'name' => ['required', 'string', 'max:200'], - 'unit_price_minor' => ['required', 'integer', 'min:1'], + 'name' => ['nullable', 'string', 'max:200'], + 'unit_price_minor' => ['nullable', 'integer', 'min:1'], 'quantity' => ['nullable', 'integer', 'min:1', 'max:999'], 'notes' => ['nullable', 'string', 'max:200'], + 'course' => ['nullable', 'in:'.implode(',', array_keys(PosSaleLine::COURSES))], + 'modifier_ids' => ['nullable', 'array'], + 'modifier_ids.*' => ['integer'], ]); + $payload = [ + 'quantity' => $data['quantity'] ?? 1, + 'notes' => $data['notes'] ?? null, + 'course' => $data['course'] ?? null, + ]; + + if (! empty($data['product_id'])) { + $product = PosProduct::owned($this->ownerRef($request)) + ->with('modifierGroups.modifiers') + ->find($data['product_id']); + + if (! $product) { + return response()->json(['message' => 'Unknown product.'], 422); + } + + // Only modifiers that belong to this product's groups count. + $allowed = $product->modifierGroups->flatMap->modifiers->keyBy('id'); + $chosen = collect($data['modifier_ids'] ?? []) + ->map(fn ($id) => $allowed->get((int) $id)) + ->filter() + ->values(); + + $payload['product_id'] = $product->id; + $payload['name'] = $product->name; + $payload['station_id'] = $product->station_id; + $payload['course'] = $payload['course'] ?? $product->course; + $payload['unit_price_minor'] = max(1, $product->price_minor + (int) $chosen->sum('price_delta_minor')); + $payload['modifiers'] = $chosen->map(fn ($m) => [ + 'modifier_id' => $m->id, 'name' => $m->name, 'price_delta_minor' => $m->price_delta_minor, + ])->all(); + } else { + // Custom (open-price) line. + $payload['name'] = (string) ($data['name'] ?? ''); + $payload['unit_price_minor'] = (int) ($data['unit_price_minor'] ?? 0); + } + try { - $this->sales->addLine($sale, $data); + $this->sales->addLine($sale, $payload); } catch (RuntimeException $e) { return response()->json(['message' => $e->getMessage()], 422); } @@ -117,7 +174,11 @@ class TicketController extends Controller { $this->authorizeOpen($request, $sale); - $fired = $this->sales->sendToKitchen($sale); + $data = $request->validate([ + 'course' => ['nullable', 'in:'.implode(',', array_keys(PosSaleLine::COURSES))], + ]); + + $fired = $this->sales->sendToKitchen($sale, $data['course'] ?? null); $sale->refresh(); return response()->json([ @@ -189,7 +250,7 @@ class TicketController extends Controller /** @return list> */ private function lineData(PosSale $sale): array { - return $sale->lines()->orderBy('position')->get()->map(fn (PosSaleLine $l) => [ + return $sale->lines()->with('modifiers')->orderBy('position')->get()->map(fn (PosSaleLine $l) => [ 'id' => $l->id, 'product_id' => $l->product_id, 'name' => $l->name, @@ -198,6 +259,8 @@ class TicketController extends Controller 'line_total_minor' => $l->line_total_minor, 'kitchen_state' => $l->kitchen_state, 'notes' => $l->notes, + 'course' => $l->course, + 'modifiers' => $l->modifiers->map(fn ($m) => $m->name)->all(), 'fired' => $l->kitchen_state !== PosSaleLine::KITCHEN_NEW, ])->all(); } diff --git a/app/Models/PosCategory.php b/app/Models/PosCategory.php new file mode 100644 index 0000000..f1dbef6 --- /dev/null +++ b/app/Models/PosCategory.php @@ -0,0 +1,22 @@ +hasMany(PosProduct::class, 'category_id'); + } + + public function scopeOwned(Builder $query, string $ownerRef): Builder + { + return $query->where('owner_ref', $ownerRef); + } +} diff --git a/app/Models/PosModifier.php b/app/Models/PosModifier.php new file mode 100644 index 0000000..be335ab --- /dev/null +++ b/app/Models/PosModifier.php @@ -0,0 +1,27 @@ + 'integer']; + } + + public function group(): BelongsTo + { + return $this->belongsTo(PosModifierGroup::class, 'modifier_group_id'); + } + + public function scopeOwned(Builder $query, string $ownerRef): Builder + { + return $query->where('owner_ref', $ownerRef); + } +} diff --git a/app/Models/PosModifierGroup.php b/app/Models/PosModifierGroup.php new file mode 100644 index 0000000..6ad30af --- /dev/null +++ b/app/Models/PosModifierGroup.php @@ -0,0 +1,33 @@ + 'integer', 'max_select' => 'integer']; + } + + public function modifiers(): HasMany + { + return $this->hasMany(PosModifier::class, 'modifier_group_id')->orderBy('position')->orderBy('id'); + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(PosProduct::class, 'pos_product_modifier_group', 'modifier_group_id', 'product_id'); + } + + public function scopeOwned(Builder $query, string $ownerRef): Builder + { + return $query->where('owner_ref', $ownerRef); + } +} diff --git a/app/Models/PosProduct.php b/app/Models/PosProduct.php index 7820ee1..626146c 100644 --- a/app/Models/PosProduct.php +++ b/app/Models/PosProduct.php @@ -5,12 +5,16 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; class PosProduct extends Model { protected $fillable = [ 'owner_ref', 'location_id', + 'category_id', + 'station_id', + 'course', 'name', 'sku', 'price_minor', @@ -31,6 +35,21 @@ class PosProduct extends Model return $this->belongsTo(PosLocation::class, 'location_id'); } + public function category(): BelongsTo + { + return $this->belongsTo(PosCategory::class, 'category_id'); + } + + public function station(): BelongsTo + { + return $this->belongsTo(PosStation::class, 'station_id'); + } + + public function modifierGroups(): BelongsToMany + { + return $this->belongsToMany(PosModifierGroup::class, 'pos_product_modifier_group', 'product_id', 'modifier_group_id'); + } + public function scopeOwned(Builder $query, string $ownerRef): Builder { return $query->where('owner_ref', $ownerRef); diff --git a/app/Models/PosSaleLine.php b/app/Models/PosSaleLine.php index f8346b6..96c49b5 100644 --- a/app/Models/PosSaleLine.php +++ b/app/Models/PosSaleLine.php @@ -20,9 +20,18 @@ class PosSaleLine extends Model /** 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]; + /** Ordered courses for fire-by-course (key => label). */ + public const COURSES = [ + 'starter' => 'Starter', + 'main' => 'Main', + 'dessert' => 'Dessert', + 'drink' => 'Drink', + ]; + protected $fillable = [ 'pos_sale_id', 'product_id', + 'station_id', 'name', 'unit_price_minor', 'quantity', @@ -30,6 +39,7 @@ class PosSaleLine extends Model 'position', 'kitchen_state', 'notes', + 'course', ]; protected function casts(): array @@ -51,4 +61,14 @@ class PosSaleLine extends Model { return $this->belongsTo(PosProduct::class, 'product_id'); } + + public function station(): BelongsTo + { + return $this->belongsTo(PosStation::class, 'station_id'); + } + + public function modifiers(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(PosSaleLineModifier::class, 'pos_sale_line_id'); + } } diff --git a/app/Models/PosSaleLineModifier.php b/app/Models/PosSaleLineModifier.php new file mode 100644 index 0000000..9787cae --- /dev/null +++ b/app/Models/PosSaleLineModifier.php @@ -0,0 +1,21 @@ + 'integer']; + } + + public function line(): BelongsTo + { + return $this->belongsTo(PosSaleLine::class, 'pos_sale_line_id'); + } +} diff --git a/app/Models/PosStation.php b/app/Models/PosStation.php new file mode 100644 index 0000000..c94aacf --- /dev/null +++ b/app/Models/PosStation.php @@ -0,0 +1,16 @@ +where('owner_ref', $ownerRef); + } +} diff --git a/app/Services/Pos/PosSaleService.php b/app/Services/Pos/PosSaleService.php index 2d35fe2..080791d 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\PosSaleLineModifier; use App\Models\PosTable; use App\Models\User; use App\Services\Pay\PayClient; @@ -113,7 +114,7 @@ class PosSaleService } /** - * @param array{product_id?: ?int, name: string, unit_price_minor: int, quantity?: int, notes?: ?string} $line + * @param array{product_id?: ?int, station_id?: ?int, course?: ?string, name: string, unit_price_minor: int, quantity?: int, notes?: ?string, modifiers?: list} $line */ public function addLine(PosSale $sale, array $line): PosSaleLine { @@ -130,6 +131,7 @@ class PosSaleService $created = PosSaleLine::create([ 'pos_sale_id' => $sale->id, 'product_id' => $line['product_id'] ?? null, + 'station_id' => $line['station_id'] ?? null, 'name' => $name, 'unit_price_minor' => $unit, 'quantity' => $qty, @@ -137,8 +139,22 @@ class PosSaleService 'position' => $position, 'kitchen_state' => PosSaleLine::KITCHEN_NEW, 'notes' => isset($line['notes']) ? trim((string) $line['notes']) ?: null : null, + 'course' => $line['course'] ?? null, ]); + foreach (($line['modifiers'] ?? []) as $mod) { + $modName = trim((string) ($mod['name'] ?? '')); + if ($modName === '') { + continue; + } + PosSaleLineModifier::create([ + 'pos_sale_line_id' => $created->id, + 'modifier_id' => $mod['modifier_id'] ?? null, + 'name' => $modName, + 'price_delta_minor' => (int) ($mod['price_delta_minor'] ?? 0), + ]); + } + $this->recomputeTotals($sale); return $created; @@ -181,15 +197,18 @@ class PosSaleService } /** - * Fire all not-yet-sent lines to the kitchen. + * Fire not-yet-sent lines to the kitchen. Pass a course to fire just that course. * * @return int number of lines fired */ - public function sendToKitchen(PosSale $sale): int + public function sendToKitchen(PosSale $sale, ?string $course = null): int { - $fired = $sale->lines() - ->where('kitchen_state', PosSaleLine::KITCHEN_NEW) - ->update(['kitchen_state' => PosSaleLine::KITCHEN_QUEUED]); + $query = $sale->lines()->where('kitchen_state', PosSaleLine::KITCHEN_NEW); + if ($course !== null && $course !== '') { + $query->where('course', $course); + } + + $fired = $query->update(['kitchen_state' => PosSaleLine::KITCHEN_QUEUED]); if ($fired > 0) { $sale->forceFill([ diff --git a/database/migrations/2026_06_28_000000_add_pos_menu_depth.php b/database/migrations/2026_06_28_000000_add_pos_menu_depth.php new file mode 100644 index 0000000..618422b --- /dev/null +++ b/database/migrations/2026_06_28_000000_add_pos_menu_depth.php @@ -0,0 +1,91 @@ +id(); + $table->string('owner_ref')->index(); + $table->string('name'); + $table->unsignedInteger('position')->default(0); + $table->timestamps(); + }); + + Schema::create('pos_stations', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->string('name'); + $table->unsignedInteger('position')->default(0); + $table->timestamps(); + }); + + Schema::create('pos_modifier_groups', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->string('name'); + $table->unsignedSmallInteger('min_select')->default(0); + $table->unsignedSmallInteger('max_select')->nullable(); // null = unlimited + $table->unsignedInteger('position')->default(0); + $table->timestamps(); + }); + + Schema::create('pos_modifiers', function (Blueprint $table) { + $table->id(); + $table->foreignId('modifier_group_id')->constrained('pos_modifier_groups')->cascadeOnDelete(); + $table->string('owner_ref')->index(); + $table->string('name'); + $table->integer('price_delta_minor')->default(0); // signed: add-ons positive, omissions negative + $table->unsignedInteger('position')->default(0); + $table->timestamps(); + }); + + Schema::create('pos_product_modifier_group', function (Blueprint $table) { + $table->id(); + $table->foreignId('product_id')->constrained('pos_products')->cascadeOnDelete(); + $table->foreignId('modifier_group_id')->constrained('pos_modifier_groups')->cascadeOnDelete(); + $table->unique(['product_id', 'modifier_group_id']); + }); + + Schema::create('pos_sale_line_modifiers', function (Blueprint $table) { + $table->id(); + $table->foreignId('pos_sale_line_id')->constrained('pos_sale_lines')->cascadeOnDelete(); + $table->unsignedBigInteger('modifier_id')->nullable(); // snapshot reference, plain + $table->string('name'); + $table->integer('price_delta_minor')->default(0); + $table->timestamps(); + }); + + // Plain columns (no ALTER-time FK — SQLite compat); resolved in app logic. + Schema::table('pos_products', function (Blueprint $table) { + $table->unsignedBigInteger('category_id')->nullable()->after('location_id')->index(); + $table->unsignedBigInteger('station_id')->nullable()->after('category_id')->index(); + $table->string('course')->nullable()->after('station_id'); + }); + + Schema::table('pos_sale_lines', function (Blueprint $table) { + $table->unsignedBigInteger('station_id')->nullable()->after('product_id')->index(); + $table->string('course')->nullable()->after('notes'); + }); + } + + public function down(): void + { + Schema::table('pos_sale_lines', function (Blueprint $table) { + $table->dropColumn(['station_id', 'course']); + }); + Schema::table('pos_products', function (Blueprint $table) { + $table->dropColumn(['category_id', 'station_id', 'course']); + }); + Schema::dropIfExists('pos_sale_line_modifiers'); + Schema::dropIfExists('pos_product_modifier_group'); + Schema::dropIfExists('pos_modifiers'); + Schema::dropIfExists('pos_modifier_groups'); + Schema::dropIfExists('pos_stations'); + Schema::dropIfExists('pos_categories'); + } +}; diff --git a/resources/js/app.js b/resources/js/app.js index 8d594c1..4b91159 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -335,11 +335,63 @@ Alpine.data('posTicket', (config = {}) => ({ busy: false, flash: '', + activeCategory: '', + picker: { open: false, product: null, groups: [], notes: '', course: '' }, + courseLabels: config.courseLabels || {}, + money(minor) { return this.currency + ' ' + ((minor || 0) / 100).toFixed(2); }, get hasNew() { return this.lines.some((l) => ! l.fired); }, + get categories() { + const set = []; + this.products.forEach((p) => { if (p.category && ! set.includes(p.category)) set.push(p.category); }); + return set; + }, get filteredProducts() { const q = this.search.trim().toLowerCase(); - return q ? this.products.filter((p) => p.name.toLowerCase().includes(q)) : this.products; + return this.products.filter((p) => + (! this.activeCategory || p.category === this.activeCategory) + && (! q || p.name.toLowerCase().includes(q))); + }, + get newCourses() { + const s = []; + this.lines.forEach((l) => { if (! l.fired) { const c = l.course || ''; if (! s.includes(c)) s.push(c); } }); + return s; + }, + courseLabel(c) { return this.courseLabels[c] || 'No course'; }, + + // Modifier picker + tap(p) { + if (p.modifier_groups && p.modifier_groups.length) this.openPicker(p); + else this.addProduct(p, {}); + }, + openPicker(p) { + this.flash = ''; + this.picker = { + open: true, product: p, notes: '', course: '', + groups: p.modifier_groups.map((g) => ({ ...g, selected: [] })), + }; + }, + closePicker() { this.picker.open = false; }, + isSelected(g, m) { return g.selected.includes(m.id); }, + toggleModifier(g, m) { + const i = g.selected.indexOf(m.id); + if (i >= 0) { g.selected.splice(i, 1); return; } + if (g.max === 1) { g.selected = [m.id]; return; } + if (g.max && g.selected.length >= g.max) return; + g.selected.push(m.id); + }, + get pickerPrice() { + if (! this.picker.product) return 0; + let total = this.picker.product.price_minor; + this.picker.groups.forEach((g) => g.modifiers.forEach((m) => { if (g.selected.includes(m.id)) total += m.price_delta_minor; })); + return total; + }, + get pickerValid() { return this.picker.groups.every((g) => g.selected.length >= (g.min || 0)); }, + confirmPicker() { + if (! this.pickerValid) { this.flash = 'Choose the required options.'; return; } + const ids = this.picker.groups.flatMap((g) => g.selected); + this.addProduct(this.picker.product, { modifier_ids: ids, notes: this.picker.notes, course: this.picker.course }); + this.closePicker(); }, async req(method, url, body) { @@ -367,17 +419,21 @@ Alpine.data('posTicket', (config = {}) => ({ }, apply(data) { if (data) { this.lines = data.lines; this.totalMinor = data.total_minor; } }, - async addProduct(p) { + async addProduct(p, opts = {}) { this.apply(await this.req('POST', this.base + '/lines', { - product_id: p.id, name: p.name, unit_price_minor: p.price_minor, quantity: 1, + product_id: p.id, + quantity: 1, + modifier_ids: opts.modifier_ids || [], + notes: opts.notes || null, + course: opts.course || null, })); }, 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', {}); + async send(course = null) { + const data = await this.req('POST', this.base + '/send', { course }); this.apply(data); if (data) this.flash = data.fired > 0 ? ('Sent ' + data.fired + ' item(s) to the kitchen.') : 'Nothing new to send.'; }, @@ -392,12 +448,20 @@ Alpine.data('posKitchen', (config = {}) => ({ csrf: config.csrf || document.querySelector('meta[name="csrf-token"]')?.content || '', now: Date.now(), loaded: false, + activeStation: '', init() { this.load(); setInterval(() => this.load(), 4000); setInterval(() => { this.now = Date.now(); }, 1000); }, + get visibleTickets() { + if (! this.activeStation) return this.tickets; + const sid = String(this.activeStation); + return this.tickets + .map((t) => ({ ...t, lines: t.lines.filter((l) => String(l.station_id || '') === sid) })) + .filter((t) => t.lines.length); + }, async load() { try { const res = await fetch(this.feedUrl, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } }); diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 605d7fe..0d0493e 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -23,6 +23,8 @@ 'icon' => ''], ['name' => 'Kitchen', 'route' => route('pos.kitchen'), 'active' => request()->routeIs('pos.kitchen'), 'icon' => ''], + ['name' => 'Menu setup', 'route' => route('pos.menu'), 'active' => request()->routeIs('pos.menu'), + 'icon' => ''], ]); } @endphp diff --git a/resources/views/pos/kitchen/index.blade.php b/resources/views/pos/kitchen/index.blade.php index ce8bcac..adbff6f 100644 --- a/resources/views/pos/kitchen/index.blade.php +++ b/resources/views/pos/kitchen/index.blade.php @@ -12,14 +12,26 @@ -