Align Care header with other Ladill apps.
Deploy Ladill Care / deploy (push) Successful in 1m0s

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:
isaacclad
2026-06-29 12:53:34 +00:00
co-authored by Cursor
parent 5411b10694
commit eaf6da5cdb
12 changed files with 378 additions and 15 deletions
+5
View File
@@ -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]);
}
}
+175
View File
@@ -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;
}
}
+11
View File
@@ -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>
+7 -7
View File
@@ -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')
+3 -4
View File
@@ -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>
+9
View File
@@ -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');