isRestaurant($request)) { $products = PosProduct::owned($this->ownerRef($request)) ->with('category')->orderBy('name')->paginate(30)->withQueryString(); return view('pos.products.index', compact('products')); } $search = trim((string) $request->query('search', '')); // Live CRM products only (not services) — same filter as the register. $response = CrmClient::for($this->ownerRef($request))->products([ 'type' => 'product', 'per_page' => 100, 'search' => $search !== '' ? $search : null, ]); return view('pos.products.retail.index', [ 'products' => (array) ($response['data'] ?? []), 'search' => $search, ]); } public function create(Request $request): View { if ($this->isRestaurant($request)) { return view('pos.products.create', [ 'product' => new PosProduct(['currency' => config('pos.default_currency', 'GHS'), 'is_active' => true]), 'selectedGroups' => [], ...$this->formOptions($request), ]); } return view('pos.products.retail.create'); } public function store(Request $request): RedirectResponse { $user = ladill_account() ?? $request->user(); $restaurant = $this->isRestaurant($request); if (! $this->subscriptions->canAddProduct($user, $this->ownerRef($request), $restaurant)) { return redirect()->route('pos.pro.index') ->with('upsell', 'You have reached the free plan limit of '.(int) config('pos.free.max_products', 50).' products. Upgrade to Pro for an unlimited catalog.'); } if ($restaurant) { $product = PosProduct::create([...$this->localValidated($request), 'owner_ref' => $this->ownerRef($request)]); $product->modifierGroups()->sync($this->groupIds($request)); return redirect()->route('pos.products.index')->with('success', 'Product added.'); } CrmClient::for($this->ownerRef($request))->createProduct($this->crmPayload($request)); return redirect()->route('pos.products.index')->with('success', 'Product added.'); } public function edit(Request $request, string $product): View { if ($this->isRestaurant($request)) { $model = PosProduct::owned($this->ownerRef($request))->findOrFail((int) $product); return view('pos.products.edit', [ 'product' => $model, 'selectedGroups' => $model->modifierGroups()->pluck('pos_modifier_groups.id')->all(), ...$this->formOptions($request), ]); } return view('pos.products.retail.edit', ['product' => CrmClient::for($this->ownerRef($request))->product($product)]); } public function update(Request $request, string $product): RedirectResponse { if ($this->isRestaurant($request)) { $model = PosProduct::owned($this->ownerRef($request))->findOrFail((int) $product); $model->update($this->localValidated($request)); $model->modifierGroups()->sync($this->groupIds($request)); return redirect()->route('pos.products.index')->with('success', 'Product updated.'); } CrmClient::for($this->ownerRef($request))->updateProduct($product, $this->crmPayload($request)); return redirect()->route('pos.products.index')->with('success', 'Product updated.'); } public function destroy(Request $request, string $product): RedirectResponse { if ($this->isRestaurant($request)) { $model = PosProduct::owned($this->ownerRef($request))->findOrFail((int) $product); $model->delete(); return redirect()->route('pos.products.index')->with('success', 'Product removed.'); } CrmClient::for($this->ownerRef($request))->deleteProduct($product); return redirect()->route('pos.products.index')->with('success', 'Product removed.'); } /** Bulk CSV import — upload form (mode-aware target catalog). */ public function importForm(Request $request): View { return view('pos.products.import', [ 'restaurant' => $this->isRestaurant($request), ]); } /** Bulk CSV import — handle the upload. */ public function import(Request $request, PosProductCsvImportService $import): RedirectResponse { $request->validate([ 'file' => ['required', 'file', 'mimes:csv,txt', 'max:10240'], ]); try { $result = $import->import( $this->ownerRef($request), $request->file('file'), $this->isRestaurant($request), ); } catch (RuntimeException $e) { return back()->with('error', $e->getMessage()); } $message = "Imported {$result['imported']} product(s)."; if ($result['skipped'] > 0) { $message .= " Skipped {$result['skipped']}."; } return redirect()->route('pos.products.index') ->with('success', $message) ->with('import_errors', array_slice($result['errors'], 0, 50)); } /** Bulk CSV import — downloadable template with example rows. */ public function importTemplate(Request $request): StreamedResponse { $restaurant = $this->isRestaurant($request); $filename = 'pos-products-template.csv'; return response()->streamDownload(function () use ($restaurant) { $out = fopen('php://output', 'wb'); if ($restaurant) { fputcsv($out, ['name', 'sku', 'price', 'currency', 'category', 'active']); fputcsv($out, ['Coffee', 'CFE-01', '15.00', 'GHS', 'Drinks', 'yes']); fputcsv($out, ['Croissant', 'CRS-02', '8.50', 'GHS', 'Pastry', 'yes']); } else { fputcsv($out, ['name', 'sku', 'price', 'currency', 'description', 'tax_rate', 'active']); fputcsv($out, ['Mug', 'MUG-01', '25.00', 'GHS', 'Ceramic mug', '0', 'yes']); fputcsv($out, ['T-Shirt', 'TSH-02', '60.00', 'GHS', 'Cotton tee', '0', 'yes']); } fclose($out); }, $filename, ['Content-Type' => 'text/csv']); } private function isRestaurant(Request $request): bool { // Use the acting branch — not "any restaurant location exists" — so a // retail register still sees the live CRM product catalog. return $this->location($request)->isRestaurant(); } /** Retail: payload for the CRM products API (always type=product for POS). */ private function crmPayload(Request $request): array { $data = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'sku' => ['nullable', 'string', 'max:80'], 'description' => ['nullable', 'string', 'max:5000'], 'unit_price' => ['nullable', 'numeric', 'min:0'], 'currency' => ['nullable', 'string', 'size:3'], 'tax_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], 'is_active' => ['sometimes', 'boolean'], ]); return [ 'name' => $data['name'], 'sku' => $data['sku'] ?? null, 'type' => 'product', 'description' => $data['description'] ?? null, 'unit_price_minor' => (int) round(((float) ($data['unit_price'] ?? 0)) * 100), 'currency' => strtoupper($data['currency'] ?? config('pos.default_currency', 'GHS')), 'tax_rate' => (float) ($data['tax_rate'] ?? 0), 'active' => $request->boolean('is_active', true), ]; } /** @return array */ private function formOptions(Request $request): array { $owner = $this->ownerRef($request); return [ 'restaurant' => true, '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(); } /** Restaurant: local pos_products attributes. */ private function localValidated(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, ]; } }