From dec282d25de0bc34a3a9d70c4d085fff53ad83d5 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 17 Jul 2026 15:55:47 +0000 Subject: [PATCH] Embed Ladill Queue on role pages and add specialty modules under Settings. Remove the standalone Service Queues nav so call-next/now-serving lives on clinical queue, appointments, pharmacy, lab, and specialty surfaces; Pro orgs can activate dentistry and related modules with branch departments and queue stubs. Co-authored-by: Cursor --- .../Care/AppointmentController.php | 12 +- .../Controllers/Care/DashboardController.php | 18 ++ .../Care/InvestigationController.php | 9 + .../Care/PrescriptionController.php | 9 + app/Http/Controllers/Care/QueueController.php | 12 +- .../Care/ServiceQueueController.php | 36 +-- .../Controllers/Care/SettingsController.php | 1 + .../Care/SettingsModulesController.php | 67 ++++ .../Care/SpecialtyModuleController.php | 80 +++++ app/Services/Care/CarePermissions.php | 3 + app/Services/Care/DemoTenantSeeder.php | 17 + app/Services/Care/PlanService.php | 6 + app/Services/Care/ServiceQueuePresenter.php | 249 +++++++++++++++ app/Services/Care/SpecialtyModuleService.php | 291 ++++++++++++++++++ app/Services/Queue/QueueClient.php | 31 ++ config/care.php | 66 ++++ .../views/care/appointments/index.blade.php | 2 + resources/views/care/dashboard.blade.php | 21 ++ .../views/care/lab/queue/index.blade.php | 4 + .../views/care/prescriptions/queue.blade.php | 4 + resources/views/care/queue/index.blade.php | 2 + .../care/service-queues/_panel.blade.php | 145 +++++++++ .../care/service-queues/console.blade.php | 2 +- resources/views/care/settings/edit.blade.php | 15 +- .../views/care/settings/modules.blade.php | 53 ++++ resources/views/care/specialty/show.blade.php | 67 ++++ resources/views/partials/sidebar.blade.php | 17 +- routes/web.php | 6 + tests/Feature/CareNaturalQueueTest.php | 199 ++++++++++++ tests/Feature/CareSpecialtyModulesTest.php | 143 +++++++++ 30 files changed, 1552 insertions(+), 35 deletions(-) create mode 100644 app/Http/Controllers/Care/SettingsModulesController.php create mode 100644 app/Http/Controllers/Care/SpecialtyModuleController.php create mode 100644 app/Services/Care/ServiceQueuePresenter.php create mode 100644 app/Services/Care/SpecialtyModuleService.php create mode 100644 resources/views/care/service-queues/_panel.blade.php create mode 100644 resources/views/care/settings/modules.blade.php create mode 100644 resources/views/care/specialty/show.blade.php create mode 100644 tests/Feature/CareNaturalQueueTest.php create mode 100644 tests/Feature/CareSpecialtyModulesTest.php diff --git a/app/Http/Controllers/Care/AppointmentController.php b/app/Http/Controllers/Care/AppointmentController.php index 9e1da4b..320802d 100644 --- a/app/Http/Controllers/Care/AppointmentController.php +++ b/app/Http/Controllers/Care/AppointmentController.php @@ -11,6 +11,7 @@ use App\Models\Patient; use App\Models\Practitioner; use App\Services\Care\AppointmentService; use App\Services\Care\OrganizationResolver; +use App\Services\Care\ServiceQueuePresenter; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -21,13 +22,15 @@ class AppointmentController extends Controller public function __construct( protected AppointmentService $appointments, + protected ServiceQueuePresenter $serviceQueues, ) {} public function index(Request $request): View { $this->authorizeAbility($request, 'appointments.view'); $organization = $this->organization($request); - $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); + $member = $this->member($request); + $branchScope = app(OrganizationResolver::class)->branchScope($member); $appointments = $this->appointments->list( $this->ownerRef($request), @@ -58,6 +61,13 @@ class AppointmentController extends Controller 'practitioners' => $practitioners, 'statuses' => config('care.appointment_statuses'), 'heroStats' => $heroStats, + 'serviceQueuePanel' => $this->serviceQueues->panel( + $request, + $organization, + $member, + 'clinical', + $request->input('counter_uuid'), + ), ]); } diff --git a/app/Http/Controllers/Care/DashboardController.php b/app/Http/Controllers/Care/DashboardController.php index 7e8d788..b5a1009 100644 --- a/app/Http/Controllers/Care/DashboardController.php +++ b/app/Http/Controllers/Care/DashboardController.php @@ -12,6 +12,8 @@ use App\Services\Care\AppointmentService; use App\Services\Care\CarePermissions; use App\Services\Care\OrganizationResolver; use App\Services\Care\ReportService; +use App\Services\Care\ServiceQueuePresenter; +use App\Services\Care\SpecialtyModuleService; use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\View\View; @@ -24,6 +26,8 @@ class DashboardController extends Controller protected ReportService $reports, protected CarePermissions $permissions, protected AppointmentService $appointments, + protected ServiceQueuePresenter $serviceQueues, + protected SpecialtyModuleService $specialtyModules, ) {} public function index(Request $request): View @@ -76,6 +80,12 @@ class DashboardController extends Controller ? $this->patientQueueForMember($request, $organization->id, $owner, $member, $branchScope) : [collect(), collect(), null]; + $serviceQueueContext = match ($member?->role) { + 'pharmacist' => 'pharmacy', + 'lab_technician' => 'laboratory', + default => 'clinical', + }; + return view('care.dashboard', [ 'organization' => $organization, 'member' => $member, @@ -93,6 +103,14 @@ class DashboardController extends Controller 'patientQueue' => $queue, 'inConsultation' => $inConsultation, 'practitionerId' => $practitionerId, + 'serviceQueuePanel' => $this->serviceQueues->panel( + $request, + $organization, + $member, + $serviceQueueContext, + $request->input('counter_uuid'), + ), + 'specialtyModules' => $this->specialtyModules->enabledModules($organization), ]); } diff --git a/app/Http/Controllers/Care/InvestigationController.php b/app/Http/Controllers/Care/InvestigationController.php index 963b7d5..71f2987 100644 --- a/app/Http/Controllers/Care/InvestigationController.php +++ b/app/Http/Controllers/Care/InvestigationController.php @@ -11,6 +11,7 @@ use App\Models\InvestigationType; use App\Models\Member; use App\Services\Care\InvestigationService; use App\Services\Care\OrganizationResolver; +use App\Services\Care\ServiceQueuePresenter; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -21,6 +22,7 @@ class InvestigationController extends Controller public function __construct( protected InvestigationService $investigations, + protected ServiceQueuePresenter $serviceQueues, ) {} public function index(Request $request): View @@ -74,6 +76,13 @@ class InvestigationController extends Controller ->where('organization_id', $organization->id) ->orderBy('role') ->get(), + 'serviceQueuePanel' => $this->serviceQueues->panel( + $request, + $organization, + $this->member($request), + 'laboratory', + $request->input('counter_uuid'), + ), ]); } diff --git a/app/Http/Controllers/Care/PrescriptionController.php b/app/Http/Controllers/Care/PrescriptionController.php index e0e0c65..ec6d598 100644 --- a/app/Http/Controllers/Care/PrescriptionController.php +++ b/app/Http/Controllers/Care/PrescriptionController.php @@ -10,6 +10,7 @@ use App\Models\Practitioner; use App\Services\Care\OrganizationResolver; use App\Services\Care\PharmacyService; use App\Services\Care\PrescriptionService; +use App\Services\Care\ServiceQueuePresenter; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -21,6 +22,7 @@ class PrescriptionController extends Controller public function __construct( protected PrescriptionService $prescriptions, protected PharmacyService $pharmacy, + protected ServiceQueuePresenter $serviceQueues, ) {} public function index(Request $request): View @@ -65,6 +67,13 @@ class PrescriptionController extends Controller 'queue' => $queue, 'drugs' => $drugs, 'canDispense' => $canDispense, + 'serviceQueuePanel' => $this->serviceQueues->panel( + $request, + $organization, + $this->member($request), + 'pharmacy', + $request->input('counter_uuid'), + ), ]); } diff --git a/app/Http/Controllers/Care/QueueController.php b/app/Http/Controllers/Care/QueueController.php index 957139f..fbbf09b 100644 --- a/app/Http/Controllers/Care/QueueController.php +++ b/app/Http/Controllers/Care/QueueController.php @@ -10,6 +10,7 @@ use App\Models\Practitioner; use App\Services\Care\AppointmentService; use App\Services\Care\ConsultationService; use App\Services\Care\OrganizationResolver; +use App\Services\Care\ServiceQueuePresenter; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -21,13 +22,15 @@ class QueueController extends Controller public function __construct( protected AppointmentService $appointments, protected ConsultationService $consultations, + protected ServiceQueuePresenter $serviceQueues, ) {} public function index(Request $request): View { $this->authorizeAbility($request, 'appointments.view'); $organization = $this->organization($request); - $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); + $member = $this->member($request); + $branchScope = app(OrganizationResolver::class)->branchScope($member); $branches = Branch::owned($this->ownerRef($request)) ->where('organization_id', $organization->id) @@ -82,6 +85,13 @@ class QueueController extends Controller 'canManageQueue' => $canManageQueue, 'canConsult' => $canConsult, 'heroStats' => $heroStats, + 'serviceQueuePanel' => $this->serviceQueues->panel( + $request, + $organization, + $member, + 'clinical', + $request->input('counter_uuid'), + ), ]); } diff --git a/app/Http/Controllers/Care/ServiceQueueController.php b/app/Http/Controllers/Care/ServiceQueueController.php index 8f42e54..eb357dd 100644 --- a/app/Http/Controllers/Care/ServiceQueueController.php +++ b/app/Http/Controllers/Care/ServiceQueueController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers\Care; use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; -use App\Models\Branch; +use App\Services\Care\ServiceQueuePresenter; use App\Services\Queue\QueueClient; use Illuminate\Http\Client\RequestException; use Illuminate\Http\RedirectResponse; @@ -15,40 +15,19 @@ class ServiceQueueController extends Controller { use ScopesToAccount; - public function index(Request $request, QueueClient $queue): View|RedirectResponse + public function index(Request $request): RedirectResponse { $this->authorizeAbility($request, 'service_queues.console'); - $organization = $this->organization($request); - if (! $this->integrationEnabled($organization)) { - return redirect()->route('care.settings')->with('error', 'Enable Ladill Queue integration in settings first.'); - } - - $owner = $this->ownerRef($request); - - try { - $counters = $queue->counters($owner); - } catch (RequestException $e) { - if ($queue->configured() && $e->response?->status() === 403) { - try { - $queue->syncIntegration($owner, true); - $counters = $queue->counters($owner); - } catch (RequestException) { - return redirect()->route('care.settings')->with('error', 'Ladill Queue integration is not enabled. Save settings with Queue integration checked, and enable Care in Queue settings.'); - } - } else { - return redirect()->route('care.settings')->with('error', 'Could not reach Ladill Queue. Check API keys and enable integration in Queue settings.'); - } - } - - return view('care.service-queues.index', compact('counters', 'organization')); + // Natural Queue: counters live on role pages (clinical queue, pharmacy, lab, specialties). + return redirect()->route('care.queue.index'); } public function console(Request $request, string $counterUuid, QueueClient $queue): View|RedirectResponse { $this->authorizeAbility($request, 'service_queues.console'); $organization = $this->organization($request); - $owner = $this->ownerRef($request); + $owner = (string) $organization->owner_ref; if (! $this->integrationEnabled($organization)) { return redirect()->route('care.settings'); @@ -57,7 +36,7 @@ class ServiceQueueController extends Controller try { $state = $queue->console($owner, $counterUuid); } catch (RequestException) { - return redirect()->route('care.service-queues.index')->with('error', 'Counter console unavailable.'); + return redirect()->route('care.queue.index')->with('error', 'Counter console unavailable.'); } return view('care.service-queues.console', [ @@ -70,7 +49,8 @@ class ServiceQueueController extends Controller public function action(Request $request, string $counterUuid, QueueClient $queue): RedirectResponse { $this->authorizeAbility($request, 'service_queues.console'); - $owner = $this->ownerRef($request); + $organization = $this->organization($request); + $owner = (string) $organization->owner_ref; $validated = $request->validate([ 'action' => ['required', 'string'], diff --git a/app/Http/Controllers/Care/SettingsController.php b/app/Http/Controllers/Care/SettingsController.php index f7ce792..b40cd90 100644 --- a/app/Http/Controllers/Care/SettingsController.php +++ b/app/Http/Controllers/Care/SettingsController.php @@ -58,6 +58,7 @@ class SettingsController extends Controller 'hasBranchesFeature' => $plans->hasFeature($organization, 'branches'), 'hasClinicalDevicesFeature' => $plans->hasFeature($organization, 'clinical_devices'), 'canUseQueueIntegration' => $plans->canUseQueueIntegration($organization), + 'canUseSpecialtyModules' => $plans->canUseSpecialtyModules($organization), 'assessmentsEngineEnabled' => $careFeatures->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE), 'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(), 'messagingEmailReady' => $suiteEmailReady || $credential->hasValidBird(), diff --git a/app/Http/Controllers/Care/SettingsModulesController.php b/app/Http/Controllers/Care/SettingsModulesController.php new file mode 100644 index 0000000..c772254 --- /dev/null +++ b/app/Http/Controllers/Care/SettingsModulesController.php @@ -0,0 +1,67 @@ +authorizeAbility($request, 'settings.view'); + $organization = $this->organization($request); + $member = $this->member($request); + $canManage = app(\App\Services\Care\CarePermissions::class)->can($member, 'settings.manage'); + + return view('care.settings.modules', [ + 'organization' => $organization, + 'canManage' => $canManage, + 'canUseSpecialtyModules' => $modules->canManage($organization), + 'catalog' => $modules->catalog(), + 'enabled' => collect($modules->enabledKeys($organization))->flip()->all(), + 'planKey' => $plans->planKey($organization), + ]); + } + + public function update(Request $request, SpecialtyModuleService $modules): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $organization = $this->organization($request); + + if (! $modules->canManage($organization)) { + return redirect() + ->route('care.pro.index') + ->with('upsell', 'Specialty modules are part of Care Pro. Upgrade to activate dentistry, eye care, and other specialty practice surfaces.'); + } + + $keys = array_keys($modules->catalog()); + $desired = []; + foreach ($keys as $key) { + $desired[$key] = $request->boolean("modules.{$key}"); + } + + $result = $modules->sync($organization, $this->ownerRef($request), $desired); + + if ($result['errors'] !== []) { + return back()->with('error', 'Some modules could not be updated: '.implode(' ', $result['errors'])); + } + + $parts = []; + if ($result['activated'] !== []) { + $parts[] = 'Activated '.count($result['activated']).' module(s)'; + } + if ($result['deactivated'] !== []) { + $parts[] = 'Deactivated '.count($result['deactivated']).' module(s)'; + } + + return back()->with('success', $parts !== [] ? implode('. ', $parts).'.' : 'Modules saved.'); + } +} diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php new file mode 100644 index 0000000..c005c94 --- /dev/null +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -0,0 +1,80 @@ +definition($module); + abort_unless($definition, 404); + + $organization = $this->organization($request); + abort_unless($modules->isEnabled($organization, $module), 404); + + $roles = $definition['roles'] ?? []; + $member = $this->member($request); + if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) { + abort(403); + } + + $owner = $this->ownerRef($request); + $branchScope = app(OrganizationResolver::class)->branchScope($member); + + $departments = Department::owned($owner) + ->where('type', $definition['department_type'] ?? 'general') + ->where('is_active', true) + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->with('branch') + ->orderBy('name') + ->get(); + + $departmentIds = $departments->pluck('id')->all(); + + $waiting = Appointment::owned($owner) + ->where('organization_id', $organization->id) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds)) + ->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0')) + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->with(['patient', 'practitioner', 'department']) + ->orderBy('queue_position') + ->orderBy('checked_in_at') + ->limit(25) + ->get(); + + $serviceQueuePanel = $presenter->panel( + $request, + $organization, + $member, + $module, + $request->input('counter_uuid'), + ); + + return view('care.specialty.show', [ + 'organization' => $organization, + 'moduleKey' => $module, + 'definition' => $definition, + 'departments' => $departments, + 'waiting' => $waiting, + 'queueStubs' => $modules->queueStubsFor($organization, $module), + 'serviceQueuePanel' => $serviceQueuePanel, + ]); + } +} diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index 8609acd..4d1286d 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -21,6 +21,7 @@ class CarePermissions 'investigations.request', 'prescriptions.manage', 'lab.results.view', 'assessments.view', 'assessments.capture', 'assessments.manage', 'pathways.manage', + 'service_queues.console', ], 'nurse' => [ 'dashboard.view', 'patients.view', 'appointments.view', @@ -30,10 +31,12 @@ class CarePermissions ], 'lab_technician' => [ 'dashboard.view', 'patients.view', 'lab.view', 'lab.manage', + 'service_queues.console', ], 'pharmacist' => [ 'dashboard.view', 'patients.view', 'prescriptions.view', 'prescriptions.dispense', 'pharmacy.view', 'pharmacy.manage', + 'service_queues.console', ], 'cashier' => [ 'dashboard.view', 'patients.view', 'bills.view', 'bills.manage', 'payments.manage', diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 645e11f..263b598 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -102,6 +102,9 @@ class DemoTenantSeeder $branches = $this->seedBranches($organization, $ownerRef, $volumes); $this->seedStaffMembers($organization, $ownerRef, $plan, $branches); $departments = $this->seedDepartments($branches, $ownerRef, $volumes); + if (in_array($plan, ['pro', 'enterprise'], true)) { + $this->seedSpecialtyModules($organization, $ownerRef); + } $practitioners = $this->seedPractitioners($organization, $branches, $departments, $ownerRef, $volumes); $patients = $this->seedPatients($organization, $branches, $ownerRef, $volumes); $appointmentCount = $this->seedAppointmentsAndClinical( @@ -310,6 +313,11 @@ class DemoTenantSeeder 'plan_expires_at' => now()->addYear()->toIso8601String(), 'billed_branches' => max(1, $billedBranches), 'demo' => true, + // Pro/Enterprise demos: Queue on natural role pages + dentistry specialty module. + 'queue_integration_enabled' => in_array($plan, ['pro', 'enterprise'], true), + 'specialty_modules' => in_array($plan, ['pro', 'enterprise'], true) + ? ['dentistry' => true] + : [], ]); if ($organization) { @@ -445,6 +453,15 @@ class DemoTenantSeeder return $byBranch; } + private function seedSpecialtyModules(Organization $organization, string $ownerRef): void + { + try { + app(SpecialtyModuleService::class)->activate($organization->fresh(), $ownerRef, 'dentistry'); + } catch (\Throwable) { + // Demo seed should not fail if Queue API is unreachable; settings flag remains. + } + } + /** * @param list $branches * @param array> $departments diff --git a/app/Services/Care/PlanService.php b/app/Services/Care/PlanService.php index a8defaf..bb89c4f 100644 --- a/app/Services/Care/PlanService.php +++ b/app/Services/Care/PlanService.php @@ -53,6 +53,12 @@ class PlanService return $this->hasPaidPlan($organization); } + /** Specialty practice modules (dentistry, eye care, …) — Pro/Enterprise expansions. */ + public function canUseSpecialtyModules(Organization $organization): bool + { + return $this->hasFeature($organization, 'specialty_modules'); + } + /** Own payment gateway (Paystack / Flutterwave / Hubtel) for encounter billing requires Pro or Enterprise. */ public function canUsePaymentGateway(Organization $organization): bool { diff --git a/app/Services/Care/ServiceQueuePresenter.php b/app/Services/Care/ServiceQueuePresenter.php new file mode 100644 index 0000000..2389ab9 --- /dev/null +++ b/app/Services/Care/ServiceQueuePresenter.php @@ -0,0 +1,249 @@ +> */ + protected array $contextKeywords = [ + 'clinical' => ['reception', 'consult', 'outpatient', 'general', 'doctor', 'nurse', 'triage', 'waiting'], + 'pharmacy' => ['pharm', 'dispens', 'drug', 'rx'], + 'laboratory' => ['lab', 'sample', 'patholog', 'investig'], + 'dentistry' => ['dent', 'dental', 'oral'], + 'ophthalmology' => ['eye', 'ophthal', 'optom'], + 'physiotherapy' => ['physio', 'rehab', 'therapy'], + 'maternity' => ['matern', 'antenatal', 'obstetric'], + 'radiology' => ['radio', 'imaging', 'x-ray', 'xray', 'ultrasound', 'ct', 'mri'], + ]; + + public function __construct( + protected QueueClient $queue, + protected CarePermissions $permissions, + protected PlanService $plans, + protected OrganizationResolver $resolver, + ) {} + + /** + * @return array{ + * enabled: bool, + * counters: list>, + * counter_uuid: ?string, + * state: ?array, + * stubs: list>, + * error: ?string, + * context: string + * }|null + */ + public function panel( + Request $request, + Organization $organization, + ?Member $member, + string $context = 'clinical', + ?string $preferredCounterUuid = null, + ): ?array { + if (! $this->shouldShow($organization, $member)) { + return null; + } + + $owner = (string) $organization->owner_ref; + $branchScope = $this->resolver->branchScope($member); + $branchName = null; + if ($branchScope) { + $branchName = Branch::owned($owner)->where('id', $branchScope)->value('name'); + } + + $stubs = $this->stubsForContext($organization, $context, $branchScope); + + if (! $this->queue->configured()) { + return [ + 'enabled' => true, + 'counters' => [], + 'counter_uuid' => null, + 'state' => null, + 'stubs' => $stubs, + 'error' => 'Ladill Queue API is not configured on this Care instance.', + 'context' => $context, + ]; + } + + try { + $counters = $this->queue->counters($owner); + } catch (RequestException $e) { + if ($e->response?->status() === 403) { + try { + $this->queue->syncIntegration($owner, true); + $counters = $this->queue->counters($owner); + } catch (RequestException) { + return [ + 'enabled' => true, + 'counters' => [], + 'counter_uuid' => null, + 'state' => null, + 'stubs' => $stubs, + 'error' => 'Queue integration is not linked. Re-save Settings with Queue enabled.', + 'context' => $context, + ]; + } + } else { + return [ + 'enabled' => true, + 'counters' => [], + 'counter_uuid' => null, + 'state' => null, + 'stubs' => $stubs, + 'error' => 'Could not reach Ladill Queue.', + 'context' => $context, + ]; + } + } + + $counters = $this->filterCounters($counters, $branchName, $context); + $counterUuid = $preferredCounterUuid + ?: (string) ($counters[0]['uuid'] ?? ''); + + if ($counterUuid !== '' && ! collect($counters)->contains(fn ($c) => ($c['uuid'] ?? '') === $counterUuid)) { + $counterUuid = (string) ($counters[0]['uuid'] ?? ''); + } + + $state = null; + if ($counterUuid !== '') { + try { + $state = $this->queue->console($owner, $counterUuid); + } catch (RequestException) { + return [ + 'enabled' => true, + 'counters' => $counters, + 'counter_uuid' => $counterUuid, + 'state' => null, + 'stubs' => $stubs, + 'error' => 'Counter console unavailable.', + 'context' => $context, + ]; + } + } + + return [ + 'enabled' => true, + 'counters' => $counters, + 'counter_uuid' => $counterUuid !== '' ? $counterUuid : null, + 'state' => $state, + 'stubs' => $stubs, + 'error' => null, + 'context' => $context, + ]; + } + + public function shouldShow(Organization $organization, ?Member $member): bool + { + if (! $member) { + return false; + } + + if (! $this->plans->canUseQueueIntegration($organization)) { + return false; + } + + if (! data_get($organization->settings, 'queue_integration_enabled')) { + return false; + } + + return $this->permissions->can($member, 'service_queues.console'); + } + + /** + * @param list> $counters + * @return list> + */ + protected function filterCounters(array $counters, ?string $branchName, string $context): array + { + $keywords = $this->keywordsFor($context); + + $filtered = collect($counters); + + if ($branchName) { + $branchFiltered = $filtered->filter(function ($counter) use ($branchName) { + $counterBranch = (string) ($counter['branch'] ?? ''); + + return $counterBranch === '' || strcasecmp($counterBranch, $branchName) === 0; + }); + if ($branchFiltered->isNotEmpty()) { + $filtered = $branchFiltered; + } + } + + if ($keywords !== []) { + $keywordFiltered = $filtered->filter(function ($counter) use ($keywords) { + $haystack = strtolower(implode(' ', [ + (string) ($counter['name'] ?? ''), + collect($counter['queues'] ?? [])->pluck('name')->implode(' '), + ])); + + foreach ($keywords as $keyword) { + if ($keyword !== '' && str_contains($haystack, $keyword)) { + return true; + } + } + + return false; + }); + + // Fall back to branch-scoped counters when no keyword match (demo / generic counters). + if ($keywordFiltered->isNotEmpty()) { + $filtered = $keywordFiltered; + } + } + + return $filtered->values()->all(); + } + + /** + * @return list + */ + protected function keywordsFor(string $context): array + { + if (isset($this->contextKeywords[$context])) { + return $this->contextKeywords[$context]; + } + + $module = config("care.specialty_modules.{$context}.queue_keywords"); + + return is_array($module) ? $module : []; + } + + /** + * @return list> + */ + protected function stubsForContext(Organization $organization, string $context, ?int $branchId): array + { + if (! array_key_exists($context, config('care.specialty_modules', []))) { + return []; + } + + $queues = data_get($organization->settings, "specialty_module_provisioning.{$context}.queues", []); + if (! is_array($queues)) { + return []; + } + + return array_values(array_filter($queues, function ($queue) use ($branchId) { + if (! ($queue['active'] ?? false)) { + return false; + } + if ($branchId !== null && (int) ($queue['branch_id'] ?? 0) !== $branchId) { + return false; + } + + return true; + })); + } +} diff --git a/app/Services/Care/SpecialtyModuleService.php b/app/Services/Care/SpecialtyModuleService.php new file mode 100644 index 0000000..565155a --- /dev/null +++ b/app/Services/Care/SpecialtyModuleService.php @@ -0,0 +1,291 @@ +> + */ + public function catalog(): array + { + return config('care.specialty_modules', []); + } + + public function definition(string $key): ?array + { + $catalog = $this->catalog(); + + return $catalog[$key] ?? null; + } + + public function isEnabled(Organization $organization, string $key): bool + { + if (! $this->definition($key)) { + return false; + } + + return (bool) data_get($organization->settings, "specialty_modules.{$key}", false); + } + + /** + * @return list + */ + public function enabledKeys(Organization $organization): array + { + $enabled = []; + foreach (array_keys($this->catalog()) as $key) { + if ($this->isEnabled($organization, $key)) { + $enabled[] = $key; + } + } + + return $enabled; + } + + /** + * @return list}> + */ + public function enabledModules(Organization $organization): array + { + $out = []; + foreach ($this->enabledKeys($organization) as $key) { + $definition = $this->definition($key); + if ($definition) { + $out[] = ['key' => $key, 'definition' => $definition]; + } + } + + return $out; + } + + public function canManage(Organization $organization): bool + { + return $this->plans->hasFeature($organization, 'specialty_modules'); + } + + /** + * @param array $desired module key => enabled + * @return array{activated: list, deactivated: list, errors: list} + */ + public function sync(Organization $organization, string $ownerRef, array $desired): array + { + $activated = []; + $deactivated = []; + $errors = []; + + foreach ($this->catalog() as $key => $definition) { + $want = (bool) ($desired[$key] ?? false); + $have = $this->isEnabled($organization, $key); + + if ($want === $have) { + continue; + } + + try { + if ($want) { + $this->activate($organization, $ownerRef, $key); + $activated[] = $key; + } else { + $this->deactivate($organization, $ownerRef, $key); + $deactivated[] = $key; + } + $organization->refresh(); + } catch (\Throwable $e) { + Log::warning('specialty_module.sync_failed', [ + 'key' => $key, + 'want' => $want, + 'message' => $e->getMessage(), + ]); + $errors[] = ($definition['label'] ?? $key).': '.$e->getMessage(); + } + } + + return compact('activated', 'deactivated', 'errors'); + } + + public function activate(Organization $organization, string $ownerRef, string $key): void + { + $definition = $this->definition($key); + if (! $definition) { + throw new \InvalidArgumentException("Unknown specialty module [{$key}]."); + } + + if (! $this->canManage($organization)) { + throw new \RuntimeException('Specialty modules require Care Pro or Enterprise.'); + } + + $settings = $organization->settings ?? []; + $modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : []; + $modules[$key] = true; + $settings['specialty_modules'] = $modules; + + $departments = $this->provisionDepartments($organization, $ownerRef, $definition); + $queueStubs = $this->provisionQueueStubs($organization, $ownerRef, $key, $definition); + + $provisioning = is_array($settings['specialty_module_provisioning'] ?? null) + ? $settings['specialty_module_provisioning'] + : []; + $provisioning[$key] = [ + 'active' => true, + 'department_ids' => $departments, + 'queues' => $queueStubs, + 'activated_at' => now()->toIso8601String(), + ]; + $settings['specialty_module_provisioning'] = $provisioning; + + $organization->update(['settings' => $settings]); + + if (data_get($organization->fresh()->settings, 'queue_integration_enabled') && $this->queue->configured()) { + $this->queue->syncSpecialtyQueueStubs($ownerRef, $queueStubs); + } + } + + public function deactivate(Organization $organization, string $ownerRef, string $key): void + { + if (! $this->definition($key)) { + throw new \InvalidArgumentException("Unknown specialty module [{$key}]."); + } + + $settings = $organization->settings ?? []; + $modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : []; + $modules[$key] = false; + $settings['specialty_modules'] = $modules; + + $provisioning = is_array($settings['specialty_module_provisioning'] ?? null) + ? $settings['specialty_module_provisioning'] + : []; + $record = is_array($provisioning[$key] ?? null) ? $provisioning[$key] : []; + + $departmentIds = $record['department_ids'] ?? []; + if (is_array($departmentIds) && $departmentIds !== []) { + Department::owned($ownerRef) + ->whereIn('id', $departmentIds) + ->update(['is_active' => false]); + } + + $queues = is_array($record['queues'] ?? null) ? $record['queues'] : []; + foreach ($queues as $i => $queue) { + $queues[$i]['active'] = false; + } + + $provisioning[$key] = array_merge($record, [ + 'active' => false, + 'queues' => $queues, + 'deactivated_at' => now()->toIso8601String(), + ]); + $settings['specialty_module_provisioning'] = $provisioning; + + $organization->update(['settings' => $settings]); + } + + /** + * @param array $definition + * @return list + */ + protected function provisionDepartments(Organization $organization, string $ownerRef, array $definition): array + { + $type = (string) ($definition['department_type'] ?? 'general'); + $name = (string) ($definition['department_name'] ?? $definition['label'] ?? 'Specialty'); + + $branches = Branch::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->get(); + + $ids = []; + foreach ($branches as $branch) { + $department = Department::withTrashed() + ->owned($ownerRef) + ->where('branch_id', $branch->id) + ->where('type', $type) + ->first(); + + if ($department) { + if ($department->trashed()) { + $department->restore(); + } + $department->update([ + 'name' => $name, + 'is_active' => true, + ]); + } else { + $department = Department::create([ + 'owner_ref' => $ownerRef, + 'branch_id' => $branch->id, + 'name' => $name, + 'type' => $type, + 'is_active' => true, + ]); + } + + $ids[] = (int) $department->id; + } + + return $ids; + } + + /** + * Care-side queue stubs (branch-aware). Full counter creation lives in Queue admin; + * stubs document intended queues and feed embedded panels / future API sync. + * + * @param array $definition + * @return list> + */ + protected function provisionQueueStubs( + Organization $organization, + string $ownerRef, + string $key, + array $definition, + ): array { + $branches = Branch::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->get(); + + $stubs = []; + foreach ($branches as $branch) { + $stubs[] = [ + 'module' => $key, + 'branch_id' => $branch->id, + 'branch_name' => $branch->name, + 'name' => (string) ($definition['queue_name'] ?? $definition['label']), + 'prefix' => (string) ($definition['queue_prefix'] ?? strtoupper(substr($key, 0, 3))), + 'active' => true, + 'synced' => false, + ]; + } + + return $stubs; + } + + /** + * @return list> + */ + public function queueStubsFor(Organization $organization, string $key): array + { + $queues = data_get($organization->settings, "specialty_module_provisioning.{$key}.queues", []); + + return is_array($queues) ? array_values(array_filter($queues, fn ($q) => (bool) ($q['active'] ?? false))) : []; + } +} diff --git a/app/Services/Queue/QueueClient.php b/app/Services/Queue/QueueClient.php index 0a274bd..3ba0398 100644 --- a/app/Services/Queue/QueueClient.php +++ b/app/Services/Queue/QueueClient.php @@ -122,4 +122,35 @@ class QueueClient return (array) ($response->json('data') ?? []); } + + /** + * Best-effort specialty queue sync. Care integration API is read/console-only today, + * so stubs remain Care-side until Queue exposes create-queue for service tokens. + * + * @param list> $stubs + * @return list> + */ + public function syncSpecialtyQueueStubs(string $owner, array $stubs): array + { + if (! $this->configured() || $stubs === []) { + return $stubs; + } + + try { + $existing = collect($this->counters($owner)) + ->flatMap(fn ($c) => $c['queues'] ?? []) + ->map(fn ($q) => strtolower((string) ($q['name'] ?? ''))) + ->filter() + ->all(); + } catch (\Throwable) { + return $stubs; + } + + foreach ($stubs as $i => $stub) { + $name = strtolower((string) ($stub['name'] ?? '')); + $stubs[$i]['synced'] = $name !== '' && in_array($name, $existing, true); + } + + return $stubs; + } } diff --git a/config/care.php b/config/care.php index 596e6b6..108834f 100644 --- a/config/care.php +++ b/config/care.php @@ -23,9 +23,73 @@ return [ 'pharmacy' => 'Pharmacy', 'maternity' => 'Maternity', 'dental' => 'Dental', + 'ophthalmology' => 'Ophthalmology / Eye care', 'physiotherapy' => 'Physiotherapy', ], + /* + | Specialty practice modules (Settings → Modules). + | Pro-gated via plans.*.features specialty_modules — expansions beyond core + | outpatient/lab/pharmacy belong on paid plans, matching queue_integration. + */ + 'specialty_modules' => [ + 'dentistry' => [ + 'label' => 'Dentistry', + 'description' => 'Dental chairs, oral consults, and dentistry waiting lists.', + 'department_type' => 'dental', + 'department_name' => 'Dentistry', + 'queue_name' => 'Dentistry', + 'queue_prefix' => 'DEN', + 'nav_label' => 'Dentistry', + 'queue_keywords' => ['dent', 'dental', 'oral'], + 'roles' => ['doctor', 'nurse', 'receptionist', 'hospital_admin', 'super_admin'], + ], + 'ophthalmology' => [ + 'label' => 'Eye care', + 'description' => 'Ophthalmology and optometry clinics with dedicated queues.', + 'department_type' => 'ophthalmology', + 'department_name' => 'Eye care', + 'queue_name' => 'Eye care', + 'queue_prefix' => 'EYE', + 'nav_label' => 'Eye care', + 'queue_keywords' => ['eye', 'ophthal', 'optom'], + 'roles' => ['doctor', 'nurse', 'receptionist', 'hospital_admin', 'super_admin'], + ], + 'physiotherapy' => [ + 'label' => 'Physiotherapy', + 'description' => 'Rehab sessions and physiotherapy treatment queues.', + 'department_type' => 'physiotherapy', + 'department_name' => 'Physiotherapy', + 'queue_name' => 'Physiotherapy', + 'queue_prefix' => 'PHY', + 'nav_label' => 'Physiotherapy', + 'queue_keywords' => ['physio', 'rehab', 'therapy'], + 'roles' => ['doctor', 'nurse', 'receptionist', 'hospital_admin', 'super_admin'], + ], + 'maternity' => [ + 'label' => 'Maternity / Antenatal', + 'description' => 'Antenatal clinics, maternity wards, and related queues.', + 'department_type' => 'maternity', + 'department_name' => 'Maternity', + 'queue_name' => 'Maternity', + 'queue_prefix' => 'MAT', + 'nav_label' => 'Maternity', + 'queue_keywords' => ['matern', 'antenatal', 'obstetric'], + 'roles' => ['doctor', 'nurse', 'receptionist', 'hospital_admin', 'super_admin'], + ], + 'radiology' => [ + 'label' => 'Radiology', + 'description' => 'Imaging requests and radiology service counters.', + 'department_type' => 'radiology', + 'department_name' => 'Radiology', + 'queue_name' => 'Radiology', + 'queue_prefix' => 'RAD', + 'nav_label' => 'Radiology', + 'queue_keywords' => ['radio', 'imaging', 'x-ray', 'xray', 'ultrasound', 'ct', 'mri'], + 'roles' => ['doctor', 'lab_technician', 'receptionist', 'hospital_admin', 'super_admin'], + ], + ], + 'audit_actions' => [ 'organization.created' => 'Organization created', 'organization.updated' => 'Organization updated', @@ -288,6 +352,7 @@ return [ 'billing', 'queue_integration', 'clinical_devices', + 'specialty_modules', ], ], 'enterprise' => [ @@ -303,6 +368,7 @@ return [ 'billing', 'queue_integration', 'clinical_devices', + 'specialty_modules', // KD-18: org-level multi-patient assessment/outcome analytics 'assessment_analytics', ], diff --git a/resources/views/care/appointments/index.blade.php b/resources/views/care/appointments/index.blade.php index 36b100e..37c29c7 100644 --- a/resources/views/care/appointments/index.blade.php +++ b/resources/views/care/appointments/index.blade.php @@ -85,5 +85,7 @@
{{ $appointments->links() }}
+ + @include('care.service-queues._panel') diff --git a/resources/views/care/dashboard.blade.php b/resources/views/care/dashboard.blade.php index ad0ab6d..0f8ba08 100644 --- a/resources/views/care/dashboard.blade.php +++ b/resources/views/care/dashboard.blade.php @@ -192,6 +192,27 @@ @endif + @if (! empty($serviceQueuePanel['enabled'])) +
+ @include('care.service-queues._panel') +
+ @endif + + @if (! empty($specialtyModules)) +
+

Specialty modules

+ +
+ @endif + @if (count($organizationCards) > 0) @php $orgCols = match (count($organizationCards)) { diff --git a/resources/views/care/lab/queue/index.blade.php b/resources/views/care/lab/queue/index.blade.php index 9184dd8..fb917f9 100644 --- a/resources/views/care/lab/queue/index.blade.php +++ b/resources/views/care/lab/queue/index.blade.php @@ -42,4 +42,8 @@

Queue is empty.

@endforelse + +
+ @include('care.service-queues._panel') +
diff --git a/resources/views/care/prescriptions/queue.blade.php b/resources/views/care/prescriptions/queue.blade.php index 6b6fd70..708d0d8 100644 --- a/resources/views/care/prescriptions/queue.blade.php +++ b/resources/views/care/prescriptions/queue.blade.php @@ -56,4 +56,8 @@

No prescriptions in queue.

@endforelse + +
+ @include('care.service-queues._panel') +
diff --git a/resources/views/care/queue/index.blade.php b/resources/views/care/queue/index.blade.php index 5123ef8..045a2b0 100644 --- a/resources/views/care/queue/index.blade.php +++ b/resources/views/care/queue/index.blade.php @@ -78,5 +78,7 @@ + + @include('care.service-queues._panel') diff --git a/resources/views/care/service-queues/_panel.blade.php b/resources/views/care/service-queues/_panel.blade.php new file mode 100644 index 0000000..010b404 --- /dev/null +++ b/resources/views/care/service-queues/_panel.blade.php @@ -0,0 +1,145 @@ +{{-- Compact Ladill Queue panel for role-native pages (not a standalone Service Queues nav). --}} +@php + $panel = $serviceQueuePanel ?? null; +@endphp +@if (! empty($panel['enabled'])) + @php + $counterUuid = $panel['counter_uuid'] ?? null; + $state = $panel['state'] ?? null; + $counters = $panel['counters'] ?? []; + $current = $state['current_ticket'] ?? null; + $queues = $state['queues'] ?? []; + $waiting = $state['waiting_by_queue'] ?? []; + $counter = $state['counter'] ?? []; + $stubs = $panel['stubs'] ?? []; + $statusLabel = match ($current['status'] ?? null) { + 'called' => 'Called', + 'serving' => 'Serving', + 'on_hold' => 'On hold', + default => $current['status'] ?? null, + }; + @endphp + +
+
+
+

Service counter

+

+ {{ $counter['name'] ?? 'Ladill Queue' }} + @if (! empty($counter['branch'])) + · {{ $counter['branch'] }} + @endif +

+
+
+ @if (count($counters) > 1 && $counterUuid) +
+ @foreach (request()->except('counter_uuid') as $key => $value) + @if (is_scalar($value)) + + @endif + @endforeach + +
+ @endif + @if ($counterUuid) + Full console + @endif +
+
+ +
+ @if (! empty($panel['error'])) +

{{ $panel['error'] }}

+ @endif + + @if ($current && $counterUuid) +
+
+

Now serving

+

{{ $current['ticket_number'] }}

+

{{ $current['customer_name'] ?? 'Walk-in' }} · {{ $current['queue']['name'] ?? '' }}

+
+ @if ($statusLabel) + {{ $statusLabel }} + @endif +
+
+ @foreach ([ + 'start' => 'Start', + 'complete' => 'Complete', + 'recall' => 'Recall', + 'skip' => 'Skip', + ] as $action => $label) +
+ @csrf + + + +
+ @endforeach +
+ @elseif ($counterUuid) +

No active ticket. Call the next patient from a queue below.

+ @elseif (count($stubs)) +
+

Specialty queues ready

+
    + @foreach ($stubs as $stub) +
  • + {{ $stub['name'] ?? 'Queue' }} + @if (! empty($stub['branch_name'])) · {{ $stub['branch_name'] }} @endif + @if (! empty($stub['prefix'])) + ({{ $stub['prefix'] }}) + @endif + @if (! empty($stub['synced'])) + · linked + @else + · create in Queue admin if missing + @endif +
  • + @endforeach +
+
+ @elseif (! $panel['error']) +

No counters found for this branch. Create counters in Queue admin, then refresh.

+ @endif + + @if ($counterUuid && count($queues)) +
+ @foreach ($queues as $queue) + @php $waitingTickets = $waiting[$queue['uuid']] ?? []; @endphp +
+
+
+

{{ $queue['name'] }}

+

{{ count($waitingTickets) }} waiting

+
+
+ @csrf + + + +
+
+ @if (count($waitingTickets)) +
    + @foreach (array_slice($waitingTickets, 0, 3) as $ticket) +
  • + {{ $ticket['ticket_number'] }} + {{ $ticket['customer_name'] ?? 'Walk-in' }} +
  • + @endforeach +
+ @endif +
+ @endforeach +
+ @endif +
+
+@endif diff --git a/resources/views/care/service-queues/console.blade.php b/resources/views/care/service-queues/console.blade.php index 9b284de..df2fb98 100644 --- a/resources/views/care/service-queues/console.blade.php +++ b/resources/views/care/service-queues/console.blade.php @@ -18,7 +18,7 @@
- ← Service queues + ← Patient queue

{{ $counter['name'] ?? 'Counter' }}

{{ $counter['branch'] ?? '' }} · Ladill Queue console

diff --git a/resources/views/care/settings/edit.blade.php b/resources/views/care/settings/edit.blade.php index e66f25b..92496a3 100644 --- a/resources/views/care/settings/edit.blade.php +++ b/resources/views/care/settings/edit.blade.php @@ -101,6 +101,19 @@ @endif +
  • + + + Modules + @if (empty($canUseSpecialtyModules)) + Pro + @endif + Dentistry, eye care, physiotherapy, maternity, radiology + + + +
  • @endif @@ -194,7 +207,7 @@
    - + @if (! empty($canUseQueueIntegration))