Show nursing care on patient chart for floor nurses.
Deploy Ladill Care / deploy (push) Successful in 1m11s
Deploy Ladill Care / deploy (push) Successful in 1m11s
Nurses without consult rights see unit placement, MAR, nursing assessments, and meds/labs instead of the GP consult/pathways toolkit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -193,33 +193,83 @@ class PatientController extends Controller
|
||||
if ($assessmentsEnabled) {
|
||||
$careFeatures->ensureAssessmentCatalog();
|
||||
}
|
||||
|
||||
$isNurseStation = $permissions->can($member, 'nursing.station.view');
|
||||
$canConsult = $permissions->can($member, 'consultations.manage');
|
||||
$canViewLab = $permissions->can($member, 'lab.view') || $permissions->can($member, 'lab.results.view');
|
||||
$canViewRx = $permissions->can($member, 'prescriptions.view') || $permissions->can($member, 'prescriptions.manage');
|
||||
$canViewBills = $permissions->can($member, 'bills.view');
|
||||
$canViewMar = $permissions->can($member, 'mar.view') || $permissions->can($member, 'mar.manage');
|
||||
|
||||
$canViewAssessments = $assessmentsEnabled && $permissions->can($member, 'assessments.view');
|
||||
$canCaptureAssessment = $assessmentsEnabled && (
|
||||
// GP instruments / pathways CTAs stay consult-gated; nurses capture via unit boards.
|
||||
$canCaptureAssessment = $assessmentsEnabled && $canConsult && (
|
||||
$permissions->can($member, 'assessments.capture')
|
||||
|| $permissions->can($member, 'assessments.manage')
|
||||
);
|
||||
$canViewPathways = $canConsult && $permissions->can($member, 'pathways.manage');
|
||||
|
||||
$recentAssessments = collect();
|
||||
$latestUniversalIntake = null;
|
||||
$nursingAssessments = collect();
|
||||
$activePlacedVisit = null;
|
||||
$latestVitals = null;
|
||||
|
||||
if ($canViewAssessments) {
|
||||
$branchScope = app(OrganizationResolver::class)->branchScope($member);
|
||||
$recentAssessments = $patient->assessments()
|
||||
$assessmentQuery = $patient->assessments()
|
||||
->with('template')
|
||||
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
|
||||
->orderByDesc('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
->orderByDesc('created_at');
|
||||
|
||||
$latestUniversalIntake = $patient->assessments()
|
||||
->with(['template', 'answers.question'])
|
||||
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
|
||||
->whereHas('template', fn ($q) => $q->where('code', 'universal_intake'))
|
||||
->whereIn('status', [\App\Models\Assessment::STATUS_DRAFT, \App\Models\Assessment::STATUS_COMPLETED])
|
||||
->orderByDesc('created_at')
|
||||
if ($isNurseStation && ! $canConsult) {
|
||||
$nursingAssessments = (clone $assessmentQuery)
|
||||
->whereHas('template', fn ($q) => $q->whereIn(
|
||||
'code',
|
||||
\App\Services\Care\NursingAssessmentService::NURSING_PACK_CODES,
|
||||
))
|
||||
->limit(10)
|
||||
->get();
|
||||
} else {
|
||||
$recentAssessments = (clone $assessmentQuery)->limit(10)->get();
|
||||
$latestUniversalIntake = $patient->assessments()
|
||||
->with(['template', 'answers.question'])
|
||||
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
|
||||
->whereHas('template', fn ($q) => $q->where('code', 'universal_intake'))
|
||||
->whereIn('status', [\App\Models\Assessment::STATUS_DRAFT, \App\Models\Assessment::STATUS_COMPLETED])
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
if ($isNurseStation) {
|
||||
$activePlacedVisit = $patient->visits()
|
||||
->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS])
|
||||
->whereNotNull('care_unit_id')
|
||||
->with(['careUnit', 'bed'])
|
||||
->orderByDesc('placed_at')
|
||||
->first();
|
||||
|
||||
if ($activePlacedVisit) {
|
||||
$latestVitals = \App\Models\VisitVital::owned($this->ownerRef($request))
|
||||
->where('visit_id', $activePlacedVisit->id)
|
||||
->orderByDesc('recorded_at')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
return view('care.patients.show', array_merge($dashboard, [
|
||||
'canManage' => $canManage,
|
||||
'isNurseStation' => $isNurseStation,
|
||||
'canConsult' => $canConsult,
|
||||
'canViewLab' => $canViewLab,
|
||||
'canViewRx' => $canViewRx,
|
||||
'canViewBills' => $canViewBills,
|
||||
'canViewMar' => $canViewMar,
|
||||
'canViewPathways' => $canViewPathways,
|
||||
'activePlacedVisit' => $activePlacedVisit,
|
||||
'latestVitals' => $latestVitals,
|
||||
'nursingAssessments' => $nursingAssessments,
|
||||
'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(),
|
||||
'messagingEmailReady' => $suiteEmailReady || $credential->hasValidBird(),
|
||||
'suiteMessagingReady' => $suiteEmailReady || $suiteSmsReady,
|
||||
|
||||
@@ -84,7 +84,9 @@
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Visit & clinical history</h2>
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
{{ ($isNurseStation ?? false) && ! ($canConsult ?? false) ? 'Visit & nursing care' : 'Visit & clinical history' }}
|
||||
</h2>
|
||||
<div class="mt-4 space-y-4 text-sm">
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800">Recent visits</h3>
|
||||
@@ -101,76 +103,52 @@
|
||||
<p class="mt-1 text-slate-400">No visits yet</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800">Consultations</h3>
|
||||
@forelse ($consultations as $consultation)
|
||||
<p class="mt-1">
|
||||
<a href="{{ route('care.consultations.show', $consultation) }}" class="text-sky-600 hover:text-sky-700">
|
||||
{{ $consultation->started_at?->format('d M Y') ?? '—' }}
|
||||
@if ($consultation->practitioner) · {{ $consultation->practitioner->name }} @endif
|
||||
</a>
|
||||
@if ($consultation->diagnoses->isNotEmpty())
|
||||
<span class="text-slate-400">— {{ $consultation->diagnoses->first()->description }}</span>
|
||||
|
||||
@if (($isNurseStation ?? false) && ! ($canConsult ?? false))
|
||||
@if ($activePlacedVisit ?? null)
|
||||
<div class="rounded-xl border border-teal-100 bg-teal-50/60 px-3 py-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-teal-800">On unit now</p>
|
||||
<p class="mt-1 font-medium text-slate-900">
|
||||
{{ $activePlacedVisit->careUnit?->name ?? 'Care unit' }}
|
||||
@if ($activePlacedVisit->bed)
|
||||
· Bed {{ $activePlacedVisit->bed->label }}
|
||||
@endif
|
||||
</p>
|
||||
@if ($latestVitals ?? null)
|
||||
<p class="mt-1 text-xs text-slate-600">
|
||||
Latest vitals
|
||||
{{ $latestVitals->recorded_at?->format('d M H:i') }}:
|
||||
BP {{ $latestVitals->bp_systolic }}/{{ $latestVitals->bp_diastolic }}
|
||||
· P {{ $latestVitals->pulse }}
|
||||
· SpO₂ {{ $latestVitals->spo2 }}
|
||||
</p>
|
||||
@else
|
||||
<p class="mt-1 text-xs text-slate-500">No vitals recorded on this placement yet.</p>
|
||||
@endif
|
||||
</p>
|
||||
@empty
|
||||
<p class="mt-1 text-slate-400">No consultations yet</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800">Laboratory</h3>
|
||||
@forelse ($laboratory as $lab)
|
||||
<p class="mt-1">
|
||||
<a href="{{ route('care.lab.requests.show', $lab) }}" class="text-sky-600 hover:text-sky-700">
|
||||
{{ $lab->investigationType->name }}
|
||||
</a>
|
||||
· {{ $lab->completed_at?->format('d M Y') ?? '—' }}
|
||||
@if ($lab->result?->is_abnormal)
|
||||
<span class="text-red-600">abnormal</span>
|
||||
@endif
|
||||
</p>
|
||||
@empty
|
||||
<p class="mt-1 text-slate-400">No lab results yet</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800">Prescriptions</h3>
|
||||
@forelse ($prescriptions as $rx)
|
||||
<p class="mt-1">
|
||||
<a href="{{ route('care.prescriptions.show', $rx) }}" class="text-sky-600 hover:text-sky-700">
|
||||
{{ $rx->created_at->format('d M Y') }}
|
||||
</a>
|
||||
· {{ config('care.prescription_statuses')[$rx->status] ?? $rx->status }}
|
||||
· {{ $rx->items->pluck('name')->take(2)->join(', ') }}
|
||||
</p>
|
||||
@empty
|
||||
<p class="mt-1 text-slate-400">No prescriptions yet</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@if ($canViewAssessments ?? false)
|
||||
<p class="mt-3 flex flex-wrap gap-3">
|
||||
<a href="{{ route('care.nursing.station', ['unit' => $activePlacedVisit->care_unit_id]) }}" class="text-sm font-medium text-teal-700 hover:text-teal-900">Open care unit</a>
|
||||
@if ($canViewMar ?? false)
|
||||
<a href="{{ route('care.visits.mar', $activePlacedVisit) }}" class="text-sm font-medium text-teal-700 hover:text-teal-900">MAR chart</a>
|
||||
@endif
|
||||
<a href="{{ route('care.care-units.assessments', $activePlacedVisit->careUnit) }}" class="text-sm font-medium text-teal-700 hover:text-teal-900">Vitals & assess</a>
|
||||
<a href="{{ route('care.care-units.notes', $activePlacedVisit->careUnit) }}" class="text-sm font-medium text-teal-700 hover:text-teal-900">Nursing notes</a>
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-dashed border-slate-200 px-3 py-3 text-slate-500">
|
||||
Not placed on a care unit right now.
|
||||
<a href="{{ route('care.nursing.station') }}" class="ml-1 font-medium text-indigo-600 hover:text-indigo-800">My care unit</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="font-medium text-slate-800">Assessments</h3>
|
||||
<a href="{{ route('care.assessments.index', $patient) }}" class="text-xs text-sky-600 hover:text-sky-700">View all</a>
|
||||
<h3 class="font-medium text-slate-800">Nursing assessments</h3>
|
||||
@if ($canViewAssessments ?? false)
|
||||
<a href="{{ route('care.assessments.index', $patient) }}" class="text-xs text-sky-600 hover:text-sky-700">View all</a>
|
||||
@endif
|
||||
</div>
|
||||
@if ($latestUniversalIntake ?? null)
|
||||
@php
|
||||
$ccAnswer = $latestUniversalIntake->answers->first(fn ($a) => $a->question?->code === 'chief_complaint');
|
||||
@endphp
|
||||
<div class="mt-2 rounded-lg border border-sky-100 bg-sky-50/50 px-3 py-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-sky-800">Patient history form</p>
|
||||
<p class="mt-1">
|
||||
<a href="{{ route('care.assessments.show', $latestUniversalIntake) }}" class="text-sky-600 hover:text-sky-700">
|
||||
{{ $assessmentStatuses[$latestUniversalIntake->status] ?? $latestUniversalIntake->status }}
|
||||
</a>
|
||||
· {{ $latestUniversalIntake->created_at?->format('d M Y') ?? '—' }}
|
||||
</p>
|
||||
@if ($ccAnswer)
|
||||
<p class="mt-1 text-slate-600">{{ \Illuminate\Support\Str::limit((string) $ccAnswer->authoritativeValue(), 120) }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@forelse ($recentAssessments as $assessment)
|
||||
@forelse ($nursingAssessments ?? [] as $assessment)
|
||||
<p class="mt-1">
|
||||
<a href="{{ route('care.assessments.show', $assessment) }}" class="text-sky-600 hover:text-sky-700">
|
||||
{{ $assessment->template->name }}
|
||||
@@ -179,18 +157,133 @@
|
||||
· {{ $assessment->created_at?->format('d M Y') ?? '—' }}
|
||||
</p>
|
||||
@empty
|
||||
<p class="mt-1 text-slate-400">No assessments yet</p>
|
||||
<p class="mt-1 text-slate-400">No nursing assessments yet — start NEWS2, Braden, Morse, or pain from the unit board.</p>
|
||||
@endforelse
|
||||
@if ($canViewAssessments ?? false)
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800">Medications</h3>
|
||||
@forelse ($prescriptions as $rx)
|
||||
<p class="mt-1 text-slate-600">
|
||||
{{ $rx->created_at->format('d M Y') }}
|
||||
· {{ config('care.prescription_statuses')[$rx->status] ?? $rx->status }}
|
||||
· {{ $rx->items->pluck('name')->take(2)->join(', ') }}
|
||||
</p>
|
||||
@empty
|
||||
<p class="mt-1 text-slate-400">No prescriptions on record</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800">Laboratory</h3>
|
||||
@forelse ($laboratory as $lab)
|
||||
<p class="mt-1 text-slate-600">
|
||||
{{ $lab->investigationType->name }}
|
||||
· {{ $lab->completed_at?->format('d M Y') ?? '—' }}
|
||||
@if ($lab->result?->is_abnormal)
|
||||
<span class="text-red-600">abnormal</span>
|
||||
@endif
|
||||
</p>
|
||||
@empty
|
||||
<p class="mt-1 text-slate-400">No lab results yet</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@else
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800">Consultations</h3>
|
||||
@forelse ($consultations as $consultation)
|
||||
<p class="mt-1">
|
||||
<a href="{{ route('care.consultations.show', $consultation) }}" class="text-sky-600 hover:text-sky-700">
|
||||
{{ $consultation->started_at?->format('d M Y') ?? '—' }}
|
||||
@if ($consultation->practitioner) · {{ $consultation->practitioner->name }} @endif
|
||||
</a>
|
||||
@if ($consultation->diagnoses->isNotEmpty())
|
||||
<span class="text-slate-400">— {{ $consultation->diagnoses->first()->description }}</span>
|
||||
@endif
|
||||
</p>
|
||||
@empty
|
||||
<p class="mt-1 text-slate-400">No consultations yet</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@if ($canViewLab ?? true)
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800">Laboratory</h3>
|
||||
@forelse ($laboratory as $lab)
|
||||
<p class="mt-1">
|
||||
<a href="{{ route('care.lab.requests.show', $lab) }}" class="text-sky-600 hover:text-sky-700">
|
||||
{{ $lab->investigationType->name }}
|
||||
</a>
|
||||
· {{ $lab->completed_at?->format('d M Y') ?? '—' }}
|
||||
@if ($lab->result?->is_abnormal)
|
||||
<span class="text-red-600">abnormal</span>
|
||||
@endif
|
||||
</p>
|
||||
@empty
|
||||
<p class="mt-1 text-slate-400">No lab results yet</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@endif
|
||||
@if ($canViewRx ?? true)
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800">Prescriptions</h3>
|
||||
@forelse ($prescriptions as $rx)
|
||||
<p class="mt-1">
|
||||
<a href="{{ route('care.prescriptions.show', $rx) }}" class="text-sky-600 hover:text-sky-700">
|
||||
{{ $rx->created_at->format('d M Y') }}
|
||||
</a>
|
||||
· {{ config('care.prescription_statuses')[$rx->status] ?? $rx->status }}
|
||||
· {{ $rx->items->pluck('name')->take(2)->join(', ') }}
|
||||
</p>
|
||||
@empty
|
||||
<p class="mt-1 text-slate-400">No prescriptions yet</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@endif
|
||||
@if ($canViewAssessments ?? false)
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="font-medium text-slate-800">Assessments</h3>
|
||||
<a href="{{ route('care.assessments.index', $patient) }}" class="text-xs text-sky-600 hover:text-sky-700">View all</a>
|
||||
</div>
|
||||
@if ($latestUniversalIntake ?? null)
|
||||
@php
|
||||
$ccAnswer = $latestUniversalIntake->answers->first(fn ($a) => $a->question?->code === 'chief_complaint');
|
||||
@endphp
|
||||
<div class="mt-2 rounded-lg border border-sky-100 bg-sky-50/50 px-3 py-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-sky-800">Patient history form</p>
|
||||
<p class="mt-1">
|
||||
<a href="{{ route('care.assessments.show', $latestUniversalIntake) }}" class="text-sky-600 hover:text-sky-700">
|
||||
{{ $assessmentStatuses[$latestUniversalIntake->status] ?? $latestUniversalIntake->status }}
|
||||
</a>
|
||||
· {{ $latestUniversalIntake->created_at?->format('d M Y') ?? '—' }}
|
||||
</p>
|
||||
@if ($ccAnswer)
|
||||
<p class="mt-1 text-slate-600">{{ \Illuminate\Support\Str::limit((string) $ccAnswer->authoritativeValue(), 120) }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@forelse ($recentAssessments as $assessment)
|
||||
<p class="mt-1">
|
||||
<a href="{{ route('care.assessments.show', $assessment) }}" class="text-sky-600 hover:text-sky-700">
|
||||
{{ $assessment->template->name }}
|
||||
</a>
|
||||
· {{ ($assessmentStatuses[$assessment->status] ?? $assessment->status) }}
|
||||
· {{ $assessment->created_at?->format('d M Y') ?? '—' }}
|
||||
</p>
|
||||
@empty
|
||||
<p class="mt-1 text-slate-400">No assessments yet</p>
|
||||
@endforelse
|
||||
<p class="mt-2 flex flex-wrap gap-3">
|
||||
@if ($canCaptureAssessment ?? false)
|
||||
<a href="{{ route('care.assessments.create', $patient) }}" class="text-sm font-medium text-sky-600 hover:text-sky-700">Start assessment</a>
|
||||
@endif
|
||||
<a href="{{ route('care.pathways.index', $patient) }}" class="text-sm font-medium text-sky-600 hover:text-sky-700">Condition pathways</a>
|
||||
<a href="{{ route('care.outcomes.index', $patient) }}" class="text-sm font-medium text-sky-600 hover:text-sky-700">Follow-up scores</a>
|
||||
@if ($canViewPathways ?? false)
|
||||
<a href="{{ route('care.pathways.index', $patient) }}" class="text-sm font-medium text-sky-600 hover:text-sky-700">Condition pathways</a>
|
||||
<a href="{{ route('care.outcomes.index', $patient) }}" class="text-sm font-medium text-sky-600 hover:text-sky-700">Follow-up scores</a>
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@@ -269,6 +362,7 @@
|
||||
@endforelse
|
||||
</section>
|
||||
|
||||
@if (($canViewBills ?? false) || ($canConsult ?? false) || ($canManage ?? false))
|
||||
@php $money = fn ($minor) => config('care.billing.currency').' '.number_format($minor / 100, 2); @endphp
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Billing</h2>
|
||||
@@ -283,6 +377,7 @@
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Documents</h2>
|
||||
|
||||
@@ -308,4 +308,53 @@ class CareMyShiftsAndNavPermissionsTest extends TestCase
|
||||
->assertOk()
|
||||
->assertSee('Open care unit');
|
||||
}
|
||||
|
||||
public function test_nurse_patient_chart_shows_nursing_care_not_gp_clinical_toolkit(): void
|
||||
{
|
||||
$user = User::create([
|
||||
'public_id' => 'nurse-chart',
|
||||
'name' => 'Nurse Chart',
|
||||
'email' => 'nurse-chart@example.com',
|
||||
]);
|
||||
Member::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'user_ref' => $user->public_id,
|
||||
'role' => 'nurse',
|
||||
'branch_id' => $this->branch->id,
|
||||
]);
|
||||
|
||||
$patient = \App\Models\Patient::create([
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-CHART-1',
|
||||
'first_name' => 'Efua',
|
||||
'last_name' => 'Owusu',
|
||||
]);
|
||||
|
||||
\App\Models\Visit::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'care_unit_id' => $this->unit->id,
|
||||
'status' => \App\Models\Visit::STATUS_OPEN,
|
||||
'checked_in_at' => now(),
|
||||
'placed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('care.patients.show', $patient))
|
||||
->assertOk()
|
||||
->assertSee('Visit & nursing care', false)
|
||||
->assertSee('On unit now')
|
||||
->assertSee('Nursing assessments')
|
||||
->assertSee('My care unit')
|
||||
->assertDontSee('Visit & clinical history', false)
|
||||
->assertDontSee('Condition pathways')
|
||||
->assertDontSee('Follow-up scores')
|
||||
->assertDontSee('Start assessment');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user