Show nursing care on patient chart for floor nurses.
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:
isaacclad
2026-07-20 16:24:55 +00:00
co-authored by Cursor
parent 2bfaa9af4a
commit d4b645d85b
3 changed files with 278 additions and 84 deletions
+61 -11
View File
@@ -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,
+168 -73
View File
@@ -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 &amp; 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 &amp; nursing care', false)
->assertSee('On unit now')
->assertSee('Nursing assessments')
->assertSee('My care unit')
->assertDontSee('Visit &amp; clinical history', false)
->assertDontSee('Condition pathways')
->assertDontSee('Follow-up scores')
->assertDontSee('Start assessment');
}
}