Add nursing assessment packs, ward board, and nurse performance KPIs.
Deploy Ladill Care / deploy (push) Successful in 55s

Seeds NEWS2/Braden/Morse/pain packs with visit vitals and a unit dashboard for MAR, assessments, notes, and handover activity.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-20 10:32:52 +00:00
co-authored by Cursor
parent cb6e59e5ed
commit 9eb6c21828
18 changed files with 1511 additions and 16 deletions
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\CareUnit;
use App\Services\Care\CarePermissions;
use App\Services\Care\NursePerformanceService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NursePerformanceController extends Controller
{
use ScopesToAccount;
public function show(Request $request, CareUnit $careUnit, NursePerformanceService $performance): View
{
$this->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'),
]);
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\CareUnit;
use App\Models\Visit;
use App\Services\Care\AssessmentService;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
use App\Services\Care\NursingAssessmentService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NursingAssessmentController extends Controller
{
use ScopesToAccount;
public function unitBoard(Request $request, CareUnit $careUnit, NursingAssessmentService $nursing): View
{
$this->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.');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class VisitVital extends Model
{
use BelongsToOwner;
protected $table = 'care_visit_vitals';
protected $fillable = [
'owner_ref',
'organization_id',
'visit_id',
'patient_id',
'care_unit_id',
'bp_systolic',
'bp_diastolic',
'pulse',
'temperature',
'spo2',
'respiratory_rate',
'recorded_by',
'recorded_at',
'notes',
];
protected function casts(): array
{
return [
'temperature' => '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');
}
}
+17 -8
View File
@@ -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,
+5
View File
@@ -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));
@@ -0,0 +1,195 @@
<?php
namespace App\Services\Care;
use App\Models\Assessment;
use App\Models\CareUnit;
use App\Models\MarAdministration;
use App\Models\NursingNote;
use App\Models\StaffAssignment;
use App\Models\User;
use App\Models\Visit;
use App\Models\VisitVital;
use App\Models\WardHandover;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
class NursePerformanceService
{
/**
* Aggregate nursing KPIs for a care unit over a date range.
*
* @return array<string, mixed>
*/
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<int, MarAdministration> $mar
* @param Collection<int, NursingNote> $notes
* @param Collection<int, Assessment> $assessments
* @param Collection<int, WardHandover> $handovers
* @return list<array<string, mixed>>
*/
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;
}
}
@@ -0,0 +1,109 @@
<?php
namespace App\Services\Care;
use App\Models\Assessment;
use App\Models\AssessmentTemplate;
use App\Models\CareUnit;
use App\Models\Visit;
use App\Models\VisitVital;
use Illuminate\Support\Collection;
class NursingAssessmentService
{
/** @var list<string> */
public const NURSING_PACK_CODES = ['news2', 'braden', 'morse', 'pain_nrs'];
/**
* @return Collection<int, AssessmentTemplate>
*/
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<array<string, mixed>>
*/
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;
}
}
+40 -8
View File
@@ -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<string, mixed>|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;
}
/**
+136
View File
@@ -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 (623).",
"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 }
]
}
}
]
}
+125
View File
@@ -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 (0125).",
"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 }
]
}
}
]
}
+152
View File
@@ -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 020.",
"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": "1220", "score": 0 },
{ "code": "1", "label": "911", "score": 1 },
{ "code": "2", "label": "2124", "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": "9495", "score": 1 },
{ "code": "2", "label": "9293", "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": "111219", "score": 0 },
{ "code": "1", "label": "101110", "score": 1 },
{ "code": "2", "label": "91100", "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": "5190", "score": 0 },
{ "code": "1", "label": "4150 or 91110", "score": 1 },
{ "code": "2", "label": "111130", "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.138.0", "score": 0 },
{ "code": "1a", "label": "35.136.0 or 38.139.0", "score": 1 },
{ "code": "2", "label": "≥39.1", "score": 2 },
{ "code": "3", "label": "≤35.0", "score": 3 }
]
}
}
]
}
@@ -0,0 +1,49 @@
{
"template": {
"code": "pain_nrs",
"name": "Pain NRS (010)",
"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": {}
}
]
}
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Visit-scoped vitals for ward nursing (no consultation required).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('care_visit_vitals', function (Blueprint $table) {
$table->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');
}
};
@@ -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)
<a href="{{ route('care.care-units.mar', $unit) }}" class="btn-primary">MAR board</a>
@endif
@if ($canAssess)
<a href="{{ route('care.care-units.assessments', $unit) }}" class="btn-secondary">Assessments</a>
@endif
@if ($canNotes)
<a href="{{ route('care.care-units.notes', $unit) }}" class="btn-secondary">Nursing notes</a>
@endif
@if ($canHandover)
<a href="{{ route('care.care-units.handovers', $unit) }}" class="btn-secondary">Handovers</a>
@endif
@if ($canPerf)
<a href="{{ route('care.care-units.performance', $unit) }}" class="btn-secondary">Performance</a>
@endif
@if ($canManageUnit)
<a href="{{ route('care.care-units.edit', $unit) }}" class="btn-secondary">Edit unit</a>
<a href="{{ route('care.staff-assignments.create', ['care_unit_id' => $unit->id]) }}" class="btn-secondary">Assign staff</a>
@@ -0,0 +1,106 @@
<x-app-layout title="Assessments · {{ $unit->name }}">
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-sm text-slate-500">
<a href="{{ route('care.care-units.show', $unit) }}" class="text-indigo-600 hover:text-indigo-800">{{ $unit->name }}</a>
<span class="text-slate-300">/</span>
Nursing assessments
</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Ward assessment board</h1>
<p class="mt-1 text-sm text-slate-500">NEWS2, Braden, Morse, pain, and visit vitals for placed patients.</p>
</div>
<a href="{{ route('care.care-units.performance', $unit) }}" class="btn-secondary">Performance</a>
</div>
@unless ($engineEnabled)
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
Assessments engine is disabled for this organization. Enable it under Settings Rollout.
</div>
@endunless
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr>
<th class="px-4 py-3">Patient</th>
<th class="px-4 py-3">Latest vitals</th>
@foreach ($templates as $template)
<th class="px-4 py-3">{{ $template->code }}</th>
@endforeach
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
@forelse ($rows as $row)
@php
$visit = $row['visit'];
$vitals = $row['vitals'];
@endphp
<tr>
<td class="px-4 py-3 font-medium">
{{ $row['patient']?->fullName() }}
<div class="text-xs text-slate-400">{{ $row['bed']?->label ?? 'No bed' }}</div>
</td>
<td class="px-4 py-3 text-xs text-slate-600">
@if ($vitals)
BP {{ $vitals->bp_systolic }}/{{ $vitals->bp_diastolic }}
· P {{ $vitals->pulse }}
· SpO₂ {{ $vitals->spo2 }}
<div class="text-slate-400">{{ $vitals->recorded_at?->format('d M H:i') }}</div>
@else
@endif
</td>
@foreach ($templates as $template)
@php $assessment = $row['assessments']->get($template->code); @endphp
<td class="px-4 py-3">
@if ($assessment)
<a href="{{ route('care.assessments.show', $assessment) }}" class="text-sky-600 hover:text-sky-800">
{{ $assessment->status === 'completed'
? ($assessment->score?->severity_label ?? ($assessment->score?->total_score !== null ? $assessment->score->total_score : 'Done'))
: 'Draft' }}
</a>
@else
<span class="text-slate-400"></span>
@endif
</td>
@endforeach
<td class="px-4 py-3 text-right">
@if ($canCapture && $engineEnabled)
<div class="inline-flex flex-wrap justify-end gap-1">
@foreach ($templates as $template)
<form method="POST" action="{{ route('care.care-units.assessments.start', [$unit, $visit]) }}">
@csrf
<input type="hidden" name="template_code" value="{{ $template->code }}">
<button type="submit" class="rounded border border-slate-200 px-2 py-1 text-xs text-slate-700 hover:bg-slate-50">{{ strtoupper($template->code) }}</button>
</form>
@endforeach
</div>
@endif
</td>
</tr>
@if ($canVitals)
<tr class="bg-slate-50/60">
<td colspan="{{ 3 + $templates->count() }}" class="px-4 py-3">
<form method="POST" action="{{ route('care.care-units.vitals.store', [$unit, $visit]) }}" class="grid gap-2 sm:grid-cols-7">
@csrf
<input type="number" name="bp_systolic" placeholder="Sys" class="rounded-lg border-slate-300 text-sm">
<input type="number" name="bp_diastolic" placeholder="Dia" class="rounded-lg border-slate-300 text-sm">
<input type="number" name="pulse" placeholder="Pulse" class="rounded-lg border-slate-300 text-sm">
<input type="number" step="0.1" name="temperature" placeholder="Temp" class="rounded-lg border-slate-300 text-sm">
<input type="number" name="spo2" placeholder="SpO₂" class="rounded-lg border-slate-300 text-sm">
<input type="number" name="respiratory_rate" placeholder="RR" class="rounded-lg border-slate-300 text-sm">
<button type="submit" class="btn-secondary text-xs">Record vitals · {{ $row['patient']?->first_name }}</button>
</form>
</td>
</tr>
@endif
@empty
<tr><td colspan="{{ 3 + max($templates->count(), 1) }}" class="px-4 py-8 text-center text-slate-500">No patients placed on this unit.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</x-app-layout>
@@ -0,0 +1,97 @@
@php $r = $report; @endphp
<x-app-layout title="Performance · {{ $unit->name }}">
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-sm text-slate-500">
<a href="{{ route('care.care-units.show', $unit) }}" class="text-indigo-600 hover:text-indigo-800">{{ $unit->name }}</a>
<span class="text-slate-300">/</span>
Nurse performance
</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Unit nursing KPIs</h1>
<p class="mt-1 text-sm text-slate-500">MAR compliance, vitals, assessments, notes, and handovers.</p>
</div>
<form method="GET" class="flex flex-wrap items-end gap-2">
<div>
<label class="block text-xs font-medium uppercase text-slate-500">From</label>
<input type="date" name="from" value="{{ $r['from']->toDateString() }}" class="mt-1 rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-xs font-medium uppercase text-slate-500">To</label>
<input type="date" name="to" value="{{ $r['to']->toDateString() }}" class="mt-1 rounded-lg border-slate-300 text-sm">
</div>
<button type="submit" class="btn-secondary">Apply</button>
</form>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">MAR given</p>
<p class="mt-2 text-3xl font-semibold text-slate-900">{{ $r['mar']['given'] }}</p>
<p class="mt-1 text-sm text-slate-500">of {{ $r['mar']['total'] }} recorded · compliance {{ $r['mar']['compliance_rate'] ?? '—' }}%</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">On-time MAR</p>
<p class="mt-2 text-3xl font-semibold text-slate-900">{{ $r['mar']['on_time_rate'] ?? '—' }}%</p>
<p class="mt-1 text-sm text-slate-500">{{ $r['mar']['on_time_given'] }} given within window</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Assessments done</p>
<p class="mt-2 text-3xl font-semibold text-slate-900">{{ $r['assessments']['completed'] }}</p>
<p class="mt-1 text-sm text-slate-500">{{ $r['assessments']['completion_rate'] ?? '—' }}% of {{ $r['assessments']['started'] }} started</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Notes / vitals</p>
<p class="mt-2 text-3xl font-semibold text-slate-900">{{ $r['notes']['total'] }} / {{ $r['vitals']['total'] }}</p>
<p class="mt-1 text-sm text-slate-500">{{ $r['handovers']['completed'] }} handovers · {{ $r['placed_patients'] }} placed now</p>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-base font-semibold text-slate-900">Nursing packs</h2>
<ul class="mt-4 divide-y divide-slate-100 text-sm">
@forelse ($r['assessments']['by_template'] as $row)
<li class="flex items-center justify-between py-2">
<span>{{ $row['name'] }}</span>
<span class="text-slate-500">{{ $row['completed'] }}/{{ $row['total'] }}</span>
</li>
@empty
<li class="py-2 text-slate-500">No nursing assessments in range.</li>
@endforelse
</ul>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-base font-semibold text-slate-900">By clinician</h2>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-left text-xs uppercase text-slate-500">
<tr>
<th class="pb-2 pr-3">Staff</th>
<th class="pb-2 pr-3">MAR</th>
<th class="pb-2 pr-3">Notes</th>
<th class="pb-2 pr-3">Assess</th>
<th class="pb-2">Handover</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($r['by_actor'] as $actor)
<tr>
<td class="py-2 pr-3 font-medium">{{ $actor['label'] }}</td>
<td class="py-2 pr-3">{{ $actor['mar_given'] }}/{{ $actor['mar_total'] }}</td>
<td class="py-2 pr-3">{{ $actor['notes'] }}</td>
<td class="py-2 pr-3">{{ $actor['assessments_completed'] }}</td>
<td class="py-2">{{ $actor['handovers'] }}</td>
</tr>
@empty
<tr><td colspan="5" class="py-3 text-slate-500">No activity attributed yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
</x-app-layout>
+7
View File
@@ -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');
@@ -0,0 +1,217 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Assessment;
use App\Models\AssessmentTemplate;
use App\Models\Branch;
use App\Models\CareUnit;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\User;
use App\Models\Visit;
use App\Models\VisitVital;
use App\Services\Care\AssessmentService;
use App\Services\Care\VisitPlacementService;
use Database\Seeders\AssessmentTemplateSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CareNursingAssessmentsAndPerformanceTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected CareUnit $unit;
protected Visit $visit;
protected Patient $patient;
protected function setUp(): void
{
parent::setUp();
$this->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());
}
}