feat(assessments): layered clinical assessment engine end-to-end
Deploy Ladill Care / deploy (push) Successful in 1m26s

Add a template-driven assessment system with universal intake, clinical
pathways, disease instruments (stroke MVP + extended, diabetes, and ten
specialty packs), scoring, patient outcome trends, REST/API + FHIR
export, and Enterprise org-level assessment analytics. Seed packs and
design/licensing docs ship for deploy and pre-GA review.
This commit is contained in:
isaacclad
2026-07-16 22:58:09 +00:00
parent 8896088425
commit 2ce4bc8993
94 changed files with 13430 additions and 56 deletions
@@ -0,0 +1,122 @@
@php
/** @var \App\Models\Assessment $assessment */
/** @var \Illuminate\Support\Collection $answersByCode */
$questions = $assessment->template->questions;
$grouped = $questions->groupBy(fn ($q) => $q->section ?: 'General');
@endphp
<div class="space-y-8">
@foreach ($grouped as $section => $sectionQuestions)
<div>
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">{{ $section }}</h3>
<div class="mt-4 space-y-4">
@foreach ($sectionQuestions as $question)
@php
$name = 'answers['.$question->code.']';
$old = old('answers.'.$question->code, $answersByCode[$question->code] ?? null);
$options = $question->options ?? [];
$choices = $options['choices'] ?? [];
@endphp
<div>
<label class="block text-sm font-medium text-slate-700">
{{ $question->label }}
@if ($question->is_required)<span class="text-red-500">*</span>@endif
</label>
@if ($question->help_text)
<p class="mt-0.5 text-xs text-slate-400">{{ $question->help_text }}</p>
@endif
@switch($question->answer_type)
@case('textarea')
<textarea name="{{ $name }}" rows="3" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ $old }}</textarea>
@break
@case('number')
@case('integer')
@case('scale')
<input
type="number"
name="{{ $name }}"
value="{{ $old }}"
@if(isset($options['min'])) min="{{ $options['min'] }}" @endif
@if(isset($options['max'])) max="{{ $options['max'] }}" @endif
@if($question->answer_type === 'integer') step="1" @else step="any" @endif
class="mt-1 w-full rounded-lg border-slate-300 text-sm"
>
@break
@case('boolean')
<select name="{{ $name }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value=""></option>
<option value="1" @selected($old === true || $old === 1 || $old === '1')>Yes</option>
<option value="0" @selected($old === false || $old === 0 || $old === '0')>No</option>
</select>
@break
@case('date')
<input type="date" name="{{ $name }}" value="{{ $old instanceof \Carbon\Carbon ? $old->format('Y-m-d') : $old }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@break
@case('datetime')
<input type="datetime-local" name="{{ $name }}" value="{{ $old instanceof \Carbon\Carbon ? $old->format('Y-m-d\TH:i') : $old }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@break
@case('single_choice')
@case('score_item')
<select name="{{ $name }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value=""></option>
@foreach ($choices as $choice)
@php
$code = (string) ($choice['code'] ?? '');
$label = $choice['label'] ?? $code;
$selected = (string) $old === $code
|| (isset($choice['score']) && (string) $old === (string) $choice['score']);
@endphp
<option value="{{ $code }}" @selected($selected)>
{{ $label }}@if(isset($choice['score'])) ({{ $choice['score'] }})@endif
</option>
@endforeach
</select>
@break
@case('multi_choice')
@php $selected = is_array($old) ? $old : []; @endphp
<div class="mt-2 space-y-2">
@foreach ($choices as $choice)
@php $code = (string) ($choice['code'] ?? ''); @endphp
<label class="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
name="{{ $name }}[]"
value="{{ $code }}"
class="rounded border-slate-300"
@checked(in_array($code, array_map('strval', $selected), true))
>
{{ $choice['label'] ?? $code }}
</label>
@endforeach
</div>
@break
@case('calculated')
<p class="mt-1 text-sm text-slate-500">{{ $old ?? '—' }}</p>
@break
@default
<input type="text" name="{{ $name }}" value="{{ $old }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@endswitch
@error('answers.'.$question->code)
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
@enderror
</div>
@endforeach
</div>
</div>
@endforeach
<div>
<label class="block text-sm font-medium text-slate-700">Notes</label>
<textarea name="notes" rows="2" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ old('notes', $assessment->notes) }}</textarea>
</div>
</div>
@@ -0,0 +1,46 @@
<x-app-layout :title="'Start assessment · '.$patient->fullName()">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Start assessment</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ $patient->fullName() }}</h1>
<p class="mt-1 text-sm text-slate-500">
<a href="{{ route('care.patients.show', $patient) }}" class="text-sky-600 hover:text-sky-700">{{ $patient->patient_number }}</a>
</p>
</div>
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
@if ($templates->isEmpty())
<p class="text-sm text-slate-500">No capturable assessment templates are available for your role. Ensure content packs are seeded and the assessments engine is enabled.</p>
@else
<form method="POST" action="{{ route('care.assessments.store', $patient) }}" class="space-y-4">
@csrf
@if ($consultationUuid)
<input type="hidden" name="consultation_uuid" value="{{ $consultationUuid }}">
@endif
@if ($visitUuid)
<input type="hidden" name="visit_uuid" value="{{ $visitUuid }}">
@endif
<div>
<label class="block text-sm font-medium text-slate-700">Template</label>
<select name="template_code" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">Select…</option>
@foreach ($templates as $template)
<option value="{{ $template->code }}" @selected(old('template_code') === $template->code)>
{{ $template->name }}
({{ $categories[$template->category] ?? $template->category }} · v{{ $template->version }})
</option>
@endforeach
</select>
@error('template_code')
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-3">
<button type="submit" class="btn-primary">Start</button>
<a href="{{ route('care.assessments.index', $patient) }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">Cancel</a>
</div>
</form>
@endif
</section>
</x-app-layout>
@@ -0,0 +1,68 @@
<x-app-layout :title="'Assessments · '.$patient->fullName()">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Clinical assessments</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ $patient->fullName() }}</h1>
<p class="mt-1 text-sm text-slate-500">
<a href="{{ route('care.patients.show', $patient) }}" class="text-sky-600 hover:text-sky-700">{{ $patient->patient_number }}</a>
</p>
</div>
@if ($canCapture)
<a href="{{ route('care.assessments.create', $patient) }}" class="btn-primary">Start assessment</a>
@endif
</div>
<form method="GET" class="mt-6 flex flex-wrap gap-3 rounded-2xl border border-slate-200 bg-white p-4 text-sm">
<select name="status" class="rounded-lg border-slate-300 text-sm">
<option value="">All statuses</option>
@foreach ($statuses as $value => $label)
<option value="{{ $value }}" @selected(($filters['status'] ?? '') === $value)>{{ $label }}</option>
@endforeach
</select>
<select name="category" class="rounded-lg border-slate-300 text-sm">
<option value="">All categories</option>
@foreach ($categories as $value => $label)
<option value="{{ $value }}" @selected(($filters['category'] ?? '') === $value)>{{ $label }}</option>
@endforeach
</select>
<input type="text" name="template_code" value="{{ $filters['template_code'] ?? '' }}" placeholder="Template code" class="rounded-lg border-slate-300 text-sm">
<button type="submit" class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-medium text-slate-700 hover:bg-slate-100">Filter</button>
</form>
<section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-100 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3">Template</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Assessed</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($assessments as $assessment)
<tr>
<td class="px-4 py-3">
<p class="font-medium text-slate-900">{{ $assessment->template->name }}</p>
<p class="text-xs text-slate-400">{{ $assessment->template->code }} · v{{ $assessment->template->version }}</p>
</td>
<td class="px-4 py-3">{{ $statuses[$assessment->status] ?? $assessment->status }}</td>
<td class="px-4 py-3 text-slate-600">
{{ $assessment->assessed_at?->format('d M Y H:i') ?? $assessment->created_at?->format('d M Y H:i') ?? '—' }}
</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('care.assessments.show', $assessment) }}" class="text-sky-600 hover:text-sky-700">Open</a>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-8 text-center text-slate-400">No assessments yet.</td>
</tr>
@endforelse
</tbody>
</table>
@if ($assessments->hasPages())
<div class="border-t border-slate-100 px-4 py-3">{{ $assessments->withQueryString()->links() }}</div>
@endif
</section>
</x-app-layout>
@@ -0,0 +1,116 @@
<x-app-layout :title="$assessment->template->name.' · '.$assessment->patient->fullName()">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-medium uppercase tracking-wide {{ $assessment->status === 'completed' ? 'text-emerald-600' : ($assessment->status === 'cancelled' ? 'text-slate-400' : 'text-amber-600') }}">
{{ $statuses[$assessment->status] ?? $assessment->status }}
</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ $assessment->template->name }}</h1>
<p class="mt-1 text-sm text-slate-500">
<a href="{{ route('care.patients.show', $assessment->patient) }}" class="text-sky-600 hover:text-sky-700">{{ $assessment->patient->fullName() }}</a>
· {{ $assessment->template->code }} v{{ $assessment->template->version }}
@if ($assessment->consultation)
· <a href="{{ route('care.consultations.show', $assessment->consultation) }}" class="text-sky-600 hover:text-sky-700">Consultation</a>
@endif
</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ route('care.assessments.fhir', $assessment) }}?download=1" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Export FHIR</a>
<a href="{{ route('care.assessments.index', $assessment->patient) }}" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">All assessments</a>
</div>
</div>
@if ($assessment->score)
<section class="mt-6 rounded-2xl border border-emerald-100 bg-emerald-50/40 p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-emerald-800">Score</h2>
<dl class="mt-3 flex flex-wrap gap-6 text-sm">
<div>
<dt class="text-slate-500">Total</dt>
<dd class="text-lg font-semibold text-slate-900">
{{ $assessment->score->total_score }}
@if ($assessment->score->max_score !== null)
<span class="text-sm font-normal text-slate-500">/ {{ $assessment->score->max_score }}</span>
@endif
</dd>
</div>
@if ($assessment->score->severity_label)
<div>
<dt class="text-slate-500">Severity</dt>
<dd class="font-medium text-slate-900">{{ $assessment->score->severity_label }}</dd>
</div>
@endif
@if ($assessment->score->subscores)
<div class="w-full">
<dt class="text-slate-500">Subscores</dt>
<dd class="mt-1 text-slate-700">
@foreach ($assessment->score->subscores as $key => $val)
<span class="mr-3"><span class="text-slate-500">{{ $key }}:</span> {{ $val }}</span>
@endforeach
</dd>
</div>
@endif
</dl>
</section>
@endif
@if ($canEdit)
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<form method="POST" action="{{ route('care.assessments.update', $assessment) }}" class="space-y-6" id="assessment-form">
@csrf
@method('PUT')
@include('care.assessments._form', ['assessment' => $assessment, 'answersByCode' => $answersByCode])
<div class="flex flex-wrap gap-3 border-t border-slate-100 pt-6">
<button type="submit" class="btn-primary">Save draft</button>
<button
type="submit"
formaction="{{ route('care.assessments.complete', $assessment) }}"
formmethod="post"
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700"
onclick="this.form.querySelector('input[name=_method]')?.remove()"
>Complete assessment</button>
</div>
</form>
<div class="mt-4">
<x-confirm-dialog
:name="'cancel-assessment-'.$assessment->id"
title="Cancel this draft?"
message="The draft assessment will be marked cancelled and can no longer be edited."
:action="route('care.assessments.cancel', $assessment)"
confirm-label="Cancel assessment"
variant="danger"
>
<x-slot:trigger>
<button type="button" class="rounded-lg border border-red-200 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50">Cancel draft</button>
</x-slot:trigger>
</x-confirm-dialog>
</div>
<p class="mt-3 text-xs text-slate-400">Required fields are validated when you complete the assessment.</p>
</section>
@else
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Answers</h2>
<dl class="mt-4 space-y-3 text-sm">
@foreach ($assessment->template->questions as $question)
@php $value = $answersByCode[$question->code] ?? null; @endphp
<div class="grid gap-1 sm:grid-cols-3">
<dt class="text-slate-500">{{ $question->label }}</dt>
<dd class="sm:col-span-2 font-medium text-slate-900">
@if (is_array($value))
{{ implode(', ', $value) }}
@elseif (is_bool($value))
{{ $value ? 'Yes' : 'No' }}
@elseif ($value instanceof \Carbon\Carbon)
{{ $value->format($question->answer_type === 'date' ? 'd M Y' : 'd M Y H:i') }}
@else
{{ $value ?? '—' }}
@endif
</dd>
</div>
@endforeach
</dl>
@if ($assessment->notes)
<p class="mt-4 text-sm text-slate-600"><span class="font-medium text-slate-700">Notes:</span> {{ $assessment->notes }}</p>
@endif
</section>
@endif
</x-app-layout>
@@ -88,6 +88,199 @@
</section>
@endif
@if ($canViewAssessments ?? false)
{{-- Layer 1: universal intake (structured overlay; does not replace free-text symptoms) --}}
@if ($universalTemplate)
<section class="mt-6 rounded-2xl border border-sky-100 bg-sky-50/40 p-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-sm font-semibold uppercase tracking-wide text-sky-800">Universal intake</h2>
<p class="mt-1 text-xs text-slate-500">
Structured Layer 1 assessment (presenting complaint, social history, function).
Free-text <span class="font-medium">Symptoms</span> and <span class="font-medium">Clinical notes</span> below remain the narrative source of truth they are not auto-copied either way.
</p>
</div>
@if ($universalAssessment)
<a href="{{ route('care.assessments.show', $universalAssessment) }}" class="rounded-lg bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-700">
{{ $universalAssessment->isDraft() ? 'Continue intake' : 'View intake' }}
</a>
@elseif (($canCaptureUniversal ?? false) && ! $isCompleted)
<form method="POST" action="{{ route('care.consultations.assessments.store', $consultation) }}">
@csrf
<input type="hidden" name="template_code" value="universal_intake">
<button type="submit" class="rounded-lg bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-700">Start universal intake</button>
</form>
@endif
</div>
@if ($universalAssessment)
<p class="mt-3 text-sm text-slate-600">
Status:
<span class="font-medium">{{ $assessmentStatuses[$universalAssessment->status] ?? $universalAssessment->status }}</span>
@if ($universalAssessment->consultation_id !== $consultation->id)
<span class="text-slate-400">(from another visit open to review)</span>
@endif
</p>
@php
$cc = $universalAssessment->answers->first(fn ($a) => $a->question?->code === 'chief_complaint');
$pain = $universalAssessment->answers->first(fn ($a) => $a->question?->code === 'pain_score');
@endphp
@if ($cc || $pain)
<dl class="mt-3 grid gap-2 text-sm sm:grid-cols-2">
@if ($cc)
<div>
<dt class="text-slate-500">Chief complaint</dt>
<dd class="font-medium text-slate-800">{{ \Illuminate\Support\Str::limit((string) $cc->authoritativeValue(), 160) }}</dd>
</div>
@endif
@if ($pain)
<div>
<dt class="text-slate-500">Pain score</dt>
<dd class="font-medium text-slate-800">{{ $pain->authoritativeValue() ?? '—' }}</dd>
</div>
@endif
</dl>
@endif
@elseif (! ($canCaptureUniversal ?? false))
<p class="mt-3 text-sm text-slate-400">No universal intake for this patient yet.</p>
@endif
</section>
@endif
{{-- Layer 2: pathways (suggest from persisted diagnoses; clinician confirms) --}}
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Clinical pathways</h2>
<a href="{{ route('care.pathways.index', $consultation->patient) }}" class="text-sm text-sky-600 hover:text-sky-700">Manage pathways</a>
</div>
@if (($activePathways ?? collect())->isNotEmpty())
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($activePathways as $pp)
<span class="inline-flex items-center rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-800 ring-1 ring-emerald-100">
{{ $pp->pathway->name }}
</span>
@endforeach
</div>
@endif
@if (($pathwayInstruments ?? collect())->isNotEmpty())
<div class="mt-4 overflow-hidden rounded-xl border border-slate-100">
<div class="bg-slate-50 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Pathway instruments
</div>
<ul class="divide-y divide-slate-100 text-sm">
@foreach ($pathwayInstruments as $instrument)
<li class="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5">
<div>
<span class="font-medium text-slate-900">{{ $instrument['template_name'] }}</span>
<span class="text-xs text-slate-400">
· {{ $instrument['pathway_name'] }}
· {{ $instrument['template_code'] }}
@if ($instrument['required']) · required @endif
</span>
@if ($instrument['assessment']?->score)
<span class="ml-1 text-xs font-medium text-emerald-700">
score {{ $instrument['assessment']->score->total_score }}@if($instrument['assessment']->score->max_score !== null)/{{ $instrument['assessment']->score->max_score }}@endif
</span>
@endif
</div>
<div class="flex items-center gap-2">
@if ($instrument['pack_missing'])
<span class="text-xs text-amber-600">Content pack not seeded</span>
@elseif ($instrument['assessment'])
<span class="text-xs text-slate-500">{{ $assessmentStatuses[$instrument['assessment']->status] ?? $instrument['assessment']->status }}</span>
<a href="{{ route('care.assessments.show', $instrument['assessment']) }}" class="text-sky-600 hover:text-sky-700">
{{ $instrument['assessment']->isDraft() ? 'Continue' : 'View' }}
</a>
@elseif (($canCaptureAssessment ?? false) && ! $isCompleted)
<form method="POST" action="{{ route('care.consultations.assessments.store', $consultation) }}">
@csrf
<input type="hidden" name="template_code" value="{{ $instrument['template_code'] }}">
<button type="submit" class="text-sm font-medium text-sky-600 hover:text-sky-700">Start</button>
</form>
@else
<span class="text-xs text-slate-400">Not started</span>
@endif
</div>
</li>
@endforeach
</ul>
</div>
@endif
@if (($pathwaySuggestions ?? collect())->isNotEmpty())
<ul class="mt-4 space-y-2 text-sm">
@foreach ($pathwaySuggestions as $suggestion)
<li class="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<div>
<span class="font-medium text-slate-900">{{ $suggestion['pathway_name'] }}</span>
<span class="text-xs text-slate-400">
· {{ $suggestion['match_reason'] }}
@if ($suggestion['already_active']) · already active @endif
</span>
</div>
@if (($canManagePathways ?? false) && ! $suggestion['already_active'] && ! $isCompleted)
<form method="POST" action="{{ route('care.pathways.store', $consultation->patient) }}">
@csrf
<input type="hidden" name="pathway_code" value="{{ $suggestion['pathway_code'] }}">
<input type="hidden" name="consultation_uuid" value="{{ $consultation->uuid }}">
<button type="submit" class="text-sm font-medium text-sky-600 hover:text-sky-700">Activate</button>
</form>
@endif
</li>
@endforeach
</ul>
@else
<p class="mt-3 text-sm text-slate-400">
@if ($consultation->diagnoses->isEmpty())
Save diagnoses to see pathway suggestions.
@else
No pathway matches for saved diagnoses.
@endif
</p>
@endif
</section>
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Clinical assessments</h2>
<a href="{{ route('care.assessments.index', $consultation->patient) }}" class="text-sm text-sky-600 hover:text-sky-700">Patient assessments</a>
</div>
@if (($consultationAssessments ?? collect())->isNotEmpty())
<ul class="mt-4 space-y-2 text-sm">
@foreach ($consultationAssessments as $item)
<li>
<a href="{{ route('care.assessments.show', $item) }}" class="text-sky-600 hover:text-sky-700">
{{ $item->template->name }}
</a>
· {{ ($assessmentStatuses[$item->status] ?? $item->status) }}
</li>
@endforeach
</ul>
@else
<p class="mt-4 text-sm text-slate-400">No assessments linked to this consultation yet.</p>
@endif
@if (($canCaptureAssessment ?? false) && ! $isCompleted && ($startableTemplates ?? collect())->isNotEmpty())
<form method="POST" action="{{ route('care.consultations.assessments.store', $consultation) }}" class="mt-4 flex flex-wrap items-end gap-3 border-t border-slate-100 pt-4">
@csrf
<div class="min-w-[12rem] flex-1">
<label class="block text-xs text-slate-500">Start other assessment</label>
<select name="template_code" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">Select template…</option>
@foreach ($startableTemplates as $template)
<option value="{{ $template->code }}">{{ $template->name }} ({{ $template->code }})</option>
@endforeach
</select>
</div>
<button type="submit" class="btn-primary">Start</button>
</form>
@endif
</section>
@endif
@if (! $isCompleted && ($canManage || $canVitals))
<form method="POST" action="{{ route('care.consultations.update', $consultation) }}" enctype="multipart/form-data" class="mt-6 space-y-6">
@csrf @method('PUT')
@@ -135,7 +328,12 @@
@if ($canManage)
<section class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Clinical notes</h2>
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Clinical notes (narrative)</h2>
@if ($canViewAssessments ?? false)
<p class="mt-1 text-xs text-slate-400">
Narrative source of truth for this encounter. Structured universal intake is a separate overlay above and is not auto-synced with these fields.
</p>
@endif
<div class="mt-4 space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700">Symptoms</label>
@@ -188,8 +386,13 @@
</section>
@endif
<div class="flex gap-3">
<div class="flex flex-wrap gap-3">
<button type="submit" class="btn-primary">Save</button>
@if ($canManage && ($canViewAssessments ?? false))
<button type="submit" name="suggest_pathways" value="1" class="rounded-lg border border-sky-200 bg-sky-50 px-4 py-2 text-sm font-medium text-sky-800 hover:bg-sky-100">
Save diagnoses &amp; suggest pathways
</button>
@endif
<a href="{{ route('care.queue.index') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600">Back to queue</a>
</div>
</form>
@@ -211,7 +414,7 @@
@if ($consultation->symptoms || $consultation->clinical_notes)
<section class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Clinical notes</h2>
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Clinical notes (narrative)</h2>
@if ($consultation->symptoms)
<p class="mt-4 text-sm"><span class="font-medium text-slate-700">Symptoms:</span> {{ $consultation->symptoms }}</p>
@endif
@@ -221,6 +424,34 @@
</section>
@endif
@if (($canViewAssessments ?? false) && ($universalAssessment ?? null)?->isCompleted())
<section class="rounded-2xl border border-sky-100 bg-white p-6">
<div class="flex items-center justify-between gap-2">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Universal intake (structured)</h2>
<a href="{{ route('care.assessments.show', $universalAssessment) }}" class="text-sm text-sky-600 hover:text-sky-700">Open</a>
</div>
<dl class="mt-4 space-y-2 text-sm">
@foreach ($universalAssessment->answers->sortBy(fn ($a) => $a->question?->sort_order ?? 0) as $answer)
@if ($answer->question)
<div class="grid gap-1 sm:grid-cols-3">
<dt class="text-slate-500">{{ $answer->question->label }}</dt>
<dd class="sm:col-span-2 font-medium text-slate-800">
@php $v = $answer->authoritativeValue(); @endphp
@if (is_array($v))
{{ implode(', ', $v) }}
@elseif (is_bool($v))
{{ $v ? 'Yes' : 'No' }}
@else
{{ $v ?? '—' }}
@endif
</dd>
</div>
@endif
@endforeach
</dl>
</section>
@endif
@if ($consultation->diagnoses->isNotEmpty())
<section class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Diagnoses</h2>
@@ -0,0 +1,153 @@
<x-app-layout :title="'Outcomes · '.$patient->fullName()">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Longitudinal outcomes</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ $patient->fullName() }}</h1>
<p class="mt-1 text-sm text-slate-500">
<a href="{{ route('care.patients.show', $patient) }}" class="text-sky-600 hover:text-sky-700">{{ $patient->patient_number }}</a>
·
<a href="{{ route('care.assessments.index', $patient) }}" class="text-sky-600 hover:text-sky-700">Assessments</a>
·
<a href="{{ route('care.pathways.index', $patient) }}" class="text-sky-600 hover:text-sky-700">Pathways</a>
</p>
</div>
@if ($canCapture)
<form method="POST" action="{{ route('care.outcomes.store', $patient) }}">
@csrf
<button type="submit" class="btn-primary">Record outcomes</button>
</form>
@endif
</div>
<p class="mt-4 text-xs text-slate-400">
Patient-level outcome history is available on all plans. Weight and blood pressure come from consultation vitals (typed records), not the outcome form.
@unless ($canOrgAnalytics)
Org-wide multi-patient analytics require Enterprise.
@endunless
</p>
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<section class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Quality of life trend</h2>
@if (empty($qolSeries))
<p class="mt-3 text-sm text-slate-400">No QoL scores yet.</p>
@else
<ul class="mt-3 space-y-1 text-sm">
@foreach ($qolSeries as $point)
<li class="flex justify-between gap-2">
<span class="text-slate-500">{{ $point['date'] }}</span>
<span class="font-medium">{{ $point['value'] }}</span>
</li>
@endforeach
</ul>
@endif
</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">Pain score trend</h2>
@if (empty($painSeries))
<p class="mt-3 text-sm text-slate-400">No pain scores yet.</p>
@else
<ul class="mt-3 space-y-1 text-sm">
@foreach ($painSeries as $point)
<li class="flex justify-between gap-2">
<span class="text-slate-500">{{ $point['date'] }}</span>
<span class="font-medium">{{ $point['value'] }}</span>
</li>
@endforeach
</ul>
@endif
</section>
</div>
<section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Outcome assessments
</div>
<table class="min-w-full divide-y divide-slate-100 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
<tr>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">QoL</th>
<th class="px-4 py-3">Pain</th>
<th class="px-4 py-3">Falls</th>
<th class="px-4 py-3">Admissions</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($outcomeSeries as $row)
@php $a = $row['answers']; @endphp
<tr>
<td class="px-4 py-3">{{ $row['assessed_at']?->format('d M Y') ?? '—' }}</td>
<td class="px-4 py-3">{{ $a['quality_of_life'] ?? '—' }}</td>
<td class="px-4 py-3">{{ $a['pain_score'] ?? '—' }}</td>
<td class="px-4 py-3">{{ $a['falls_count'] ?? '—' }}</td>
<td class="px-4 py-3">{{ $a['hospital_admissions_since_last'] ?? '—' }}</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('care.assessments.show', $row['assessment']) }}" class="text-sky-600 hover:text-sky-700">View</a>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-8 text-center text-slate-400">No completed outcome assessments yet.</td>
</tr>
@endforelse
</tbody>
</table>
</section>
<section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Instrument scores
</div>
<table class="min-w-full divide-y divide-slate-100 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
<tr>
<th class="px-4 py-3">Instrument</th>
<th class="px-4 py-3">Total</th>
<th class="px-4 py-3">Max</th>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($scores as $score)
<tr>
<td class="px-4 py-3 font-medium">{{ $score->template_code }}</td>
<td class="px-4 py-3">{{ $score->total_score }}</td>
<td class="px-4 py-3">{{ $score->max_score ?? '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $score->computed_at?->format('d M Y H:i') }}</td>
<td class="px-4 py-3 text-right">
@if ($score->assessment)
<a href="{{ route('care.assessments.show', $score->assessment) }}" class="text-sky-600 hover:text-sky-700">Open</a>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-8 text-center text-slate-400">No scored instruments yet (e.g. NIHSS, mRS, Barthel, GCS).</td>
</tr>
@endforelse
</tbody>
</table>
</section>
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Recent vitals (typed)</h2>
@if ($vitals->isEmpty())
<p class="mt-3 text-sm text-slate-400">No vitals recorded on consultations.</p>
@else
<ul class="mt-3 space-y-2 text-sm">
@foreach ($vitals as $v)
<li class="text-slate-700">
{{ $v->recorded_at?->format('d M Y H:i') }}
· BP {{ $v->bp_systolic }}/{{ $v->bp_diastolic }}
· Wt {{ $v->weight_kg ?? '—' }} kg
· SpO₂ {{ $v->spo2 ? $v->spo2.'%' : '—' }}
</li>
@endforeach
</ul>
@endif
</section>
</x-app-layout>
@@ -0,0 +1,89 @@
<x-app-layout :title="'Pathways · '.$patient->fullName()">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Clinical pathways</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ $patient->fullName() }}</h1>
<p class="mt-1 text-sm text-slate-500">
<a href="{{ route('care.patients.show', $patient) }}" class="text-sky-600 hover:text-sky-700">{{ $patient->patient_number }}</a>
·
<a href="{{ route('care.assessments.index', $patient) }}" class="text-sky-600 hover:text-sky-700">Assessments</a>
</p>
</div>
</div>
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Active pathways</h2>
@if ($active->isEmpty())
<p class="mt-3 text-sm text-slate-400">No active pathways.</p>
@else
<ul class="mt-4 space-y-3 text-sm">
@foreach ($active as $row)
<li class="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-100 bg-slate-50 px-4 py-3">
<div>
<p class="font-medium text-slate-900">{{ $row->pathway->name }}</p>
<p class="text-xs text-slate-400">
Activated {{ $row->activated_at?->format('d M Y H:i') }}
@if ($row->activation_diagnosis_text)
· {{ \Illuminate\Support\Str::limit($row->activation_diagnosis_text, 80) }}
@endif
</p>
</div>
@if ($canManage)
<form method="POST" action="{{ route('care.pathways.deactivate', [$patient, $row]) }}">
@csrf
<button type="submit" class="text-sm text-red-600 hover:text-red-700">Deactivate</button>
</form>
@endif
</li>
@endforeach
</ul>
@endif
</section>
@if ($canManage && $catalog->isNotEmpty())
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Activate pathway</h2>
<form method="POST" action="{{ route('care.pathways.store', $patient) }}" class="mt-4 flex flex-wrap items-end gap-3">
@csrf
<div class="min-w-[14rem] flex-1">
<label class="block text-xs text-slate-500">Pathway</label>
<select name="pathway_code" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">Select…</option>
@foreach ($catalog as $pathway)
<option value="{{ $pathway->code }}">{{ $pathway->name }} ({{ $pathway->code }})</option>
@endforeach
</select>
</div>
<button type="submit" class="btn-primary">Activate</button>
</form>
<p class="mt-2 text-xs text-slate-400">Required disease assessments are drafted when their content packs are seeded.</p>
</section>
@endif
<section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide text-slate-500">History</div>
<table class="min-w-full divide-y divide-slate-100 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
<tr>
<th class="px-4 py-3">Pathway</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Activated</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($history as $row)
<tr>
<td class="px-4 py-3 font-medium">{{ $row->pathway?->name ?? '—' }}</td>
<td class="px-4 py-3">{{ $statuses[$row->status] ?? $row->status }}</td>
<td class="px-4 py-3 text-slate-600">{{ $row->activated_at?->format('d M Y H:i') ?? '—' }}</td>
</tr>
@empty
<tr><td colspan="3" class="px-4 py-8 text-center text-slate-400">No pathway history.</td></tr>
@endforelse
</tbody>
</table>
@if ($history->hasPages())
<div class="border-t border-slate-100 px-4 py-3">{{ $history->links() }}</div>
@endif
</section>
</x-app-layout>
@@ -137,6 +137,51 @@
<p class="mt-1 text-slate-400">No prescriptions yet</p>
@endforelse
</div>
@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">Universal intake</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
@if ($canViewAssessments ?? false)
<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">Clinical pathways</a>
<a href="{{ route('care.outcomes.index', $patient) }}" class="text-sm font-medium text-sky-600 hover:text-sky-700">Outcomes &amp; trends</a>
</p>
@endif
</div>
@endif
</div>
</section>
</div>
+106 -19
View File
@@ -26,27 +26,114 @@
</select>
<button type="submit" class="btn-primary">Apply</button>
</form>
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<section class="mt-6 space-y-6">
@if ($type === 'clinical' && isset($data['diagnoses']))
<table class="min-w-full text-sm">
<thead><tr><th class="py-2 text-left">Diagnosis</th><th class="py-2 text-right">Count</th></tr></thead>
<tbody>
@forelse ($data['diagnoses'] as $row)
<tr class="border-t border-slate-50"><td class="py-2">{{ $row->description }}</td><td class="py-2 text-right">{{ $row->total }}</td></tr>
@empty
<tr><td colspan="2" class="py-4 text-slate-500">No diagnoses in period.</td></tr>
@endforelse
</tbody>
</table>
<div class="rounded-2xl border border-slate-200 bg-white p-6">
<table class="min-w-full text-sm">
<thead><tr><th class="py-2 text-left">Diagnosis</th><th class="py-2 text-right">Count</th></tr></thead>
<tbody>
@forelse ($data['diagnoses'] as $row)
<tr class="border-t border-slate-50"><td class="py-2">{{ $row->description }}</td><td class="py-2 text-right">{{ $row->total }}</td></tr>
@empty
<tr><td colspan="2" class="py-4 text-slate-500">No diagnoses in period.</td></tr>
@endforelse
</tbody>
</table>
</div>
@elseif ($type === 'assessments')
<div class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Summary</h2>
<dl class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($data['summary'] ?? [] as $key => $value)
<div class="rounded-xl border border-slate-100 bg-slate-50 p-4">
<dt class="text-xs uppercase text-slate-500">{{ str_replace('_', ' ', $key) }}</dt>
<dd class="mt-1 text-2xl font-semibold text-slate-900">{{ $value }}</dd>
</div>
@endforeach
</dl>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">By template</h2>
<table class="mt-4 min-w-full text-sm">
<thead>
<tr>
<th class="py-2 text-left">Template</th>
<th class="py-2 text-right">Started</th>
<th class="py-2 text-right">Completed</th>
</tr>
</thead>
<tbody>
@forelse ($data['by_template'] ?? [] as $row)
<tr class="border-t border-slate-50">
<td class="py-2">{{ $row->template_name }} <span class="text-slate-400">({{ $row->template_code }})</span></td>
<td class="py-2 text-right">{{ $row->total }}</td>
<td class="py-2 text-right">{{ $row->completed }}</td>
</tr>
@empty
<tr><td colspan="3" class="py-4 text-slate-500">No assessments in period.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Pathway activations</h2>
<table class="mt-4 min-w-full text-sm">
<thead>
<tr>
<th class="py-2 text-left">Pathway</th>
<th class="py-2 text-right">Activations</th>
<th class="py-2 text-right">Still active</th>
</tr>
</thead>
<tbody>
@forelse ($data['by_pathway'] ?? [] as $row)
<tr class="border-t border-slate-50">
<td class="py-2">{{ $row->pathway_name }}</td>
<td class="py-2 text-right">{{ $row->total }}</td>
<td class="py-2 text-right">{{ $row->still_active }}</td>
</tr>
@empty
<tr><td colspan="3" class="py-4 text-slate-500">No pathway activations in period.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Average scores</h2>
<table class="mt-4 min-w-full text-sm">
<thead>
<tr>
<th class="py-2 text-left">Instrument</th>
<th class="py-2 text-right">Completions</th>
<th class="py-2 text-right">Avg total</th>
<th class="py-2 text-right">Avg max</th>
</tr>
</thead>
<tbody>
@forelse ($data['score_averages'] ?? [] as $row)
<tr class="border-t border-slate-50">
<td class="py-2 font-mono text-xs">{{ $row->template_code }}</td>
<td class="py-2 text-right">{{ $row->completions }}</td>
<td class="py-2 text-right">{{ $row->avg_total !== null ? round((float) $row->avg_total, 2) : '—' }}</td>
<td class="py-2 text-right">{{ $row->avg_max !== null ? round((float) $row->avg_max, 2) : '—' }}</td>
</tr>
@empty
<tr><td colspan="4" class="py-4 text-slate-500">No scored completions in period.</td></tr>
@endforelse
</tbody>
</table>
</div>
@else
<dl class="grid gap-4 sm:grid-cols-2">
@foreach ($data as $key => $value)
<div class="rounded-xl border border-slate-100 bg-slate-50 p-4">
<dt class="text-xs uppercase text-slate-500">{{ str_replace('_', ' ', $key) }}</dt>
<dd class="mt-1 text-2xl font-semibold text-slate-900">{{ $formatValue($key, $value) }}</dd>
</div>
@endforeach
</dl>
<div class="rounded-2xl border border-slate-200 bg-white p-6">
<dl class="grid gap-4 sm:grid-cols-2">
@foreach ($data as $key => $value)
<div class="rounded-xl border border-slate-100 bg-slate-50 p-4">
<dt class="text-xs uppercase text-slate-500">{{ str_replace('_', ' ', $key) }}</dt>
<dd class="mt-1 text-2xl font-semibold text-slate-900">{{ $formatValue($key, $value) }}</dd>
</div>
@endforeach
</dl>
</div>
@endif
</section>
</x-app-layout>