From e9a0c923087410442ac7fb303be51e4ece46fa12 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 24 Jun 2026 07:08:43 +0000 Subject: [PATCH] Align POS header with other apps: AI assistant, register button, sidebar - Add the Afia AI assistant to the header (topbar AI button + slide-over), matching every other Ladill app. Ports config/afia.php, AfiaService, a POS AiController (pos.ai.chat) scoped to register/sales/products, and includes the slide-over in the app layout. The afia() Alpine component already existed. - Hide the header "Open register" button while on the register page. - Move Settings to a pinned bottom section of the sidebar, like other apps. - Commit the updated POS logo + launcher icon assets. Co-Authored-By: Claude Opus 4.8 --- app/Http/Controllers/Pos/AiController.php | 61 ++++++++++ app/Services/Afia/AfiaService.php | 112 ++++++++++++++++++ config/afia.php | 11 ++ public/images/launcher-icons/pos.svg | 11 +- public/images/logo/ladillpos-logo.svg | 31 +++-- .../views/components/app-layout.blade.php | 1 + .../views/partials/afia-button.blade.php | 15 +++ resources/views/partials/afia.blade.php | 106 +++++++++++++++++ resources/views/partials/sidebar.blade.php | 12 +- .../partials/topbar-desktop-widgets.blade.php | 4 +- resources/views/partials/topbar.blade.php | 4 +- routes/web.php | 3 + 12 files changed, 345 insertions(+), 26 deletions(-) create mode 100644 app/Http/Controllers/Pos/AiController.php create mode 100644 app/Services/Afia/AfiaService.php create mode 100644 config/afia.php create mode 100644 resources/views/partials/afia-button.blade.php create mode 100644 resources/views/partials/afia.blade.php diff --git a/app/Http/Controllers/Pos/AiController.php b/app/Http/Controllers/Pos/AiController.php new file mode 100644 index 0000000..1a5196e --- /dev/null +++ b/app/Http/Controllers/Pos/AiController.php @@ -0,0 +1,61 @@ +validate([ + 'message' => ['required', 'string', 'max:2000'], + 'history' => ['nullable', 'array', 'max:20'], + 'history.*.role' => ['nullable', 'string'], + 'history.*.text' => ['nullable', 'string'], + ]); + + if (! $afia->enabled()) { + return response()->json(['message' => 'Afia is not available right now.'], 503); + } + + try { + $reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context($request)); + } catch (\Throwable $e) { + report($e); + + return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502); + } + + return response()->json(['reply' => $reply]); + } + + /** @return array */ + private function context(Request $request): array + { + $owner = $this->ownerRef($request); + $currency = strtoupper((string) config('pos.default_currency', 'GHS')); + + $todaySales = PosSale::owned($owner) + ->where('status', PosSale::STATUS_PAID) + ->where('paid_at', '>=', now()->startOfDay()); + + $todayTotalMinor = (int) (clone $todaySales)->sum('total_minor'); + + return [ + 'signed_in' => 'yes', + 'today_takings' => $currency.' '.number_format($todayTotalMinor / 100, 2), + 'today_sales_count' => (clone $todaySales)->count(), + 'active_products' => PosProduct::owned($owner)->active()->count(), + 'pending_sales' => PosSale::owned($owner)->where('status', PosSale::STATUS_PENDING)->count(), + ]; + } +} diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php new file mode 100644 index 0000000..82d6aea --- /dev/null +++ b/app/Services/Afia/AfiaService.php @@ -0,0 +1,112 @@ + $history + * @param array $context + */ + public function chat(string $message, array $history, array $context): string + { + if (! $this->enabled()) { + throw new RuntimeException('Afia is not configured.'); + } + + $provider = (string) config('afia.provider', 'openai'); + $model = (string) config('afia.model', 'gpt-4o-mini'); + $apiKey = (string) config('afia.api_key'); + + $messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]]; + foreach (array_slice($history, -8) as $turn) { + $role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user'; + $text = trim((string) ($turn['text'] ?? '')); + if ($text !== '') { + $messages[] = ['role' => $role, 'content' => $text]; + } + } + $messages[] = ['role' => 'user', 'content' => $message]; + + return $provider === 'anthropic' + ? $this->viaAnthropic($model, $apiKey, $messages) + : $this->viaOpenAi($model, $apiKey, $messages); + } + + private function viaOpenAi(string $model, string $apiKey, array $messages): string + { + $res = Http::withToken($apiKey)->acceptJson()->timeout(45) + ->post('https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'temperature' => 0.3, + 'max_tokens' => 600, + 'messages' => $messages, + ]); + + if ($res->failed()) { + throw new RuntimeException('OpenAI request failed: '.$res->status()); + } + + return trim((string) $res->json('choices.0.message.content', '')); + } + + private function viaAnthropic(string $model, string $apiKey, array $messages): string + { + $system = $messages[0]['content']; + $turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system')); + + $res = Http::withHeaders([ + 'x-api-key' => $apiKey, + 'anthropic-version' => '2023-06-01', + ])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [ + 'model' => $model, + 'max_tokens' => 600, + 'system' => $system, + 'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns), + ]); + + if ($res->failed()) { + throw new RuntimeException('Anthropic request failed: '.$res->status()); + } + + return trim((string) $res->json('content.0.text', '')); + } + + /** @param array $context */ + private function systemPrompt(array $context): string + { + $ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n"); + + return << env('AFIA_PRODUCT', 'pos'), + 'enabled' => (bool) env('AFIA_ENABLED', true), + 'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic + 'model' => env('AFIA_MODEL', 'gpt-4o-mini'), + 'api_key' => env('AFIA_API_KEY', env('OPENAI_API_KEY')), +]; diff --git a/public/images/launcher-icons/pos.svg b/public/images/launcher-icons/pos.svg index 84b9280..3bdb6c6 100644 --- a/public/images/launcher-icons/pos.svg +++ b/public/images/launcher-icons/pos.svg @@ -15,7 +15,7 @@ } .cls-3 { - fill: #868f77; + fill: #2a9779; } .cls-4 { @@ -23,9 +23,8 @@ } - - - - - + + + + \ No newline at end of file diff --git a/public/images/logo/ladillpos-logo.svg b/public/images/logo/ladillpos-logo.svg index e96ae46..ffccce4 100644 --- a/public/images/logo/ladillpos-logo.svg +++ b/public/images/logo/ladillpos-logo.svg @@ -1,5 +1,5 @@ - + - - - - - - + + + + + + - - - - - - - - + + + + + + + \ No newline at end of file diff --git a/resources/views/components/app-layout.blade.php b/resources/views/components/app-layout.blade.php index 0004f4d..abf6a77 100644 --- a/resources/views/components/app-layout.blade.php +++ b/resources/views/components/app-layout.blade.php @@ -27,5 +27,6 @@ + @include('partials.afia') diff --git a/resources/views/partials/afia-button.blade.php b/resources/views/partials/afia-button.blade.php new file mode 100644 index 0000000..35c0366 --- /dev/null +++ b/resources/views/partials/afia-button.blade.php @@ -0,0 +1,15 @@ +@props(['compact' => false]) + + $compact, 'hidden sm:inline' => ! $compact])>AI + diff --git a/resources/views/partials/afia.blade.php b/resources/views/partials/afia.blade.php new file mode 100644 index 0000000..f5953aa --- /dev/null +++ b/resources/views/partials/afia.blade.php @@ -0,0 +1,106 @@ +@php + $afiaGreeting = "Hi, I'm Afia 👋 Ask me about taking a sale, charging by cash or Ladill Pay, managing products, or your sales history…"; + $afiaSuggestions = [ + 'How do I take a sale?', + 'How do I charge with Ladill Pay?', + 'How do I add a product?', + 'How do I import products from CRM?', + ]; +@endphp +{{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}} +
+
+ +
+
+
+ + + + +
+

Afia

+

POS assistant

+
+
+
+ History + +
+
+ +
+
+ +
+
+
+ + + +
+
+
+
+ +
+ +
+
+ +
+
+ + +
+

Afia can make mistakes — verify important details.

+
+
+
diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index faca108..e2ab3b9 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -14,8 +14,6 @@ 'icon' => ''], ['name' => 'Sales', 'route' => route('pos.sales.index'), 'active' => request()->routeIs('pos.sales.*'), 'icon' => ''], - ['name' => 'Settings', 'route' => route('pos.settings'), 'active' => request()->routeIs('pos.settings*'), - 'icon' => ''], ]; @endphp + + diff --git a/resources/views/partials/topbar-desktop-widgets.blade.php b/resources/views/partials/topbar-desktop-widgets.blade.php index 9bd2e1d..f6d950c 100644 --- a/resources/views/partials/topbar-desktop-widgets.blade.php +++ b/resources/views/partials/topbar-desktop-widgets.blade.php @@ -1,6 +1,6 @@ {{-- Standard desktop top-right widgets: - notifications → launcher → divider → avatar dropdown. + AI → notifications → launcher → divider → avatar dropdown. --}} @php $topbarUser = $user ?? auth()->user(); @@ -12,6 +12,8 @@ $showUserHeader = $showUser ?? true; @endphp +@includeIf('partials.afia-button', ['compact' => true]) + @includeIf('partials.notification-dropdown') @include('partials.launcher') diff --git a/resources/views/partials/topbar.blade.php b/resources/views/partials/topbar.blade.php index 1411e69..73d4933 100644 --- a/resources/views/partials/topbar.blade.php +++ b/resources/views/partials/topbar.blade.php @@ -9,7 +9,9 @@

{{ $heading ?? 'Ladill POS' }}

- + @unless(request()->routeIs('pos.register')) + + @endunless
diff --git a/routes/web.php b/routes/web.php index f04e3c8..95b37e9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name( Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance'); + Route::post('/ai/chat', [AiController::class, 'chat'])->middleware('throttle:30,1')->name('pos.ai.chat'); + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread'); Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read');