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;
}
/**