Add shared specialty module shell (Phase 2).
Deploy Ladill Care / deploy (push) Successful in 52s

Overview/Visits/History/Billing/Workspace layout with stage maps, KPIs,
patient header/timeline, and service catalogs seeded into Billing on activate.
Emergency, Blood Bank, and Dentistry ship first-class shell configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 10:01:17 +00:00
co-authored by Cursor
parent 6cf5a24019
commit 0181221fe8
21 changed files with 1587 additions and 153 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ Full standard: `docs/specialty-module-design-standard.md`.
## Current reality ## 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 ## When adding or deepening a module
@@ -217,6 +217,9 @@ class PatientController extends Controller
'recentAssessments' => $recentAssessments, 'recentAssessments' => $recentAssessments,
'latestUniversalIntake' => $latestUniversalIntake, 'latestUniversalIntake' => $latestUniversalIntake,
'assessmentStatuses' => config('care.assessment_statuses'), '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')),
])); ]));
} }
@@ -2,13 +2,15 @@
namespace App\Http\Controllers\Care; namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount; use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\Appointment; use App\Models\Appointment;
use App\Models\Department; use App\Models\Department;
use App\Models\Visit;
use App\Services\Care\CareQueueBridge; use App\Services\Care\CareQueueBridge;
use App\Services\Care\OrganizationResolver; use App\Services\Care\OrganizationResolver;
use App\Services\Care\SpecialtyModuleService; use App\Services\Care\SpecialtyModuleService;
use App\Services\Care\SpecialtyShellService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
@@ -21,68 +23,86 @@ class SpecialtyModuleController extends Controller
Request $request, Request $request,
string $module, string $module,
SpecialtyModuleService $modules, SpecialtyModuleService $modules,
SpecialtyShellService $shell,
CareQueueBridge $queueBridge, CareQueueBridge $queueBridge,
): View { ): 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); $definition = $modules->definition($module);
abort_unless($definition, 404); abort_unless($definition, 404);
$organization = $this->organization($request); $organization = $this->organization($request);
$member = $this->member($request); $member = $this->member($request);
abort_unless($modules->memberCanAccess($organization, $member, $module), 403); abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
abort_unless($visit->organization_id === $organization->id, 404);
$owner = $this->ownerRef($request); $validated = $request->validate([
$resolver = app(OrganizationResolver::class); 'service_code' => ['required', 'string', 'max:64'],
$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'])
->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;
}
}
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],
]); ]);
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 back()->with('success', 'Added to invoice '.$bill->invoice_number.'.');
} }
public function callNext( public function callNext(
@@ -126,4 +146,125 @@ class SpecialtyModuleController extends Controller
return back()->with('success', 'Called '.$ticket['ticket_number'].'.'); 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'))),
]);
}
} }
@@ -320,6 +320,16 @@ class SpecialtyModuleService
$organization->update(['settings' => $settings]); $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(); $fresh = $organization->fresh();
if (data_get($fresh?->settings, 'queue_integration_enabled')) { if (data_get($fresh?->settings, 'queue_integration_enabled')) {
if (config('care.queue.driver', 'native') === 'native') { if (config('care.queue.driver', 'native') === 'native') {
+470
View File
@@ -0,0 +1,470 @@
<?php
namespace App\Services\Care;
use App\Models\Appointment;
use App\Models\Bill;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Visit;
use Illuminate\Support\Collection;
/**
* Shared specialty module shell KPIs, Visit-backed lists, stage maps, service catalogs.
*/
class SpecialtyShellService
{
public function __construct(
protected SpecialtyModuleService $modules,
protected BillService $bills,
) {}
/**
* @return array<string, mixed>
*/
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<array{code: string, label: string, queue_point: ?string}>
*/
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<array{code: string, label: string, amount_minor: int, type: string}>
*/
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<array{code: string, label: string, amount_minor: int, type: string}>
*/
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<array{code: string, label: string, amount_minor: int, type: string}>
*/
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<string, string>
*/
public function workspaceTabs(string $moduleKey): array
{
$tabs = $this->definition($moduleKey)['workspace_tabs'] ?? [];
return is_array($tabs) ? $tabs : [];
}
/**
* @return array<string, string>
*/
public function actions(string $moduleKey): array
{
$actions = $this->definition($moduleKey)['actions'] ?? [];
return is_array($actions) ? $actions : [];
}
/**
* @return list<int>
*/
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<int>|null $practitionerScope
* @return array{
* waiting: int,
* in_progress: int,
* completed_today: int,
* open_visits: int,
* revenue_today_minor: int,
* stages: list<array{code: string, label: string, count: int}>
* }
*/
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<int>|null $practitionerScope
* @return Collection<int, Visit>
*/
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<int>|null $practitionerScope
* @return Collection<int, Visit>
*/
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<array{at: string, label: string, detail: ?string}>
*/
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<string>,
* 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);
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
/**
* Specialty shell overlays: stage maps, billable service catalogs, workspace tabs.
* Merged onto catalog entries from care_specialty_modules.php by SpecialtyShellService.
*
* Modules without an entry inherit the generic defaults below.
*
* @return array{
* defaults: array<string, mixed>,
* modules: array<string, array<string, mixed>>
* }
*/
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',
],
],
],
];
+9 -3
View File
@@ -1,6 +1,6 @@
# Care Queue Engine + Specialty Modules — Unified Plan # Care Queue Engine + Specialty Modules — Unified Plan
**Status:** Phase 1 in progress **Status:** Phase 2 complete (shell); Phase 3 next
**Date:** 2026-07-18 **Date:** 2026-07-18
**Repos:** ladill-care (primary), ladill-queue (healthcare removal) **Repos:** ladill-care (primary), ladill-queue (healthcare removal)
**Related:** `docs/specialty-module-design-standard.md` **Related:** `docs/specialty-module-design-standard.md`
@@ -12,8 +12,14 @@
- Config: `CARE_QUEUE_DRIVER=native` (default) | `remote` (legacy Ladill Queue HTTP) - Config: `CARE_QUEUE_DRIVER=native` (default) | `remote` (legacy Ladill Queue HTTP)
- Tables: `care_service_queues`, `care_service_points`, `care_queue_tickets` - Tables: `care_service_queues`, `care_service_points`, `care_queue_tickets`
- Service: `App\Services\Care\CareQueueEngine` - 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
--- ---
+12 -6
View File
@@ -188,16 +188,22 @@ Use this prompt when implementing a specific specialty:
## Current implementation status (as of 2026-07) ## 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 | | Capability | Status |
| --- | --- | | --- | --- |
| Module catalog + activate/deactivate | Exists (`config/care_specialty_modules.php`, `SpecialtyModuleService`) | | Module catalog + activate/deactivate | Exists (`config/care_specialty_modules.php`, `SpecialtyModuleService`) |
| Department + queue stub provisioning | Exists | | Department + queue provisioning | Exists (native Care Queue Engine) |
| Specialty Call next + waiting list | Exists (thin show page) | | Shared specialty shell (Overview / Visits / History / Billing / Workspace) | Exists (`SpecialtyShellService`, `care/specialty/shell.blade.php`) |
| Workflow-driven episodes | Missing (use `Visit` as unit) | | Stage maps + service catalogs | Exists (`config/care_specialty_shell.php` — Emergency, Blood Bank, Dentistry first-class; others inherit defaults) |
| Unified patient timeline component | Partial (patient chart lists only) | | Standard patient header + timeline | Exists (reused on specialty workspace + patient chart) |
| Standard patient header / specialty shell | Missing | | 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 | | Specialty dashboard KPIs | Missing |
| Left nav Overview / Visits / History / Module Tabs | Missing | | Left nav Overview / Visits / History / Module Tabs | Missing |
| Clinical workspace | Missing (falls through to appointments/consultations) | | Clinical workspace | Missing (falls through to appointments/consultations) |
@@ -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
<section class="rounded-2xl border border-slate-200 bg-white px-4 py-4 sm:px-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex min-w-0 items-start gap-3">
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-slate-100 text-sm font-semibold text-slate-600">
{{ strtoupper(substr($patient->first_name ?? 'P', 0, 1).substr($patient->last_name ?? '', 0, 1)) }}
</div>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="truncate text-lg font-semibold text-slate-900">{{ $patient->fullName() }}</h2>
@if ($emergency)
<span class="rounded-full bg-rose-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-700">Emergency contact on file</span>
@endif
</div>
<p class="mt-0.5 text-sm text-slate-500">
<span class="font-mono text-sky-700">{{ $patient->patient_number }}</span>
@if ($gender) · {{ $gender }} @endif
@if ($age !== null) · {{ $age }} yrs @endif
@if ($visit)
· Visit <span class="font-mono text-xs">{{ \Illuminate\Support\Str::limit($visit->uuid, 8, '') }}</span>
@endif
</p>
<p class="mt-1 text-xs text-slate-500">
@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
</p>
</div>
</div>
<div class="grid gap-2 text-right text-xs sm:text-sm">
<div>
<p class="text-slate-500">Outstanding</p>
<p class="font-semibold {{ $outstandingMinor > 0 ? 'text-amber-700' : 'text-slate-800' }}">
{{ $currency }} {{ number_format($outstandingMinor / 100, 2) }}
</p>
</div>
@if ($insuranceSummary)
<div>
<p class="text-slate-500">Insurance</p>
<p class="font-medium text-slate-800">{{ $insuranceSummary }}</p>
</div>
@endif
</div>
</div>
@if (! empty($allergies))
<div class="mt-3 flex flex-wrap gap-1.5 border-t border-slate-100 pt-3">
<span class="text-xs font-semibold uppercase tracking-wide text-rose-600">Allergies</span>
@foreach ($allergies as $allergy)
<span class="rounded-full bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-800">{{ $allergy }}</span>
@endforeach
</div>
@endif
</section>
@@ -0,0 +1,22 @@
@props(['events' => []])
<section class="rounded-2xl border border-slate-200 bg-white p-4 sm:p-5">
<h3 class="text-sm font-semibold text-slate-900">Patient timeline</h3>
<ol class="mt-3 space-y-3">
@forelse ($events as $event)
<li class="relative flex gap-3 pl-4 before:absolute before:left-0 before:top-1.5 before:h-2 before:w-2 before:rounded-full before:bg-sky-500">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-slate-800">{{ $event['label'] ?? '' }}</p>
@if (! empty($event['detail']))
<p class="text-xs text-slate-500">{{ $event['detail'] }}</p>
@endif
</div>
<time class="shrink-0 text-xs text-slate-400">
{{ isset($event['at']) ? \Illuminate\Support\Carbon::parse($event['at'])->timezone(config('app.timezone'))->format('d M H:i') : '' }}
</time>
</li>
@empty
<li class="text-sm text-slate-500">No timeline events yet.</li>
@endforelse
</ol>
</section>
+14 -10
View File
@@ -1,15 +1,17 @@
<x-app-layout :title="$patient->fullName()"> <x-app-layout :title="$patient->fullName()">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> @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',
])
<div class="mt-4 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<p class="font-mono text-xs text-sky-600">{{ $patient->patient_number }}</p> <p class="text-sm text-slate-500">
<h1 class="text-2xl font-semibold text-slate-900">{{ $patient->fullName() }}</h1> @if ($patient->phone) {{ $patient->phone }} @endif
<p class="mt-1 text-sm text-slate-500">
{{ $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
</p> </p>
</div> </div>
@if ($canManage) @if ($canManage)
@@ -192,6 +194,8 @@
</div> </div>
<div class="space-y-6"> <div class="space-y-6">
@include('care.partials.patient-timeline', ['events' => $patientTimeline ?? []])
@if ($canManage) @if ($canManage)
<section class="rounded-2xl border border-slate-200 bg-white p-6" x-data="{ tab: 'sms' }"> <section class="rounded-2xl border border-slate-200 bg-white p-6" x-data="{ tab: 'sms' }">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Message patient</h2> <h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Message patient</h2>
@@ -0,0 +1,41 @@
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-sm font-semibold text-slate-900">Service catalog</h2>
<p class="mt-0.5 text-xs text-slate-500">Billable services seeded when this module is activated. Invoices use the Billing Engine.</p>
</div>
</div>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-left text-sm">
<thead class="border-b border-slate-100 text-xs uppercase tracking-wide text-slate-500">
<tr>
<th class="px-2 py-2 font-medium">Code</th>
<th class="px-2 py-2 font-medium">Service</th>
<th class="px-2 py-2 font-medium">Type</th>
<th class="px-2 py-2 text-right font-medium">Amount</th>
</tr>
</thead>
<tbody>
@forelse ($services as $service)
<tr class="border-b border-slate-50">
<td class="px-2 py-2 font-mono text-xs text-slate-500">{{ $service['code'] ?? '' }}</td>
<td class="px-2 py-2 font-medium text-slate-800">{{ $service['label'] ?? '' }}</td>
<td class="px-2 py-2 text-slate-500">{{ $service['type'] ?? 'misc' }}</td>
<td class="px-2 py-2 text-right tabular-nums text-slate-800">
{{ $currency ?? 'GHS' }} {{ number_format(((int) ($service['amount_minor'] ?? 0)) / 100, 2) }}
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-2 py-8 text-center text-slate-500">No services provisioned. Re-activate the module to seed the catalog.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<p class="mt-4 text-xs text-slate-500">
Add a service to a visit invoice from the specialty <a href="{{ route('care.specialty.workspace', $moduleKey) }}" class="font-medium text-indigo-600">workspace</a> Billing tab.
</p>
</section>
@@ -0,0 +1,31 @@
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Visit history</h2>
<p class="mt-0.5 text-xs text-slate-500">Completed specialty episodes.</p>
<div class="mt-4 space-y-2">
@forelse ($visitHistory as $visit)
<div class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 px-3 py-3 text-sm">
<div class="min-w-0">
<p class="font-semibold text-slate-900">{{ $visit->patient?->fullName() ?? 'Patient' }}</p>
<p class="text-xs text-slate-500">
{{ $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
</p>
</div>
<div class="flex gap-2">
@if ($visit->patient)
<a href="{{ route('care.patients.show', $visit->patient) }}" class="text-xs font-medium text-sky-600">Chart</a>
@endif
<a href="{{ route('care.specialty.workspace', ['module' => $moduleKey, 'visit' => $visit]) }}" class="text-xs font-medium text-indigo-600">Review</a>
</div>
</div>
@empty
<p class="rounded-xl border border-dashed border-slate-200 px-4 py-10 text-center text-sm text-slate-500">No completed specialty visits yet.</p>
@endforelse
</div>
</section>
@@ -0,0 +1,76 @@
{{-- KPI strip --}}
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
@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])
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3">
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">{{ $label }}</p>
<p class="mt-1 text-2xl font-semibold {{ $color }}">{{ $value }}</p>
</div>
@endforeach
</div>
@if (! empty($kpis['stages']))
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Workflow stages</h2>
<div class="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($kpis['stages'] as $stage)
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<p class="text-xs text-slate-500">{{ $stage['label'] }}</p>
<p class="text-lg font-semibold text-slate-900">{{ $stage['count'] }}</p>
</div>
@endforeach
</div>
</section>
@endif
<div class="grid gap-4 lg:grid-cols-2">
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Departments</h2>
<ul class="mt-3 space-y-2">
@forelse ($departments as $department)
<li class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-3 py-2 text-sm">
<span class="font-medium text-slate-800">{{ $department->name }}</span>
<span class="text-xs text-slate-500">{{ $department->branch?->name }}</span>
</li>
@empty
<li class="text-sm text-slate-500">No active departments for this module at your branch.</li>
@endforelse
</ul>
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between gap-3">
<h2 class="text-sm font-semibold text-slate-900">Waiting patients</h2>
<a href="{{ route('care.specialty.visits', $moduleKey) }}" class="text-xs font-medium text-indigo-600">All visits</a>
</div>
<div class="mt-3 space-y-2">
@forelse ($waiting as $appointment)
<div class="flex items-start justify-between gap-4 rounded-xl border border-slate-100 bg-slate-50 px-3 py-3 text-sm">
<div class="min-w-0 flex-1 space-y-1">
@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
<p class="truncate font-semibold text-slate-900">{{ $appointment->patient?->fullName() }}</p>
<p class="truncate text-xs text-slate-500">{{ $appointment->practitioner?->name ?? 'Unassigned' }}</p>
</div>
@if ($appointment->visit)
<a href="{{ route('care.specialty.workspace', ['module' => $moduleKey, 'visit' => $appointment->visit]) }}" class="shrink-0 text-sky-600">Open</a>
@else
<a href="{{ route('care.appointments.show', $appointment) }}" class="shrink-0 text-sky-600">View</a>
@endif
</div>
@empty
<p class="rounded-xl border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-slate-500">No patients waiting.</p>
@endforelse
</div>
</section>
</div>
@@ -0,0 +1,56 @@
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-sm font-semibold text-slate-900">Open visits</h2>
<p class="mt-0.5 text-xs text-slate-500">Visit-backed episodes for this specialty (not appointment-only).</p>
</div>
</div>
<div class="mt-4 space-y-2">
@forelse ($openVisits as $visit)
<div class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 px-3 py-3 text-sm">
<div class="min-w-0">
<p class="font-semibold text-slate-900">{{ $visit->patient?->fullName() ?? 'Patient' }}</p>
<p class="text-xs text-slate-500">
{{ $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
</p>
</div>
<a href="{{ route('care.specialty.workspace', ['module' => $moduleKey, 'visit' => $visit]) }}"
class="shrink-0 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700">
Workspace
</a>
</div>
@empty
<p class="rounded-xl border border-dashed border-slate-200 px-4 py-10 text-center text-sm text-slate-500">
No open specialty visits. Check in a patient to this specialty department to create a visit episode.
</p>
@endforelse
</div>
<div class="mt-6 border-t border-slate-100 pt-4">
<h3 class="text-sm font-semibold text-slate-900">Waiting list</h3>
<div class="mt-3 space-y-2">
@forelse ($waiting as $appointment)
<div class="flex items-center justify-between gap-3 rounded-xl border border-slate-100 px-3 py-2 text-sm">
<div>
<p class="font-medium text-slate-800">{{ $appointment->patient?->fullName() }}</p>
<p class="text-xs text-slate-500">
@if ($appointment->queue_ticket_number)
{{ $appointment->queue_ticket_number }} ·
@endif
{{ $appointment->status }}
</p>
</div>
<a href="{{ route('care.appointments.show', $appointment) }}" class="text-xs font-medium text-sky-600">Appointment</a>
</div>
@empty
<p class="text-sm text-slate-500">Queue is empty.</p>
@endforelse
</div>
</div>
</section>
@@ -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)
<section class="rounded-2xl border border-dashed border-slate-200 bg-white px-4 py-12 text-center">
<h2 class="text-sm font-semibold text-slate-900">No open visit selected</h2>
<p class="mt-1 text-sm text-slate-500">Open a visit from the Visits list, or call next from the waiting queue.</p>
<a href="{{ route('care.specialty.visits', $moduleKey) }}" class="mt-4 inline-flex text-sm font-medium text-indigo-600">Go to visits</a>
</section>
@else
{{-- Module tabs --}}
<div class="flex flex-wrap gap-1 border-b border-slate-200 pb-2">
@foreach ($workspaceTabs as $tabKey => $tabLabel)
<a href="{{ route('care.specialty.workspace', ['module' => $moduleKey, 'visit' => $workspaceVisit, 'tab' => $tabKey]) }}"
@class([
'rounded-lg px-3 py-1.5 text-sm font-medium',
'bg-indigo-50 text-indigo-800' => $activeTab === $tabKey,
'text-slate-600 hover:bg-slate-50' => $activeTab !== $tabKey,
])>
{{ $tabLabel }}
</a>
@endforeach
</div>
<div class="grid gap-4 lg:grid-cols-5">
<div class="space-y-4 lg:col-span-3">
@if ($activeTab === 'billing')
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Add specialty service</h3>
<form method="POST" action="{{ route('care.specialty.services.add', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" class="mt-3 space-y-3">
@csrf
<select name="service_code" required class="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
<option value="">Select service…</option>
@foreach ($services as $service)
<option value="{{ $service['code'] }}">
{{ $service['label'] }} {{ $currency ?? 'GHS' }} {{ number_format(((int) $service['amount_minor']) / 100, 2) }}
</option>
@endforeach
</select>
<button type="submit" class="btn-primary">{{ $actions['invoice'] ?? 'Add to invoice' }}</button>
</form>
@if ($workspaceVisit->bill)
<div class="mt-6 border-t border-slate-100 pt-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Current invoice {{ $workspaceVisit->bill->invoice_number }}</p>
<ul class="mt-2 space-y-1 text-sm">
@forelse ($workspaceVisit->bill->lineItems as $line)
<li class="flex justify-between gap-3">
<span>{{ $line->description }}</span>
<span class="tabular-nums">{{ number_format($line->total_minor / 100, 2) }}</span>
</li>
@empty
<li class="text-slate-500">No line items yet.</li>
@endforelse
</ul>
<a href="{{ route('care.bills.show', $workspaceVisit->bill) }}" class="mt-3 inline-flex text-sm font-medium text-indigo-600">Open bill</a>
</div>
@endif
</section>
@elseif (in_array($activeTab, ['clinical_notes', 'triage', 'odontogram', 'requests', 'inventory'], true))
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">{{ $workspaceTabs[$activeTab] ?? 'Clinical' }}</h3>
<p class="mt-2 text-sm text-slate-500">
Structured clinical forms for this tab ship in Phase 3. Use the patient chart and appointment for notes today.
</p>
@if ($workspaceVisit->appointment)
<a href="{{ route('care.appointments.show', $workspaceVisit->appointment) }}" class="mt-3 inline-flex text-sm font-medium text-indigo-600">Open appointment</a>
@endif
</section>
@elseif ($activeTab === 'orders')
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Orders</h3>
<p class="mt-2 text-sm text-slate-500">Order lab / imaging from the consultation flow. Specialty-specific order sets arrive in Phase 3.</p>
<a href="{{ route('care.lab.requests.index') }}" class="mt-3 inline-flex text-sm font-medium text-indigo-600">Lab requests</a>
</section>
@elseif ($activeTab === 'documents')
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Documents</h3>
<p class="mt-2 text-sm text-slate-500">Consent forms and specialty documents will attach here. Patient attachments remain on the chart.</p>
@if ($workspaceVisit->patient)
<a href="{{ route('care.patients.show', $workspaceVisit->patient) }}" class="mt-3 inline-flex text-sm font-medium text-indigo-600">Patient attachments</a>
@endif
</section>
@else
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Overview</h3>
<dl class="mt-3 grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt class="text-slate-500">Visit status</dt>
<dd class="font-medium text-slate-900">{{ ucfirst(str_replace('_', ' ', $workspaceVisit->status)) }}</dd>
</div>
<div>
<dt class="text-slate-500">Checked in</dt>
<dd class="font-medium text-slate-900">{{ $workspaceVisit->checked_in_at?->format('d M Y H:i') ?? '—' }}</dd>
</div>
<div>
<dt class="text-slate-500">Department</dt>
<dd class="font-medium text-slate-900">{{ $workspaceVisit->appointment?->department?->name ?? '—' }}</dd>
</div>
<div>
<dt class="text-slate-500">Queue</dt>
<dd class="font-medium text-slate-900">
{{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }}
@if ($workspaceVisit->appointment?->queue_ticket_status)
<span class="text-slate-500">({{ $workspaceVisit->appointment->queue_ticket_status }})</span>
@endif
</dd>
</div>
</dl>
</section>
@endif
</div>
<div class="lg:col-span-2">
@include('care.partials.patient-timeline', ['events' => $timeline ?? []])
</div>
</div>
@endif
@@ -0,0 +1,109 @@
<x-app-layout title="{{ $definition['label'] ?? $moduleKey }}">
<div class="space-y-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Specialty module</p>
<h1 class="mt-1 text-xl font-semibold text-slate-900">{{ $definition['label'] ?? $moduleKey }}</h1>
<p class="mt-1 max-w-2xl text-sm text-slate-500">{{ $definition['description'] ?? '' }}</p>
</div>
<a href="{{ route('care.settings.modules') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Manage modules</a>
</div>
<div class="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)_240px]">
{{-- Left navigation --}}
<nav class="rounded-2xl border border-slate-200 bg-white p-3 lg:sticky lg:top-4 lg:self-start">
<p class="px-2 pb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Navigate</p>
<ul class="space-y-0.5">
@foreach ($navItems as $key => $item)
@php
$active = $section === $key;
$params = ['module' => $moduleKey];
if ($key === 'workspace' && $workspaceVisit) {
$params['visit'] = $workspaceVisit;
}
@endphp
<li>
<a href="{{ route($item['route'], $params) }}"
@class([
'block rounded-xl px-3 py-2 text-sm font-medium',
'bg-indigo-50 text-indigo-800' => $active,
'text-slate-600 hover:bg-slate-50 hover:text-slate-900' => ! $active,
])>
{{ $item['label'] }}
</a>
</li>
@endforeach
</ul>
@if (! empty($stages))
<p class="mt-4 px-2 pb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Stage map</p>
<ol class="space-y-1 px-1">
@foreach ($stages as $stage)
<li class="flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-slate-600">
<span class="flex h-5 w-5 items-center justify-center rounded-full bg-slate-100 text-[10px] font-semibold text-slate-500">{{ $loop->iteration }}</span>
{{ $stage['label'] ?? $stage['code'] }}
</li>
@endforeach
</ol>
@endif
</nav>
{{-- Main workspace --}}
<div class="min-w-0 space-y-4">
@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
</div>
{{-- Action panel --}}
<aside class="rounded-2xl border border-slate-200 bg-white p-4 lg:sticky lg:top-4 lg:self-start">
<h2 class="text-sm font-semibold text-slate-900">Actions</h2>
<div class="mt-3 space-y-2">
@include('care.partials.queue-ops', [
'queueIntegration' => $queueIntegration ?? null,
'queueCallNextRoute' => 'care.specialty.call-next',
'queueCallNextParams' => ['module' => $moduleKey],
'branchId' => $branchId ?? null,
])
@if ($workspaceVisit)
<a href="{{ route('care.specialty.workspace', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}"
class="flex w-full items-center justify-center rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-100">
{{ $actions['start'] ?? 'Open workspace' }}
</a>
@if ($workspaceVisit->patient)
<a href="{{ route('care.patients.show', $workspaceVisit->patient) }}"
class="flex w-full items-center justify-center rounded-xl border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
Patient chart
</a>
@endif
@if ($workspaceVisit->appointment)
<a href="{{ route('care.appointments.show', $workspaceVisit->appointment) }}"
class="flex w-full items-center justify-center rounded-xl border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
Appointment
</a>
@endif
@else
<a href="{{ route('care.specialty.workspace', ['module' => $moduleKey]) }}"
class="flex w-full items-center justify-center rounded-xl bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-700">
Open workspace
</a>
@endif
<a href="{{ route('care.appointments.create') }}"
class="flex w-full items-center justify-center rounded-xl border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-800 hover:bg-indigo-100">
Register / book
</a>
</div>
</aside>
</div>
</div>
</x-app-layout>
@@ -1,81 +0,0 @@
<x-app-layout title="{{ $definition['label'] }}">
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Specialty module</p>
<h1 class="mt-1 text-xl font-semibold text-slate-900">{{ $definition['label'] }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ $definition['description'] ?? '' }}</p>
</div>
<a href="{{ route('care.settings.modules') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Manage modules</a>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Departments</h2>
<ul class="mt-3 space-y-2">
@forelse ($departments as $department)
<li class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-3 py-2 text-sm">
<span class="font-medium text-slate-800">{{ $department->name }}</span>
<span class="text-xs text-slate-500">{{ $department->branch?->name }}</span>
</li>
@empty
<li class="text-sm text-slate-500">No active departments for this module at your branch.</li>
@endforelse
</ul>
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Queue stubs</h2>
<ul class="mt-3 space-y-2 text-sm">
@forelse ($queueStubs as $stub)
<li class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span class="font-medium text-slate-800">{{ $stub['name'] ?? 'Queue' }}</span>
@if (! empty($stub['prefix']))
<span class="font-mono text-xs text-slate-500">({{ $stub['prefix'] }})</span>
@endif
<span class="mt-0.5 block text-xs text-slate-500">{{ $stub['branch_name'] ?? '' }}</span>
</li>
@empty
<li class="text-sm text-slate-500">No queue stubs for your branch.</li>
@endforelse
</ul>
</section>
</div>
@include('care.partials.queue-ops', [
'queueIntegration' => $queueIntegration ?? null,
'queueCallNextRoute' => 'care.specialty.call-next',
'queueCallNextParams' => ['module' => $moduleKey],
'branchId' => $branchId ?? null,
])
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between gap-3">
<h2 class="text-sm font-semibold text-slate-900">Waiting patients</h2>
<a href="{{ route('care.appointments.index') }}" class="text-xs font-medium text-indigo-600">Appointments</a>
</div>
<div class="mt-3 space-y-2">
@forelse ($waiting as $appointment)
<div class="flex items-start justify-between gap-4 rounded-xl border border-slate-100 bg-slate-50 px-3 py-3 text-sm">
<div class="min-w-0 flex-1 space-y-1.5">
@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
<div class="min-w-0">
<p class="truncate font-semibold text-slate-900">{{ $appointment->patient?->fullName() }}</p>
<p class="mt-0.5 truncate text-xs text-slate-500">{{ $appointment->department?->name }} · {{ $appointment->practitioner?->name ?? 'Unassigned' }}</p>
</div>
</div>
<a href="{{ route('care.appointments.show', $appointment) }}" class="shrink-0 text-sky-600">View</a>
</div>
@empty
<p class="rounded-xl border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-slate-500">No patients waiting in this specialty.</p>
@endforelse
</div>
</section>
</div>
</x-app-layout>
+5
View File
@@ -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::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::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::get('/specialty/{module}', [SpecialtyModuleController::class, 'show'])->name('care.specialty.show');
Route::post('/specialty/{module}/call-next', [SpecialtyModuleController::class, 'callNext'])->name('care.specialty.call-next'); Route::post('/specialty/{module}/call-next', [SpecialtyModuleController::class, 'callNext'])->name('care.specialty.call-next');
+3 -1
View File
@@ -266,7 +266,9 @@ class CareSpecialtyModulesTest extends TestCase
->get(route('care.specialty.show', 'dentistry')) ->get(route('care.specialty.show', 'dentistry'))
->assertOk() ->assertOk()
->assertSee('Dentistry') ->assertSee('Dentistry')
->assertSee('Departments'); ->assertSee('Departments')
->assertSee('Overview')
->assertSee('Stage map');
} }
public function test_unassigned_doctor_cannot_open_specialty_module(): void public function test_unassigned_doctor_cannot_open_specialty_module(): void
+210
View File
@@ -0,0 +1,210 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Bill;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\User;
use App\Models\Visit;
use App\Services\Care\SpecialtyModuleService;
use App\Services\Care\SpecialtyShellService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CareSpecialtyShellTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected function setUp(): void
{
parent::setUp();
$this->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()
);
}
}