diff --git a/.cursor/rules/specialty-modules.mdc b/.cursor/rules/specialty-modules.mdc index 7b29ab5..4cb754c 100644 --- a/.cursor/rules/specialty-modules.mdc +++ b/.cursor/rules/specialty-modules.mdc @@ -21,7 +21,7 @@ Full standard: `docs/specialty-module-design-standard.md`. ## Current reality -Today specialties are a thin layer and clinical call-next still adapts to Ladill Queue via `QueueClient`. Program direction: replace that with the Care Queue Engine, then build the shared specialty shell on it. Do **not** deepen per-specialty UIs on the remote Queue adapter. +Specialty modules use the **shared specialty shell** on the Care Queue Engine (`SpecialtyShellService`, Overview / Visits / History / Billing / Workspace). Stage maps and service catalogs live in `config/care_specialty_shell.php`. Deepen clinical forms per module in Phase 3 — do **not** invent one-off layouts. ## When adding or deepening a module diff --git a/app/Http/Controllers/Care/PatientController.php b/app/Http/Controllers/Care/PatientController.php index 33cabee..09d2573 100644 --- a/app/Http/Controllers/Care/PatientController.php +++ b/app/Http/Controllers/Care/PatientController.php @@ -217,6 +217,9 @@ class PatientController extends Controller 'recentAssessments' => $recentAssessments, 'latestUniversalIntake' => $latestUniversalIntake, 'assessmentStatuses' => config('care.assessment_statuses'), + 'patientHeaderMeta' => app(\App\Services\Care\SpecialtyShellService::class)->patientHeaderMeta($patient), + 'patientTimeline' => app(\App\Services\Care\SpecialtyShellService::class)->patientTimeline($patient), + 'currency' => strtoupper((string) config('care.billing.currency', 'GHS')), ])); } diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 35ce821..84b73cb 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -2,13 +2,15 @@ namespace App\Http\Controllers\Care; -use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; +use App\Http\Controllers\Controller; use App\Models\Appointment; use App\Models\Department; +use App\Models\Visit; use App\Services\Care\CareQueueBridge; use App\Services\Care\OrganizationResolver; use App\Services\Care\SpecialtyModuleService; +use App\Services\Care\SpecialtyShellService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -21,68 +23,86 @@ class SpecialtyModuleController extends Controller Request $request, string $module, SpecialtyModuleService $modules, + SpecialtyShellService $shell, CareQueueBridge $queueBridge, ): View { + return $this->renderShell($request, $module, 'overview', $modules, $shell, $queueBridge); + } + + public function visits( + Request $request, + string $module, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + CareQueueBridge $queueBridge, + ): View { + return $this->renderShell($request, $module, 'visits', $modules, $shell, $queueBridge); + } + + public function history( + Request $request, + string $module, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + CareQueueBridge $queueBridge, + ): View { + return $this->renderShell($request, $module, 'history', $modules, $shell, $queueBridge); + } + + public function billing( + Request $request, + string $module, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + CareQueueBridge $queueBridge, + ): View { + return $this->renderShell($request, $module, 'billing', $modules, $shell, $queueBridge); + } + + public function workspace( + Request $request, + string $module, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + CareQueueBridge $queueBridge, + ?Visit $visit = null, + ): View { + return $this->renderShell($request, $module, 'workspace', $modules, $shell, $queueBridge, $visit); + } + + public function addService( + Request $request, + string $module, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + ): RedirectResponse { $definition = $modules->definition($module); abort_unless($definition, 404); $organization = $this->organization($request); $member = $this->member($request); abort_unless($modules->memberCanAccess($organization, $member, $module), 403); + abort_unless($visit->organization_id === $organization->id, 404); - $owner = $this->ownerRef($request); - $resolver = app(OrganizationResolver::class); - $branchScope = $resolver->branchScope($member); - $practitionerScope = $resolver->practitionerScope($organization, $member); + $validated = $request->validate([ + 'service_code' => ['required', 'string', 'max:64'], + ]); - $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)) - ->when( - $practitionerScope !== null, - fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]), - ) - ->with(['patient', 'practitioner', 'department']) - ->orderBy('queue_position') - ->orderBy('checked_in_at') - ->limit(25) - ->get(); - - $branchId = (int) ($branchScope ?: $departments->first()?->branch_id); - if ($practitionerScope !== null && $practitionerScope !== []) { - $pracBranch = (int) \App\Models\Practitioner::owned($owner) - ->whereKey($practitionerScope[0]) - ->value('branch_id'); - if ($pracBranch > 0) { - $branchId = $pracBranch; - } + try { + $bill = $shell->addCatalogServiceToVisit( + $organization, + $visit, + $module, + $validated['service_code'], + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\Throwable $e) { + return back()->with('error', $e->getMessage()); } - return view('care.specialty.show', [ - 'organization' => $organization, - 'moduleKey' => $module, - 'definition' => $definition, - 'departments' => $departments, - 'waiting' => $waiting, - 'queueStubs' => $modules->queueStubsFor($organization, $module), - 'branchId' => $branchId, - 'queueIntegration' => $branchId - ? $queueBridge->listMeta($organization, $module, $branchId) - : ['enabled' => false], - ]); + return back()->with('success', 'Added to invoice '.$bill->invoice_number.'.'); } public function callNext( @@ -126,4 +146,125 @@ class SpecialtyModuleController extends Controller return back()->with('success', 'Called '.$ticket['ticket_number'].'.'); } + + protected function renderShell( + Request $request, + string $module, + string $section, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + CareQueueBridge $queueBridge, + ?Visit $visit = null, + ): View { + $definition = $modules->definition($module); + abort_unless($definition, 404); + + $organization = $this->organization($request); + $member = $this->member($request); + abort_unless($modules->memberCanAccess($organization, $member, $module), 403); + + $owner = $this->ownerRef($request); + $resolver = app(OrganizationResolver::class); + $branchScope = $resolver->branchScope($member); + $practitionerScope = $resolver->practitionerScope($organization, $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)) + ->when( + $practitionerScope !== null, + fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]), + ) + ->with(['patient', 'practitioner', 'department', 'visit']) + ->orderBy('queue_position') + ->orderBy('checked_in_at') + ->limit(25) + ->get(); + + $branchId = (int) ($branchScope ?: $departments->first()?->branch_id); + if ($practitionerScope !== null && $practitionerScope !== []) { + $pracBranch = (int) \App\Models\Practitioner::owned($owner) + ->whereKey($practitionerScope[0]) + ->value('branch_id'); + if ($pracBranch > 0) { + $branchId = $pracBranch; + } + } + + $shellDefinition = $shell->definition($module); + $kpis = $shell->kpis($organization, $module, $owner, $branchScope, $practitionerScope); + $openVisits = $shell->openVisits($organization, $module, $owner, $branchScope, $practitionerScope); + $visitHistory = $section === 'history' + ? $shell->visitHistory($organization, $module, $owner, $branchScope, $practitionerScope) + : collect(); + $services = $shell->provisionedServices($organization, $module); + + $workspaceVisit = null; + $patientHeader = null; + $timeline = []; + $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); + + if ($visit) { + abort_unless($visit->organization_id === $organization->id, 404); + $visit->load(['patient.allergies', 'patient.emergencyContacts', 'appointment.practitioner', 'bill.lineItems']); + $workspaceVisit = $visit; + if ($visit->patient) { + $patientHeader = array_merge( + ['patient' => $visit->patient, 'visit' => $visit, 'appointment' => $visit->appointment], + $shell->patientHeaderMeta($visit->patient), + ); + $timeline = $shell->patientTimeline($visit->patient); + } + } elseif ($section === 'workspace') { + $first = $openVisits->first(); + if ($first?->patient) { + $workspaceVisit = $first; + $patientHeader = array_merge( + ['patient' => $first->patient, 'visit' => $first, 'appointment' => $first->appointment], + $shell->patientHeaderMeta($first->patient), + ); + $timeline = $shell->patientTimeline($first->patient); + } + } + + return view('care.specialty.shell', [ + 'organization' => $organization, + 'moduleKey' => $module, + 'definition' => $shellDefinition, + 'section' => $section, + 'navItems' => $shell->navItems($module), + 'departments' => $departments, + 'waiting' => $waiting, + 'openVisits' => $openVisits, + 'visitHistory' => $visitHistory, + 'kpis' => $kpis, + 'stages' => $shell->stages($module), + 'services' => $services, + 'workspaceTabs' => $shell->workspaceTabs($module), + 'actions' => $shell->actions($module), + 'activeTab' => $activeTab, + 'workspaceVisit' => $workspaceVisit, + 'patientHeader' => $patientHeader, + 'timeline' => $timeline, + 'queueStubs' => $modules->queueStubsFor($organization, $module), + 'branchId' => $branchId, + 'queueIntegration' => $branchId + ? $queueBridge->listMeta($organization, $module, $branchId) + : ['enabled' => false], + 'currency' => strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS'))), + ]); + } } diff --git a/app/Services/Care/SpecialtyModuleService.php b/app/Services/Care/SpecialtyModuleService.php index df7856f..6957aeb 100644 --- a/app/Services/Care/SpecialtyModuleService.php +++ b/app/Services/Care/SpecialtyModuleService.php @@ -320,6 +320,16 @@ class SpecialtyModuleService $organization->update(['settings' => $settings]); + // Seed billable specialty services into provisioning (Billing Engine catalog). + try { + app(SpecialtyShellService::class)->seedServices($organization->fresh(), $key); + } catch (\Throwable $e) { + Log::warning('specialty_module.service_catalog_seed_failed', [ + 'key' => $key, + 'message' => $e->getMessage(), + ]); + } + $fresh = $organization->fresh(); if (data_get($fresh?->settings, 'queue_integration_enabled')) { if (config('care.queue.driver', 'native') === 'native') { diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php new file mode 100644 index 0000000..f648d8e --- /dev/null +++ b/app/Services/Care/SpecialtyShellService.php @@ -0,0 +1,470 @@ + + */ + public function definition(string $moduleKey): array + { + $base = $this->modules->definition($moduleKey) ?? []; + $shell = config("care_specialty_shell.modules.{$moduleKey}", []); + $defaults = config('care_specialty_shell.defaults', []); + + return array_merge($defaults, $base, is_array($shell) ? $shell : []); + } + + /** + * @return list + */ + public function stages(string $moduleKey): array + { + $stages = $this->definition($moduleKey)['stages'] ?? []; + + return is_array($stages) ? array_values($stages) : []; + } + + /** + * Catalog from config (authoritative template). + * + * @return list + */ + public function catalogServices(string $moduleKey): array + { + $services = $this->definition($moduleKey)['services'] ?? []; + if (! is_array($services)) { + return []; + } + + return array_values(array_map(function ($row) { + return [ + 'code' => (string) ($row['code'] ?? ''), + 'label' => (string) ($row['label'] ?? ''), + 'amount_minor' => (int) ($row['amount_minor'] ?? 0), + 'type' => (string) ($row['type'] ?? 'misc'), + ]; + }, $services)); + } + + /** + * Seeded services stored on the organization when the module was activated. + * + * @return list + */ + public function provisionedServices(Organization $organization, string $moduleKey): array + { + $stored = data_get($organization->settings, "specialty_module_provisioning.{$moduleKey}.services"); + if (is_array($stored) && $stored !== []) { + return array_values($stored); + } + + return $this->catalogServices($moduleKey); + } + + /** + * Persist catalog services onto org settings (idempotent merge by code). + * + * @return list + */ + public function seedServices(Organization $organization, string $moduleKey): array + { + $catalog = $this->catalogServices($moduleKey); + $settings = $organization->settings ?? []; + $provisioning = is_array($settings['specialty_module_provisioning'] ?? null) + ? $settings['specialty_module_provisioning'] + : []; + $record = is_array($provisioning[$moduleKey] ?? null) ? $provisioning[$moduleKey] : []; + $existing = collect(is_array($record['services'] ?? null) ? $record['services'] : []) + ->keyBy(fn ($s) => (string) ($s['code'] ?? '')); + + foreach ($catalog as $service) { + if ($service['code'] === '') { + continue; + } + $prior = $existing->get($service['code'], []); + $existing->put($service['code'], array_merge(is_array($prior) ? $prior : [], $service)); + } + + $merged = $existing->filter(fn ($s, $code) => $code !== '')->values()->all(); + $record['services'] = $merged; + $provisioning[$moduleKey] = $record; + $settings['specialty_module_provisioning'] = $provisioning; + $organization->update(['settings' => $settings]); + + return $merged; + } + + /** + * @return array + */ + public function workspaceTabs(string $moduleKey): array + { + $tabs = $this->definition($moduleKey)['workspace_tabs'] ?? []; + + return is_array($tabs) ? $tabs : []; + } + + /** + * @return array + */ + public function actions(string $moduleKey): array + { + $actions = $this->definition($moduleKey)['actions'] ?? []; + + return is_array($actions) ? $actions : []; + } + + /** + * @return list + */ + public function departmentIds( + Organization $organization, + string $moduleKey, + string $ownerRef, + ?int $branchScope = null, + ): array { + $definition = $this->modules->definition($moduleKey); + if (! $definition) { + return []; + } + + $fromSettings = data_get($organization->settings, "specialty_module_provisioning.{$moduleKey}.department_ids", []); + if (is_array($fromSettings) && $fromSettings !== []) { + $ids = array_map('intval', $fromSettings); + } else { + $ids = Department::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('type', $definition['department_type'] ?? 'general') + ->where('is_active', true) + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + } + + if ($branchScope) { + $ids = Department::owned($ownerRef) + ->whereIn('id', $ids ?: [0]) + ->where('branch_id', $branchScope) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + } + + return $ids; + } + + /** + * @param list|null $practitionerScope + * @return array{ + * waiting: int, + * in_progress: int, + * completed_today: int, + * open_visits: int, + * revenue_today_minor: int, + * stages: list + * } + */ + public function kpis( + Organization $organization, + string $moduleKey, + string $ownerRef, + ?int $branchScope = null, + ?array $practitionerScope = null, + ): array { + $departmentIds = $this->departmentIds($organization, $moduleKey, $ownerRef, $branchScope); + $todayStart = now()->startOfDay(); + + $appointmentBase = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->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)) + ->when( + $practitionerScope !== null, + fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]), + ); + + $waiting = (clone $appointmentBase) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->count(); + + $inProgress = (clone $appointmentBase) + ->where('status', Appointment::STATUS_IN_CONSULTATION) + ->count(); + + $completedToday = (clone $appointmentBase) + ->where('status', Appointment::STATUS_COMPLETED) + ->where('completed_at', '>=', $todayStart) + ->count(); + + $visitIds = (clone $appointmentBase)->whereNotNull('visit_id')->pluck('visit_id'); + + $openVisits = Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->count(); + + $revenueToday = (int) Bill::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds) + ->where('created_at', '>=', $todayStart) + ->whereNotIn('status', [Bill::STATUS_VOID]) + ->sum('amount_paid_minor'); + + $stages = collect($this->stages($moduleKey))->map(function (array $stage) use ($waiting, $inProgress, $completedToday) { + $code = (string) ($stage['code'] ?? ''); + $count = match (true) { + in_array($code, ['waiting', 'arrival', 'request'], true) => $waiting, + in_array($code, ['in_care', 'chair', 'procedure', 'treatment', 'resus', 'crossmatch', 'issue', 'observation'], true) => $inProgress, + in_array($code, ['completed', 'disposition', 'transfusion', 'recovery'], true) => $completedToday, + default => 0, + }; + + return [ + 'code' => $code, + 'label' => (string) ($stage['label'] ?? $code), + 'count' => $count, + ]; + })->all(); + + return [ + 'waiting' => $waiting, + 'in_progress' => $inProgress, + 'completed_today' => $completedToday, + 'open_visits' => $openVisits, + 'revenue_today_minor' => $revenueToday, + 'stages' => $stages, + ]; + } + + /** + * @param list|null $practitionerScope + * @return Collection + */ + public function openVisits( + Organization $organization, + string $moduleKey, + string $ownerRef, + ?int $branchScope = null, + ?array $practitionerScope = null, + int $limit = 40, + ): Collection { + $departmentIds = $this->departmentIds($organization, $moduleKey, $ownerRef, $branchScope); + + return Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->whereHas('appointment', function ($q) use ($departmentIds, $practitionerScope) { + $q->when($departmentIds !== [], fn ($qq) => $qq->whereIn('department_id', $departmentIds)) + ->when($departmentIds === [], fn ($qq) => $qq->whereRaw('1 = 0')) + ->when( + $practitionerScope !== null, + fn ($qq) => $qq->whereIn('practitioner_id', $practitionerScope ?: [0]), + ); + }) + ->with(['patient', 'appointment.practitioner', 'appointment.department', 'bill']) + ->orderByDesc('checked_in_at') + ->limit($limit) + ->get(); + } + + /** + * @param list|null $practitionerScope + * @return Collection + */ + public function visitHistory( + Organization $organization, + string $moduleKey, + string $ownerRef, + ?int $branchScope = null, + ?array $practitionerScope = null, + int $limit = 40, + ): Collection { + $departmentIds = $this->departmentIds($organization, $moduleKey, $ownerRef, $branchScope); + + return Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('status', Visit::STATUS_COMPLETED) + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->whereHas('appointment', function ($q) use ($departmentIds, $practitionerScope) { + $q->when($departmentIds !== [], fn ($qq) => $qq->whereIn('department_id', $departmentIds)) + ->when($departmentIds === [], fn ($qq) => $qq->whereRaw('1 = 0')) + ->when( + $practitionerScope !== null, + fn ($qq) => $qq->whereIn('practitioner_id', $practitionerScope ?: [0]), + ); + }) + ->with(['patient', 'appointment.practitioner', 'appointment.department', 'bill']) + ->orderByDesc('completed_at') + ->limit($limit) + ->get(); + } + + /** + * @return list + */ + public function patientTimeline(Patient $patient, int $limit = 20): array + { + $patient->loadMissing([ + 'visits' => fn ($q) => $q->orderByDesc('checked_in_at')->limit(10), + 'appointments' => fn ($q) => $q->orderByDesc('scheduled_at')->limit(10), + 'bills' => fn ($q) => $q->orderByDesc('created_at')->limit(10), + ]); + + $events = collect(); + + foreach ($patient->visits as $visit) { + if ($visit->checked_in_at) { + $events->push([ + 'at' => $visit->checked_in_at->toIso8601String(), + 'label' => 'Visit opened', + 'detail' => 'Visit '.$visit->uuid, + ]); + } + if ($visit->completed_at) { + $events->push([ + 'at' => $visit->completed_at->toIso8601String(), + 'label' => 'Visit completed', + 'detail' => null, + ]); + } + } + + foreach ($patient->appointments as $appointment) { + if ($appointment->waiting_at) { + $events->push([ + 'at' => $appointment->waiting_at->toIso8601String(), + 'label' => 'Joined waiting list', + 'detail' => $appointment->queue_ticket_number + ? 'Ticket '.$appointment->queue_ticket_number + : null, + ]); + } + if ($appointment->started_at) { + $events->push([ + 'at' => $appointment->started_at->toIso8601String(), + 'label' => 'Consultation started', + 'detail' => $appointment->practitioner?->name, + ]); + } + if ($appointment->completed_at) { + $events->push([ + 'at' => $appointment->completed_at->toIso8601String(), + 'label' => 'Consultation completed', + 'detail' => null, + ]); + } + } + + foreach ($patient->bills as $bill) { + $events->push([ + 'at' => $bill->created_at?->toIso8601String() ?? now()->toIso8601String(), + 'label' => 'Invoice '.$bill->invoice_number, + 'detail' => strtoupper((string) $bill->status), + ]); + } + + return $events + ->sortByDesc('at') + ->take($limit) + ->values() + ->all(); + } + + /** + * @return array{ + * outstanding_minor: int, + * allergies: list, + * insurance_summary: ?string, + * emergency: bool + * } + */ + public function patientHeaderMeta(Patient $patient): array + { + $patient->loadMissing(['allergies', 'insurancePolicies', 'emergencyContacts']); + + $outstanding = (int) Bill::owned($patient->owner_ref) + ->where('patient_id', $patient->id) + ->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL, Bill::STATUS_DRAFT]) + ->sum('balance_minor'); + + $allergies = $patient->allergies->map(fn ($a) => (string) $a->allergen)->filter()->values()->all(); + + $policy = $patient->insurancePolicies->first(); + $insurance = $policy + ? trim(implode(' · ', array_filter([(string) ($policy->provider_name ?? ''), (string) ($policy->policy_number ?? '')]))) + : null; + + return [ + 'outstanding_minor' => $outstanding, + 'allergies' => $allergies, + 'insurance_summary' => $insurance !== '' ? $insurance : null, + 'emergency' => $patient->emergencyContacts->isNotEmpty(), + ]; + } + + public function addCatalogServiceToVisit( + Organization $organization, + Visit $visit, + string $moduleKey, + string $serviceCode, + string $ownerRef, + ?string $actorRef = null, + ): Bill { + $service = collect($this->provisionedServices($organization, $moduleKey)) + ->first(fn ($s) => ($s['code'] ?? '') === $serviceCode); + + if (! $service) { + throw new \InvalidArgumentException("Unknown specialty service [{$serviceCode}]."); + } + + $bill = $this->bills->generateFromVisit($visit, $ownerRef, $actorRef); + + return $this->bills->addManualLineItem($bill, $ownerRef, [ + 'type' => $service['type'] ?? 'misc', + 'description' => $service['label'], + 'quantity' => 1, + 'unit_price_minor' => (int) ($service['amount_minor'] ?? 0), + 'source_type' => 'specialty_service', + 'source_id' => null, + ], $actorRef); + } + + public function navItems(string $moduleKey): array + { + return [ + 'overview' => ['label' => 'Overview', 'route' => 'care.specialty.show'], + 'visits' => ['label' => 'Visits', 'route' => 'care.specialty.visits'], + 'history' => ['label' => 'History', 'route' => 'care.specialty.history'], + 'billing' => ['label' => 'Billing', 'route' => 'care.specialty.billing'], + 'workspace' => ['label' => 'Workspace', 'route' => 'care.specialty.workspace'], + ]; + } + + public function memberCanAccess(Organization $organization, ?Member $member, string $moduleKey): bool + { + return $this->modules->memberCanAccess($organization, $member, $moduleKey); + } +} diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php new file mode 100644 index 0000000..06f3add --- /dev/null +++ b/config/care_specialty_shell.php @@ -0,0 +1,118 @@ +, + * modules: array> + * } + */ +return [ + 'defaults' => [ + 'stages' => [ + ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], + ['code' => 'in_care', 'label' => 'In care', 'queue_point' => 'chair'], + ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], + ], + 'services' => [ + [ + 'code' => 'specialty.consultation', + 'label' => 'Specialty consultation', + 'amount_minor' => (int) env('CARE_CONSULTATION_FEE_MINOR', 5000), + 'type' => 'consultation', + ], + ], + 'workspace_tabs' => [ + 'overview' => 'Overview', + 'clinical_notes' => 'Clinical notes', + 'orders' => 'Orders', + 'billing' => 'Billing', + 'documents' => 'Documents', + ], + 'actions' => [ + 'call_next' => 'Call next patient', + 'start' => 'Start consultation', + 'complete' => 'Complete', + 'order_test' => 'Order test', + 'prescribe' => 'Prescribe', + 'invoice' => 'Generate invoice', + 'discharge' => 'Discharge', + ], + ], + + 'modules' => [ + 'emergency' => [ + 'stages' => [ + ['code' => 'arrival', 'label' => 'Arrival / triage', 'queue_point' => 'waiting'], + ['code' => 'resus', 'label' => 'Resuscitation', 'queue_point' => 'resus'], + ['code' => 'treatment', 'label' => 'Treatment bay', 'queue_point' => 'bay'], + ['code' => 'observation', 'label' => 'Observation', 'queue_point' => 'obs'], + ['code' => 'disposition', 'label' => 'Disposition', 'queue_point' => null], + ], + 'services' => [ + ['code' => 'er.triage', 'label' => 'Emergency triage', 'amount_minor' => 0, 'type' => 'consultation'], + ['code' => 'er.consultation', 'label' => 'Emergency consultation', 'amount_minor' => 8000, 'type' => 'consultation'], + ['code' => 'er.procedure', 'label' => 'Emergency procedure', 'amount_minor' => 15000, 'type' => 'procedure'], + ['code' => 'er.observation', 'label' => 'Observation bed (per hour)', 'amount_minor' => 3000, 'type' => 'misc'], + ], + 'workspace_tabs' => [ + 'overview' => 'Overview', + 'triage' => 'Triage', + 'clinical_notes' => 'Clinical notes', + 'orders' => 'Orders', + 'billing' => 'Billing', + 'documents' => 'Documents', + ], + ], + 'blood_bank' => [ + 'stages' => [ + ['code' => 'request', 'label' => 'Request received', 'queue_point' => 'waiting'], + ['code' => 'crossmatch', 'label' => 'Cross-match', 'queue_point' => 'lab'], + ['code' => 'issue', 'label' => 'Product issue', 'queue_point' => 'issue'], + ['code' => 'transfusion', 'label' => 'Transfusion', 'queue_point' => null], + ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], + ], + 'services' => [ + ['code' => 'bb.crossmatch', 'label' => 'Cross-match', 'amount_minor' => 4000, 'type' => 'lab'], + ['code' => 'bb.whole_blood', 'label' => 'Whole blood unit', 'amount_minor' => 12000, 'type' => 'misc'], + ['code' => 'bb.packed_rbc', 'label' => 'Packed RBC unit', 'amount_minor' => 15000, 'type' => 'misc'], + ['code' => 'bb.platelets', 'label' => 'Platelets unit', 'amount_minor' => 18000, 'type' => 'misc'], + ], + 'workspace_tabs' => [ + 'overview' => 'Overview', + 'requests' => 'Requests', + 'inventory' => 'Inventory', + 'billing' => 'Billing', + 'documents' => 'Documents', + ], + ], + 'dentistry' => [ + 'stages' => [ + ['code' => 'waiting', 'label' => 'Waiting room', 'queue_point' => 'waiting'], + ['code' => 'chair', 'label' => 'Chair assignment', 'queue_point' => 'chair'], + ['code' => 'procedure', 'label' => 'Procedure', 'queue_point' => 'chair'], + ['code' => 'recovery', 'label' => 'Recovery', 'queue_point' => 'recovery'], + ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], + ], + 'services' => [ + ['code' => 'den.consult', 'label' => 'Dental consultation', 'amount_minor' => 5000, 'type' => 'consultation'], + ['code' => 'den.cleaning', 'label' => 'Scaling / polishing', 'amount_minor' => 8000, 'type' => 'procedure'], + ['code' => 'den.fill', 'label' => 'Filling', 'amount_minor' => 12000, 'type' => 'procedure'], + ['code' => 'den.extraction', 'label' => 'Extraction', 'amount_minor' => 15000, 'type' => 'procedure'], + ['code' => 'den.xray', 'label' => 'Dental X-ray', 'amount_minor' => 4000, 'type' => 'imaging'], + ], + 'workspace_tabs' => [ + 'overview' => 'Overview', + 'odontogram' => 'Chart', + 'clinical_notes' => 'Clinical notes', + 'orders' => 'Orders', + 'billing' => 'Billing', + 'documents' => 'Documents', + ], + ], + ], +]; diff --git a/docs/care-queue-engine-and-specialty-plan.md b/docs/care-queue-engine-and-specialty-plan.md index 6becc9e..6e91c99 100644 --- a/docs/care-queue-engine-and-specialty-plan.md +++ b/docs/care-queue-engine-and-specialty-plan.md @@ -1,6 +1,6 @@ # Care Queue Engine + Specialty Modules — Unified Plan -**Status:** Phase 1 in progress +**Status:** Phase 2 complete (shell); Phase 3 next **Date:** 2026-07-18 **Repos:** ladill-care (primary), ladill-queue (healthcare removal) **Related:** `docs/specialty-module-design-standard.md` @@ -12,8 +12,14 @@ - Config: `CARE_QUEUE_DRIVER=native` (default) | `remote` (legacy Ladill Queue HTTP) - Tables: `care_service_queues`, `care_service_points`, `care_queue_tickets` - Service: `App\Services\Care\CareQueueEngine` -- Bridge / provisioner route to native when driver is `native`; PHPUnit sets `remote` so legacy Http::fake tests keep working -- Exit criterion: Care Pro demo issues / call-next / complete with `QUEUE_API_*` unset + +## Phase 2 notes (implementation) + +- Shared shell: Overview / Visits / History / Billing / Workspace + action panel + stage map nav +- `SpecialtyShellService` + `config/care_specialty_shell.php` (Emergency, Blood Bank, Dentistry stage maps & catalogs; defaults for others) +- Patient header + timeline partials reused on specialty workspace and patient chart +- Activate seeds billable services; workspace Billing tab posts into Billing Engine +- Exit: Emergency (+ Dentistry) demonstrate the standard on the in-app queue engine --- diff --git a/docs/specialty-module-design-standard.md b/docs/specialty-module-design-standard.md index 25079a1..1d08ca6 100644 --- a/docs/specialty-module-design-standard.md +++ b/docs/specialty-module-design-standard.md @@ -188,16 +188,22 @@ Use this prompt when implementing a specific specialty: ## Current implementation status (as of 2026-07) -Specialty modules today are a **thin Pro enablement layer**, not the full clinical product shell described above. +Specialty modules share a **specialty shell** on the Care Queue Engine. Per-module clinical forms deepen in Phase 3. | Capability | Status | | --- | --- | | Module catalog + activate/deactivate | Exists (`config/care_specialty_modules.php`, `SpecialtyModuleService`) | -| Department + queue stub provisioning | Exists | -| Specialty Call next + waiting list | Exists (thin show page) | -| Workflow-driven episodes | Missing (use `Visit` as unit) | -| Unified patient timeline component | Partial (patient chart lists only) | -| Standard patient header / specialty shell | Missing | +| Department + queue provisioning | Exists (native Care Queue Engine) | +| Shared specialty shell (Overview / Visits / History / Billing / Workspace) | Exists (`SpecialtyShellService`, `care/specialty/shell.blade.php`) | +| Stage maps + service catalogs | Exists (`config/care_specialty_shell.php` — Emergency, Blood Bank, Dentistry first-class; others inherit defaults) | +| Standard patient header + timeline | Exists (reused on specialty workspace + patient chart) | +| Visit-backed specialty visits | Exists (open visits + history lists) | +| Service catalog → Billing Engine | Exists (seed on activate; add line from workspace Billing tab) | +| Specialty KPIs | Exists (overview strip) | +| Deep clinical forms / order sets / documents | Phase 3 | + +**Program plan:** `docs/care-queue-engine-and-specialty-plan.md` (Phase 2 shell complete; Phase 3 rolls remaining modules onto clinical depth). + | Specialty dashboard KPIs | Missing | | Left nav Overview / Visits / History / Module Tabs | Missing | | Clinical workspace | Missing (falls through to appointments/consultations) | diff --git a/resources/views/care/partials/patient-header.blade.php b/resources/views/care/partials/patient-header.blade.php new file mode 100644 index 0000000..c67ef03 --- /dev/null +++ b/resources/views/care/partials/patient-header.blade.php @@ -0,0 +1,77 @@ +@props([ + 'patient', + 'visit' => null, + 'appointment' => null, + 'outstandingMinor' => 0, + 'allergies' => [], + 'insuranceSummary' => null, + 'emergency' => false, + 'currency' => 'GHS', +]) + +@php + $age = $patient->date_of_birth?->age; + $gender = $patient->gender ? ucfirst((string) $patient->gender) : null; +@endphp + +
+
+
+
+ {{ strtoupper(substr($patient->first_name ?? 'P', 0, 1).substr($patient->last_name ?? '', 0, 1)) }} +
+
+
+

{{ $patient->fullName() }}

+ @if ($emergency) + Emergency contact on file + @endif +
+

+ {{ $patient->patient_number }} + @if ($gender) · {{ $gender }} @endif + @if ($age !== null) · {{ $age }} yrs @endif + @if ($visit) + · Visit {{ \Illuminate\Support\Str::limit($visit->uuid, 8, '') }} + @endif +

+

+ @if ($appointment?->practitioner) + {{ $appointment->practitioner->name }} + @if ($appointment->queue_ticket_number) + · Ticket {{ $appointment->queue_ticket_number }} + @if ($appointment->queue_ticket_status) + ({{ $appointment->queue_ticket_status }}) + @endif + @endif + @else + No assigned clinician + @endif +

+
+
+
+
+

Outstanding

+

+ {{ $currency }} {{ number_format($outstandingMinor / 100, 2) }} +

+
+ @if ($insuranceSummary) +
+

Insurance

+

{{ $insuranceSummary }}

+
+ @endif +
+
+ + @if (! empty($allergies)) +
+ Allergies + @foreach ($allergies as $allergy) + {{ $allergy }} + @endforeach +
+ @endif +
diff --git a/resources/views/care/partials/patient-timeline.blade.php b/resources/views/care/partials/patient-timeline.blade.php new file mode 100644 index 0000000..3f1eee6 --- /dev/null +++ b/resources/views/care/partials/patient-timeline.blade.php @@ -0,0 +1,22 @@ +@props(['events' => []]) + +
+

Patient timeline

+
    + @forelse ($events as $event) +
  1. +
    +

    {{ $event['label'] ?? '' }}

    + @if (! empty($event['detail'])) +

    {{ $event['detail'] }}

    + @endif +
    + +
  2. + @empty +
  3. No timeline events yet.
  4. + @endforelse +
+
diff --git a/resources/views/care/patients/show.blade.php b/resources/views/care/patients/show.blade.php index c4c0ac7..31f9b2c 100644 --- a/resources/views/care/patients/show.blade.php +++ b/resources/views/care/patients/show.blade.php @@ -1,15 +1,17 @@ -
+ @include('care.partials.patient-header', [ + 'patient' => $patient, + 'outstandingMinor' => $patientHeaderMeta['outstanding_minor'] ?? 0, + 'allergies' => $patientHeaderMeta['allergies'] ?? [], + 'insuranceSummary' => $patientHeaderMeta['insurance_summary'] ?? null, + 'emergency' => $patientHeaderMeta['emergency'] ?? false, + 'currency' => $currency ?? 'GHS', + ]) + +
-

{{ $patient->patient_number }}

-

{{ $patient->fullName() }}

-

- {{ $genders[$patient->gender] ?? '—' }} - @if ($patient->date_of_birth) - · {{ $patient->date_of_birth->format('d M Y') }} - ({{ $patient->date_of_birth->age }} yrs) - @endif - @if ($patient->phone) · {{ $patient->phone }} @endif +

+ @if ($patient->phone) {{ $patient->phone }} @endif

@if ($canManage) @@ -192,6 +194,8 @@
+ @include('care.partials.patient-timeline', ['events' => $patientTimeline ?? []]) + @if ($canManage)

Message patient

diff --git a/resources/views/care/specialty/sections/billing.blade.php b/resources/views/care/specialty/sections/billing.blade.php new file mode 100644 index 0000000..9d55bb5 --- /dev/null +++ b/resources/views/care/specialty/sections/billing.blade.php @@ -0,0 +1,41 @@ +
+
+
+

Service catalog

+

Billable services seeded when this module is activated. Invoices use the Billing Engine.

+
+
+ +
+ + + + + + + + + + + @forelse ($services as $service) + + + + + + + @empty + + + + @endforelse + +
CodeServiceTypeAmount
{{ $service['code'] ?? '' }}{{ $service['label'] ?? '' }}{{ $service['type'] ?? 'misc' }} + {{ $currency ?? 'GHS' }} {{ number_format(((int) ($service['amount_minor'] ?? 0)) / 100, 2) }} +
No services provisioned. Re-activate the module to seed the catalog.
+
+ +

+ Add a service to a visit invoice from the specialty workspace Billing tab. +

+
diff --git a/resources/views/care/specialty/sections/history.blade.php b/resources/views/care/specialty/sections/history.blade.php new file mode 100644 index 0000000..0b248e8 --- /dev/null +++ b/resources/views/care/specialty/sections/history.blade.php @@ -0,0 +1,31 @@ +
+

Visit history

+

Completed specialty episodes.

+ +
+ @forelse ($visitHistory as $visit) +
+
+

{{ $visit->patient?->fullName() ?? 'Patient' }}

+

+ {{ $visit->appointment?->department?->name ?? 'Specialty' }} + @if ($visit->completed_at) + · {{ $visit->completed_at->format('d M Y H:i') }} + @endif + @if ($visit->bill) + · Invoice {{ $visit->bill->invoice_number }} + @endif +

+
+
+ @if ($visit->patient) + Chart + @endif + Review +
+
+ @empty +

No completed specialty visits yet.

+ @endforelse +
+
diff --git a/resources/views/care/specialty/sections/overview.blade.php b/resources/views/care/specialty/sections/overview.blade.php new file mode 100644 index 0000000..848f2bc --- /dev/null +++ b/resources/views/care/specialty/sections/overview.blade.php @@ -0,0 +1,76 @@ +{{-- KPI strip --}} +
+ @foreach ([ + ['Waiting', $kpis['waiting'] ?? 0, 'text-amber-700'], + ['In care', $kpis['in_progress'] ?? 0, 'text-sky-700'], + ['Completed today', $kpis['completed_today'] ?? 0, 'text-emerald-700'], + ['Open visits', $kpis['open_visits'] ?? 0, 'text-indigo-700'], + ['Paid today', ($currency ?? 'GHS').' '.number_format(($kpis['revenue_today_minor'] ?? 0) / 100, 2), 'text-slate-800'], + ] as [$label, $value, $color]) +
+

{{ $label }}

+

{{ $value }}

+
+ @endforeach +
+ +@if (! empty($kpis['stages'])) +
+

Workflow stages

+
+ @foreach ($kpis['stages'] as $stage) +
+

{{ $stage['label'] }}

+

{{ $stage['count'] }}

+
+ @endforeach +
+
+@endif + +
+
+

Departments

+
    + @forelse ($departments as $department) +
  • + {{ $department->name }} + {{ $department->branch?->name }} +
  • + @empty +
  • No active departments for this module at your branch.
  • + @endforelse +
+
+ +
+
+

Waiting patients

+ All visits +
+
+ @forelse ($waiting as $appointment) +
+
+ @if (! empty($queueIntegration['enabled']) && $appointment->queue_ticket_number) + @include('care.partials.queue-ticket', [ + 'ticketNumber' => $appointment->queue_ticket_number, + 'ticketStatus' => $appointment->queue_ticket_status, + 'showAssignment' => false, + ]) + @endif +

{{ $appointment->patient?->fullName() }}

+

{{ $appointment->practitioner?->name ?? 'Unassigned' }}

+
+ @if ($appointment->visit) + Open + @else + View + @endif +
+ @empty +

No patients waiting.

+ @endforelse +
+
+
diff --git a/resources/views/care/specialty/sections/visits.blade.php b/resources/views/care/specialty/sections/visits.blade.php new file mode 100644 index 0000000..288b5c4 --- /dev/null +++ b/resources/views/care/specialty/sections/visits.blade.php @@ -0,0 +1,56 @@ +
+
+
+

Open visits

+

Visit-backed episodes for this specialty (not appointment-only).

+
+
+ +
+ @forelse ($openVisits as $visit) +
+
+

{{ $visit->patient?->fullName() ?? 'Patient' }}

+

+ {{ $visit->appointment?->department?->name ?? 'Specialty' }} + · {{ $visit->appointment?->practitioner?->name ?? 'Unassigned' }} + · {{ ucfirst(str_replace('_', ' ', $visit->status)) }} + @if ($visit->checked_in_at) + · {{ $visit->checked_in_at->format('d M H:i') }} + @endif +

+
+ + Workspace + +
+ @empty +

+ No open specialty visits. Check in a patient to this specialty department to create a visit episode. +

+ @endforelse +
+ +
+

Waiting list

+
+ @forelse ($waiting as $appointment) +
+
+

{{ $appointment->patient?->fullName() }}

+

+ @if ($appointment->queue_ticket_number) + {{ $appointment->queue_ticket_number }} · + @endif + {{ $appointment->status }} +

+
+ Appointment +
+ @empty +

Queue is empty.

+ @endforelse +
+
+
diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php new file mode 100644 index 0000000..f1b516b --- /dev/null +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -0,0 +1,128 @@ +@if ($patientHeader) + @include('care.partials.patient-header', [ + 'patient' => $patientHeader['patient'], + 'visit' => $patientHeader['visit'] ?? null, + 'appointment' => $patientHeader['appointment'] ?? null, + 'outstandingMinor' => $patientHeader['outstanding_minor'] ?? 0, + 'allergies' => $patientHeader['allergies'] ?? [], + 'insuranceSummary' => $patientHeader['insurance_summary'] ?? null, + 'emergency' => $patientHeader['emergency'] ?? false, + 'currency' => $currency ?? 'GHS', + ]) +@endif + +@if (! $workspaceVisit) +
+

No open visit selected

+

Open a visit from the Visits list, or call next from the waiting queue.

+ Go to visits +
+@else + {{-- Module tabs --}} + + +
+
+ @if ($activeTab === 'billing') +
+

Add specialty service

+
+ @csrf + + +
+ + @if ($workspaceVisit->bill) +
+

Current invoice {{ $workspaceVisit->bill->invoice_number }}

+
    + @forelse ($workspaceVisit->bill->lineItems as $line) +
  • + {{ $line->description }} + {{ number_format($line->total_minor / 100, 2) }} +
  • + @empty +
  • No line items yet.
  • + @endforelse +
+ Open bill +
+ @endif +
+ @elseif (in_array($activeTab, ['clinical_notes', 'triage', 'odontogram', 'requests', 'inventory'], true)) +
+

{{ $workspaceTabs[$activeTab] ?? 'Clinical' }}

+

+ Structured clinical forms for this tab ship in Phase 3. Use the patient chart and appointment for notes today. +

+ @if ($workspaceVisit->appointment) + Open appointment + @endif +
+ @elseif ($activeTab === 'orders') +
+

Orders

+

Order lab / imaging from the consultation flow. Specialty-specific order sets arrive in Phase 3.

+ Lab requests +
+ @elseif ($activeTab === 'documents') +
+

Documents

+

Consent forms and specialty documents will attach here. Patient attachments remain on the chart.

+ @if ($workspaceVisit->patient) + Patient attachments + @endif +
+ @else +
+

Overview

+
+
+
Visit status
+
{{ ucfirst(str_replace('_', ' ', $workspaceVisit->status)) }}
+
+
+
Checked in
+
{{ $workspaceVisit->checked_in_at?->format('d M Y H:i') ?? '—' }}
+
+
+
Department
+
{{ $workspaceVisit->appointment?->department?->name ?? '—' }}
+
+
+
Queue
+
+ {{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }} + @if ($workspaceVisit->appointment?->queue_ticket_status) + ({{ $workspaceVisit->appointment->queue_ticket_status }}) + @endif +
+
+
+
+ @endif +
+ +
+ @include('care.partials.patient-timeline', ['events' => $timeline ?? []]) +
+
+@endif diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php new file mode 100644 index 0000000..cb8ed34 --- /dev/null +++ b/resources/views/care/specialty/shell.blade.php @@ -0,0 +1,109 @@ + +
+
+
+

Specialty module

+

{{ $definition['label'] ?? $moduleKey }}

+

{{ $definition['description'] ?? '' }}

+
+ Manage modules +
+ +
+ {{-- Left navigation --}} + + + {{-- Main workspace --}} +
+ @if ($section === 'overview') + @include('care.specialty.sections.overview') + @elseif ($section === 'visits') + @include('care.specialty.sections.visits') + @elseif ($section === 'history') + @include('care.specialty.sections.history') + @elseif ($section === 'billing') + @include('care.specialty.sections.billing') + @elseif ($section === 'workspace') + @include('care.specialty.sections.workspace') + @endif +
+ + {{-- Action panel --}} + +
+
+
diff --git a/resources/views/care/specialty/show.blade.php b/resources/views/care/specialty/show.blade.php deleted file mode 100644 index f43ae35..0000000 --- a/resources/views/care/specialty/show.blade.php +++ /dev/null @@ -1,81 +0,0 @@ - -
-
-
-

Specialty module

-

{{ $definition['label'] }}

-

{{ $definition['description'] ?? '' }}

-
- Manage modules -
- -
-
-

Departments

-
    - @forelse ($departments as $department) -
  • - {{ $department->name }} - {{ $department->branch?->name }} -
  • - @empty -
  • No active departments for this module at your branch.
  • - @endforelse -
-
- -
-

Queue stubs

-
    - @forelse ($queueStubs as $stub) -
  • - {{ $stub['name'] ?? 'Queue' }} - @if (! empty($stub['prefix'])) - ({{ $stub['prefix'] }}) - @endif - {{ $stub['branch_name'] ?? '' }} -
  • - @empty -
  • No queue stubs for your branch.
  • - @endforelse -
-
-
- - @include('care.partials.queue-ops', [ - 'queueIntegration' => $queueIntegration ?? null, - 'queueCallNextRoute' => 'care.specialty.call-next', - 'queueCallNextParams' => ['module' => $moduleKey], - 'branchId' => $branchId ?? null, - ]) - -
-
-

Waiting patients

- Appointments -
-
- @forelse ($waiting as $appointment) -
-
- @if (! empty($queueIntegration['enabled']) && $appointment->queue_ticket_number) - @include('care.partials.queue-ticket', [ - 'ticketNumber' => $appointment->queue_ticket_number, - 'ticketStatus' => $appointment->queue_ticket_status, - 'showAssignment' => false, - ]) - @endif -
-

{{ $appointment->patient?->fullName() }}

-

{{ $appointment->department?->name }} · {{ $appointment->practitioner?->name ?? 'Unassigned' }}

-
-
- View -
- @empty -

No patients waiting in this specialty.

- @endforelse -
-
-
-
diff --git a/routes/web.php b/routes/web.php index b625127..a86a8f4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -101,6 +101,11 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/service-queues/console/{counterUuid}', [ServiceQueueController::class, 'console'])->name('care.service-queues.console'); Route::post('/service-queues/console/{counterUuid}/action', [ServiceQueueController::class, 'action'])->name('care.service-queues.action'); + Route::get('/specialty/{module}/visits', [SpecialtyModuleController::class, 'visits'])->name('care.specialty.visits'); + Route::get('/specialty/{module}/history', [SpecialtyModuleController::class, 'history'])->name('care.specialty.history'); + Route::get('/specialty/{module}/billing', [SpecialtyModuleController::class, 'billing'])->name('care.specialty.billing'); + Route::get('/specialty/{module}/workspace/{visit?}', [SpecialtyModuleController::class, 'workspace'])->name('care.specialty.workspace'); + Route::post('/specialty/{module}/workspace/{visit}/services', [SpecialtyModuleController::class, 'addService'])->name('care.specialty.services.add'); Route::get('/specialty/{module}', [SpecialtyModuleController::class, 'show'])->name('care.specialty.show'); Route::post('/specialty/{module}/call-next', [SpecialtyModuleController::class, 'callNext'])->name('care.specialty.call-next'); diff --git a/tests/Feature/CareSpecialtyModulesTest.php b/tests/Feature/CareSpecialtyModulesTest.php index 6dadb54..e393cc1 100644 --- a/tests/Feature/CareSpecialtyModulesTest.php +++ b/tests/Feature/CareSpecialtyModulesTest.php @@ -266,7 +266,9 @@ class CareSpecialtyModulesTest extends TestCase ->get(route('care.specialty.show', 'dentistry')) ->assertOk() ->assertSee('Dentistry') - ->assertSee('Departments'); + ->assertSee('Departments') + ->assertSee('Overview') + ->assertSee('Stage map'); } public function test_unassigned_doctor_cannot_open_specialty_module(): void diff --git a/tests/Feature/CareSpecialtyShellTest.php b/tests/Feature/CareSpecialtyShellTest.php new file mode 100644 index 0000000..41e4f78 --- /dev/null +++ b/tests/Feature/CareSpecialtyShellTest.php @@ -0,0 +1,210 @@ +withoutMiddleware(EnsurePlatformSession::class); + + config(['care.queue.driver' => 'native']); + + $this->owner = User::create([ + 'public_id' => 'shell-owner', + 'name' => 'Owner', + 'email' => 'shell@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Shell Clinic', + 'slug' => 'shell-clinic', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'clinic', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'queue_integration_enabled' => true, + ], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'hospital_admin', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + } + + public function test_activating_emergency_seeds_service_catalog_and_stage_map(): void + { + app(SpecialtyModuleService::class)->activate( + $this->organization, + $this->owner->public_id, + 'emergency', + ); + + $this->organization->refresh(); + $services = data_get($this->organization->settings, 'specialty_module_provisioning.emergency.services'); + $this->assertIsArray($services); + $this->assertNotEmpty($services); + $this->assertTrue(collect($services)->contains(fn ($s) => ($s['code'] ?? '') === 'er.consultation')); + + $shell = app(SpecialtyShellService::class); + $stages = $shell->stages('emergency'); + $this->assertSame('arrival', $stages[0]['code'] ?? null); + $this->assertGreaterThanOrEqual(4, count($stages)); + } + + public function test_specialty_shell_overview_and_sections_render(): void + { + app(SpecialtyModuleService::class)->activate( + $this->organization, + $this->owner->public_id, + 'emergency', + ); + + $this->actingAs($this->owner) + ->get(route('care.specialty.show', 'emergency')) + ->assertOk() + ->assertSee('Emergency') + ->assertSee('Overview') + ->assertSee('Waiting') + ->assertSee('Stage map'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.visits', 'emergency')) + ->assertOk() + ->assertSee('Open visits'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.history', 'emergency')) + ->assertOk() + ->assertSee('Visit history'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.billing', 'emergency')) + ->assertOk() + ->assertSee('Service catalog') + ->assertSee('er.consultation'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', 'emergency')) + ->assertOk(); + } + + public function test_dentistry_shell_has_chair_stage_and_catalog(): void + { + app(SpecialtyModuleService::class)->activate( + $this->organization, + $this->owner->public_id, + 'dentistry', + ); + + $shell = app(SpecialtyShellService::class); + $codes = collect($shell->stages('dentistry'))->pluck('code')->all(); + $this->assertContains('chair', $codes); + $this->assertContains('procedure', $codes); + + $this->actingAs($this->owner) + ->get(route('care.specialty.billing', 'dentistry')) + ->assertOk() + ->assertSee('den.fill'); + } + + public function test_workspace_can_add_catalog_service_to_visit_bill(): void + { + app(SpecialtyModuleService::class)->activate( + $this->organization, + $this->owner->public_id, + 'emergency', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'emergency') + ->firstOrFail(); + + $patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-ER-1', + 'first_name' => 'Ama', + 'last_name' => 'Mensah', + 'gender' => 'female', + 'date_of_birth' => '1992-01-01', + ]); + + $visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'department_id' => $department->id, + 'visit_id' => $visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'scheduled_at' => now(), + 'waiting_at' => now(), + ]); + + $this->actingAs($this->owner) + ->post(route('care.specialty.services.add', ['module' => 'emergency', 'visit' => $visit]), [ + 'service_code' => 'er.consultation', + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('care_bills', [ + 'visit_id' => $visit->id, + 'patient_id' => $patient->id, + ]); + + $bill = Bill::query()->where('visit_id', $visit->id)->first(); + $this->assertNotNull($bill); + $this->assertTrue( + $bill->lineItems()->where('description', 'Emergency consultation')->exists() + ); + } +}