Add full Vaccination, Pathology, and Infusion specialty clinical suites.
Deploy Ladill Care / deploy (push) Successful in 34s
Deploy Ladill Care / deploy (push) Successful in 34s
Mirror Oncology-depth stage flows, workspace controllers, clinical forms, reports/print, demo seed, and suite tests. Pathology stays clinic specialty complementary to the Lab app. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SpecialtyClinicalRecord;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\Infusion\InfusionAnalyticsService;
|
||||||
|
use App\Services\Care\Infusion\InfusionWorkflowService;
|
||||||
|
use App\Services\Care\SpecialtyClinicalRecordService;
|
||||||
|
use App\Services\Care\SpecialtyModuleService;
|
||||||
|
use App\Services\Care\SpecialtyShellService;
|
||||||
|
use App\Services\Care\SpecialtyVisitStageService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class InfusionWorkspaceController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
protected function assertInfusionAccess(Request $request, SpecialtyModuleService $modules): void
|
||||||
|
{
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'infusion'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertInfusionManage(Request $request, SpecialtyModuleService $modules): void
|
||||||
|
{
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
abort_unless($modules->memberCanManage($organization, $this->member($request), 'infusion'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertVisit(Request $request, Visit $visit): void
|
||||||
|
{
|
||||||
|
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
$this->authorizeBranch($request, (int) $visit->branch_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStage(
|
||||||
|
Request $request,
|
||||||
|
Visit $visit,
|
||||||
|
SpecialtyModuleService $modules,
|
||||||
|
SpecialtyVisitStageService $stages,
|
||||||
|
SpecialtyShellService $shell,
|
||||||
|
): RedirectResponse {
|
||||||
|
$this->authorizeAbility($request, 'consultations.manage');
|
||||||
|
$this->assertInfusionManage($request, $modules);
|
||||||
|
$this->assertVisit($request, $visit);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'stage' => ['required', 'string', 'max:32'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stages->setStage(
|
||||||
|
$this->organization($request),
|
||||||
|
$visit,
|
||||||
|
'infusion',
|
||||||
|
$validated['stage'],
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->actorRef($request),
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.specialty.workspace', [
|
||||||
|
'module' => 'infusion',
|
||||||
|
'visit' => $visit,
|
||||||
|
'tab' => $shell->workspaceTabForStage('infusion', $validated['stage']),
|
||||||
|
])
|
||||||
|
->with('success', 'Visit stage updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveMonitoring(
|
||||||
|
Request $request,
|
||||||
|
Visit $visit,
|
||||||
|
SpecialtyModuleService $modules,
|
||||||
|
SpecialtyClinicalRecordService $clinical,
|
||||||
|
SpecialtyVisitStageService $stages,
|
||||||
|
InfusionWorkflowService $workflow,
|
||||||
|
): RedirectResponse {
|
||||||
|
$this->authorizeAbility($request, 'consultations.manage');
|
||||||
|
$this->assertInfusionManage($request, $modules);
|
||||||
|
$this->assertVisit($request, $visit);
|
||||||
|
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$payload = (array) $request->input('payload', []);
|
||||||
|
foreach ($clinical->fieldsFor('infusion', 'infusion_monitoring') as $field) {
|
||||||
|
if (($field['type'] ?? '') === 'boolean') {
|
||||||
|
$payload[$field['name']] = $request->boolean('payload.'.$field['name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$request->merge(['payload' => $payload, 'tab' => 'monitoring']);
|
||||||
|
|
||||||
|
$validated = $request->validate(array_merge([
|
||||||
|
'tab' => ['required', 'string'],
|
||||||
|
], $clinical->validationRules('infusion', 'infusion_monitoring')));
|
||||||
|
|
||||||
|
$record = $clinical->upsert(
|
||||||
|
$organization,
|
||||||
|
$visit,
|
||||||
|
'infusion',
|
||||||
|
'infusion_monitoring',
|
||||||
|
$clinical->payloadFromRequest($validated),
|
||||||
|
$owner,
|
||||||
|
$owner,
|
||||||
|
InfusionWorkflowService::STAGE_MONITORING,
|
||||||
|
SpecialtyClinicalRecord::STATUS_COMPLETED,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stages->setStage(
|
||||||
|
$organization,
|
||||||
|
$visit,
|
||||||
|
'infusion',
|
||||||
|
InfusionWorkflowService::STAGE_MONITORING,
|
||||||
|
$owner,
|
||||||
|
$owner,
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workflow->shouldCompleteVisit($record->payload ?? [])) {
|
||||||
|
try {
|
||||||
|
$stages->setStage(
|
||||||
|
$organization,
|
||||||
|
$visit,
|
||||||
|
'infusion',
|
||||||
|
InfusionWorkflowService::STAGE_COMPLETED,
|
||||||
|
$owner,
|
||||||
|
$owner,
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
$visit->refresh();
|
||||||
|
if ($visit->status !== Visit::STATUS_COMPLETED) {
|
||||||
|
$visit->update([
|
||||||
|
'status' => Visit::STATUS_COMPLETED,
|
||||||
|
'completed_at' => $visit->completed_at ?? now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$appointment = $visit->appointment;
|
||||||
|
if ($appointment && $appointment->status !== \App\Models\Appointment::STATUS_COMPLETED) {
|
||||||
|
$appointment->update([
|
||||||
|
'status' => \App\Models\Appointment::STATUS_COMPLETED,
|
||||||
|
'completed_at' => $appointment->completed_at ?? now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.specialty.workspace', ['module' => 'infusion', 'visit' => $visit, 'tab' => 'monitoring'])
|
||||||
|
->with('success', 'Infusion monitoring saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reports(
|
||||||
|
Request $request,
|
||||||
|
SpecialtyModuleService $modules,
|
||||||
|
SpecialtyShellService $shell,
|
||||||
|
InfusionAnalyticsService $analytics,
|
||||||
|
): View {
|
||||||
|
$this->authorizeAbility($request, 'consultations.view');
|
||||||
|
$this->assertInfusionAccess($request, $modules);
|
||||||
|
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(\App\Services\Care\OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$report = $analytics->report(
|
||||||
|
$organization,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$branchScope,
|
||||||
|
$request->query('from'),
|
||||||
|
$request->query('to'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('care.specialty.infusion.reports', [
|
||||||
|
'moduleKey' => 'infusion',
|
||||||
|
'definition' => $modules->definition('infusion'),
|
||||||
|
'shellNav' => $shell->navItems('infusion'),
|
||||||
|
'report' => $report,
|
||||||
|
'currency' => strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS'))),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function printSummary(
|
||||||
|
Request $request,
|
||||||
|
Visit $visit,
|
||||||
|
SpecialtyModuleService $modules,
|
||||||
|
SpecialtyClinicalRecordService $clinical,
|
||||||
|
): View {
|
||||||
|
$this->authorizeAbility($request, 'consultations.view');
|
||||||
|
$this->assertInfusionAccess($request, $modules);
|
||||||
|
$this->assertVisit($request, $visit);
|
||||||
|
|
||||||
|
$visit->loadMissing(['patient', 'appointment.practitioner', 'branch']);
|
||||||
|
|
||||||
|
return view('care.specialty.infusion.print', [
|
||||||
|
'visit' => $visit,
|
||||||
|
'patient' => $visit->patient,
|
||||||
|
'assessment' => $clinical->findForVisit($visit, 'infusion', 'infusion_assessment'),
|
||||||
|
'protocol' => $clinical->findForVisit($visit, 'infusion', 'infusion_protocol'),
|
||||||
|
'session' => $clinical->findForVisit($visit, 'infusion', 'infusion_session'),
|
||||||
|
'monitoring' => $clinical->findForVisit($visit, 'infusion', 'infusion_monitoring'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SpecialtyClinicalRecord;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\Pathology\PathologyAnalyticsService;
|
||||||
|
use App\Services\Care\Pathology\PathologyWorkflowService;
|
||||||
|
use App\Services\Care\SpecialtyClinicalRecordService;
|
||||||
|
use App\Services\Care\SpecialtyModuleService;
|
||||||
|
use App\Services\Care\SpecialtyShellService;
|
||||||
|
use App\Services\Care\SpecialtyVisitStageService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class PathologyWorkspaceController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
protected function assertPathologyAccess(Request $request, SpecialtyModuleService $modules): void
|
||||||
|
{
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'pathology'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertPathologyManage(Request $request, SpecialtyModuleService $modules): void
|
||||||
|
{
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
abort_unless($modules->memberCanManage($organization, $this->member($request), 'pathology'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertVisit(Request $request, Visit $visit): void
|
||||||
|
{
|
||||||
|
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
$this->authorizeBranch($request, (int) $visit->branch_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStage(
|
||||||
|
Request $request,
|
||||||
|
Visit $visit,
|
||||||
|
SpecialtyModuleService $modules,
|
||||||
|
SpecialtyVisitStageService $stages,
|
||||||
|
SpecialtyShellService $shell,
|
||||||
|
): RedirectResponse {
|
||||||
|
$this->authorizeAbility($request, 'consultations.manage');
|
||||||
|
$this->assertPathologyManage($request, $modules);
|
||||||
|
$this->assertVisit($request, $visit);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'stage' => ['required', 'string', 'max:32'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stages->setStage(
|
||||||
|
$this->organization($request),
|
||||||
|
$visit,
|
||||||
|
'pathology',
|
||||||
|
$validated['stage'],
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->actorRef($request),
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.specialty.workspace', [
|
||||||
|
'module' => 'pathology',
|
||||||
|
'visit' => $visit,
|
||||||
|
'tab' => $shell->workspaceTabForStage('pathology', $validated['stage']),
|
||||||
|
])
|
||||||
|
->with('success', 'Visit stage updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveVerification(
|
||||||
|
Request $request,
|
||||||
|
Visit $visit,
|
||||||
|
SpecialtyModuleService $modules,
|
||||||
|
SpecialtyClinicalRecordService $clinical,
|
||||||
|
SpecialtyVisitStageService $stages,
|
||||||
|
PathologyWorkflowService $workflow,
|
||||||
|
): RedirectResponse {
|
||||||
|
$this->authorizeAbility($request, 'consultations.manage');
|
||||||
|
$this->assertPathologyManage($request, $modules);
|
||||||
|
$this->assertVisit($request, $visit);
|
||||||
|
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$payload = (array) $request->input('payload', []);
|
||||||
|
foreach ($clinical->fieldsFor('pathology', 'path_verification') as $field) {
|
||||||
|
if (($field['type'] ?? '') === 'boolean') {
|
||||||
|
$payload[$field['name']] = $request->boolean('payload.'.$field['name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$request->merge(['payload' => $payload, 'tab' => 'verification']);
|
||||||
|
|
||||||
|
$validated = $request->validate(array_merge([
|
||||||
|
'tab' => ['required', 'string'],
|
||||||
|
], $clinical->validationRules('pathology', 'path_verification')));
|
||||||
|
|
||||||
|
$record = $clinical->upsert(
|
||||||
|
$organization,
|
||||||
|
$visit,
|
||||||
|
'pathology',
|
||||||
|
'path_verification',
|
||||||
|
$clinical->payloadFromRequest($validated),
|
||||||
|
$owner,
|
||||||
|
$owner,
|
||||||
|
PathologyWorkflowService::STAGE_VERIFIED,
|
||||||
|
SpecialtyClinicalRecord::STATUS_COMPLETED,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stages->setStage(
|
||||||
|
$organization,
|
||||||
|
$visit,
|
||||||
|
'pathology',
|
||||||
|
PathologyWorkflowService::STAGE_VERIFIED,
|
||||||
|
$owner,
|
||||||
|
$owner,
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workflow->shouldCompleteVisit($record->payload ?? [])) {
|
||||||
|
try {
|
||||||
|
$stages->setStage(
|
||||||
|
$organization,
|
||||||
|
$visit,
|
||||||
|
'pathology',
|
||||||
|
PathologyWorkflowService::STAGE_COMPLETED,
|
||||||
|
$owner,
|
||||||
|
$owner,
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
$visit->refresh();
|
||||||
|
if ($visit->status !== Visit::STATUS_COMPLETED) {
|
||||||
|
$visit->update([
|
||||||
|
'status' => Visit::STATUS_COMPLETED,
|
||||||
|
'completed_at' => $visit->completed_at ?? now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$appointment = $visit->appointment;
|
||||||
|
if ($appointment && $appointment->status !== \App\Models\Appointment::STATUS_COMPLETED) {
|
||||||
|
$appointment->update([
|
||||||
|
'status' => \App\Models\Appointment::STATUS_COMPLETED,
|
||||||
|
'completed_at' => $appointment->completed_at ?? now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.specialty.workspace', ['module' => 'pathology', 'visit' => $visit, 'tab' => 'verification'])
|
||||||
|
->with('success', 'Pathology verification saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reports(
|
||||||
|
Request $request,
|
||||||
|
SpecialtyModuleService $modules,
|
||||||
|
SpecialtyShellService $shell,
|
||||||
|
PathologyAnalyticsService $analytics,
|
||||||
|
): View {
|
||||||
|
$this->authorizeAbility($request, 'consultations.view');
|
||||||
|
$this->assertPathologyAccess($request, $modules);
|
||||||
|
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(\App\Services\Care\OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$report = $analytics->report(
|
||||||
|
$organization,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$branchScope,
|
||||||
|
$request->query('from'),
|
||||||
|
$request->query('to'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('care.specialty.pathology.reports', [
|
||||||
|
'moduleKey' => 'pathology',
|
||||||
|
'definition' => $modules->definition('pathology'),
|
||||||
|
'shellNav' => $shell->navItems('pathology'),
|
||||||
|
'report' => $report,
|
||||||
|
'currency' => strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS'))),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function printSummary(
|
||||||
|
Request $request,
|
||||||
|
Visit $visit,
|
||||||
|
SpecialtyModuleService $modules,
|
||||||
|
SpecialtyClinicalRecordService $clinical,
|
||||||
|
): View {
|
||||||
|
$this->authorizeAbility($request, 'consultations.view');
|
||||||
|
$this->assertPathologyAccess($request, $modules);
|
||||||
|
$this->assertVisit($request, $visit);
|
||||||
|
|
||||||
|
$visit->loadMissing(['patient', 'appointment.practitioner', 'branch']);
|
||||||
|
|
||||||
|
return view('care.specialty.pathology.print', [
|
||||||
|
'visit' => $visit,
|
||||||
|
'patient' => $visit->patient,
|
||||||
|
'requestRecord' => $clinical->findForVisit($visit, 'pathology', 'path_request'),
|
||||||
|
'specimen' => $clinical->findForVisit($visit, 'pathology', 'path_specimen'),
|
||||||
|
'processing' => $clinical->findForVisit($visit, 'pathology', 'path_processing'),
|
||||||
|
'report' => $clinical->findForVisit($visit, 'pathology', 'path_report'),
|
||||||
|
'verification' => $clinical->findForVisit($visit, 'pathology', 'path_verification'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,6 +165,9 @@ class SpecialtyModuleController extends Controller
|
|||||||
'oncology' => 'history',
|
'oncology' => 'history',
|
||||||
'renal' => 'history',
|
'renal' => 'history',
|
||||||
'surgery' => 'history',
|
'surgery' => 'history',
|
||||||
|
'vaccination' => 'eligibility',
|
||||||
|
'pathology' => 'request',
|
||||||
|
'infusion' => 'assessment',
|
||||||
default => (collect(array_keys($shellTabs))
|
default => (collect(array_keys($shellTabs))
|
||||||
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
|
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
|
||||||
?? 'clinical_notes'),
|
?? 'clinical_notes'),
|
||||||
@@ -248,6 +251,9 @@ class SpecialtyModuleController extends Controller
|
|||||||
'oncology' => 'history',
|
'oncology' => 'history',
|
||||||
'renal' => 'history',
|
'renal' => 'history',
|
||||||
'surgery' => 'history',
|
'surgery' => 'history',
|
||||||
|
'vaccination' => 'eligibility',
|
||||||
|
'pathology' => 'request',
|
||||||
|
'infusion' => 'assessment',
|
||||||
default => (collect(array_keys($shellTabs))
|
default => (collect(array_keys($shellTabs))
|
||||||
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
|
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
|
||||||
?? 'clinical_notes'),
|
?? 'clinical_notes'),
|
||||||
@@ -915,6 +921,104 @@ class SpecialtyModuleController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($module === 'vaccination' && in_array($recordType, ['vax_eligibility', 'vax_consent', 'vax_admin'], true)) {
|
||||||
|
$workflow = app(\App\Services\Care\Vaccination\VaccinationWorkflowService::class);
|
||||||
|
$stageService = app(\App\Services\Care\SpecialtyVisitStageService::class);
|
||||||
|
$suggested = match ($recordType) {
|
||||||
|
'vax_eligibility' => $workflow->stageFromEligibility($record->payload ?? []),
|
||||||
|
'vax_consent' => $workflow->stageFromConsent($record->payload ?? []),
|
||||||
|
'vax_admin' => $workflow->stageFromAdmin($record->payload ?? []),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
$current = $visit->specialty_stage;
|
||||||
|
$early = ['check_in', 'waiting', null, ''];
|
||||||
|
$order = [
|
||||||
|
'check_in' => 0,
|
||||||
|
'eligibility' => 1,
|
||||||
|
'consent' => 2,
|
||||||
|
'administer' => 3,
|
||||||
|
'observation' => 4,
|
||||||
|
'completed' => 5,
|
||||||
|
];
|
||||||
|
if ($suggested && (! $current || in_array($current, $early, true))) {
|
||||||
|
try {
|
||||||
|
$stageService->setStage($organization, $visit, 'vaccination', $suggested, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
} elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) {
|
||||||
|
try {
|
||||||
|
$stageService->setStage($organization, $visit, 'vaccination', $suggested, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($module === 'pathology' && in_array($recordType, ['path_request', 'path_specimen', 'path_processing', 'path_report'], true)) {
|
||||||
|
$workflow = app(\App\Services\Care\Pathology\PathologyWorkflowService::class);
|
||||||
|
$stageService = app(\App\Services\Care\SpecialtyVisitStageService::class);
|
||||||
|
$suggested = match ($recordType) {
|
||||||
|
'path_request' => $workflow->stageFromRequest($record->payload ?? []),
|
||||||
|
'path_specimen' => $workflow->stageFromSpecimen($record->payload ?? []),
|
||||||
|
'path_processing' => $workflow->stageFromProcessing($record->payload ?? []),
|
||||||
|
'path_report' => $workflow->stageFromReport($record->payload ?? []),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
$current = $visit->specialty_stage;
|
||||||
|
$early = ['check_in', 'waiting', null, ''];
|
||||||
|
$order = [
|
||||||
|
'check_in' => 0,
|
||||||
|
'request' => 1,
|
||||||
|
'specimen' => 2,
|
||||||
|
'processing' => 3,
|
||||||
|
'report' => 4,
|
||||||
|
'verified' => 5,
|
||||||
|
'completed' => 6,
|
||||||
|
];
|
||||||
|
if ($suggested && (! $current || in_array($current, $early, true))) {
|
||||||
|
try {
|
||||||
|
$stageService->setStage($organization, $visit, 'pathology', $suggested, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
} elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) {
|
||||||
|
try {
|
||||||
|
$stageService->setStage($organization, $visit, 'pathology', $suggested, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($module === 'infusion' && in_array($recordType, ['infusion_assessment', 'infusion_protocol', 'infusion_session'], true)) {
|
||||||
|
$workflow = app(\App\Services\Care\Infusion\InfusionWorkflowService::class);
|
||||||
|
$stageService = app(\App\Services\Care\SpecialtyVisitStageService::class);
|
||||||
|
$suggested = match ($recordType) {
|
||||||
|
'infusion_assessment' => $workflow->stageFromAssessment($record->payload ?? []),
|
||||||
|
'infusion_protocol' => $workflow->stageFromProtocol($record->payload ?? []),
|
||||||
|
'infusion_session' => $workflow->stageFromSession($record->payload ?? []),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
$current = $visit->specialty_stage;
|
||||||
|
$early = ['check_in', 'waiting', null, ''];
|
||||||
|
$order = [
|
||||||
|
'check_in' => 0,
|
||||||
|
'assessment' => 1,
|
||||||
|
'protocol' => 2,
|
||||||
|
'session' => 3,
|
||||||
|
'monitoring' => 4,
|
||||||
|
'completed' => 5,
|
||||||
|
];
|
||||||
|
if ($suggested && (! $current || in_array($current, $early, true))) {
|
||||||
|
try {
|
||||||
|
$stageService->setStage($organization, $visit, 'infusion', $suggested, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
} elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) {
|
||||||
|
try {
|
||||||
|
$stageService->setStage($organization, $visit, 'infusion', $suggested, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('care.specialty.workspace', [
|
->route('care.specialty.workspace', [
|
||||||
'module' => $module,
|
'module' => $module,
|
||||||
@@ -1392,6 +1496,25 @@ class SpecialtyModuleController extends Controller
|
|||||||
$surgeryPostop = null;
|
$surgeryPostop = null;
|
||||||
$surgeryStageCodes = [];
|
$surgeryStageCodes = [];
|
||||||
$surgeryStageFlow = [];
|
$surgeryStageFlow = [];
|
||||||
|
$vaccinationEligibility = null;
|
||||||
|
$vaccinationConsent = null;
|
||||||
|
$vaccinationAdmin = null;
|
||||||
|
$vaccinationObservation = null;
|
||||||
|
$vaccinationStageCodes = [];
|
||||||
|
$vaccinationStageFlow = [];
|
||||||
|
$pathologyRequest = null;
|
||||||
|
$pathologySpecimen = null;
|
||||||
|
$pathologyProcessing = null;
|
||||||
|
$pathologyReport = null;
|
||||||
|
$pathologyVerification = null;
|
||||||
|
$pathologyStageCodes = [];
|
||||||
|
$pathologyStageFlow = [];
|
||||||
|
$infusionAssessment = null;
|
||||||
|
$infusionProtocol = null;
|
||||||
|
$infusionSession = null;
|
||||||
|
$infusionMonitoring = null;
|
||||||
|
$infusionStageCodes = [];
|
||||||
|
$infusionStageFlow = [];
|
||||||
$activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview');
|
$activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview');
|
||||||
$clinical = app(SpecialtyClinicalRecordService::class);
|
$clinical = app(SpecialtyClinicalRecordService::class);
|
||||||
|
|
||||||
@@ -1616,6 +1739,34 @@ class SpecialtyModuleController extends Controller
|
|||||||
$surgeryStageFlow = app(\App\Services\Care\Surgery\SurgeryWorkflowService::class)->stageFlow();
|
$surgeryStageFlow = app(\App\Services\Care\Surgery\SurgeryWorkflowService::class)->stageFlow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($module === 'vaccination') {
|
||||||
|
$vaccinationEligibility = $clinical->findForVisit($workspaceVisit, 'vaccination', 'vax_eligibility');
|
||||||
|
$vaccinationConsent = $clinical->findForVisit($workspaceVisit, 'vaccination', 'vax_consent');
|
||||||
|
$vaccinationAdmin = $clinical->findForVisit($workspaceVisit, 'vaccination', 'vax_admin');
|
||||||
|
$vaccinationObservation = $clinical->findForVisit($workspaceVisit, 'vaccination', 'vax_observation');
|
||||||
|
$vaccinationStageCodes = collect($shell->stages('vaccination'))->pluck('code')->all();
|
||||||
|
$vaccinationStageFlow = app(\App\Services\Care\Vaccination\VaccinationWorkflowService::class)->stageFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($module === 'pathology') {
|
||||||
|
$pathologyRequest = $clinical->findForVisit($workspaceVisit, 'pathology', 'path_request');
|
||||||
|
$pathologySpecimen = $clinical->findForVisit($workspaceVisit, 'pathology', 'path_specimen');
|
||||||
|
$pathologyProcessing = $clinical->findForVisit($workspaceVisit, 'pathology', 'path_processing');
|
||||||
|
$pathologyReport = $clinical->findForVisit($workspaceVisit, 'pathology', 'path_report');
|
||||||
|
$pathologyVerification = $clinical->findForVisit($workspaceVisit, 'pathology', 'path_verification');
|
||||||
|
$pathologyStageCodes = collect($shell->stages('pathology'))->pluck('code')->all();
|
||||||
|
$pathologyStageFlow = app(\App\Services\Care\Pathology\PathologyWorkflowService::class)->stageFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($module === 'infusion') {
|
||||||
|
$infusionAssessment = $clinical->findForVisit($workspaceVisit, 'infusion', 'infusion_assessment');
|
||||||
|
$infusionProtocol = $clinical->findForVisit($workspaceVisit, 'infusion', 'infusion_protocol');
|
||||||
|
$infusionSession = $clinical->findForVisit($workspaceVisit, 'infusion', 'infusion_session');
|
||||||
|
$infusionMonitoring = $clinical->findForVisit($workspaceVisit, 'infusion', 'infusion_monitoring');
|
||||||
|
$infusionStageCodes = collect($shell->stages('infusion'))->pluck('code')->all();
|
||||||
|
$infusionStageFlow = app(\App\Services\Care\Infusion\InfusionWorkflowService::class)->stageFlow();
|
||||||
|
}
|
||||||
|
|
||||||
if ($patientHeader !== null) {
|
if ($patientHeader !== null) {
|
||||||
$patientHeader['clinical_alerts'] = $clinicalAlerts;
|
$patientHeader['clinical_alerts'] = $clinicalAlerts;
|
||||||
}
|
}
|
||||||
@@ -1785,6 +1936,25 @@ class SpecialtyModuleController extends Controller
|
|||||||
'surgeryPostop' => $surgeryPostop,
|
'surgeryPostop' => $surgeryPostop,
|
||||||
'surgeryStageCodes' => $surgeryStageCodes,
|
'surgeryStageCodes' => $surgeryStageCodes,
|
||||||
'surgeryStageFlow' => $surgeryStageFlow,
|
'surgeryStageFlow' => $surgeryStageFlow,
|
||||||
|
'vaccinationEligibility' => $vaccinationEligibility,
|
||||||
|
'vaccinationConsent' => $vaccinationConsent,
|
||||||
|
'vaccinationAdmin' => $vaccinationAdmin,
|
||||||
|
'vaccinationObservation' => $vaccinationObservation,
|
||||||
|
'vaccinationStageCodes' => $vaccinationStageCodes,
|
||||||
|
'vaccinationStageFlow' => $vaccinationStageFlow,
|
||||||
|
'pathologyRequest' => $pathologyRequest,
|
||||||
|
'pathologySpecimen' => $pathologySpecimen,
|
||||||
|
'pathologyProcessing' => $pathologyProcessing,
|
||||||
|
'pathologyReport' => $pathologyReport,
|
||||||
|
'pathologyVerification' => $pathologyVerification,
|
||||||
|
'pathologyStageCodes' => $pathologyStageCodes,
|
||||||
|
'pathologyStageFlow' => $pathologyStageFlow,
|
||||||
|
'infusionAssessment' => $infusionAssessment,
|
||||||
|
'infusionProtocol' => $infusionProtocol,
|
||||||
|
'infusionSession' => $infusionSession,
|
||||||
|
'infusionMonitoring' => $infusionMonitoring,
|
||||||
|
'infusionStageCodes' => $infusionStageCodes,
|
||||||
|
'infusionStageFlow' => $infusionStageFlow,
|
||||||
'queueStubs' => $modules->queueStubsFor($organization, $module),
|
'queueStubs' => $modules->queueStubsFor($organization, $module),
|
||||||
'branchId' => $branchId,
|
'branchId' => $branchId,
|
||||||
'branchLabel' => $branchLabel,
|
'branchLabel' => $branchLabel,
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SpecialtyClinicalRecord;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\SpecialtyClinicalRecordService;
|
||||||
|
use App\Services\Care\SpecialtyModuleService;
|
||||||
|
use App\Services\Care\SpecialtyShellService;
|
||||||
|
use App\Services\Care\SpecialtyVisitStageService;
|
||||||
|
use App\Services\Care\Vaccination\VaccinationAnalyticsService;
|
||||||
|
use App\Services\Care\Vaccination\VaccinationWorkflowService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class VaccinationWorkspaceController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
protected function assertVaccinationAccess(Request $request, SpecialtyModuleService $modules): void
|
||||||
|
{
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'vaccination'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertVaccinationManage(Request $request, SpecialtyModuleService $modules): void
|
||||||
|
{
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
abort_unless($modules->memberCanManage($organization, $this->member($request), 'vaccination'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertVisit(Request $request, Visit $visit): void
|
||||||
|
{
|
||||||
|
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
$this->authorizeBranch($request, (int) $visit->branch_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStage(
|
||||||
|
Request $request,
|
||||||
|
Visit $visit,
|
||||||
|
SpecialtyModuleService $modules,
|
||||||
|
SpecialtyVisitStageService $stages,
|
||||||
|
SpecialtyShellService $shell,
|
||||||
|
): RedirectResponse {
|
||||||
|
$this->authorizeAbility($request, 'consultations.manage');
|
||||||
|
$this->assertVaccinationManage($request, $modules);
|
||||||
|
$this->assertVisit($request, $visit);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'stage' => ['required', 'string', 'max:32'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stages->setStage(
|
||||||
|
$this->organization($request),
|
||||||
|
$visit,
|
||||||
|
'vaccination',
|
||||||
|
$validated['stage'],
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->actorRef($request),
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.specialty.workspace', [
|
||||||
|
'module' => 'vaccination',
|
||||||
|
'visit' => $visit,
|
||||||
|
'tab' => $shell->workspaceTabForStage('vaccination', $validated['stage']),
|
||||||
|
])
|
||||||
|
->with('success', 'Visit stage updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveObservation(
|
||||||
|
Request $request,
|
||||||
|
Visit $visit,
|
||||||
|
SpecialtyModuleService $modules,
|
||||||
|
SpecialtyClinicalRecordService $clinical,
|
||||||
|
SpecialtyVisitStageService $stages,
|
||||||
|
VaccinationWorkflowService $workflow,
|
||||||
|
): RedirectResponse {
|
||||||
|
$this->authorizeAbility($request, 'consultations.manage');
|
||||||
|
$this->assertVaccinationManage($request, $modules);
|
||||||
|
$this->assertVisit($request, $visit);
|
||||||
|
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$payload = (array) $request->input('payload', []);
|
||||||
|
foreach ($clinical->fieldsFor('vaccination', 'vax_observation') as $field) {
|
||||||
|
if (($field['type'] ?? '') === 'boolean') {
|
||||||
|
$payload[$field['name']] = $request->boolean('payload.'.$field['name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$request->merge(['payload' => $payload, 'tab' => 'observation']);
|
||||||
|
|
||||||
|
$validated = $request->validate(array_merge([
|
||||||
|
'tab' => ['required', 'string'],
|
||||||
|
], $clinical->validationRules('vaccination', 'vax_observation')));
|
||||||
|
|
||||||
|
$record = $clinical->upsert(
|
||||||
|
$organization,
|
||||||
|
$visit,
|
||||||
|
'vaccination',
|
||||||
|
'vax_observation',
|
||||||
|
$clinical->payloadFromRequest($validated),
|
||||||
|
$owner,
|
||||||
|
$owner,
|
||||||
|
VaccinationWorkflowService::STAGE_OBSERVATION,
|
||||||
|
SpecialtyClinicalRecord::STATUS_COMPLETED,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stages->setStage(
|
||||||
|
$organization,
|
||||||
|
$visit,
|
||||||
|
'vaccination',
|
||||||
|
VaccinationWorkflowService::STAGE_OBSERVATION,
|
||||||
|
$owner,
|
||||||
|
$owner,
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workflow->shouldCompleteVisit($record->payload ?? [])) {
|
||||||
|
try {
|
||||||
|
$stages->setStage(
|
||||||
|
$organization,
|
||||||
|
$visit,
|
||||||
|
'vaccination',
|
||||||
|
VaccinationWorkflowService::STAGE_COMPLETED,
|
||||||
|
$owner,
|
||||||
|
$owner,
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
$visit->refresh();
|
||||||
|
if ($visit->status !== Visit::STATUS_COMPLETED) {
|
||||||
|
$visit->update([
|
||||||
|
'status' => Visit::STATUS_COMPLETED,
|
||||||
|
'completed_at' => $visit->completed_at ?? now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$appointment = $visit->appointment;
|
||||||
|
if ($appointment && $appointment->status !== \App\Models\Appointment::STATUS_COMPLETED) {
|
||||||
|
$appointment->update([
|
||||||
|
'status' => \App\Models\Appointment::STATUS_COMPLETED,
|
||||||
|
'completed_at' => $appointment->completed_at ?? now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.specialty.workspace', ['module' => 'vaccination', 'visit' => $visit, 'tab' => 'observation'])
|
||||||
|
->with('success', 'Vaccination observation saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reports(
|
||||||
|
Request $request,
|
||||||
|
SpecialtyModuleService $modules,
|
||||||
|
SpecialtyShellService $shell,
|
||||||
|
VaccinationAnalyticsService $analytics,
|
||||||
|
): View {
|
||||||
|
$this->authorizeAbility($request, 'consultations.view');
|
||||||
|
$this->assertVaccinationAccess($request, $modules);
|
||||||
|
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(\App\Services\Care\OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$report = $analytics->report(
|
||||||
|
$organization,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$branchScope,
|
||||||
|
$request->query('from'),
|
||||||
|
$request->query('to'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('care.specialty.vaccination.reports', [
|
||||||
|
'moduleKey' => 'vaccination',
|
||||||
|
'definition' => $modules->definition('vaccination'),
|
||||||
|
'shellNav' => $shell->navItems('vaccination'),
|
||||||
|
'report' => $report,
|
||||||
|
'currency' => strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS'))),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function printSummary(
|
||||||
|
Request $request,
|
||||||
|
Visit $visit,
|
||||||
|
SpecialtyModuleService $modules,
|
||||||
|
SpecialtyClinicalRecordService $clinical,
|
||||||
|
): View {
|
||||||
|
$this->authorizeAbility($request, 'consultations.view');
|
||||||
|
$this->assertVaccinationAccess($request, $modules);
|
||||||
|
$this->assertVisit($request, $visit);
|
||||||
|
|
||||||
|
$visit->loadMissing(['patient', 'appointment.practitioner', 'branch']);
|
||||||
|
|
||||||
|
return view('care.specialty.vaccination.print', [
|
||||||
|
'visit' => $visit,
|
||||||
|
'patient' => $visit->patient,
|
||||||
|
'eligibility' => $clinical->findForVisit($visit, 'vaccination', 'vax_eligibility'),
|
||||||
|
'consent' => $clinical->findForVisit($visit, 'vaccination', 'vax_consent'),
|
||||||
|
'admin' => $clinical->findForVisit($visit, 'vaccination', 'vax_admin'),
|
||||||
|
'observation' => $clinical->findForVisit($visit, 'vaccination', 'vax_observation'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1026,6 +1026,9 @@ class DemoTenantSeeder
|
|||||||
$this->seedOncologyClinicalDemo($organization, $ownerRef);
|
$this->seedOncologyClinicalDemo($organization, $ownerRef);
|
||||||
$this->seedRenalClinicalDemo($organization, $ownerRef);
|
$this->seedRenalClinicalDemo($organization, $ownerRef);
|
||||||
$this->seedSurgeryClinicalDemo($organization, $ownerRef);
|
$this->seedSurgeryClinicalDemo($organization, $ownerRef);
|
||||||
|
$this->seedVaccinationClinicalDemo($organization, $ownerRef);
|
||||||
|
$this->seedPathologyClinicalDemo($organization, $ownerRef);
|
||||||
|
$this->seedInfusionClinicalDemo($organization, $ownerRef);
|
||||||
|
|
||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
@@ -2445,6 +2448,227 @@ class DemoTenantSeeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function seedVaccinationClinicalDemo(Organization $organization, string $ownerRef): void
|
||||||
|
{
|
||||||
|
$branchIds = Branch::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->pluck('id');
|
||||||
|
if ($branchIds->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$departmentIds = Department::owned($ownerRef)
|
||||||
|
->whereIn('branch_id', $branchIds)
|
||||||
|
->where('type', 'vaccination')
|
||||||
|
->where('is_active', true)
|
||||||
|
->pluck('id');
|
||||||
|
if ($departmentIds->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$appointments = Appointment::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->whereIn('department_id', $departmentIds)
|
||||||
|
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||||||
|
->whereNotNull('visit_id')
|
||||||
|
->with('visit')
|
||||||
|
->orderBy('branch_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
if ($appointments->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$clinical = app(SpecialtyClinicalRecordService::class);
|
||||||
|
$index = 0;
|
||||||
|
foreach ($appointments as $appointment) {
|
||||||
|
$visit = $appointment->visit;
|
||||||
|
if (! $visit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$alertCase = $index === 0;
|
||||||
|
$clinical->upsert($organization, $visit, 'vaccination', 'vax_eligibility', [
|
||||||
|
'vaccine' => $alertCase ? 'Yellow fever' : 'Tetanus toxoid',
|
||||||
|
'schedule' => $alertCase ? 'Travel' : 'Routine EPI',
|
||||||
|
'prior_doses' => $alertCase ? 'None documented' : 'Primary series complete',
|
||||||
|
'indications' => $alertCase ? 'Travel certificate required' : 'Antenatal TT booster',
|
||||||
|
'contraindications' => true,
|
||||||
|
'allergy_history' => 'NKDA',
|
||||||
|
'fever_today' => $alertCase,
|
||||||
|
'pregnancy' => ! $alertCase,
|
||||||
|
'cleared' => ! $alertCase,
|
||||||
|
], $ownerRef, $ownerRef, 'eligibility');
|
||||||
|
$clinical->upsert($organization, $visit, 'vaccination', 'vax_consent', [
|
||||||
|
'information_given' => true,
|
||||||
|
'consent_obtained' => ! $alertCase,
|
||||||
|
'consent_type' => 'Verbal',
|
||||||
|
'consented_by' => 'Patient',
|
||||||
|
'risks_discussed' => true,
|
||||||
|
'notes' => $alertCase ? 'Defer pending fever resolution' : 'Consent for TT booster',
|
||||||
|
], $ownerRef, $ownerRef, $alertCase ? 'eligibility' : 'consent');
|
||||||
|
if (! $alertCase) {
|
||||||
|
$clinical->upsert($organization, $visit, 'vaccination', 'vax_admin', [
|
||||||
|
'vaccine' => 'Tetanus toxoid',
|
||||||
|
'dose' => '0.5 mL',
|
||||||
|
'site' => 'Left arm',
|
||||||
|
'batch' => 'TT-DEMO-'.($index + 1),
|
||||||
|
'expiry' => now()->addYear()->toDateString(),
|
||||||
|
'route' => 'IM',
|
||||||
|
'administered_by' => 'Demo nurse',
|
||||||
|
'adverse_events' => 'None immediate',
|
||||||
|
'next_due' => now()->addMonths(6)->toDateString(),
|
||||||
|
'outcome' => 'Completed — observe',
|
||||||
|
], $ownerRef, $ownerRef, 'administer');
|
||||||
|
}
|
||||||
|
if (! $visit->specialty_stage) {
|
||||||
|
$visit->update(['specialty_stage' => $alertCase ? 'eligibility' : 'administer']);
|
||||||
|
}
|
||||||
|
$index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedPathologyClinicalDemo(Organization $organization, string $ownerRef): void
|
||||||
|
{
|
||||||
|
$branchIds = Branch::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->pluck('id');
|
||||||
|
if ($branchIds->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$departmentIds = Department::owned($ownerRef)
|
||||||
|
->whereIn('branch_id', $branchIds)
|
||||||
|
->where('type', 'pathology')
|
||||||
|
->where('is_active', true)
|
||||||
|
->pluck('id');
|
||||||
|
if ($departmentIds->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$appointments = Appointment::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->whereIn('department_id', $departmentIds)
|
||||||
|
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||||||
|
->whereNotNull('visit_id')
|
||||||
|
->with('visit')
|
||||||
|
->orderBy('branch_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
if ($appointments->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$clinical = app(SpecialtyClinicalRecordService::class);
|
||||||
|
$index = 0;
|
||||||
|
foreach ($appointments as $appointment) {
|
||||||
|
$visit = $appointment->visit;
|
||||||
|
if (! $visit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$urgent = $index === 0;
|
||||||
|
$clinical->upsert($organization, $visit, 'pathology', 'path_request', [
|
||||||
|
'specimen_type' => $urgent ? 'Breast core biopsy' : 'Cervical cytology',
|
||||||
|
'site' => $urgent ? 'Left breast' : 'Cervix',
|
||||||
|
'clinical_history' => $urgent
|
||||||
|
? 'Palpable mass; BIRADS 4; rule out malignancy'
|
||||||
|
: 'Routine Pap smear; previous NILM',
|
||||||
|
'urgency' => $urgent ? 'Urgent' : 'Routine',
|
||||||
|
'tests_requested' => $urgent ? 'Histo + IHC if indicated' : 'Pap smear',
|
||||||
|
'requesting_clinician' => 'Demo clinician',
|
||||||
|
], $ownerRef, $ownerRef, 'request');
|
||||||
|
$clinical->upsert($organization, $visit, 'pathology', 'path_specimen', [
|
||||||
|
'received_at' => now()->subHours(2)->format('Y-m-d H:i'),
|
||||||
|
'container_label' => 'PATH-DEMO-'.($index + 1),
|
||||||
|
'adequacy' => $urgent ? 'Adequate' : 'Adequate',
|
||||||
|
'fixative' => $urgent ? '10% formalin' : 'Liquid-based cytology',
|
||||||
|
'notes' => 'Received sealed and labelled',
|
||||||
|
], $ownerRef, $ownerRef, 'specimen');
|
||||||
|
if ($urgent) {
|
||||||
|
$clinical->upsert($organization, $visit, 'pathology', 'path_processing', [
|
||||||
|
'gross' => 'Two cores, 1.2 cm aggregate',
|
||||||
|
'blocks' => 'A1–A2',
|
||||||
|
'stains' => 'H&E',
|
||||||
|
'status' => 'Ready for reporting',
|
||||||
|
'notes' => 'Priority processing',
|
||||||
|
], $ownerRef, $ownerRef, 'processing');
|
||||||
|
}
|
||||||
|
if (! $visit->specialty_stage) {
|
||||||
|
$visit->update(['specialty_stage' => $urgent ? 'processing' : 'specimen']);
|
||||||
|
}
|
||||||
|
$index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedInfusionClinicalDemo(Organization $organization, string $ownerRef): void
|
||||||
|
{
|
||||||
|
$branchIds = Branch::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->pluck('id');
|
||||||
|
if ($branchIds->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$departmentIds = Department::owned($ownerRef)
|
||||||
|
->whereIn('branch_id', $branchIds)
|
||||||
|
->where('type', 'infusion')
|
||||||
|
->where('is_active', true)
|
||||||
|
->pluck('id');
|
||||||
|
if ($departmentIds->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$appointments = Appointment::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->whereIn('department_id', $departmentIds)
|
||||||
|
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||||||
|
->whereNotNull('visit_id')
|
||||||
|
->with('visit')
|
||||||
|
->orderBy('branch_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
if ($appointments->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$clinical = app(SpecialtyClinicalRecordService::class);
|
||||||
|
$index = 0;
|
||||||
|
foreach ($appointments as $appointment) {
|
||||||
|
$visit = $appointment->visit;
|
||||||
|
if (! $visit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$notFit = $index === 0;
|
||||||
|
$clinical->upsert($organization, $visit, 'infusion', 'infusion_assessment', [
|
||||||
|
'indication' => $notFit ? 'IV iron — symptomatic anaemia' : 'IV antibiotics — cellulitis',
|
||||||
|
'history' => $notFit
|
||||||
|
? 'Hb 7.8; prior infusion reaction reported'
|
||||||
|
: 'Day 3 of outpatient IV ceftriaxone',
|
||||||
|
'allergies' => $notFit ? 'Possible iron sucrose reaction' : 'NKDA',
|
||||||
|
'baseline_vitals' => $notFit ? 'BP 88/54; HR 112' : 'BP 118/74; HR 78',
|
||||||
|
'access_plan' => 'Peripheral IV',
|
||||||
|
'fit_for_infusion' => ! $notFit,
|
||||||
|
], $ownerRef, $ownerRef, 'assessment');
|
||||||
|
if (! $notFit) {
|
||||||
|
$clinical->upsert($organization, $visit, 'infusion', 'infusion_protocol', [
|
||||||
|
'medication' => 'Ceftriaxone',
|
||||||
|
'dose' => '2 g',
|
||||||
|
'diluent' => '100 mL NS',
|
||||||
|
'rate' => '30 minutes',
|
||||||
|
'duration' => '30 min',
|
||||||
|
'premeds' => 'None',
|
||||||
|
'ordered_by' => 'Demo clinician',
|
||||||
|
], $ownerRef, $ownerRef, 'protocol');
|
||||||
|
$clinical->upsert($organization, $visit, 'infusion', 'infusion_session', [
|
||||||
|
'medication' => 'Ceftriaxone',
|
||||||
|
'dose' => '2 g',
|
||||||
|
'rate' => '30 min',
|
||||||
|
'access' => 'Left forearm PIV',
|
||||||
|
'start_time' => now()->subMinutes(20)->format('H:i'),
|
||||||
|
'end_time' => '',
|
||||||
|
'vitals' => 'Stable mid-infusion',
|
||||||
|
'reactions' => 'None',
|
||||||
|
'notes' => 'Infusing without incident',
|
||||||
|
'outcome' => 'In progress',
|
||||||
|
], $ownerRef, $ownerRef, 'session');
|
||||||
|
}
|
||||||
|
if (! $visit->specialty_stage) {
|
||||||
|
$visit->update(['specialty_stage' => $notFit ? 'assessment' : 'session']);
|
||||||
|
}
|
||||||
|
$index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function seedDentistryClinicalDemo(Organization $organization, string $ownerRef): void
|
private function seedDentistryClinicalDemo(Organization $organization, string $ownerRef): void
|
||||||
{
|
{
|
||||||
// Specialty departments are provisioned without organization_id; scope via branches.
|
// Specialty departments are provisioned without organization_id; scope via branches.
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care\Infusion;
|
||||||
|
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Bill;
|
||||||
|
use App\Models\BillLineItem;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\SpecialtyClinicalRecord;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\SpecialtyShellService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class InfusionAnalyticsService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected SpecialtyShellService $shell,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* arrivals_today: int,
|
||||||
|
* completed_today: int,
|
||||||
|
* sessions_open: int,
|
||||||
|
* medication_breakdown: Collection,
|
||||||
|
* reaction_breakdown: Collection,
|
||||||
|
* avg_los_minutes: ?float,
|
||||||
|
* revenue_by_service: Collection,
|
||||||
|
* from: string,
|
||||||
|
* to: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function report(
|
||||||
|
Organization $organization,
|
||||||
|
string $ownerRef,
|
||||||
|
?int $branchId = null,
|
||||||
|
?string $from = null,
|
||||||
|
?string $to = null,
|
||||||
|
): array {
|
||||||
|
$todayStart = now()->startOfDay();
|
||||||
|
$rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth();
|
||||||
|
$rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay();
|
||||||
|
|
||||||
|
$departmentIds = $this->shell->departmentIds($organization, 'infusion', $ownerRef, $branchId);
|
||||||
|
|
||||||
|
$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($branchId, fn ($q) => $q->where('branch_id', $branchId));
|
||||||
|
|
||||||
|
$arrivalsToday = (clone $appointmentBase)
|
||||||
|
->where('checked_in_at', '>=', $todayStart)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$completedToday = (clone $appointmentBase)
|
||||||
|
->where('status', Appointment::STATUS_COMPLETED)
|
||||||
|
->where('completed_at', '>=', $todayStart)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$visitIds = (clone $appointmentBase)->whereNotNull('visit_id')->pluck('visit_id');
|
||||||
|
|
||||||
|
$openVisitIds = Visit::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds)
|
||||||
|
->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS])
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$sessionsOpen = SpecialtyClinicalRecord::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('module_key', 'infusion')
|
||||||
|
->where('record_type', 'infusion_session')
|
||||||
|
->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds)
|
||||||
|
->where('status', '!=', SpecialtyClinicalRecord::STATUS_COMPLETED)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$sessionRecords = SpecialtyClinicalRecord::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('module_key', 'infusion')
|
||||||
|
->where('record_type', 'infusion_session')
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$medicationBreakdown = $sessionRecords
|
||||||
|
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['medication'] ?? 'Unknown'))
|
||||||
|
->map(fn (Collection $rows, string $medication) => [
|
||||||
|
'medication' => $medication,
|
||||||
|
'total' => $rows->count(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->sortByDesc('total')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$monitoringRecords = SpecialtyClinicalRecord::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('module_key', 'infusion')
|
||||||
|
->where('record_type', 'infusion_monitoring')
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$reactionBreakdown = $monitoringRecords
|
||||||
|
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['reaction'] ?? 'None'))
|
||||||
|
->map(fn (Collection $rows, string $reaction) => [
|
||||||
|
'reaction' => $reaction,
|
||||||
|
'total' => $rows->count(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->sortByDesc('total')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$completedVisits = Visit::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds)
|
||||||
|
->where('status', Visit::STATUS_COMPLETED)
|
||||||
|
->whereBetween('completed_at', [$rangeFrom, $rangeTo])
|
||||||
|
->whereNotNull('checked_in_at')
|
||||||
|
->whereNotNull('completed_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$avgLos = null;
|
||||||
|
if ($completedVisits->isNotEmpty()) {
|
||||||
|
$avgLos = round($completedVisits->avg(function (Visit $visit) {
|
||||||
|
return $visit->checked_in_at->diffInMinutes($visit->completed_at);
|
||||||
|
}), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$serviceLabels = collect($this->shell->provisionedServices($organization, 'infusion'))
|
||||||
|
->pluck('label')
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$revenueByService = BillLineItem::query()
|
||||||
|
->where('owner_ref', $ownerRef)
|
||||||
|
->where('source_type', 'specialty_service')
|
||||||
|
->whereIn('description', $serviceLabels ?: ['__none__'])
|
||||||
|
->whereBetween('created_at', [$rangeFrom, $rangeTo])
|
||||||
|
->whereHas('bill', function ($q) use ($organization, $visitIds) {
|
||||||
|
$q->where('organization_id', $organization->id)
|
||||||
|
->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds)
|
||||||
|
->whereNotIn('status', [Bill::STATUS_VOID]);
|
||||||
|
})
|
||||||
|
->selectRaw('description, count(*) as total, sum(total_minor) as amount_minor')
|
||||||
|
->groupBy('description')
|
||||||
|
->orderByDesc('amount_minor')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'arrivals_today' => $arrivalsToday,
|
||||||
|
'completed_today' => $completedToday,
|
||||||
|
'sessions_open' => $sessionsOpen,
|
||||||
|
'medication_breakdown' => $medicationBreakdown,
|
||||||
|
'reaction_breakdown' => $reactionBreakdown,
|
||||||
|
'avg_los_minutes' => $avgLos,
|
||||||
|
'revenue_by_service' => $revenueByService,
|
||||||
|
'from' => $rangeFrom->toDateString(),
|
||||||
|
'to' => $rangeTo->toDateString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care\Infusion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map infusion centre clinical progress onto visit stages.
|
||||||
|
*/
|
||||||
|
class InfusionWorkflowService
|
||||||
|
{
|
||||||
|
public const STAGE_CHECK_IN = 'check_in';
|
||||||
|
|
||||||
|
public const STAGE_ASSESSMENT = 'assessment';
|
||||||
|
|
||||||
|
public const STAGE_PROTOCOL = 'protocol';
|
||||||
|
|
||||||
|
public const STAGE_SESSION = 'session';
|
||||||
|
|
||||||
|
public const STAGE_MONITORING = 'monitoring';
|
||||||
|
|
||||||
|
public const STAGE_COMPLETED = 'completed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromAssessment(array $payload): string
|
||||||
|
{
|
||||||
|
if (! empty($payload['indication']) || ! empty($payload['fit_for_infusion'])) {
|
||||||
|
return self::STAGE_ASSESSMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_CHECK_IN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromProtocol(array $payload): string
|
||||||
|
{
|
||||||
|
if (! empty($payload['medication']) && ! empty($payload['dose'])) {
|
||||||
|
return self::STAGE_PROTOCOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_ASSESSMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromSession(array $payload): string
|
||||||
|
{
|
||||||
|
$outcome = strtolower((string) ($payload['outcome'] ?? ''));
|
||||||
|
if (str_contains($outcome, 'observe') || str_contains($outcome, 'completed') || ! empty($payload['end_time'])) {
|
||||||
|
return self::STAGE_MONITORING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($payload['notes']) || ! empty($payload['start_time'])) {
|
||||||
|
return self::STAGE_SESSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_PROTOCOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromMonitoring(array $payload): string
|
||||||
|
{
|
||||||
|
if ($this->shouldCompleteVisit($payload)) {
|
||||||
|
return self::STAGE_COMPLETED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($payload['outcome']) || ! empty($payload['post_vitals'])) {
|
||||||
|
return self::STAGE_MONITORING;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_SESSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function shouldCompleteVisit(array $payload): bool
|
||||||
|
{
|
||||||
|
$outcome = strtolower((string) ($payload['outcome'] ?? ''));
|
||||||
|
|
||||||
|
return str_contains($outcome, 'completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{next: string, label: string}>
|
||||||
|
*/
|
||||||
|
public function stageFlow(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::STAGE_CHECK_IN => ['next' => self::STAGE_ASSESSMENT, 'label' => 'Start assessment'],
|
||||||
|
self::STAGE_ASSESSMENT => ['next' => self::STAGE_PROTOCOL, 'label' => 'Move to protocol'],
|
||||||
|
self::STAGE_PROTOCOL => ['next' => self::STAGE_SESSION, 'label' => 'Move to infusion session'],
|
||||||
|
self::STAGE_SESSION => ['next' => self::STAGE_MONITORING, 'label' => 'Move to monitoring'],
|
||||||
|
self::STAGE_MONITORING => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'],
|
||||||
|
self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care\Pathology;
|
||||||
|
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Bill;
|
||||||
|
use App\Models\BillLineItem;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\SpecialtyClinicalRecord;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\SpecialtyShellService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class PathologyAnalyticsService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected SpecialtyShellService $shell,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* arrivals_today: int,
|
||||||
|
* completed_today: int,
|
||||||
|
* reports_open: int,
|
||||||
|
* diagnosis_breakdown: Collection,
|
||||||
|
* specimen_breakdown: Collection,
|
||||||
|
* avg_los_minutes: ?float,
|
||||||
|
* revenue_by_service: Collection,
|
||||||
|
* from: string,
|
||||||
|
* to: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function report(
|
||||||
|
Organization $organization,
|
||||||
|
string $ownerRef,
|
||||||
|
?int $branchId = null,
|
||||||
|
?string $from = null,
|
||||||
|
?string $to = null,
|
||||||
|
): array {
|
||||||
|
$todayStart = now()->startOfDay();
|
||||||
|
$rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth();
|
||||||
|
$rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay();
|
||||||
|
|
||||||
|
$departmentIds = $this->shell->departmentIds($organization, 'pathology', $ownerRef, $branchId);
|
||||||
|
|
||||||
|
$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($branchId, fn ($q) => $q->where('branch_id', $branchId));
|
||||||
|
|
||||||
|
$arrivalsToday = (clone $appointmentBase)
|
||||||
|
->where('checked_in_at', '>=', $todayStart)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$completedToday = (clone $appointmentBase)
|
||||||
|
->where('status', Appointment::STATUS_COMPLETED)
|
||||||
|
->where('completed_at', '>=', $todayStart)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$visitIds = (clone $appointmentBase)->whereNotNull('visit_id')->pluck('visit_id');
|
||||||
|
|
||||||
|
$openVisitIds = Visit::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds)
|
||||||
|
->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS])
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$reportsOpen = SpecialtyClinicalRecord::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('module_key', 'pathology')
|
||||||
|
->where('record_type', 'path_report')
|
||||||
|
->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds)
|
||||||
|
->where('status', '!=', SpecialtyClinicalRecord::STATUS_COMPLETED)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$reportRecords = SpecialtyClinicalRecord::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('module_key', 'pathology')
|
||||||
|
->where('record_type', 'path_report')
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$diagnosisBreakdown = $reportRecords
|
||||||
|
->groupBy(fn (SpecialtyClinicalRecord $r) => \Illuminate\Support\Str::limit((string) ($r->payload['diagnosis'] ?? 'Unknown'), 60))
|
||||||
|
->map(fn (Collection $rows, string $diagnosis) => [
|
||||||
|
'diagnosis' => $diagnosis,
|
||||||
|
'total' => $rows->count(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->sortByDesc('total')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$requestRecords = SpecialtyClinicalRecord::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('module_key', 'pathology')
|
||||||
|
->where('record_type', 'path_request')
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$specimenBreakdown = $requestRecords
|
||||||
|
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['specimen_type'] ?? 'Unknown'))
|
||||||
|
->map(fn (Collection $rows, string $specimen) => [
|
||||||
|
'specimen' => $specimen,
|
||||||
|
'total' => $rows->count(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->sortByDesc('total')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$completedVisits = Visit::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds)
|
||||||
|
->where('status', Visit::STATUS_COMPLETED)
|
||||||
|
->whereBetween('completed_at', [$rangeFrom, $rangeTo])
|
||||||
|
->whereNotNull('checked_in_at')
|
||||||
|
->whereNotNull('completed_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$avgLos = null;
|
||||||
|
if ($completedVisits->isNotEmpty()) {
|
||||||
|
$avgLos = round($completedVisits->avg(function (Visit $visit) {
|
||||||
|
return $visit->checked_in_at->diffInMinutes($visit->completed_at);
|
||||||
|
}), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$serviceLabels = collect($this->shell->provisionedServices($organization, 'pathology'))
|
||||||
|
->pluck('label')
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$revenueByService = BillLineItem::query()
|
||||||
|
->where('owner_ref', $ownerRef)
|
||||||
|
->where('source_type', 'specialty_service')
|
||||||
|
->whereIn('description', $serviceLabels ?: ['__none__'])
|
||||||
|
->whereBetween('created_at', [$rangeFrom, $rangeTo])
|
||||||
|
->whereHas('bill', function ($q) use ($organization, $visitIds) {
|
||||||
|
$q->where('organization_id', $organization->id)
|
||||||
|
->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds)
|
||||||
|
->whereNotIn('status', [Bill::STATUS_VOID]);
|
||||||
|
})
|
||||||
|
->selectRaw('description, count(*) as total, sum(total_minor) as amount_minor')
|
||||||
|
->groupBy('description')
|
||||||
|
->orderByDesc('amount_minor')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'arrivals_today' => $arrivalsToday,
|
||||||
|
'completed_today' => $completedToday,
|
||||||
|
'reports_open' => $reportsOpen,
|
||||||
|
'diagnosis_breakdown' => $diagnosisBreakdown,
|
||||||
|
'specimen_breakdown' => $specimenBreakdown,
|
||||||
|
'avg_los_minutes' => $avgLos,
|
||||||
|
'revenue_by_service' => $revenueByService,
|
||||||
|
'from' => $rangeFrom->toDateString(),
|
||||||
|
'to' => $rangeTo->toDateString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care\Pathology;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map clinic pathology specialty progress onto visit stages.
|
||||||
|
*/
|
||||||
|
class PathologyWorkflowService
|
||||||
|
{
|
||||||
|
public const STAGE_CHECK_IN = 'check_in';
|
||||||
|
|
||||||
|
public const STAGE_REQUEST = 'request';
|
||||||
|
|
||||||
|
public const STAGE_SPECIMEN = 'specimen';
|
||||||
|
|
||||||
|
public const STAGE_PROCESSING = 'processing';
|
||||||
|
|
||||||
|
public const STAGE_REPORT = 'report';
|
||||||
|
|
||||||
|
public const STAGE_VERIFIED = 'verified';
|
||||||
|
|
||||||
|
public const STAGE_COMPLETED = 'completed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromRequest(array $payload): string
|
||||||
|
{
|
||||||
|
if (! empty($payload['specimen_type']) || ! empty($payload['clinical_history'])) {
|
||||||
|
return self::STAGE_REQUEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_CHECK_IN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromSpecimen(array $payload): string
|
||||||
|
{
|
||||||
|
if (! empty($payload['container_label'])) {
|
||||||
|
return self::STAGE_SPECIMEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_REQUEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromProcessing(array $payload): string
|
||||||
|
{
|
||||||
|
$status = strtolower((string) ($payload['status'] ?? ''));
|
||||||
|
if (str_contains($status, 'ready')) {
|
||||||
|
return self::STAGE_REPORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($payload['status']) || ! empty($payload['gross'])) {
|
||||||
|
return self::STAGE_PROCESSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_SPECIMEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromReport(array $payload): string
|
||||||
|
{
|
||||||
|
if (! empty($payload['diagnosis']) && ! empty($payload['microscopy'])) {
|
||||||
|
return self::STAGE_REPORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_PROCESSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromVerification(array $payload): string
|
||||||
|
{
|
||||||
|
if ($this->shouldCompleteVisit($payload)) {
|
||||||
|
return self::STAGE_COMPLETED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($payload['verified'])) {
|
||||||
|
return self::STAGE_VERIFIED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_REPORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function shouldCompleteVisit(array $payload): bool
|
||||||
|
{
|
||||||
|
$outcome = strtolower((string) ($payload['outcome'] ?? ''));
|
||||||
|
|
||||||
|
return str_contains($outcome, 'completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{next: string, label: string}>
|
||||||
|
*/
|
||||||
|
public function stageFlow(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::STAGE_CHECK_IN => ['next' => self::STAGE_REQUEST, 'label' => 'Start request'],
|
||||||
|
self::STAGE_REQUEST => ['next' => self::STAGE_SPECIMEN, 'label' => 'Move to specimen'],
|
||||||
|
self::STAGE_SPECIMEN => ['next' => self::STAGE_PROCESSING, 'label' => 'Move to processing'],
|
||||||
|
self::STAGE_PROCESSING => ['next' => self::STAGE_REPORT, 'label' => 'Move to report'],
|
||||||
|
self::STAGE_REPORT => ['next' => self::STAGE_VERIFIED, 'label' => 'Move to verification'],
|
||||||
|
self::STAGE_VERIFIED => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'],
|
||||||
|
self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -483,6 +483,77 @@ class SpecialtyClinicalRecordService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($moduleKey === 'vaccination' && $recordType === 'vax_eligibility') {
|
||||||
|
if (empty($payload['cleared']) && ! empty($payload['vaccine'])) {
|
||||||
|
$alerts[] = [
|
||||||
|
'code' => 'vax.not_cleared',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'message' => 'Vaccine requested but patient is not cleared to vaccinate.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (! empty($payload['fever_today'])) {
|
||||||
|
$alerts[] = [
|
||||||
|
'code' => 'vax.fever',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'message' => 'Fever recorded on vaccination eligibility screen.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($moduleKey === 'vaccination' && $recordType === 'vax_observation') {
|
||||||
|
$aefi = strtolower((string) ($payload['aefi'] ?? ''));
|
||||||
|
if (str_contains($aefi, 'severe')) {
|
||||||
|
$alerts[] = [
|
||||||
|
'code' => 'vax.aefi_severe',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'message' => 'Severe AEFI recorded — escalate and document follow-up.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($moduleKey === 'pathology' && $recordType === 'path_request') {
|
||||||
|
$urgency = strtolower((string) ($payload['urgency'] ?? ''));
|
||||||
|
if (str_contains($urgency, 'urgent') || str_contains($urgency, 'intra')) {
|
||||||
|
$alerts[] = [
|
||||||
|
'code' => 'path.urgent',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'message' => 'Urgent / intra-op pathology request — prioritise processing.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($moduleKey === 'pathology' && $recordType === 'path_specimen') {
|
||||||
|
$adequacy = strtolower((string) ($payload['adequacy'] ?? ''));
|
||||||
|
if (str_contains($adequacy, 'inadequate')) {
|
||||||
|
$alerts[] = [
|
||||||
|
'code' => 'path.inadequate',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'message' => 'Specimen marked inadequate — notify requesting clinician.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($moduleKey === 'infusion' && $recordType === 'infusion_assessment') {
|
||||||
|
if (empty($payload['fit_for_infusion']) && ! empty($payload['indication'])) {
|
||||||
|
$alerts[] = [
|
||||||
|
'code' => 'inf.not_fit',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'message' => 'Indication recorded but patient not marked fit for infusion.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($moduleKey === 'infusion' && in_array($recordType, ['infusion_session', 'infusion_monitoring'], true)) {
|
||||||
|
$reaction = strtolower((string) ($payload['reactions'] ?? $payload['reaction'] ?? ''));
|
||||||
|
if (str_contains($reaction, 'severe') || str_contains($reaction, 'stop')) {
|
||||||
|
$alerts[] = [
|
||||||
|
'code' => 'inf.reaction',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'message' => 'Infusion reaction / stop flagged — review interventions.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($moduleKey === 'renal' && $recordType === 'renal_exam') {
|
if ($moduleKey === 'renal' && $recordType === 'renal_exam') {
|
||||||
$fluid = strtolower((string) ($payload['fluid_status'] ?? ''));
|
$fluid = strtolower((string) ($payload['fluid_status'] ?? ''));
|
||||||
if (str_contains($fluid, 'overload')) {
|
if (str_contains($fluid, 'overload')) {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ use App\Services\Care\Ent\EntWorkflowService;
|
|||||||
use App\Services\Care\Oncology\OncologyWorkflowService;
|
use App\Services\Care\Oncology\OncologyWorkflowService;
|
||||||
use App\Services\Care\Renal\RenalWorkflowService;
|
use App\Services\Care\Renal\RenalWorkflowService;
|
||||||
use App\Services\Care\Surgery\SurgeryWorkflowService;
|
use App\Services\Care\Surgery\SurgeryWorkflowService;
|
||||||
|
use App\Services\Care\Vaccination\VaccinationWorkflowService;
|
||||||
|
use App\Services\Care\Pathology\PathologyWorkflowService;
|
||||||
|
use App\Services\Care\Infusion\InfusionWorkflowService;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,6 +81,9 @@ class SpecialtyShellService
|
|||||||
'oncology' => 'care.specialty.oncology.stage',
|
'oncology' => 'care.specialty.oncology.stage',
|
||||||
'renal' => 'care.specialty.renal.stage',
|
'renal' => 'care.specialty.renal.stage',
|
||||||
'surgery' => 'care.specialty.surgery.stage',
|
'surgery' => 'care.specialty.surgery.stage',
|
||||||
|
'vaccination' => 'care.specialty.vaccination.stage',
|
||||||
|
'pathology' => 'care.specialty.pathology.stage',
|
||||||
|
'infusion' => 'care.specialty.infusion.stage',
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -104,6 +110,9 @@ class SpecialtyShellService
|
|||||||
'oncology' => app(OncologyWorkflowService::class)->stageFlow(),
|
'oncology' => app(OncologyWorkflowService::class)->stageFlow(),
|
||||||
'renal' => app(RenalWorkflowService::class)->stageFlow(),
|
'renal' => app(RenalWorkflowService::class)->stageFlow(),
|
||||||
'surgery' => app(SurgeryWorkflowService::class)->stageFlow(),
|
'surgery' => app(SurgeryWorkflowService::class)->stageFlow(),
|
||||||
|
'vaccination' => app(VaccinationWorkflowService::class)->stageFlow(),
|
||||||
|
'pathology' => app(PathologyWorkflowService::class)->stageFlow(),
|
||||||
|
'infusion' => app(InfusionWorkflowService::class)->stageFlow(),
|
||||||
'dentistry' => [
|
'dentistry' => [
|
||||||
'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'],
|
'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'],
|
||||||
'chair' => ['next' => 'procedure', 'label' => 'Start procedure'],
|
'chair' => ['next' => 'procedure', 'label' => 'Start procedure'],
|
||||||
@@ -403,7 +412,7 @@ class SpecialtyShellService
|
|||||||
) {
|
) {
|
||||||
$code = (string) ($stage['code'] ?? '');
|
$code = (string) ($stage['code'] ?? '');
|
||||||
|
|
||||||
if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery'], true) && $visitIds->isNotEmpty()) {
|
if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery', 'vaccination', 'pathology', 'infusion'], true) && $visitIds->isNotEmpty()) {
|
||||||
$count = Visit::owned($ownerRef)
|
$count = Visit::owned($ownerRef)
|
||||||
->where('organization_id', $organization->id)
|
->where('organization_id', $organization->id)
|
||||||
->whereIn('id', $visitIds)
|
->whereIn('id', $visitIds)
|
||||||
@@ -445,6 +454,9 @@ class SpecialtyShellService
|
|||||||
'oncology' => $clinical->countOpenByType($organization, 'oncology', 'onc_staging', $branchScope),
|
'oncology' => $clinical->countOpenByType($organization, 'oncology', 'onc_staging', $branchScope),
|
||||||
'renal' => $clinical->countOpenByType($organization, 'renal', 'renal_exam', $branchScope),
|
'renal' => $clinical->countOpenByType($organization, 'renal', 'renal_exam', $branchScope),
|
||||||
'surgery' => $clinical->countOpenByType($organization, 'surgery', 'surg_exam', $branchScope),
|
'surgery' => $clinical->countOpenByType($organization, 'surgery', 'surg_exam', $branchScope),
|
||||||
|
'vaccination' => $clinical->countOpenByType($organization, 'vaccination', 'vax_eligibility', $branchScope),
|
||||||
|
'pathology' => $clinical->countOpenByType($organization, 'pathology', 'path_request', $branchScope),
|
||||||
|
'infusion' => $clinical->countOpenByType($organization, 'infusion', 'infusion_assessment', $branchScope),
|
||||||
'dentistry' => \App\Models\DentalPlanItem::query()
|
'dentistry' => \App\Models\DentalPlanItem::query()
|
||||||
->whereHas('plan', function ($q) use ($organization, $ownerRef) {
|
->whereHas('plan', function ($q) use ($organization, $ownerRef) {
|
||||||
$q->owned($ownerRef)
|
$q->owned($ownerRef)
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care\Vaccination;
|
||||||
|
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Bill;
|
||||||
|
use App\Models\BillLineItem;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\SpecialtyClinicalRecord;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\SpecialtyShellService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class VaccinationAnalyticsService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected SpecialtyShellService $shell,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* arrivals_today: int,
|
||||||
|
* completed_today: int,
|
||||||
|
* observation_open: int,
|
||||||
|
* vaccine_breakdown: Collection,
|
||||||
|
* aefi_breakdown: Collection,
|
||||||
|
* avg_los_minutes: ?float,
|
||||||
|
* revenue_by_service: Collection,
|
||||||
|
* from: string,
|
||||||
|
* to: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function report(
|
||||||
|
Organization $organization,
|
||||||
|
string $ownerRef,
|
||||||
|
?int $branchId = null,
|
||||||
|
?string $from = null,
|
||||||
|
?string $to = null,
|
||||||
|
): array {
|
||||||
|
$todayStart = now()->startOfDay();
|
||||||
|
$rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth();
|
||||||
|
$rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay();
|
||||||
|
|
||||||
|
$departmentIds = $this->shell->departmentIds($organization, 'vaccination', $ownerRef, $branchId);
|
||||||
|
|
||||||
|
$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($branchId, fn ($q) => $q->where('branch_id', $branchId));
|
||||||
|
|
||||||
|
$arrivalsToday = (clone $appointmentBase)
|
||||||
|
->where('checked_in_at', '>=', $todayStart)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$completedToday = (clone $appointmentBase)
|
||||||
|
->where('status', Appointment::STATUS_COMPLETED)
|
||||||
|
->where('completed_at', '>=', $todayStart)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$visitIds = (clone $appointmentBase)->whereNotNull('visit_id')->pluck('visit_id');
|
||||||
|
|
||||||
|
$openVisitIds = Visit::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds)
|
||||||
|
->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS])
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$observationOpen = SpecialtyClinicalRecord::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('module_key', 'vaccination')
|
||||||
|
->where('record_type', 'vax_observation')
|
||||||
|
->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds)
|
||||||
|
->where('status', '!=', SpecialtyClinicalRecord::STATUS_COMPLETED)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$adminRecords = SpecialtyClinicalRecord::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('module_key', 'vaccination')
|
||||||
|
->where('record_type', 'vax_admin')
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$vaccineBreakdown = $adminRecords
|
||||||
|
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['vaccine'] ?? 'Unknown'))
|
||||||
|
->map(fn (Collection $rows, string $vaccine) => [
|
||||||
|
'vaccine' => $vaccine,
|
||||||
|
'total' => $rows->count(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->sortByDesc('total')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$observationRecords = SpecialtyClinicalRecord::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('module_key', 'vaccination')
|
||||||
|
->where('record_type', 'vax_observation')
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$aefiBreakdown = $observationRecords
|
||||||
|
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['aefi'] ?? 'None'))
|
||||||
|
->map(fn (Collection $rows, string $aefi) => [
|
||||||
|
'aefi' => $aefi,
|
||||||
|
'total' => $rows->count(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->sortByDesc('total')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$completedVisits = Visit::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds)
|
||||||
|
->where('status', Visit::STATUS_COMPLETED)
|
||||||
|
->whereBetween('completed_at', [$rangeFrom, $rangeTo])
|
||||||
|
->whereNotNull('checked_in_at')
|
||||||
|
->whereNotNull('completed_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$avgLos = null;
|
||||||
|
if ($completedVisits->isNotEmpty()) {
|
||||||
|
$avgLos = round($completedVisits->avg(function (Visit $visit) {
|
||||||
|
return $visit->checked_in_at->diffInMinutes($visit->completed_at);
|
||||||
|
}), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$serviceLabels = collect($this->shell->provisionedServices($organization, 'vaccination'))
|
||||||
|
->pluck('label')
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$revenueByService = BillLineItem::query()
|
||||||
|
->where('owner_ref', $ownerRef)
|
||||||
|
->where('source_type', 'specialty_service')
|
||||||
|
->whereIn('description', $serviceLabels ?: ['__none__'])
|
||||||
|
->whereBetween('created_at', [$rangeFrom, $rangeTo])
|
||||||
|
->whereHas('bill', function ($q) use ($organization, $visitIds) {
|
||||||
|
$q->where('organization_id', $organization->id)
|
||||||
|
->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds)
|
||||||
|
->whereNotIn('status', [Bill::STATUS_VOID]);
|
||||||
|
})
|
||||||
|
->selectRaw('description, count(*) as total, sum(total_minor) as amount_minor')
|
||||||
|
->groupBy('description')
|
||||||
|
->orderByDesc('amount_minor')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'arrivals_today' => $arrivalsToday,
|
||||||
|
'completed_today' => $completedToday,
|
||||||
|
'observation_open' => $observationOpen,
|
||||||
|
'vaccine_breakdown' => $vaccineBreakdown,
|
||||||
|
'aefi_breakdown' => $aefiBreakdown,
|
||||||
|
'avg_los_minutes' => $avgLos,
|
||||||
|
'revenue_by_service' => $revenueByService,
|
||||||
|
'from' => $rangeFrom->toDateString(),
|
||||||
|
'to' => $rangeTo->toDateString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care\Vaccination;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map vaccination clinical progress onto visit stages.
|
||||||
|
*/
|
||||||
|
class VaccinationWorkflowService
|
||||||
|
{
|
||||||
|
public const STAGE_CHECK_IN = 'check_in';
|
||||||
|
|
||||||
|
public const STAGE_ELIGIBILITY = 'eligibility';
|
||||||
|
|
||||||
|
public const STAGE_CONSENT = 'consent';
|
||||||
|
|
||||||
|
public const STAGE_ADMINISTER = 'administer';
|
||||||
|
|
||||||
|
public const STAGE_OBSERVATION = 'observation';
|
||||||
|
|
||||||
|
public const STAGE_COMPLETED = 'completed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromEligibility(array $payload): string
|
||||||
|
{
|
||||||
|
if (! empty($payload['cleared']) || ! empty($payload['vaccine'])) {
|
||||||
|
return self::STAGE_ELIGIBILITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_CHECK_IN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromConsent(array $payload): string
|
||||||
|
{
|
||||||
|
if (! empty($payload['consent_obtained'])) {
|
||||||
|
return self::STAGE_ADMINISTER;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($payload['information_given'])) {
|
||||||
|
return self::STAGE_CONSENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_ELIGIBILITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromAdmin(array $payload): string
|
||||||
|
{
|
||||||
|
$outcome = strtolower((string) ($payload['outcome'] ?? ''));
|
||||||
|
if (str_contains($outcome, 'completed') || ! empty($payload['batch'])) {
|
||||||
|
return self::STAGE_OBSERVATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_ADMINISTER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function stageFromObservation(array $payload): string
|
||||||
|
{
|
||||||
|
if ($this->shouldCompleteVisit($payload)) {
|
||||||
|
return self::STAGE_COMPLETED;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aefi = strtolower((string) ($payload['aefi'] ?? ''));
|
||||||
|
if ($aefi !== '' || ! empty($payload['outcome'])) {
|
||||||
|
return self::STAGE_OBSERVATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STAGE_ADMINISTER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function shouldCompleteVisit(array $payload): bool
|
||||||
|
{
|
||||||
|
$outcome = strtolower((string) ($payload['outcome'] ?? ''));
|
||||||
|
|
||||||
|
return str_contains($outcome, 'completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{next: string, label: string}>
|
||||||
|
*/
|
||||||
|
public function stageFlow(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::STAGE_CHECK_IN => ['next' => self::STAGE_ELIGIBILITY, 'label' => 'Start eligibility'],
|
||||||
|
self::STAGE_ELIGIBILITY => ['next' => self::STAGE_CONSENT, 'label' => 'Move to consent'],
|
||||||
|
self::STAGE_CONSENT => ['next' => self::STAGE_ADMINISTER, 'label' => 'Move to administer'],
|
||||||
|
self::STAGE_ADMINISTER => ['next' => self::STAGE_OBSERVATION, 'label' => 'Move to observation'],
|
||||||
|
self::STAGE_OBSERVATION => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'],
|
||||||
|
self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,16 +108,23 @@ return [
|
|||||||
'postop' => 'surg_postop',
|
'postop' => 'surg_postop',
|
||||||
],
|
],
|
||||||
'vaccination' => [
|
'vaccination' => [
|
||||||
'screening' => 'vax_screening',
|
'eligibility' => 'vax_eligibility',
|
||||||
'administration' => 'vax_admin',
|
'consent' => 'vax_consent',
|
||||||
|
'administer' => 'vax_admin',
|
||||||
|
'observation' => 'vax_observation',
|
||||||
],
|
],
|
||||||
'pathology' => [
|
'pathology' => [
|
||||||
'request' => 'path_request',
|
'request' => 'path_request',
|
||||||
|
'specimen' => 'path_specimen',
|
||||||
|
'processing' => 'path_processing',
|
||||||
'report' => 'path_report',
|
'report' => 'path_report',
|
||||||
|
'verification' => 'path_verification',
|
||||||
],
|
],
|
||||||
'infusion' => [
|
'infusion' => [
|
||||||
|
'assessment' => 'infusion_assessment',
|
||||||
|
'protocol' => 'infusion_protocol',
|
||||||
'session' => 'infusion_session',
|
'session' => 'infusion_session',
|
||||||
'clinical_notes' => 'clinical_note',
|
'monitoring' => 'infusion_monitoring',
|
||||||
],
|
],
|
||||||
'dermatology' => [
|
'dermatology' => [
|
||||||
'exam' => 'skin_exam',
|
'exam' => 'skin_exam',
|
||||||
@@ -759,14 +766,25 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
'vaccination' => [
|
'vaccination' => [
|
||||||
'vax_screening' => [
|
'vax_eligibility' => [
|
||||||
['name' => 'vaccine', 'label' => 'Vaccine', 'type' => 'text', 'required' => true],
|
['name' => 'vaccine', 'label' => 'Vaccine requested', 'type' => 'text', 'required' => true],
|
||||||
|
['name' => 'schedule', 'label' => 'Schedule / indication', 'type' => 'select', 'options' => ['Routine EPI', 'Catch-up', 'Travel', 'Occupational', 'Campaign', 'Other']],
|
||||||
|
['name' => 'prior_doses', 'label' => 'Prior doses / history', 'type' => 'textarea', 'rows' => 2],
|
||||||
['name' => 'indications', 'label' => 'Indications', 'type' => 'textarea', 'rows' => 2],
|
['name' => 'indications', 'label' => 'Indications', 'type' => 'textarea', 'rows' => 2],
|
||||||
['name' => 'contraindications', 'label' => 'Contraindications checked', 'type' => 'boolean', 'required' => true],
|
['name' => 'contraindications', 'label' => 'Contraindications checked', 'type' => 'boolean', 'required' => true],
|
||||||
['name' => 'allergy_history', 'label' => 'Allergy / prior reaction', 'type' => 'textarea', 'rows' => 2],
|
['name' => 'allergy_history', 'label' => 'Allergy / prior reaction', 'type' => 'textarea', 'rows' => 2],
|
||||||
['name' => 'fever_today', 'label' => 'Fever today', 'type' => 'boolean'],
|
['name' => 'fever_today', 'label' => 'Fever today', 'type' => 'boolean'],
|
||||||
|
['name' => 'pregnancy', 'label' => 'Pregnant / breastfeeding', 'type' => 'boolean'],
|
||||||
['name' => 'cleared', 'label' => 'Cleared to vaccinate', 'type' => 'boolean', 'required' => true],
|
['name' => 'cleared', 'label' => 'Cleared to vaccinate', 'type' => 'boolean', 'required' => true],
|
||||||
],
|
],
|
||||||
|
'vax_consent' => [
|
||||||
|
['name' => 'information_given', 'label' => 'Information given', 'type' => 'boolean', 'required' => true],
|
||||||
|
['name' => 'consent_obtained', 'label' => 'Consent obtained', 'type' => 'boolean', 'required' => true],
|
||||||
|
['name' => 'consent_type', 'label' => 'Consent type', 'type' => 'select', 'options' => ['Verbal', 'Written', 'Guardian', 'Implied']],
|
||||||
|
['name' => 'consented_by', 'label' => 'Consented by', 'type' => 'text'],
|
||||||
|
['name' => 'risks_discussed', 'label' => 'Risks / AEFI discussed', 'type' => 'boolean'],
|
||||||
|
['name' => 'notes', 'label' => 'Consent notes', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
],
|
||||||
'vax_admin' => [
|
'vax_admin' => [
|
||||||
['name' => 'vaccine', 'label' => 'Vaccine administered', 'type' => 'text', 'required' => true],
|
['name' => 'vaccine', 'label' => 'Vaccine administered', 'type' => 'text', 'required' => true],
|
||||||
['name' => 'dose', 'label' => 'Dose / antigen', 'type' => 'text'],
|
['name' => 'dose', 'label' => 'Dose / antigen', 'type' => 'text'],
|
||||||
@@ -774,8 +792,18 @@ return [
|
|||||||
['name' => 'batch', 'label' => 'Batch / lot number', 'type' => 'text', 'required' => true],
|
['name' => 'batch', 'label' => 'Batch / lot number', 'type' => 'text', 'required' => true],
|
||||||
['name' => 'expiry', 'label' => 'Expiry date', 'type' => 'text'],
|
['name' => 'expiry', 'label' => 'Expiry date', 'type' => 'text'],
|
||||||
['name' => 'route', 'label' => 'Route', 'type' => 'select', 'options' => ['IM', 'SC', 'ID', 'Oral', 'Nasal']],
|
['name' => 'route', 'label' => 'Route', 'type' => 'select', 'options' => ['IM', 'SC', 'ID', 'Oral', 'Nasal']],
|
||||||
|
['name' => 'administered_by', 'label' => 'Administered by', 'type' => 'text'],
|
||||||
['name' => 'adverse_events', 'label' => 'Immediate adverse events', 'type' => 'textarea', 'rows' => 2],
|
['name' => 'adverse_events', 'label' => 'Immediate adverse events', 'type' => 'textarea', 'rows' => 2],
|
||||||
['name' => 'next_due', 'label' => 'Next dose due', 'type' => 'text'],
|
['name' => 'next_due', 'label' => 'Next dose due', 'type' => 'text'],
|
||||||
|
['name' => 'outcome', 'label' => 'Administration outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed — observe', 'Deferred', 'Refused', 'Incomplete']],
|
||||||
|
],
|
||||||
|
'vax_observation' => [
|
||||||
|
['name' => 'observation_minutes', 'label' => 'Observation minutes', 'type' => 'number'],
|
||||||
|
['name' => 'vitals', 'label' => 'Vitals / condition', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
['name' => 'aefi', 'label' => 'AEFI', 'type' => 'select', 'options' => ['None', 'Mild', 'Moderate', 'Severe — escalate']],
|
||||||
|
['name' => 'aefi_details', 'label' => 'AEFI details / actions', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
['name' => 'advice', 'label' => 'Home advice', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
['name' => 'outcome', 'label' => 'Disposition', 'type' => 'select', 'required' => true, 'options' => ['Completed — discharged', 'Completed — AEFI follow-up', 'Escalated', 'In progress']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'pathology' => [
|
'pathology' => [
|
||||||
@@ -785,15 +813,55 @@ return [
|
|||||||
['name' => 'clinical_history', 'label' => 'Clinical history', 'type' => 'textarea', 'required' => true, 'rows' => 3],
|
['name' => 'clinical_history', 'label' => 'Clinical history', 'type' => 'textarea', 'required' => true, 'rows' => 3],
|
||||||
['name' => 'urgency', 'label' => 'Urgency', 'type' => 'select', 'options' => ['Routine', 'Urgent', 'Intra-op']],
|
['name' => 'urgency', 'label' => 'Urgency', 'type' => 'select', 'options' => ['Routine', 'Urgent', 'Intra-op']],
|
||||||
['name' => 'tests_requested', 'label' => 'Tests requested', 'type' => 'textarea', 'rows' => 2],
|
['name' => 'tests_requested', 'label' => 'Tests requested', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
['name' => 'requesting_clinician', 'label' => 'Requesting clinician', 'type' => 'text'],
|
||||||
|
],
|
||||||
|
'path_specimen' => [
|
||||||
|
['name' => 'received_at', 'label' => 'Received at', 'type' => 'text'],
|
||||||
|
['name' => 'container_label', 'label' => 'Container / accession label', 'type' => 'text', 'required' => true],
|
||||||
|
['name' => 'adequacy', 'label' => 'Specimen adequacy', 'type' => 'select', 'options' => ['Adequate', 'Suboptimal', 'Inadequate']],
|
||||||
|
['name' => 'fixative', 'label' => 'Fixative / medium', 'type' => 'text'],
|
||||||
|
['name' => 'notes', 'label' => 'Receipt notes', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
],
|
||||||
|
'path_processing' => [
|
||||||
|
['name' => 'gross', 'label' => 'Gross description', 'type' => 'textarea', 'rows' => 3],
|
||||||
|
['name' => 'blocks', 'label' => 'Blocks / slides', 'type' => 'text'],
|
||||||
|
['name' => 'stains', 'label' => 'Stains / techniques', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
['name' => 'status', 'label' => 'Processing status', 'type' => 'select', 'required' => true, 'options' => ['In process', 'Ready for reporting', 'Hold — missing info']],
|
||||||
|
['name' => 'notes', 'label' => 'Processing notes', 'type' => 'textarea', 'rows' => 2],
|
||||||
],
|
],
|
||||||
'path_report' => [
|
'path_report' => [
|
||||||
['name' => 'gross', 'label' => 'Gross description', 'type' => 'textarea', 'rows' => 3],
|
['name' => 'gross', 'label' => 'Gross description', 'type' => 'textarea', 'rows' => 3],
|
||||||
['name' => 'microscopy', 'label' => 'Microscopy', 'type' => 'textarea', 'required' => true, 'rows' => 4],
|
['name' => 'microscopy', 'label' => 'Microscopy', 'type' => 'textarea', 'required' => true, 'rows' => 4],
|
||||||
['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'textarea', 'required' => true, 'rows' => 3],
|
['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'textarea', 'required' => true, 'rows' => 3],
|
||||||
['name' => 'comment', 'label' => 'Comment', 'type' => 'textarea', 'rows' => 2],
|
['name' => 'comment', 'label' => 'Comment', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
['name' => 'reported_by', 'label' => 'Reported by', 'type' => 'text'],
|
||||||
|
],
|
||||||
|
'path_verification' => [
|
||||||
|
['name' => 'verified', 'label' => 'Report verified', 'type' => 'boolean', 'required' => true],
|
||||||
|
['name' => 'verified_by', 'label' => 'Verified by', 'type' => 'text'],
|
||||||
|
['name' => 'amendments', 'label' => 'Amendments', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
['name' => 'released', 'label' => 'Released to clinician', 'type' => 'boolean'],
|
||||||
|
['name' => 'outcome', 'label' => 'Verification outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed — released', 'Completed — amended', 'Hold', 'In progress']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'infusion' => [
|
'infusion' => [
|
||||||
|
'infusion_assessment' => [
|
||||||
|
['name' => 'indication', 'label' => 'Indication', 'type' => 'text', 'required' => true],
|
||||||
|
['name' => 'history', 'label' => 'Relevant history', 'type' => 'textarea', 'required' => true, 'rows' => 3],
|
||||||
|
['name' => 'allergies', 'label' => 'Allergies', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
['name' => 'baseline_vitals', 'label' => 'Baseline vitals', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
['name' => 'access_plan', 'label' => 'IV access plan', 'type' => 'text'],
|
||||||
|
['name' => 'fit_for_infusion', 'label' => 'Fit for infusion', 'type' => 'boolean', 'required' => true],
|
||||||
|
],
|
||||||
|
'infusion_protocol' => [
|
||||||
|
['name' => 'medication', 'label' => 'Medication / regimen', 'type' => 'text', 'required' => true],
|
||||||
|
['name' => 'dose', 'label' => 'Dose', 'type' => 'text', 'required' => true],
|
||||||
|
['name' => 'diluent', 'label' => 'Diluent / volume', 'type' => 'text'],
|
||||||
|
['name' => 'rate', 'label' => 'Ordered rate', 'type' => 'text'],
|
||||||
|
['name' => 'duration', 'label' => 'Expected duration', 'type' => 'text'],
|
||||||
|
['name' => 'premeds', 'label' => 'Premedications', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
['name' => 'ordered_by', 'label' => 'Ordered by', 'type' => 'text'],
|
||||||
|
],
|
||||||
'infusion_session' => [
|
'infusion_session' => [
|
||||||
['name' => 'medication', 'label' => 'Medication / regimen', 'type' => 'text', 'required' => true],
|
['name' => 'medication', 'label' => 'Medication / regimen', 'type' => 'text', 'required' => true],
|
||||||
['name' => 'dose', 'label' => 'Dose', 'type' => 'text'],
|
['name' => 'dose', 'label' => 'Dose', 'type' => 'text'],
|
||||||
@@ -804,12 +872,14 @@ return [
|
|||||||
['name' => 'vitals', 'label' => 'Vitals during infusion', 'type' => 'textarea', 'rows' => 2],
|
['name' => 'vitals', 'label' => 'Vitals during infusion', 'type' => 'textarea', 'rows' => 2],
|
||||||
['name' => 'reactions', 'label' => 'Reactions / interventions', 'type' => 'textarea', 'rows' => 2],
|
['name' => 'reactions', 'label' => 'Reactions / interventions', 'type' => 'textarea', 'rows' => 2],
|
||||||
['name' => 'notes', 'label' => 'Session notes', 'type' => 'textarea', 'required' => true, 'rows' => 2],
|
['name' => 'notes', 'label' => 'Session notes', 'type' => 'textarea', 'required' => true, 'rows' => 2],
|
||||||
|
['name' => 'outcome', 'label' => 'Session outcome', 'type' => 'select', 'options' => ['In progress', 'Completed — observe', 'Stopped — reaction', 'Deferred']],
|
||||||
],
|
],
|
||||||
'clinical_note' => [
|
'infusion_monitoring' => [
|
||||||
['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4],
|
['name' => 'post_vitals', 'label' => 'Post-infusion vitals', 'type' => 'textarea', 'rows' => 2],
|
||||||
['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3],
|
['name' => 'reaction', 'label' => 'Delayed reaction', 'type' => 'select', 'options' => ['None', 'Mild', 'Moderate', 'Severe']],
|
||||||
['name' => 'working_diagnosis', 'label' => 'Working diagnosis', 'type' => 'text'],
|
['name' => 'interventions', 'label' => 'Interventions', 'type' => 'textarea', 'rows' => 2],
|
||||||
['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 3],
|
['name' => 'advice', 'label' => 'Discharge advice', 'type' => 'textarea', 'rows' => 2],
|
||||||
|
['name' => 'outcome', 'label' => 'Disposition', 'type' => 'select', 'required' => true, 'options' => ['Completed — discharged', 'Completed — admit / escalate', 'In progress']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'dermatology' => [
|
'dermatology' => [
|
||||||
|
|||||||
@@ -265,10 +265,11 @@ return [
|
|||||||
'queue_keywords' => ['vaccin', 'immun', 'epi', 'injection'],
|
'queue_keywords' => ['vaccin', 'immun', 'epi', 'injection'],
|
||||||
'access' => 'general',
|
'access' => 'general',
|
||||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||||
|
'specialist_keywords' => ['vaccin', 'immun', 'epi'],
|
||||||
],
|
],
|
||||||
'pathology' => [
|
'pathology' => [
|
||||||
'label' => 'Pathology',
|
'label' => 'Pathology',
|
||||||
'description' => 'Histopathology, cytology, and pathology service counters.',
|
'description' => 'Clinic histopathology and cytology specialty flow (complementary to the Lab app queue).',
|
||||||
'department_type' => 'pathology',
|
'department_type' => 'pathology',
|
||||||
'department_name' => 'Pathology',
|
'department_name' => 'Pathology',
|
||||||
'queue_name' => 'Pathology',
|
'queue_name' => 'Pathology',
|
||||||
@@ -292,6 +293,7 @@ return [
|
|||||||
'queue_keywords' => ['infusion', 'iv', 'drip', 'day care'],
|
'queue_keywords' => ['infusion', 'iv', 'drip', 'day care'],
|
||||||
'access' => 'general',
|
'access' => 'general',
|
||||||
'roles' => ['doctor', 'nurse', 'pharmacist', 'receptionist'],
|
'roles' => ['doctor', 'nurse', 'pharmacist', 'receptionist'],
|
||||||
|
'specialist_keywords' => ['infusion', 'iv therapy', 'day care'],
|
||||||
],
|
],
|
||||||
'dermatology' => [
|
'dermatology' => [
|
||||||
'label' => 'Dermatology',
|
'label' => 'Dermatology',
|
||||||
|
|||||||
@@ -624,59 +624,104 @@ return [
|
|||||||
],
|
],
|
||||||
'vaccination' => [
|
'vaccination' => [
|
||||||
'stages' => [
|
'stages' => [
|
||||||
['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'],
|
['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'],
|
||||||
['code' => 'screening', 'label' => 'Screening', 'queue_point' => 'chair'],
|
['code' => 'eligibility', 'label' => 'History / eligibility', 'queue_point' => 'chair'],
|
||||||
['code' => 'vaccinate', 'label' => 'Vaccination', 'queue_point' => 'chair'],
|
['code' => 'consent', 'label' => 'Consent', 'queue_point' => 'chair'],
|
||||||
|
['code' => 'administer', 'label' => 'Administer', 'queue_point' => 'chair'],
|
||||||
|
['code' => 'observation', 'label' => 'Observation / AEFI', 'queue_point' => 'obs'],
|
||||||
['code' => 'completed', 'label' => 'Completed', 'queue_point' => null],
|
['code' => 'completed', 'label' => 'Completed', 'queue_point' => null],
|
||||||
],
|
],
|
||||||
'services' => [
|
'services' => [
|
||||||
|
['code' => 'vax.consult', 'label' => 'Immunization counselling', 'amount_minor' => 2000, 'type' => 'consultation'],
|
||||||
['code' => 'vax.admin', 'label' => 'Vaccine administration', 'amount_minor' => 3000, 'type' => 'procedure'],
|
['code' => 'vax.admin', 'label' => 'Vaccine administration', 'amount_minor' => 3000, 'type' => 'procedure'],
|
||||||
|
['code' => 'vax.travel', 'label' => 'Travel vaccine package', 'amount_minor' => 8000, 'type' => 'procedure'],
|
||||||
],
|
],
|
||||||
'workspace_tabs' => [
|
'workspace_tabs' => [
|
||||||
'overview' => 'Overview',
|
'overview' => 'Overview',
|
||||||
'screening' => 'Screening',
|
'eligibility' => 'History / eligibility',
|
||||||
'administration' => 'Administration',
|
'consent' => 'Consent',
|
||||||
|
'administer' => 'Administer',
|
||||||
|
'observation' => 'Observation / AEFI',
|
||||||
|
'orders' => 'Orders',
|
||||||
'billing' => 'Billing',
|
'billing' => 'Billing',
|
||||||
'documents' => 'Documents',
|
'documents' => 'Documents',
|
||||||
],
|
],
|
||||||
|
'stage_tabs' => [
|
||||||
|
'check_in' => 'overview',
|
||||||
|
'eligibility' => 'eligibility',
|
||||||
|
'consent' => 'consent',
|
||||||
|
'administer' => 'administer',
|
||||||
|
'observation' => 'observation',
|
||||||
|
'completed' => 'overview',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'pathology' => [
|
'pathology' => [
|
||||||
'stages' => [
|
'stages' => [
|
||||||
['code' => 'request', 'label' => 'Specimen received', 'queue_point' => 'waiting'],
|
['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'],
|
||||||
|
['code' => 'request', 'label' => 'Request', 'queue_point' => 'waiting'],
|
||||||
|
['code' => 'specimen', 'label' => 'Specimen', 'queue_point' => 'lab'],
|
||||||
['code' => 'processing', 'label' => 'Processing', 'queue_point' => 'lab'],
|
['code' => 'processing', 'label' => 'Processing', 'queue_point' => 'lab'],
|
||||||
['code' => 'reporting', 'label' => 'Reporting', 'queue_point' => 'lab'],
|
['code' => 'report', 'label' => 'Report', 'queue_point' => 'lab'],
|
||||||
|
['code' => 'verified', 'label' => 'Verified', 'queue_point' => 'lab'],
|
||||||
['code' => 'completed', 'label' => 'Completed', 'queue_point' => null],
|
['code' => 'completed', 'label' => 'Completed', 'queue_point' => null],
|
||||||
],
|
],
|
||||||
'services' => [
|
'services' => [
|
||||||
['code' => 'path.histo', 'label' => 'Histopathology', 'amount_minor' => 20000, 'type' => 'lab'],
|
['code' => 'path.histo', 'label' => 'Histopathology', 'amount_minor' => 20000, 'type' => 'lab'],
|
||||||
['code' => 'path.cyto', 'label' => 'Cytology', 'amount_minor' => 15000, 'type' => 'lab'],
|
['code' => 'path.cyto', 'label' => 'Cytology', 'amount_minor' => 15000, 'type' => 'lab'],
|
||||||
|
['code' => 'path.special', 'label' => 'Special stains / IHC', 'amount_minor' => 25000, 'type' => 'lab'],
|
||||||
],
|
],
|
||||||
'workspace_tabs' => [
|
'workspace_tabs' => [
|
||||||
'overview' => 'Overview',
|
'overview' => 'Overview',
|
||||||
'request' => 'Specimen request',
|
'request' => 'Request',
|
||||||
|
'specimen' => 'Specimen',
|
||||||
|
'processing' => 'Processing',
|
||||||
'report' => 'Report',
|
'report' => 'Report',
|
||||||
|
'verification' => 'Verification',
|
||||||
'billing' => 'Billing',
|
'billing' => 'Billing',
|
||||||
'documents' => 'Documents',
|
'documents' => 'Documents',
|
||||||
],
|
],
|
||||||
|
'stage_tabs' => [
|
||||||
|
'check_in' => 'overview',
|
||||||
|
'request' => 'request',
|
||||||
|
'specimen' => 'specimen',
|
||||||
|
'processing' => 'processing',
|
||||||
|
'report' => 'report',
|
||||||
|
'verified' => 'verification',
|
||||||
|
'completed' => 'overview',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'infusion' => [
|
'infusion' => [
|
||||||
'stages' => [
|
'stages' => [
|
||||||
['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'],
|
['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'],
|
||||||
['code' => 'infusion', 'label' => 'Infusion', 'queue_point' => 'chair'],
|
['code' => 'assessment', 'label' => 'Assessment', 'queue_point' => 'chair'],
|
||||||
['code' => 'observation', 'label' => 'Observation', 'queue_point' => 'obs'],
|
['code' => 'protocol', 'label' => 'Order / protocol', 'queue_point' => 'chair'],
|
||||||
|
['code' => 'session', 'label' => 'Infusion session', 'queue_point' => 'chair'],
|
||||||
|
['code' => 'monitoring', 'label' => 'Monitoring', 'queue_point' => 'obs'],
|
||||||
['code' => 'completed', 'label' => 'Completed', 'queue_point' => null],
|
['code' => 'completed', 'label' => 'Completed', 'queue_point' => null],
|
||||||
],
|
],
|
||||||
'services' => [
|
'services' => [
|
||||||
|
['code' => 'inf.assess', 'label' => 'Infusion assessment', 'amount_minor' => 4000, 'type' => 'consultation'],
|
||||||
['code' => 'inf.session', 'label' => 'Infusion session', 'amount_minor' => 15000, 'type' => 'procedure'],
|
['code' => 'inf.session', 'label' => 'Infusion session', 'amount_minor' => 15000, 'type' => 'procedure'],
|
||||||
|
['code' => 'inf.observe', 'label' => 'Post-infusion observation', 'amount_minor' => 3000, 'type' => 'misc'],
|
||||||
],
|
],
|
||||||
'workspace_tabs' => [
|
'workspace_tabs' => [
|
||||||
'overview' => 'Overview',
|
'overview' => 'Overview',
|
||||||
|
'assessment' => 'Assessment',
|
||||||
|
'protocol' => 'Protocol / order',
|
||||||
'session' => 'Infusion session',
|
'session' => 'Infusion session',
|
||||||
'clinical_notes' => 'Clinical notes',
|
'monitoring' => 'Monitoring',
|
||||||
'orders' => 'Orders',
|
'orders' => 'Orders',
|
||||||
'billing' => 'Billing',
|
'billing' => 'Billing',
|
||||||
'documents' => 'Documents',
|
'documents' => 'Documents',
|
||||||
],
|
],
|
||||||
|
'stage_tabs' => [
|
||||||
|
'check_in' => 'overview',
|
||||||
|
'assessment' => 'assessment',
|
||||||
|
'protocol' => 'protocol',
|
||||||
|
'session' => 'session',
|
||||||
|
'monitoring' => 'monitoring',
|
||||||
|
'completed' => 'overview',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'dermatology' => [
|
'dermatology' => [
|
||||||
'stages' => [
|
'stages' => [
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Infusion summary · {{ $patient->fullName() }}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; color: #0f172a; margin: 2rem; }
|
||||||
|
h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
|
||||||
|
h2 { font-size: .95rem; margin: 1.25rem 0 .5rem; border-bottom: 1px solid #e2e8f0; padding-bottom: .25rem; }
|
||||||
|
.meta { color: #64748b; font-size: .875rem; }
|
||||||
|
dl { display: grid; grid-template-columns: 10rem 1fr; gap: .35rem .75rem; font-size: .875rem; }
|
||||||
|
dt { color: #64748b; }
|
||||||
|
dd { margin: 0; font-weight: 500; }
|
||||||
|
@media print { .no-print { display: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p class="no-print"><a href="{{ route('care.specialty.workspace', ['module' => 'infusion', 'visit' => $visit]) }}">Back</a></p>
|
||||||
|
<h1>{{ $patient->fullName() }}</h1>
|
||||||
|
<p class="meta">{{ $patient->patient_number }} · Visit #{{ $visit->id }} · Stage {{ $visit->specialty_stage ?? '—' }}</p>
|
||||||
|
<h2>Assessment</h2>
|
||||||
|
@if ($assessment)<dl><dt>Indication</dt><dd>{{ $assessment->payload['indication'] ?? '—' }}</dd><dt>Fit</dt><dd>{{ ! empty($assessment->payload['fit_for_infusion']) ? 'Yes' : 'No' }}</dd></dl>@else<p class="meta">No assessment recorded.</p>@endif
|
||||||
|
<h2>Protocol</h2>
|
||||||
|
@if ($protocol)<dl><dt>Medication</dt><dd>{{ $protocol->payload['medication'] ?? '—' }}</dd><dt>Dose</dt><dd>{{ $protocol->payload['dose'] ?? '—' }}</dd><dt>Rate</dt><dd>{{ $protocol->payload['rate'] ?? '—' }}</dd></dl>@else<p class="meta">No protocol recorded.</p>@endif
|
||||||
|
<h2>Session</h2>
|
||||||
|
@if ($session)<dl><dt>Medication</dt><dd>{{ $session->payload['medication'] ?? '—' }}</dd><dt>Start</dt><dd>{{ $session->payload['start_time'] ?? '—' }}</dd><dt>End</dt><dd>{{ $session->payload['end_time'] ?? '—' }}</dd></dl>@else<p class="meta">No session recorded.</p>@endif
|
||||||
|
<h2>Monitoring</h2>
|
||||||
|
@if ($monitoring)<dl><dt>Reaction</dt><dd>{{ $monitoring->payload['reaction'] ?? '—' }}</dd><dt>Outcome</dt><dd>{{ $monitoring->payload['outcome'] ?? '—' }}</dd></dl>@else<p class="meta">No monitoring recorded.</p>@endif
|
||||||
|
<script>window.print();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<x-app-layout title="Infusion reports">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-slate-900">Infusion reports</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Arrivals, open sessions, medications, reactions, length of stay, and infusion revenue.</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('care.specialty.show', 'infusion') }}" class="text-sm font-medium text-indigo-600">← Specialty home</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" class="flex flex-wrap items-end gap-3 rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-600">From</label>
|
||||||
|
<input type="date" name="from" value="{{ $report['from'] ?? '' }}" class="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-600">To</label>
|
||||||
|
<input type="date" name="to" value="{{ $report['to'] ?? '' }}" class="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white">Apply</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-4">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Arrivals today</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">{{ number_format($report['arrivals_today']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Completed today</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">{{ number_format($report['completed_today']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Sessions open</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold tabular-nums text-rose-700">{{ number_format($report['sessions_open']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Avg LOS (range)</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">
|
||||||
|
{{ $report['avg_los_minutes'] !== null ? $report['avg_los_minutes'].' min' : '—' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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">Medications / regimens</h2>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
|
@forelse ($report['medication_breakdown'] as $row)
|
||||||
|
<li class="flex justify-between gap-3">
|
||||||
|
<span>{{ $row['medication'] }}</span>
|
||||||
|
<span class="tabular-nums text-slate-600">{{ $row['total'] }}</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="text-slate-500">No sessions in range.</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">Reactions</h2>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
|
@forelse ($report['reaction_breakdown'] as $row)
|
||||||
|
<li class="flex justify-between gap-3">
|
||||||
|
<span>{{ $row['reaction'] }}</span>
|
||||||
|
<span class="tabular-nums text-slate-600">{{ $row['total'] }}</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="text-slate-500">No monitoring records in range.</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-900">Infusion service revenue</h2>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
|
@forelse ($report['revenue_by_service'] as $row)
|
||||||
|
<li class="flex justify-between gap-3">
|
||||||
|
<span>{{ $row->description }} <span class="text-slate-400">×{{ $row->total }}</span></span>
|
||||||
|
<span class="tabular-nums">{{ $currency }} {{ number_format($row->amount_minor / 100, 2) }}</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="text-slate-500">No billed infusion services in range.</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
@php
|
||||||
|
$record = $infusionMonitoring;
|
||||||
|
$payload = $record?->payload ?? [];
|
||||||
|
$canManage = $canManageClinical ?? $canConsult ?? false;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-slate-900">Monitoring</h3>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">Completed monitoring can close the infusion visit.</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('care.specialty.infusion.print', $workspaceVisit) }}" target="_blank" class="text-sm font-medium text-indigo-600">Print summary</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($canManage)
|
||||||
|
<form method="POST" action="{{ route('care.specialty.infusion.monitoring', $workspaceVisit) }}" class="mt-4 space-y-4">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="tab" value="monitoring">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
@foreach ($clinicalFields ?? [] as $field)
|
||||||
|
@php
|
||||||
|
$name = $field['name'];
|
||||||
|
$value = old('payload.'.$name, $payload[$name] ?? '');
|
||||||
|
$type = $field['type'] ?? 'text';
|
||||||
|
@endphp
|
||||||
|
<div @class(['sm:col-span-2' => in_array($type, ['textarea'], true)])>
|
||||||
|
<label class="block text-xs font-medium text-slate-600">
|
||||||
|
{{ $field['label'] }}
|
||||||
|
@if (! empty($field['required'])) <span class="text-rose-600">*</span> @endif
|
||||||
|
</label>
|
||||||
|
@if ($type === 'textarea')
|
||||||
|
<textarea name="payload[{{ $name }}]" rows="{{ $field['rows'] ?? 3 }}" @required(! empty($field['required']))
|
||||||
|
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">{{ $value }}</textarea>
|
||||||
|
@elseif ($type === 'select')
|
||||||
|
<select name="payload[{{ $name }}]" @required(! empty($field['required']))
|
||||||
|
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<option value="">Select…</option>
|
||||||
|
@foreach ($field['options'] ?? [] as $option)
|
||||||
|
<option value="{{ $option }}" @selected((string) $value === (string) $option)>{{ $option }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@elseif ($type === 'boolean')
|
||||||
|
<label class="mt-2 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="payload[{{ $name }}]" value="1" @checked(old('payload.'.$name, ! empty($payload[$name])))>
|
||||||
|
Yes
|
||||||
|
</label>
|
||||||
|
@else
|
||||||
|
<input type="{{ $type === 'number' ? 'number' : 'text' }}" name="payload[{{ $name }}]" value="{{ $value }}"
|
||||||
|
@required(! empty($field['required']))
|
||||||
|
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Save monitoring</button>
|
||||||
|
</form>
|
||||||
|
@elseif ($record)
|
||||||
|
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
||||||
|
@foreach ($clinicalFields ?? [] as $field)
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">{{ $field['label'] }}</dt>
|
||||||
|
<dd class="font-medium text-slate-900">{{ $payload[$field['name']] ?? '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
@else
|
||||||
|
<p class="mt-4 text-sm text-slate-500">No monitoring recorded yet.</p>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@php
|
||||||
|
$assess = $infusionAssessment?->payload ?? [];
|
||||||
|
$proto = $infusionProtocol?->payload ?? [];
|
||||||
|
$sess = $infusionSession?->payload ?? [];
|
||||||
|
$mon = $infusionMonitoring?->payload ?? [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-slate-900">Infusion overview</h3>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">Assessment, protocol, session, and post-infusion monitoring for this episode.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3 text-sm">
|
||||||
|
<a href="{{ route('care.specialty.infusion.print', $workspaceVisit) }}" target="_blank" class="font-medium text-indigo-600">Print summary</a>
|
||||||
|
<a href="{{ route('care.specialty.infusion.reports') }}" class="font-medium text-indigo-600">Infusion reports</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Indication</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $assess['indication'] ?? '—' }}</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{{ ! empty($assess['fit_for_infusion']) ? 'Fit for infusion' : '—' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Protocol</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $proto['medication'] ?? ($sess['medication'] ?? '—') }}</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{{ $proto['dose'] ?? ($sess['dose'] ?? '—') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Monitoring</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $mon['reaction'] ?? '—' }}</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{{ $mon['outcome'] ?? ($sess['outcome'] ?? '—') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (! empty($clinicalAlerts))
|
||||||
|
<div class="mt-4 space-y-1.5 border-t border-slate-100 pt-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Alerts</p>
|
||||||
|
@foreach ($clinicalAlerts as $alert)
|
||||||
|
<p class="text-sm {{ ($alert['severity'] ?? '') === 'critical' ? 'text-rose-700' : 'text-amber-800' }}">
|
||||||
|
{{ $alert['message'] ?? '' }}
|
||||||
|
</p>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Pathology summary · {{ $patient->fullName() }}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; color: #0f172a; margin: 2rem; }
|
||||||
|
h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
|
||||||
|
h2 { font-size: .95rem; margin: 1.25rem 0 .5rem; border-bottom: 1px solid #e2e8f0; padding-bottom: .25rem; }
|
||||||
|
.meta { color: #64748b; font-size: .875rem; }
|
||||||
|
dl { display: grid; grid-template-columns: 10rem 1fr; gap: .35rem .75rem; font-size: .875rem; }
|
||||||
|
dt { color: #64748b; }
|
||||||
|
dd { margin: 0; font-weight: 500; }
|
||||||
|
@media print { .no-print { display: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p class="no-print"><a href="{{ route('care.specialty.workspace', ['module' => 'pathology', 'visit' => $visit]) }}">Back</a></p>
|
||||||
|
<h1>{{ $patient->fullName() }}</h1>
|
||||||
|
<p class="meta">{{ $patient->patient_number }} · Visit #{{ $visit->id }} · Stage {{ $visit->specialty_stage ?? '—' }}</p>
|
||||||
|
<h2>Request</h2>
|
||||||
|
@if ($requestRecord)<dl><dt>Specimen</dt><dd>{{ $requestRecord->payload['specimen_type'] ?? '—' }}</dd><dt>Site</dt><dd>{{ $requestRecord->payload['site'] ?? '—' }}</dd><dt>History</dt><dd>{{ $requestRecord->payload['clinical_history'] ?? '—' }}</dd></dl>@else<p class="meta">No request recorded.</p>@endif
|
||||||
|
<h2>Specimen</h2>
|
||||||
|
@if ($specimen)<dl><dt>Label</dt><dd>{{ $specimen->payload['container_label'] ?? '—' }}</dd><dt>Adequacy</dt><dd>{{ $specimen->payload['adequacy'] ?? '—' }}</dd></dl>@else<p class="meta">No specimen receipt recorded.</p>@endif
|
||||||
|
<h2>Processing</h2>
|
||||||
|
@if ($processing)<dl><dt>Status</dt><dd>{{ $processing->payload['status'] ?? '—' }}</dd><dt>Gross</dt><dd>{{ $processing->payload['gross'] ?? '—' }}</dd></dl>@else<p class="meta">No processing recorded.</p>@endif
|
||||||
|
<h2>Report</h2>
|
||||||
|
@if ($report)<dl><dt>Microscopy</dt><dd>{{ $report->payload['microscopy'] ?? '—' }}</dd><dt>Diagnosis</dt><dd>{{ $report->payload['diagnosis'] ?? '—' }}</dd></dl>@else<p class="meta">No report recorded.</p>@endif
|
||||||
|
<h2>Verification</h2>
|
||||||
|
@if ($verification)<dl><dt>Verified</dt><dd>{{ ! empty($verification->payload['verified']) ? 'Yes' : 'No' }}</dd><dt>Outcome</dt><dd>{{ $verification->payload['outcome'] ?? '—' }}</dd></dl>@else<p class="meta">No verification recorded.</p>@endif
|
||||||
|
<script>window.print();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<x-app-layout title="Pathology reports">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-slate-900">Pathology reports</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Arrivals, open reports, diagnoses, specimens, length of stay, and pathology revenue.</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('care.specialty.show', 'pathology') }}" class="text-sm font-medium text-indigo-600">← Specialty home</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" class="flex flex-wrap items-end gap-3 rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-600">From</label>
|
||||||
|
<input type="date" name="from" value="{{ $report['from'] ?? '' }}" class="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-600">To</label>
|
||||||
|
<input type="date" name="to" value="{{ $report['to'] ?? '' }}" class="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white">Apply</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-4">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Arrivals today</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">{{ number_format($report['arrivals_today']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Completed today</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">{{ number_format($report['completed_today']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Reports open</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold tabular-nums text-rose-700">{{ number_format($report['reports_open']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Avg LOS (range)</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">
|
||||||
|
{{ $report['avg_los_minutes'] !== null ? $report['avg_los_minutes'].' min' : '—' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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">Diagnoses</h2>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
|
@forelse ($report['diagnosis_breakdown'] as $row)
|
||||||
|
<li class="flex justify-between gap-3">
|
||||||
|
<span>{{ $row['diagnosis'] }}</span>
|
||||||
|
<span class="tabular-nums text-slate-600">{{ $row['total'] }}</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="text-slate-500">No pathology reports in range.</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">Specimen types</h2>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
|
@forelse ($report['specimen_breakdown'] as $row)
|
||||||
|
<li class="flex justify-between gap-3">
|
||||||
|
<span>{{ $row['specimen'] }}</span>
|
||||||
|
<span class="tabular-nums text-slate-600">{{ $row['total'] }}</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="text-slate-500">No requests in range.</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-900">Pathology service revenue</h2>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
|
@forelse ($report['revenue_by_service'] as $row)
|
||||||
|
<li class="flex justify-between gap-3">
|
||||||
|
<span>{{ $row->description }} <span class="text-slate-400">×{{ $row->total }}</span></span>
|
||||||
|
<span class="tabular-nums">{{ $currency }} {{ number_format($row->amount_minor / 100, 2) }}</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="text-slate-500">No billed pathology services in range.</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
@php
|
||||||
|
$req = $pathologyRequest?->payload ?? [];
|
||||||
|
$rep = $pathologyReport?->payload ?? [];
|
||||||
|
$ver = $pathologyVerification?->payload ?? [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-slate-900">Pathology overview</h3>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">Clinic pathology request through verification — complementary to the Lab app queue.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3 text-sm">
|
||||||
|
<a href="{{ route('care.specialty.pathology.print', $workspaceVisit) }}" target="_blank" class="font-medium text-indigo-600">Print summary</a>
|
||||||
|
<a href="{{ route('care.specialty.pathology.reports') }}" class="font-medium text-indigo-600">Pathology reports</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Specimen</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $req['specimen_type'] ?? '—' }}</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{{ $req['site'] ?? '—' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Urgency</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $req['urgency'] ?? '—' }}</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{{ \Illuminate\Support\Str::limit($req['clinical_history'] ?? '—', 40) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Diagnosis</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ \Illuminate\Support\Str::limit($rep['diagnosis'] ?? '—', 40) }}</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{{ $ver['outcome'] ?? (! empty($ver['verified']) ? 'Verified' : '—') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (! empty($clinicalAlerts))
|
||||||
|
<div class="mt-4 space-y-1.5 border-t border-slate-100 pt-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Alerts</p>
|
||||||
|
@foreach ($clinicalAlerts as $alert)
|
||||||
|
<p class="text-sm {{ ($alert['severity'] ?? '') === 'critical' ? 'text-rose-700' : 'text-amber-800' }}">
|
||||||
|
{{ $alert['message'] ?? '' }}
|
||||||
|
</p>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
@php
|
||||||
|
$record = $pathologyVerification;
|
||||||
|
$payload = $record?->payload ?? [];
|
||||||
|
$canManage = $canManageClinical ?? $canConsult ?? false;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-slate-900">Verification</h3>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">Verified / released reports can close the pathology visit.</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('care.specialty.pathology.print', $workspaceVisit) }}" target="_blank" class="text-sm font-medium text-indigo-600">Print summary</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($canManage)
|
||||||
|
<form method="POST" action="{{ route('care.specialty.pathology.verification', $workspaceVisit) }}" class="mt-4 space-y-4">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="tab" value="verification">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
@foreach ($clinicalFields ?? [] as $field)
|
||||||
|
@php
|
||||||
|
$name = $field['name'];
|
||||||
|
$value = old('payload.'.$name, $payload[$name] ?? '');
|
||||||
|
$type = $field['type'] ?? 'text';
|
||||||
|
@endphp
|
||||||
|
<div @class(['sm:col-span-2' => in_array($type, ['textarea'], true)])>
|
||||||
|
<label class="block text-xs font-medium text-slate-600">
|
||||||
|
{{ $field['label'] }}
|
||||||
|
@if (! empty($field['required'])) <span class="text-rose-600">*</span> @endif
|
||||||
|
</label>
|
||||||
|
@if ($type === 'textarea')
|
||||||
|
<textarea name="payload[{{ $name }}]" rows="{{ $field['rows'] ?? 3 }}" @required(! empty($field['required']))
|
||||||
|
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">{{ $value }}</textarea>
|
||||||
|
@elseif ($type === 'select')
|
||||||
|
<select name="payload[{{ $name }}]" @required(! empty($field['required']))
|
||||||
|
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<option value="">Select…</option>
|
||||||
|
@foreach ($field['options'] ?? [] as $option)
|
||||||
|
<option value="{{ $option }}" @selected((string) $value === (string) $option)>{{ $option }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@elseif ($type === 'boolean')
|
||||||
|
<label class="mt-2 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="payload[{{ $name }}]" value="1" @checked(old('payload.'.$name, ! empty($payload[$name])))>
|
||||||
|
Yes
|
||||||
|
</label>
|
||||||
|
@else
|
||||||
|
<input type="{{ $type === 'number' ? 'number' : 'text' }}" name="payload[{{ $name }}]" value="{{ $value }}"
|
||||||
|
@required(! empty($field['required']))
|
||||||
|
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Save verification</button>
|
||||||
|
</form>
|
||||||
|
@elseif ($record)
|
||||||
|
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
||||||
|
@foreach ($clinicalFields ?? [] as $field)
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">{{ $field['label'] }}</dt>
|
||||||
|
<dd class="font-medium text-slate-900">{{ is_bool($payload[$field['name']] ?? null) ? (! empty($payload[$field['name']]) ? 'Yes' : 'No') : ($payload[$field['name']] ?? '—') }}</dd>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
@else
|
||||||
|
<p class="mt-4 text-sm text-slate-500">No verification recorded yet.</p>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
@@ -85,6 +85,12 @@
|
|||||||
@include('care.specialty.renal.workspace-'.$activeTab)
|
@include('care.specialty.renal.workspace-'.$activeTab)
|
||||||
@elseif ($moduleKey === 'surgery' && in_array($activeTab, ['overview', 'postop'], true))
|
@elseif ($moduleKey === 'surgery' && in_array($activeTab, ['overview', 'postop'], true))
|
||||||
@include('care.specialty.surgery.workspace-'.$activeTab)
|
@include('care.specialty.surgery.workspace-'.$activeTab)
|
||||||
|
@elseif ($moduleKey === 'vaccination' && in_array($activeTab, ['overview', 'observation'], true))
|
||||||
|
@include('care.specialty.vaccination.workspace-'.$activeTab)
|
||||||
|
@elseif ($moduleKey === 'pathology' && in_array($activeTab, ['overview', 'verification'], true))
|
||||||
|
@include('care.specialty.pathology.workspace-'.$activeTab)
|
||||||
|
@elseif ($moduleKey === 'infusion' && in_array($activeTab, ['overview', 'monitoring'], true))
|
||||||
|
@include('care.specialty.infusion.workspace-'.$activeTab)
|
||||||
@elseif ($activeTab === 'billing')
|
@elseif ($activeTab === 'billing')
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
<h3 class="text-sm font-semibold text-slate-900">
|
<h3 class="text-sm font-semibold text-slate-900">
|
||||||
|
|||||||
@@ -70,6 +70,15 @@
|
|||||||
@if ($moduleKey === 'surgery')
|
@if ($moduleKey === 'surgery')
|
||||||
<a href="{{ route('care.specialty.surgery.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
|
<a href="{{ route('care.specialty.surgery.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($moduleKey === 'vaccination')
|
||||||
|
<a href="{{ route('care.specialty.vaccination.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
|
||||||
|
@endif
|
||||||
|
@if ($moduleKey === 'pathology')
|
||||||
|
<a href="{{ route('care.specialty.pathology.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
|
||||||
|
@endif
|
||||||
|
@if ($moduleKey === 'infusion')
|
||||||
|
<a href="{{ route('care.specialty.infusion.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
|
||||||
|
@endif
|
||||||
<a href="{{ route('care.specialty.history', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">History</a>
|
<a href="{{ route('care.specialty.history', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">History</a>
|
||||||
@if ($canManageClinical ?? $canManageSpecialty ?? true)
|
@if ($canManageClinical ?? $canManageSpecialty ?? true)
|
||||||
<a href="{{ route('care.specialty.billing', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">Billing</a>
|
<a href="{{ route('care.specialty.billing', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">Billing</a>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Vaccination summary · {{ $patient->fullName() }}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; color: #0f172a; margin: 2rem; }
|
||||||
|
h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
|
||||||
|
h2 { font-size: .95rem; margin: 1.25rem 0 .5rem; border-bottom: 1px solid #e2e8f0; padding-bottom: .25rem; }
|
||||||
|
.meta { color: #64748b; font-size: .875rem; }
|
||||||
|
dl { display: grid; grid-template-columns: 10rem 1fr; gap: .35rem .75rem; font-size: .875rem; }
|
||||||
|
dt { color: #64748b; }
|
||||||
|
dd { margin: 0; font-weight: 500; }
|
||||||
|
@media print { .no-print { display: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p class="no-print"><a href="{{ route('care.specialty.workspace', ['module' => 'vaccination', 'visit' => $visit]) }}">Back</a></p>
|
||||||
|
<h1>{{ $patient->fullName() }}</h1>
|
||||||
|
<p class="meta">{{ $patient->patient_number }} · Visit #{{ $visit->id }} · Stage {{ $visit->specialty_stage ?? '—' }}</p>
|
||||||
|
<h2>Eligibility</h2>
|
||||||
|
@if ($eligibility)<dl><dt>Vaccine</dt><dd>{{ $eligibility->payload['vaccine'] ?? '—' }}</dd><dt>Cleared</dt><dd>{{ ! empty($eligibility->payload['cleared']) ? 'Yes' : 'No' }}</dd></dl>@else<p class="meta">No eligibility recorded.</p>@endif
|
||||||
|
<h2>Consent</h2>
|
||||||
|
@if ($consent)<dl><dt>Consent</dt><dd>{{ ! empty($consent->payload['consent_obtained']) ? 'Obtained' : '—' }}</dd><dt>Type</dt><dd>{{ $consent->payload['consent_type'] ?? '—' }}</dd></dl>@else<p class="meta">No consent recorded.</p>@endif
|
||||||
|
<h2>Administration</h2>
|
||||||
|
@if ($admin)<dl><dt>Vaccine</dt><dd>{{ $admin->payload['vaccine'] ?? '—' }}</dd><dt>Lot</dt><dd>{{ $admin->payload['batch'] ?? '—' }}</dd><dt>Site</dt><dd>{{ $admin->payload['site'] ?? '—' }}</dd></dl>@else<p class="meta">No administration recorded.</p>@endif
|
||||||
|
<h2>Observation / AEFI</h2>
|
||||||
|
@if ($observation)<dl><dt>AEFI</dt><dd>{{ $observation->payload['aefi'] ?? '—' }}</dd><dt>Outcome</dt><dd>{{ $observation->payload['outcome'] ?? '—' }}</dd></dl>@else<p class="meta">No observation recorded.</p>@endif
|
||||||
|
<script>window.print();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<x-app-layout title="Vaccination reports">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-slate-900">Vaccination reports</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Arrivals, open observation notes, vaccines, AEFI, length of stay, and vaccination revenue.</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('care.specialty.show', 'vaccination') }}" class="text-sm font-medium text-indigo-600">← Specialty home</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" class="flex flex-wrap items-end gap-3 rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-600">From</label>
|
||||||
|
<input type="date" name="from" value="{{ $report['from'] ?? '' }}" class="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-600">To</label>
|
||||||
|
<input type="date" name="to" value="{{ $report['to'] ?? '' }}" class="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white">Apply</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-4">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Arrivals today</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">{{ number_format($report['arrivals_today']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Completed today</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">{{ number_format($report['completed_today']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Observation open</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold tabular-nums text-rose-700">{{ number_format($report['observation_open']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Avg LOS (range)</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">
|
||||||
|
{{ $report['avg_los_minutes'] !== null ? $report['avg_los_minutes'].' min' : '—' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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">Vaccines administered</h2>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
|
@forelse ($report['vaccine_breakdown'] as $row)
|
||||||
|
<li class="flex justify-between gap-3">
|
||||||
|
<span>{{ $row['vaccine'] }}</span>
|
||||||
|
<span class="tabular-nums text-slate-600">{{ $row['total'] }}</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="text-slate-500">No administrations in range.</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">AEFI</h2>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
|
@forelse ($report['aefi_breakdown'] as $row)
|
||||||
|
<li class="flex justify-between gap-3">
|
||||||
|
<span>{{ $row['aefi'] }}</span>
|
||||||
|
<span class="tabular-nums text-slate-600">{{ $row['total'] }}</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="text-slate-500">No observation records in range.</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-900">Vaccination service revenue</h2>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
|
@forelse ($report['revenue_by_service'] as $row)
|
||||||
|
<li class="flex justify-between gap-3">
|
||||||
|
<span>{{ $row->description }} <span class="text-slate-400">×{{ $row->total }}</span></span>
|
||||||
|
<span class="tabular-nums">{{ $currency }} {{ number_format($row->amount_minor / 100, 2) }}</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="text-slate-500">No billed vaccination services in range.</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
@php
|
||||||
|
$record = $vaccinationObservation;
|
||||||
|
$payload = $record?->payload ?? [];
|
||||||
|
$canManage = $canManageClinical ?? $canConsult ?? false;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-slate-900">Observation / AEFI</h3>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">Completed observation can close the vaccination visit.</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('care.specialty.vaccination.print', $workspaceVisit) }}" target="_blank" class="text-sm font-medium text-indigo-600">Print summary</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($canManage)
|
||||||
|
<form method="POST" action="{{ route('care.specialty.vaccination.observation', $workspaceVisit) }}" class="mt-4 space-y-4">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="tab" value="observation">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
@foreach ($clinicalFields ?? [] as $field)
|
||||||
|
@php
|
||||||
|
$name = $field['name'];
|
||||||
|
$value = old('payload.'.$name, $payload[$name] ?? '');
|
||||||
|
$type = $field['type'] ?? 'text';
|
||||||
|
@endphp
|
||||||
|
<div @class(['sm:col-span-2' => in_array($type, ['textarea'], true)])>
|
||||||
|
<label class="block text-xs font-medium text-slate-600">
|
||||||
|
{{ $field['label'] }}
|
||||||
|
@if (! empty($field['required'])) <span class="text-rose-600">*</span> @endif
|
||||||
|
</label>
|
||||||
|
@if ($type === 'textarea')
|
||||||
|
<textarea name="payload[{{ $name }}]" rows="{{ $field['rows'] ?? 3 }}" @required(! empty($field['required']))
|
||||||
|
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">{{ $value }}</textarea>
|
||||||
|
@elseif ($type === 'select')
|
||||||
|
<select name="payload[{{ $name }}]" @required(! empty($field['required']))
|
||||||
|
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<option value="">Select…</option>
|
||||||
|
@foreach ($field['options'] ?? [] as $option)
|
||||||
|
<option value="{{ $option }}" @selected((string) $value === (string) $option)>{{ $option }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@elseif ($type === 'boolean')
|
||||||
|
<label class="mt-2 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="payload[{{ $name }}]" value="1" @checked(old('payload.'.$name, ! empty($payload[$name])))>
|
||||||
|
Yes
|
||||||
|
</label>
|
||||||
|
@else
|
||||||
|
<input type="{{ $type === 'number' ? 'number' : 'text' }}" name="payload[{{ $name }}]" value="{{ $value }}"
|
||||||
|
@required(! empty($field['required']))
|
||||||
|
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Save observation</button>
|
||||||
|
</form>
|
||||||
|
@elseif ($record)
|
||||||
|
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
||||||
|
@foreach ($clinicalFields ?? [] as $field)
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">{{ $field['label'] }}</dt>
|
||||||
|
<dd class="font-medium text-slate-900">{{ $payload[$field['name']] ?? '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
@else
|
||||||
|
<p class="mt-4 text-sm text-slate-500">No observation recorded yet.</p>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
@php
|
||||||
|
$elig = $vaccinationEligibility?->payload ?? [];
|
||||||
|
$admin = $vaccinationAdmin?->payload ?? [];
|
||||||
|
$obs = $vaccinationObservation?->payload ?? [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-slate-900">Vaccination overview</h3>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">Eligibility, consent, administration, and AEFI observation for this episode.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3 text-sm">
|
||||||
|
<a href="{{ route('care.specialty.vaccination.print', $workspaceVisit) }}" target="_blank" class="font-medium text-indigo-600">Print summary</a>
|
||||||
|
<a href="{{ route('care.specialty.vaccination.reports') }}" class="font-medium text-indigo-600">Vaccination reports</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Vaccine</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $admin['vaccine'] ?? ($elig['vaccine'] ?? '—') }}</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Lot {{ $admin['batch'] ?? '—' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Cleared</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ ! empty($elig['cleared']) ? 'Yes' : '—' }}</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{{ $elig['schedule'] ?? '—' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">AEFI / disposition</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $obs['aefi'] ?? '—' }}</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{{ $obs['outcome'] ?? '—' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (! empty($clinicalAlerts))
|
||||||
|
<div class="mt-4 space-y-1.5 border-t border-slate-100 pt-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Alerts</p>
|
||||||
|
@foreach ($clinicalAlerts as $alert)
|
||||||
|
<p class="text-sm {{ ($alert['severity'] ?? '') === 'critical' ? 'text-rose-700' : 'text-amber-800' }}">
|
||||||
|
{{ $alert['message'] ?? '' }}
|
||||||
|
</p>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
@@ -53,6 +53,9 @@ use App\Http\Controllers\Care\EntWorkspaceController;
|
|||||||
use App\Http\Controllers\Care\OncologyWorkspaceController;
|
use App\Http\Controllers\Care\OncologyWorkspaceController;
|
||||||
use App\Http\Controllers\Care\RenalWorkspaceController;
|
use App\Http\Controllers\Care\RenalWorkspaceController;
|
||||||
use App\Http\Controllers\Care\SurgeryWorkspaceController;
|
use App\Http\Controllers\Care\SurgeryWorkspaceController;
|
||||||
|
use App\Http\Controllers\Care\VaccinationWorkspaceController;
|
||||||
|
use App\Http\Controllers\Care\PathologyWorkspaceController;
|
||||||
|
use App\Http\Controllers\Care\InfusionWorkspaceController;
|
||||||
use App\Http\Controllers\Care\SpecialtyModuleController;
|
use App\Http\Controllers\Care\SpecialtyModuleController;
|
||||||
use App\Http\Controllers\Care\VisitWorkflowController;
|
use App\Http\Controllers\Care\VisitWorkflowController;
|
||||||
use App\Http\Controllers\NotificationController;
|
use App\Http\Controllers\NotificationController;
|
||||||
@@ -147,6 +150,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
|||||||
Route::get('/specialty/oncology/reports', [OncologyWorkspaceController::class, 'reports'])->name('care.specialty.oncology.reports');
|
Route::get('/specialty/oncology/reports', [OncologyWorkspaceController::class, 'reports'])->name('care.specialty.oncology.reports');
|
||||||
Route::get('/specialty/renal/reports', [RenalWorkspaceController::class, 'reports'])->name('care.specialty.renal.reports');
|
Route::get('/specialty/renal/reports', [RenalWorkspaceController::class, 'reports'])->name('care.specialty.renal.reports');
|
||||||
Route::get('/specialty/surgery/reports', [SurgeryWorkspaceController::class, 'reports'])->name('care.specialty.surgery.reports');
|
Route::get('/specialty/surgery/reports', [SurgeryWorkspaceController::class, 'reports'])->name('care.specialty.surgery.reports');
|
||||||
|
Route::get('/specialty/vaccination/reports', [VaccinationWorkspaceController::class, 'reports'])->name('care.specialty.vaccination.reports');
|
||||||
|
Route::get('/specialty/pathology/reports', [PathologyWorkspaceController::class, 'reports'])->name('care.specialty.pathology.reports');
|
||||||
|
Route::get('/specialty/infusion/reports', [InfusionWorkspaceController::class, 'reports'])->name('care.specialty.infusion.reports');
|
||||||
Route::get('/specialty/{module}/workspace/{visit?}', [SpecialtyModuleController::class, 'workspace'])->name('care.specialty.workspace');
|
Route::get('/specialty/{module}/workspace/{visit?}', [SpecialtyModuleController::class, 'workspace'])->name('care.specialty.workspace');
|
||||||
Route::post('/specialty/{module}/workspace/{visit}/clinical', [SpecialtyModuleController::class, 'saveClinical'])->name('care.specialty.clinical.save');
|
Route::post('/specialty/{module}/workspace/{visit}/clinical', [SpecialtyModuleController::class, 'saveClinical'])->name('care.specialty.clinical.save');
|
||||||
Route::post('/specialty/{module}/workspace/{visit}/documents', [SpecialtyModuleController::class, 'uploadDocument'])->name('care.specialty.documents.upload');
|
Route::post('/specialty/{module}/workspace/{visit}/documents', [SpecialtyModuleController::class, 'uploadDocument'])->name('care.specialty.documents.upload');
|
||||||
@@ -216,6 +222,15 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
|||||||
Route::post('/specialty/surgery/workspace/{visit}/stage', [SurgeryWorkspaceController::class, 'setStage'])->name('care.specialty.surgery.stage');
|
Route::post('/specialty/surgery/workspace/{visit}/stage', [SurgeryWorkspaceController::class, 'setStage'])->name('care.specialty.surgery.stage');
|
||||||
Route::post('/specialty/surgery/workspace/{visit}/postop', [SurgeryWorkspaceController::class, 'savePostop'])->name('care.specialty.surgery.postop');
|
Route::post('/specialty/surgery/workspace/{visit}/postop', [SurgeryWorkspaceController::class, 'savePostop'])->name('care.specialty.surgery.postop');
|
||||||
Route::get('/specialty/surgery/workspace/{visit}/print', [SurgeryWorkspaceController::class, 'printSummary'])->name('care.specialty.surgery.print');
|
Route::get('/specialty/surgery/workspace/{visit}/print', [SurgeryWorkspaceController::class, 'printSummary'])->name('care.specialty.surgery.print');
|
||||||
|
Route::post('/specialty/vaccination/workspace/{visit}/stage', [VaccinationWorkspaceController::class, 'setStage'])->name('care.specialty.vaccination.stage');
|
||||||
|
Route::post('/specialty/vaccination/workspace/{visit}/observation', [VaccinationWorkspaceController::class, 'saveObservation'])->name('care.specialty.vaccination.observation');
|
||||||
|
Route::get('/specialty/vaccination/workspace/{visit}/print', [VaccinationWorkspaceController::class, 'printSummary'])->name('care.specialty.vaccination.print');
|
||||||
|
Route::post('/specialty/pathology/workspace/{visit}/stage', [PathologyWorkspaceController::class, 'setStage'])->name('care.specialty.pathology.stage');
|
||||||
|
Route::post('/specialty/pathology/workspace/{visit}/verification', [PathologyWorkspaceController::class, 'saveVerification'])->name('care.specialty.pathology.verification');
|
||||||
|
Route::get('/specialty/pathology/workspace/{visit}/print', [PathologyWorkspaceController::class, 'printSummary'])->name('care.specialty.pathology.print');
|
||||||
|
Route::post('/specialty/infusion/workspace/{visit}/stage', [InfusionWorkspaceController::class, 'setStage'])->name('care.specialty.infusion.stage');
|
||||||
|
Route::post('/specialty/infusion/workspace/{visit}/monitoring', [InfusionWorkspaceController::class, 'saveMonitoring'])->name('care.specialty.infusion.monitoring');
|
||||||
|
Route::get('/specialty/infusion/workspace/{visit}/print', [InfusionWorkspaceController::class, 'printSummary'])->name('care.specialty.infusion.print');
|
||||||
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');
|
||||||
Route::post('/specialty/{module}/refer', [SpecialtyModuleController::class, 'refer'])->name('care.specialty.refer');
|
Route::post('/specialty/{module}/refer', [SpecialtyModuleController::class, 'refer'])->name('care.specialty.refer');
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Department;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\SpecialtyClinicalRecord;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\SpecialtyModuleService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CareInfusionSuiteTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $owner;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Branch $branch;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected Visit $visit;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->owner = User::create([
|
||||||
|
'public_id' => 'inf-owner',
|
||||||
|
'name' => 'Owner',
|
||||||
|
'email' => 'inf-owner@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'name' => 'Infusion Clinic',
|
||||||
|
'slug' => 'inf-owner-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',
|
||||||
|
'branch_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->branch = Branch::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(SpecialtyModuleService::class)->activate(
|
||||||
|
$this->organization,
|
||||||
|
$this->owner->public_id,
|
||||||
|
'infusion',
|
||||||
|
);
|
||||||
|
|
||||||
|
$department = Department::query()
|
||||||
|
->where('branch_id', $this->branch->id)
|
||||||
|
->where('type', 'infusion')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_number' => 'P-INF-SUITE',
|
||||||
|
'first_name' => 'Efua',
|
||||||
|
'last_name' => 'Infusion',
|
||||||
|
'gender' => 'female',
|
||||||
|
'date_of_birth' => '1992-11-03',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->visit = Visit::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Visit::STATUS_IN_PROGRESS,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
'specialty_stage' => 'check_in',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Appointment::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'department_id' => $department->id,
|
||||||
|
'visit_id' => $this->visit->id,
|
||||||
|
'type' => Appointment::TYPE_WALK_IN,
|
||||||
|
'status' => Appointment::STATUS_IN_CONSULTATION,
|
||||||
|
'scheduled_at' => now(),
|
||||||
|
'waiting_at' => now(),
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_overview_and_clinical_tabs_render(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.workspace', [
|
||||||
|
'module' => 'infusion',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
'tab' => 'overview',
|
||||||
|
]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Infusion overview')
|
||||||
|
->assertSee('data-care-stage-bar', false)
|
||||||
|
->assertSee('Start assessment')
|
||||||
|
->assertSee('Infusion reports');
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.workspace', [
|
||||||
|
'module' => 'infusion',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
'tab' => 'assessment',
|
||||||
|
]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Fit for infusion');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_clinical_save_sets_stage_and_alerts(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->post(route('care.specialty.clinical.save', [
|
||||||
|
'module' => 'infusion',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
]), [
|
||||||
|
'tab' => 'assessment',
|
||||||
|
'payload' => [
|
||||||
|
'indication' => 'IV iron',
|
||||||
|
'history' => 'Symptomatic anaemia',
|
||||||
|
'fit_for_infusion' => '0',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame('assessment', $this->visit->fresh()->specialty_stage);
|
||||||
|
|
||||||
|
$record = SpecialtyClinicalRecord::query()
|
||||||
|
->where('visit_id', $this->visit->id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$codes = collect($record->alerts)->pluck('code')->all();
|
||||||
|
$this->assertContains('inf.not_fit', $codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stage_advance_and_terminal_completes_visit(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->post(route('care.specialty.infusion.stage', $this->visit), [
|
||||||
|
'stage' => 'monitoring',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('care.specialty.workspace', [
|
||||||
|
'module' => 'infusion',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
'tab' => 'monitoring',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertSame('monitoring', $this->visit->fresh()->specialty_stage);
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->post(route('care.specialty.infusion.monitoring', $this->visit), [
|
||||||
|
'tab' => 'monitoring',
|
||||||
|
'payload' => [
|
||||||
|
'post_vitals' => 'Stable',
|
||||||
|
'reaction' => 'None',
|
||||||
|
'advice' => 'Return if fever',
|
||||||
|
'outcome' => 'Completed — discharged',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('care.specialty.workspace', [
|
||||||
|
'module' => 'infusion',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
'tab' => 'monitoring',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertSame('completed', $this->visit->fresh()->specialty_stage);
|
||||||
|
$this->assertSame(Visit::STATUS_COMPLETED, $this->visit->fresh()->status);
|
||||||
|
$this->assertDatabaseHas('care_specialty_clinical_records', [
|
||||||
|
'visit_id' => $this->visit->id,
|
||||||
|
'record_type' => 'infusion_monitoring',
|
||||||
|
'status' => SpecialtyClinicalRecord::STATUS_COMPLETED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reports_and_print(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.infusion.reports'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Infusion reports')
|
||||||
|
->assertSee('Arrivals today');
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.infusion.print', $this->visit))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee($this->patient->fullName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_list_index_does_not_auto_open_patient(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.show', 'infusion'))
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('Infusion overview');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Department;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\SpecialtyClinicalRecord;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\SpecialtyModuleService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CarePathologySuiteTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $owner;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Branch $branch;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected Visit $visit;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->owner = User::create([
|
||||||
|
'public_id' => 'path-owner',
|
||||||
|
'name' => 'Owner',
|
||||||
|
'email' => 'path-owner@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'name' => 'Pathology Clinic',
|
||||||
|
'slug' => 'path-owner-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',
|
||||||
|
'branch_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->branch = Branch::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(SpecialtyModuleService::class)->activate(
|
||||||
|
$this->organization,
|
||||||
|
$this->owner->public_id,
|
||||||
|
'pathology',
|
||||||
|
);
|
||||||
|
|
||||||
|
$department = Department::query()
|
||||||
|
->where('branch_id', $this->branch->id)
|
||||||
|
->where('type', 'pathology')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_number' => 'P-PATH-SUITE',
|
||||||
|
'first_name' => 'Ama',
|
||||||
|
'last_name' => 'Biopsy',
|
||||||
|
'gender' => 'female',
|
||||||
|
'date_of_birth' => '1985-08-20',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->visit = Visit::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Visit::STATUS_IN_PROGRESS,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
'specialty_stage' => 'check_in',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Appointment::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'department_id' => $department->id,
|
||||||
|
'visit_id' => $this->visit->id,
|
||||||
|
'type' => Appointment::TYPE_WALK_IN,
|
||||||
|
'status' => Appointment::STATUS_IN_CONSULTATION,
|
||||||
|
'scheduled_at' => now(),
|
||||||
|
'waiting_at' => now(),
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_overview_and_clinical_tabs_render(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.workspace', [
|
||||||
|
'module' => 'pathology',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
'tab' => 'overview',
|
||||||
|
]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Pathology overview')
|
||||||
|
->assertSee('data-care-stage-bar', false)
|
||||||
|
->assertSee('Start request')
|
||||||
|
->assertSee('Pathology reports');
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.workspace', [
|
||||||
|
'module' => 'pathology',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
'tab' => 'request',
|
||||||
|
]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Specimen type');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_clinical_save_sets_stage_and_alerts(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->post(route('care.specialty.clinical.save', [
|
||||||
|
'module' => 'pathology',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
]), [
|
||||||
|
'tab' => 'request',
|
||||||
|
'payload' => [
|
||||||
|
'specimen_type' => 'Breast core biopsy',
|
||||||
|
'site' => 'Left breast',
|
||||||
|
'clinical_history' => 'Palpable mass',
|
||||||
|
'urgency' => 'Urgent',
|
||||||
|
'tests_requested' => 'Histo',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame('request', $this->visit->fresh()->specialty_stage);
|
||||||
|
|
||||||
|
$record = SpecialtyClinicalRecord::query()
|
||||||
|
->where('visit_id', $this->visit->id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$codes = collect($record->alerts)->pluck('code')->all();
|
||||||
|
$this->assertContains('path.urgent', $codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stage_advance_and_terminal_completes_visit(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->post(route('care.specialty.pathology.stage', $this->visit), [
|
||||||
|
'stage' => 'verified',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('care.specialty.workspace', [
|
||||||
|
'module' => 'pathology',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
'tab' => 'verification',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertSame('verified', $this->visit->fresh()->specialty_stage);
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->post(route('care.specialty.pathology.verification', $this->visit), [
|
||||||
|
'tab' => 'verification',
|
||||||
|
'payload' => [
|
||||||
|
'verified' => '1',
|
||||||
|
'verified_by' => 'Dr Path',
|
||||||
|
'released' => '1',
|
||||||
|
'outcome' => 'Completed — released',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('care.specialty.workspace', [
|
||||||
|
'module' => 'pathology',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
'tab' => 'verification',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertSame('completed', $this->visit->fresh()->specialty_stage);
|
||||||
|
$this->assertSame(Visit::STATUS_COMPLETED, $this->visit->fresh()->status);
|
||||||
|
$this->assertDatabaseHas('care_specialty_clinical_records', [
|
||||||
|
'visit_id' => $this->visit->id,
|
||||||
|
'record_type' => 'path_verification',
|
||||||
|
'status' => SpecialtyClinicalRecord::STATUS_COMPLETED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reports_and_print(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.pathology.reports'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Pathology reports')
|
||||||
|
->assertSee('Arrivals today');
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.pathology.print', $this->visit))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee($this->patient->fullName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_list_index_does_not_auto_open_patient(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.show', 'pathology'))
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('Pathology overview');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Department;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\SpecialtyClinicalRecord;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\SpecialtyModuleService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CareVaccinationSuiteTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $owner;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Branch $branch;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected Visit $visit;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->owner = User::create([
|
||||||
|
'public_id' => 'vax-owner',
|
||||||
|
'name' => 'Owner',
|
||||||
|
'email' => 'vax-owner@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'name' => 'Vaccination Clinic',
|
||||||
|
'slug' => 'vax-owner-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',
|
||||||
|
'branch_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->branch = Branch::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(SpecialtyModuleService::class)->activate(
|
||||||
|
$this->organization,
|
||||||
|
$this->owner->public_id,
|
||||||
|
'vaccination',
|
||||||
|
);
|
||||||
|
|
||||||
|
$department = Department::query()
|
||||||
|
->where('branch_id', $this->branch->id)
|
||||||
|
->where('type', 'vaccination')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_number' => 'P-VAX-SUITE',
|
||||||
|
'first_name' => 'Kwame',
|
||||||
|
'last_name' => 'Vaccine',
|
||||||
|
'gender' => 'male',
|
||||||
|
'date_of_birth' => '1990-04-12',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->visit = Visit::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Visit::STATUS_IN_PROGRESS,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
'specialty_stage' => 'check_in',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Appointment::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'department_id' => $department->id,
|
||||||
|
'visit_id' => $this->visit->id,
|
||||||
|
'type' => Appointment::TYPE_WALK_IN,
|
||||||
|
'status' => Appointment::STATUS_IN_CONSULTATION,
|
||||||
|
'scheduled_at' => now(),
|
||||||
|
'waiting_at' => now(),
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_overview_and_clinical_tabs_render(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.workspace', [
|
||||||
|
'module' => 'vaccination',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
'tab' => 'overview',
|
||||||
|
]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Vaccination overview')
|
||||||
|
->assertSee('data-care-stage-bar', false)
|
||||||
|
->assertSee('Start eligibility')
|
||||||
|
->assertSee('Vaccination reports');
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.workspace', [
|
||||||
|
'module' => 'vaccination',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
'tab' => 'eligibility',
|
||||||
|
]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Cleared to vaccinate');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_clinical_save_sets_stage_and_alerts(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->post(route('care.specialty.clinical.save', [
|
||||||
|
'module' => 'vaccination',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
]), [
|
||||||
|
'tab' => 'eligibility',
|
||||||
|
'payload' => [
|
||||||
|
'vaccine' => 'Yellow fever',
|
||||||
|
'schedule' => 'Travel',
|
||||||
|
'contraindications' => '1',
|
||||||
|
'fever_today' => '1',
|
||||||
|
'cleared' => '0',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame('eligibility', $this->visit->fresh()->specialty_stage);
|
||||||
|
|
||||||
|
$record = SpecialtyClinicalRecord::query()
|
||||||
|
->where('visit_id', $this->visit->id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$codes = collect($record->alerts)->pluck('code')->all();
|
||||||
|
$this->assertContains('vax.not_cleared', $codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stage_advance_and_terminal_completes_visit(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->post(route('care.specialty.vaccination.stage', $this->visit), [
|
||||||
|
'stage' => 'observation',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('care.specialty.workspace', [
|
||||||
|
'module' => 'vaccination',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
'tab' => 'observation',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertSame('observation', $this->visit->fresh()->specialty_stage);
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->post(route('care.specialty.vaccination.observation', $this->visit), [
|
||||||
|
'tab' => 'observation',
|
||||||
|
'payload' => [
|
||||||
|
'observation_minutes' => '15',
|
||||||
|
'vitals' => 'Stable',
|
||||||
|
'aefi' => 'None',
|
||||||
|
'advice' => 'Paracetamol PRN',
|
||||||
|
'outcome' => 'Completed — discharged',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('care.specialty.workspace', [
|
||||||
|
'module' => 'vaccination',
|
||||||
|
'visit' => $this->visit,
|
||||||
|
'tab' => 'observation',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertSame('completed', $this->visit->fresh()->specialty_stage);
|
||||||
|
$this->assertSame(Visit::STATUS_COMPLETED, $this->visit->fresh()->status);
|
||||||
|
$this->assertDatabaseHas('care_specialty_clinical_records', [
|
||||||
|
'visit_id' => $this->visit->id,
|
||||||
|
'record_type' => 'vax_observation',
|
||||||
|
'status' => SpecialtyClinicalRecord::STATUS_COMPLETED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reports_and_print(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.vaccination.reports'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Vaccination reports')
|
||||||
|
->assertSee('Arrivals today');
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.vaccination.print', $this->visit))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee($this->patient->fullName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_list_index_does_not_auto_open_patient(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.show', 'vaccination'))
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('Vaccination overview');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user