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:
@@ -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)
|
try {
|
||||||
->where('type', $definition['department_type'] ?? 'general')
|
$bill = $shell->addCatalogServiceToVisit(
|
||||||
->where('is_active', true)
|
$organization,
|
||||||
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
|
$visit,
|
||||||
->with('branch')
|
$module,
|
||||||
->orderBy('name')
|
$validated['service_code'],
|
||||||
->get();
|
$this->ownerRef($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
$departmentIds = $departments->pluck('id')->all();
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
$waiting = Appointment::owned($owner)
|
return back()->with('error', $e->getMessage());
|
||||||
->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', [
|
return back()->with('success', 'Added to invoice '.$bill->invoice_number.'.');
|
||||||
'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],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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') {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user