diff --git a/app/Http/Controllers/Care/NursePerformanceController.php b/app/Http/Controllers/Care/NursePerformanceController.php new file mode 100644 index 0000000..ddaded3 --- /dev/null +++ b/app/Http/Controllers/Care/NursePerformanceController.php @@ -0,0 +1,39 @@ +authorizeAbility($request, 'nursing.performance.view'); + $this->authorizeOwner($request, $careUnit); + abort_unless((int) $careUnit->organization_id === (int) $this->organization($request)->id, 404); + + $from = $request->filled('from') + ? Carbon::parse($request->input('from'))->startOfDay() + : now()->subDays(7)->startOfDay(); + $to = $request->filled('to') + ? Carbon::parse($request->input('to'))->endOfDay() + : now()->endOfDay(); + + $report = $performance->unitReport($careUnit, $this->ownerRef($request), $from, $to); + + return view('care.nursing.performance', [ + 'unit' => $careUnit->load('department.branch'), + 'report' => $report, + 'canManage' => app(CarePermissions::class)->can($this->member($request), 'admin.departments.manage'), + ]); + } +} diff --git a/app/Http/Controllers/Care/NursingAssessmentController.php b/app/Http/Controllers/Care/NursingAssessmentController.php new file mode 100644 index 0000000..74f20c0 --- /dev/null +++ b/app/Http/Controllers/Care/NursingAssessmentController.php @@ -0,0 +1,114 @@ +authorizeAbility($request, 'nursing.assessments.view'); + $this->authorizeOwner($request, $careUnit); + abort_unless((int) $careUnit->organization_id === (int) $this->organization($request)->id, 404); + + app(CareFeatures::class)->ensureAssessmentCatalog(); + + $permissions = app(CarePermissions::class); + $member = $this->member($request); + + return view('care.nursing.assessments-board', [ + 'unit' => $careUnit->load('department.branch'), + 'templates' => $nursing->nursingTemplates(), + 'rows' => $nursing->unitBoard($careUnit, $this->ownerRef($request)), + 'canCapture' => $permissions->can($member, 'nursing.assessments.manage') + || $permissions->can($member, 'assessments.capture') + || $permissions->can($member, 'assessments.manage'), + 'canVitals' => $permissions->can($member, 'vitals.manage'), + 'engineEnabled' => app(CareFeatures::class)->enabled( + $this->organization($request), + CareFeatures::ASSESSMENTS_ENGINE, + ), + ]); + } + + public function start( + Request $request, + CareUnit $careUnit, + Visit $visit, + AssessmentService $assessments, + CareFeatures $features, + ): RedirectResponse { + $this->authorizeAbility($request, 'nursing.assessments.manage'); + $this->authorizeOwner($request, $careUnit); + $this->authorizeOwner($request, $visit); + abort_unless((int) $visit->care_unit_id === (int) $careUnit->id, 404); + abort_unless($features->enabled($this->organization($request), CareFeatures::ASSESSMENTS_ENGINE), 404); + + $features->ensureAssessmentCatalog(); + + $validated = $request->validate([ + 'template_code' => ['required', 'string', 'max:100'], + ]); + + $assessment = $assessments->start( + $visit->patient, + $validated['template_code'], + $this->ownerRef($request), + $this->member($request), + [ + 'visit' => $visit, + 'actor' => $this->actorRef($request), + ], + ); + + return redirect() + ->route('care.assessments.show', $assessment) + ->with('success', 'Nursing assessment started.'); + } + + public function storeVitals( + Request $request, + CareUnit $careUnit, + Visit $visit, + NursingAssessmentService $nursing, + ): RedirectResponse { + $this->authorizeAbility($request, 'vitals.manage'); + $this->authorizeOwner($request, $careUnit); + $this->authorizeOwner($request, $visit); + abort_unless((int) $visit->care_unit_id === (int) $careUnit->id, 404); + + $validated = $request->validate([ + 'bp_systolic' => ['nullable', 'integer', 'min:40', 'max:300'], + 'bp_diastolic' => ['nullable', 'integer', 'min:20', 'max:200'], + 'pulse' => ['nullable', 'integer', 'min:20', 'max:300'], + 'temperature' => ['nullable', 'numeric', 'min:30', 'max:45'], + 'spo2' => ['nullable', 'integer', 'min:50', 'max:100'], + 'respiratory_rate' => ['nullable', 'integer', 'min:4', 'max:60'], + 'notes' => ['nullable', 'string', 'max:1000'], + ]); + + $nursing->recordVisitVitals( + $visit, + $this->ownerRef($request), + $this->actorRef($request), + $validated, + ); + + return redirect() + ->route('care.care-units.assessments', $careUnit) + ->with('success', 'Ward vitals recorded.'); + } +} diff --git a/app/Models/VisitVital.php b/app/Models/VisitVital.php new file mode 100644 index 0000000..f5ff839 --- /dev/null +++ b/app/Models/VisitVital.php @@ -0,0 +1,54 @@ + 'decimal:1', + 'recorded_at' => 'datetime', + ]; + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function careUnit(): BelongsTo + { + return $this->belongsTo(CareUnit::class, 'care_unit_id'); + } +} diff --git a/app/Services/Care/CareFeatures.php b/app/Services/Care/CareFeatures.php index dd1894e..7bd959b 100644 --- a/app/Services/Care/CareFeatures.php +++ b/app/Services/Care/CareFeatures.php @@ -3,6 +3,7 @@ namespace App\Services\Care; use App\Models\AssessmentTemplate; +use App\Models\ClinicalPathway; use App\Models\Organization; use Database\Seeders\AssessmentTemplateSeeder; use Database\Seeders\ClinicalPathwaySeeder; @@ -72,16 +73,24 @@ class CareFeatures return; } - if (AssessmentTemplate::query()->whereNull('organization_id')->exists()) { - return; + $nursingCodes = ['news2', 'braden', 'morse', 'pain_nrs']; + $haveNursing = AssessmentTemplate::query() + ->whereNull('organization_id') + ->whereIn('code', $nursingCodes) + ->where('is_current', true) + ->count(); + + $empty = ! AssessmentTemplate::query()->whereNull('organization_id')->exists(); + + if ($empty || $haveNursing < count($nursingCodes)) { + Artisan::call('db:seed', [ + '--class' => AssessmentTemplateSeeder::class, + '--force' => true, + ]); } - Artisan::call('db:seed', [ - '--class' => AssessmentTemplateSeeder::class, - '--force' => true, - ]); - - if (Schema::hasTable('care_clinical_pathways')) { + if (Schema::hasTable('care_clinical_pathways') + && ! ClinicalPathway::query()->whereNull('organization_id')->exists()) { Artisan::call('db:seed', [ '--class' => ClinicalPathwaySeeder::class, '--force' => true, diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index a975d80..d64363b 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -590,6 +590,9 @@ class CarePermissions $abilities[] = 'nursing.notes.manage'; $abilities[] = 'nursing.handover.view'; $abilities[] = 'nursing.handover.manage'; + $abilities[] = 'nursing.assessments.view'; + $abilities[] = 'nursing.assessments.manage'; + $abilities[] = 'nursing.performance.view'; } if (in_array('analytics.department.view', $abilities, true)) { @@ -597,6 +600,8 @@ class CarePermissions $abilities[] = 'mar.view'; $abilities[] = 'nursing.notes.view'; $abilities[] = 'nursing.handover.view'; + $abilities[] = 'nursing.assessments.view'; + $abilities[] = 'nursing.performance.view'; } return array_values(array_unique($abilities)); diff --git a/app/Services/Care/NursePerformanceService.php b/app/Services/Care/NursePerformanceService.php new file mode 100644 index 0000000..983c864 --- /dev/null +++ b/app/Services/Care/NursePerformanceService.php @@ -0,0 +1,195 @@ + + */ + public function unitReport( + CareUnit $unit, + string $ownerRef, + ?CarbonInterface $from = null, + ?CarbonInterface $to = null, + ): array { + $from = Carbon::parse($from ?? now()->subDays(7))->startOfDay(); + $to = Carbon::parse($to ?? now())->endOfDay(); + + $visitIds = Visit::owned($ownerRef) + ->where('care_unit_id', $unit->id) + ->pluck('id'); + + $mar = MarAdministration::owned($ownerRef) + ->where('care_unit_id', $unit->id) + ->whereBetween('recorded_at', [$from, $to]) + ->get(); + + $marGiven = $mar->where('status', MarAdministration::STATUS_GIVEN)->count(); + $marHeld = $mar->where('status', MarAdministration::STATUS_HELD)->count(); + $marRefused = $mar->where('status', MarAdministration::STATUS_REFUSED)->count(); + $marMissed = $mar->where('status', MarAdministration::STATUS_MISSED)->count(); + $marTotal = $mar->count(); + $marOnTime = $mar->filter(function (MarAdministration $a) { + if ($a->status !== MarAdministration::STATUS_GIVEN || ! $a->scheduled_for) { + return false; + } + + return $a->recorded_at->between( + $a->scheduled_for->copy()->subMinutes(30), + $a->scheduled_for->copy()->addMinutes(60), + ); + })->count(); + + $notes = NursingNote::owned($ownerRef) + ->where('care_unit_id', $unit->id) + ->whereBetween('recorded_at', [$from, $to]) + ->get(); + + $vitals = VisitVital::owned($ownerRef) + ->where('care_unit_id', $unit->id) + ->whereBetween('recorded_at', [$from, $to]) + ->count(); + + $nursingCodes = NursingAssessmentService::NURSING_PACK_CODES; + $assessments = Assessment::owned($ownerRef) + ->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds->all()) + ->whereBetween('created_at', [$from, $to]) + ->whereHas('template', fn ($q) => $q->whereIn('code', $nursingCodes)) + ->with('template') + ->get(); + + $assessmentsCompleted = $assessments->where('status', Assessment::STATUS_COMPLETED)->count(); + $assessmentsDraft = $assessments->where('status', Assessment::STATUS_DRAFT)->count(); + + $handovers = WardHandover::owned($ownerRef) + ->where('care_unit_id', $unit->id) + ->whereBetween('created_at', [$from, $to]) + ->get(); + + $assignedStaff = StaffAssignment::owned($ownerRef) + ->where('care_unit_id', $unit->id) + ->where('status', 'active') + ->effectiveOn($to) + ->with('member') + ->get(); + + $byActor = $this->byActor( + $mar, + $notes, + $assessments->where('status', Assessment::STATUS_COMPLETED), + $handovers->where('status', WardHandover::STATUS_COMPLETED), + ); + + return [ + 'from' => $from, + 'to' => $to, + 'placed_patients' => Visit::owned($ownerRef) + ->where('care_unit_id', $unit->id) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->count(), + 'mar' => [ + 'total' => $marTotal, + 'given' => $marGiven, + 'held' => $marHeld, + 'refused' => $marRefused, + 'missed' => $marMissed, + 'on_time_given' => $marOnTime, + 'on_time_rate' => $marGiven > 0 ? round(($marOnTime / $marGiven) * 100, 1) : null, + 'compliance_rate' => $marTotal > 0 + ? round((($marGiven) / $marTotal) * 100, 1) + : null, + ], + 'notes' => [ + 'total' => $notes->count(), + 'by_type' => $notes->groupBy('note_type')->map->count()->all(), + ], + 'vitals' => [ + 'total' => $vitals, + ], + 'assessments' => [ + 'started' => $assessments->count(), + 'completed' => $assessmentsCompleted, + 'draft' => $assessmentsDraft, + 'completion_rate' => $assessments->count() > 0 + ? round(($assessmentsCompleted / $assessments->count()) * 100, 1) + : null, + 'by_template' => $assessments + ->groupBy(fn (Assessment $a) => $a->template?->code ?? 'unknown') + ->map(fn (Collection $group, $code) => [ + 'code' => $code, + 'name' => $group->first()->template?->name ?? $code, + 'completed' => $group->where('status', Assessment::STATUS_COMPLETED)->count(), + 'total' => $group->count(), + ]) + ->values() + ->all(), + ], + 'handovers' => [ + 'total' => $handovers->count(), + 'completed' => $handovers->where('status', WardHandover::STATUS_COMPLETED)->count(), + ], + 'assigned_staff' => $assignedStaff->count(), + 'by_actor' => $byActor, + ]; + } + + /** + * @param Collection $mar + * @param Collection $notes + * @param Collection $assessments + * @param Collection $handovers + * @return list> + */ + protected function byActor( + Collection $mar, + Collection $notes, + Collection $assessments, + Collection $handovers, + ): array { + $refs = collect() + ->merge($mar->pluck('administered_by')) + ->merge($notes->pluck('recorded_by')) + ->merge($assessments->pluck('completed_by')) + ->merge($handovers->pluck('handed_over_by')) + ->filter() + ->unique() + ->values(); + + $emails = User::query()->whereIn('public_id', $refs)->pluck('email', 'public_id'); + $names = User::query()->whereIn('public_id', $refs)->pluck('name', 'public_id'); + + $rows = []; + foreach ($refs as $ref) { + $rows[] = [ + 'actor_ref' => $ref, + 'label' => $names[$ref] ?? $emails[$ref] ?? $ref, + 'mar_given' => $mar->where('administered_by', $ref)->where('status', MarAdministration::STATUS_GIVEN)->count(), + 'mar_total' => $mar->where('administered_by', $ref)->count(), + 'notes' => $notes->where('recorded_by', $ref)->count(), + 'assessments_completed' => $assessments->where('completed_by', $ref)->count(), + 'handovers' => $handovers->where('handed_over_by', $ref)->count(), + ]; + } + + usort($rows, fn ($a, $b) => ($b['mar_total'] + $b['notes'] + $b['assessments_completed']) + <=> ($a['mar_total'] + $a['notes'] + $a['assessments_completed'])); + + return $rows; + } +} diff --git a/app/Services/Care/NursingAssessmentService.php b/app/Services/Care/NursingAssessmentService.php new file mode 100644 index 0000000..ed28e20 --- /dev/null +++ b/app/Services/Care/NursingAssessmentService.php @@ -0,0 +1,109 @@ + */ + public const NURSING_PACK_CODES = ['news2', 'braden', 'morse', 'pain_nrs']; + + /** + * @return Collection + */ + public function nursingTemplates(): Collection + { + return AssessmentTemplate::query() + ->whereNull('organization_id') + ->where('is_current', true) + ->where('is_active', true) + ->whereIn('code', self::NURSING_PACK_CODES) + ->orderBy('name') + ->get(); + } + + /** + * Board rows for placed patients: latest nursing assessments + latest vitals. + * + * @return list> + */ + public function unitBoard(CareUnit $unit, string $ownerRef): array + { + $templates = $this->nursingTemplates(); + $visits = Visit::owned($ownerRef) + ->where('care_unit_id', $unit->id) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->with(['patient', 'bed']) + ->orderBy('placed_at') + ->get(); + + $rows = []; + foreach ($visits as $visit) { + $latestByCode = Assessment::owned($ownerRef) + ->where('visit_id', $visit->id) + ->whereIn('status', [Assessment::STATUS_DRAFT, Assessment::STATUS_COMPLETED]) + ->whereHas('template', fn ($q) => $q->whereIn('code', $templates->pluck('code')->all())) + ->with(['template', 'score']) + ->orderByDesc('id') + ->get() + ->unique(fn (Assessment $a) => $a->template?->code) + ->keyBy(fn (Assessment $a) => $a->template?->code); + + $latestVitals = VisitVital::owned($ownerRef) + ->where('visit_id', $visit->id) + ->orderByDesc('recorded_at') + ->first(); + + $rows[] = [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'bed' => $visit->bed, + 'assessments' => $latestByCode, + 'vitals' => $latestVitals, + ]; + } + + return $rows; + } + + public function recordVisitVitals( + Visit $visit, + string $ownerRef, + ?string $actorRef, + array $data, + ): VisitVital { + $vital = VisitVital::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $visit->organization_id, + 'visit_id' => $visit->id, + 'patient_id' => $visit->patient_id, + 'care_unit_id' => $visit->care_unit_id, + 'bp_systolic' => $data['bp_systolic'] ?? null, + 'bp_diastolic' => $data['bp_diastolic'] ?? null, + 'pulse' => $data['pulse'] ?? null, + 'temperature' => $data['temperature'] ?? null, + 'spo2' => $data['spo2'] ?? null, + 'respiratory_rate' => $data['respiratory_rate'] ?? null, + 'notes' => $data['notes'] ?? null, + 'recorded_by' => $actorRef, + 'recorded_at' => now(), + ]); + + AuditLogger::record( + $ownerRef, + 'visit_vitals.recorded', + $visit->organization_id, + $actorRef, + VisitVital::class, + $vital->id, + ); + + return $vital; + } +} diff --git a/app/Services/Care/ScoringService.php b/app/Services/Care/ScoringService.php index 5a2871c..e6512dd 100644 --- a/app/Services/Care/ScoringService.php +++ b/app/Services/Care/ScoringService.php @@ -80,16 +80,48 @@ class ScoringService } if (str_starts_with($strategy, 'custom:')) { - return $this->customScore($assessment, substr($strategy, 7)); + $result = $this->customScore($assessment, substr($strategy, 7)); + } else { + $result = match ($strategy) { + 'sum_items' => $this->sumItems($assessment), + 'single_value' => $this->singleValue($assessment), + default => throw ValidationException::withMessages([ + 'scoring' => "Unknown scoring strategy [{$strategy}].", + ]), + }; } - return match ($strategy) { - 'sum_items' => $this->sumItems($assessment), - 'single_value' => $this->singleValue($assessment), - default => throw ValidationException::withMessages([ - 'scoring' => "Unknown scoring strategy [{$strategy}].", - ]), - }; + $result['severity_label'] = $result['severity_label'] + ?? $this->severityFromBands($assessment->template?->meta, $result['total'] ?? null); + + return $result; + } + + /** + * @param array|null $meta + */ + protected function severityFromBands(?array $meta, mixed $total): ?string + { + if ($total === null || ! is_numeric($total)) { + return null; + } + + $bands = $meta['severity_bands'] ?? null; + if (! is_array($bands) || $bands === []) { + return null; + } + + $score = (float) $total; + foreach ($bands as $band) { + if (! is_array($band) || ! isset($band['max'], $band['label'])) { + continue; + } + if ($score <= (float) $band['max']) { + return (string) $band['label']; + } + } + + return null; } /** diff --git a/database/data/assessments/braden.v1.json b/database/data/assessments/braden.v1.json new file mode 100644 index 0000000..c3c7019 --- /dev/null +++ b/database/data/assessments/braden.v1.json @@ -0,0 +1,136 @@ +{ + "template": { + "code": "braden", + "name": "Braden Scale (pressure injury risk)", + "category": "screening", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "sum_items", + "description": "Braden Scale for predicting pressure sore risk. Lower scores = higher risk (6–23).", + "meta": { + "capture_roles": ["nurse", "ed_nurse", "theatre_nurse", "labour_ward_nurse", "fertility_nurse", "dialysis_nurse", "midwife", "doctor", "general_physician"], + "specialty": "nursing", + "pack_family": "nursing", + "estimated_minutes": 5, + "max_total": 23, + "severity_bands": [ + { "max": 9, "label": "Very high risk" }, + { "max": 12, "label": "High risk" }, + { "max": 14, "label": "Moderate risk" }, + { "max": 18, "label": "Mild risk" }, + { "max": 23, "label": "No apparent risk" } + ], + "attribution": "Braden Scale — verify licensing for commercial redistribution of branded materials." + } + }, + "questions": [ + { + "code": "sensory", + "section": "Risk factors", + "label": "Sensory perception", + "answer_type": "score_item", + "is_required": true, + "sort_order": 10, + "score_key": "braden", + "options": { + "max": 4, + "choices": [ + { "code": "1", "label": "Completely limited", "score": 1 }, + { "code": "2", "label": "Very limited", "score": 2 }, + { "code": "3", "label": "Slightly limited", "score": 3 }, + { "code": "4", "label": "No impairment", "score": 4 } + ] + } + }, + { + "code": "moisture", + "section": "Risk factors", + "label": "Moisture", + "answer_type": "score_item", + "is_required": true, + "sort_order": 20, + "score_key": "braden", + "options": { + "max": 4, + "choices": [ + { "code": "1", "label": "Constantly moist", "score": 1 }, + { "code": "2", "label": "Very moist", "score": 2 }, + { "code": "3", "label": "Occasionally moist", "score": 3 }, + { "code": "4", "label": "Rarely moist", "score": 4 } + ] + } + }, + { + "code": "activity", + "section": "Risk factors", + "label": "Activity", + "answer_type": "score_item", + "is_required": true, + "sort_order": 30, + "score_key": "braden", + "options": { + "max": 4, + "choices": [ + { "code": "1", "label": "Bedfast", "score": 1 }, + { "code": "2", "label": "Chairfast", "score": 2 }, + { "code": "3", "label": "Walks occasionally", "score": 3 }, + { "code": "4", "label": "Walks frequently", "score": 4 } + ] + } + }, + { + "code": "mobility", + "section": "Risk factors", + "label": "Mobility", + "answer_type": "score_item", + "is_required": true, + "sort_order": 40, + "score_key": "braden", + "options": { + "max": 4, + "choices": [ + { "code": "1", "label": "Completely immobile", "score": 1 }, + { "code": "2", "label": "Very limited", "score": 2 }, + { "code": "3", "label": "Slightly limited", "score": 3 }, + { "code": "4", "label": "No limitation", "score": 4 } + ] + } + }, + { + "code": "nutrition", + "section": "Risk factors", + "label": "Nutrition", + "answer_type": "score_item", + "is_required": true, + "sort_order": 50, + "score_key": "braden", + "options": { + "max": 4, + "choices": [ + { "code": "1", "label": "Very poor", "score": 1 }, + { "code": "2", "label": "Probably inadequate", "score": 2 }, + { "code": "3", "label": "Adequate", "score": 3 }, + { "code": "4", "label": "Excellent", "score": 4 } + ] + } + }, + { + "code": "friction", + "section": "Risk factors", + "label": "Friction and shear", + "answer_type": "score_item", + "is_required": true, + "sort_order": 60, + "score_key": "braden", + "options": { + "max": 3, + "choices": [ + { "code": "1", "label": "Problem", "score": 1 }, + { "code": "2", "label": "Potential problem", "score": 2 }, + { "code": "3", "label": "No apparent problem", "score": 3 } + ] + } + } + ] +} diff --git a/database/data/assessments/morse.v1.json b/database/data/assessments/morse.v1.json new file mode 100644 index 0000000..6ab4da6 --- /dev/null +++ b/database/data/assessments/morse.v1.json @@ -0,0 +1,125 @@ +{ + "template": { + "code": "morse", + "name": "Morse Fall Scale", + "category": "screening", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "sum_items", + "description": "Morse Fall Scale for inpatient fall risk. Higher scores = higher risk (0–125).", + "meta": { + "capture_roles": ["nurse", "ed_nurse", "theatre_nurse", "labour_ward_nurse", "fertility_nurse", "dialysis_nurse", "midwife", "doctor", "general_physician"], + "specialty": "nursing", + "pack_family": "nursing", + "estimated_minutes": 3, + "max_total": 125, + "severity_bands": [ + { "max": 24, "label": "Low risk" }, + { "max": 44, "label": "Medium risk" }, + { "max": 125, "label": "High risk" } + ], + "attribution": "Morse Fall Scale — verify licensing for commercial redistribution of branded materials." + } + }, + "questions": [ + { + "code": "history_falling", + "section": "Fall risk", + "label": "History of falling", + "answer_type": "score_item", + "is_required": true, + "sort_order": 10, + "score_key": "morse", + "options": { + "max": 25, + "choices": [ + { "code": "0", "label": "No", "score": 0 }, + { "code": "25", "label": "Yes", "score": 25 } + ] + } + }, + { + "code": "secondary_diagnosis", + "section": "Fall risk", + "label": "Secondary diagnosis", + "answer_type": "score_item", + "is_required": true, + "sort_order": 20, + "score_key": "morse", + "options": { + "max": 15, + "choices": [ + { "code": "0", "label": "No", "score": 0 }, + { "code": "15", "label": "Yes", "score": 15 } + ] + } + }, + { + "code": "ambulatory_aid", + "section": "Fall risk", + "label": "Ambulatory aid", + "answer_type": "score_item", + "is_required": true, + "sort_order": 30, + "score_key": "morse", + "options": { + "max": 30, + "choices": [ + { "code": "0", "label": "None / bed rest / nurse assist", "score": 0 }, + { "code": "15", "label": "Crutches / cane / walker", "score": 15 }, + { "code": "30", "label": "Furniture", "score": 30 } + ] + } + }, + { + "code": "iv_therapy", + "section": "Fall risk", + "label": "IV / heparin lock", + "answer_type": "score_item", + "is_required": true, + "sort_order": 40, + "score_key": "morse", + "options": { + "max": 20, + "choices": [ + { "code": "0", "label": "No", "score": 0 }, + { "code": "20", "label": "Yes", "score": 20 } + ] + } + }, + { + "code": "gait", + "section": "Fall risk", + "label": "Gait / transferring", + "answer_type": "score_item", + "is_required": true, + "sort_order": 50, + "score_key": "morse", + "options": { + "max": 20, + "choices": [ + { "code": "0", "label": "Normal / bedrest / immobile", "score": 0 }, + { "code": "10", "label": "Weak", "score": 10 }, + { "code": "20", "label": "Impaired", "score": 20 } + ] + } + }, + { + "code": "mental_status", + "section": "Fall risk", + "label": "Mental status", + "answer_type": "score_item", + "is_required": true, + "sort_order": 60, + "score_key": "morse", + "options": { + "max": 15, + "choices": [ + { "code": "0", "label": "Oriented to own ability", "score": 0 }, + { "code": "15", "label": "Forgets limitations", "score": 15 } + ] + } + } + ] +} diff --git a/database/data/assessments/news2.v1.json b/database/data/assessments/news2.v1.json new file mode 100644 index 0000000..0c5b111 --- /dev/null +++ b/database/data/assessments/news2.v1.json @@ -0,0 +1,152 @@ +{ + "template": { + "code": "news2", + "name": "NEWS2 (Nursing early warning)", + "category": "screening", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "sum_items", + "description": "National Early Warning Score 2 style physiological track-and-trigger for ward nursing. Scores 0–20.", + "meta": { + "capture_roles": ["nurse", "ed_nurse", "theatre_nurse", "labour_ward_nurse", "fertility_nurse", "dialysis_nurse", "midwife", "doctor", "general_physician", "emergency_physician", "obstetrician", "gynecologist", "obgyn"], + "specialty": "nursing", + "pack_family": "nursing", + "estimated_minutes": 3, + "max_total": 20, + "severity_bands": [ + { "max": 4, "label": "Low" }, + { "max": 6, "label": "Medium" }, + { "max": 20, "label": "High" } + ], + "attribution": "NEWS2-inspired clinical scoring for operational use. Official NEWS2 materials remain Royal College of Physicians IP — verify licensing for branded redistribution." + } + }, + "questions": [ + { + "code": "respiration", + "section": "Physiology", + "label": "Respiration rate (breaths/min)", + "answer_type": "score_item", + "is_required": true, + "sort_order": 10, + "score_key": "news2", + "options": { + "max": 3, + "choices": [ + { "code": "0", "label": "12–20", "score": 0 }, + { "code": "1", "label": "9–11", "score": 1 }, + { "code": "2", "label": "21–24", "score": 2 }, + { "code": "3a", "label": "≤8", "score": 3 }, + { "code": "3b", "label": "≥25", "score": 3 } + ] + } + }, + { + "code": "spo2", + "section": "Physiology", + "label": "SpO₂ scale 1 (%)", + "answer_type": "score_item", + "is_required": true, + "sort_order": 20, + "score_key": "news2", + "options": { + "max": 3, + "choices": [ + { "code": "0", "label": "≥96", "score": 0 }, + { "code": "1", "label": "94–95", "score": 1 }, + { "code": "2", "label": "92–93", "score": 2 }, + { "code": "3", "label": "≤91", "score": 3 } + ] + } + }, + { + "code": "air_oxygen", + "section": "Physiology", + "label": "Air or oxygen", + "answer_type": "score_item", + "is_required": true, + "sort_order": 30, + "score_key": "news2", + "options": { + "max": 2, + "choices": [ + { "code": "0", "label": "Air", "score": 0 }, + { "code": "2", "label": "Oxygen", "score": 2 } + ] + } + }, + { + "code": "systolic_bp", + "section": "Physiology", + "label": "Systolic BP (mmHg)", + "answer_type": "score_item", + "is_required": true, + "sort_order": 40, + "score_key": "news2", + "options": { + "max": 3, + "choices": [ + { "code": "0", "label": "111–219", "score": 0 }, + { "code": "1", "label": "101–110", "score": 1 }, + { "code": "2", "label": "91–100", "score": 2 }, + { "code": "3a", "label": "≤90", "score": 3 }, + { "code": "3b", "label": "≥220", "score": 3 } + ] + } + }, + { + "code": "pulse", + "section": "Physiology", + "label": "Pulse (bpm)", + "answer_type": "score_item", + "is_required": true, + "sort_order": 50, + "score_key": "news2", + "options": { + "max": 3, + "choices": [ + { "code": "0", "label": "51–90", "score": 0 }, + { "code": "1", "label": "41–50 or 91–110", "score": 1 }, + { "code": "2", "label": "111–130", "score": 2 }, + { "code": "3a", "label": "≤40", "score": 3 }, + { "code": "3b", "label": "≥131", "score": 3 } + ] + } + }, + { + "code": "consciousness", + "section": "Physiology", + "label": "Consciousness", + "answer_type": "score_item", + "is_required": true, + "sort_order": 60, + "score_key": "news2", + "options": { + "max": 3, + "choices": [ + { "code": "0", "label": "Alert", "score": 0 }, + { "code": "3", "label": "CVPU", "score": 3 } + ] + } + }, + { + "code": "temperature", + "section": "Physiology", + "label": "Temperature (°C)", + "answer_type": "score_item", + "is_required": true, + "sort_order": 70, + "score_key": "news2", + "options": { + "max": 3, + "choices": [ + { "code": "0", "label": "36.1–38.0", "score": 0 }, + { "code": "1a", "label": "35.1–36.0 or 38.1–39.0", "score": 1 }, + { "code": "2", "label": "≥39.1", "score": 2 }, + { "code": "3", "label": "≤35.0", "score": 3 } + ] + } + } + ] +} diff --git a/database/data/assessments/pain_nrs.v1.json b/database/data/assessments/pain_nrs.v1.json new file mode 100644 index 0000000..8cca041 --- /dev/null +++ b/database/data/assessments/pain_nrs.v1.json @@ -0,0 +1,49 @@ +{ + "template": { + "code": "pain_nrs", + "name": "Pain NRS (0–10)", + "category": "screening", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "single_value", + "description": "Numeric rating scale for pain intensity at rest / with activity.", + "meta": { + "capture_roles": ["nurse", "ed_nurse", "theatre_nurse", "labour_ward_nurse", "fertility_nurse", "dialysis_nurse", "midwife", "doctor", "general_physician", "emergency_physician"], + "specialty": "nursing", + "pack_family": "nursing", + "estimated_minutes": 1, + "max_total": 10, + "severity_bands": [ + { "max": 3, "label": "Mild" }, + { "max": 6, "label": "Moderate" }, + { "max": 10, "label": "Severe" } + ] + } + }, + "questions": [ + { + "code": "pain_score", + "section": "Pain", + "label": "Pain intensity (0 = none, 10 = worst imaginable)", + "answer_type": "scale", + "is_required": true, + "sort_order": 10, + "score_key": "pain", + "options": { + "min": 0, + "max": 10, + "step": 1 + } + }, + { + "code": "pain_location", + "section": "Pain", + "label": "Location / notes", + "answer_type": "text", + "is_required": false, + "sort_order": 20, + "options": {} + } + ] +} diff --git a/database/migrations/2026_07_20_160000_create_care_visit_vitals_table.php b/database/migrations/2026_07_20_160000_create_care_visit_vitals_table.php new file mode 100644 index 0000000..e3138e8 --- /dev/null +++ b/database/migrations/2026_07_20_160000_create_care_visit_vitals_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('visit_id')->constrained('care_visits')->cascadeOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + $table->foreignId('care_unit_id')->nullable()->constrained('care_care_units')->nullOnDelete(); + $table->unsignedSmallInteger('bp_systolic')->nullable(); + $table->unsignedSmallInteger('bp_diastolic')->nullable(); + $table->unsignedSmallInteger('pulse')->nullable(); + $table->decimal('temperature', 4, 1)->nullable(); + $table->unsignedSmallInteger('spo2')->nullable(); + $table->unsignedSmallInteger('respiratory_rate')->nullable(); + $table->string('recorded_by')->nullable(); + $table->timestamp('recorded_at'); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['visit_id', 'recorded_at']); + $table->index(['care_unit_id', 'recorded_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_visit_vitals'); + } +}; diff --git a/resources/views/care/admin/care-units/show.blade.php b/resources/views/care/admin/care-units/show.blade.php index 7c03c0c..01b6060 100644 --- a/resources/views/care/admin/care-units/show.blade.php +++ b/resources/views/care/admin/care-units/show.blade.php @@ -27,16 +27,24 @@ $canMar = $permissions->can($member, 'mar.view'); $canNotes = $permissions->can($member, 'nursing.notes.view'); $canHandover = $permissions->can($member, 'nursing.handover.view'); + $canAssess = $permissions->can($member, 'nursing.assessments.view'); + $canPerf = $permissions->can($member, 'nursing.performance.view'); @endphp @if ($canMar) MAR board @endif + @if ($canAssess) + Assessments + @endif @if ($canNotes) Nursing notes @endif @if ($canHandover) Handovers @endif + @if ($canPerf) + Performance + @endif @if ($canManageUnit) Edit unit Assign staff diff --git a/resources/views/care/nursing/assessments-board.blade.php b/resources/views/care/nursing/assessments-board.blade.php new file mode 100644 index 0000000..df210e9 --- /dev/null +++ b/resources/views/care/nursing/assessments-board.blade.php @@ -0,0 +1,106 @@ + +
+
+
+

+ {{ $unit->name }} + / + Nursing assessments +

+

Ward assessment board

+

NEWS2, Braden, Morse, pain, and visit vitals for placed patients.

+
+ Performance +
+ + @unless ($engineEnabled) +
+ Assessments engine is disabled for this organization. Enable it under Settings → Rollout. +
+ @endunless + +
+ + + + + + @foreach ($templates as $template) + + @endforeach + + + + + @forelse ($rows as $row) + @php + $visit = $row['visit']; + $vitals = $row['vitals']; + @endphp + + + + @foreach ($templates as $template) + @php $assessment = $row['assessments']->get($template->code); @endphp + + @endforeach + + + @if ($canVitals) + + + + @endif + @empty + + @endforelse + +
PatientLatest vitals{{ $template->code }}
+ {{ $row['patient']?->fullName() }} +
{{ $row['bed']?->label ?? 'No bed' }}
+
+ @if ($vitals) + BP {{ $vitals->bp_systolic }}/{{ $vitals->bp_diastolic }} + · P {{ $vitals->pulse }} + · SpO₂ {{ $vitals->spo2 }} +
{{ $vitals->recorded_at?->format('d M H:i') }}
+ @else + — + @endif +
+ @if ($assessment) + + {{ $assessment->status === 'completed' + ? ($assessment->score?->severity_label ?? ($assessment->score?->total_score !== null ? $assessment->score->total_score : 'Done')) + : 'Draft' }} + + @else + + @endif + + @if ($canCapture && $engineEnabled) +
+ @foreach ($templates as $template) +
+ @csrf + + +
+ @endforeach +
+ @endif +
+
+ @csrf + + + + + + + +
+
No patients placed on this unit.
+
+
+
diff --git a/resources/views/care/nursing/performance.blade.php b/resources/views/care/nursing/performance.blade.php new file mode 100644 index 0000000..48f5008 --- /dev/null +++ b/resources/views/care/nursing/performance.blade.php @@ -0,0 +1,97 @@ +@php $r = $report; @endphp + + +
+
+
+

+ {{ $unit->name }} + / + Nurse performance +

+

Unit nursing KPIs

+

MAR compliance, vitals, assessments, notes, and handovers.

+
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+

MAR given

+

{{ $r['mar']['given'] }}

+

of {{ $r['mar']['total'] }} recorded · compliance {{ $r['mar']['compliance_rate'] ?? '—' }}%

+
+
+

On-time MAR

+

{{ $r['mar']['on_time_rate'] ?? '—' }}%

+

{{ $r['mar']['on_time_given'] }} given within window

+
+
+

Assessments done

+

{{ $r['assessments']['completed'] }}

+

{{ $r['assessments']['completion_rate'] ?? '—' }}% of {{ $r['assessments']['started'] }} started

+
+
+

Notes / vitals

+

{{ $r['notes']['total'] }} / {{ $r['vitals']['total'] }}

+

{{ $r['handovers']['completed'] }} handovers · {{ $r['placed_patients'] }} placed now

+
+
+ +
+
+

Nursing packs

+
    + @forelse ($r['assessments']['by_template'] as $row) +
  • + {{ $row['name'] }} + {{ $row['completed'] }}/{{ $row['total'] }} +
  • + @empty +
  • No nursing assessments in range.
  • + @endforelse +
+
+ +
+

By clinician

+
+ + + + + + + + + + + + @forelse ($r['by_actor'] as $actor) + + + + + + + + @empty + + @endforelse + +
StaffMARNotesAssessHandover
{{ $actor['label'] }}{{ $actor['mar_given'] }}/{{ $actor['mar_total'] }}{{ $actor['notes'] }}{{ $actor['assessments_completed'] }}{{ $actor['handovers'] }}
No activity attributed yet.
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index 0fb3913..a198625 100644 --- a/routes/web.php +++ b/routes/web.php @@ -18,6 +18,8 @@ use App\Http\Controllers\Care\StaffAssignmentController; use App\Http\Controllers\Care\VisitPlacementController; use App\Http\Controllers\Care\MarController; use App\Http\Controllers\Care\NursingDocumentationController; +use App\Http\Controllers\Care\NursingAssessmentController; +use App\Http\Controllers\Care\NursePerformanceController; use App\Http\Controllers\Care\DeviceController; use App\Http\Controllers\Care\DisplayPublicController; use App\Http\Controllers\Care\DisplayScreenController; @@ -447,6 +449,11 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/care-units/{careUnit}/handovers/{wardHandover}', [NursingDocumentationController::class, 'showHandover'])->name('care.care-units.handovers.show'); Route::post('/care-units/{careUnit}/handovers/{wardHandover}/complete', [NursingDocumentationController::class, 'completeHandover'])->name('care.care-units.handovers.complete'); + Route::get('/care-units/{careUnit}/assessments', [NursingAssessmentController::class, 'unitBoard'])->name('care.care-units.assessments'); + Route::post('/care-units/{careUnit}/visits/{visit}/assessments', [NursingAssessmentController::class, 'start'])->name('care.care-units.assessments.start'); + Route::post('/care-units/{careUnit}/visits/{visit}/vitals', [NursingAssessmentController::class, 'storeVitals'])->name('care.care-units.vitals.store'); + Route::get('/care-units/{careUnit}/performance', [NursePerformanceController::class, 'show'])->name('care.care-units.performance'); + Route::get('/staff-assignments', [StaffAssignmentController::class, 'index'])->name('care.staff-assignments.index'); Route::get('/staff-assignments/create', [StaffAssignmentController::class, 'create'])->name('care.staff-assignments.create'); Route::post('/staff-assignments', [StaffAssignmentController::class, 'store'])->name('care.staff-assignments.store'); diff --git a/tests/Feature/CareNursingAssessmentsAndPerformanceTest.php b/tests/Feature/CareNursingAssessmentsAndPerformanceTest.php new file mode 100644 index 0000000..fb18b84 --- /dev/null +++ b/tests/Feature/CareNursingAssessmentsAndPerformanceTest.php @@ -0,0 +1,217 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->seed(AssessmentTemplateSeeder::class); + + $this->owner = User::create([ + 'public_id' => 'nursing-de-owner', + 'name' => 'Owner', + 'email' => 'nursing-de@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Nursing DE Hospital', + 'slug' => 'nursing-de-hospital', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'hospital', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'rollout' => ['assessments_engine' => true], + ], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'hospital_admin', + ]); + + $branch = Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $department = Department::create([ + 'owner_ref' => $this->owner->public_id, + 'branch_id' => $branch->id, + 'name' => 'Medicine', + 'type' => 'general', + 'is_active' => true, + ]); + + $this->unit = CareUnit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'department_id' => $department->id, + 'name' => 'Medical Ward', + 'kind' => 'inpatient', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $branch->id, + 'patient_number' => 'P-NDE-1', + 'first_name' => 'Ama', + 'last_name' => 'Mensah', + 'gender' => 'female', + ]); + + $this->visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + ]); + + app(VisitPlacementService::class)->place( + $this->visit, + $this->unit, + null, + $this->owner->public_id, + $this->owner->public_id, + ); + } + + public function test_nursing_packs_are_seeded(): void + { + foreach (['news2', 'braden', 'morse', 'pain_nrs'] as $code) { + $this->assertNotNull( + AssessmentTemplate::currentSystemByCode($code), + "Missing nursing pack {$code}", + ); + } + } + + public function test_start_nursing_assessment_from_unit_board(): void + { + $this->actingAs($this->owner) + ->post(route('care.care-units.assessments.start', [$this->unit, $this->visit]), [ + 'template_code' => 'news2', + ]) + ->assertRedirect(); + + $assessment = Assessment::query()->where('visit_id', $this->visit->id)->first(); + $this->assertNotNull($assessment); + $this->assertSame(Assessment::STATUS_DRAFT, $assessment->status); + $this->assertSame('news2', $assessment->template->code); + + $this->actingAs($this->owner) + ->get(route('care.care-units.assessments', $this->unit)) + ->assertOk() + ->assertSee('Ama') + ->assertSee('NEWS2'); + } + + public function test_record_visit_vitals_and_performance_dashboard(): void + { + $this->actingAs($this->owner) + ->post(route('care.care-units.vitals.store', [$this->unit, $this->visit]), [ + 'bp_systolic' => 120, + 'bp_diastolic' => 80, + 'pulse' => 72, + 'spo2' => 98, + 'respiratory_rate' => 16, + 'temperature' => 36.8, + ]) + ->assertRedirect(route('care.care-units.assessments', $this->unit)); + + $this->assertDatabaseHas('care_visit_vitals', [ + 'visit_id' => $this->visit->id, + 'pulse' => 72, + 'care_unit_id' => $this->unit->id, + ]); + + $member = Member::query()->where('user_ref', $this->owner->public_id)->first(); + $assessment = app(AssessmentService::class)->start( + $this->patient, + 'pain_nrs', + $this->owner->public_id, + $member, + ['visit' => $this->visit->fresh(), 'actor' => $this->owner->public_id], + ); + + $this->actingAs($this->owner) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => ['pain_score' => 4], + ]) + ->assertRedirect(); + + $this->actingAs($this->owner) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $assessment->refresh()->load('score'); + $this->assertSame(Assessment::STATUS_COMPLETED, $assessment->status); + $this->assertNotNull($assessment->score); + $this->assertSame('Moderate', $assessment->score->severity_label); + + $this->actingAs($this->owner) + ->get(route('care.care-units.performance', $this->unit)) + ->assertOk() + ->assertSee('Unit nursing KPIs') + ->assertSee('Assessments done'); + } + + public function test_visit_vitals_model_created(): void + { + VisitVital::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'visit_id' => $this->visit->id, + 'patient_id' => $this->patient->id, + 'care_unit_id' => $this->unit->id, + 'pulse' => 80, + 'recorded_at' => now(), + 'recorded_by' => $this->owner->public_id, + ]); + + $this->assertSame(1, VisitVital::query()->where('visit_id', $this->visit->id)->count()); + } +}