Add full Vaccination, Pathology, and Infusion specialty clinical suites.
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:
isaacclad
2026-07-19 21:39:15 +00:00
co-authored by Cursor
parent b7e5a7d9ad
commit 7b722c71d0
34 changed files with 3508 additions and 24 deletions
@@ -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'),
]);
}
}
+224
View File
@@ -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' => 'A1A2',
'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')) {
+13 -1
View File
@@ -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'],
];
}
}
+80 -10
View File
@@ -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' => [
+3 -1
View File
@@ -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',
+57 -12
View File
@@ -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>
+15
View File
@@ -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');
+230
View File
@@ -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');
}
}
+232
View File
@@ -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');
}
}
+233
View File
@@ -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');
}
}