Add full Oncology, Renal Care, and Surgery specialty clinical suites.
Deploy Ladill Care / deploy (push) Successful in 32s

Match Pediatrics/ENT depth with stages, workspace tabs, clinical records, reports/print, demo seed, and suite tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-19 20:16:18 +00:00
co-authored by Cursor
parent dc86b51ed5
commit b7e5a7d9ad
33 changed files with 3392 additions and 53 deletions
@@ -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\Oncology\OncologyAnalyticsService;
use App\Services\Care\Oncology\OncologyWorkflowService;
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 OncologyWorkspaceController extends Controller
{
use ScopesToAccount;
protected function assertOncologyAccess(Request $request, SpecialtyModuleService $modules): void
{
$organization = $this->organization($request);
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'oncology'), 403);
}
protected function assertOncologyManage(Request $request, SpecialtyModuleService $modules): void
{
$organization = $this->organization($request);
abort_unless($modules->memberCanManage($organization, $this->member($request), 'oncology'), 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->assertOncologyManage($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'stage' => ['required', 'string', 'max:32'],
]);
try {
$stages->setStage(
$this->organization($request),
$visit,
'oncology',
$validated['stage'],
$this->ownerRef($request),
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', [
'module' => 'oncology',
'visit' => $visit,
'tab' => $shell->workspaceTabForStage('oncology', $validated['stage']),
])
->with('success', 'Visit stage updated.');
}
public function saveTreatment(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
SpecialtyClinicalRecordService $clinical,
SpecialtyVisitStageService $stages,
OncologyWorkflowService $workflow,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertOncologyManage($request, $modules);
$this->assertVisit($request, $visit);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$payload = (array) $request->input('payload', []);
foreach ($clinical->fieldsFor('oncology', 'onc_treatment') as $field) {
if (($field['type'] ?? '') === 'boolean') {
$payload[$field['name']] = $request->boolean('payload.'.$field['name']);
}
}
$request->merge(['payload' => $payload, 'tab' => 'treatment']);
$validated = $request->validate(array_merge([
'tab' => ['required', 'string'],
], $clinical->validationRules('oncology', 'onc_treatment')));
$record = $clinical->upsert(
$organization,
$visit,
'oncology',
'onc_treatment',
$clinical->payloadFromRequest($validated),
$owner,
$owner,
OncologyWorkflowService::STAGE_TREATMENT,
SpecialtyClinicalRecord::STATUS_COMPLETED,
);
try {
$stages->setStage(
$organization,
$visit,
'oncology',
OncologyWorkflowService::STAGE_TREATMENT,
$owner,
$owner,
);
} catch (\InvalidArgumentException) {
}
if ($workflow->shouldCompleteVisit($record->payload ?? [])) {
try {
$stages->setStage(
$organization,
$visit,
'oncology',
OncologyWorkflowService::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' => 'oncology', 'visit' => $visit, 'tab' => 'treatment'])
->with('success', 'Oncology treatment saved.');
}
public function reports(
Request $request,
SpecialtyModuleService $modules,
SpecialtyShellService $shell,
OncologyAnalyticsService $analytics,
): View {
$this->authorizeAbility($request, 'consultations.view');
$this->assertOncologyAccess($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.oncology.reports', [
'moduleKey' => 'oncology',
'definition' => $modules->definition('oncology'),
'shellNav' => $shell->navItems('oncology'),
'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->assertOncologyAccess($request, $modules);
$this->assertVisit($request, $visit);
$visit->loadMissing(['patient', 'appointment.practitioner', 'branch']);
return view('care.specialty.oncology.print', [
'visit' => $visit,
'patient' => $visit->patient,
'history' => $clinical->findForVisit($visit, 'oncology', 'onc_history'),
'staging' => $clinical->findForVisit($visit, 'oncology', 'onc_staging'),
'investigation' => $clinical->findForVisit($visit, 'oncology', 'onc_investigation'),
'plan' => $clinical->findForVisit($visit, 'oncology', 'onc_plan'),
'treatment' => $clinical->findForVisit($visit, 'oncology', 'onc_treatment'),
]);
}
}
@@ -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\Renal\RenalAnalyticsService;
use App\Services\Care\Renal\RenalWorkflowService;
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 RenalWorkspaceController extends Controller
{
use ScopesToAccount;
protected function assertRenalAccess(Request $request, SpecialtyModuleService $modules): void
{
$organization = $this->organization($request);
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'renal'), 403);
}
protected function assertRenalManage(Request $request, SpecialtyModuleService $modules): void
{
$organization = $this->organization($request);
abort_unless($modules->memberCanManage($organization, $this->member($request), 'renal'), 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->assertRenalManage($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'stage' => ['required', 'string', 'max:32'],
]);
try {
$stages->setStage(
$this->organization($request),
$visit,
'renal',
$validated['stage'],
$this->ownerRef($request),
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', [
'module' => 'renal',
'visit' => $visit,
'tab' => $shell->workspaceTabForStage('renal', $validated['stage']),
])
->with('success', 'Visit stage updated.');
}
public function savePlan(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
SpecialtyClinicalRecordService $clinical,
SpecialtyVisitStageService $stages,
RenalWorkflowService $workflow,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertRenalManage($request, $modules);
$this->assertVisit($request, $visit);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$payload = (array) $request->input('payload', []);
foreach ($clinical->fieldsFor('renal', 'renal_plan') as $field) {
if (($field['type'] ?? '') === 'boolean') {
$payload[$field['name']] = $request->boolean('payload.'.$field['name']);
}
}
$request->merge(['payload' => $payload, 'tab' => 'plan']);
$validated = $request->validate(array_merge([
'tab' => ['required', 'string'],
], $clinical->validationRules('renal', 'renal_plan')));
$record = $clinical->upsert(
$organization,
$visit,
'renal',
'renal_plan',
$clinical->payloadFromRequest($validated),
$owner,
$owner,
RenalWorkflowService::STAGE_PLAN,
SpecialtyClinicalRecord::STATUS_COMPLETED,
);
try {
$stages->setStage(
$organization,
$visit,
'renal',
RenalWorkflowService::STAGE_PLAN,
$owner,
$owner,
);
} catch (\InvalidArgumentException) {
}
if ($workflow->shouldCompleteVisit($record->payload ?? [])) {
try {
$stages->setStage(
$organization,
$visit,
'renal',
RenalWorkflowService::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' => 'renal', 'visit' => $visit, 'tab' => 'plan'])
->with('success', 'Renal care plan saved.');
}
public function reports(
Request $request,
SpecialtyModuleService $modules,
SpecialtyShellService $shell,
RenalAnalyticsService $analytics,
): View {
$this->authorizeAbility($request, 'consultations.view');
$this->assertRenalAccess($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.renal.reports', [
'moduleKey' => 'renal',
'definition' => $modules->definition('renal'),
'shellNav' => $shell->navItems('renal'),
'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->assertRenalAccess($request, $modules);
$this->assertVisit($request, $visit);
$visit->loadMissing(['patient', 'appointment.practitioner', 'branch']);
return view('care.specialty.renal.print', [
'visit' => $visit,
'patient' => $visit->patient,
'history' => $clinical->findForVisit($visit, 'renal', 'renal_history'),
'exam' => $clinical->findForVisit($visit, 'renal', 'renal_exam'),
'dialysis' => $clinical->findForVisit($visit, 'renal', 'renal_session'),
'plan' => $clinical->findForVisit($visit, 'renal', 'renal_plan'),
]);
}
}
@@ -162,6 +162,9 @@ class SpecialtyModuleController extends Controller
'pediatrics' => 'history', 'pediatrics' => 'history',
'orthopedics' => 'history', 'orthopedics' => 'history',
'ent' => 'history', 'ent' => 'history',
'oncology' => 'history',
'renal' => 'history',
'surgery' => 'history',
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'),
@@ -242,6 +245,9 @@ class SpecialtyModuleController extends Controller
'pediatrics' => 'history', 'pediatrics' => 'history',
'orthopedics' => 'history', 'orthopedics' => 'history',
'ent' => 'history', 'ent' => 'history',
'oncology' => 'history',
'renal' => 'history',
'surgery' => 'history',
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'),
@@ -806,6 +812,109 @@ class SpecialtyModuleController extends Controller
} }
} }
if ($module === 'oncology' && in_array($recordType, ['onc_history', 'onc_staging', 'onc_investigation', 'onc_plan'], true)) {
$workflow = app(\App\Services\Care\Oncology\OncologyWorkflowService::class);
$stageService = app(\App\Services\Care\SpecialtyVisitStageService::class);
$suggested = match ($recordType) {
'onc_history' => $workflow->stageFromHistory($record->payload ?? []),
'onc_staging' => $workflow->stageFromStaging($record->payload ?? []),
'onc_investigation' => $workflow->stageFromInvestigation($record->payload ?? []),
'onc_plan' => $workflow->stageFromPlan($record->payload ?? []),
default => null,
};
$current = $visit->specialty_stage;
$early = ['check_in', 'waiting', null, ''];
$order = [
'check_in' => 0,
'history' => 1,
'staging' => 2,
'investigation' => 3,
'plan' => 4,
'treatment' => 5,
'completed' => 6,
];
if ($suggested && (! $current || in_array($current, $early, true))) {
try {
$stageService->setStage($organization, $visit, 'oncology', $suggested, $this->ownerRef($request), $this->ownerRef($request));
} catch (\InvalidArgumentException) {
}
} elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) {
try {
$stageService->setStage($organization, $visit, 'oncology', $suggested, $this->ownerRef($request), $this->ownerRef($request));
} catch (\InvalidArgumentException) {
}
}
}
if ($module === 'renal' && in_array($recordType, ['renal_history', 'renal_exam', 'renal_session'], true)) {
$workflow = app(\App\Services\Care\Renal\RenalWorkflowService::class);
$stageService = app(\App\Services\Care\SpecialtyVisitStageService::class);
$suggested = match ($recordType) {
'renal_history' => $workflow->stageFromHistory($record->payload ?? []),
'renal_exam' => $workflow->stageFromExam($record->payload ?? []),
'renal_session' => $workflow->stageFromDialysis($record->payload ?? []),
default => null,
};
$current = $visit->specialty_stage;
$early = ['check_in', 'waiting', null, ''];
$order = [
'check_in' => 0,
'history' => 1,
'exam' => 2,
'dialysis' => 3,
'plan' => 4,
'completed' => 5,
];
if ($suggested && (! $current || in_array($current, $early, true))) {
try {
$stageService->setStage($organization, $visit, 'renal', $suggested, $this->ownerRef($request), $this->ownerRef($request));
} catch (\InvalidArgumentException) {
}
} elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) {
try {
$stageService->setStage($organization, $visit, 'renal', $suggested, $this->ownerRef($request), $this->ownerRef($request));
} catch (\InvalidArgumentException) {
}
}
}
if ($module === 'surgery' && in_array($recordType, ['surg_history', 'surg_exam', 'surg_investigation', 'surg_plan', 'surg_procedure'], true)) {
$workflow = app(\App\Services\Care\Surgery\SurgeryWorkflowService::class);
$stageService = app(\App\Services\Care\SpecialtyVisitStageService::class);
$suggested = match ($recordType) {
'surg_history' => $workflow->stageFromHistory($record->payload ?? []),
'surg_exam' => $workflow->stageFromExam($record->payload ?? []),
'surg_investigation' => $workflow->stageFromInvestigation($record->payload ?? []),
'surg_plan' => $workflow->stageFromPlan($record->payload ?? []),
'surg_procedure' => $workflow->stageFromProcedure($record->payload ?? []),
default => null,
};
$current = $visit->specialty_stage;
$early = ['check_in', 'waiting', null, ''];
$order = [
'check_in' => 0,
'history' => 1,
'exam' => 2,
'investigation' => 3,
'plan' => 4,
'procedure' => 5,
'postop' => 6,
'completed' => 7,
];
if ($suggested && (! $current || in_array($current, $early, true))) {
try {
$stageService->setStage($organization, $visit, 'surgery', $suggested, $this->ownerRef($request), $this->ownerRef($request));
} catch (\InvalidArgumentException) {
}
} elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) {
try {
$stageService->setStage($organization, $visit, 'surgery', $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,
@@ -1262,6 +1371,27 @@ class SpecialtyModuleController extends Controller
$entProcedure = null; $entProcedure = null;
$entStageCodes = []; $entStageCodes = [];
$entStageFlow = []; $entStageFlow = [];
$oncologyHistory = null;
$oncologyStaging = null;
$oncologyInvestigation = null;
$oncologyPlan = null;
$oncologyTreatment = null;
$oncologyStageCodes = [];
$oncologyStageFlow = [];
$renalHistory = null;
$renalExam = null;
$renalDialysis = null;
$renalPlan = null;
$renalStageCodes = [];
$renalStageFlow = [];
$surgeryHistory = null;
$surgeryExam = null;
$surgeryInvestigation = null;
$surgeryPlan = null;
$surgeryProcedure = null;
$surgeryPostop = null;
$surgeryStageCodes = [];
$surgeryStageFlow = [];
$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);
@@ -1456,6 +1586,36 @@ class SpecialtyModuleController extends Controller
$entStageFlow = app(\App\Services\Care\Ent\EntWorkflowService::class)->stageFlow(); $entStageFlow = app(\App\Services\Care\Ent\EntWorkflowService::class)->stageFlow();
} }
if ($module === 'oncology') {
$oncologyHistory = $clinical->findForVisit($workspaceVisit, 'oncology', 'onc_history');
$oncologyStaging = $clinical->findForVisit($workspaceVisit, 'oncology', 'onc_staging');
$oncologyInvestigation = $clinical->findForVisit($workspaceVisit, 'oncology', 'onc_investigation');
$oncologyPlan = $clinical->findForVisit($workspaceVisit, 'oncology', 'onc_plan');
$oncologyTreatment = $clinical->findForVisit($workspaceVisit, 'oncology', 'onc_treatment');
$oncologyStageCodes = collect($shell->stages('oncology'))->pluck('code')->all();
$oncologyStageFlow = app(\App\Services\Care\Oncology\OncologyWorkflowService::class)->stageFlow();
}
if ($module === 'renal') {
$renalHistory = $clinical->findForVisit($workspaceVisit, 'renal', 'renal_history');
$renalExam = $clinical->findForVisit($workspaceVisit, 'renal', 'renal_exam');
$renalDialysis = $clinical->findForVisit($workspaceVisit, 'renal', 'renal_session');
$renalPlan = $clinical->findForVisit($workspaceVisit, 'renal', 'renal_plan');
$renalStageCodes = collect($shell->stages('renal'))->pluck('code')->all();
$renalStageFlow = app(\App\Services\Care\Renal\RenalWorkflowService::class)->stageFlow();
}
if ($module === 'surgery') {
$surgeryHistory = $clinical->findForVisit($workspaceVisit, 'surgery', 'surg_history');
$surgeryExam = $clinical->findForVisit($workspaceVisit, 'surgery', 'surg_exam');
$surgeryInvestigation = $clinical->findForVisit($workspaceVisit, 'surgery', 'surg_investigation');
$surgeryPlan = $clinical->findForVisit($workspaceVisit, 'surgery', 'surg_plan');
$surgeryProcedure = $clinical->findForVisit($workspaceVisit, 'surgery', 'surg_procedure');
$surgeryPostop = $clinical->findForVisit($workspaceVisit, 'surgery', 'surg_postop');
$surgeryStageCodes = collect($shell->stages('surgery'))->pluck('code')->all();
$surgeryStageFlow = app(\App\Services\Care\Surgery\SurgeryWorkflowService::class)->stageFlow();
}
if ($patientHeader !== null) { if ($patientHeader !== null) {
$patientHeader['clinical_alerts'] = $clinicalAlerts; $patientHeader['clinical_alerts'] = $clinicalAlerts;
} }
@@ -1604,6 +1764,27 @@ class SpecialtyModuleController extends Controller
'entProcedure' => $entProcedure, 'entProcedure' => $entProcedure,
'entStageCodes' => $entStageCodes, 'entStageCodes' => $entStageCodes,
'entStageFlow' => $entStageFlow, 'entStageFlow' => $entStageFlow,
'oncologyHistory' => $oncologyHistory,
'oncologyStaging' => $oncologyStaging,
'oncologyInvestigation' => $oncologyInvestigation,
'oncologyPlan' => $oncologyPlan,
'oncologyTreatment' => $oncologyTreatment,
'oncologyStageCodes' => $oncologyStageCodes,
'oncologyStageFlow' => $oncologyStageFlow,
'renalHistory' => $renalHistory,
'renalExam' => $renalExam,
'renalDialysis' => $renalDialysis,
'renalPlan' => $renalPlan,
'renalStageCodes' => $renalStageCodes,
'renalStageFlow' => $renalStageFlow,
'surgeryHistory' => $surgeryHistory,
'surgeryExam' => $surgeryExam,
'surgeryInvestigation' => $surgeryInvestigation,
'surgeryPlan' => $surgeryPlan,
'surgeryProcedure' => $surgeryProcedure,
'surgeryPostop' => $surgeryPostop,
'surgeryStageCodes' => $surgeryStageCodes,
'surgeryStageFlow' => $surgeryStageFlow,
'queueStubs' => $modules->queueStubsFor($organization, $module), 'queueStubs' => $modules->queueStubsFor($organization, $module),
'branchId' => $branchId, 'branchId' => $branchId,
'branchLabel' => $branchLabel, 'branchLabel' => $branchLabel,
@@ -0,0 +1,216 @@
<?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\Surgery\SurgeryAnalyticsService;
use App\Services\Care\Surgery\SurgeryWorkflowService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SurgeryWorkspaceController extends Controller
{
use ScopesToAccount;
protected function assertSurgeryAccess(Request $request, SpecialtyModuleService $modules): void
{
$organization = $this->organization($request);
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'surgery'), 403);
}
protected function assertSurgeryManage(Request $request, SpecialtyModuleService $modules): void
{
$organization = $this->organization($request);
abort_unless($modules->memberCanManage($organization, $this->member($request), 'surgery'), 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->assertSurgeryManage($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'stage' => ['required', 'string', 'max:32'],
]);
try {
$stages->setStage(
$this->organization($request),
$visit,
'surgery',
$validated['stage'],
$this->ownerRef($request),
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', [
'module' => 'surgery',
'visit' => $visit,
'tab' => $shell->workspaceTabForStage('surgery', $validated['stage']),
])
->with('success', 'Visit stage updated.');
}
public function savePostop(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
SpecialtyClinicalRecordService $clinical,
SpecialtyVisitStageService $stages,
SurgeryWorkflowService $workflow,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertSurgeryManage($request, $modules);
$this->assertVisit($request, $visit);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$payload = (array) $request->input('payload', []);
foreach ($clinical->fieldsFor('surgery', 'surg_postop') as $field) {
if (($field['type'] ?? '') === 'boolean') {
$payload[$field['name']] = $request->boolean('payload.'.$field['name']);
}
}
$request->merge(['payload' => $payload, 'tab' => 'postop']);
$validated = $request->validate(array_merge([
'tab' => ['required', 'string'],
], $clinical->validationRules('surgery', 'surg_postop')));
$record = $clinical->upsert(
$organization,
$visit,
'surgery',
'surg_postop',
$clinical->payloadFromRequest($validated),
$owner,
$owner,
SurgeryWorkflowService::STAGE_POSTOP,
SpecialtyClinicalRecord::STATUS_COMPLETED,
);
try {
$stages->setStage(
$organization,
$visit,
'surgery',
SurgeryWorkflowService::STAGE_POSTOP,
$owner,
$owner,
);
} catch (\InvalidArgumentException) {
}
if ($workflow->shouldCompleteVisit($record->payload ?? [])) {
try {
$stages->setStage(
$organization,
$visit,
'surgery',
SurgeryWorkflowService::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' => 'surgery', 'visit' => $visit, 'tab' => 'postop'])
->with('success', 'Post-op note saved.');
}
public function reports(
Request $request,
SpecialtyModuleService $modules,
SpecialtyShellService $shell,
SurgeryAnalyticsService $analytics,
): View {
$this->authorizeAbility($request, 'consultations.view');
$this->assertSurgeryAccess($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.surgery.reports', [
'moduleKey' => 'surgery',
'definition' => $modules->definition('surgery'),
'shellNav' => $shell->navItems('surgery'),
'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->assertSurgeryAccess($request, $modules);
$this->assertVisit($request, $visit);
$visit->loadMissing(['patient', 'appointment.practitioner', 'branch']);
return view('care.specialty.surgery.print', [
'visit' => $visit,
'patient' => $visit->patient,
'history' => $clinical->findForVisit($visit, 'surgery', 'surg_history'),
'exam' => $clinical->findForVisit($visit, 'surgery', 'surg_exam'),
'investigation' => $clinical->findForVisit($visit, 'surgery', 'surg_investigation'),
'plan' => $clinical->findForVisit($visit, 'surgery', 'surg_plan'),
'procedure' => $clinical->findForVisit($visit, 'surgery', 'surg_procedure'),
'postop' => $clinical->findForVisit($visit, 'surgery', 'surg_postop'),
]);
}
}
+223
View File
@@ -1023,6 +1023,9 @@ class DemoTenantSeeder
$this->seedPediatricsClinicalDemo($organization, $ownerRef); $this->seedPediatricsClinicalDemo($organization, $ownerRef);
$this->seedOrthopedicsClinicalDemo($organization, $ownerRef); $this->seedOrthopedicsClinicalDemo($organization, $ownerRef);
$this->seedEntClinicalDemo($organization, $ownerRef); $this->seedEntClinicalDemo($organization, $ownerRef);
$this->seedOncologyClinicalDemo($organization, $ownerRef);
$this->seedRenalClinicalDemo($organization, $ownerRef);
$this->seedSurgeryClinicalDemo($organization, $ownerRef);
return $count; return $count;
} }
@@ -2222,6 +2225,226 @@ class DemoTenantSeeder
} }
} }
private function seedOncologyClinicalDemo(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', 'oncology')
->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;
}
$highRisk = $index === 0;
$clinical->upsert($organization, $visit, 'oncology', 'onc_history', [
'history' => $highRisk
? 'New diagnosis work-up; progressive fatigue and weight loss'
: 'On adjuvant regimen; here for cycle review',
'cancer_history' => $highRisk ? 'Breast mass biopsied last month' : 'Stage II breast Ca — surgery 4 months ago',
'medications' => $highRisk ? 'Analgesia PRN' : 'Current chemo cycle medications',
'allergies' => 'NKDA',
'comorbidities' => $highRisk ? 'Hypertension' : 'None significant',
'social' => 'Family support available',
], $ownerRef, $ownerRef, 'history');
$clinical->upsert($organization, $visit, 'oncology', 'onc_staging', [
'diagnosis' => $highRisk ? 'Suspected metastatic breast carcinoma' : 'Breast carcinoma — adjuvant',
'stage' => $highRisk ? 'cT4 N2 M1' : 'pT2 N1 M0',
'histology' => 'Invasive ductal carcinoma',
'performance_status' => $highRisk ? '3' : '1',
'exam_findings' => $highRisk ? 'Cachexia; axillary nodes; hepatic tenderness' : 'Well; surgical scar healed',
'sites' => $highRisk ? 'Breast, axilla, liver' : 'None residual known',
], $ownerRef, $ownerRef, 'staging');
if ($highRisk) {
$clinical->upsert($organization, $visit, 'oncology', 'onc_plan', [
'diagnosis' => 'Metastatic breast carcinoma',
'plan' => 'Staging imaging; MDT; supportive care; consider palliative systemic therapy',
'intent' => 'Palliative',
'medications' => 'Analgesia; antiemetics PRN',
'treatment_planned' => true,
'follow_up' => 'Chemo day-care after MDT',
'advice' => 'Return if fever or uncontrolled pain',
], $ownerRef, $ownerRef, 'plan');
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'plan' : 'staging']);
}
$index++;
}
}
private function seedRenalClinicalDemo(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', 'renal')
->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;
}
$highRisk = $index === 0;
$clinical->upsert($organization, $visit, 'renal', 'renal_history', [
'history' => $highRisk
? 'Missed last dialysis; progressive dyspnoea and oedema'
: 'Routine thrice-weekly haemodialysis review',
'ckd_stage' => $highRisk ? 'CKD 5D — 2 years on HD' : 'CKD 5D — 8 months on HD',
'access_history' => 'Left AV fistula',
'medications' => 'EPO; phosphate binders; antihypertensives',
'allergies' => 'NKDA',
'comorbidities' => $highRisk ? 'Diabetes; heart failure' : 'Hypertension',
], $ownerRef, $ownerRef, 'history');
$clinical->upsert($organization, $visit, 'renal', 'renal_exam', [
'chief_complaint' => $highRisk ? 'Fluid overload / missed dialysis' : 'Routine dialysis session',
'bp' => $highRisk ? '168/98' : '132/78',
'weight_kg' => $highRisk ? 78.5 : 65.2,
'fluid_status' => $highRisk ? 'Overload' : 'Euvolemic',
'access' => 'Left AVF thrills present',
'exam_findings' => $highRisk ? 'Bilateral basal crackles; sacral oedema' : 'Chest clear; no oedema',
'uremic_symptoms' => $highRisk ? 'Nausea; pruritus' : 'None',
], $ownerRef, $ownerRef, 'exam');
if ($highRisk) {
$clinical->upsert($organization, $visit, 'renal', 'renal_session', [
'session_type' => 'Hemodialysis',
'pre_weight_kg' => 78.5,
'uf_goal_l' => 3.5,
'access' => 'Left AVF',
'bp_pre' => '168/98',
'labs_summary' => 'K 5.8; Cr elevated; Hb 9.1',
'complications' => 'Hypotension mid-session anticipated',
'notes' => 'Urgent HD for fluid overload after missed session',
], $ownerRef, $ownerRef, 'dialysis');
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'dialysis' : 'exam']);
}
$index++;
}
}
private function seedSurgeryClinicalDemo(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', 'surgery')
->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;
}
$highRisk = $index === 0;
$clinical->upsert($organization, $visit, 'surgery', 'surg_history', [
'history' => $highRisk
? 'Right inguinal hernia with intermittent pain; electing repair'
: 'Post-appendicectomy wound check',
'past_surgical' => $highRisk ? 'None' : 'Laparoscopic appendicectomy 10 days ago',
'medications' => $highRisk ? 'None' : 'Analgesia; antibiotics completed',
'allergies' => 'NKDA',
'comorbidities' => $highRisk ? 'Well-controlled diabetes' : 'None',
'anaesthesia_history' => 'No prior issues',
], $ownerRef, $ownerRef, 'history');
$clinical->upsert($organization, $visit, 'surgery', 'surg_exam', [
'chief_complaint' => $highRisk ? 'Inguinal hernia — pre-op' : 'Wound review',
'exam_findings' => $highRisk
? 'Right reducible inguinal hernia; no strangulation'
: 'Wound clean; mild erythema; no discharge',
'asa_class' => $highRisk ? 'II' : 'I',
'fitness' => 'Fit for day-case anaesthesia',
'site_marking' => $highRisk,
], $ownerRef, $ownerRef, 'exam');
if ($highRisk) {
$clinical->upsert($organization, $visit, 'surgery', 'surg_plan', [
'diagnosis' => 'Right inguinal hernia',
'proposed_procedure' => 'Open mesh hernia repair',
'plan' => 'Day-case repair; consent discussed including mesh risks',
'consent_obtained' => true,
'procedure_planned' => true,
'follow_up' => 'Wound check 710 days',
'advice' => 'NPO from midnight; bring meds list',
], $ownerRef, $ownerRef, 'plan');
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']);
}
$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\Oncology;
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 OncologyAnalyticsService
{
public function __construct(
protected SpecialtyShellService $shell,
) {}
/**
* @return array{
* arrivals_today: int,
* completed_today: int,
* chemo_open: int,
* diagnosis_breakdown: Collection,
* regimen_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, 'oncology', $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');
$chemoOpen = SpecialtyClinicalRecord::owned($ownerRef)
->where('organization_id', $organization->id)
->where('module_key', 'oncology')
->where('record_type', 'onc_treatment')
->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds)
->where('status', '!=', SpecialtyClinicalRecord::STATUS_COMPLETED)
->count();
$stagingRecords = SpecialtyClinicalRecord::owned($ownerRef)
->where('organization_id', $organization->id)
->where('module_key', 'oncology')
->where('record_type', 'onc_staging')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
->get();
$diagnosisBreakdown = $stagingRecords
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['diagnosis'] ?? 'Unknown'))
->map(fn (Collection $rows, string $diagnosis) => [
'diagnosis' => $diagnosis,
'total' => $rows->count(),
])
->values()
->sortByDesc('total')
->values();
$treatmentRecords = SpecialtyClinicalRecord::owned($ownerRef)
->where('organization_id', $organization->id)
->where('module_key', 'oncology')
->where('record_type', 'onc_treatment')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
->get();
$regimenBreakdown = $treatmentRecords
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['regimen'] ?? ($r->payload['treatment'] ?? 'Unknown')))
->map(fn (Collection $rows, string $regimen) => [
'regimen' => $regimen,
'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, 'oncology'))
->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,
'chemo_open' => $chemoOpen,
'diagnosis_breakdown' => $diagnosisBreakdown,
'regimen_breakdown' => $regimenBreakdown,
'avg_los_minutes' => $avgLos,
'revenue_by_service' => $revenueByService,
'from' => $rangeFrom->toDateString(),
'to' => $rangeTo->toDateString(),
];
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Services\Care\Oncology;
/**
* Map oncology clinical progress onto visit stages.
*/
class OncologyWorkflowService
{
public const STAGE_CHECK_IN = 'check_in';
public const STAGE_HISTORY = 'history';
public const STAGE_STAGING = 'staging';
public const STAGE_INVESTIGATION = 'investigation';
public const STAGE_PLAN = 'plan';
public const STAGE_TREATMENT = 'treatment';
public const STAGE_COMPLETED = 'completed';
/**
* @param array<string, mixed> $payload
*/
public function stageFromHistory(array $payload): string
{
$history = trim((string) ($payload['history'] ?? ''));
if ($history !== '') {
return self::STAGE_HISTORY;
}
return self::STAGE_CHECK_IN;
}
/**
* @param array<string, mixed> $payload
*/
public function stageFromStaging(array $payload): string
{
if (! empty($payload['diagnosis'])
|| ! empty($payload['stage'])
|| ! empty($payload['exam_findings'])) {
return self::STAGE_STAGING;
}
return self::STAGE_HISTORY;
}
/**
* @param array<string, mixed> $payload
*/
public function stageFromInvestigation(array $payload): string
{
$findings = trim((string) ($payload['findings'] ?? ''));
if ($findings !== '') {
return self::STAGE_INVESTIGATION;
}
return self::STAGE_STAGING;
}
/**
* @param array<string, mixed> $payload
*/
public function stageFromPlan(array $payload): string
{
if (! empty($payload['treatment_planned'])) {
return self::STAGE_TREATMENT;
}
$plan = trim((string) ($payload['plan'] ?? ''));
if ($plan !== '') {
return self::STAGE_PLAN;
}
return self::STAGE_INVESTIGATION;
}
/**
* @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_HISTORY, 'label' => 'Start history'],
self::STAGE_HISTORY => ['next' => self::STAGE_STAGING, 'label' => 'Move to staging / exam'],
self::STAGE_STAGING => ['next' => self::STAGE_INVESTIGATION, 'label' => 'Move to investigations'],
self::STAGE_INVESTIGATION => ['next' => self::STAGE_PLAN, 'label' => 'Move to diagnosis & plan'],
self::STAGE_PLAN => ['next' => self::STAGE_TREATMENT, 'label' => 'Move to treatment'],
self::STAGE_TREATMENT => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'],
self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'],
];
}
}
@@ -0,0 +1,161 @@
<?php
namespace App\Services\Care\Renal;
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 RenalAnalyticsService
{
public function __construct(
protected SpecialtyShellService $shell,
) {}
/**
* @return array{
* arrivals_today: int,
* completed_today: int,
* dialysis_open: int,
* session_breakdown: Collection,
* diagnosis_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, 'renal', $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');
$dialysisOpen = Visit::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('id', $openVisitIds->isEmpty() ? [0] : $openVisitIds)
->where('specialty_stage', 'dialysis')
->count();
$sessionRecords = SpecialtyClinicalRecord::owned($ownerRef)
->where('organization_id', $organization->id)
->where('module_key', 'renal')
->where('record_type', 'renal_session')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
->get();
$sessionBreakdown = $sessionRecords
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['session_type'] ?? 'Unknown'))
->map(fn (Collection $rows, string $type) => [
'session_type' => $type,
'total' => $rows->count(),
])
->values()
->sortByDesc('total')
->values();
$planRecords = SpecialtyClinicalRecord::owned($ownerRef)
->where('organization_id', $organization->id)
->where('module_key', 'renal')
->where('record_type', 'renal_plan')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
->get();
$diagnosisBreakdown = $planRecords
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['diagnosis'] ?? 'Unknown'))
->map(fn (Collection $rows, string $diagnosis) => [
'diagnosis' => $diagnosis,
'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, 'renal'))
->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,
'dialysis_open' => $dialysisOpen,
'session_breakdown' => $sessionBreakdown,
'diagnosis_breakdown' => $diagnosisBreakdown,
'avg_los_minutes' => $avgLos,
'revenue_by_service' => $revenueByService,
'from' => $rangeFrom->toDateString(),
'to' => $rangeTo->toDateString(),
];
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Services\Care\Renal;
/**
* Map renal / nephrology clinical progress onto visit stages.
*/
class RenalWorkflowService
{
public const STAGE_CHECK_IN = 'check_in';
public const STAGE_HISTORY = 'history';
public const STAGE_EXAM = 'exam';
public const STAGE_DIALYSIS = 'dialysis';
public const STAGE_PLAN = 'plan';
public const STAGE_COMPLETED = 'completed';
/**
* @param array<string, mixed> $payload
*/
public function stageFromHistory(array $payload): string
{
$history = trim((string) ($payload['history'] ?? ''));
if ($history !== '') {
return self::STAGE_HISTORY;
}
return self::STAGE_CHECK_IN;
}
/**
* @param array<string, mixed> $payload
*/
public function stageFromExam(array $payload): string
{
if (! empty($payload['chief_complaint'])
|| ! empty($payload['exam_findings'])
|| ! empty($payload['fluid_status'])) {
return self::STAGE_EXAM;
}
return self::STAGE_HISTORY;
}
/**
* @param array<string, mixed> $payload
*/
public function stageFromDialysis(array $payload): string
{
if (! empty($payload['session_type']) || ! empty($payload['notes'])) {
return self::STAGE_DIALYSIS;
}
return self::STAGE_EXAM;
}
/**
* @param array<string, mixed> $payload
*/
public function stageFromPlan(array $payload): string
{
$plan = trim((string) ($payload['plan'] ?? ''));
if ($plan !== '') {
return self::STAGE_PLAN;
}
return self::STAGE_DIALYSIS;
}
/**
* @param array<string, mixed> $payload
*/
public function shouldCompleteVisit(array $payload): bool
{
$outcome = strtolower((string) ($payload['outcome'] ?? ''));
return str_contains($outcome, 'completed') || str_contains($outcome, 'discharged');
}
/**
* @return array<string, array{next: string, label: string}>
*/
public function stageFlow(): array
{
return [
self::STAGE_CHECK_IN => ['next' => self::STAGE_HISTORY, 'label' => 'Start history'],
self::STAGE_HISTORY => ['next' => self::STAGE_EXAM, 'label' => 'Move to exam'],
self::STAGE_EXAM => ['next' => self::STAGE_DIALYSIS, 'label' => 'Move to dialysis / labs'],
self::STAGE_DIALYSIS => ['next' => self::STAGE_PLAN, 'label' => 'Move to care plan'],
self::STAGE_PLAN => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'],
self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'],
];
}
}
@@ -471,6 +471,39 @@ class SpecialtyClinicalRecordService
} }
} }
if ($moduleKey === 'oncology' && $recordType === 'onc_staging') {
$ecog = (string) ($payload['performance_status'] ?? '');
if (in_array($ecog, ['3', '4'], true)) {
$alerts[] = [
'code' => 'onc.ecog_high',
'severity' => 'warning',
'message' => 'High ECOG performance status ('.$ecog.') — review treatment fitness.',
];
}
}
if ($moduleKey === 'renal' && $recordType === 'renal_exam') {
$fluid = strtolower((string) ($payload['fluid_status'] ?? ''));
if (str_contains($fluid, 'overload')) {
$alerts[] = [
'code' => 'ren.fluid_overload',
'severity' => 'warning',
'message' => 'Fluid overload recorded on renal exam.',
];
}
}
if ($moduleKey === 'surgery' && $recordType === 'surg_plan') {
if (empty($payload['consent_obtained']) && ! empty($payload['procedure_planned'])) {
$alerts[] = [
'code' => 'sur.consent_missing',
'severity' => 'critical',
'message' => 'Procedure planned without documented consent.',
];
}
}
if ($moduleKey === 'psychiatry' && $recordType === 'risk_assessment') { if ($moduleKey === 'psychiatry' && $recordType === 'risk_assessment') {
$level = strtolower((string) ($payload['overall_risk'] ?? '')); $level = strtolower((string) ($payload['overall_risk'] ?? ''));
if (str_contains($level, 'imminent')) { if (str_contains($level, 'imminent')) {
+13 -1
View File
@@ -20,6 +20,9 @@ use App\Services\Care\Psychiatry\PsychiatryWorkflowService;
use App\Services\Care\Pediatrics\PediatricsWorkflowService; use App\Services\Care\Pediatrics\PediatricsWorkflowService;
use App\Services\Care\Orthopedics\OrthopedicsWorkflowService; use App\Services\Care\Orthopedics\OrthopedicsWorkflowService;
use App\Services\Care\Ent\EntWorkflowService; use App\Services\Care\Ent\EntWorkflowService;
use App\Services\Care\Oncology\OncologyWorkflowService;
use App\Services\Care\Renal\RenalWorkflowService;
use App\Services\Care\Surgery\SurgeryWorkflowService;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
/** /**
@@ -72,6 +75,9 @@ class SpecialtyShellService
'pediatrics' => 'care.specialty.pediatrics.stage', 'pediatrics' => 'care.specialty.pediatrics.stage',
'orthopedics' => 'care.specialty.orthopedics.stage', 'orthopedics' => 'care.specialty.orthopedics.stage',
'ent' => 'care.specialty.ent.stage', 'ent' => 'care.specialty.ent.stage',
'oncology' => 'care.specialty.oncology.stage',
'renal' => 'care.specialty.renal.stage',
'surgery' => 'care.specialty.surgery.stage',
default => null, default => null,
}; };
} }
@@ -95,6 +101,9 @@ class SpecialtyShellService
'pediatrics' => app(PediatricsWorkflowService::class)->stageFlow(), 'pediatrics' => app(PediatricsWorkflowService::class)->stageFlow(),
'orthopedics' => app(OrthopedicsWorkflowService::class)->stageFlow(), 'orthopedics' => app(OrthopedicsWorkflowService::class)->stageFlow(),
'ent' => app(EntWorkflowService::class)->stageFlow(), 'ent' => app(EntWorkflowService::class)->stageFlow(),
'oncology' => app(OncologyWorkflowService::class)->stageFlow(),
'renal' => app(RenalWorkflowService::class)->stageFlow(),
'surgery' => app(SurgeryWorkflowService::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'],
@@ -394,7 +403,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'], true) && $visitIds->isNotEmpty()) { if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery'], 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)
@@ -433,6 +442,9 @@ class SpecialtyShellService
'pediatrics' => $clinical->countOpenByType($organization, 'pediatrics', 'pediatric_exam', $branchScope), 'pediatrics' => $clinical->countOpenByType($organization, 'pediatrics', 'pediatric_exam', $branchScope),
'orthopedics' => $clinical->countOpenByType($organization, 'orthopedics', 'ortho_exam', $branchScope), 'orthopedics' => $clinical->countOpenByType($organization, 'orthopedics', 'ortho_exam', $branchScope),
'ent' => $clinical->countOpenByType($organization, 'ent', 'ent_exam', $branchScope), 'ent' => $clinical->countOpenByType($organization, 'ent', 'ent_exam', $branchScope),
'oncology' => $clinical->countOpenByType($organization, 'oncology', 'onc_staging', $branchScope),
'renal' => $clinical->countOpenByType($organization, 'renal', 'renal_exam', $branchScope),
'surgery' => $clinical->countOpenByType($organization, 'surgery', 'surg_exam', $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,161 @@
<?php
namespace App\Services\Care\Surgery;
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 SurgeryAnalyticsService
{
public function __construct(
protected SpecialtyShellService $shell,
) {}
/**
* @return array{
* arrivals_today: int,
* completed_today: int,
* preop_open: int,
* diagnosis_breakdown: Collection,
* procedure_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, 'surgery', $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');
$preopOpen = Visit::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('id', $openVisitIds->isEmpty() ? [0] : $openVisitIds)
->whereIn('specialty_stage', ['history', 'exam', 'investigation', 'plan'])
->count();
$planRecords = SpecialtyClinicalRecord::owned($ownerRef)
->where('organization_id', $organization->id)
->where('module_key', 'surgery')
->where('record_type', 'surg_plan')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
->get();
$diagnosisBreakdown = $planRecords
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['diagnosis'] ?? 'Unknown'))
->map(fn (Collection $rows, string $diagnosis) => [
'diagnosis' => $diagnosis,
'total' => $rows->count(),
])
->values()
->sortByDesc('total')
->values();
$procedureRecords = SpecialtyClinicalRecord::owned($ownerRef)
->where('organization_id', $organization->id)
->where('module_key', 'surgery')
->where('record_type', 'surg_procedure')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
->get();
$procedureBreakdown = $procedureRecords
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['procedure'] ?? 'Unknown'))
->map(fn (Collection $rows, string $procedure) => [
'procedure' => $procedure,
'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, 'surgery'))
->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,
'preop_open' => $preopOpen,
'diagnosis_breakdown' => $diagnosisBreakdown,
'procedure_breakdown' => $procedureBreakdown,
'avg_los_minutes' => $avgLos,
'revenue_by_service' => $revenueByService,
'from' => $rangeFrom->toDateString(),
'to' => $rangeTo->toDateString(),
];
}
}
@@ -0,0 +1,119 @@
<?php
namespace App\Services\Care\Surgery;
/**
* Map surgical OPD / pre-op clinic progress onto visit stages.
*/
class SurgeryWorkflowService
{
public const STAGE_CHECK_IN = 'check_in';
public const STAGE_HISTORY = 'history';
public const STAGE_EXAM = 'exam';
public const STAGE_INVESTIGATION = 'investigation';
public const STAGE_PLAN = 'plan';
public const STAGE_PROCEDURE = 'procedure';
public const STAGE_POSTOP = 'postop';
public const STAGE_COMPLETED = 'completed';
/**
* @param array<string, mixed> $payload
*/
public function stageFromHistory(array $payload): string
{
$history = trim((string) ($payload['history'] ?? ''));
if ($history !== '') {
return self::STAGE_HISTORY;
}
return self::STAGE_CHECK_IN;
}
/**
* @param array<string, mixed> $payload
*/
public function stageFromExam(array $payload): string
{
if (! empty($payload['chief_complaint']) || ! empty($payload['exam_findings'])) {
return self::STAGE_EXAM;
}
return self::STAGE_HISTORY;
}
/**
* @param array<string, mixed> $payload
*/
public function stageFromInvestigation(array $payload): string
{
$findings = trim((string) ($payload['findings'] ?? ''));
if ($findings !== '') {
return self::STAGE_INVESTIGATION;
}
return self::STAGE_EXAM;
}
/**
* @param array<string, mixed> $payload
*/
public function stageFromPlan(array $payload): string
{
if (! empty($payload['procedure_planned'])) {
return self::STAGE_PROCEDURE;
}
$plan = trim((string) ($payload['plan'] ?? ''));
if ($plan !== '') {
return self::STAGE_PLAN;
}
return self::STAGE_INVESTIGATION;
}
/**
* @param array<string, mixed> $payload
*/
public function stageFromProcedure(array $payload): string
{
if (! empty($payload['procedure']) || ! empty($payload['outcome'])) {
return self::STAGE_PROCEDURE;
}
return self::STAGE_PLAN;
}
/**
* @param array<string, mixed> $payload
*/
public function shouldCompleteVisit(array $payload): bool
{
$outcome = strtolower((string) ($payload['outcome'] ?? ''));
return str_contains($outcome, 'completed') || str_contains($outcome, 'discharged');
}
/**
* @return array<string, array{next: string, label: string}>
*/
public function stageFlow(): array
{
return [
self::STAGE_CHECK_IN => ['next' => self::STAGE_HISTORY, 'label' => 'Start pre-op history'],
self::STAGE_HISTORY => ['next' => self::STAGE_EXAM, 'label' => 'Move to exam'],
self::STAGE_EXAM => ['next' => self::STAGE_INVESTIGATION, 'label' => 'Move to investigations'],
self::STAGE_INVESTIGATION => ['next' => self::STAGE_PLAN, 'label' => 'Move to consent / plan'],
self::STAGE_PLAN => ['next' => self::STAGE_PROCEDURE, 'label' => 'Move to procedure'],
self::STAGE_PROCEDURE => ['next' => self::STAGE_POSTOP, 'label' => 'Move to post-op'],
self::STAGE_POSTOP => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'],
self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'],
];
}
}
+122 -38
View File
@@ -87,16 +87,25 @@ return [
'procedure' => 'ent_procedure', 'procedure' => 'ent_procedure',
], ],
'oncology' => [ 'oncology' => [
'review' => 'oncology_review', 'history' => 'onc_history',
'clinical_notes' => 'clinical_note', 'staging' => 'onc_staging',
'investigations' => 'onc_investigation',
'plan' => 'onc_plan',
'treatment' => 'onc_treatment',
], ],
'renal' => [ 'renal' => [
'session' => 'renal_session', 'history' => 'renal_history',
'clinical_notes' => 'clinical_note', 'exam' => 'renal_exam',
'dialysis' => 'renal_session',
'plan' => 'renal_plan',
], ],
'surgery' => [ 'surgery' => [
'preop' => 'preop', 'history' => 'surg_history',
'clinical_notes' => 'clinical_note', 'exam' => 'surg_exam',
'investigations' => 'surg_investigation',
'plan' => 'surg_plan',
'procedure' => 'surg_procedure',
'postop' => 'surg_postop',
], ],
'vaccination' => [ 'vaccination' => [
'screening' => 'vax_screening', 'screening' => 'vax_screening',
@@ -620,58 +629,133 @@ return [
], ],
], ],
'oncology' => [ 'oncology' => [
'oncology_review' => [ 'onc_history' => [
['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4],
['name' => 'cancer_history', 'label' => 'Cancer / treatment history', 'type' => 'textarea', 'rows' => 2],
['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2],
['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'],
['name' => 'comorbidities', 'label' => 'Comorbidities', 'type' => 'textarea', 'rows' => 2],
['name' => 'social', 'label' => 'Social / support context', 'type' => 'textarea', 'rows' => 2],
],
'onc_staging' => [
['name' => 'diagnosis', 'label' => 'Oncology diagnosis', 'type' => 'text', 'required' => true], ['name' => 'diagnosis', 'label' => 'Oncology diagnosis', 'type' => 'text', 'required' => true],
['name' => 'stage', 'label' => 'Stage', 'type' => 'text'], ['name' => 'stage', 'label' => 'Stage / TNM', 'type' => 'text'],
['name' => 'histology', 'label' => 'Histology', 'type' => 'text'],
['name' => 'performance_status', 'label' => 'Performance status (ECOG)', 'type' => 'select', 'options' => ['0', '1', '2', '3', '4']], ['name' => 'performance_status', 'label' => 'Performance status (ECOG)', 'type' => 'select', 'options' => ['0', '1', '2', '3', '4']],
['name' => 'current_regimen', 'label' => 'Current regimen', 'type' => 'textarea', 'rows' => 2], ['name' => 'exam_findings', 'label' => 'Exam findings', 'type' => 'textarea', 'rows' => 3],
['name' => 'sites', 'label' => 'Disease sites', 'type' => 'textarea', 'rows' => 2],
],
'onc_investigation' => [
['name' => 'investigation', 'label' => 'Investigation', 'type' => 'text', 'required' => true],
['name' => 'findings', 'label' => 'Findings', 'type' => 'textarea', 'required' => true, 'rows' => 3],
['name' => 'impression', 'label' => 'Impression', 'type' => 'textarea', 'rows' => 2],
['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2],
],
'onc_plan' => [
['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true],
['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'required' => true, 'rows' => 3],
['name' => 'intent', 'label' => 'Treatment intent', 'type' => 'select', 'options' => ['Curative', 'Adjuvant', 'Neoadjuvant', 'Palliative', 'Supportive']],
['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2],
['name' => 'treatment_planned', 'label' => 'Treatment / chemo planned', 'type' => 'boolean'],
['name' => 'follow_up', 'label' => 'Follow-up', 'type' => 'text'],
['name' => 'advice', 'label' => 'Patient advice', 'type' => 'textarea', 'rows' => 2],
],
'onc_treatment' => [
['name' => 'treatment', 'label' => 'Treatment / chemo note', 'type' => 'text', 'required' => true],
['name' => 'regimen', 'label' => 'Regimen', 'type' => 'text'],
['name' => 'cycle', 'label' => 'Cycle / day', 'type' => 'text'], ['name' => 'cycle', 'label' => 'Cycle / day', 'type' => 'text'],
['name' => 'toxicity', 'label' => 'Toxicity / side effects', 'type' => 'textarea', 'rows' => 2], ['name' => 'toxicity', 'label' => 'Toxicity / side effects', 'type' => 'textarea', 'rows' => 2],
['name' => 'response', 'label' => 'Treatment response', 'type' => 'textarea', 'rows' => 2], ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed uneventfully', 'Completed with complications', 'Deferred', 'Aborted']],
['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'required' => true, 'rows' => 2], ['name' => 'post_treatment_plan', 'label' => 'Post-treatment plan', 'type' => 'textarea', 'rows' => 2],
], ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2],
'clinical_note' => [
['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4],
['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3],
['name' => 'working_diagnosis', 'label' => 'Working diagnosis', 'type' => 'text'],
['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 3],
], ],
], ],
'renal' => [ 'renal' => [
'renal_history' => [
['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4],
['name' => 'ckd_stage', 'label' => 'CKD stage / dialysis vintage', 'type' => 'text'],
['name' => 'access_history', 'label' => 'Access history', 'type' => 'textarea', 'rows' => 2],
['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2],
['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'],
['name' => 'comorbidities', 'label' => 'Comorbidities', 'type' => 'textarea', 'rows' => 2],
],
'renal_exam' => [
['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true],
['name' => 'bp', 'label' => 'Blood pressure', 'type' => 'text'],
['name' => 'weight_kg', 'label' => 'Weight (kg)', 'type' => 'number'],
['name' => 'fluid_status', 'label' => 'Fluid status', 'type' => 'select', 'options' => ['Euvolemic', 'Overload', 'Dehydrated', 'Not assessed']],
['name' => 'access', 'label' => 'Access assessment', 'type' => 'text'],
['name' => 'exam_findings', 'label' => 'Exam findings', 'type' => 'textarea', 'rows' => 3],
['name' => 'uremic_symptoms', 'label' => 'Uremic symptoms', 'type' => 'textarea', 'rows' => 2],
],
'renal_session' => [ 'renal_session' => [
['name' => 'session_type', 'label' => 'Session type', 'type' => 'select', 'required' => true, 'options' => ['Hemodialysis', 'Peritoneal dialysis', 'Clinic review', 'Other']], ['name' => 'session_type', 'label' => 'Session / labs type', 'type' => 'select', 'required' => true, 'options' => ['Hemodialysis', 'Peritoneal dialysis', 'Clinic labs review', 'Other']],
['name' => 'pre_weight_kg', 'label' => 'Pre-weight (kg)', 'type' => 'number'], ['name' => 'pre_weight_kg', 'label' => 'Pre-weight (kg)', 'type' => 'number'],
['name' => 'post_weight_kg', 'label' => 'Post-weight (kg)', 'type' => 'number'], ['name' => 'post_weight_kg', 'label' => 'Post-weight (kg)', 'type' => 'number'],
['name' => 'uf_goal_l', 'label' => 'UF goal (L)', 'type' => 'number'], ['name' => 'uf_goal_l', 'label' => 'UF goal (L)', 'type' => 'number'],
['name' => 'access', 'label' => 'Access', 'type' => 'text'], ['name' => 'access', 'label' => 'Access', 'type' => 'text'],
['name' => 'bp_pre', 'label' => 'BP pre', 'type' => 'text'], ['name' => 'bp_pre', 'label' => 'BP pre', 'type' => 'text'],
['name' => 'bp_post', 'label' => 'BP post', 'type' => 'text'], ['name' => 'bp_post', 'label' => 'BP post', 'type' => 'text'],
['name' => 'labs_summary', 'label' => 'Labs summary', 'type' => 'textarea', 'rows' => 2],
['name' => 'complications', 'label' => 'Complications', 'type' => 'textarea', 'rows' => 2], ['name' => 'complications', 'label' => 'Complications', 'type' => 'textarea', 'rows' => 2],
['name' => 'notes', 'label' => 'Session notes', 'type' => 'textarea', 'required' => true, 'rows' => 3], ['name' => 'notes', 'label' => 'Session / labs notes', 'type' => 'textarea', 'required' => true, 'rows' => 3],
], ],
'clinical_note' => [ 'renal_plan' => [
['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4], ['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true],
['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3], ['name' => 'plan', 'label' => 'Care plan', 'type' => 'textarea', 'required' => true, 'rows' => 3],
['name' => 'working_diagnosis', 'label' => 'Working diagnosis', 'type' => 'text'], ['name' => 'dialysis_prescription', 'label' => 'Dialysis prescription notes', 'type' => 'textarea', 'rows' => 2],
['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 3], ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2],
['name' => 'follow_up', 'label' => 'Follow-up', 'type' => 'text'],
['name' => 'outcome', 'label' => 'Visit outcome', 'type' => 'select', 'required' => true, 'options' => ['In progress', 'Completed — continue plan', 'Completed — discharged', 'Referred', 'Admitted']],
['name' => 'advice', 'label' => 'Patient advice', 'type' => 'textarea', 'rows' => 2],
], ],
], ],
'surgery' => [ 'surgery' => [
'preop' => [ 'surg_history' => [
['name' => 'proposed_procedure', 'label' => 'Proposed procedure', 'type' => 'text', 'required' => true],
['name' => 'indication', 'label' => 'Indication', 'type' => 'textarea', 'required' => true, 'rows' => 2],
['name' => 'asa_class', 'label' => 'ASA class', 'type' => 'select', 'options' => ['I', 'II', 'III', 'IV', 'V', 'E']],
['name' => 'allergies_reviewed', 'label' => 'Allergies reviewed', 'type' => 'boolean'],
['name' => 'consent_obtained', 'label' => 'Consent obtained', 'type' => 'boolean'],
['name' => 'fitness', 'label' => 'Fitness for anaesthesia', 'type' => 'textarea', 'rows' => 2],
['name' => 'investigations', 'label' => 'Relevant investigations', 'type' => 'textarea', 'rows' => 2],
['name' => 'plan', 'label' => 'Peri-op plan', 'type' => 'textarea', 'required' => true, 'rows' => 2],
],
'clinical_note' => [
['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4], ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4],
['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3], ['name' => 'past_surgical', 'label' => 'Past surgical history', 'type' => 'textarea', 'rows' => 2],
['name' => 'working_diagnosis', 'label' => 'Working diagnosis', 'type' => 'text'], ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2],
['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 3], ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'],
['name' => 'comorbidities', 'label' => 'Comorbidities', 'type' => 'textarea', 'rows' => 2],
['name' => 'anaesthesia_history', 'label' => 'Anaesthesia history', 'type' => 'textarea', 'rows' => 2],
],
'surg_exam' => [
['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true],
['name' => 'exam_findings', 'label' => 'Examination findings', 'type' => 'textarea', 'required' => true, 'rows' => 3],
['name' => 'asa_class', 'label' => 'ASA class', 'type' => 'select', 'options' => ['I', 'II', 'III', 'IV', 'V', 'E']],
['name' => 'fitness', 'label' => 'Fitness notes', 'type' => 'textarea', 'rows' => 2],
['name' => 'site_marking', 'label' => 'Site marking reviewed', 'type' => 'boolean'],
],
'surg_investigation' => [
['name' => 'investigation', 'label' => 'Investigation', 'type' => 'text', 'required' => true],
['name' => 'findings', 'label' => 'Findings', 'type' => 'textarea', 'required' => true, 'rows' => 3],
['name' => 'impression', 'label' => 'Impression', 'type' => 'textarea', 'rows' => 2],
['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2],
],
'surg_plan' => [
['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true],
['name' => 'proposed_procedure', 'label' => 'Proposed procedure', 'type' => 'text'],
['name' => 'plan', 'label' => 'Consent / peri-op plan', 'type' => 'textarea', 'required' => true, 'rows' => 3],
['name' => 'consent_obtained', 'label' => 'Consent obtained', 'type' => 'boolean'],
['name' => 'procedure_planned', 'label' => 'Procedure planned today', 'type' => 'boolean'],
['name' => 'follow_up', 'label' => 'Follow-up', 'type' => 'text'],
['name' => 'advice', 'label' => 'Patient advice', 'type' => 'textarea', 'rows' => 2],
],
'surg_procedure' => [
['name' => 'procedure', 'label' => 'Procedure', 'type' => 'text', 'required' => true],
['name' => 'indication', 'label' => 'Indication', 'type' => 'textarea', 'rows' => 2],
['name' => 'technique', 'label' => 'Technique / findings', 'type' => 'textarea', 'rows' => 2],
['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed uneventfully', 'Completed with complications', 'Deferred', 'Aborted']],
['name' => 'complications', 'label' => 'Complications', 'type' => 'textarea', 'rows' => 2],
['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2],
],
'surg_postop' => [
['name' => 'review_type', 'label' => 'Review type', 'type' => 'select', 'required' => true, 'options' => ['Immediate post-op', 'Clinic wound check', 'Follow-up review', 'Discharge review']],
['name' => 'progress', 'label' => 'Progress / wound status', 'type' => 'textarea', 'required' => true, 'rows' => 3],
['name' => 'pain_score', 'label' => 'Pain score (010)', 'type' => 'number'],
['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['In progress', 'Completed — continue plan', 'Completed — discharged', 'Referred', 'Admitted']],
['name' => 'next_steps', 'label' => 'Next steps', 'type' => 'textarea', 'rows' => 2],
['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2],
], ],
], ],
'vaccination' => [ 'vaccination' => [
+66 -14
View File
@@ -513,62 +513,114 @@ return [
], ],
'oncology' => [ 'oncology' => [
'stages' => [ 'stages' => [
['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'],
['code' => 'review', 'label' => 'Oncology review', 'queue_point' => 'chair'], ['code' => 'history', 'label' => 'History', 'queue_point' => 'waiting'],
['code' => 'treatment', 'label' => 'Treatment', 'queue_point' => 'chair'], ['code' => 'staging', 'label' => 'Staging / exam', 'queue_point' => 'chair'],
['code' => 'investigation', 'label' => 'Investigations', 'queue_point' => 'chair'],
['code' => 'plan', 'label' => 'Diagnosis & plan', 'queue_point' => 'chair'],
['code' => 'treatment', 'label' => 'Treatment / chemo', 'queue_point' => 'chair'],
['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null],
], ],
'services' => [ 'services' => [
['code' => 'onc.consult', 'label' => 'Oncology consultation', 'amount_minor' => 10000, 'type' => 'consultation'], ['code' => 'onc.consult', 'label' => 'Oncology consultation', 'amount_minor' => 10000, 'type' => 'consultation'],
['code' => 'onc.staging', 'label' => 'Staging assessment', 'amount_minor' => 8000, 'type' => 'consultation'],
['code' => 'onc.chemo', 'label' => 'Chemo day-care session', 'amount_minor' => 50000, 'type' => 'procedure'], ['code' => 'onc.chemo', 'label' => 'Chemo day-care session', 'amount_minor' => 50000, 'type' => 'procedure'],
['code' => 'onc.supportive', 'label' => 'Supportive care visit', 'amount_minor' => 6000, 'type' => 'consultation'],
], ],
'workspace_tabs' => [ 'workspace_tabs' => [
'overview' => 'Overview', 'overview' => 'Overview',
'review' => 'Oncology review', 'history' => 'History',
'clinical_notes' => 'Clinical notes', 'staging' => 'Staging / exam',
'investigations' => 'Investigations',
'plan' => 'Diagnosis & plan',
'treatment' => 'Treatment',
'orders' => 'Orders', 'orders' => 'Orders',
'billing' => 'Billing', 'billing' => 'Billing',
'documents' => 'Documents', 'documents' => 'Documents',
], ],
'stage_tabs' => [
'check_in' => 'overview',
'history' => 'history',
'staging' => 'staging',
'investigation' => 'investigations',
'plan' => 'plan',
'treatment' => 'treatment',
'completed' => 'overview',
],
], ],
'renal' => [ 'renal' => [
'stages' => [ 'stages' => [
['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'],
['code' => 'dialysis', 'label' => 'Dialysis / clinic', 'queue_point' => 'chair'], ['code' => 'history', 'label' => 'History', 'queue_point' => 'waiting'],
['code' => 'exam', 'label' => 'Exam', 'queue_point' => 'chair'],
['code' => 'dialysis', 'label' => 'Dialysis / labs', 'queue_point' => 'chair'],
['code' => 'plan', 'label' => 'Care plan', 'queue_point' => 'chair'],
['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null],
], ],
'services' => [ 'services' => [
['code' => 'ren.consult', 'label' => 'Nephrology consultation', 'amount_minor' => 8000, 'type' => 'consultation'], ['code' => 'ren.consult', 'label' => 'Nephrology consultation', 'amount_minor' => 8000, 'type' => 'consultation'],
['code' => 'ren.dialysis', 'label' => 'Dialysis session', 'amount_minor' => 35000, 'type' => 'procedure'], ['code' => 'ren.dialysis', 'label' => 'Dialysis session', 'amount_minor' => 35000, 'type' => 'procedure'],
['code' => 'ren.labs', 'label' => 'Renal labs panel', 'amount_minor' => 6000, 'type' => 'lab'],
['code' => 'ren.review', 'label' => 'Clinic review', 'amount_minor' => 5000, 'type' => 'consultation'],
], ],
'workspace_tabs' => [ 'workspace_tabs' => [
'overview' => 'Overview', 'overview' => 'Overview',
'session' => 'Renal session', 'history' => 'History',
'clinical_notes' => 'Clinical notes', 'exam' => 'Exam',
'dialysis' => 'Dialysis / labs',
'plan' => 'Care plan',
'orders' => 'Orders', 'orders' => 'Orders',
'billing' => 'Billing', 'billing' => 'Billing',
'documents' => 'Documents', 'documents' => 'Documents',
], ],
'stage_tabs' => [
'check_in' => 'overview',
'history' => 'history',
'exam' => 'exam',
'dialysis' => 'dialysis',
'plan' => 'plan',
'completed' => 'overview',
],
], ],
'surgery' => [ 'surgery' => [
'stages' => [ 'stages' => [
['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'],
['code' => 'preop', 'label' => 'Pre-op assessment', 'queue_point' => 'chair'], ['code' => 'history', 'label' => 'Pre-op history', 'queue_point' => 'waiting'],
['code' => 'postop', 'label' => 'Post-op review', 'queue_point' => 'chair'], ['code' => 'exam', 'label' => 'Exam', 'queue_point' => 'chair'],
['code' => 'investigation', 'label' => 'Investigations', 'queue_point' => 'chair'],
['code' => 'plan', 'label' => 'Consent / plan', 'queue_point' => 'chair'],
['code' => 'procedure', 'label' => 'Procedure', 'queue_point' => 'chair'],
['code' => 'postop', 'label' => 'Post-op', 'queue_point' => 'chair'],
['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null],
], ],
'services' => [ 'services' => [
['code' => 'sur.consult', 'label' => 'Surgical consultation', 'amount_minor' => 8000, 'type' => 'consultation'], ['code' => 'sur.consult', 'label' => 'Surgical consultation', 'amount_minor' => 8000, 'type' => 'consultation'],
['code' => 'sur.preop', 'label' => 'Pre-op assessment', 'amount_minor' => 5000, 'type' => 'consultation'], ['code' => 'sur.preop', 'label' => 'Pre-op assessment', 'amount_minor' => 5000, 'type' => 'consultation'],
['code' => 'sur.procedure', 'label' => 'Minor procedure / clinic', 'amount_minor' => 25000, 'type' => 'procedure'],
['code' => 'sur.postop', 'label' => 'Post-op review', 'amount_minor' => 4500, 'type' => 'consultation'],
], ],
'workspace_tabs' => [ 'workspace_tabs' => [
'overview' => 'Overview', 'overview' => 'Overview',
'preop' => 'Pre-op', 'history' => 'History',
'clinical_notes' => 'Clinical notes', 'exam' => 'Exam',
'investigations' => 'Investigations',
'plan' => 'Consent / plan',
'procedure' => 'Procedure',
'postop' => 'Post-op',
'orders' => 'Orders', 'orders' => 'Orders',
'billing' => 'Billing', 'billing' => 'Billing',
'documents' => 'Documents', 'documents' => 'Documents',
], ],
'stage_tabs' => [
'check_in' => 'overview',
'history' => 'history',
'exam' => 'exam',
'investigation' => 'investigations',
'plan' => 'plan',
'procedure' => 'procedure',
'postop' => 'postop',
'completed' => 'overview',
],
], ],
'vaccination' => [ 'vaccination' => [
'stages' => [ 'stages' => [
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Oncology 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' => 'oncology', 'visit' => $visit]) }}">Back</a></p>
<h1>{{ $patient->fullName() }}</h1>
<p class="meta">{{ $patient->patient_number }} · Visit #{{ $visit->id }} · Stage {{ $visit->specialty_stage ?? '—' }}</p>
<h2>History</h2>
@if ($history)<dl><dt>HPI</dt><dd>{{ $history->payload['history'] ?? '—' }}</dd><dt>Cancer history</dt><dd>{{ $history->payload['cancer_history'] ?? '—' }}</dd></dl>@else<p class="meta">No history recorded.</p>@endif
<h2>Staging / exam</h2>
@if ($staging)<dl><dt>Diagnosis</dt><dd>{{ $staging->payload['diagnosis'] ?? '—' }}</dd><dt>Stage</dt><dd>{{ $staging->payload['stage'] ?? '—' }}</dd><dt>ECOG</dt><dd>{{ $staging->payload['performance_status'] ?? '—' }}</dd></dl>@else<p class="meta">No staging recorded.</p>@endif
<h2>Investigations</h2>
@if ($investigation)<dl><dt>Investigation</dt><dd>{{ $investigation->payload['investigation'] ?? '—' }}</dd><dt>Findings</dt><dd>{{ $investigation->payload['findings'] ?? '—' }}</dd></dl>@else<p class="meta">No investigation recorded.</p>@endif
<h2>Plan</h2>
@if ($plan)<dl><dt>Diagnosis</dt><dd>{{ $plan->payload['diagnosis'] ?? '—' }}</dd><dt>Plan</dt><dd>{{ $plan->payload['plan'] ?? '—' }}</dd></dl>@else<p class="meta">No plan recorded.</p>@endif
<h2>Treatment</h2>
@if ($treatment)<dl><dt>Treatment</dt><dd>{{ $treatment->payload['treatment'] ?? '—' }}</dd><dt>Outcome</dt><dd>{{ $treatment->payload['outcome'] ?? '—' }}</dd></dl>@else<p class="meta">No treatment recorded.</p>@endif
<script>window.print();</script>
</body>
</html>
@@ -0,0 +1,87 @@
<x-app-layout title="Oncology 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">Oncology reports</h1>
<p class="mt-1 text-sm text-slate-500">Arrivals, open chemo notes, diagnoses, regimens, length of stay, and oncology revenue.</p>
</div>
<a href="{{ route('care.specialty.show', 'oncology') }}" 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">Chemo notes open</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-rose-700">{{ number_format($report['chemo_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 staging records 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">Regimens / treatments</h2>
<ul class="mt-3 space-y-2 text-sm">
@forelse ($report['regimen_breakdown'] as $row)
<li class="flex justify-between gap-3">
<span>{{ $row['regimen'] }}</span>
<span class="tabular-nums text-slate-600">{{ $row['total'] }}</span>
</li>
@empty
<li class="text-slate-500">No treatments 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">Oncology 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 oncology services in range.</li>
@endforelse
</ul>
</section>
</div>
</x-app-layout>
@@ -0,0 +1,60 @@
@php
$hist = $oncologyHistory?->payload ?? [];
$staging = $oncologyStaging?->payload ?? [];
$plan = $oncologyPlan?->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">Oncology overview</h3>
<p class="mt-0.5 text-xs text-slate-500">History, staging, investigations, plan, and treatment for this episode.</p>
</div>
<div class="flex flex-wrap gap-3 text-sm">
<a href="{{ route('care.specialty.oncology.print', $workspaceVisit) }}" target="_blank" class="font-medium text-indigo-600">Print summary</a>
<a href="{{ route('care.specialty.oncology.reports') }}" class="font-medium text-indigo-600">Oncology 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">Diagnosis</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $staging['diagnosis'] ?? ($plan['diagnosis'] ?? '—') }}</p>
<p class="mt-1 text-xs text-slate-500">Stage {{ $staging['stage'] ?? '—' }}</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">ECOG</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $staging['performance_status'] ?? '—' }}</p>
<p class="mt-1 text-xs text-slate-500">{{ $plan['intent'] ?? '—' }}</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">Plan</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ \Illuminate\Support\Str::limit($plan['plan'] ?? 'Not set', 40) }}</p>
<p class="mt-1 text-xs text-slate-500">{{ $plan['follow_up'] ?? '—' }}</p>
</div>
</div>
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt class="text-slate-500">History</dt>
<dd class="font-medium text-slate-900">{{ $hist['history'] ?? '—' }}</dd>
</div>
<div>
<dt class="text-slate-500">Queue</dt>
<dd class="font-medium text-slate-900">
{{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }}
</dd>
</div>
</dl>
@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 = $oncologyTreatment;
$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">Treatment / chemo</h3>
<p class="mt-0.5 text-xs text-slate-500">Completed treatments can close the oncology visit.</p>
</div>
<a href="{{ route('care.specialty.oncology.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.oncology.treatment', $workspaceVisit) }}" class="mt-4 space-y-4">
@csrf
<input type="hidden" name="tab" value="treatment">
<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 treatment</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 treatment recorded yet.</p>
@endif
</section>
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><title>Renal summary · {{ $patient->fullName() }}</title>
<style>body{font-family:system-ui,sans-serif;color:#0f172a;margin:2rem}h1{font-size:1.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' => 'renal', 'visit' => $visit]) }}">Back</a></p>
<h1>{{ $patient->fullName() }}</h1>
<p class="meta">{{ $patient->patient_number }} · Visit #{{ $visit->id }} · Stage {{ $visit->specialty_stage ?? '—' }}</p>
<h2>History</h2>@if($history)<dl><dt>HPI</dt><dd>{{ $history->payload['history'] ?? '—' }}</dd><dt>CKD</dt><dd>{{ $history->payload['ckd_stage'] ?? '—' }}</dd></dl>@else<p class="meta">No history.</p>@endif
<h2>Exam</h2>@if($exam)<dl><dt>Complaint</dt><dd>{{ $exam->payload['chief_complaint'] ?? '—' }}</dd><dt>Fluid</dt><dd>{{ $exam->payload['fluid_status'] ?? '—' }}</dd></dl>@else<p class="meta">No exam.</p>@endif
<h2>Dialysis / labs</h2>@if($dialysis)<dl><dt>Type</dt><dd>{{ $dialysis->payload['session_type'] ?? '—' }}</dd><dt>Notes</dt><dd>{{ $dialysis->payload['notes'] ?? '—' }}</dd></dl>@else<p class="meta">No session.</p>@endif
<h2>Plan</h2>@if($plan)<dl><dt>Diagnosis</dt><dd>{{ $plan->payload['diagnosis'] ?? '—' }}</dd><dt>Plan</dt><dd>{{ $plan->payload['plan'] ?? '—' }}</dd><dt>Outcome</dt><dd>{{ $plan->payload['outcome'] ?? '—' }}</dd></dl>@else<p class="meta">No plan.</p>@endif
<script>window.print();</script></body></html>
@@ -0,0 +1,48 @@
<x-app-layout title="Renal Care 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">Renal Care reports</h1>
<p class="mt-1 text-sm text-slate-500">Arrivals, dialysis sessions, diagnoses, length of stay, and renal revenue.</p>
</div>
<a href="{{ route('care.specialty.show', 'renal') }}" 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">Dialysis stage open</p><p class="mt-2 text-2xl font-semibold tabular-nums text-rose-700">{{ number_format($report['dialysis_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</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">Session types</h2>
<ul class="mt-3 space-y-2 text-sm">
@forelse ($report['session_breakdown'] as $row)
<li class="flex justify-between gap-3"><span>{{ $row['session_type'] }}</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">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 care plans 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">Renal 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 renal services in range.</li>@endforelse
</ul>
</section>
</div>
</x-app-layout>
@@ -0,0 +1,46 @@
@php
$hist = $renalHistory?->payload ?? [];
$exam = $renalExam?->payload ?? [];
$plan = $renalPlan?->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">Renal Care overview</h3>
<p class="mt-0.5 text-xs text-slate-500">History, exam, dialysis/labs, and care plan for this episode.</p>
</div>
<div class="flex flex-wrap gap-3 text-sm">
<a href="{{ route('care.specialty.renal.print', $workspaceVisit) }}" target="_blank" class="font-medium text-indigo-600">Print summary</a>
<a href="{{ route('care.specialty.renal.reports') }}" class="font-medium text-indigo-600">Renal 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">Complaint</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $exam['chief_complaint'] ?? '—' }}</p>
<p class="mt-1 text-xs text-slate-500">Fluid {{ $exam['fluid_status'] ?? '—' }}</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">CKD / access</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $hist['ckd_stage'] ?? '—' }}</p>
<p class="mt-1 text-xs text-slate-500">{{ $exam['access'] ?? '—' }}</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 / plan</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $plan['diagnosis'] ?? 'Not set' }}</p>
<p class="mt-1 text-xs text-slate-500">{{ $plan['follow_up'] ?? '—' }}</p>
</div>
</div>
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
<div><dt class="text-slate-500">History</dt><dd class="font-medium text-slate-900">{{ $hist['history'] ?? '—' }}</dd></div>
<div><dt class="text-slate-500">Plan</dt><dd class="font-medium text-slate-900">{{ $plan['plan'] ?? '—' }}</dd></div>
</dl>
@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,67 @@
@php
$record = $renalPlan;
$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">Care plan</h3>
<p class="mt-0.5 text-xs text-slate-500">Completed / discharged outcomes can close the renal visit.</p>
</div>
<a href="{{ route('care.specialty.renal.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.renal.plan', $workspaceVisit) }}" class="mt-4 space-y-4">
@csrf
<input type="hidden" name="tab" value="plan">
<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 care plan</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 care plan recorded yet.</p>
@endif
</section>
@@ -79,6 +79,12 @@
@include('care.specialty.orthopedics.workspace-'.$activeTab) @include('care.specialty.orthopedics.workspace-'.$activeTab)
@elseif ($moduleKey === 'ent' && in_array($activeTab, ['overview', 'procedure'], true)) @elseif ($moduleKey === 'ent' && in_array($activeTab, ['overview', 'procedure'], true))
@include('care.specialty.ent.workspace-'.$activeTab) @include('care.specialty.ent.workspace-'.$activeTab)
@elseif ($moduleKey === 'oncology' && in_array($activeTab, ['overview', 'treatment'], true))
@include('care.specialty.oncology.workspace-'.$activeTab)
@elseif ($moduleKey === 'renal' && in_array($activeTab, ['overview', 'plan'], true))
@include('care.specialty.renal.workspace-'.$activeTab)
@elseif ($moduleKey === 'surgery' && in_array($activeTab, ['overview', 'postop'], true))
@include('care.specialty.surgery.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">
@@ -61,6 +61,15 @@
@if ($moduleKey === 'ent') @if ($moduleKey === 'ent')
<a href="{{ route('care.specialty.ent.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a> <a href="{{ route('care.specialty.ent.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
@endif @endif
@if ($moduleKey === 'oncology')
<a href="{{ route('care.specialty.oncology.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
@endif
@if ($moduleKey === 'renal')
<a href="{{ route('care.specialty.renal.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
@endif
@if ($moduleKey === 'surgery')
<a href="{{ route('care.specialty.surgery.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,14 @@
<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><title>Surgery summary · {{ $patient->fullName() }}</title>
<style>body{font-family:system-ui,sans-serif;color:#0f172a;margin:2rem}h1{font-size:1.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' => 'surgery', 'visit' => $visit]) }}">Back</a></p>
<h1>{{ $patient->fullName() }}</h1>
<p class="meta">{{ $patient->patient_number }} · Visit #{{ $visit->id }} · Stage {{ $visit->specialty_stage ?? '—' }}</p>
<h2>History</h2>@if($history)<dl><dt>HPI</dt><dd>{{ $history->payload['history'] ?? '—' }}</dd></dl>@else<p class="meta">No history.</p>@endif
<h2>Exam</h2>@if($exam)<dl><dt>Complaint</dt><dd>{{ $exam->payload['chief_complaint'] ?? '—' }}</dd><dt>ASA</dt><dd>{{ $exam->payload['asa_class'] ?? '—' }}</dd></dl>@else<p class="meta">No exam.</p>@endif
<h2>Investigations</h2>@if($investigation)<dl><dt>Investigation</dt><dd>{{ $investigation->payload['investigation'] ?? '—' }}</dd><dt>Findings</dt><dd>{{ $investigation->payload['findings'] ?? '—' }}</dd></dl>@else<p class="meta">No investigation.</p>@endif
<h2>Consent / plan</h2>@if($plan)<dl><dt>Diagnosis</dt><dd>{{ $plan->payload['diagnosis'] ?? '—' }}</dd><dt>Procedure</dt><dd>{{ $plan->payload['proposed_procedure'] ?? '—' }}</dd><dt>Consent</dt><dd>{{ !empty($plan->payload['consent_obtained']) ? 'Yes' : 'No' }}</dd></dl>@else<p class="meta">No plan.</p>@endif
<h2>Procedure</h2>@if($procedure)<dl><dt>Procedure</dt><dd>{{ $procedure->payload['procedure'] ?? '—' }}</dd><dt>Outcome</dt><dd>{{ $procedure->payload['outcome'] ?? '—' }}</dd></dl>@else<p class="meta">No procedure.</p>@endif
<h2>Post-op</h2>@if($postop)<dl><dt>Progress</dt><dd>{{ $postop->payload['progress'] ?? '—' }}</dd><dt>Outcome</dt><dd>{{ $postop->payload['outcome'] ?? '—' }}</dd></dl>@else<p class="meta">No post-op.</p>@endif
<script>window.print();</script></body></html>
@@ -0,0 +1,33 @@
<x-app-layout title="Surgery 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">Surgery reports</h1>
<p class="mt-1 text-sm text-slate-500">Arrivals, pre-op pipeline, diagnoses, procedures, length of stay, and surgery revenue.</p>
</div>
<a href="{{ route('care.specialty.show', 'surgery') }}" 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">Pre-op pipeline</p><p class="mt-2 text-2xl font-semibold tabular-nums text-rose-700">{{ number_format($report['preop_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</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 plans 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">Procedures</h2>
<ul class="mt-3 space-y-2 text-sm">@forelse ($report['procedure_breakdown'] as $row)<li class="flex justify-between gap-3"><span>{{ $row['procedure'] }}</span><span class="tabular-nums text-slate-600">{{ $row['total'] }}</span></li>@empty<li class="text-slate-500">No procedures 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">Surgery 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 surgery services in range.</li>@endforelse</ul>
</section>
</div>
</x-app-layout>
@@ -0,0 +1,46 @@
@php
$hist = $surgeryHistory?->payload ?? [];
$exam = $surgeryExam?->payload ?? [];
$plan = $surgeryPlan?->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">Surgery overview</h3>
<p class="mt-0.5 text-xs text-slate-500">Pre-op history, exam, investigations, consent/plan, procedure, and post-op.</p>
</div>
<div class="flex flex-wrap gap-3 text-sm">
<a href="{{ route('care.specialty.surgery.print', $workspaceVisit) }}" target="_blank" class="font-medium text-indigo-600">Print summary</a>
<a href="{{ route('care.specialty.surgery.reports') }}" class="font-medium text-indigo-600">Surgery 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">Complaint</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $exam['chief_complaint'] ?? '—' }}</p>
<p class="mt-1 text-xs text-slate-500">ASA {{ $exam['asa_class'] ?? '—' }}</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">Proposed procedure</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $plan['proposed_procedure'] ?? '—' }}</p>
<p class="mt-1 text-xs text-slate-500">{{ ! empty($plan['consent_obtained']) ? 'Consent obtained' : 'Consent pending' }}</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 / plan</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $plan['diagnosis'] ?? 'Not set' }}</p>
<p class="mt-1 text-xs text-slate-500">{{ $plan['follow_up'] ?? '—' }}</p>
</div>
</div>
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
<div><dt class="text-slate-500">History</dt><dd class="font-medium text-slate-900">{{ $hist['history'] ?? '—' }}</dd></div>
<div><dt class="text-slate-500">Plan</dt><dd class="font-medium text-slate-900">{{ $plan['plan'] ?? '—' }}</dd></div>
</dl>
@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,67 @@
@php
$record = $surgeryPostop;
$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">Post-op</h3>
<p class="mt-0.5 text-xs text-slate-500">Completed / discharged outcomes can close the surgery visit.</p>
</div>
<a href="{{ route('care.specialty.surgery.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.surgery.postop', $workspaceVisit) }}" class="mt-4 space-y-4">
@csrf
<input type="hidden" name="tab" value="postop">
<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 post-op</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 post-op note recorded yet.</p>
@endif
</section>
+15
View File
@@ -50,6 +50,9 @@ use App\Http\Controllers\Care\PsychiatryWorkspaceController;
use App\Http\Controllers\Care\PediatricsWorkspaceController; use App\Http\Controllers\Care\PediatricsWorkspaceController;
use App\Http\Controllers\Care\OrthopedicsWorkspaceController; use App\Http\Controllers\Care\OrthopedicsWorkspaceController;
use App\Http\Controllers\Care\EntWorkspaceController; use App\Http\Controllers\Care\EntWorkspaceController;
use App\Http\Controllers\Care\OncologyWorkspaceController;
use App\Http\Controllers\Care\RenalWorkspaceController;
use App\Http\Controllers\Care\SurgeryWorkspaceController;
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;
@@ -141,6 +144,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/specialty/pediatrics/reports', [PediatricsWorkspaceController::class, 'reports'])->name('care.specialty.pediatrics.reports'); Route::get('/specialty/pediatrics/reports', [PediatricsWorkspaceController::class, 'reports'])->name('care.specialty.pediatrics.reports');
Route::get('/specialty/orthopedics/reports', [OrthopedicsWorkspaceController::class, 'reports'])->name('care.specialty.orthopedics.reports'); Route::get('/specialty/orthopedics/reports', [OrthopedicsWorkspaceController::class, 'reports'])->name('care.specialty.orthopedics.reports');
Route::get('/specialty/ent/reports', [EntWorkspaceController::class, 'reports'])->name('care.specialty.ent.reports'); Route::get('/specialty/ent/reports', [EntWorkspaceController::class, 'reports'])->name('care.specialty.ent.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/surgery/reports', [SurgeryWorkspaceController::class, 'reports'])->name('care.specialty.surgery.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');
@@ -201,6 +207,15 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::post('/specialty/ent/workspace/{visit}/stage', [EntWorkspaceController::class, 'setStage'])->name('care.specialty.ent.stage'); Route::post('/specialty/ent/workspace/{visit}/stage', [EntWorkspaceController::class, 'setStage'])->name('care.specialty.ent.stage');
Route::post('/specialty/ent/workspace/{visit}/procedure', [EntWorkspaceController::class, 'saveProcedure'])->name('care.specialty.ent.procedure'); Route::post('/specialty/ent/workspace/{visit}/procedure', [EntWorkspaceController::class, 'saveProcedure'])->name('care.specialty.ent.procedure');
Route::get('/specialty/ent/workspace/{visit}/print', [EntWorkspaceController::class, 'printSummary'])->name('care.specialty.ent.print'); Route::get('/specialty/ent/workspace/{visit}/print', [EntWorkspaceController::class, 'printSummary'])->name('care.specialty.ent.print');
Route::post('/specialty/oncology/workspace/{visit}/stage', [OncologyWorkspaceController::class, 'setStage'])->name('care.specialty.oncology.stage');
Route::post('/specialty/oncology/workspace/{visit}/treatment', [OncologyWorkspaceController::class, 'saveTreatment'])->name('care.specialty.oncology.treatment');
Route::get('/specialty/oncology/workspace/{visit}/print', [OncologyWorkspaceController::class, 'printSummary'])->name('care.specialty.oncology.print');
Route::post('/specialty/renal/workspace/{visit}/stage', [RenalWorkspaceController::class, 'setStage'])->name('care.specialty.renal.stage');
Route::post('/specialty/renal/workspace/{visit}/plan', [RenalWorkspaceController::class, 'savePlan'])->name('care.specialty.renal.plan');
Route::get('/specialty/renal/workspace/{visit}/print', [RenalWorkspaceController::class, 'printSummary'])->name('care.specialty.renal.print');
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::get('/specialty/surgery/workspace/{visit}/print', [SurgeryWorkspaceController::class, 'printSummary'])->name('care.specialty.surgery.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');
+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 CareOncologySuiteTest 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' => 'onc-owner',
'name' => 'Owner',
'email' => 'onc-owner@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->owner->public_id,
'name' => 'Oncology Clinic',
'slug' => 'onc-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,
'oncology',
);
$department = Department::query()
->where('branch_id', $this->branch->id)
->where('type', 'oncology')
->firstOrFail();
$this->patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-ONC-SUITE',
'first_name' => 'Ama',
'last_name' => 'Cancer',
'gender' => 'female',
'date_of_birth' => '1988-09-02',
]);
$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' => 'oncology',
'visit' => $this->visit,
'tab' => 'overview',
]))
->assertOk()
->assertSee('Oncology overview')
->assertSee('data-care-stage-bar', false)
->assertSee('Start history')
->assertSee('Oncology reports');
$this->actingAs($this->owner)
->get(route('care.specialty.workspace', [
'module' => 'oncology',
'visit' => $this->visit,
'tab' => 'staging',
]))
->assertOk()
->assertSee('Oncology diagnosis');
}
public function test_clinical_save_sets_stage_and_alerts(): void
{
$this->actingAs($this->owner)
->post(route('care.specialty.clinical.save', [
'module' => 'oncology',
'visit' => $this->visit,
]), [
'tab' => 'staging',
'payload' => [
'diagnosis' => 'Breast carcinoma',
'stage' => 'IIA',
'performance_status' => '3',
'exam_findings' => 'Palpable mass',
],
])
->assertRedirect();
$this->assertSame('staging', $this->visit->fresh()->specialty_stage);
$record = SpecialtyClinicalRecord::query()
->where('visit_id', $this->visit->id)
->firstOrFail();
$codes = collect($record->alerts)->pluck('code')->all();
$this->assertContains('onc.ecog_high', $codes);
}
public function test_stage_advance_and_terminal_completes_visit(): void
{
$this->actingAs($this->owner)
->post(route('care.specialty.oncology.stage', $this->visit), [
'stage' => 'treatment',
])
->assertRedirect(route('care.specialty.workspace', [
'module' => 'oncology',
'visit' => $this->visit,
'tab' => 'treatment',
]));
$this->assertSame('treatment', $this->visit->fresh()->specialty_stage);
$this->actingAs($this->owner)
->post(route('care.specialty.oncology.treatment', $this->visit), [
'tab' => 'treatment',
'payload' => [
'treatment' => 'AC cycle 1 day 1',
'regimen' => 'AC',
'cycle' => '1/4',
'outcome' => 'Completed uneventfully',
'post_treatment_plan' => 'Return cycle 2 in 21 days',
],
])
->assertRedirect(route('care.specialty.workspace', [
'module' => 'oncology',
'visit' => $this->visit,
'tab' => 'treatment',
]));
$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' => 'onc_treatment',
'status' => SpecialtyClinicalRecord::STATUS_COMPLETED,
]);
}
public function test_reports_and_print(): void
{
$this->actingAs($this->owner)
->get(route('care.specialty.oncology.reports'))
->assertOk()
->assertSee('Oncology reports')
->assertSee('Arrivals today');
$this->actingAs($this->owner)
->get(route('care.specialty.oncology.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', 'oncology'))
->assertOk()
->assertDontSee('Oncology overview');
}
}
+231
View File
@@ -0,0 +1,231 @@
<?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 CareRenalSuiteTest 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' => 'renal-owner',
'name' => 'Owner',
'email' => 'renal-owner@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->owner->public_id,
'name' => 'Renal Clinic',
'slug' => 'renal-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,
'renal',
);
$department = Department::query()
->where('branch_id', $this->branch->id)
->where('type', 'renal')
->firstOrFail();
$this->patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-REN-SUITE',
'first_name' => 'Kojo',
'last_name' => 'Kidney',
'gender' => 'female',
'date_of_birth' => '1988-09-02',
]);
$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' => 'renal',
'visit' => $this->visit,
'tab' => 'overview',
]))
->assertOk()
->assertSee('Renal Care overview')
->assertSee('data-care-stage-bar', false)
->assertSee('Start history')
->assertSee('Renal reports');
$this->actingAs($this->owner)
->get(route('care.specialty.workspace', [
'module' => 'renal',
'visit' => $this->visit,
'tab' => 'exam',
]))
->assertOk()
->assertSee('Chief complaint', false);
}
public function test_clinical_save_sets_stage_and_alerts(): void
{
$this->actingAs($this->owner)
->post(route('care.specialty.clinical.save', [
'module' => 'renal',
'visit' => $this->visit,
]), [
'tab' => 'exam',
'payload' => [
'chief_complaint' => 'Missed dialysis with oedema',
'fluid_status' => 'Overload',
'exam_findings' => 'Bilateral crackles',
],
])
->assertRedirect();
$this->assertSame('exam', $this->visit->fresh()->specialty_stage);
$record = SpecialtyClinicalRecord::query()
->where('visit_id', $this->visit->id)
->firstOrFail();
$codes = collect($record->alerts)->pluck('code')->all();
$this->assertContains('ren.fluid_overload', $codes);
}
public function test_stage_advance_and_terminal_completes_visit(): void
{
$this->actingAs($this->owner)
->post(route('care.specialty.renal.stage', $this->visit), [
'stage' => 'plan',
])
->assertRedirect(route('care.specialty.workspace', [
'module' => 'renal',
'visit' => $this->visit,
'tab' => 'plan',
]));
$this->assertSame('plan', $this->visit->fresh()->specialty_stage);
$this->actingAs($this->owner)
->post(route('care.specialty.renal.plan', $this->visit), [
'tab' => 'plan',
'payload' => [
'diagnosis' => 'CKD 5D with fluid overload',
'plan' => 'Urgent HD; adjust dry weight',
'outcome' => 'Completed — discharged',
'follow_up' => 'Next HD in 2 days',
],
])
->assertRedirect(route('care.specialty.workspace', [
'module' => 'renal',
'visit' => $this->visit,
'tab' => 'plan',
]));
$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' => 'renal_plan',
'status' => SpecialtyClinicalRecord::STATUS_COMPLETED,
]);
}
public function test_reports_and_print(): void
{
$this->actingAs($this->owner)
->get(route('care.specialty.renal.reports'))
->assertOk()
->assertSee('Renal Care reports')
->assertSee('Arrivals today');
$this->actingAs($this->owner)
->get(route('care.specialty.renal.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', 'renal'))
->assertOk()
->assertDontSee('Renal Care overview');
}
}
+224
View File
@@ -0,0 +1,224 @@
<?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 CareSurgerySuiteTest 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' => 'surg-owner',
'name' => 'Owner',
'email' => 'surg-owner@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->owner->public_id,
'name' => 'Surgery Clinic',
'slug' => 'surg-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,
'surgery',
);
$department = Department::query()
->where('branch_id', $this->branch->id)
->where('type', 'surgery')
->firstOrFail();
$this->patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-SUR-SUITE',
'first_name' => 'Efua',
'last_name' => 'Hernia',
'gender' => 'female',
'date_of_birth' => '1988-09-02',
]);
$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' => 'surgery',
'visit' => $this->visit,
'tab' => 'overview',
]))
->assertOk()
->assertSee('Surgery overview')
->assertSee('data-care-stage-bar', false)
->assertSee('Start pre-op history')
->assertSee('Surgery reports');
$this->actingAs($this->owner)
->get(route('care.specialty.workspace', [
'module' => 'surgery',
'visit' => $this->visit,
'tab' => 'exam',
]))
->assertOk()
->assertSee('Chief complaint', false);
}
public function test_clinical_save_sets_stage_and_alerts(): void
{
$this->actingAs($this->owner)
->post(route('care.specialty.clinical.save', [
'module' => 'surgery',
'visit' => $this->visit,
]), [
'tab' => 'exam',
'payload' => [
'chief_complaint' => 'Right inguinal hernia',
'exam_findings' => 'Reducible hernia',
'asa_class' => 'II',
],
])
->assertRedirect();
$this->assertSame('exam', $this->visit->fresh()->specialty_stage);
}
public function test_stage_advance_and_terminal_completes_visit(): void
{
$this->actingAs($this->owner)
->post(route('care.specialty.surgery.stage', $this->visit), [
'stage' => 'postop',
])
->assertRedirect(route('care.specialty.workspace', [
'module' => 'surgery',
'visit' => $this->visit,
'tab' => 'postop',
]));
$this->assertSame('postop', $this->visit->fresh()->specialty_stage);
$this->actingAs($this->owner)
->post(route('care.specialty.surgery.postop', $this->visit), [
'tab' => 'postop',
'payload' => [
'review_type' => 'Immediate post-op',
'progress' => 'Stable; wound dry',
'outcome' => 'Completed — discharged',
'next_steps' => 'Wound check in 7 days',
],
])
->assertRedirect(route('care.specialty.workspace', [
'module' => 'surgery',
'visit' => $this->visit,
'tab' => 'postop',
]));
$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' => 'surg_postop',
'status' => SpecialtyClinicalRecord::STATUS_COMPLETED,
]);
}
public function test_reports_and_print(): void
{
$this->actingAs($this->owner)
->get(route('care.specialty.surgery.reports'))
->assertOk()
->assertSee('Surgery reports')
->assertSee('Arrivals today');
$this->actingAs($this->owner)
->get(route('care.specialty.surgery.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', 'surgery'))
->assertOk()
->assertDontSee('Surgery overview');
}
}