Add nursing assessment packs, ward board, and nurse performance KPIs.
Deploy Ladill Care / deploy (push) Successful in 55s
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:
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Services\Care;
|
namespace App\Services\Care;
|
||||||
|
|
||||||
use App\Models\AssessmentTemplate;
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\ClinicalPathway;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use Database\Seeders\AssessmentTemplateSeeder;
|
use Database\Seeders\AssessmentTemplateSeeder;
|
||||||
use Database\Seeders\ClinicalPathwaySeeder;
|
use Database\Seeders\ClinicalPathwaySeeder;
|
||||||
@@ -72,16 +73,24 @@ class CareFeatures
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (AssessmentTemplate::query()->whereNull('organization_id')->exists()) {
|
$nursingCodes = ['news2', 'braden', 'morse', 'pain_nrs'];
|
||||||
return;
|
$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', [
|
if (Schema::hasTable('care_clinical_pathways')
|
||||||
'--class' => AssessmentTemplateSeeder::class,
|
&& ! ClinicalPathway::query()->whereNull('organization_id')->exists()) {
|
||||||
'--force' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (Schema::hasTable('care_clinical_pathways')) {
|
|
||||||
Artisan::call('db:seed', [
|
Artisan::call('db:seed', [
|
||||||
'--class' => ClinicalPathwaySeeder::class,
|
'--class' => ClinicalPathwaySeeder::class,
|
||||||
'--force' => true,
|
'--force' => true,
|
||||||
|
|||||||
@@ -590,6 +590,9 @@ class CarePermissions
|
|||||||
$abilities[] = 'nursing.notes.manage';
|
$abilities[] = 'nursing.notes.manage';
|
||||||
$abilities[] = 'nursing.handover.view';
|
$abilities[] = 'nursing.handover.view';
|
||||||
$abilities[] = 'nursing.handover.manage';
|
$abilities[] = 'nursing.handover.manage';
|
||||||
|
$abilities[] = 'nursing.assessments.view';
|
||||||
|
$abilities[] = 'nursing.assessments.manage';
|
||||||
|
$abilities[] = 'nursing.performance.view';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array('analytics.department.view', $abilities, true)) {
|
if (in_array('analytics.department.view', $abilities, true)) {
|
||||||
@@ -597,6 +600,8 @@ class CarePermissions
|
|||||||
$abilities[] = 'mar.view';
|
$abilities[] = 'mar.view';
|
||||||
$abilities[] = 'nursing.notes.view';
|
$abilities[] = 'nursing.notes.view';
|
||||||
$abilities[] = 'nursing.handover.view';
|
$abilities[] = 'nursing.handover.view';
|
||||||
|
$abilities[] = 'nursing.assessments.view';
|
||||||
|
$abilities[] = 'nursing.performance.view';
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_unique($abilities));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,16 +80,48 @@ class ScoringService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($strategy, 'custom:')) {
|
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) {
|
$result['severity_label'] = $result['severity_label']
|
||||||
'sum_items' => $this->sumItems($assessment),
|
?? $this->severityFromBands($assessment->template?->meta, $result['total'] ?? null);
|
||||||
'single_value' => $this->singleValue($assessment),
|
|
||||||
default => throw ValidationException::withMessages([
|
return $result;
|
||||||
'scoring' => "Unknown scoring strategy [{$strategy}].",
|
}
|
||||||
]),
|
|
||||||
};
|
/**
|
||||||
|
* @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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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');
|
$canMar = $permissions->can($member, 'mar.view');
|
||||||
$canNotes = $permissions->can($member, 'nursing.notes.view');
|
$canNotes = $permissions->can($member, 'nursing.notes.view');
|
||||||
$canHandover = $permissions->can($member, 'nursing.handover.view');
|
$canHandover = $permissions->can($member, 'nursing.handover.view');
|
||||||
|
$canAssess = $permissions->can($member, 'nursing.assessments.view');
|
||||||
|
$canPerf = $permissions->can($member, 'nursing.performance.view');
|
||||||
@endphp
|
@endphp
|
||||||
@if ($canMar)
|
@if ($canMar)
|
||||||
<a href="{{ route('care.care-units.mar', $unit) }}" class="btn-primary">MAR board</a>
|
<a href="{{ route('care.care-units.mar', $unit) }}" class="btn-primary">MAR board</a>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($canAssess)
|
||||||
|
<a href="{{ route('care.care-units.assessments', $unit) }}" class="btn-secondary">Assessments</a>
|
||||||
|
@endif
|
||||||
@if ($canNotes)
|
@if ($canNotes)
|
||||||
<a href="{{ route('care.care-units.notes', $unit) }}" class="btn-secondary">Nursing notes</a>
|
<a href="{{ route('care.care-units.notes', $unit) }}" class="btn-secondary">Nursing notes</a>
|
||||||
@endif
|
@endif
|
||||||
@if ($canHandover)
|
@if ($canHandover)
|
||||||
<a href="{{ route('care.care-units.handovers', $unit) }}" class="btn-secondary">Handovers</a>
|
<a href="{{ route('care.care-units.handovers', $unit) }}" class="btn-secondary">Handovers</a>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($canPerf)
|
||||||
|
<a href="{{ route('care.care-units.performance', $unit) }}" class="btn-secondary">Performance</a>
|
||||||
|
@endif
|
||||||
@if ($canManageUnit)
|
@if ($canManageUnit)
|
||||||
<a href="{{ route('care.care-units.edit', $unit) }}" class="btn-secondary">Edit unit</a>
|
<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>
|
<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>
|
||||||
@@ -18,6 +18,8 @@ use App\Http\Controllers\Care\StaffAssignmentController;
|
|||||||
use App\Http\Controllers\Care\VisitPlacementController;
|
use App\Http\Controllers\Care\VisitPlacementController;
|
||||||
use App\Http\Controllers\Care\MarController;
|
use App\Http\Controllers\Care\MarController;
|
||||||
use App\Http\Controllers\Care\NursingDocumentationController;
|
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\DeviceController;
|
||||||
use App\Http\Controllers\Care\DisplayPublicController;
|
use App\Http\Controllers\Care\DisplayPublicController;
|
||||||
use App\Http\Controllers\Care\DisplayScreenController;
|
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::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::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', [StaffAssignmentController::class, 'index'])->name('care.staff-assignments.index');
|
||||||
Route::get('/staff-assignments/create', [StaffAssignmentController::class, 'create'])->name('care.staff-assignments.create');
|
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');
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user