Add Afia and notifications to the top bar, remove the duplicate desktop page title, wire notification and AI routes, and fix mobile nav notification links. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,6 +29,11 @@ BILLING_API_KEY_CARE=
|
||||
IDENTITY_API_URL=https://ladill.com/api
|
||||
IDENTITY_API_KEY_CARE=
|
||||
|
||||
# Afia in-app assistant (uses platform relay when AFIA_API_KEY is empty)
|
||||
AFIA_ENABLED=true
|
||||
AFIA_PRODUCT=care
|
||||
AFIA_PLATFORM_API_KEY=
|
||||
|
||||
# Platform events webhook (user/org lifecycle from the monolith)
|
||||
SERVICE_EVENTS_INBOUND_SECRET=
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Models\Appointment;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\Patient;
|
||||
use App\Services\Afia\AfiaService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AiController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function chat(Request $request, AfiaService $afia): JsonResponse
|
||||
{
|
||||
$validated = $request->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<string, mixed> */
|
||||
private function context(Request $request): array
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
$organization = $this->organization($request);
|
||||
|
||||
return [
|
||||
'signed_in' => 'yes',
|
||||
'patients_total' => Patient::owned($owner)->where('organization_id', $organization->id)->count(),
|
||||
'appointments_today' => Appointment::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->whereDate('scheduled_at', today())
|
||||
->count(),
|
||||
'queue_waiting' => Appointment::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('status', Appointment::STATUS_WAITING)
|
||||
->count(),
|
||||
'in_consultation' => Appointment::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('status', Appointment::STATUS_IN_CONSULTATION)
|
||||
->count(),
|
||||
'consultations_completed_today' => Consultation::owned($owner)
|
||||
->whereHas('visit', fn ($q) => $q->where('organization_id', $organization->id))
|
||||
->where('status', Consultation::STATUS_COMPLETED)
|
||||
->whereDate('completed_at', today())
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$notifications = $request->user()
|
||||
->notifications()
|
||||
->latest()
|
||||
->paginate(20);
|
||||
|
||||
return view('notifications.index', compact('notifications'));
|
||||
}
|
||||
|
||||
public function unread(Request $request): JsonResponse
|
||||
{
|
||||
$notifications = $request->user()
|
||||
->unreadNotifications()
|
||||
->latest()
|
||||
->take(10)
|
||||
->get()
|
||||
->map(fn ($n) => [
|
||||
'id' => $n->id,
|
||||
'type' => class_basename($n->type),
|
||||
'title' => $n->data['title'] ?? 'Notification',
|
||||
'message' => $n->data['message'] ?? '',
|
||||
'icon' => $n->data['icon'] ?? 'bell',
|
||||
'url' => $n->data['url'] ?? null,
|
||||
'created_at' => $n->created_at->diffForHumans(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'notifications' => $notifications,
|
||||
'unread_count' => $request->user()->unreadNotifications()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsRead(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$notification = $request->user()
|
||||
->notifications()
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if ($notification) {
|
||||
$notification->markAsRead();
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function markAllAsRead(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Afia;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Afia — Ladill in-app AI assistant scoped to Ladill Care.
|
||||
*/
|
||||
class AfiaService
|
||||
{
|
||||
public function enabled(): bool
|
||||
{
|
||||
if (! (bool) config('afia.enabled', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->hasLocalKey() || $this->hasPlatformRelay();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{role: string, text: string}> $history
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function chat(string $message, array $history, array $context): string
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
throw new RuntimeException('Afia is not configured.');
|
||||
}
|
||||
|
||||
if ($this->hasLocalKey()) {
|
||||
return $this->chatLocally($message, $history, $context);
|
||||
}
|
||||
|
||||
return $this->chatViaPlatform($message, $history, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{role: string, text: string}> $history
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function chatLocally(string $message, array $history, array $context): string
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{role: string, text: string}> $history
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function chatViaPlatform(string $message, array $history, array $context): string
|
||||
{
|
||||
$base = rtrim((string) config('afia.platform_api_url', ''), '/');
|
||||
$token = (string) config('afia.platform_api_key', '');
|
||||
|
||||
$res = Http::withToken($token)->acceptJson()->timeout(50)->post($base.'/afia/chat', [
|
||||
'product' => (string) config('afia.product', 'care'),
|
||||
'message' => $message,
|
||||
'history' => $history,
|
||||
'system_prompt' => $this->systemPrompt($context),
|
||||
]);
|
||||
|
||||
if ($res->status() === 503) {
|
||||
throw new RuntimeException('Afia is not configured on the platform.');
|
||||
}
|
||||
|
||||
if ($res->failed()) {
|
||||
throw new RuntimeException('Platform Afia relay failed: '.$res->status());
|
||||
}
|
||||
|
||||
$reply = trim((string) $res->json('reply', ''));
|
||||
if ($reply === '') {
|
||||
throw new RuntimeException('Platform Afia relay returned an empty response.');
|
||||
}
|
||||
|
||||
return $reply;
|
||||
}
|
||||
|
||||
private function hasLocalKey(): bool
|
||||
{
|
||||
return (string) config('afia.api_key', '') !== '';
|
||||
}
|
||||
|
||||
private function hasPlatformRelay(): bool
|
||||
{
|
||||
return rtrim((string) config('afia.platform_api_url', ''), '/') !== ''
|
||||
&& (string) config('afia.platform_api_key', '') !== '';
|
||||
}
|
||||
|
||||
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<string, mixed> $context */
|
||||
private function systemPrompt(array $context): string
|
||||
{
|
||||
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
|
||||
|
||||
return <<<PROMPT
|
||||
You are Afia, the assistant inside Ladill Care (care.ladill.com).
|
||||
Help clinical and administrative staff manage patients, appointments, consultations, laboratory, pharmacy, and billing. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
|
||||
|
||||
What Ladill Care does and where things live:
|
||||
- Dashboard: today's activity and quick links for the facility.
|
||||
- Patients: register patients, view demographics, history, consultations, prescriptions, and bills.
|
||||
- Appointments: schedule visits and manage walk-ins.
|
||||
- Queue: waiting patients and active consultations — doctors start consultations from here.
|
||||
- Consultations: record vitals, symptoms, clinical notes, diagnoses, lab requests, and prescriptions; complete the encounter when done.
|
||||
- Laboratory: investigation catalog, request tests from consultations, sample collection, results entry, and approval.
|
||||
- Prescriptions & pharmacy queue: prescribe during consultations; pharmacists dispense from the queue.
|
||||
- Billing: generate bills from completed visits, record payments, and print receipts.
|
||||
- Settings & admin: branches, departments, members, practitioners, and audit logs.
|
||||
|
||||
Rules:
|
||||
- Only answer questions about Ladill Care — patients, appointments, queue, consultations, lab, pharmacy, and billing.
|
||||
- If asked about CRM, visitor management, domains, or email mailboxes, briefly say those live in other Ladill apps and suggest the app launcher.
|
||||
- Never invent patient names, counts, or clinical results — use the user context below or tell them where to check.
|
||||
|
||||
Current user context:
|
||||
{$ctx}
|
||||
PROMPT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'product' => env('AFIA_PRODUCT', 'care'),
|
||||
'enabled' => (bool) env('AFIA_ENABLED', true),
|
||||
'provider' => env('AFIA_PROVIDER', 'openai'),
|
||||
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
|
||||
'api_key' => env('AFIA_API_KEY', env('OPENAI_API_KEY')),
|
||||
'platform_api_url' => env('AFIA_PLATFORM_API_URL', env('IDENTITY_API_URL', 'https://ladill.com/api')),
|
||||
'platform_api_key' => env('AFIA_PLATFORM_API_KEY', env('IDENTITY_API_KEY_CARE')),
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('type');
|
||||
$table->morphs('notifiable');
|
||||
$table->text('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
@@ -44,9 +44,9 @@
|
||||
'homeActive' => request()->routeIs('care.dashboard'),
|
||||
'searchUrl' => route('care.patients.index'),
|
||||
'searchActive' => request()->routeIs('care.patients.*'),
|
||||
'notificationsUrl' => route('care.dashboard'),
|
||||
'notificationsActive' => false,
|
||||
'unreadUrl' => route('care.dashboard'),
|
||||
'notificationsUrl' => route('notifications.index'),
|
||||
'notificationsActive' => request()->routeIs('notifications.*'),
|
||||
'unreadUrl' => route('notifications.unread'),
|
||||
'profileActive' => false,
|
||||
'profileName' => $navUser?->name ?? '',
|
||||
'profileSubtitle' => $navUser?->email ?? '',
|
||||
@@ -55,6 +55,7 @@
|
||||
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
||||
])
|
||||
@endauth
|
||||
@include('partials.afia')
|
||||
@include('partials.wallet-topup-modal', ['openOnLoad' => (bool) session('topup_required')])
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
@php
|
||||
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about visitor check-in, kiosks, hosts, badges, devices, or setting up your reception desk…";
|
||||
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about patients, appointments, the queue, consultations, lab requests, prescriptions, or billing…";
|
||||
$afiaSuggestions = [
|
||||
'How do I set up a visitor kiosk?',
|
||||
'How do I check a visitor in?',
|
||||
'Where do I add reception desks?',
|
||||
'How do hosts approve visits?',
|
||||
'How do I register a new patient?',
|
||||
'How does the patient queue work?',
|
||||
'How do I start a consultation?',
|
||||
'How do I request lab investigations?',
|
||||
];
|
||||
@endphp
|
||||
{{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
|
||||
<div x-data="afia({
|
||||
chatUrl: '{{ route('frontdesk.ai.chat') }}',
|
||||
chatUrl: '{{ route('care.ai.chat') }}',
|
||||
csrf: '{{ csrf_token() }}',
|
||||
greeting: @js($afiaGreeting),
|
||||
suggestions: @js($afiaSuggestions),
|
||||
@@ -48,7 +48,7 @@
|
||||
</span>
|
||||
<div class="leading-tight">
|
||||
<p class="text-sm font-semibold text-slate-900">Afia</p>
|
||||
<p class="text-[11px] text-slate-400">Frontdesk assistant</p>
|
||||
<p class="text-[11px] text-slate-400">Care assistant</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@php
|
||||
$title = $mobileTopbarTitle ?? 'Ladill';
|
||||
$title = $heading ?? $mobileTopbarTitle ?? 'Ladill Care';
|
||||
@endphp
|
||||
<div class="min-w-0 flex-1 lg:hidden">
|
||||
<h1 class="truncate text-base font-semibold text-slate-900">{{ $title }}</h1>
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
$showUserHeader = $showUser ?? true;
|
||||
@endphp
|
||||
|
||||
@includeIf('partials.afia-button', ['compact' => true])
|
||||
|
||||
@includeIf('partials.notification-dropdown')
|
||||
|
||||
@include('partials.launcher')
|
||||
|
||||
@includeIf('partials.topbar-widgets-mid')
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</button>
|
||||
|
||||
@include('partials.mobile-topbar-title')
|
||||
|
||||
<h1 class="hidden text-sm font-semibold text-slate-700 lg:block">{{ $heading ?? 'Ladill Care' }}</h1>
|
||||
@include('partials.mobile-topbar-title', ['heading' => $heading ?? null])
|
||||
|
||||
@if (auth()->check() && app(\App\Services\Care\CarePermissions::class)->can(
|
||||
app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user()), 'patients.view'))
|
||||
@@ -22,7 +20,8 @@
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
<input x-ref="search" type="search" name="q" value="{{ request('q') }}"
|
||||
placeholder="Search patients…"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-10 text-sm text-slate-700 placeholder-slate-400 transition focus:border-sky-300 focus:bg-white focus:ring-2 focus:ring-sky-100 focus:outline-none">
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-10 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
|
||||
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 rounded-md border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-400 sm:inline-block">/</kbd>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Auth\SsoLoginController;
|
||||
use App\Http\Controllers\Care\AiController;
|
||||
use App\Http\Controllers\NotificationController;
|
||||
use App\Http\Controllers\Care\AuditLogController;
|
||||
use App\Http\Controllers\Care\AppointmentController;
|
||||
use App\Http\Controllers\Care\BillController;
|
||||
@@ -35,10 +37,17 @@ Route::get('/sso/platform-signed-out', [SsoLoginController::class, 'platformSign
|
||||
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('care.dashboard') : view('auth.signed-out'))->name('care.signed-out');
|
||||
|
||||
Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
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');
|
||||
Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read');
|
||||
|
||||
Route::get('/onboarding', [OnboardingController::class, 'show'])->name('care.onboarding.show');
|
||||
Route::post('/onboarding', [OnboardingController::class, 'store'])->name('care.onboarding.store');
|
||||
|
||||
Route::middleware(['care.setup'])->group(function () {
|
||||
Route::post('/ai/chat', [AiController::class, 'chat'])->middleware('throttle:30,1')->name('care.ai.chat');
|
||||
|
||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('care.dashboard');
|
||||
|
||||
Route::get('/patients', [PatientController::class, 'index'])->name('care.patients.index');
|
||||
|
||||
Reference in New Issue
Block a user