feat(assessments): layered clinical assessment engine end-to-end
Deploy Ladill Care / deploy (push) Successful in 1m26s
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:
@@ -71,6 +71,22 @@ curl -s -o /dev/null -w '%{http_code}\n' https://care.ladill.com/login
|
|||||||
curl -s https://care.ladill.com/api/health
|
curl -s https://care.ladill.com/api/health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Clinical assessment content packs
|
||||||
|
|
||||||
|
After migrate (and on each release that adds packs under `database/data/assessments/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/ladill-care/current
|
||||||
|
php artisan db:seed --class=Database\\Seeders\\AssessmentTemplateSeeder --force
|
||||||
|
php artisan db:seed --class=Database\\Seeders\\ClinicalPathwaySeeder --force
|
||||||
|
# or full: php artisan db:seed --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Packs are platform-global (no per-tenant copy). Enable the engine per organization via
|
||||||
|
`settings.rollout.assessments_engine = true` (see design doc / `CareFeatures`).
|
||||||
|
Pathway content lives under `database/data/pathways/`; assessment instruments under
|
||||||
|
`database/data/assessments/`.
|
||||||
|
|
||||||
## 7. Optional queue worker
|
## 7. Optional queue worker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Contracts\Care;
|
||||||
|
|
||||||
|
use App\Models\Assessment;
|
||||||
|
|
||||||
|
interface ScoresAssessment
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{total: ?float, max: ?float, subscores: array<string, float|int>, severity_label: ?string}
|
||||||
|
*/
|
||||||
|
public function score(Assessment $assessment): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\AssessmentService;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
|
use App\Services\Care\FhirAssessmentExporter;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AssessmentController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected AssessmentService $assessments,
|
||||||
|
protected CareFeatures $features,
|
||||||
|
protected CarePermissions $permissions,
|
||||||
|
protected FhirAssessmentExporter $fhir,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request, Patient $patient): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'assessments.view');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
$page = $this->assessments->listForPatient(
|
||||||
|
$patient,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$request->only(['status', 'template_code', 'category', 'from', 'to', 'per_page']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($page);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function templates(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'assessments.view');
|
||||||
|
|
||||||
|
$templates = AssessmentTemplate::query()
|
||||||
|
->system()
|
||||||
|
->current()
|
||||||
|
->with('questions')
|
||||||
|
->orderBy('category')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json(['data' => $templates]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Patient $patient): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeCapture($request);
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'template_code' => ['required', 'string', 'max:100'],
|
||||||
|
'consultation_uuid' => ['nullable', 'uuid'],
|
||||||
|
'visit_uuid' => ['nullable', 'uuid'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$context = $this->resolveContext($request, $patient, $validated);
|
||||||
|
$assessment = $this->assessments->start(
|
||||||
|
$patient,
|
||||||
|
$validated['template_code'],
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
$context,
|
||||||
|
);
|
||||||
|
|
||||||
|
$status = $assessment->wasRecentlyCreated ? 201 : 200;
|
||||||
|
|
||||||
|
return response()->json($this->serializeAssessment($assessment), $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeForConsultation(Request $request, Consultation $consultation): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeCapture($request);
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
$consultation->loadMissing(['patient', 'visit']);
|
||||||
|
$this->authorizePatient($request, $consultation->patient);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'template_code' => ['required', 'string', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assessment = $this->assessments->start(
|
||||||
|
$consultation->patient,
|
||||||
|
$validated['template_code'],
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
[
|
||||||
|
'consultation' => $consultation,
|
||||||
|
'visit' => $consultation->visit,
|
||||||
|
'actor' => $this->ownerRef($request),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($this->serializeAssessment($assessment), $assessment->wasRecentlyCreated ? 201 : 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Assessment $assessment): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'assessments.view');
|
||||||
|
$this->authorizeAssessment($request, $assessment);
|
||||||
|
|
||||||
|
return response()->json($this->serializeAssessment(
|
||||||
|
$assessment->load(['template.questions', 'answers.question', 'score', 'patient', 'consultation', 'visit'])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Assessment $assessment): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeCapture($request);
|
||||||
|
$this->authorizeAssessment($request, $assessment);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'answers' => ['nullable', 'array'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:10000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updated = $this->assessments->saveAnswers(
|
||||||
|
$assessment,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated['answers'] ?? [],
|
||||||
|
$this->member($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
array_key_exists('notes', $validated) ? $validated['notes'] : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($this->serializeAssessment($updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(Request $request, Assessment $assessment): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeCapture($request);
|
||||||
|
$this->authorizeAssessment($request, $assessment);
|
||||||
|
|
||||||
|
if ($request->has('answers') || $request->has('notes')) {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'answers' => ['nullable', 'array'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:10000'],
|
||||||
|
]);
|
||||||
|
$this->assessments->saveAnswers(
|
||||||
|
$assessment,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated['answers'] ?? [],
|
||||||
|
$this->member($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
array_key_exists('notes', $validated) ? $validated['notes'] : null,
|
||||||
|
);
|
||||||
|
$assessment->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
$completed = $this->assessments->complete(
|
||||||
|
$assessment,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($this->serializeAssessment($completed));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Request $request, Assessment $assessment): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeCapture($request);
|
||||||
|
$this->authorizeAssessment($request, $assessment);
|
||||||
|
|
||||||
|
$cancelled = $this->assessments->cancel(
|
||||||
|
$assessment,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($this->serializeAssessment($cancelled));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fhir(Request $request, Assessment $assessment): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'assessments.view');
|
||||||
|
$this->authorizeAssessment($request, $assessment);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
$this->fhir->exportBundle($assessment),
|
||||||
|
200,
|
||||||
|
['Content-Type' => 'application/fhir+json'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function serializeAssessment(Assessment $assessment): array
|
||||||
|
{
|
||||||
|
$assessment->loadMissing(['template', 'answers.question', 'score', 'patient']);
|
||||||
|
|
||||||
|
$answers = [];
|
||||||
|
foreach ($assessment->answers as $answer) {
|
||||||
|
if ($answer->question) {
|
||||||
|
$answers[$answer->question->code] = $answer->authoritativeValue($answer->question->answer_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'uuid' => $assessment->uuid,
|
||||||
|
'status' => $assessment->status,
|
||||||
|
'template_code' => $assessment->template?->code,
|
||||||
|
'template_name' => $assessment->template?->name,
|
||||||
|
'template_version' => $assessment->template?->version,
|
||||||
|
'patient_uuid' => $assessment->patient?->uuid,
|
||||||
|
'consultation_uuid' => $assessment->consultation?->uuid,
|
||||||
|
'visit_uuid' => $assessment->visit?->uuid,
|
||||||
|
'assessed_at' => $assessment->assessed_at?->toIso8601String(),
|
||||||
|
'completed_at' => $assessment->completed_at?->toIso8601String(),
|
||||||
|
'notes' => $assessment->notes,
|
||||||
|
'answers' => $answers,
|
||||||
|
'score' => $assessment->score ? [
|
||||||
|
'total_score' => $assessment->score->total_score,
|
||||||
|
'max_score' => $assessment->score->max_score,
|
||||||
|
'subscores' => $assessment->score->subscores,
|
||||||
|
'severity_label' => $assessment->score->severity_label,
|
||||||
|
'computed_at' => $assessment->score->computed_at?->toIso8601String(),
|
||||||
|
] : null,
|
||||||
|
'template' => $assessment->template,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertEngineEnabled(Request $request): void
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
$this->features->enabled($this->organization($request), CareFeatures::ASSESSMENTS_ENGINE),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeCapture(Request $request): void
|
||||||
|
{
|
||||||
|
$member = $this->member($request);
|
||||||
|
abort_unless(
|
||||||
|
$this->permissions->can($member, 'assessments.capture')
|
||||||
|
|| $this->permissions->can($member, 'assessments.manage'),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizePatient(Request $request, Patient $patient): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $patient);
|
||||||
|
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $patient->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $consultation);
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $consultation->visit->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAssessment(Request $request, Assessment $assessment): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $assessment);
|
||||||
|
abort_unless($assessment->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $assessment->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{consultation_uuid?: ?string, visit_uuid?: ?string} $validated
|
||||||
|
* @return array{consultation?: Consultation, visit?: Visit, actor: string}
|
||||||
|
*/
|
||||||
|
protected function resolveContext(Request $request, Patient $patient, array $validated): array
|
||||||
|
{
|
||||||
|
$context = ['actor' => $this->ownerRef($request)];
|
||||||
|
|
||||||
|
if (! empty($validated['consultation_uuid'])) {
|
||||||
|
$consultation = Consultation::query()
|
||||||
|
->where('uuid', $validated['consultation_uuid'])
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->firstOrFail();
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
$context['consultation'] = $consultation;
|
||||||
|
$context['visit'] = $consultation->visit;
|
||||||
|
} elseif (! empty($validated['visit_uuid'])) {
|
||||||
|
$visit = Visit::query()
|
||||||
|
->where('uuid', $validated['visit_uuid'])
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->firstOrFail();
|
||||||
|
$this->authorizeOwner($request, $visit);
|
||||||
|
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $visit->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
$context['visit'] = $visit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ClinicalPathway;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\PatientPathway;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\PathwayService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PathwayController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected PathwayService $pathways,
|
||||||
|
protected CareFeatures $features,
|
||||||
|
protected CarePermissions $permissions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function catalog(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'assessments.view');
|
||||||
|
|
||||||
|
$pathways = ClinicalPathway::query()
|
||||||
|
->active()
|
||||||
|
->with('templates')
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json(['data' => $pathways]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request, Patient $patient): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'assessments.view');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$active = $this->pathways->activeFor($patient)->load('pathway.templates');
|
||||||
|
$history = PatientPathway::query()
|
||||||
|
->owned($this->ownerRef($request))
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->with('pathway')
|
||||||
|
->orderByDesc('activated_at')
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'active' => $active->map(fn (PatientPathway $pp) => $this->serializePatientPathway($pp)),
|
||||||
|
'history' => $history->map(fn (PatientPathway $pp) => $this->serializePatientPathway($pp)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Patient $patient): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'pathways.manage');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'pathway_code' => ['required', 'string', 'max:100'],
|
||||||
|
'consultation_uuid' => ['nullable', 'uuid'],
|
||||||
|
'activation_diagnosis_text' => ['nullable', 'string', 'max:500'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pathway = ClinicalPathway::findByCode($validated['pathway_code']);
|
||||||
|
abort_unless($pathway, 422, 'Unknown or inactive pathway.');
|
||||||
|
|
||||||
|
$consultation = null;
|
||||||
|
if (! empty($validated['consultation_uuid'])) {
|
||||||
|
$consultation = Consultation::query()
|
||||||
|
->where('uuid', $validated['consultation_uuid'])
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->firstOrFail();
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
$consultation->loadMissing(['visit', 'diagnoses']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$patientPathway = $this->pathways->activate(
|
||||||
|
$patient,
|
||||||
|
$pathway,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
[
|
||||||
|
'consultation' => $consultation,
|
||||||
|
'activation_diagnosis_text' => $validated['activation_diagnosis_text'] ?? null,
|
||||||
|
'actor' => $this->ownerRef($request),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($this->serializePatientPathway($patientPathway->load(['pathway.templates', 'assessments.template'])), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deactivate(Request $request, Patient $patient, PatientPathway $patientPathway): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'pathways.manage');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
abort_unless($patientPathway->patient_id === $patient->id, 404);
|
||||||
|
$this->authorizeOwner($request, $patientPathway);
|
||||||
|
|
||||||
|
$updated = $this->pathways->deactivate(
|
||||||
|
$patientPathway,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($this->serializePatientPathway($updated->load('pathway')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function suggestions(Request $request, Consultation $consultation): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'consultations.view');
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
$consultation->loadMissing(['diagnoses', 'patient']);
|
||||||
|
$suggestions = $this->pathways->suggest($consultation->patient, $consultation->diagnoses);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $suggestions->map(fn (array $row) => [
|
||||||
|
'pathway_code' => $row['pathway_code'],
|
||||||
|
'pathway_name' => $row['pathway_name'],
|
||||||
|
'rank' => $row['rank'],
|
||||||
|
'match_reason' => $row['match_reason'],
|
||||||
|
'already_active' => $row['already_active'],
|
||||||
|
])->values()->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function serializePatientPathway(PatientPathway $pp): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $pp->uuid,
|
||||||
|
'status' => $pp->status,
|
||||||
|
'pathway_code' => $pp->pathway?->code,
|
||||||
|
'pathway_name' => $pp->pathway?->name,
|
||||||
|
'activated_at' => $pp->activated_at?->toIso8601String(),
|
||||||
|
'activation_diagnosis_text' => $pp->activation_diagnosis_text,
|
||||||
|
'resolved_at' => $pp->resolved_at?->toIso8601String(),
|
||||||
|
'assessments' => $pp->relationLoaded('assessments')
|
||||||
|
? $pp->assessments->map(fn ($a) => [
|
||||||
|
'uuid' => $a->uuid,
|
||||||
|
'status' => $a->status,
|
||||||
|
'template_code' => $a->template?->code,
|
||||||
|
])->values()->all()
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertEngineEnabled(Request $request): void
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
$this->features->enabled($this->organization($request), CareFeatures::ASSESSMENTS_ENGINE),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizePatient(Request $request, Patient $patient): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $patient);
|
||||||
|
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $patient->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $consultation);
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $consultation->visit->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\AssessmentService;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
|
use App\Services\Care\FhirAssessmentExporter;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class AssessmentController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected AssessmentService $assessments,
|
||||||
|
protected CareFeatures $features,
|
||||||
|
protected CarePermissions $permissions,
|
||||||
|
protected FhirAssessmentExporter $fhir,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request, Patient $patient): View
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'assessments.view');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
$assessments = $this->assessments->listForPatient(
|
||||||
|
$patient,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$request->only(['status', 'template_code', 'category', 'from', 'to', 'per_page']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
$canCapture = $this->canCapture($request);
|
||||||
|
|
||||||
|
return view('care.assessments.index', [
|
||||||
|
'patient' => $patient,
|
||||||
|
'assessments' => $assessments,
|
||||||
|
'canCapture' => $canCapture,
|
||||||
|
'statuses' => config('care.assessment_statuses'),
|
||||||
|
'categories' => config('care.assessment_template_categories'),
|
||||||
|
'filters' => $request->only(['status', 'template_code', 'category']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request, Patient $patient): View
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeCapture($request);
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$templates = AssessmentTemplate::query()
|
||||||
|
->system()
|
||||||
|
->current()
|
||||||
|
->orderBy('category')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$member = $this->member($request);
|
||||||
|
$capturable = $templates->filter(function (AssessmentTemplate $template) use ($member) {
|
||||||
|
try {
|
||||||
|
$this->assessments->assertCaptureAllowed($member, $template);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
return view('care.assessments.create', [
|
||||||
|
'patient' => $patient,
|
||||||
|
'templates' => $capturable,
|
||||||
|
'categories' => config('care.assessment_template_categories'),
|
||||||
|
'consultationUuid' => $request->query('consultation_uuid'),
|
||||||
|
'visitUuid' => $request->query('visit_uuid'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Patient $patient): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeCapture($request);
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'template_code' => ['required', 'string', 'max:100'],
|
||||||
|
'consultation_uuid' => ['nullable', 'uuid'],
|
||||||
|
'visit_uuid' => ['nullable', 'uuid'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$context = $this->resolveContext($request, $patient, $validated);
|
||||||
|
$assessment = $this->assessments->start(
|
||||||
|
$patient,
|
||||||
|
$validated['template_code'],
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
$context,
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.assessments.show', $assessment)
|
||||||
|
->with('success', 'Assessment started.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeForConsultation(Request $request, Consultation $consultation): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeCapture($request);
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'template_code' => ['required', 'string', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$consultation->loadMissing(['patient', 'visit']);
|
||||||
|
$this->authorizePatient($request, $consultation->patient);
|
||||||
|
|
||||||
|
$assessment = $this->assessments->start(
|
||||||
|
$consultation->patient,
|
||||||
|
$validated['template_code'],
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
[
|
||||||
|
'consultation' => $consultation,
|
||||||
|
'visit' => $consultation->visit,
|
||||||
|
'actor' => $this->ownerRef($request),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.assessments.show', $assessment)
|
||||||
|
->with('success', 'Assessment started.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Assessment $assessment): View
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'assessments.view');
|
||||||
|
$this->authorizeAssessment($request, $assessment);
|
||||||
|
|
||||||
|
$assessment->load([
|
||||||
|
'template.questions',
|
||||||
|
'answers.question',
|
||||||
|
'patient',
|
||||||
|
'consultation',
|
||||||
|
'visit',
|
||||||
|
'practitioner',
|
||||||
|
'score',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$answersByCode = $assessment->answers
|
||||||
|
->mapWithKeys(fn ($answer) => [
|
||||||
|
$answer->question->code => $answer->authoritativeValue($answer->question->answer_type),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$canEdit = false;
|
||||||
|
if ($assessment->isDraft() && $this->canCapture($request)) {
|
||||||
|
try {
|
||||||
|
$this->assessments->assertCaptureAllowed($this->member($request), $assessment->template);
|
||||||
|
$canEdit = true;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$canEdit = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('care.assessments.show', [
|
||||||
|
'assessment' => $assessment,
|
||||||
|
'answersByCode' => $answersByCode,
|
||||||
|
'canEdit' => $canEdit,
|
||||||
|
'statuses' => config('care.assessment_statuses'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Assessment $assessment): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeCapture($request);
|
||||||
|
$this->authorizeAssessment($request, $assessment);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'answers' => ['nullable', 'array'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:10000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assessments->saveAnswers(
|
||||||
|
$assessment,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated['answers'] ?? [],
|
||||||
|
$this->member($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
array_key_exists('notes', $validated) ? $validated['notes'] : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.assessments.show', $assessment)
|
||||||
|
->with('success', 'Assessment saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(Request $request, Assessment $assessment): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeCapture($request);
|
||||||
|
$this->authorizeAssessment($request, $assessment);
|
||||||
|
|
||||||
|
// Persist any posted answers first (complete form can include them).
|
||||||
|
if ($request->has('answers') || $request->has('notes')) {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'answers' => ['nullable', 'array'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:10000'],
|
||||||
|
]);
|
||||||
|
$this->assessments->saveAnswers(
|
||||||
|
$assessment,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated['answers'] ?? [],
|
||||||
|
$this->member($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
array_key_exists('notes', $validated) ? $validated['notes'] : null,
|
||||||
|
);
|
||||||
|
$assessment->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assessments->complete(
|
||||||
|
$assessment,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.assessments.show', $assessment)
|
||||||
|
->with('success', 'Assessment completed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Request $request, Assessment $assessment): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeCapture($request);
|
||||||
|
$this->authorizeAssessment($request, $assessment);
|
||||||
|
|
||||||
|
$this->assessments->cancel(
|
||||||
|
$assessment,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.assessments.index', $assessment->patient)
|
||||||
|
->with('success', 'Assessment cancelled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FHIR R4 Bundle (Questionnaire + QuestionnaireResponse) download.
|
||||||
|
*/
|
||||||
|
public function fhirExport(Request $request, Assessment $assessment): StreamedResponse|JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'assessments.view');
|
||||||
|
$this->authorizeAssessment($request, $assessment);
|
||||||
|
|
||||||
|
$bundle = $this->fhir->exportBundle($assessment);
|
||||||
|
$filename = 'assessment-'.$assessment->uuid.'-fhir.json';
|
||||||
|
|
||||||
|
if ($request->wantsJson() && ! $request->boolean('download')) {
|
||||||
|
return response()->json($bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->streamDownload(
|
||||||
|
function () use ($bundle) {
|
||||||
|
echo json_encode($bundle, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
},
|
||||||
|
$filename,
|
||||||
|
['Content-Type' => 'application/fhir+json'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertEngineEnabled(Request $request): void
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
$this->features->enabled($this->organization($request), CareFeatures::ASSESSMENTS_ENGINE),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeCapture(Request $request): void
|
||||||
|
{
|
||||||
|
abort_unless($this->canCapture($request), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function canCapture(Request $request): bool
|
||||||
|
{
|
||||||
|
$member = $this->member($request);
|
||||||
|
|
||||||
|
return $this->permissions->can($member, 'assessments.capture')
|
||||||
|
|| $this->permissions->can($member, 'assessments.manage');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizePatient(Request $request, Patient $patient): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $patient);
|
||||||
|
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $patient->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $consultation);
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $consultation->visit->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAssessment(Request $request, Assessment $assessment): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $assessment);
|
||||||
|
abort_unless($assessment->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $assessment->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{consultation_uuid?: ?string, visit_uuid?: ?string} $validated
|
||||||
|
* @return array{consultation?: Consultation, visit?: Visit, actor: string}
|
||||||
|
*/
|
||||||
|
protected function resolveContext(Request $request, Patient $patient, array $validated): array
|
||||||
|
{
|
||||||
|
$context = ['actor' => $this->ownerRef($request)];
|
||||||
|
|
||||||
|
if (! empty($validated['consultation_uuid'])) {
|
||||||
|
$consultation = Consultation::query()
|
||||||
|
->where('uuid', $validated['consultation_uuid'])
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->firstOrFail();
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
$context['consultation'] = $consultation;
|
||||||
|
$context['visit'] = $consultation->visit;
|
||||||
|
} elseif (! empty($validated['visit_uuid'])) {
|
||||||
|
$visit = Visit::query()
|
||||||
|
->where('uuid', $validated['visit_uuid'])
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->firstOrFail();
|
||||||
|
$this->authorizeOwner($request, $visit);
|
||||||
|
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $visit->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
$context['visit'] = $visit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,13 @@ use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
|||||||
use App\Models\Consultation;
|
use App\Models\Consultation;
|
||||||
use App\Models\InvestigationType;
|
use App\Models\InvestigationType;
|
||||||
use App\Models\Practitioner;
|
use App\Models\Practitioner;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
use App\Services\Care\ConsultationService;
|
use App\Services\Care\ConsultationService;
|
||||||
use App\Services\Care\OrganizationResolver;
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\PathwayService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
@@ -32,25 +37,127 @@ class ConsultationController extends Controller
|
|||||||
'investigationRequests.investigationType', 'prescriptions.items',
|
'investigationRequests.investigationType', 'prescriptions.items',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$canManage = app(\App\Services\Care\CarePermissions::class)
|
$permissions = app(CarePermissions::class);
|
||||||
->can($this->member($request), 'consultations.manage');
|
$member = $this->member($request);
|
||||||
$canVitals = app(\App\Services\Care\CarePermissions::class)
|
$organization = $this->organization($request);
|
||||||
->can($this->member($request), 'vitals.manage');
|
|
||||||
$canRequestInvestigations = app(\App\Services\Care\CarePermissions::class)
|
$canManage = $permissions->can($member, 'consultations.manage');
|
||||||
->can($this->member($request), 'investigations.request');
|
$canVitals = $permissions->can($member, 'vitals.manage');
|
||||||
$canPrescribe = app(\App\Services\Care\CarePermissions::class)
|
$canRequestInvestigations = $permissions->can($member, 'investigations.request');
|
||||||
->can($this->member($request), 'prescriptions.manage');
|
$canPrescribe = $permissions->can($member, 'prescriptions.manage');
|
||||||
$canGenerateBill = app(\App\Services\Care\CarePermissions::class)
|
$canGenerateBill = $permissions->can($member, 'bills.manage');
|
||||||
->can($this->member($request), 'bills.manage');
|
|
||||||
|
$assessmentsEnabled = app(CareFeatures::class)->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE);
|
||||||
|
$canViewAssessments = $assessmentsEnabled && $permissions->can($member, 'assessments.view');
|
||||||
|
$canCaptureAssessment = $assessmentsEnabled && (
|
||||||
|
$permissions->can($member, 'assessments.capture')
|
||||||
|
|| $permissions->can($member, 'assessments.manage')
|
||||||
|
);
|
||||||
|
|
||||||
|
$consultationAssessments = collect();
|
||||||
|
$startableTemplates = collect();
|
||||||
|
$universalTemplate = null;
|
||||||
|
$universalAssessment = null;
|
||||||
|
$canCaptureUniversal = false;
|
||||||
|
$canManagePathways = $assessmentsEnabled && $permissions->can($member, 'pathways.manage');
|
||||||
|
$pathwaySuggestions = collect();
|
||||||
|
$activePathways = collect();
|
||||||
|
|
||||||
|
if ($canViewAssessments) {
|
||||||
|
$consultationAssessments = Assessment::query()
|
||||||
|
->owned($this->ownerRef($request))
|
||||||
|
->where('consultation_id', $consultation->id)
|
||||||
|
->with(['template', 'answers.question'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$universalTemplate = AssessmentTemplate::currentSystemByCode('universal_intake');
|
||||||
|
if ($universalTemplate) {
|
||||||
|
$universalAssessment = $consultationAssessments
|
||||||
|
->first(fn (Assessment $a) => $a->template_id === $universalTemplate->id)
|
||||||
|
?? Assessment::query()
|
||||||
|
->owned($this->ownerRef($request))
|
||||||
|
->where('patient_id', $consultation->patient_id)
|
||||||
|
->where('template_id', $universalTemplate->id)
|
||||||
|
->whereIn('status', [Assessment::STATUS_DRAFT, Assessment::STATUS_COMPLETED])
|
||||||
|
->with(['template', 'answers.question'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($canCaptureAssessment) {
|
||||||
|
$startableTemplates = AssessmentTemplate::query()
|
||||||
|
->system()
|
||||||
|
->current()
|
||||||
|
->orderBy('category')
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
// Universal has a dedicated CTA; keep other templates in the general list.
|
||||||
|
->reject(fn (AssessmentTemplate $t) => $t->code === 'universal_intake')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($universalTemplate) {
|
||||||
|
try {
|
||||||
|
app(\App\Services\Care\AssessmentService::class)
|
||||||
|
->assertCaptureAllowed($member, $universalTemplate);
|
||||||
|
$canCaptureUniversal = true;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$canCaptureUniversal = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pathwayService = app(PathwayService::class);
|
||||||
|
$activePathways = $pathwayService->activeFor($consultation->patient)
|
||||||
|
->load(['pathway.templates', 'assessments.template', 'assessments.score']);
|
||||||
|
// Suggestions from **persisted** diagnoses only (after save).
|
||||||
|
$pathwaySuggestions = $pathwayService->suggest(
|
||||||
|
$consultation->patient,
|
||||||
|
$consultation->diagnoses,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Instruments for active pathways (e.g. stroke → NIHSS, mRS) with draft/complete status.
|
||||||
|
$pathwayInstruments = collect();
|
||||||
|
foreach ($activePathways as $patientPathway) {
|
||||||
|
foreach ($patientPathway->pathway->templates as $binding) {
|
||||||
|
$template = AssessmentTemplate::currentSystemByCode($binding->template_code);
|
||||||
|
$assessment = $patientPathway->assessments
|
||||||
|
->first(fn (Assessment $a) => $a->template?->code === $binding->template_code)
|
||||||
|
?? $consultationAssessments
|
||||||
|
->first(fn (Assessment $a) => $a->template?->code === $binding->template_code)
|
||||||
|
?? ($template
|
||||||
|
? Assessment::query()
|
||||||
|
->owned($this->ownerRef($request))
|
||||||
|
->where('patient_id', $consultation->patient_id)
|
||||||
|
->where('template_id', $template->id)
|
||||||
|
->with(['template', 'score'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->first()
|
||||||
|
: null);
|
||||||
|
|
||||||
|
$pathwayInstruments->push([
|
||||||
|
'pathway_code' => $patientPathway->pathway->code,
|
||||||
|
'pathway_name' => $patientPathway->pathway->name,
|
||||||
|
'template_code' => $binding->template_code,
|
||||||
|
'template_name' => $template?->name ?? $binding->template_code,
|
||||||
|
'required' => $binding->is_required_on_activation,
|
||||||
|
'phase' => $binding->phase,
|
||||||
|
'template' => $template,
|
||||||
|
'assessment' => $assessment,
|
||||||
|
'pack_missing' => $template === null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$practitioners = Practitioner::owned($this->ownerRef($request))
|
$practitioners = Practitioner::owned($this->ownerRef($request))
|
||||||
->where('organization_id', $this->organization($request)->id)
|
->where('organization_id', $organization->id)
|
||||||
->where('is_active', true)
|
->where('is_active', true)
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$investigationTypes = InvestigationType::owned($this->ownerRef($request))
|
$investigationTypes = InvestigationType::owned($this->ownerRef($request))
|
||||||
->where('organization_id', $this->organization($request)->id)
|
->where('organization_id', $organization->id)
|
||||||
->where('is_active', true)
|
->where('is_active', true)
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->get();
|
||||||
@@ -65,6 +172,19 @@ class ConsultationController extends Controller
|
|||||||
'canPrescribe' => $canPrescribe,
|
'canPrescribe' => $canPrescribe,
|
||||||
'canGenerateBill' => $canGenerateBill,
|
'canGenerateBill' => $canGenerateBill,
|
||||||
'isCompleted' => $consultation->status === Consultation::STATUS_COMPLETED,
|
'isCompleted' => $consultation->status === Consultation::STATUS_COMPLETED,
|
||||||
|
'assessmentsEnabled' => $assessmentsEnabled,
|
||||||
|
'canViewAssessments' => $canViewAssessments,
|
||||||
|
'canCaptureAssessment' => $canCaptureAssessment,
|
||||||
|
'consultationAssessments' => $consultationAssessments,
|
||||||
|
'startableTemplates' => $startableTemplates,
|
||||||
|
'assessmentStatuses' => config('care.assessment_statuses'),
|
||||||
|
'universalTemplate' => $universalTemplate,
|
||||||
|
'universalAssessment' => $universalAssessment,
|
||||||
|
'canCaptureUniversal' => $canCaptureUniversal,
|
||||||
|
'canManagePathways' => $canManagePathways,
|
||||||
|
'pathwaySuggestions' => $pathwaySuggestions,
|
||||||
|
'activePathways' => $activePathways,
|
||||||
|
'pathwayInstruments' => $pathwayInstruments ?? collect(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +208,16 @@ class ConsultationController extends Controller
|
|||||||
$this->ownerRef($request),
|
$this->ownerRef($request),
|
||||||
);
|
);
|
||||||
|
|
||||||
return back()->with('success', 'Consultation saved.');
|
$message = 'Consultation saved.';
|
||||||
|
if ($canManage && $request->boolean('suggest_pathways')) {
|
||||||
|
$consultation->load('diagnoses');
|
||||||
|
$count = $consultation->diagnoses->filter(fn ($d) => filled($d->description) || filled($d->code))->count();
|
||||||
|
$message = $count > 0
|
||||||
|
? 'Diagnoses saved — review pathway suggestions below.'
|
||||||
|
: 'Consultation saved. Add and save diagnoses to see pathway suggestions.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function complete(Request $request, Consultation $consultation): RedirectResponse
|
public function complete(Request $request, Consultation $consultation): RedirectResponse
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Services\Care\AssessmentService;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\OutcomeTrendService;
|
||||||
|
use App\Services\Care\PlanService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class OutcomeController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected OutcomeTrendService $trends,
|
||||||
|
protected AssessmentService $assessments,
|
||||||
|
protected CareFeatures $features,
|
||||||
|
protected CarePermissions $permissions,
|
||||||
|
protected PlanService $plans,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-patient outcome history + instrument score trends (free).
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Patient $patient): View
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'assessments.view');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
$ownerRef = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$outcomeSeries = $this->trends->outcomeSeries($patient, $ownerRef, $branchScope);
|
||||||
|
$scores = $this->trends->instrumentScores($patient, $ownerRef, $branchScope);
|
||||||
|
$vitals = $this->trends->recentVitals($patient, $ownerRef);
|
||||||
|
|
||||||
|
$canCapture = $this->permissions->can($this->member($request), 'assessments.capture')
|
||||||
|
|| $this->permissions->can($this->member($request), 'assessments.manage');
|
||||||
|
|
||||||
|
// Org-level multi-patient analytics is Enterprise (KD-18); surface a notice only.
|
||||||
|
$canOrgAnalytics = $this->plans->isEnterprise($this->organization($request));
|
||||||
|
|
||||||
|
return view('care.outcomes.index', [
|
||||||
|
'patient' => $patient,
|
||||||
|
'outcomeSeries' => $outcomeSeries,
|
||||||
|
'scores' => $scores,
|
||||||
|
'vitals' => $vitals,
|
||||||
|
'canCapture' => $canCapture,
|
||||||
|
'canOrgAnalytics' => $canOrgAnalytics,
|
||||||
|
'qolSeries' => $this->trends->numericOutcomeSeries($patient, $ownerRef, 'quality_of_life', $branchScope),
|
||||||
|
'painSeries' => $this->trends->numericOutcomeSeries($patient, $ownerRef, 'pain_score', $branchScope),
|
||||||
|
'statuses' => config('care.assessment_statuses'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new outcome_core draft for the patient.
|
||||||
|
*/
|
||||||
|
public function store(Request $request, Patient $patient): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->assertEngineEnabled($request);
|
||||||
|
abort_unless(
|
||||||
|
$this->permissions->can($this->member($request), 'assessments.capture')
|
||||||
|
|| $this->permissions->can($this->member($request), 'assessments.manage'),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$assessment = $this->assessments->start(
|
||||||
|
$patient,
|
||||||
|
OutcomeTrendService::OUTCOME_TEMPLATE_CODE,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
['actor' => $this->ownerRef($request)],
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.assessments.show', $assessment)
|
||||||
|
->with('success', 'Outcome assessment started.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertEngineEnabled(Request $request): void
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
$this->features->enabled($this->organization($request), CareFeatures::ASSESSMENTS_ENGINE),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizePatient(Request $request, Patient $patient): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $patient);
|
||||||
|
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $patient->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ClinicalPathway;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\PatientPathway;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\PathwayService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class PathwayController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected PathwayService $pathways,
|
||||||
|
protected CareFeatures $features,
|
||||||
|
protected CarePermissions $permissions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request, Patient $patient): View
|
||||||
|
{
|
||||||
|
$this->assertPathwaysEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'assessments.view');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$active = $this->pathways->activeFor($patient);
|
||||||
|
$history = PatientPathway::query()
|
||||||
|
->owned($this->ownerRef($request))
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->with('pathway')
|
||||||
|
->orderByDesc('activated_at')
|
||||||
|
->paginate(20);
|
||||||
|
|
||||||
|
$catalog = ClinicalPathway::query()->active()->orderBy('sort_order')->get();
|
||||||
|
$canManage = $this->permissions->can($this->member($request), 'pathways.manage');
|
||||||
|
|
||||||
|
return view('care.pathways.index', [
|
||||||
|
'patient' => $patient,
|
||||||
|
'active' => $active,
|
||||||
|
'history' => $history,
|
||||||
|
'catalog' => $catalog,
|
||||||
|
'canManage' => $canManage,
|
||||||
|
'statuses' => config('care.patient_pathway_statuses'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Patient $patient): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->assertPathwaysEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'pathways.manage');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'pathway_code' => ['required', 'string', 'max:100'],
|
||||||
|
'consultation_uuid' => ['nullable', 'uuid'],
|
||||||
|
'activation_diagnosis_text' => ['nullable', 'string', 'max:500'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pathway = ClinicalPathway::findByCode($validated['pathway_code']);
|
||||||
|
abort_unless($pathway, 422, 'Unknown or inactive pathway.');
|
||||||
|
|
||||||
|
$consultation = null;
|
||||||
|
if (! empty($validated['consultation_uuid'])) {
|
||||||
|
$consultation = Consultation::query()
|
||||||
|
->where('uuid', $validated['consultation_uuid'])
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->firstOrFail();
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
$consultation->loadMissing(['visit', 'diagnoses']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$patientPathway = $this->pathways->activate(
|
||||||
|
$patient,
|
||||||
|
$pathway,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
[
|
||||||
|
'consultation' => $consultation,
|
||||||
|
'activation_diagnosis_text' => $validated['activation_diagnosis_text'] ?? null,
|
||||||
|
'actor' => $this->ownerRef($request),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('care.pathways.index', $patient)
|
||||||
|
->with('success', "Pathway “{$pathway->name}” activated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deactivate(Request $request, Patient $patient, PatientPathway $patientPathway): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->assertPathwaysEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'pathways.manage');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
abort_unless($patientPathway->patient_id === $patient->id, 404);
|
||||||
|
$this->authorizeOwner($request, $patientPathway);
|
||||||
|
|
||||||
|
$this->pathways->deactivate(
|
||||||
|
$patientPathway,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->member($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return back()->with('success', 'Pathway deactivated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function suggestions(Request $request, Consultation $consultation): JsonResponse|RedirectResponse
|
||||||
|
{
|
||||||
|
$this->assertPathwaysEnabled($request);
|
||||||
|
$this->authorizeAbility($request, 'consultations.view');
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
$consultation->loadMissing(['diagnoses', 'patient']);
|
||||||
|
$suggestions = $this->pathways->suggest($consultation->patient, $consultation->diagnoses);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'data' => $suggestions->map(fn (array $row) => [
|
||||||
|
'pathway_code' => $row['pathway_code'],
|
||||||
|
'pathway_name' => $row['pathway_name'],
|
||||||
|
'rank' => $row['rank'],
|
||||||
|
'match_reason' => $row['match_reason'],
|
||||||
|
'already_active' => $row['already_active'],
|
||||||
|
])->values()->all(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->ajax()) {
|
||||||
|
return response()->json($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML fallthrough not used — consultation show embeds suggestions server-side.
|
||||||
|
return back()->with('pathway_suggestions', $payload['data']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertPathwaysEnabled(Request $request): void
|
||||||
|
{
|
||||||
|
$org = $this->organization($request);
|
||||||
|
// Pathways require assessments engine; suggestions flag is optional UX.
|
||||||
|
abort_unless(
|
||||||
|
$this->features->enabled($org, CareFeatures::ASSESSMENTS_ENGINE),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizePatient(Request $request, Patient $patient): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $patient);
|
||||||
|
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $patient->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $consultation);
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $consultation->visit->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
use App\Models\Branch;
|
use App\Models\Branch;
|
||||||
use App\Models\Patient;
|
use App\Models\Patient;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
use App\Services\Care\OrganizationResolver;
|
use App\Services\Care\OrganizationResolver;
|
||||||
use App\Services\Care\PatientService;
|
use App\Services\Care\PatientService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -91,13 +93,41 @@ class PatientController extends Controller
|
|||||||
$this->authorizePatient($request, $patient);
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
$dashboard = $this->patients->dashboard($patient);
|
$dashboard = $this->patients->dashboard($patient);
|
||||||
$canManage = app(\App\Services\Care\CarePermissions::class)
|
$permissions = app(CarePermissions::class);
|
||||||
->can($this->member($request), 'patients.manage');
|
$member = $this->member($request);
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$canManage = $permissions->can($member, 'patients.manage');
|
||||||
$credential = app(\App\Services\Messaging\MessagingCredentialsService::class)
|
$credential = app(\App\Services\Messaging\MessagingCredentialsService::class)
|
||||||
->forOrganization($this->organization($request));
|
->forOrganization($organization);
|
||||||
$suiteEmailReady = app(\App\Services\Billing\PlatformEmailClient::class)->isConfigured();
|
$suiteEmailReady = app(\App\Services\Billing\PlatformEmailClient::class)->isConfigured();
|
||||||
$suiteSmsReady = app(\App\Services\Billing\PlatformSmsClient::class)->isConfigured();
|
$suiteSmsReady = app(\App\Services\Billing\PlatformSmsClient::class)->isConfigured();
|
||||||
|
|
||||||
|
$assessmentsEnabled = app(CareFeatures::class)->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE);
|
||||||
|
$canViewAssessments = $assessmentsEnabled && $permissions->can($member, 'assessments.view');
|
||||||
|
$canCaptureAssessment = $assessmentsEnabled && (
|
||||||
|
$permissions->can($member, 'assessments.capture')
|
||||||
|
|| $permissions->can($member, 'assessments.manage')
|
||||||
|
);
|
||||||
|
$recentAssessments = collect();
|
||||||
|
$latestUniversalIntake = null;
|
||||||
|
if ($canViewAssessments) {
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($member);
|
||||||
|
$recentAssessments = $patient->assessments()
|
||||||
|
->with('template')
|
||||||
|
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->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();
|
||||||
|
}
|
||||||
|
|
||||||
return view('care.patients.show', array_merge($dashboard, [
|
return view('care.patients.show', array_merge($dashboard, [
|
||||||
'canManage' => $canManage,
|
'canManage' => $canManage,
|
||||||
'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(),
|
'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(),
|
||||||
@@ -109,6 +139,12 @@ class PatientController extends Controller
|
|||||||
'genders' => config('care.genders'),
|
'genders' => config('care.genders'),
|
||||||
'allergySeverities' => config('care.allergy_severities'),
|
'allergySeverities' => config('care.allergy_severities'),
|
||||||
'documentTypes' => config('care.document_types'),
|
'documentTypes' => config('care.document_types'),
|
||||||
|
'assessmentsEnabled' => $assessmentsEnabled,
|
||||||
|
'canViewAssessments' => $canViewAssessments,
|
||||||
|
'canCaptureAssessment' => $canCaptureAssessment,
|
||||||
|
'recentAssessments' => $recentAssessments,
|
||||||
|
'latestUniversalIntake' => $latestUniversalIntake,
|
||||||
|
'assessmentStatuses' => config('care.assessment_statuses'),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ namespace App\Http\Controllers\Care;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
use App\Models\Branch;
|
use App\Models\Branch;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
use App\Services\Care\OrganizationResolver;
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\PlanService;
|
||||||
use App\Services\Care\ReportService;
|
use App\Services\Care\ReportService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -47,14 +49,7 @@ class ReportController extends Controller
|
|||||||
|
|
||||||
[$from, $to] = $this->dateRange($request);
|
[$from, $to] = $this->dateRange($request);
|
||||||
|
|
||||||
$data = match ($type) {
|
$data = $this->reportData($request, $type, $organization->id, $from, $to, $branchId);
|
||||||
'patients' => $this->reports->patientsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
|
||||||
'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
|
||||||
'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
|
||||||
'finance' => $this->reports->financeReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
|
||||||
'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organization->id, $from, $to)],
|
|
||||||
default => abort(404),
|
|
||||||
};
|
|
||||||
|
|
||||||
$branches = Branch::owned($this->ownerRef($request))
|
$branches = Branch::owned($this->ownerRef($request))
|
||||||
->where('organization_id', $organization->id)
|
->where('organization_id', $organization->id)
|
||||||
@@ -87,14 +82,7 @@ class ReportController extends Controller
|
|||||||
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
|
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
|
||||||
[$from, $to] = $this->dateRange($request);
|
[$from, $to] = $this->dateRange($request);
|
||||||
|
|
||||||
$data = match ($type) {
|
$data = $this->reportData($request, $type, $organization->id, $from, $to, $branchId);
|
||||||
'patients' => $this->reports->patientsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
|
||||||
'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
|
||||||
'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
|
||||||
'finance' => $this->reports->financeReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
|
||||||
'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organization->id, $from, $to)],
|
|
||||||
default => abort(404),
|
|
||||||
};
|
|
||||||
|
|
||||||
$filename = "care-report-{$type}-".now()->format('Y-m-d').'.csv';
|
$filename = "care-report-{$type}-".now()->format('Y-m-d').'.csv';
|
||||||
|
|
||||||
@@ -105,6 +93,20 @@ class ReportController extends Controller
|
|||||||
foreach ($data['diagnoses'] as $row) {
|
foreach ($data['diagnoses'] as $row) {
|
||||||
fputcsv($handle, [$row->description, $row->total]);
|
fputcsv($handle, [$row->description, $row->total]);
|
||||||
}
|
}
|
||||||
|
} elseif ($type === 'assessments') {
|
||||||
|
fputcsv($handle, ['Section', 'Key', 'Value']);
|
||||||
|
foreach ($data['summary'] ?? [] as $key => $value) {
|
||||||
|
fputcsv($handle, ['summary', $key, $value]);
|
||||||
|
}
|
||||||
|
foreach ($data['by_template'] ?? [] as $row) {
|
||||||
|
fputcsv($handle, ['template', $row->template_code, "{$row->total} total / {$row->completed} completed"]);
|
||||||
|
}
|
||||||
|
foreach ($data['by_pathway'] ?? [] as $row) {
|
||||||
|
fputcsv($handle, ['pathway', $row->pathway_code, "{$row->total} activations"]);
|
||||||
|
}
|
||||||
|
foreach ($data['score_averages'] ?? [] as $row) {
|
||||||
|
fputcsv($handle, ['score_avg', $row->template_code, round((float) $row->avg_total, 2)]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fputcsv($handle, ['Metric', 'Value']);
|
fputcsv($handle, ['Metric', 'Value']);
|
||||||
foreach ($data as $key => $value) {
|
foreach ($data as $key => $value) {
|
||||||
@@ -115,10 +117,39 @@ class ReportController extends Controller
|
|||||||
}, $filename, ['Content-Type' => 'text/csv']);
|
}, $filename, ['Content-Type' => 'text/csv']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function reportData(Request $request, string $type, int $organizationId, Carbon $from, Carbon $to, ?int $branchId): array
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
'patients' => $this->reports->patientsReport($this->ownerRef($request), $organizationId, $from, $to, $branchId),
|
||||||
|
'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organizationId, $from, $to, $branchId),
|
||||||
|
'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organizationId, $from, $to, $branchId),
|
||||||
|
'finance' => $this->reports->financeReport($this->ownerRef($request), $organizationId, $from, $to, $branchId),
|
||||||
|
'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organizationId, $from, $to)],
|
||||||
|
'assessments' => $this->reports->assessmentsReport($this->ownerRef($request), $organizationId, $from, $to, $branchId),
|
||||||
|
default => abort(404),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected function authorizeReport(Request $request, string $type): void
|
protected function authorizeReport(Request $request, string $type): void
|
||||||
{
|
{
|
||||||
abort_unless(array_key_exists($type, config('care.report_types')), 404);
|
abort_unless(array_key_exists($type, config('care.report_types')), 404);
|
||||||
$this->authorizeAbility($request, 'reports.finance.view');
|
$this->authorizeAbility($request, 'reports.finance.view');
|
||||||
|
|
||||||
|
if ($type === 'assessments') {
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
abort_unless(
|
||||||
|
app(CareFeatures::class)->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
abort_unless(
|
||||||
|
app(PlanService::class)->hasFeature($organization, 'assessment_analytics'),
|
||||||
|
403,
|
||||||
|
'Assessment analytics require an Enterprise plan.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Assessment extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
|
||||||
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
protected $table = 'care_assessments';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_id',
|
||||||
|
'template_id', 'consultation_id', 'visit_id', 'patient_pathway_id', 'practitioner_id',
|
||||||
|
'status', 'assessed_at', 'completed_at', 'completed_by', 'started_by', 'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'assessed_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Assessment $assessment) {
|
||||||
|
if (! $assessment->uuid) {
|
||||||
|
$assessment->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function template(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AssessmentTemplate::class, 'template_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consultation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Consultation::class, 'consultation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function visit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Visit::class, 'visit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patientPathway(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PatientPathway::class, 'patient_pathway_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function practitioner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Practitioner::class, 'practitioner_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function answers(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AssessmentAnswer::class, 'assessment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function score(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(AssessmentScore::class, 'assessment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDraft(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_DRAFT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCompleted(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_COMPLETED;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class AssessmentAnswer extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_assessment_answers';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'assessment_id', 'question_id',
|
||||||
|
'value_text', 'value_number', 'value_boolean', 'value_date', 'value_json',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'value_number' => 'decimal:4',
|
||||||
|
'value_boolean' => 'boolean',
|
||||||
|
'value_date' => 'datetime',
|
||||||
|
'value_json' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assessment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Assessment::class, 'assessment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function question(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AssessmentQuestion::class, 'question_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authoritative value for the given answer type (unused columns must stay null at write time).
|
||||||
|
*/
|
||||||
|
public function authoritativeValue(?string $answerType = null): mixed
|
||||||
|
{
|
||||||
|
$type = $answerType ?? $this->question?->answer_type;
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
AssessmentQuestion::TYPE_TEXT,
|
||||||
|
AssessmentQuestion::TYPE_TEXTAREA,
|
||||||
|
AssessmentQuestion::TYPE_SINGLE_CHOICE => $this->value_text,
|
||||||
|
AssessmentQuestion::TYPE_NUMBER,
|
||||||
|
AssessmentQuestion::TYPE_INTEGER,
|
||||||
|
AssessmentQuestion::TYPE_SCALE,
|
||||||
|
AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
AssessmentQuestion::TYPE_CALCULATED => $this->value_number,
|
||||||
|
AssessmentQuestion::TYPE_BOOLEAN => $this->value_boolean,
|
||||||
|
AssessmentQuestion::TYPE_DATE,
|
||||||
|
AssessmentQuestion::TYPE_DATETIME => $this->value_date,
|
||||||
|
AssessmentQuestion::TYPE_MULTI_CHOICE => $this->value_json,
|
||||||
|
default => $this->value_text ?? $this->value_number ?? $this->value_boolean ?? $this->value_date ?? $this->value_json,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class AssessmentQuestion extends Model
|
||||||
|
{
|
||||||
|
public const TYPE_TEXT = 'text';
|
||||||
|
|
||||||
|
public const TYPE_TEXTAREA = 'textarea';
|
||||||
|
|
||||||
|
public const TYPE_NUMBER = 'number';
|
||||||
|
|
||||||
|
public const TYPE_INTEGER = 'integer';
|
||||||
|
|
||||||
|
public const TYPE_BOOLEAN = 'boolean';
|
||||||
|
|
||||||
|
public const TYPE_DATE = 'date';
|
||||||
|
|
||||||
|
public const TYPE_DATETIME = 'datetime';
|
||||||
|
|
||||||
|
public const TYPE_SINGLE_CHOICE = 'single_choice';
|
||||||
|
|
||||||
|
public const TYPE_MULTI_CHOICE = 'multi_choice';
|
||||||
|
|
||||||
|
public const TYPE_SCALE = 'scale';
|
||||||
|
|
||||||
|
public const TYPE_SCORE_ITEM = 'score_item';
|
||||||
|
|
||||||
|
public const TYPE_CALCULATED = 'calculated';
|
||||||
|
|
||||||
|
protected $table = 'care_assessment_questions';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'template_id', 'code', 'section', 'label', 'help_text', 'answer_type',
|
||||||
|
'options', 'is_required', 'sort_order', 'score_key', 'validation',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'options' => 'array',
|
||||||
|
'validation' => 'array',
|
||||||
|
'is_required' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function template(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AssessmentTemplate::class, 'template_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function answers(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AssessmentAnswer::class, 'question_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class AssessmentScore extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_assessment_scores';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'assessment_id', 'template_code',
|
||||||
|
'total_score', 'max_score', 'subscores', 'severity_label', 'computed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'total_score' => 'decimal:4',
|
||||||
|
'max_score' => 'decimal:4',
|
||||||
|
'subscores' => 'array',
|
||||||
|
'computed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assessment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Assessment::class, 'assessment_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform catalog row (no owner_ref). System templates in v1 always have null organization_id.
|
||||||
|
*/
|
||||||
|
class AssessmentTemplate extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
public const CATEGORY_UNIVERSAL = 'universal';
|
||||||
|
|
||||||
|
public const CATEGORY_DISEASE = 'disease';
|
||||||
|
|
||||||
|
public const CATEGORY_OUTCOME = 'outcome';
|
||||||
|
|
||||||
|
public const CATEGORY_SCREENING = 'screening';
|
||||||
|
|
||||||
|
protected $table = 'care_assessment_templates';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'organization_id', 'code', 'name', 'category', 'description',
|
||||||
|
'version', 'is_current', 'scoring_strategy', 'meta', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'version' => 'integer',
|
||||||
|
'is_current' => 'boolean',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'meta' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (AssessmentTemplate $template) {
|
||||||
|
if (! $template->uuid) {
|
||||||
|
$template->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function questions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AssessmentQuestion::class, 'template_id')->orderBy('sort_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assessments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Assessment::class, 'template_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeSystem(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereNull($this->getTable().'.organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeCurrent(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where($this->getTable().'.is_current', true)
|
||||||
|
->where($this->getTable().'.is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current system template for a stable code (e.g. nihss, universal_intake).
|
||||||
|
*/
|
||||||
|
public static function currentSystemByCode(string $code): ?self
|
||||||
|
{
|
||||||
|
return static::query()
|
||||||
|
->system()
|
||||||
|
->current()
|
||||||
|
->where('code', $code)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/** Platform catalog pathway (no owner_ref). */
|
||||||
|
class ClinicalPathway extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'care_clinical_pathways';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'code', 'name', 'description', 'match_rules',
|
||||||
|
'is_active', 'sort_order', 'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'match_rules' => 'array',
|
||||||
|
'meta' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (ClinicalPathway $pathway) {
|
||||||
|
if (! $pathway->uuid) {
|
||||||
|
$pathway->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function templates(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PathwayTemplate::class, 'pathway_id')->orderBy('sort_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patientPathways(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PatientPathway::class, 'pathway_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findByCode(string $code): ?self
|
||||||
|
{
|
||||||
|
return static::query()->where('code', $code)->where('is_active', true)->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,11 @@ class Consultation extends Model
|
|||||||
return $this->hasMany(Diagnosis::class, 'consultation_id');
|
return $this->hasMany(Diagnosis::class, 'consultation_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function assessments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Assessment::class, 'consultation_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function documents(): HasMany
|
public function documents(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ConsultationDocument::class, 'consultation_id');
|
return $this->hasMany(ConsultationDocument::class, 'consultation_id');
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/** Catalog binding: pathway → template_code (resolve is_current at assessment start). */
|
||||||
|
class PathwayTemplate extends Model
|
||||||
|
{
|
||||||
|
public const PHASE_ACUTE = 'acute';
|
||||||
|
|
||||||
|
public const PHASE_FOLLOW_UP = 'follow_up';
|
||||||
|
|
||||||
|
public const PHASE_ANY = 'any';
|
||||||
|
|
||||||
|
protected $table = 'care_pathway_templates';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'pathway_id', 'template_code', 'is_required_on_activation', 'phase', 'sort_order',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_required_on_activation' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pathway(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ClinicalPathway::class, 'pathway_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,16 @@ class Patient extends Model
|
|||||||
return $this->hasMany(Visit::class, 'patient_id');
|
return $this->hasMany(Visit::class, 'patient_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function assessments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Assessment::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pathways(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PatientPathway::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function investigationRequests(): HasMany
|
public function investigationRequests(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(InvestigationRequest::class, 'patient_id');
|
return $this->hasMany(InvestigationRequest::class, 'patient_id');
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PatientPathway extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const STATUS_RESOLVED = 'resolved';
|
||||||
|
|
||||||
|
public const STATUS_INACTIVE = 'inactive';
|
||||||
|
|
||||||
|
protected $table = 'care_patient_pathways';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'patient_id', 'pathway_id',
|
||||||
|
'status', 'activated_at', 'activated_by', 'activation_consultation_id',
|
||||||
|
'activation_diagnosis_text', 'resolved_at', 'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'activated_at' => 'datetime',
|
||||||
|
'resolved_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (PatientPathway $row) {
|
||||||
|
if (! $row->uuid) {
|
||||||
|
$row->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pathway(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ClinicalPathway::class, 'pathway_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activationConsultation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Consultation::class, 'activation_consultation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assessments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Assessment::class, 'patient_pathway_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_ACTIVE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,11 @@ class Visit extends Model
|
|||||||
return $this->hasMany(Consultation::class, 'visit_id');
|
return $this->hasMany(Consultation::class, 'visit_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function assessments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Assessment::class, 'visit_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function bill(): HasOne
|
public function bill(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(Bill::class, 'visit_id');
|
return $this->hasOne(Bill::class, 'visit_id');
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentAnswer;
|
||||||
|
use App\Models\AssessmentQuestion;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
|
class AssessmentService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected ScoringService $scoring,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a draft assessment for the current system template by code.
|
||||||
|
* Idempotent: returns existing draft for the same patient/template/(consultation).
|
||||||
|
*
|
||||||
|
* @param array{
|
||||||
|
* consultation?: ?Consultation,
|
||||||
|
* visit?: ?Visit,
|
||||||
|
* patient_pathway?: ?\App\Models\PatientPathway,
|
||||||
|
* practitioner_id?: ?int,
|
||||||
|
* actor?: ?string,
|
||||||
|
* } $context
|
||||||
|
*/
|
||||||
|
public function start(
|
||||||
|
Patient $patient,
|
||||||
|
string $templateCode,
|
||||||
|
string $ownerRef,
|
||||||
|
?Member $member,
|
||||||
|
array $context = [],
|
||||||
|
): Assessment {
|
||||||
|
$template = AssessmentTemplate::currentSystemByCode($templateCode);
|
||||||
|
|
||||||
|
if (! $template) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'template_code' => 'Unknown, inactive, or non-current assessment template.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertCaptureAllowed($member, $template);
|
||||||
|
|
||||||
|
$consultation = $context['consultation'] ?? null;
|
||||||
|
$visit = $context['visit'] ?? $consultation?->visit;
|
||||||
|
$patientPathway = $context['patient_pathway'] ?? null;
|
||||||
|
$actor = $context['actor'] ?? null;
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($patient, $template, $ownerRef, $consultation, $visit, $patientPathway, $context, $actor) {
|
||||||
|
$query = Assessment::query()
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->where('template_id', $template->id)
|
||||||
|
->where('status', Assessment::STATUS_DRAFT)
|
||||||
|
->lockForUpdate();
|
||||||
|
|
||||||
|
if ($consultation) {
|
||||||
|
$query->where('consultation_id', $consultation->id);
|
||||||
|
} else {
|
||||||
|
$query->whereNull('consultation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $query->first();
|
||||||
|
if ($existing) {
|
||||||
|
if ($patientPathway && ! $existing->patient_pathway_id) {
|
||||||
|
$existing->update(['patient_pathway_id' => $patientPathway->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existing->load(['template.questions', 'answers.question', 'patient', 'consultation', 'visit', 'score']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$branchId = $visit?->branch_id ?? $patient->branch_id;
|
||||||
|
|
||||||
|
$assessment = Assessment::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $patient->organization_id,
|
||||||
|
'branch_id' => $branchId,
|
||||||
|
'patient_id' => $patient->id,
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'consultation_id' => $consultation?->id,
|
||||||
|
'visit_id' => $visit?->id,
|
||||||
|
'patient_pathway_id' => $patientPathway?->id,
|
||||||
|
'practitioner_id' => $context['practitioner_id'] ?? $consultation?->practitioner_id,
|
||||||
|
'status' => Assessment::STATUS_DRAFT,
|
||||||
|
'started_by' => $actor,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$ownerRef,
|
||||||
|
'assessment.started',
|
||||||
|
$patient->organization_id,
|
||||||
|
$actor,
|
||||||
|
Assessment::class,
|
||||||
|
$assessment->id,
|
||||||
|
['template_code' => $template->code, 'template_version' => $template->version],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $assessment->load(['template.questions', 'answers.question', 'patient', 'consultation', 'visit', 'score']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $answers keyed by question code
|
||||||
|
*/
|
||||||
|
public function saveAnswers(
|
||||||
|
Assessment $assessment,
|
||||||
|
string $ownerRef,
|
||||||
|
array $answers,
|
||||||
|
?Member $member = null,
|
||||||
|
?string $actorRef = null,
|
||||||
|
?string $notes = null,
|
||||||
|
): Assessment {
|
||||||
|
$this->assertOwner($assessment, $ownerRef);
|
||||||
|
$this->assertDraft($assessment);
|
||||||
|
$assessment->loadMissing('template.questions');
|
||||||
|
$this->assertCaptureAllowed($member, $assessment->template);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($assessment, $ownerRef, $answers, $notes) {
|
||||||
|
foreach ($answers as $code => $raw) {
|
||||||
|
$question = $assessment->template->questions->firstWhere('code', $code);
|
||||||
|
if (! $question) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$code}" => "Unknown question code [{$code}].",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($question->answer_type === AssessmentQuestion::TYPE_CALCULATED) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = $this->normalizeValue($question, $raw);
|
||||||
|
|
||||||
|
AssessmentAnswer::updateOrCreate(
|
||||||
|
[
|
||||||
|
'assessment_id' => $assessment->id,
|
||||||
|
'question_id' => $question->id,
|
||||||
|
],
|
||||||
|
array_merge(
|
||||||
|
['owner_ref' => $ownerRef],
|
||||||
|
$normalized,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($notes !== null) {
|
||||||
|
$assessment->update(['notes' => $notes]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$ownerRef,
|
||||||
|
'assessment.updated',
|
||||||
|
$assessment->organization_id,
|
||||||
|
$actorRef,
|
||||||
|
Assessment::class,
|
||||||
|
$assessment->id,
|
||||||
|
['template_code' => $assessment->template->code, 'answer_keys' => array_keys($answers)],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $assessment->fresh(['template.questions', 'answers.question', 'patient', 'consultation', 'visit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(
|
||||||
|
Assessment $assessment,
|
||||||
|
string $ownerRef,
|
||||||
|
?Member $member = null,
|
||||||
|
?string $actorRef = null,
|
||||||
|
): Assessment {
|
||||||
|
$this->assertOwner($assessment, $ownerRef);
|
||||||
|
$this->assertDraft($assessment);
|
||||||
|
$assessment->loadMissing(['template.questions', 'answers']);
|
||||||
|
$this->assertCaptureAllowed($member, $assessment->template);
|
||||||
|
|
||||||
|
$this->validateRequiredAnswers($assessment);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($assessment, $ownerRef, $actorRef) {
|
||||||
|
// Score first so complete fails cleanly without marking completed if scoring 422s.
|
||||||
|
$scoreMeta = [];
|
||||||
|
if ($this->scoring->requiresScore($assessment)) {
|
||||||
|
$score = $this->scoring->materialize($assessment);
|
||||||
|
$scoreMeta = [
|
||||||
|
'total_score' => $score->total_score,
|
||||||
|
'max_score' => $score->max_score,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$assessment->update([
|
||||||
|
'status' => Assessment::STATUS_COMPLETED,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'completed_by' => $actorRef,
|
||||||
|
'assessed_at' => $assessment->assessed_at ?? now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$ownerRef,
|
||||||
|
'assessment.completed',
|
||||||
|
$assessment->organization_id,
|
||||||
|
$actorRef,
|
||||||
|
Assessment::class,
|
||||||
|
$assessment->id,
|
||||||
|
array_merge(['template_code' => $assessment->template->code], $scoreMeta),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $assessment->fresh(['template.questions', 'answers.question', 'patient', 'consultation', 'visit', 'score']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(
|
||||||
|
Assessment $assessment,
|
||||||
|
string $ownerRef,
|
||||||
|
?Member $member = null,
|
||||||
|
?string $actorRef = null,
|
||||||
|
): Assessment {
|
||||||
|
$this->assertOwner($assessment, $ownerRef);
|
||||||
|
$this->assertDraft($assessment);
|
||||||
|
$assessment->loadMissing('template');
|
||||||
|
$this->assertCaptureAllowed($member, $assessment->template);
|
||||||
|
|
||||||
|
$assessment->update([
|
||||||
|
'status' => Assessment::STATUS_CANCELLED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$ownerRef,
|
||||||
|
'assessment.cancelled',
|
||||||
|
$assessment->organization_id,
|
||||||
|
$actorRef,
|
||||||
|
Assessment::class,
|
||||||
|
$assessment->id,
|
||||||
|
['template_code' => $assessment->template->code],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $assessment->fresh(['template', 'patient']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
*/
|
||||||
|
public function listForPatient(
|
||||||
|
Patient $patient,
|
||||||
|
string $ownerRef,
|
||||||
|
array $filters = [],
|
||||||
|
?int $branchScope = null,
|
||||||
|
): LengthAwarePaginator {
|
||||||
|
$query = Assessment::query()
|
||||||
|
->owned($ownerRef)
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->with(['template', 'consultation', 'visit', 'practitioner'])
|
||||||
|
->orderByDesc('assessed_at')
|
||||||
|
->orderByDesc('created_at');
|
||||||
|
|
||||||
|
if ($branchScope !== null) {
|
||||||
|
$query->where('branch_id', $branchScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($filters['status'])) {
|
||||||
|
$query->where('status', $filters['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($filters['template_code'])) {
|
||||||
|
$query->whereHas('template', fn ($q) => $q->where('code', $filters['template_code']));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($filters['category'])) {
|
||||||
|
$query->whereHas('template', fn ($q) => $q->where('category', $filters['category']));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($filters['from'])) {
|
||||||
|
$query->whereDate('assessed_at', '>=', $filters['from']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($filters['to'])) {
|
||||||
|
$query->whereDate('assessed_at', '<=', $filters['to']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$perPage = min(max((int) ($filters['per_page'] ?? 20), 1), 100);
|
||||||
|
|
||||||
|
return $query->paginate($perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assertCaptureAllowed(?Member $member, AssessmentTemplate $template): void
|
||||||
|
{
|
||||||
|
if ($member === null) {
|
||||||
|
throw new HttpException(403, 'Not authorized to capture assessments.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($member->role, ['hospital_admin', 'super_admin'], true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $template->meta['capture_roles']
|
||||||
|
?? $this->defaultCaptureRoles($template->category);
|
||||||
|
|
||||||
|
if (! in_array($member->role, $roles, true)) {
|
||||||
|
throw new HttpException(403, 'Your role cannot capture this assessment template.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function defaultCaptureRoles(string $category): array
|
||||||
|
{
|
||||||
|
return match ($category) {
|
||||||
|
AssessmentTemplate::CATEGORY_DISEASE => ['doctor'],
|
||||||
|
default => ['doctor', 'nurse'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw answer into typed columns (exactly one authoritative column set).
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* value_text: ?string,
|
||||||
|
* value_number: ?string|null,
|
||||||
|
* value_boolean: ?bool,
|
||||||
|
* value_date: ?string,
|
||||||
|
* value_json: mixed
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function normalizeValue(AssessmentQuestion $question, mixed $raw): array
|
||||||
|
{
|
||||||
|
$empty = [
|
||||||
|
'value_text' => null,
|
||||||
|
'value_number' => null,
|
||||||
|
'value_boolean' => null,
|
||||||
|
'value_date' => null,
|
||||||
|
'value_json' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($raw === null || $raw === '') {
|
||||||
|
return $empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $question->answer_type;
|
||||||
|
$options = $question->options ?? [];
|
||||||
|
$validation = $question->validation ?? [];
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
AssessmentQuestion::TYPE_TEXT,
|
||||||
|
AssessmentQuestion::TYPE_TEXTAREA => array_merge($empty, [
|
||||||
|
'value_text' => $this->validatedString($raw, $question->code, $validation),
|
||||||
|
]),
|
||||||
|
AssessmentQuestion::TYPE_SINGLE_CHOICE => array_merge($empty, [
|
||||||
|
'value_text' => $this->validatedChoiceCode($raw, $question->code, $options),
|
||||||
|
]),
|
||||||
|
AssessmentQuestion::TYPE_NUMBER,
|
||||||
|
AssessmentQuestion::TYPE_INTEGER,
|
||||||
|
AssessmentQuestion::TYPE_SCALE => array_merge($empty, [
|
||||||
|
'value_number' => $this->validatedNumber($raw, $question->code, $options, $validation, $type === AssessmentQuestion::TYPE_INTEGER),
|
||||||
|
]),
|
||||||
|
AssessmentQuestion::TYPE_SCORE_ITEM => array_merge($empty, [
|
||||||
|
'value_number' => $this->validatedScoreItem($raw, $question->code, $options),
|
||||||
|
]),
|
||||||
|
AssessmentQuestion::TYPE_BOOLEAN => array_merge($empty, [
|
||||||
|
'value_boolean' => filter_var($raw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $raw,
|
||||||
|
]),
|
||||||
|
AssessmentQuestion::TYPE_DATE => array_merge($empty, [
|
||||||
|
'value_date' => $this->validatedDate($raw, $question->code, false),
|
||||||
|
]),
|
||||||
|
AssessmentQuestion::TYPE_DATETIME => array_merge($empty, [
|
||||||
|
'value_date' => $this->validatedDate($raw, $question->code, true),
|
||||||
|
]),
|
||||||
|
AssessmentQuestion::TYPE_MULTI_CHOICE => array_merge($empty, [
|
||||||
|
'value_json' => $this->validatedMultiChoice($raw, $question->code, $options),
|
||||||
|
]),
|
||||||
|
AssessmentQuestion::TYPE_CALCULATED => $empty,
|
||||||
|
default => throw ValidationException::withMessages([
|
||||||
|
"answers.{$question->code}" => "Unsupported answer type [{$type}].",
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertOwner(Assessment $assessment, string $ownerRef): void
|
||||||
|
{
|
||||||
|
if ($assessment->owner_ref !== $ownerRef) {
|
||||||
|
throw new HttpException(404, 'Assessment not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertDraft(Assessment $assessment): void
|
||||||
|
{
|
||||||
|
if ($assessment->status !== Assessment::STATUS_DRAFT) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'status' => 'Only draft assessments can be modified. Completed assessments are immutable.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validateRequiredAnswers(Assessment $assessment): void
|
||||||
|
{
|
||||||
|
$answersByQuestion = $assessment->answers->keyBy('question_id');
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($assessment->template->questions as $question) {
|
||||||
|
if ($question->answer_type === AssessmentQuestion::TYPE_CALCULATED) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $question->is_required) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$answer = $answersByQuestion->get($question->id);
|
||||||
|
if (! $answer || $this->isEmptyAuthoritative($answer, $question->answer_type)) {
|
||||||
|
$errors["answers.{$question->code}"] = "{$question->label} is required.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors !== []) {
|
||||||
|
throw ValidationException::withMessages($errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isEmptyAuthoritative(AssessmentAnswer $answer, string $type): bool
|
||||||
|
{
|
||||||
|
$value = $answer->authoritativeValue($type);
|
||||||
|
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value) && $value === []) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validatedString(mixed $raw, string $code, array $validation): string
|
||||||
|
{
|
||||||
|
$value = is_scalar($raw) ? (string) $raw : '';
|
||||||
|
$max = (int) ($validation['max'] ?? 10000);
|
||||||
|
if (mb_strlen($value) > $max) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$code}" => "Must be at most {$max} characters.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validatedChoiceCode(mixed $raw, string $code, array $options): string
|
||||||
|
{
|
||||||
|
$value = is_scalar($raw) ? (string) $raw : '';
|
||||||
|
$choices = collect($options['choices'] ?? [])->pluck('code')->map(fn ($c) => (string) $c)->all();
|
||||||
|
if ($choices !== [] && ! in_array($value, $choices, true)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$code}" => 'Invalid choice.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validatedNumber(mixed $raw, string $code, array $options, array $validation, bool $integerOnly): string
|
||||||
|
{
|
||||||
|
if (! is_numeric($raw)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$code}" => 'Must be a number.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($integerOnly && (float) $raw != (int) $raw) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$code}" => 'Must be an integer.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$number = (float) $raw;
|
||||||
|
$min = $validation['min'] ?? $options['min'] ?? null;
|
||||||
|
$max = $validation['max'] ?? $options['max'] ?? null;
|
||||||
|
|
||||||
|
if ($min !== null && $number < (float) $min) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$code}" => "Must be at least {$min}.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if ($max !== null && $number > (float) $max) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$code}" => "Must be at most {$max}.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $number;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validatedScoreItem(mixed $raw, string $code, array $options): string
|
||||||
|
{
|
||||||
|
$choices = $options['choices'] ?? [];
|
||||||
|
if (is_array($choices) && $choices !== []) {
|
||||||
|
// Accept choice code or numeric score.
|
||||||
|
if (is_string($raw) || is_int($raw)) {
|
||||||
|
$asString = (string) $raw;
|
||||||
|
foreach ($choices as $choice) {
|
||||||
|
if ((string) ($choice['code'] ?? '') === $asString) {
|
||||||
|
if (! array_key_exists('score', $choice)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$code}" => 'Choice has no score.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $choice['score'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->validatedNumber($raw, $code, $options, [], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validatedDate(mixed $raw, string $code, bool $withTime): string
|
||||||
|
{
|
||||||
|
$format = $withTime ? 'Y-m-d H:i:s' : 'Y-m-d';
|
||||||
|
$rules = [$withTime ? 'date' : 'date_format:Y-m-d'];
|
||||||
|
$validator = Validator::make(
|
||||||
|
['v' => $raw],
|
||||||
|
['v' => $rules],
|
||||||
|
);
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$code}" => 'Invalid date.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dt = \Carbon\Carbon::parse($raw);
|
||||||
|
|
||||||
|
return $withTime ? $dt->format('Y-m-d H:i:s') : $dt->format('Y-m-d').' 00:00:00';
|
||||||
|
} catch (\Throwable) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$code}" => 'Invalid date.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
protected function validatedMultiChoice(mixed $raw, string $code, array $options): array
|
||||||
|
{
|
||||||
|
if (! is_array($raw)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$code}" => 'Must be a list of choices.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = collect($options['choices'] ?? [])->pluck('code')->map(fn ($c) => (string) $c)->all();
|
||||||
|
$values = array_values(array_map(fn ($v) => (string) $v, $raw));
|
||||||
|
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if ($allowed !== [] && ! in_array($value, $allowed, true)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$code}" => "Invalid choice [{$value}].",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product rollout flags stored under organization settings.rollout.*
|
||||||
|
* Distinct from PlanService entitlements (lab/pharmacy/billing plan features).
|
||||||
|
*/
|
||||||
|
class CareFeatures
|
||||||
|
{
|
||||||
|
public const ASSESSMENTS_ENGINE = 'assessments_engine';
|
||||||
|
|
||||||
|
public const PATHWAY_SUGGESTIONS = 'pathway_suggestions';
|
||||||
|
|
||||||
|
public const ASSESSMENT_REQUIRED_ON_COMPLETE = 'assessment_required_on_complete';
|
||||||
|
|
||||||
|
public function enabled(Organization $organization, string $flag): bool
|
||||||
|
{
|
||||||
|
$settings = $organization->settings ?? [];
|
||||||
|
$rollout = is_array($settings['rollout'] ?? null) ? $settings['rollout'] : [];
|
||||||
|
|
||||||
|
return (bool) ($rollout[$flag] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $flags
|
||||||
|
*/
|
||||||
|
public function setFlags(Organization $organization, array $flags): Organization
|
||||||
|
{
|
||||||
|
$settings = $organization->settings ?? [];
|
||||||
|
$rollout = is_array($settings['rollout'] ?? null) ? $settings['rollout'] : [];
|
||||||
|
$settings['rollout'] = array_merge($rollout, $flags);
|
||||||
|
$organization->settings = $settings;
|
||||||
|
$organization->save();
|
||||||
|
|
||||||
|
return $organization->fresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,11 +19,14 @@ class CarePermissions
|
|||||||
'dashboard.view', 'patients.view', 'appointments.view',
|
'dashboard.view', 'patients.view', 'appointments.view',
|
||||||
'consultations.view', 'consultations.manage',
|
'consultations.view', 'consultations.manage',
|
||||||
'investigations.request', 'prescriptions.manage', 'lab.results.view',
|
'investigations.request', 'prescriptions.manage', 'lab.results.view',
|
||||||
|
'assessments.view', 'assessments.capture', 'assessments.manage',
|
||||||
|
'pathways.manage',
|
||||||
],
|
],
|
||||||
'nurse' => [
|
'nurse' => [
|
||||||
'dashboard.view', 'patients.view', 'appointments.view',
|
'dashboard.view', 'patients.view', 'appointments.view',
|
||||||
'consultations.view', 'vitals.manage', 'queue.manage',
|
'consultations.view', 'vitals.manage', 'queue.manage',
|
||||||
'service_queues.console',
|
'service_queues.console',
|
||||||
|
'assessments.view', 'assessments.capture',
|
||||||
],
|
],
|
||||||
'lab_technician' => [
|
'lab_technician' => [
|
||||||
'dashboard.view', 'patients.view', 'lab.view', 'lab.manage',
|
'dashboard.view', 'patients.view', 'lab.view', 'lab.manage',
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentQuestion;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a Care assessment as FHIR R4 Questionnaire + QuestionnaireResponse (JSON).
|
||||||
|
* Not a full FHIR server — interchange export for completed/draft assessments.
|
||||||
|
*/
|
||||||
|
class FhirAssessmentExporter
|
||||||
|
{
|
||||||
|
public const FHIR_VERSION = '4.0.1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundle containing Questionnaire and QuestionnaireResponse.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function exportBundle(Assessment $assessment): array
|
||||||
|
{
|
||||||
|
$assessment->loadMissing(['template.questions', 'answers.question', 'patient', 'score']);
|
||||||
|
|
||||||
|
$questionnaire = $this->questionnaire($assessment);
|
||||||
|
$response = $this->questionnaireResponse($assessment, $questionnaire['url']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'resourceType' => 'Bundle',
|
||||||
|
'type' => 'collection',
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
'meta' => [
|
||||||
|
'tag' => [[
|
||||||
|
'system' => 'https://care.ladill.com/fhir/tags',
|
||||||
|
'code' => 'ladill-care-assessment-export',
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
'entry' => [
|
||||||
|
['fullUrl' => $questionnaire['url'], 'resource' => $questionnaire],
|
||||||
|
['fullUrl' => $response['id'] ? 'urn:uuid:'.$response['id'] : 'QuestionnaireResponse/'.$assessment->uuid, 'resource' => $response],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function questionnaire(Assessment $assessment): array
|
||||||
|
{
|
||||||
|
$template = $assessment->template;
|
||||||
|
$url = 'https://care.ladill.com/fhir/Questionnaire/'.($template?->code ?? 'unknown').'/v'.($template?->version ?? 1);
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($template?->questions ?? [] as $question) {
|
||||||
|
$items[] = $this->questionItem($question);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'resourceType' => 'Questionnaire',
|
||||||
|
'id' => ($template?->code ?? 'unknown').'-v'.($template?->version ?? 1),
|
||||||
|
'url' => $url,
|
||||||
|
'version' => (string) ($template?->version ?? 1),
|
||||||
|
'name' => Str::studly($template?->code ?? 'Assessment'),
|
||||||
|
'title' => $template?->name ?? 'Assessment',
|
||||||
|
'status' => ($template?->is_active ?? true) ? 'active' : 'retired',
|
||||||
|
'description' => $template?->description,
|
||||||
|
'item' => $items,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function questionnaireResponse(Assessment $assessment, ?string $questionnaireUrl = null): array
|
||||||
|
{
|
||||||
|
$assessment->loadMissing(['template.questions', 'answers.question', 'patient', 'score']);
|
||||||
|
$questionnaireUrl ??= $this->questionnaire($assessment)['url'];
|
||||||
|
|
||||||
|
$answersByQ = $assessment->answers->keyBy('question_id');
|
||||||
|
$items = [];
|
||||||
|
foreach ($assessment->template?->questions ?? [] as $question) {
|
||||||
|
$answer = $answersByQ->get($question->id);
|
||||||
|
if (! $answer) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = $answer->authoritativeValue($question->answer_type);
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items[] = [
|
||||||
|
'linkId' => $question->code,
|
||||||
|
'text' => $question->label,
|
||||||
|
'answer' => $this->fhirAnswers($question, $value),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = match ($assessment->status) {
|
||||||
|
Assessment::STATUS_COMPLETED => 'completed',
|
||||||
|
Assessment::STATUS_CANCELLED => 'entered-in-error',
|
||||||
|
default => 'in-progress',
|
||||||
|
};
|
||||||
|
|
||||||
|
$resource = [
|
||||||
|
'resourceType' => 'QuestionnaireResponse',
|
||||||
|
'id' => $assessment->uuid,
|
||||||
|
'questionnaire' => $questionnaireUrl,
|
||||||
|
'status' => $status,
|
||||||
|
'authored' => ($assessment->assessed_at ?? $assessment->completed_at ?? $assessment->created_at)?->toIso8601String(),
|
||||||
|
'subject' => [
|
||||||
|
'reference' => 'Patient/'.($assessment->patient?->uuid ?? $assessment->patient_id),
|
||||||
|
'display' => $assessment->patient?->fullName(),
|
||||||
|
],
|
||||||
|
'item' => $items,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($assessment->score) {
|
||||||
|
$resource['extension'] = [[
|
||||||
|
'url' => 'https://care.ladill.com/fhir/StructureDefinition/assessment-score',
|
||||||
|
'extension' => array_values(array_filter([
|
||||||
|
['url' => 'total', 'valueDecimal' => (float) $assessment->score->total_score],
|
||||||
|
$assessment->score->max_score !== null
|
||||||
|
? ['url' => 'max', 'valueDecimal' => (float) $assessment->score->max_score]
|
||||||
|
: null,
|
||||||
|
$assessment->score->severity_label
|
||||||
|
? ['url' => 'severity', 'valueString' => $assessment->score->severity_label]
|
||||||
|
: null,
|
||||||
|
['url' => 'templateCode', 'valueString' => $assessment->score->template_code],
|
||||||
|
])),
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function questionItem(AssessmentQuestion $question): array
|
||||||
|
{
|
||||||
|
$type = match ($question->answer_type) {
|
||||||
|
AssessmentQuestion::TYPE_BOOLEAN => 'boolean',
|
||||||
|
AssessmentQuestion::TYPE_INTEGER => 'integer',
|
||||||
|
AssessmentQuestion::TYPE_NUMBER,
|
||||||
|
AssessmentQuestion::TYPE_SCALE,
|
||||||
|
AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
AssessmentQuestion::TYPE_CALCULATED => 'decimal',
|
||||||
|
AssessmentQuestion::TYPE_DATE => 'date',
|
||||||
|
AssessmentQuestion::TYPE_DATETIME => 'dateTime',
|
||||||
|
AssessmentQuestion::TYPE_MULTI_CHOICE => 'choice',
|
||||||
|
AssessmentQuestion::TYPE_SINGLE_CHOICE => 'choice',
|
||||||
|
default => 'string',
|
||||||
|
};
|
||||||
|
|
||||||
|
$item = [
|
||||||
|
'linkId' => $question->code,
|
||||||
|
'text' => $question->label,
|
||||||
|
'type' => $type,
|
||||||
|
'required' => (bool) $question->is_required,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($question->help_text) {
|
||||||
|
$item['prefix'] = $question->help_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
$choices = $question->options['choices'] ?? null;
|
||||||
|
if (is_array($choices) && $choices !== []) {
|
||||||
|
$item['answerOption'] = array_map(function ($c) {
|
||||||
|
$code = (string) ($c['code'] ?? '');
|
||||||
|
$label = (string) ($c['label'] ?? $code);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valueCoding' => [
|
||||||
|
'code' => $code,
|
||||||
|
'display' => $label,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}, $choices);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
protected function fhirAnswers(AssessmentQuestion $question, mixed $value): array
|
||||||
|
{
|
||||||
|
return match ($question->answer_type) {
|
||||||
|
AssessmentQuestion::TYPE_BOOLEAN => [['valueBoolean' => (bool) $value]],
|
||||||
|
AssessmentQuestion::TYPE_INTEGER => [['valueInteger' => (int) $value]],
|
||||||
|
AssessmentQuestion::TYPE_NUMBER,
|
||||||
|
AssessmentQuestion::TYPE_SCALE,
|
||||||
|
AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
AssessmentQuestion::TYPE_CALCULATED => [['valueDecimal' => (float) $value]],
|
||||||
|
AssessmentQuestion::TYPE_DATE => [['valueDate' => $value instanceof \Carbon\Carbon
|
||||||
|
? $value->toDateString()
|
||||||
|
: (string) $value]],
|
||||||
|
AssessmentQuestion::TYPE_DATETIME => [['valueDateTime' => $value instanceof \Carbon\Carbon
|
||||||
|
? $value->toIso8601String()
|
||||||
|
: (string) $value]],
|
||||||
|
AssessmentQuestion::TYPE_MULTI_CHOICE => collect(is_array($value) ? $value : [$value])
|
||||||
|
->map(fn ($v) => ['valueString' => (string) $v])
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
AssessmentQuestion::TYPE_SINGLE_CHOICE => [['valueString' => (string) $value]],
|
||||||
|
default => [['valueString' => is_scalar($value) ? (string) $value : json_encode($value)]],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentScore;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\VitalSign;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-patient longitudinal trends (Layer 4) — free for all plans.
|
||||||
|
* Org-wide multi-patient analytics remain Enterprise (KD-18).
|
||||||
|
*/
|
||||||
|
class OutcomeTrendService
|
||||||
|
{
|
||||||
|
public const OUTCOME_TEMPLATE_CODE = 'outcome_core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completed outcome_core assessments newest-first with answer maps.
|
||||||
|
*
|
||||||
|
* @return Collection<int, array{assessment: Assessment, answers: array<string, mixed>, assessed_at: ?\Carbon\Carbon}>
|
||||||
|
*/
|
||||||
|
public function outcomeSeries(Patient $patient, string $ownerRef, ?int $branchScope = null, int $limit = 50): Collection
|
||||||
|
{
|
||||||
|
$template = AssessmentTemplate::currentSystemByCode(self::OUTCOME_TEMPLATE_CODE);
|
||||||
|
|
||||||
|
$query = Assessment::query()
|
||||||
|
->owned($ownerRef)
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->where('status', Assessment::STATUS_COMPLETED)
|
||||||
|
->with(['answers.question', 'template'])
|
||||||
|
->orderByDesc('assessed_at')
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->limit($limit);
|
||||||
|
|
||||||
|
if ($template) {
|
||||||
|
$query->where('template_id', $template->id);
|
||||||
|
} else {
|
||||||
|
$query->whereHas('template', fn ($q) => $q->where('code', self::OUTCOME_TEMPLATE_CODE));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($branchScope !== null) {
|
||||||
|
$query->where('branch_id', $branchScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get()->map(function (Assessment $assessment) {
|
||||||
|
$answers = [];
|
||||||
|
foreach ($assessment->answers as $answer) {
|
||||||
|
if ($answer->question) {
|
||||||
|
$answers[$answer->question->code] = $answer->authoritativeValue($answer->question->answer_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'assessment' => $assessment,
|
||||||
|
'answers' => $answers,
|
||||||
|
'assessed_at' => $assessment->assessed_at ?? $assessment->completed_at,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent instrument scores for chartable disease templates.
|
||||||
|
*
|
||||||
|
* @return Collection<int, AssessmentScore>
|
||||||
|
*/
|
||||||
|
public function instrumentScores(
|
||||||
|
Patient $patient,
|
||||||
|
string $ownerRef,
|
||||||
|
?int $branchScope = null,
|
||||||
|
?string $templateCode = null,
|
||||||
|
int $limit = 50,
|
||||||
|
): Collection {
|
||||||
|
$query = AssessmentScore::query()
|
||||||
|
->owned($ownerRef)
|
||||||
|
->whereHas('assessment', function ($q) use ($patient, $branchScope) {
|
||||||
|
$q->where('patient_id', $patient->id)
|
||||||
|
->where('status', Assessment::STATUS_COMPLETED)
|
||||||
|
->when($branchScope !== null, fn ($qq) => $qq->where('branch_id', $branchScope));
|
||||||
|
})
|
||||||
|
->with(['assessment.template'])
|
||||||
|
->orderByDesc('computed_at')
|
||||||
|
->limit($limit);
|
||||||
|
|
||||||
|
if ($templateCode) {
|
||||||
|
$query->where('template_code', $templateCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest vitals from consultations (typed path — not outcome form).
|
||||||
|
*
|
||||||
|
* @return Collection<int, VitalSign>
|
||||||
|
*/
|
||||||
|
public function recentVitals(Patient $patient, string $ownerRef, int $limit = 10): Collection
|
||||||
|
{
|
||||||
|
return VitalSign::query()
|
||||||
|
->where('owner_ref', $ownerRef)
|
||||||
|
->whereHas('consultation', fn ($q) => $q->where('patient_id', $patient->id))
|
||||||
|
->orderByDesc('recorded_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sparkline-friendly series for a single outcome numeric code (oldest → newest).
|
||||||
|
*
|
||||||
|
* @return list<array{date: string, value: float|int|null}>
|
||||||
|
*/
|
||||||
|
public function numericOutcomeSeries(Patient $patient, string $ownerRef, string $questionCode, ?int $branchScope = null): array
|
||||||
|
{
|
||||||
|
$rows = $this->outcomeSeries($patient, $ownerRef, $branchScope)->reverse()->values();
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$val = $row['answers'][$questionCode] ?? null;
|
||||||
|
if ($val === null || $val === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[] = [
|
||||||
|
'date' => $row['assessed_at']?->format('Y-m-d') ?? '',
|
||||||
|
'value' => is_numeric($val) ? $val + 0 : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\ClinicalPathway;
|
||||||
|
use App\Models\Diagnosis;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match persisted diagnosis rows to clinical pathways (v1 algorithm).
|
||||||
|
*/
|
||||||
|
class PathwayMatcher
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param iterable<Diagnosis|array{code?: ?string, description?: ?string}> $diagnoses
|
||||||
|
* @return Collection<int, array{
|
||||||
|
* pathway: ClinicalPathway,
|
||||||
|
* pathway_code: string,
|
||||||
|
* pathway_name: string,
|
||||||
|
* rank: int,
|
||||||
|
* match_reason: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function match(iterable $diagnoses): Collection
|
||||||
|
{
|
||||||
|
$dxList = collect($diagnoses)->map(function ($d) {
|
||||||
|
if ($d instanceof Diagnosis) {
|
||||||
|
return [
|
||||||
|
'code' => $d->code,
|
||||||
|
'description' => $d->description,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => $d['code'] ?? null,
|
||||||
|
'description' => $d['description'] ?? null,
|
||||||
|
];
|
||||||
|
})->filter(fn ($d) => filled($d['code']) || filled($d['description']));
|
||||||
|
|
||||||
|
if ($dxList->isEmpty()) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$pathways = ClinicalPathway::query()
|
||||||
|
->active()
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$matches = [];
|
||||||
|
|
||||||
|
foreach ($pathways as $pathway) {
|
||||||
|
$rules = $pathway->match_rules ?? [];
|
||||||
|
$bestRank = null;
|
||||||
|
$bestReason = null;
|
||||||
|
|
||||||
|
foreach ($dxList as $dx) {
|
||||||
|
$result = $this->matchDiagnosis($dx, $rules);
|
||||||
|
if ($result === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($bestRank === null || $result['rank'] > $bestRank) {
|
||||||
|
$bestRank = $result['rank'];
|
||||||
|
$bestReason = $result['reason'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bestRank !== null) {
|
||||||
|
$matches[] = [
|
||||||
|
'pathway' => $pathway,
|
||||||
|
'pathway_code' => $pathway->code,
|
||||||
|
'pathway_name' => $pathway->name,
|
||||||
|
'rank' => $bestRank,
|
||||||
|
'match_reason' => $bestReason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($matches)
|
||||||
|
->sort(function ($a, $b) {
|
||||||
|
if ($a['rank'] !== $b['rank']) {
|
||||||
|
return $b['rank'] <=> $a['rank'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $a['pathway']->sort_order <=> $b['pathway']->sort_order;
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{code?: ?string, description?: ?string} $dx
|
||||||
|
* @param array<string, mixed> $rules
|
||||||
|
* @return array{rank: int, reason: string}|null
|
||||||
|
*/
|
||||||
|
protected function matchDiagnosis(array $dx, array $rules): ?array
|
||||||
|
{
|
||||||
|
$desc = $this->normalize((string) ($dx['description'] ?? ''));
|
||||||
|
$codeStripped = $this->stripCode((string) ($dx['code'] ?? ''));
|
||||||
|
|
||||||
|
foreach ($rules['exclude_keywords'] ?? [] as $exclude) {
|
||||||
|
$ex = $this->normalize((string) $exclude);
|
||||||
|
if ($ex !== '' && $desc !== '' && str_contains($desc, $ex)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rules['icd_prefixes'] ?? [] as $prefix) {
|
||||||
|
$p = $this->stripCode((string) $prefix);
|
||||||
|
if ($p !== '' && $codeStripped !== '' && str_starts_with($codeStripped, $p)) {
|
||||||
|
return ['rank' => 100, 'reason' => 'icd_prefix:'.$prefix];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rules['keywords'] ?? [] as $keyword) {
|
||||||
|
$kw = $this->normalize((string) $keyword);
|
||||||
|
if ($kw !== '' && $desc !== '' && str_contains($desc, $kw)) {
|
||||||
|
return ['rank' => 50, 'reason' => 'keyword:'.$keyword];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalize(string $s): string
|
||||||
|
{
|
||||||
|
$s = mb_strtolower(trim($s));
|
||||||
|
$s = preg_replace('/\s+/u', ' ', $s) ?? $s;
|
||||||
|
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function stripCode(string $code): string
|
||||||
|
{
|
||||||
|
return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code) ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\ClinicalPathway;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\PatientPathway;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
|
class PathwayService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected PathwayMatcher $matcher,
|
||||||
|
protected AssessmentService $assessments,
|
||||||
|
protected CarePermissions $permissions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<\App\Models\Diagnosis|array{code?: string, description?: string}> $diagnoses
|
||||||
|
* @return Collection<int, array{pathway: ClinicalPathway, pathway_code: string, pathway_name: string, rank: int, match_reason: string, already_active: bool}>
|
||||||
|
*/
|
||||||
|
public function suggest(Patient $patient, iterable $diagnoses): Collection
|
||||||
|
{
|
||||||
|
$activeIds = $this->activeFor($patient)->pluck('pathway_id')->all();
|
||||||
|
|
||||||
|
return $this->matcher->match($diagnoses)->map(function (array $row) use ($activeIds) {
|
||||||
|
$row['already_active'] = in_array($row['pathway']->id, $activeIds, true);
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* consultation?: ?Consultation,
|
||||||
|
* activation_diagnosis_text?: ?string,
|
||||||
|
* actor?: ?string,
|
||||||
|
* notes?: ?string,
|
||||||
|
* } $context
|
||||||
|
*/
|
||||||
|
public function activate(
|
||||||
|
Patient $patient,
|
||||||
|
ClinicalPathway $pathway,
|
||||||
|
string $ownerRef,
|
||||||
|
?Member $member,
|
||||||
|
array $context = [],
|
||||||
|
): PatientPathway {
|
||||||
|
if (! $this->permissions->can($member, 'pathways.manage')) {
|
||||||
|
throw new HttpException(403, 'Not authorized to manage clinical pathways.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $pathway->is_active) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'pathway_code' => 'Pathway is inactive.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$consultation = $context['consultation'] ?? null;
|
||||||
|
$actor = $context['actor'] ?? null;
|
||||||
|
$diagnosisText = $context['activation_diagnosis_text']
|
||||||
|
?? $this->snapshotDiagnosisText($consultation);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($patient, $pathway, $ownerRef, $member, $consultation, $actor, $diagnosisText, $context) {
|
||||||
|
$existingActive = PatientPathway::query()
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->where('pathway_id', $pathway->id)
|
||||||
|
->where('status', PatientPathway::STATUS_ACTIVE)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingActive) {
|
||||||
|
return $existingActive->load(['pathway.templates', 'assessments.template']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$patientPathway = PatientPathway::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $patient->organization_id,
|
||||||
|
'patient_id' => $patient->id,
|
||||||
|
'pathway_id' => $pathway->id,
|
||||||
|
'status' => PatientPathway::STATUS_ACTIVE,
|
||||||
|
'activated_at' => now(),
|
||||||
|
'activated_by' => $actor,
|
||||||
|
'activation_consultation_id' => $consultation?->id,
|
||||||
|
'activation_diagnosis_text' => $diagnosisText ? mb_substr($diagnosisText, 0, 500) : null,
|
||||||
|
'notes' => $context['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pathway->loadMissing('templates');
|
||||||
|
foreach ($pathway->templates->where('is_required_on_activation', true) as $binding) {
|
||||||
|
try {
|
||||||
|
$this->assessments->start(
|
||||||
|
$patient,
|
||||||
|
$binding->template_code,
|
||||||
|
$ownerRef,
|
||||||
|
$member,
|
||||||
|
[
|
||||||
|
'consultation' => $consultation,
|
||||||
|
'visit' => $consultation?->visit,
|
||||||
|
'patient_pathway' => $patientPathway,
|
||||||
|
'actor' => $actor,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
// Template may not be seeded yet (content pack PR); skip missing packs.
|
||||||
|
if (isset($e->errors()['template_code'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$ownerRef,
|
||||||
|
'pathway.activated',
|
||||||
|
$patient->organization_id,
|
||||||
|
$actor,
|
||||||
|
PatientPathway::class,
|
||||||
|
$patientPathway->id,
|
||||||
|
[
|
||||||
|
'pathway_code' => $pathway->code,
|
||||||
|
'activation_diagnosis_text' => $patientPathway->activation_diagnosis_text,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $patientPathway->load(['pathway.templates', 'assessments.template']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deactivate(
|
||||||
|
PatientPathway $patientPathway,
|
||||||
|
string $ownerRef,
|
||||||
|
?Member $member,
|
||||||
|
?string $actorRef = null,
|
||||||
|
): PatientPathway {
|
||||||
|
if (! $this->permissions->can($member, 'pathways.manage')) {
|
||||||
|
throw new HttpException(403, 'Not authorized to manage clinical pathways.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($patientPathway->owner_ref !== $ownerRef) {
|
||||||
|
throw new HttpException(404, 'Pathway enrollment not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($patientPathway->status !== PatientPathway::STATUS_ACTIVE) {
|
||||||
|
return $patientPathway;
|
||||||
|
}
|
||||||
|
|
||||||
|
$patientPathway->update([
|
||||||
|
'status' => PatientPathway::STATUS_INACTIVE,
|
||||||
|
'resolved_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$ownerRef,
|
||||||
|
'pathway.deactivated',
|
||||||
|
$patientPathway->organization_id,
|
||||||
|
$actorRef,
|
||||||
|
PatientPathway::class,
|
||||||
|
$patientPathway->id,
|
||||||
|
['pathway_code' => $patientPathway->pathway?->code ?? $patientPathway->pathway_id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $patientPathway->fresh(['pathway']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, PatientPathway>
|
||||||
|
*/
|
||||||
|
public function activeFor(Patient $patient): Collection
|
||||||
|
{
|
||||||
|
return PatientPathway::query()
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->where('status', PatientPathway::STATUS_ACTIVE)
|
||||||
|
->with('pathway')
|
||||||
|
->orderByDesc('activated_at')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function snapshotDiagnosisText(?Consultation $consultation): ?string
|
||||||
|
{
|
||||||
|
if (! $consultation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$consultation->loadMissing('diagnoses');
|
||||||
|
if ($consultation->diagnoses->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $consultation->diagnoses
|
||||||
|
->map(fn ($d) => trim(($d->code ? $d->code.' ' : '').$d->description))
|
||||||
|
->filter()
|
||||||
|
->implode('; ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,13 @@
|
|||||||
namespace App\Services\Care;
|
namespace App\Services\Care;
|
||||||
|
|
||||||
use App\Models\Appointment;
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentScore;
|
||||||
use App\Models\Bill;
|
use App\Models\Bill;
|
||||||
use App\Models\Diagnosis;
|
use App\Models\Diagnosis;
|
||||||
use App\Models\InvestigationRequest;
|
use App\Models\InvestigationRequest;
|
||||||
use App\Models\Patient;
|
use App\Models\Patient;
|
||||||
|
use App\Models\PatientPathway;
|
||||||
use App\Models\Payment;
|
use App\Models\Payment;
|
||||||
use App\Models\Visit;
|
use App\Models\Visit;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -200,4 +203,101 @@ class ReportService
|
|||||||
->limit(20)
|
->limit(20)
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Org-level assessment analytics (Enterprise / assessment_analytics).
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* summary: array<string, int>,
|
||||||
|
* by_template: Collection<int, object>,
|
||||||
|
* by_pathway: Collection<int, object>,
|
||||||
|
* score_averages: Collection<int, object>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function assessmentsReport(
|
||||||
|
string $ownerRef,
|
||||||
|
int $organizationId,
|
||||||
|
Carbon $from,
|
||||||
|
Carbon $to,
|
||||||
|
?int $branchId = null,
|
||||||
|
): array {
|
||||||
|
$base = Assessment::query()
|
||||||
|
->from('care_assessments')
|
||||||
|
->where('care_assessments.owner_ref', $ownerRef)
|
||||||
|
->where('care_assessments.organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('care_assessments.branch_id', $branchId))
|
||||||
|
->whereBetween('care_assessments.created_at', [$from, $to])
|
||||||
|
->whereNull('care_assessments.deleted_at');
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'total_started' => (clone $base)->count(),
|
||||||
|
'completed' => (clone $base)->where('care_assessments.status', Assessment::STATUS_COMPLETED)->count(),
|
||||||
|
'draft' => (clone $base)->where('care_assessments.status', Assessment::STATUS_DRAFT)->count(),
|
||||||
|
'cancelled' => (clone $base)->where('care_assessments.status', Assessment::STATUS_CANCELLED)->count(),
|
||||||
|
'patients_with_assessments' => (clone $base)->distinct('care_assessments.patient_id')->count('care_assessments.patient_id'),
|
||||||
|
'pathways_activated' => PatientPathway::query()
|
||||||
|
->where('care_patient_pathways.owner_ref', $ownerRef)
|
||||||
|
->where('care_patient_pathways.organization_id', $organizationId)
|
||||||
|
->whereBetween('care_patient_pathways.activated_at', [$from, $to])
|
||||||
|
->whereNull('care_patient_pathways.deleted_at')
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$byTemplate = (clone $base)
|
||||||
|
->join('care_assessment_templates', 'care_assessments.template_id', '=', 'care_assessment_templates.id')
|
||||||
|
->select(
|
||||||
|
'care_assessment_templates.code as template_code',
|
||||||
|
'care_assessment_templates.name as template_name',
|
||||||
|
DB::raw('count(*) as total'),
|
||||||
|
DB::raw("sum(case when care_assessments.status = 'completed' then 1 else 0 end) as completed"),
|
||||||
|
)
|
||||||
|
->groupBy('care_assessment_templates.code', 'care_assessment_templates.name')
|
||||||
|
->orderByDesc('total')
|
||||||
|
->limit(30)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byPathway = PatientPathway::query()
|
||||||
|
->from('care_patient_pathways')
|
||||||
|
->where('care_patient_pathways.owner_ref', $ownerRef)
|
||||||
|
->where('care_patient_pathways.organization_id', $organizationId)
|
||||||
|
->whereBetween('care_patient_pathways.activated_at', [$from, $to])
|
||||||
|
->whereNull('care_patient_pathways.deleted_at')
|
||||||
|
->join('care_clinical_pathways', 'care_patient_pathways.pathway_id', '=', 'care_clinical_pathways.id')
|
||||||
|
->select(
|
||||||
|
'care_clinical_pathways.code as pathway_code',
|
||||||
|
'care_clinical_pathways.name as pathway_name',
|
||||||
|
DB::raw('count(*) as total'),
|
||||||
|
DB::raw("sum(case when care_patient_pathways.status = 'active' then 1 else 0 end) as still_active"),
|
||||||
|
)
|
||||||
|
->groupBy('care_clinical_pathways.code', 'care_clinical_pathways.name')
|
||||||
|
->orderByDesc('total')
|
||||||
|
->limit(20)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$scoreAverages = AssessmentScore::query()
|
||||||
|
->from('care_assessment_scores')
|
||||||
|
->where('care_assessment_scores.owner_ref', $ownerRef)
|
||||||
|
->whereHas('assessment', function (Builder $q) use ($organizationId, $branchId, $from, $to) {
|
||||||
|
$q->where('care_assessments.organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($qq) => $qq->where('care_assessments.branch_id', $branchId))
|
||||||
|
->whereBetween('care_assessments.completed_at', [$from, $to]);
|
||||||
|
})
|
||||||
|
->select(
|
||||||
|
'care_assessment_scores.template_code',
|
||||||
|
DB::raw('count(*) as completions'),
|
||||||
|
DB::raw('avg(care_assessment_scores.total_score) as avg_total'),
|
||||||
|
DB::raw('avg(care_assessment_scores.max_score) as avg_max'),
|
||||||
|
)
|
||||||
|
->groupBy('care_assessment_scores.template_code')
|
||||||
|
->orderByDesc('completions')
|
||||||
|
->limit(20)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => $summary,
|
||||||
|
'by_template' => $byTemplate,
|
||||||
|
'by_pathway' => $byPathway,
|
||||||
|
'score_averages' => $scoreAverages,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Contracts\Care\ScoresAssessment;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentQuestion;
|
||||||
|
use App\Models\AssessmentScore;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class ScoringService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Preview scores without persisting.
|
||||||
|
*
|
||||||
|
* @return array{total: ?float, max: ?float, subscores: array<string, float|int>, severity_label: ?string}
|
||||||
|
*/
|
||||||
|
public function preview(Assessment $assessment): array
|
||||||
|
{
|
||||||
|
$assessment->loadMissing(['template.questions', 'answers.question']);
|
||||||
|
|
||||||
|
return $this->compute($assessment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function materialize(Assessment $assessment): AssessmentScore
|
||||||
|
{
|
||||||
|
$assessment->loadMissing(['template.questions', 'answers.question']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->compute($assessment);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('care.scoring.failure', [
|
||||||
|
'template_code' => $assessment->template?->code,
|
||||||
|
'assessment_uuid' => $assessment->uuid,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AssessmentScore::updateOrCreate(
|
||||||
|
['assessment_id' => $assessment->id],
|
||||||
|
[
|
||||||
|
'owner_ref' => $assessment->owner_ref,
|
||||||
|
'template_code' => $assessment->template->code,
|
||||||
|
'total_score' => $result['total'],
|
||||||
|
'max_score' => $result['max'],
|
||||||
|
'subscores' => $result['subscores'] !== [] ? $result['subscores'] : null,
|
||||||
|
'severity_label' => $result['severity_label'],
|
||||||
|
'computed_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this template requires score materialization on complete.
|
||||||
|
*/
|
||||||
|
public function requiresScore(Assessment $assessment): bool
|
||||||
|
{
|
||||||
|
$strategy = $assessment->template?->scoring_strategy;
|
||||||
|
|
||||||
|
return filled($strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{total: ?float, max: ?float, subscores: array<string, float|int>, severity_label: ?string}
|
||||||
|
*/
|
||||||
|
protected function compute(Assessment $assessment): array
|
||||||
|
{
|
||||||
|
$strategy = $assessment->template?->scoring_strategy;
|
||||||
|
|
||||||
|
if (! filled($strategy)) {
|
||||||
|
return [
|
||||||
|
'total' => null,
|
||||||
|
'max' => null,
|
||||||
|
'subscores' => [],
|
||||||
|
'severity_label' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($strategy, 'custom:')) {
|
||||||
|
return $this->customScore($assessment, substr($strategy, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($strategy) {
|
||||||
|
'sum_items' => $this->sumItems($assessment),
|
||||||
|
'single_value' => $this->singleValue($assessment),
|
||||||
|
default => throw ValidationException::withMessages([
|
||||||
|
'scoring' => "Unknown scoring strategy [{$strategy}].",
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{total: ?float, max: ?float, subscores: array<string, float|int>, severity_label: ?string}
|
||||||
|
*/
|
||||||
|
protected function sumItems(Assessment $assessment): array
|
||||||
|
{
|
||||||
|
$scoreItems = $assessment->template->questions
|
||||||
|
->where('answer_type', AssessmentQuestion::TYPE_SCORE_ITEM);
|
||||||
|
|
||||||
|
if ($scoreItems->isEmpty()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'scoring' => 'sum_items strategy requires score_item questions.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$answersByQuestion = $assessment->answers->keyBy('question_id');
|
||||||
|
$total = 0.0;
|
||||||
|
$max = 0.0;
|
||||||
|
$subscores = [];
|
||||||
|
$missing = [];
|
||||||
|
|
||||||
|
foreach ($scoreItems as $question) {
|
||||||
|
$answer = $answersByQuestion->get($question->id);
|
||||||
|
$value = $answer?->value_number;
|
||||||
|
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
$missing[] = $question->code;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$num = (float) $value;
|
||||||
|
$total += $num;
|
||||||
|
$itemMax = $this->itemMaxScore($question);
|
||||||
|
$max += $itemMax;
|
||||||
|
|
||||||
|
$key = $question->score_key ?: $question->code;
|
||||||
|
$subscores[$key] = ($subscores[$key] ?? 0) + $num;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missing !== []) {
|
||||||
|
throw ValidationException::withMessages(
|
||||||
|
collect($missing)->mapWithKeys(
|
||||||
|
fn ($code) => ["answers.{$code}" => 'Score item is required for scoring.']
|
||||||
|
)->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $total,
|
||||||
|
'max' => $max > 0 ? $max : null,
|
||||||
|
'subscores' => $subscores,
|
||||||
|
'severity_label' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{total: ?float, max: ?float, subscores: array<string, float|int>, severity_label: ?string}
|
||||||
|
*/
|
||||||
|
protected function singleValue(Assessment $assessment): array
|
||||||
|
{
|
||||||
|
$scored = $assessment->template->questions->filter(
|
||||||
|
fn (AssessmentQuestion $q) => in_array($q->answer_type, [
|
||||||
|
AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
AssessmentQuestion::TYPE_SCALE,
|
||||||
|
AssessmentQuestion::TYPE_NUMBER,
|
||||||
|
], true)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($scored->isEmpty()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'scoring' => 'single_value strategy requires a scored question.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$question = $scored->sortBy('sort_order')->first();
|
||||||
|
$answer = $assessment->answers->firstWhere('question_id', $question->id);
|
||||||
|
if ($answer?->value_number === null || $answer->value_number === '') {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"answers.{$question->code}" => 'Score is required for scoring.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (float) $answer->value_number;
|
||||||
|
$max = $this->itemMaxScore($question);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $total,
|
||||||
|
'max' => $max > 0 ? $max : null,
|
||||||
|
'subscores' => [$question->score_key ?: $question->code => $total],
|
||||||
|
'severity_label' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{total: ?float, max: ?float, subscores: array<string, float|int>, severity_label: ?string}
|
||||||
|
*/
|
||||||
|
protected function customScore(Assessment $assessment, string $handlerName): array
|
||||||
|
{
|
||||||
|
if (! preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $handlerName)) {
|
||||||
|
throw new InvalidArgumentException('Invalid custom scoring handler name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = 'App\\Services\\Care\\Scoring\\'.$handlerName;
|
||||||
|
if (! class_exists($class)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'scoring' => "Scoring handler [{$handlerName}] is not registered.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$handler = app($class);
|
||||||
|
if (! $handler instanceof ScoresAssessment) {
|
||||||
|
throw new InvalidArgumentException("Handler [{$handlerName}] must implement ScoresAssessment.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $handler->score($assessment);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => isset($result['total']) ? (float) $result['total'] : null,
|
||||||
|
'max' => isset($result['max']) ? (float) $result['max'] : null,
|
||||||
|
'subscores' => is_array($result['subscores'] ?? null) ? $result['subscores'] : [],
|
||||||
|
'severity_label' => $result['severity_label'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function itemMaxScore(AssessmentQuestion $question): float
|
||||||
|
{
|
||||||
|
$options = $question->options ?? [];
|
||||||
|
if (isset($options['max']) && is_numeric($options['max'])) {
|
||||||
|
return (float) $options['max'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$choices = $options['choices'] ?? [];
|
||||||
|
$scores = [];
|
||||||
|
foreach ($choices as $choice) {
|
||||||
|
if (isset($choice['score']) && is_numeric($choice['score'])) {
|
||||||
|
$scores[] = (float) $choice['score'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scores !== [] ? max($scores) : 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,46 @@ return [
|
|||||||
'drug.created' => 'Drug added',
|
'drug.created' => 'Drug added',
|
||||||
'drug.updated' => 'Drug updated',
|
'drug.updated' => 'Drug updated',
|
||||||
'drug.batch_received' => 'Drug stock received',
|
'drug.batch_received' => 'Drug stock received',
|
||||||
|
'assessment.started' => 'Assessment started',
|
||||||
|
'assessment.updated' => 'Assessment updated',
|
||||||
|
'assessment.completed' => 'Assessment completed',
|
||||||
|
'assessment.cancelled' => 'Assessment cancelled',
|
||||||
|
'pathway.activated' => 'Clinical pathway activated',
|
||||||
|
'pathway.deactivated' => 'Clinical pathway deactivated',
|
||||||
|
],
|
||||||
|
|
||||||
|
'assessment_statuses' => [
|
||||||
|
'draft' => 'Draft',
|
||||||
|
'completed' => 'Completed',
|
||||||
|
'cancelled' => 'Cancelled',
|
||||||
|
],
|
||||||
|
|
||||||
|
'assessment_template_categories' => [
|
||||||
|
'universal' => 'Universal intake',
|
||||||
|
'disease' => 'Disease-specific',
|
||||||
|
'outcome' => 'Outcome measures',
|
||||||
|
'screening' => 'Screening',
|
||||||
|
],
|
||||||
|
|
||||||
|
'assessment_answer_types' => [
|
||||||
|
'text' => 'Text',
|
||||||
|
'textarea' => 'Long text',
|
||||||
|
'number' => 'Number',
|
||||||
|
'integer' => 'Integer',
|
||||||
|
'boolean' => 'Yes / No',
|
||||||
|
'date' => 'Date',
|
||||||
|
'datetime' => 'Date & time',
|
||||||
|
'single_choice' => 'Single choice',
|
||||||
|
'multi_choice' => 'Multiple choice',
|
||||||
|
'scale' => 'Scale',
|
||||||
|
'score_item' => 'Score item',
|
||||||
|
'calculated' => 'Calculated',
|
||||||
|
],
|
||||||
|
|
||||||
|
'patient_pathway_statuses' => [
|
||||||
|
'active' => 'Active',
|
||||||
|
'resolved' => 'Resolved',
|
||||||
|
'inactive' => 'Inactive',
|
||||||
],
|
],
|
||||||
|
|
||||||
'appointment_statuses' => [
|
'appointment_statuses' => [
|
||||||
@@ -218,6 +258,8 @@ return [
|
|||||||
'laboratory' => 'Laboratory',
|
'laboratory' => 'Laboratory',
|
||||||
'finance' => 'Finance',
|
'finance' => 'Finance',
|
||||||
'clinical' => 'Clinical trends',
|
'clinical' => 'Clinical trends',
|
||||||
|
// KD-18: org-level multi-patient assessment analytics (Enterprise assessment_analytics)
|
||||||
|
'assessments' => 'Assessment analytics',
|
||||||
],
|
],
|
||||||
|
|
||||||
'plans' => [
|
'plans' => [
|
||||||
@@ -254,6 +296,8 @@ return [
|
|||||||
'pharmacy',
|
'pharmacy',
|
||||||
'billing',
|
'billing',
|
||||||
'queue_integration',
|
'queue_integration',
|
||||||
|
// KD-18: org-level multi-patient assessment/outcome analytics
|
||||||
|
'assessment_analytics',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "asthma_core",
|
||||||
|
"name": "Asthma assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": null,
|
||||||
|
"description": "Control, exacerbations, inhaler technique, peak flow.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": [
|
||||||
|
"doctor",
|
||||||
|
"nurse"
|
||||||
|
],
|
||||||
|
"specialty": "respiratory",
|
||||||
|
"estimated_minutes": 8,
|
||||||
|
"pathway_codes": [
|
||||||
|
"asthma"
|
||||||
|
],
|
||||||
|
"attribution": "Ladill Care Asthma assessment content pack (platform-authored clinical checklist)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "control",
|
||||||
|
"section": "Control",
|
||||||
|
"label": "Asthma control",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "well",
|
||||||
|
"label": "Well controlled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "partly",
|
||||||
|
"label": "Partly controlled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "uncontrolled",
|
||||||
|
"label": "Uncontrolled"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "exacerbations_12m",
|
||||||
|
"section": "Control",
|
||||||
|
"label": "Exacerbations in last 12 months",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 20,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 50
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "peak_flow",
|
||||||
|
"section": "Lung function",
|
||||||
|
"label": "Peak expiratory flow (L/min)",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": {
|
||||||
|
"min": 50,
|
||||||
|
"max": 800,
|
||||||
|
"unit": "L/min"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 50,
|
||||||
|
"max": 800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "inhaler_technique",
|
||||||
|
"section": "Treatment",
|
||||||
|
"label": "Inhaler technique",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "good",
|
||||||
|
"label": "Good"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fair",
|
||||||
|
"label": "Fair"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "poor",
|
||||||
|
"label": "Poor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "not_assessed",
|
||||||
|
"label": "Not assessed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "night_symptoms",
|
||||||
|
"section": "Symptoms",
|
||||||
|
"label": "Night symptoms",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "none",
|
||||||
|
"label": "None"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "occasional",
|
||||||
|
"label": "Occasional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "frequent",
|
||||||
|
"label": "Frequent"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"validation": {
|
||||||
|
"max": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "barthel",
|
||||||
|
"name": "Barthel Index",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": "sum_items",
|
||||||
|
"description": "Barthel Index of Activities of Daily Living (0–100). Optional on stroke pathway activation.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": ["doctor", "nurse"],
|
||||||
|
"specialty": "neurology",
|
||||||
|
"estimated_minutes": 5,
|
||||||
|
"pathway_codes": ["stroke"],
|
||||||
|
"max_total": 100,
|
||||||
|
"attribution": "Barthel Index — standard ADL scale for clinical use. Verify licensing for commercial redistribution."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "feeding",
|
||||||
|
"section": "ADL",
|
||||||
|
"label": "Feeding",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"score_key": "adl",
|
||||||
|
"options": {
|
||||||
|
"max": 10,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Unable", "score": 0 },
|
||||||
|
{ "code": "5", "label": "Needs help cutting, spreading butter, etc., or requires modified diet", "score": 5 },
|
||||||
|
{ "code": "10", "label": "Independent", "score": 10 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bathing",
|
||||||
|
"section": "ADL",
|
||||||
|
"label": "Bathing",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 20,
|
||||||
|
"score_key": "adl",
|
||||||
|
"options": {
|
||||||
|
"max": 5,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Dependent", "score": 0 },
|
||||||
|
{ "code": "5", "label": "Independent (or in shower)", "score": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "grooming",
|
||||||
|
"section": "ADL",
|
||||||
|
"label": "Grooming",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 30,
|
||||||
|
"score_key": "adl",
|
||||||
|
"options": {
|
||||||
|
"max": 5,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Needs help with personal care", "score": 0 },
|
||||||
|
{ "code": "5", "label": "Independent face/hair/teeth/shaving", "score": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dressing",
|
||||||
|
"section": "ADL",
|
||||||
|
"label": "Dressing",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 40,
|
||||||
|
"score_key": "adl",
|
||||||
|
"options": {
|
||||||
|
"max": 10,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Dependent", "score": 0 },
|
||||||
|
{ "code": "5", "label": "Needs help but can do about half unaided", "score": 5 },
|
||||||
|
{ "code": "10", "label": "Independent (including buttons, zips, laces, etc.)", "score": 10 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bowels",
|
||||||
|
"section": "ADL",
|
||||||
|
"label": "Bowels",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 50,
|
||||||
|
"score_key": "adl",
|
||||||
|
"options": {
|
||||||
|
"max": 10,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Incontinent (or needs to be given enemas)", "score": 0 },
|
||||||
|
{ "code": "5", "label": "Occasional accident", "score": 5 },
|
||||||
|
{ "code": "10", "label": "Continent", "score": 10 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bladder",
|
||||||
|
"section": "ADL",
|
||||||
|
"label": "Bladder",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 60,
|
||||||
|
"score_key": "adl",
|
||||||
|
"options": {
|
||||||
|
"max": 10,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Incontinent, or catheterized and unable to manage alone", "score": 0 },
|
||||||
|
{ "code": "5", "label": "Occasional accident", "score": 5 },
|
||||||
|
{ "code": "10", "label": "Continent", "score": 10 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "toilet",
|
||||||
|
"section": "ADL",
|
||||||
|
"label": "Toilet use",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 70,
|
||||||
|
"score_key": "adl",
|
||||||
|
"options": {
|
||||||
|
"max": 10,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Dependent", "score": 0 },
|
||||||
|
{ "code": "5", "label": "Needs some help, but can do something alone", "score": 5 },
|
||||||
|
{ "code": "10", "label": "Independent (on and off, dressing, wiping)", "score": 10 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "transfers",
|
||||||
|
"section": "ADL",
|
||||||
|
"label": "Transfers (bed to chair and back)",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 80,
|
||||||
|
"score_key": "adl",
|
||||||
|
"options": {
|
||||||
|
"max": 15,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Unable, no sitting balance", "score": 0 },
|
||||||
|
{ "code": "5", "label": "Major help (one or two people, physical), can sit", "score": 5 },
|
||||||
|
{ "code": "10", "label": "Minor help (verbal or physical)", "score": 10 },
|
||||||
|
{ "code": "15", "label": "Independent", "score": 15 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mobility",
|
||||||
|
"section": "ADL",
|
||||||
|
"label": "Mobility (on level surfaces)",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 90,
|
||||||
|
"score_key": "adl",
|
||||||
|
"options": {
|
||||||
|
"max": 15,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Immobile or <50 yards", "score": 0 },
|
||||||
|
{ "code": "5", "label": "Wheelchair independent, including corners, >50 yards", "score": 5 },
|
||||||
|
{ "code": "10", "label": "Walks with help of one person (verbal or physical) >50 yards", "score": 10 },
|
||||||
|
{ "code": "15", "label": "Independent (but may use any aid; for example, stick) >50 yards", "score": 15 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "stairs",
|
||||||
|
"section": "ADL",
|
||||||
|
"label": "Stairs",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 100,
|
||||||
|
"score_key": "adl",
|
||||||
|
"options": {
|
||||||
|
"max": 10,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Unable", "score": 0 },
|
||||||
|
{ "code": "5", "label": "Needs help (verbal, physical, carrying aid)", "score": 5 },
|
||||||
|
{ "code": "10", "label": "Independent", "score": 10 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "cancer_core",
|
||||||
|
"name": "Cancer care assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": "single_value",
|
||||||
|
"description": "Primary site, stage summary, performance status, treatment phase.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": [
|
||||||
|
"doctor",
|
||||||
|
"nurse"
|
||||||
|
],
|
||||||
|
"specialty": "oncology",
|
||||||
|
"estimated_minutes": 8,
|
||||||
|
"pathway_codes": [
|
||||||
|
"cancer"
|
||||||
|
],
|
||||||
|
"attribution": "Ladill Care Cancer care assessment content pack (platform-authored clinical checklist)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "primary_site",
|
||||||
|
"section": "Disease",
|
||||||
|
"label": "Primary site / diagnosis",
|
||||||
|
"answer_type": "text",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"validation": {
|
||||||
|
"max": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "stage_summary",
|
||||||
|
"section": "Disease",
|
||||||
|
"label": "Stage summary",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 20,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "local",
|
||||||
|
"label": "Localized"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "regional",
|
||||||
|
"label": "Regional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "metastatic",
|
||||||
|
"label": "Metastatic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unknown",
|
||||||
|
"label": "Unknown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "remission",
|
||||||
|
"label": "Remission"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ecog",
|
||||||
|
"section": "Function",
|
||||||
|
"label": "ECOG performance status",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "0",
|
||||||
|
"label": "0 \u2014 Fully active",
|
||||||
|
"score": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "1",
|
||||||
|
"label": "1 \u2014 Restricted strenuous",
|
||||||
|
"score": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "2",
|
||||||
|
"label": "2 \u2014 Ambulatory, self-care",
|
||||||
|
"score": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "3",
|
||||||
|
"label": "3 \u2014 Limited self-care",
|
||||||
|
"score": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "4",
|
||||||
|
"label": "4 \u2014 Completely disabled",
|
||||||
|
"score": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"score_key": "ecog"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "treatment_phase",
|
||||||
|
"section": "Treatment",
|
||||||
|
"label": "Treatment phase",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "workup",
|
||||||
|
"label": "Work-up"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "active",
|
||||||
|
"label": "Active treatment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "surveillance",
|
||||||
|
"label": "Surveillance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "palliative",
|
||||||
|
"label": "Palliative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "survivorship",
|
||||||
|
"label": "Survivorship"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"validation": {
|
||||||
|
"max": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "ckd_core",
|
||||||
|
"name": "CKD assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": null,
|
||||||
|
"description": "CKD stage, creatinine, eGFR, proteinuria, anaemia flags.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": [
|
||||||
|
"doctor",
|
||||||
|
"nurse"
|
||||||
|
],
|
||||||
|
"specialty": "nephrology",
|
||||||
|
"estimated_minutes": 8,
|
||||||
|
"pathway_codes": [
|
||||||
|
"ckd"
|
||||||
|
],
|
||||||
|
"attribution": "Ladill Care CKD assessment content pack (platform-authored clinical checklist)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "ckd_stage",
|
||||||
|
"section": "Staging",
|
||||||
|
"label": "CKD stage",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "1",
|
||||||
|
"label": "Stage 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "2",
|
||||||
|
"label": "Stage 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "3a",
|
||||||
|
"label": "Stage 3a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "3b",
|
||||||
|
"label": "Stage 3b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "4",
|
||||||
|
"label": "Stage 4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "5",
|
||||||
|
"label": "Stage 5"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "creatinine",
|
||||||
|
"section": "Labs",
|
||||||
|
"label": "Creatinine (\u00b5mol/L)",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 20,
|
||||||
|
"options": {
|
||||||
|
"min": 20,
|
||||||
|
"max": 2000,
|
||||||
|
"unit": "\u00b5mol/L"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 20,
|
||||||
|
"max": 2000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "egfr",
|
||||||
|
"section": "Labs",
|
||||||
|
"label": "eGFR (mL/min/1.73m\u00b2)",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 150,
|
||||||
|
"unit": "mL/min"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 150
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "proteinuria",
|
||||||
|
"section": "Labs",
|
||||||
|
"label": "Proteinuria / ACR",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "none",
|
||||||
|
"label": "None/normal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "micro",
|
||||||
|
"label": "Microalbuminuria"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "macro",
|
||||||
|
"label": "Overt proteinuria"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unknown",
|
||||||
|
"label": "Unknown"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "anaemia",
|
||||||
|
"section": "Complications",
|
||||||
|
"label": "Anaemia of CKD",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "no",
|
||||||
|
"label": "No"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "yes",
|
||||||
|
"label": "Yes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unknown",
|
||||||
|
"label": "Unknown"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dialysis",
|
||||||
|
"section": "Treatment",
|
||||||
|
"label": "Dialysis status",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 60,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "none",
|
||||||
|
"label": "None"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hd",
|
||||||
|
"label": "Haemodialysis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "pd",
|
||||||
|
"label": "Peritoneal dialysis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "transplant",
|
||||||
|
"label": "Transplant"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"validation": {
|
||||||
|
"max": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "copd_core",
|
||||||
|
"name": "COPD assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": "single_value",
|
||||||
|
"description": "CAT, mMRC, spirometry, pack-years, oxygen therapy.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": [
|
||||||
|
"doctor",
|
||||||
|
"nurse"
|
||||||
|
],
|
||||||
|
"specialty": "respiratory",
|
||||||
|
"estimated_minutes": 8,
|
||||||
|
"pathway_codes": [
|
||||||
|
"copd"
|
||||||
|
],
|
||||||
|
"attribution": "Ladill Care COPD assessment content pack (platform-authored clinical checklist)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "mmrc",
|
||||||
|
"section": "Dyspnea",
|
||||||
|
"label": "mMRC dyspnea scale",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "0",
|
||||||
|
"label": "0 \u2014 breathless only with strenuous exercise",
|
||||||
|
"score": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "1",
|
||||||
|
"label": "1 \u2014 short of breath when hurrying",
|
||||||
|
"score": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "2",
|
||||||
|
"label": "2 \u2014 walks slower than people of same age",
|
||||||
|
"score": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "3",
|
||||||
|
"label": "3 \u2014 stops for breath after ~100 m",
|
||||||
|
"score": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "4",
|
||||||
|
"label": "4 \u2014 too breathless to leave house",
|
||||||
|
"score": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"score_key": "mmrc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "cat_score",
|
||||||
|
"section": "Symptoms",
|
||||||
|
"label": "CAT score (0\u201340)",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 20,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 40
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 40
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fev1_percent",
|
||||||
|
"section": "Spirometry",
|
||||||
|
"label": "FEV1 % predicted",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": {
|
||||||
|
"min": 10,
|
||||||
|
"max": 150,
|
||||||
|
"unit": "%"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 10,
|
||||||
|
"max": 150
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "pack_years",
|
||||||
|
"section": "Risk",
|
||||||
|
"label": "Smoking pack-years",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 200
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "oxygen_therapy",
|
||||||
|
"section": "Treatment",
|
||||||
|
"label": "Long-term oxygen therapy",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "no",
|
||||||
|
"label": "No"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "yes",
|
||||||
|
"label": "Yes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "exacerbations_12m",
|
||||||
|
"section": "Control",
|
||||||
|
"label": "Exacerbations last 12 months",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 60,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 30
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"validation": {
|
||||||
|
"max": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "dementia_core",
|
||||||
|
"name": "Dementia assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": null,
|
||||||
|
"description": "MMSE/MoCA scores, ADL, behaviour, caregiver burden.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": [
|
||||||
|
"doctor",
|
||||||
|
"nurse"
|
||||||
|
],
|
||||||
|
"specialty": "neurology",
|
||||||
|
"estimated_minutes": 8,
|
||||||
|
"pathway_codes": [
|
||||||
|
"dementia"
|
||||||
|
],
|
||||||
|
"attribution": "Ladill Care Dementia assessment content pack (platform-authored clinical checklist)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "mmse",
|
||||||
|
"section": "Cognition",
|
||||||
|
"label": "MMSE score (0\u201330)",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 30
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "moca",
|
||||||
|
"section": "Cognition",
|
||||||
|
"label": "MoCA score (0\u201330)",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 20,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 30
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "adl",
|
||||||
|
"section": "Function",
|
||||||
|
"label": "ADL independence",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "independent",
|
||||||
|
"label": "Independent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "partial",
|
||||||
|
"label": "Partial help"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dependent",
|
||||||
|
"label": "Dependent"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "behavioural_symptoms",
|
||||||
|
"section": "Behaviour",
|
||||||
|
"label": "Behavioural symptoms",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "none",
|
||||||
|
"label": "None"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mild",
|
||||||
|
"label": "Mild"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "moderate",
|
||||||
|
"label": "Moderate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "severe",
|
||||||
|
"label": "Severe"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "caregiver_burden",
|
||||||
|
"section": "Caregiver",
|
||||||
|
"label": "Caregiver burden (0\u201310)",
|
||||||
|
"answer_type": "scale",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 10
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"validation": {
|
||||||
|
"max": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "diabetes_core",
|
||||||
|
"name": "Diabetes assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": null,
|
||||||
|
"description": "Core diabetes review: glycaemia, feet, eyes, kidney markers, lifestyle, and insulin regimen.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": ["doctor", "nurse"],
|
||||||
|
"specialty": "endocrinology",
|
||||||
|
"estimated_minutes": 12,
|
||||||
|
"pathway_codes": ["diabetes"],
|
||||||
|
"attribution": "Ladill Care diabetes core assessment (platform-authored checklist aligned to common clinic review items)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "hba1c",
|
||||||
|
"section": "Glycaemia",
|
||||||
|
"label": "HbA1c (%)",
|
||||||
|
"help_text": "Optional manual entry. Prefer lab results when available.",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": { "min": 3, "max": 20, "unit": "%" },
|
||||||
|
"validation": { "min": 3, "max": 20 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hba1c_date",
|
||||||
|
"section": "Glycaemia",
|
||||||
|
"label": "HbA1c date",
|
||||||
|
"answer_type": "date",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hypoglycaemia_episodes",
|
||||||
|
"section": "Glycaemia",
|
||||||
|
"label": "Hypoglycaemia episodes (last 3 months)",
|
||||||
|
"answer_type": "integer",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": { "min": 0, "max": 100 },
|
||||||
|
"validation": { "min": 0, "max": 100 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "insulin_regimen",
|
||||||
|
"section": "Treatment",
|
||||||
|
"label": "Insulin regimen",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"validation": { "max": 2000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "diet_adherence",
|
||||||
|
"section": "Treatment",
|
||||||
|
"label": "Diet adherence",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "good", "label": "Good" },
|
||||||
|
{ "code": "fair", "label": "Fair" },
|
||||||
|
{ "code": "poor", "label": "Poor" },
|
||||||
|
{ "code": "unknown", "label": "Unknown" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "foot_assessment",
|
||||||
|
"section": "Complications",
|
||||||
|
"label": "Foot assessment",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 60,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "normal", "label": "Normal" },
|
||||||
|
{ "code": "at_risk", "label": "At risk" },
|
||||||
|
{ "code": "ulcer", "label": "Ulcer present" },
|
||||||
|
{ "code": "amputation", "label": "Prior amputation" },
|
||||||
|
{ "code": "not_examined", "label": "Not examined" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "monofilament",
|
||||||
|
"section": "Complications",
|
||||||
|
"label": "Monofilament test",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 70,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "intact", "label": "Sensation intact" },
|
||||||
|
{ "code": "reduced", "label": "Reduced" },
|
||||||
|
{ "code": "absent", "label": "Absent" },
|
||||||
|
{ "code": "not_done", "label": "Not done" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "eye_examination",
|
||||||
|
"section": "Complications",
|
||||||
|
"label": "Eye examination / retinopathy status",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 80,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "normal", "label": "No retinopathy" },
|
||||||
|
{ "code": "non_proliferative", "label": "Non-proliferative" },
|
||||||
|
{ "code": "proliferative", "label": "Proliferative" },
|
||||||
|
{ "code": "maculopathy", "label": "Maculopathy" },
|
||||||
|
{ "code": "unknown", "label": "Unknown / pending" },
|
||||||
|
{ "code": "not_done", "label": "Not examined this visit" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "microalbuminuria",
|
||||||
|
"section": "Complications",
|
||||||
|
"label": "Microalbuminuria / ACR",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 90,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "normal", "label": "Normal" },
|
||||||
|
{ "code": "elevated", "label": "Elevated" },
|
||||||
|
{ "code": "unknown", "label": "Unknown" },
|
||||||
|
{ "code": "not_done", "label": "Not done" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "neuropathy_score",
|
||||||
|
"section": "Complications",
|
||||||
|
"label": "Neuropathy score (0–10 clinical impression)",
|
||||||
|
"answer_type": "scale",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"options": { "min": 0, "max": 10 },
|
||||||
|
"validation": { "min": 0, "max": 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Clinical notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 110,
|
||||||
|
"validation": { "max": 5000 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "gcs",
|
||||||
|
"name": "Glasgow Coma Scale",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": "sum_items",
|
||||||
|
"description": "Glasgow Coma Scale (E + V + M). Total 3–15. Optional on stroke pathway.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": ["doctor", "nurse"],
|
||||||
|
"specialty": "neurology",
|
||||||
|
"estimated_minutes": 2,
|
||||||
|
"pathway_codes": ["stroke"],
|
||||||
|
"max_total": 15,
|
||||||
|
"attribution": "Glasgow Coma Scale (GCS) — Teasdale & Jennett. Standard clinical instrument; confirm local documentation practice."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "eye",
|
||||||
|
"section": "GCS",
|
||||||
|
"label": "Eye opening (E)",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"score_key": "eye",
|
||||||
|
"options": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 4,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "4", "label": "Spontaneous", "score": 4 },
|
||||||
|
{ "code": "3", "label": "To speech", "score": 3 },
|
||||||
|
{ "code": "2", "label": "To pain", "score": 2 },
|
||||||
|
{ "code": "1", "label": "None", "score": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "verbal",
|
||||||
|
"section": "GCS",
|
||||||
|
"label": "Verbal response (V)",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 20,
|
||||||
|
"score_key": "verbal",
|
||||||
|
"options": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 5,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "5", "label": "Oriented", "score": 5 },
|
||||||
|
{ "code": "4", "label": "Confused", "score": 4 },
|
||||||
|
{ "code": "3", "label": "Inappropriate words", "score": 3 },
|
||||||
|
{ "code": "2", "label": "Incomprehensible sounds", "score": 2 },
|
||||||
|
{ "code": "1", "label": "None", "score": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "motor",
|
||||||
|
"section": "GCS",
|
||||||
|
"label": "Motor response (M)",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 30,
|
||||||
|
"score_key": "motor",
|
||||||
|
"options": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 6,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "6", "label": "Obeys commands", "score": 6 },
|
||||||
|
{ "code": "5", "label": "Localizes pain", "score": 5 },
|
||||||
|
{ "code": "4", "label": "Withdrawal from pain", "score": 4 },
|
||||||
|
{ "code": "3", "label": "Flexion to pain (decorticate)", "score": 3 },
|
||||||
|
{ "code": "2", "label": "Extension to pain (decerebrate)", "score": 2 },
|
||||||
|
{ "code": "1", "label": "None", "score": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "heart_failure_core",
|
||||||
|
"name": "Heart failure assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": "single_value",
|
||||||
|
"description": "NYHA class, EF, BNP, fluid status for heart failure pathway.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": [
|
||||||
|
"doctor",
|
||||||
|
"nurse"
|
||||||
|
],
|
||||||
|
"specialty": "cardiology",
|
||||||
|
"estimated_minutes": 8,
|
||||||
|
"pathway_codes": [
|
||||||
|
"heart_failure"
|
||||||
|
],
|
||||||
|
"attribution": "Ladill Care Heart failure assessment content pack (platform-authored clinical checklist)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "nyha",
|
||||||
|
"section": "Classification",
|
||||||
|
"label": "NYHA classification",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "1",
|
||||||
|
"label": "I \u2014 No limitation",
|
||||||
|
"score": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "2",
|
||||||
|
"label": "II \u2014 Slight limitation",
|
||||||
|
"score": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "3",
|
||||||
|
"label": "III \u2014 Marked limitation",
|
||||||
|
"score": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "4",
|
||||||
|
"label": "IV \u2014 Symptoms at rest",
|
||||||
|
"score": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"score_key": "nyha"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ejection_fraction",
|
||||||
|
"section": "Cardiac",
|
||||||
|
"label": "Ejection fraction (%)",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 20,
|
||||||
|
"options": {
|
||||||
|
"min": 5,
|
||||||
|
"max": 80,
|
||||||
|
"unit": "%"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 5,
|
||||||
|
"max": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bnp",
|
||||||
|
"section": "Labs",
|
||||||
|
"label": "BNP / NT-proBNP",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 100000,
|
||||||
|
"unit": "pg/mL"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 100000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "edema",
|
||||||
|
"section": "Fluid",
|
||||||
|
"label": "Peripheral edema",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "none",
|
||||||
|
"label": "None"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mild",
|
||||||
|
"label": "Mild"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "moderate",
|
||||||
|
"label": "Moderate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "severe",
|
||||||
|
"label": "Severe"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "weight_kg",
|
||||||
|
"section": "Fluid",
|
||||||
|
"label": "Current weight (kg)",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": {
|
||||||
|
"min": 20,
|
||||||
|
"max": 300,
|
||||||
|
"unit": "kg"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 20,
|
||||||
|
"max": 300
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "weight_change",
|
||||||
|
"section": "Fluid",
|
||||||
|
"label": "Weight change since last visit",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 60,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "down",
|
||||||
|
"label": "Decreased"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "stable",
|
||||||
|
"label": "Stable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "up",
|
||||||
|
"label": "Increased"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "exercise_tolerance",
|
||||||
|
"section": "Function",
|
||||||
|
"label": "Exercise tolerance",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 70,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "good",
|
||||||
|
"label": "Good"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "reduced",
|
||||||
|
"label": "Reduced"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "poor",
|
||||||
|
"label": "Poor"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"validation": {
|
||||||
|
"max": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "hypertension_core",
|
||||||
|
"name": "Hypertension assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": null,
|
||||||
|
"description": "BP control, end-organ risk, adherence for hypertension pathway.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": [
|
||||||
|
"doctor",
|
||||||
|
"nurse"
|
||||||
|
],
|
||||||
|
"specialty": "cardiology",
|
||||||
|
"estimated_minutes": 8,
|
||||||
|
"pathway_codes": [
|
||||||
|
"hypertension"
|
||||||
|
],
|
||||||
|
"attribution": "Ladill Care Hypertension assessment content pack (platform-authored clinical checklist)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "sbp",
|
||||||
|
"section": "BP",
|
||||||
|
"label": "Clinic SBP (mmHg)",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": {
|
||||||
|
"min": 60,
|
||||||
|
"max": 300,
|
||||||
|
"unit": "mmHg"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 60,
|
||||||
|
"max": 300
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dbp",
|
||||||
|
"section": "BP",
|
||||||
|
"label": "Clinic DBP (mmHg)",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 20,
|
||||||
|
"options": {
|
||||||
|
"min": 30,
|
||||||
|
"max": 200,
|
||||||
|
"unit": "mmHg"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 30,
|
||||||
|
"max": 200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "control_status",
|
||||||
|
"section": "BP",
|
||||||
|
"label": "BP control",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "controlled",
|
||||||
|
"label": "Controlled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "uncontrolled",
|
||||||
|
"label": "Uncontrolled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unknown",
|
||||||
|
"label": "Unknown"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "home_monitoring",
|
||||||
|
"section": "BP",
|
||||||
|
"label": "Home BP monitoring",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "yes",
|
||||||
|
"label": "Yes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "no",
|
||||||
|
"label": "No"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "target_organ",
|
||||||
|
"section": "Risk",
|
||||||
|
"label": "Target organ damage",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "none",
|
||||||
|
"label": "None known"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lvh",
|
||||||
|
"label": "LVH"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ckd",
|
||||||
|
"label": "CKD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "retinopathy",
|
||||||
|
"label": "Retinopathy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "multiple",
|
||||||
|
"label": "Multiple"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "adherence",
|
||||||
|
"section": "Treatment",
|
||||||
|
"label": "Medication adherence",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 60,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "good",
|
||||||
|
"label": "Good"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fair",
|
||||||
|
"label": "Fair"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "poor",
|
||||||
|
"label": "Poor"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"validation": {
|
||||||
|
"max": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "mrs",
|
||||||
|
"name": "Modified Rankin Scale",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": "single_value",
|
||||||
|
"description": "Global disability scale after stroke (0–6). Required on stroke pathway activation.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": ["doctor"],
|
||||||
|
"specialty": "neurology",
|
||||||
|
"estimated_minutes": 2,
|
||||||
|
"pathway_codes": ["stroke"],
|
||||||
|
"attribution": "Modified Rankin Scale (mRS) — clinical instrument for educational/clinical use. Verify commercial redistribution rights before packaging for sale."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "mrs_score",
|
||||||
|
"section": "Disability",
|
||||||
|
"label": "Modified Rankin Scale score",
|
||||||
|
"help_text": "Select the grade that best describes the patient.",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 1,
|
||||||
|
"score_key": "total",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 6,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "No symptoms at all", "score": 0 },
|
||||||
|
{ "code": "1", "label": "No significant disability despite symptoms; able to carry out all usual duties and activities", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Slight disability; unable to carry out all previous activities, but able to look after own affairs without assistance", "score": 2 },
|
||||||
|
{ "code": "3", "label": "Moderate disability; requiring some help, but able to walk without assistance", "score": 3 },
|
||||||
|
{ "code": "4", "label": "Moderately severe disability; unable to walk without assistance and unable to attend to own bodily needs without assistance", "score": 4 },
|
||||||
|
{ "code": "5", "label": "Severe disability; bedridden, incontinent and requiring constant nursing care and attention", "score": 5 },
|
||||||
|
{ "code": "6", "label": "Dead", "score": 6 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"validation": { "min": 0, "max": 6 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "nihss",
|
||||||
|
"name": "NIH Stroke Scale",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": "sum_items",
|
||||||
|
"description": "National Institutes of Health Stroke Scale (NIHSS). Total 0–42. Required on stroke pathway activation.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": ["doctor"],
|
||||||
|
"specialty": "neurology",
|
||||||
|
"estimated_minutes": 10,
|
||||||
|
"pathway_codes": ["stroke"],
|
||||||
|
"max_total": 42,
|
||||||
|
"attribution": "NIH Stroke Scale (NIHSS) — public-domain clinical instrument originating from NINDS/NIH. Confirm local training and documentation requirements for clinical use."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "1a_loc",
|
||||||
|
"section": "1. Level of consciousness",
|
||||||
|
"label": "1a. Level of consciousness",
|
||||||
|
"help_text": "Alertness.",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"score_key": "loc",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 3,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Alert; keenly responsive", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Not alert; arousable by minor stimulation", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Not alert; requires repeated stimulation / obtunded", "score": 2 },
|
||||||
|
{ "code": "3", "label": "Responds only with reflex motor or autonomic effects / totally unresponsive", "score": 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "1b_loc_questions",
|
||||||
|
"section": "1. Level of consciousness",
|
||||||
|
"label": "1b. LOC questions",
|
||||||
|
"help_text": "Month and age. Score only initial answer.",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 20,
|
||||||
|
"score_key": "loc",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 2,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Answers both questions correctly", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Answers one question correctly", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Answers neither question correctly", "score": 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "1c_loc_commands",
|
||||||
|
"section": "1. Level of consciousness",
|
||||||
|
"label": "1c. LOC commands",
|
||||||
|
"help_text": "Open and close eyes; grip and release non-paretic hand.",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 30,
|
||||||
|
"score_key": "loc",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 2,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Performs both tasks correctly", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Performs one task correctly", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Performs neither task correctly", "score": 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "2_gaze",
|
||||||
|
"section": "2. Best gaze",
|
||||||
|
"label": "2. Best gaze",
|
||||||
|
"help_text": "Horizontal eye movements only.",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 40,
|
||||||
|
"score_key": "gaze",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 2,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Normal", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Partial gaze palsy", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Forced deviation, or total gaze paresis not overcome by oculocephalic maneuver", "score": 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "3_visual",
|
||||||
|
"section": "3. Visual",
|
||||||
|
"label": "3. Visual fields",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 50,
|
||||||
|
"score_key": "visual",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 3,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "No visual loss", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Partial hemianopia", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Complete hemianopia", "score": 2 },
|
||||||
|
{ "code": "3", "label": "Bilateral hemianopia (blind including cortical blindness)", "score": 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "4_facial",
|
||||||
|
"section": "4. Facial palsy",
|
||||||
|
"label": "4. Facial palsy",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 60,
|
||||||
|
"score_key": "facial",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 3,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Normal symmetrical movements", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Minor paralysis (flattened nasolabial fold, asymmetry on smiling)", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Partial paralysis (total or near-total paralysis of lower face)", "score": 2 },
|
||||||
|
{ "code": "3", "label": "Complete paralysis of one or both sides (upper and lower face)", "score": 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "5a_motor_arm_left",
|
||||||
|
"section": "5. Motor arm",
|
||||||
|
"label": "5a. Motor arm — left",
|
||||||
|
"help_text": "Extend arms 90° (sitting) or 45° (supine) for 10 seconds.",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 70,
|
||||||
|
"score_key": "motor_arm",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 4,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "No drift; limb holds 90° (or 45°) for full 10 seconds", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Drift; limb holds but drifts down before full 10 seconds", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Some effort against gravity", "score": 2 },
|
||||||
|
{ "code": "3", "label": "No effort against gravity; limb falls", "score": 3 },
|
||||||
|
{ "code": "4", "label": "No movement", "score": 4 },
|
||||||
|
{ "code": "UN", "label": "Amputation or joint fusion (score as 0 for total; document)", "score": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "5b_motor_arm_right",
|
||||||
|
"section": "5. Motor arm",
|
||||||
|
"label": "5b. Motor arm — right",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 80,
|
||||||
|
"score_key": "motor_arm",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 4,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "No drift; limb holds 90° (or 45°) for full 10 seconds", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Drift; limb holds but drifts down before full 10 seconds", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Some effort against gravity", "score": 2 },
|
||||||
|
{ "code": "3", "label": "No effort against gravity; limb falls", "score": 3 },
|
||||||
|
{ "code": "4", "label": "No movement", "score": 4 },
|
||||||
|
{ "code": "UN", "label": "Amputation or joint fusion (score as 0 for total; document)", "score": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "6a_motor_leg_left",
|
||||||
|
"section": "6. Motor leg",
|
||||||
|
"label": "6a. Motor leg — left",
|
||||||
|
"help_text": "Hold leg at 30° for 5 seconds (always tested supine).",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 90,
|
||||||
|
"score_key": "motor_leg",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 4,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "No drift; leg holds 30° for full 5 seconds", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Drift; leg falls by the end of 5-second period but does not hit bed", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Some effort against gravity; leg falls to bed by 5 seconds", "score": 2 },
|
||||||
|
{ "code": "3", "label": "No effort against gravity; leg falls to bed immediately", "score": 3 },
|
||||||
|
{ "code": "4", "label": "No movement", "score": 4 },
|
||||||
|
{ "code": "UN", "label": "Amputation or joint fusion (score as 0 for total; document)", "score": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "6b_motor_leg_right",
|
||||||
|
"section": "6. Motor leg",
|
||||||
|
"label": "6b. Motor leg — right",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 100,
|
||||||
|
"score_key": "motor_leg",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 4,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "No drift; leg holds 30° for full 5 seconds", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Drift; leg falls by the end of 5-second period but does not hit bed", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Some effort against gravity; leg falls to bed by 5 seconds", "score": 2 },
|
||||||
|
{ "code": "3", "label": "No effort against gravity; leg falls to bed immediately", "score": 3 },
|
||||||
|
{ "code": "4", "label": "No movement", "score": 4 },
|
||||||
|
{ "code": "UN", "label": "Amputation or joint fusion (score as 0 for total; document)", "score": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "7_ataxia",
|
||||||
|
"section": "7. Limb ataxia",
|
||||||
|
"label": "7. Limb ataxia",
|
||||||
|
"help_text": "Finger-nose-finger and heel-shin tests on both sides.",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 110,
|
||||||
|
"score_key": "ataxia",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 2,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Absent", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Present in one limb", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Present in two limbs", "score": 2 },
|
||||||
|
{ "code": "UN", "label": "Amputation or joint fusion (score as 0; document)", "score": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "8_sensory",
|
||||||
|
"section": "8. Sensory",
|
||||||
|
"label": "8. Sensory",
|
||||||
|
"help_text": "Sensation or grimace to pinprick when consciousness decreased.",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 120,
|
||||||
|
"score_key": "sensory",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 2,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Normal; no sensory loss", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Mild-to-moderate sensory loss", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Severe to total sensory loss", "score": 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "9_language",
|
||||||
|
"section": "9. Best language",
|
||||||
|
"label": "9. Best language",
|
||||||
|
"help_text": "Describe picture, name items, read sentences.",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 130,
|
||||||
|
"score_key": "language",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 3,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "No aphasia; normal", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Mild-to-moderate aphasia", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Severe aphasia", "score": 2 },
|
||||||
|
{ "code": "3", "label": "Mute, global aphasia; no usable speech or auditory comprehension", "score": 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "10_dysarthria",
|
||||||
|
"section": "10. Dysarthria",
|
||||||
|
"label": "10. Dysarthria",
|
||||||
|
"help_text": "Read or repeat words from list.",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 140,
|
||||||
|
"score_key": "dysarthria",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 2,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "Normal", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Mild-to-moderate dysarthria", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Severe dysarthria; unintelligible or mute/anarthric", "score": 2 },
|
||||||
|
{ "code": "UN", "label": "Intubated or other physical barrier (score as 0; document)", "score": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "11_extinction",
|
||||||
|
"section": "11. Extinction and inattention",
|
||||||
|
"label": "11. Extinction and inattention (formerly neglect)",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 150,
|
||||||
|
"score_key": "extinction",
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 2,
|
||||||
|
"choices": [
|
||||||
|
{ "code": "0", "label": "No abnormality", "score": 0 },
|
||||||
|
{ "code": "1", "label": "Visual, tactile, auditory, spatial, or personal inattention; or extinction to bilateral simultaneous stimulation in one sensory modality", "score": 1 },
|
||||||
|
{ "code": "2", "label": "Profound hemi-inattention or extinction to more than one modality; does not recognize own hand or orients to only one side of space", "score": 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "orthopaedics_core",
|
||||||
|
"name": "Orthopaedics assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": null,
|
||||||
|
"description": "Injury/region, pain, weight-bearing, fracture status.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": [
|
||||||
|
"doctor",
|
||||||
|
"nurse"
|
||||||
|
],
|
||||||
|
"specialty": "orthopaedics",
|
||||||
|
"estimated_minutes": 8,
|
||||||
|
"pathway_codes": [
|
||||||
|
"orthopaedics"
|
||||||
|
],
|
||||||
|
"attribution": "Ladill Care Orthopaedics assessment content pack (platform-authored clinical checklist)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "region",
|
||||||
|
"section": "Injury",
|
||||||
|
"label": "Anatomical region",
|
||||||
|
"answer_type": "text",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"validation": {
|
||||||
|
"max": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "weight_bearing",
|
||||||
|
"section": "Function",
|
||||||
|
"label": "Weight-bearing status",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 20,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "full",
|
||||||
|
"label": "Full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "partial",
|
||||||
|
"label": "Partial"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "non",
|
||||||
|
"label": "Non-weight-bearing"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "pain_score",
|
||||||
|
"section": "Symptoms",
|
||||||
|
"label": "Pain score (0\u201310)",
|
||||||
|
"answer_type": "scale",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 10
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fracture_status",
|
||||||
|
"section": "Imaging",
|
||||||
|
"label": "Fracture status",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "none",
|
||||||
|
"label": "No fracture"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "suspected",
|
||||||
|
"label": "Suspected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "confirmed",
|
||||||
|
"label": "Confirmed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "healed",
|
||||||
|
"label": "Healing/healed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "operative_plan",
|
||||||
|
"section": "Plan",
|
||||||
|
"label": "Operative plan",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "none",
|
||||||
|
"label": "Conservative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "planned",
|
||||||
|
"label": "Surgery planned"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "done",
|
||||||
|
"label": "Post-op"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unknown",
|
||||||
|
"label": "Unknown"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"validation": {
|
||||||
|
"max": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "outcome_core",
|
||||||
|
"name": "Outcome measures",
|
||||||
|
"category": "outcome",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": null,
|
||||||
|
"description": "Generic longitudinal outcome tracking across conditions. Prefer typed vitals for weight/BP.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": ["doctor", "nurse"],
|
||||||
|
"specialty": "general",
|
||||||
|
"estimated_minutes": 5,
|
||||||
|
"attribution": "Ladill Care generic outcome measures (platform-authored)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "quality_of_life",
|
||||||
|
"section": "Patient-reported",
|
||||||
|
"label": "Quality of life (0–10)",
|
||||||
|
"help_text": "0 = worst imaginable, 10 = best imaginable.",
|
||||||
|
"answer_type": "scale",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": { "min": 0, "max": 10 },
|
||||||
|
"validation": { "min": 0, "max": 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "pain_score",
|
||||||
|
"section": "Patient-reported",
|
||||||
|
"label": "Pain score (0–10)",
|
||||||
|
"answer_type": "scale",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 20,
|
||||||
|
"options": { "min": 0, "max": 10 },
|
||||||
|
"validation": { "min": 0, "max": 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "functional_independence",
|
||||||
|
"section": "Function",
|
||||||
|
"label": "Functional independence",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "independent", "label": "Independent" },
|
||||||
|
{ "code": "minimal_help", "label": "Minimal help" },
|
||||||
|
{ "code": "moderate_help", "label": "Moderate help" },
|
||||||
|
{ "code": "dependent", "label": "Dependent" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "medication_adherence",
|
||||||
|
"section": "Treatment",
|
||||||
|
"label": "Medication adherence",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "good", "label": "Good (≥80%)" },
|
||||||
|
{ "code": "fair", "label": "Fair" },
|
||||||
|
{ "code": "poor", "label": "Poor" },
|
||||||
|
{ "code": "unknown", "label": "Unknown" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hospital_admissions_since_last",
|
||||||
|
"section": "Events",
|
||||||
|
"label": "Hospital admissions since last review",
|
||||||
|
"answer_type": "integer",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": { "min": 0, "max": 50 },
|
||||||
|
"validation": { "min": 0, "max": 50 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "falls_count",
|
||||||
|
"section": "Events",
|
||||||
|
"label": "Falls since last review",
|
||||||
|
"answer_type": "integer",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 60,
|
||||||
|
"options": { "min": 0, "max": 100 },
|
||||||
|
"validation": { "min": 0, "max": 100 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "readmissions_flag",
|
||||||
|
"section": "Events",
|
||||||
|
"label": "Unplanned readmission since last review?",
|
||||||
|
"answer_type": "boolean",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "patient_satisfaction",
|
||||||
|
"section": "Patient-reported",
|
||||||
|
"label": "Patient satisfaction (0–10)",
|
||||||
|
"answer_type": "scale",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 80,
|
||||||
|
"options": { "min": 0, "max": 10 },
|
||||||
|
"validation": { "min": 0, "max": 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 90,
|
||||||
|
"validation": { "max": 3000 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "parkinsons_core",
|
||||||
|
"name": "Parkinson's assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": "single_value",
|
||||||
|
"description": "Hoehn & Yahr, tremor, freezing, falls.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": [
|
||||||
|
"doctor"
|
||||||
|
],
|
||||||
|
"specialty": "neurology",
|
||||||
|
"estimated_minutes": 8,
|
||||||
|
"pathway_codes": [
|
||||||
|
"parkinsons"
|
||||||
|
],
|
||||||
|
"attribution": "Ladill Care Parkinson's assessment content pack (platform-authored clinical checklist)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "hoehn_yahr",
|
||||||
|
"section": "Staging",
|
||||||
|
"label": "Hoehn & Yahr stage",
|
||||||
|
"answer_type": "score_item",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "1",
|
||||||
|
"label": "1",
|
||||||
|
"score": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "1.5",
|
||||||
|
"label": "1.5",
|
||||||
|
"score": 1.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "2",
|
||||||
|
"label": "2",
|
||||||
|
"score": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "2.5",
|
||||||
|
"label": "2.5",
|
||||||
|
"score": 2.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "3",
|
||||||
|
"label": "3",
|
||||||
|
"score": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "4",
|
||||||
|
"label": "4",
|
||||||
|
"score": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "5",
|
||||||
|
"label": "5",
|
||||||
|
"score": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"score_key": "hy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "tremor_score",
|
||||||
|
"section": "Motor",
|
||||||
|
"label": "Tremor severity (0\u201310)",
|
||||||
|
"answer_type": "scale",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 20,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 10
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "freezing",
|
||||||
|
"section": "Motor",
|
||||||
|
"label": "Freezing episodes",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "none",
|
||||||
|
"label": "None"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "rare",
|
||||||
|
"label": "Rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "frequent",
|
||||||
|
"label": "Frequent"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "falls_6m",
|
||||||
|
"section": "Safety",
|
||||||
|
"label": "Falls in last 6 months",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 50
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "updrs_total",
|
||||||
|
"section": "Motor",
|
||||||
|
"label": "UPDRS total (if available)",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 200
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"validation": {
|
||||||
|
"max": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "pregnancy_core",
|
||||||
|
"name": "Pregnancy assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": null,
|
||||||
|
"description": "Gestational age, parity, risk factors, BP, foetal movement.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": [
|
||||||
|
"doctor",
|
||||||
|
"nurse"
|
||||||
|
],
|
||||||
|
"specialty": "obstetrics",
|
||||||
|
"estimated_minutes": 8,
|
||||||
|
"pathway_codes": [
|
||||||
|
"pregnancy"
|
||||||
|
],
|
||||||
|
"attribution": "Ladill Care Pregnancy assessment content pack (platform-authored clinical checklist)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "ga_weeks",
|
||||||
|
"section": "Pregnancy",
|
||||||
|
"label": "Gestational age (weeks)",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 45,
|
||||||
|
"unit": "weeks"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 45
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "gravida",
|
||||||
|
"section": "Obstetric history",
|
||||||
|
"label": "Gravida",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 20,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 20
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "para",
|
||||||
|
"section": "Obstetric history",
|
||||||
|
"label": "Para",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 20
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sbp",
|
||||||
|
"section": "Vitals",
|
||||||
|
"label": "BP systolic",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"min": 60,
|
||||||
|
"max": 250,
|
||||||
|
"unit": "mmHg"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 60,
|
||||||
|
"max": 250
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dbp",
|
||||||
|
"section": "Vitals",
|
||||||
|
"label": "BP diastolic",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": {
|
||||||
|
"min": 30,
|
||||||
|
"max": 150,
|
||||||
|
"unit": "mmHg"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"min": 30,
|
||||||
|
"max": 150
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "risk_category",
|
||||||
|
"section": "Risk",
|
||||||
|
"label": "Risk category",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 60,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "low",
|
||||||
|
"label": "Low"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "moderate",
|
||||||
|
"label": "Moderate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "high",
|
||||||
|
"label": "High"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "foetal_movement",
|
||||||
|
"section": "Foetal",
|
||||||
|
"label": "Foetal movements",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 70,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"code": "normal",
|
||||||
|
"label": "Normal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "reduced",
|
||||||
|
"label": "Reduced"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "na",
|
||||||
|
"label": "N/A early"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unknown",
|
||||||
|
"label": "Unknown"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"validation": {
|
||||||
|
"max": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "stroke_clinical",
|
||||||
|
"name": "Stroke clinical assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": null,
|
||||||
|
"description": "Stroke subtype (incl. TIA), timeline, imaging, reperfusion, and focal findings. Optional on pathway activation.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": ["doctor"],
|
||||||
|
"specialty": "neurology",
|
||||||
|
"estimated_minutes": 8,
|
||||||
|
"pathway_codes": ["stroke"],
|
||||||
|
"attribution": "Ladill Care stroke clinical form (platform-authored). Subtype field implements KD-19 (single stroke pathway for TIA and stroke)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "stroke_subtype",
|
||||||
|
"section": "Classification",
|
||||||
|
"label": "Stroke / TIA subtype",
|
||||||
|
"help_text": "TIA shares the stroke pathway; record subtype here (not a separate pathway).",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "tia", "label": "TIA" },
|
||||||
|
{ "code": "ischaemic", "label": "Ischaemic stroke" },
|
||||||
|
{ "code": "haemorrhagic", "label": "Haemorrhagic stroke" },
|
||||||
|
{ "code": "unspecified", "label": "Unspecified / under investigation" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "tlkw",
|
||||||
|
"section": "Timeline",
|
||||||
|
"label": "Time last known well",
|
||||||
|
"answer_type": "datetime",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "symptom_onset",
|
||||||
|
"section": "Timeline",
|
||||||
|
"label": "Symptom onset (if witnessed)",
|
||||||
|
"answer_type": "datetime",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ct_findings",
|
||||||
|
"section": "Imaging",
|
||||||
|
"label": "CT / imaging findings",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"validation": { "max": 5000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "thrombolysis_status",
|
||||||
|
"section": "Reperfusion",
|
||||||
|
"label": "Thrombolysis status",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "not_indicated", "label": "Not indicated" },
|
||||||
|
{ "code": "contraindicated", "label": "Contraindicated" },
|
||||||
|
{ "code": "given", "label": "Given" },
|
||||||
|
{ "code": "offered_declined", "label": "Offered / declined" },
|
||||||
|
{ "code": "pending", "label": "Pending decision" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "thrombolysis_time",
|
||||||
|
"section": "Reperfusion",
|
||||||
|
"label": "Thrombolysis time (if given)",
|
||||||
|
"answer_type": "datetime",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mechanical_thrombectomy",
|
||||||
|
"section": "Reperfusion",
|
||||||
|
"label": "Mechanical thrombectomy",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 70,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "not_indicated", "label": "Not indicated" },
|
||||||
|
{ "code": "referred", "label": "Referred / transferred" },
|
||||||
|
{ "code": "performed", "label": "Performed" },
|
||||||
|
{ "code": "not_available", "label": "Not available" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "limb_strength",
|
||||||
|
"section": "Findings",
|
||||||
|
"label": "Limb strength summary",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 80,
|
||||||
|
"validation": { "max": 2000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "aphasia",
|
||||||
|
"section": "Findings",
|
||||||
|
"label": "Aphasia",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 90,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "none", "label": "None" },
|
||||||
|
{ "code": "mild", "label": "Mild" },
|
||||||
|
{ "code": "moderate", "label": "Moderate" },
|
||||||
|
{ "code": "severe", "label": "Severe" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dysphagia",
|
||||||
|
"section": "Findings",
|
||||||
|
"label": "Dysphagia",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "none", "label": "None / not suspected" },
|
||||||
|
{ "code": "suspected", "label": "Suspected" },
|
||||||
|
{ "code": "confirmed", "label": "Confirmed" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "spasticity",
|
||||||
|
"section": "Findings",
|
||||||
|
"label": "Spasticity",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 110,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "none", "label": "None" },
|
||||||
|
{ "code": "mild", "label": "Mild" },
|
||||||
|
{ "code": "moderate", "label": "Moderate" },
|
||||||
|
{ "code": "severe", "label": "Severe" },
|
||||||
|
{ "code": "na_acute", "label": "N/A (hyperacute)" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "cognition",
|
||||||
|
"section": "Findings",
|
||||||
|
"label": "Cognition notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 120,
|
||||||
|
"validation": { "max": 2000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "clinical_notes",
|
||||||
|
"section": "Notes",
|
||||||
|
"label": "Additional clinical notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 130,
|
||||||
|
"validation": { "max": 10000 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "stroke_swallow",
|
||||||
|
"name": "Swallow assessment",
|
||||||
|
"category": "disease",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": null,
|
||||||
|
"description": "Bedside swallow screen after stroke. Optional on stroke pathway.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": ["doctor", "nurse"],
|
||||||
|
"specialty": "neurology",
|
||||||
|
"estimated_minutes": 5,
|
||||||
|
"pathway_codes": ["stroke"],
|
||||||
|
"attribution": "Ladill Care stroke swallow screen (platform-authored checklist). Not a substitute for formal SALT assessment."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "npo_status",
|
||||||
|
"section": "Screen",
|
||||||
|
"label": "Nil by mouth status",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "npo", "label": "NBM / NPO" },
|
||||||
|
{ "code": "oral_allowed", "label": "Oral intake allowed" },
|
||||||
|
{ "code": "modified", "label": "Modified texture only" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "level_of_alertness",
|
||||||
|
"section": "Screen",
|
||||||
|
"label": "Alert enough for swallow trial?",
|
||||||
|
"answer_type": "boolean",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "voice_wet",
|
||||||
|
"section": "Screen",
|
||||||
|
"label": "Wet / gurgly voice after sip?",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "no", "label": "No" },
|
||||||
|
{ "code": "yes", "label": "Yes" },
|
||||||
|
{ "code": "not_tested", "label": "Not tested" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "cough_after_sip",
|
||||||
|
"section": "Screen",
|
||||||
|
"label": "Cough / choke on water trial?",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "no", "label": "No" },
|
||||||
|
{ "code": "yes", "label": "Yes" },
|
||||||
|
{ "code": "not_tested", "label": "Not tested" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "screen_result",
|
||||||
|
"section": "Result",
|
||||||
|
"label": "Screen result",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "pass", "label": "Pass — oral trial appropriate" },
|
||||||
|
{ "code": "fail", "label": "Fail — keep NBM, refer SALT" },
|
||||||
|
{ "code": "deferred", "label": "Deferred — not safe to screen" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "salt_referral",
|
||||||
|
"section": "Result",
|
||||||
|
"label": "Speech & language therapy referral",
|
||||||
|
"answer_type": "boolean",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "notes",
|
||||||
|
"section": "Result",
|
||||||
|
"label": "Notes",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 70,
|
||||||
|
"validation": { "max": 2000 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"code": "universal_intake",
|
||||||
|
"name": "Universal Intake",
|
||||||
|
"category": "universal",
|
||||||
|
"version": 1,
|
||||||
|
"is_current": true,
|
||||||
|
"is_active": true,
|
||||||
|
"scoring_strategy": null,
|
||||||
|
"description": "Disease-agnostic structured intake: presenting complaint, social history, and functional status. Demographics, allergies, vitals, and labs remain on typed Care forms.",
|
||||||
|
"meta": {
|
||||||
|
"capture_roles": ["doctor", "nurse"],
|
||||||
|
"specialty": "general",
|
||||||
|
"estimated_minutes": 8,
|
||||||
|
"attribution": "Ladill Care universal clinical intake (platform-authored). Not a licensed third-party instrument."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"code": "chief_complaint",
|
||||||
|
"section": "Presenting complaint",
|
||||||
|
"label": "Chief complaint",
|
||||||
|
"help_text": "Primary reason for this visit in the patient's own words when possible.",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"options": null,
|
||||||
|
"validation": { "max": 2000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hpi",
|
||||||
|
"section": "Presenting complaint",
|
||||||
|
"label": "History of presenting illness",
|
||||||
|
"help_text": "Onset, course, associated features. Free-text symptoms on the consultation remain the narrative source of truth (not auto-synced).",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 20,
|
||||||
|
"options": null,
|
||||||
|
"validation": { "max": 10000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "duration_value",
|
||||||
|
"section": "Presenting complaint",
|
||||||
|
"label": "Duration (value)",
|
||||||
|
"help_text": "Numeric duration; pair with duration unit.",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 30,
|
||||||
|
"options": { "min": 0, "max": 9999 },
|
||||||
|
"validation": { "min": 0, "max": 9999 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "duration_unit",
|
||||||
|
"section": "Presenting complaint",
|
||||||
|
"label": "Duration unit",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 40,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "minutes", "label": "Minutes" },
|
||||||
|
{ "code": "hours", "label": "Hours" },
|
||||||
|
{ "code": "days", "label": "Days" },
|
||||||
|
{ "code": "weeks", "label": "Weeks" },
|
||||||
|
{ "code": "months", "label": "Months" },
|
||||||
|
{ "code": "years", "label": "Years" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "pain_score",
|
||||||
|
"section": "Presenting complaint",
|
||||||
|
"label": "Pain score (0–10)",
|
||||||
|
"help_text": "0 = no pain, 10 = worst imaginable pain.",
|
||||||
|
"answer_type": "scale",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 50,
|
||||||
|
"options": { "min": 0, "max": 10 },
|
||||||
|
"validation": { "min": 0, "max": 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "current_symptoms",
|
||||||
|
"section": "Presenting complaint",
|
||||||
|
"label": "Current symptoms",
|
||||||
|
"help_text": "Structured symptom list or free description. Does not replace consultation free-text symptoms.",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 60,
|
||||||
|
"options": null,
|
||||||
|
"validation": { "max": 5000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "smoking_status",
|
||||||
|
"section": "Social history",
|
||||||
|
"label": "Smoking status",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 100,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "never", "label": "Never smoked" },
|
||||||
|
{ "code": "former", "label": "Former smoker" },
|
||||||
|
{ "code": "current", "label": "Current smoker" },
|
||||||
|
{ "code": "unknown", "label": "Unknown / not asked" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "smoking_pack_years",
|
||||||
|
"section": "Social history",
|
||||||
|
"label": "Smoking pack-years",
|
||||||
|
"help_text": "If former or current smoker. Packs/day × years.",
|
||||||
|
"answer_type": "number",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 110,
|
||||||
|
"options": { "min": 0, "max": 500, "unit": "pack-years" },
|
||||||
|
"validation": { "min": 0, "max": 500 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "alcohol_use",
|
||||||
|
"section": "Social history",
|
||||||
|
"label": "Alcohol use",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 120,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "none", "label": "None" },
|
||||||
|
{ "code": "occasional", "label": "Occasional" },
|
||||||
|
{ "code": "moderate", "label": "Moderate" },
|
||||||
|
{ "code": "heavy", "label": "Heavy" },
|
||||||
|
{ "code": "unknown", "label": "Unknown / not asked" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "occupation",
|
||||||
|
"section": "Social history",
|
||||||
|
"label": "Occupation",
|
||||||
|
"answer_type": "text",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 130,
|
||||||
|
"options": null,
|
||||||
|
"validation": { "max": 255 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mobility",
|
||||||
|
"section": "Functional status",
|
||||||
|
"label": "Mobility",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 200,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "independent", "label": "Independent" },
|
||||||
|
{ "code": "with_aid", "label": "With aid (stick/frame)" },
|
||||||
|
{ "code": "wheelchair", "label": "Wheelchair" },
|
||||||
|
{ "code": "bedbound", "label": "Bedbound" },
|
||||||
|
{ "code": "unknown", "label": "Unknown" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "communication",
|
||||||
|
"section": "Functional status",
|
||||||
|
"label": "Communication",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 210,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "normal", "label": "Normal" },
|
||||||
|
{ "code": "impaired", "label": "Impaired" },
|
||||||
|
{ "code": "non_verbal", "label": "Non-verbal" },
|
||||||
|
{ "code": "unknown", "label": "Unknown" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "vision",
|
||||||
|
"section": "Functional status",
|
||||||
|
"label": "Vision",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 220,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "normal", "label": "Normal / corrected" },
|
||||||
|
{ "code": "impaired", "label": "Impaired" },
|
||||||
|
{ "code": "blind", "label": "Blind" },
|
||||||
|
{ "code": "unknown", "label": "Unknown" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hearing",
|
||||||
|
"section": "Functional status",
|
||||||
|
"label": "Hearing",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 230,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "normal", "label": "Normal" },
|
||||||
|
{ "code": "impaired", "label": "Impaired" },
|
||||||
|
{ "code": "deaf", "label": "Deaf" },
|
||||||
|
{ "code": "unknown", "label": "Unknown" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "feeding",
|
||||||
|
"section": "Functional status",
|
||||||
|
"label": "Feeding",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 240,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "independent", "label": "Independent" },
|
||||||
|
{ "code": "needs_help", "label": "Needs help" },
|
||||||
|
{ "code": "dependent", "label": "Dependent / tube" },
|
||||||
|
{ "code": "unknown", "label": "Unknown" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "continence",
|
||||||
|
"section": "Functional status",
|
||||||
|
"label": "Continence",
|
||||||
|
"answer_type": "single_choice",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 250,
|
||||||
|
"options": {
|
||||||
|
"choices": [
|
||||||
|
{ "code": "continent", "label": "Continent" },
|
||||||
|
{ "code": "occasional", "label": "Occasional incontinence" },
|
||||||
|
{ "code": "incontinent", "label": "Incontinent" },
|
||||||
|
{ "code": "catheter", "label": "Catheter / diversion" },
|
||||||
|
{ "code": "unknown", "label": "Unknown" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "baseline_labs_summary",
|
||||||
|
"section": "Baseline laboratories",
|
||||||
|
"label": "Baseline labs summary (optional)",
|
||||||
|
"help_text": "Free-text summary only. Prefer ordered investigation results in Lab for structured values (CBC, glucose, creatinine, electrolytes).",
|
||||||
|
"answer_type": "textarea",
|
||||||
|
"is_required": false,
|
||||||
|
"sort_order": 300,
|
||||||
|
"options": null,
|
||||||
|
"validation": { "max": 5000 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"pathway": {
|
||||||
|
"code": "asthma",
|
||||||
|
"name": "Asthma",
|
||||||
|
"description": "Asthma clinical pathway.",
|
||||||
|
"match_rules": {
|
||||||
|
"icd_prefixes": [
|
||||||
|
"J45"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"asthma",
|
||||||
|
"asthmatic"
|
||||||
|
],
|
||||||
|
"exclude_keywords": [
|
||||||
|
"family history"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
"sort_order": 60,
|
||||||
|
"meta": {
|
||||||
|
"specialty": "respiratory"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"template_code": "asthma_core",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "any",
|
||||||
|
"sort_order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"pathway": {
|
||||||
|
"code": "cancer",
|
||||||
|
"name": "Cancer",
|
||||||
|
"description": "Cancer clinical pathway.",
|
||||||
|
"match_rules": {
|
||||||
|
"icd_prefixes": [
|
||||||
|
"C"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"cancer",
|
||||||
|
"carcinoma",
|
||||||
|
"malignancy",
|
||||||
|
"malignant neoplasm",
|
||||||
|
"tumour",
|
||||||
|
"tumor"
|
||||||
|
],
|
||||||
|
"exclude_keywords": [
|
||||||
|
"family history"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
"sort_order": 100,
|
||||||
|
"meta": {
|
||||||
|
"specialty": "oncology"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"template_code": "cancer_core",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "any",
|
||||||
|
"sort_order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"pathway": {
|
||||||
|
"code": "ckd",
|
||||||
|
"name": "CKD",
|
||||||
|
"description": "CKD clinical pathway.",
|
||||||
|
"match_rules": {
|
||||||
|
"icd_prefixes": [
|
||||||
|
"N18",
|
||||||
|
"N19"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"ckd",
|
||||||
|
"chronic kidney",
|
||||||
|
"chronic renal",
|
||||||
|
"end stage renal",
|
||||||
|
"esrd"
|
||||||
|
],
|
||||||
|
"exclude_keywords": [
|
||||||
|
"family history"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
"sort_order": 40,
|
||||||
|
"meta": {
|
||||||
|
"specialty": "nephrology"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"template_code": "ckd_core",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "any",
|
||||||
|
"sort_order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"pathway": {
|
||||||
|
"code": "copd",
|
||||||
|
"name": "COPD",
|
||||||
|
"description": "COPD clinical pathway.",
|
||||||
|
"match_rules": {
|
||||||
|
"icd_prefixes": [
|
||||||
|
"J44"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"copd",
|
||||||
|
"chronic obstructive",
|
||||||
|
"emphysema",
|
||||||
|
"chronic bronchitis"
|
||||||
|
],
|
||||||
|
"exclude_keywords": [
|
||||||
|
"family history"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
"sort_order": 70,
|
||||||
|
"meta": {
|
||||||
|
"specialty": "respiratory"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"template_code": "copd_core",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "any",
|
||||||
|
"sort_order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"pathway": {
|
||||||
|
"code": "dementia",
|
||||||
|
"name": "Dementia",
|
||||||
|
"description": "Dementia clinical pathway.",
|
||||||
|
"match_rules": {
|
||||||
|
"icd_prefixes": [
|
||||||
|
"F00",
|
||||||
|
"F01",
|
||||||
|
"F02",
|
||||||
|
"F03",
|
||||||
|
"G30"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"dementia",
|
||||||
|
"alzheimer",
|
||||||
|
"alzheimers",
|
||||||
|
"vascular dementia"
|
||||||
|
],
|
||||||
|
"exclude_keywords": [
|
||||||
|
"family history"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
"sort_order": 80,
|
||||||
|
"meta": {
|
||||||
|
"specialty": "neurology"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"template_code": "dementia_core",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "any",
|
||||||
|
"sort_order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"pathway": {
|
||||||
|
"code": "diabetes",
|
||||||
|
"name": "Diabetes",
|
||||||
|
"description": "Diabetes mellitus clinical pathway for structured review assessments.",
|
||||||
|
"match_rules": {
|
||||||
|
"icd_prefixes": ["E10", "E11", "E12", "E13", "E14"],
|
||||||
|
"keywords": [
|
||||||
|
"diabetes",
|
||||||
|
"diabetic",
|
||||||
|
"type 1 diabetes",
|
||||||
|
"type 2 diabetes",
|
||||||
|
"dm",
|
||||||
|
"iddm",
|
||||||
|
"niddm",
|
||||||
|
"hyperglycaemia",
|
||||||
|
"hyperglycemia"
|
||||||
|
],
|
||||||
|
"exclude_keywords": ["family history", "gestational diabetes risk"]
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
"sort_order": 20,
|
||||||
|
"meta": {
|
||||||
|
"specialty": "endocrinology",
|
||||||
|
"color": "amber"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"template_code": "diabetes_core",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "any",
|
||||||
|
"sort_order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"pathway": {
|
||||||
|
"code": "heart_failure",
|
||||||
|
"name": "Heart Failure",
|
||||||
|
"description": "Heart Failure clinical pathway.",
|
||||||
|
"match_rules": {
|
||||||
|
"icd_prefixes": [
|
||||||
|
"I50",
|
||||||
|
"I11"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"heart failure",
|
||||||
|
"chf",
|
||||||
|
"ccf",
|
||||||
|
"hfrEF",
|
||||||
|
"hfpef",
|
||||||
|
"congestive cardiac failure"
|
||||||
|
],
|
||||||
|
"exclude_keywords": [
|
||||||
|
"family history"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
"sort_order": 30,
|
||||||
|
"meta": {
|
||||||
|
"specialty": "cardiology"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"template_code": "heart_failure_core",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "any",
|
||||||
|
"sort_order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"pathway": {
|
||||||
|
"code": "hypertension",
|
||||||
|
"name": "Hypertension",
|
||||||
|
"description": "Hypertension clinical pathway.",
|
||||||
|
"match_rules": {
|
||||||
|
"icd_prefixes": [
|
||||||
|
"I10",
|
||||||
|
"I11",
|
||||||
|
"I12",
|
||||||
|
"I13",
|
||||||
|
"I15"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"hypertension",
|
||||||
|
"high blood pressure",
|
||||||
|
"htn",
|
||||||
|
"hypertensive"
|
||||||
|
],
|
||||||
|
"exclude_keywords": [
|
||||||
|
"family history"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
"sort_order": 50,
|
||||||
|
"meta": {
|
||||||
|
"specialty": "cardiology"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"template_code": "hypertension_core",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "any",
|
||||||
|
"sort_order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"pathway": {
|
||||||
|
"code": "orthopaedics",
|
||||||
|
"name": "Orthopaedics",
|
||||||
|
"description": "Orthopaedics clinical pathway.",
|
||||||
|
"match_rules": {
|
||||||
|
"icd_prefixes": [
|
||||||
|
"S",
|
||||||
|
"M80",
|
||||||
|
"M81",
|
||||||
|
"M84"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"fracture",
|
||||||
|
"orthopaedic",
|
||||||
|
"orthopedic",
|
||||||
|
"dislocation",
|
||||||
|
"sprain"
|
||||||
|
],
|
||||||
|
"exclude_keywords": [
|
||||||
|
"family history"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
"sort_order": 120,
|
||||||
|
"meta": {
|
||||||
|
"specialty": "orthopaedics"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"template_code": "orthopaedics_core",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "any",
|
||||||
|
"sort_order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"pathway": {
|
||||||
|
"code": "parkinsons",
|
||||||
|
"name": "Parkinson's",
|
||||||
|
"description": "Parkinson's clinical pathway.",
|
||||||
|
"match_rules": {
|
||||||
|
"icd_prefixes": [
|
||||||
|
"G20"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"parkinson",
|
||||||
|
"parkinsons",
|
||||||
|
"parkinson's disease"
|
||||||
|
],
|
||||||
|
"exclude_keywords": [
|
||||||
|
"family history"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
"sort_order": 90,
|
||||||
|
"meta": {
|
||||||
|
"specialty": "neurology"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"template_code": "parkinsons_core",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "any",
|
||||||
|
"sort_order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"pathway": {
|
||||||
|
"code": "pregnancy",
|
||||||
|
"name": "Pregnancy",
|
||||||
|
"description": "Pregnancy clinical pathway.",
|
||||||
|
"match_rules": {
|
||||||
|
"icd_prefixes": [
|
||||||
|
"O",
|
||||||
|
"Z33",
|
||||||
|
"Z34"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"pregnancy",
|
||||||
|
"pregnant",
|
||||||
|
"antenatal",
|
||||||
|
"prenatal"
|
||||||
|
],
|
||||||
|
"exclude_keywords": [
|
||||||
|
"family history"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
"sort_order": 110,
|
||||||
|
"meta": {
|
||||||
|
"specialty": "obstetrics"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"template_code": "pregnancy_core",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "any",
|
||||||
|
"sort_order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"pathway": {
|
||||||
|
"code": "stroke",
|
||||||
|
"name": "Stroke",
|
||||||
|
"description": "Acute and follow-up stroke clinical pathway (includes TIA as subtype on assessments).",
|
||||||
|
"match_rules": {
|
||||||
|
"icd_prefixes": ["I60", "I61", "I62", "I63", "I64", "G45"],
|
||||||
|
"keywords": [
|
||||||
|
"stroke",
|
||||||
|
"cva",
|
||||||
|
"tia",
|
||||||
|
"transient ischaemic attack",
|
||||||
|
"transient ischemic attack",
|
||||||
|
"cerebrovascular",
|
||||||
|
"hemiplegia",
|
||||||
|
"ischaemic stroke",
|
||||||
|
"ischemic stroke",
|
||||||
|
"hemorrhagic stroke",
|
||||||
|
"haemorrhagic stroke"
|
||||||
|
],
|
||||||
|
"exclude_keywords": ["family history"]
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
"sort_order": 10,
|
||||||
|
"meta": {
|
||||||
|
"specialty": "neurology",
|
||||||
|
"color": "rose"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"template_code": "nihss",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "acute",
|
||||||
|
"sort_order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"template_code": "mrs",
|
||||||
|
"is_required_on_activation": true,
|
||||||
|
"phase": "acute",
|
||||||
|
"sort_order": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"template_code": "gcs",
|
||||||
|
"is_required_on_activation": false,
|
||||||
|
"phase": "acute",
|
||||||
|
"sort_order": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"template_code": "stroke_clinical",
|
||||||
|
"is_required_on_activation": false,
|
||||||
|
"phase": "acute",
|
||||||
|
"sort_order": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"template_code": "stroke_swallow",
|
||||||
|
"is_required_on_activation": false,
|
||||||
|
"phase": "acute",
|
||||||
|
"sort_order": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"template_code": "barthel",
|
||||||
|
"is_required_on_activation": false,
|
||||||
|
"phase": "follow_up",
|
||||||
|
"sort_order": 6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PR 1 — Assessment engine schema (templates, questions, assessments, answers).
|
||||||
|
* Scores: PR 4. Pathways: PR 5.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('care_assessment_templates', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->uuid('uuid')->unique();
|
||||||
|
// v1 always NULL (system catalog). Reserved for future org forks.
|
||||||
|
$table->foreignId('organization_id')->nullable()->constrained('care_organizations')->nullOnDelete();
|
||||||
|
$table->string('code');
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('category'); // universal, disease, outcome, screening
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->unsignedInteger('version')->default(1);
|
||||||
|
$table->boolean('is_current')->default(true);
|
||||||
|
$table->string('scoring_strategy')->nullable(); // sum_items, single_value, custom:Handler
|
||||||
|
$table->json('meta')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
// Valid for v1 (system-only rows). Org clones out of scope.
|
||||||
|
$table->unique(['code', 'version']);
|
||||||
|
$table->index(['category', 'is_current', 'is_active']);
|
||||||
|
$table->index(['code', 'is_current']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('care_assessment_questions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('template_id')->constrained('care_assessment_templates')->cascadeOnDelete();
|
||||||
|
$table->string('code');
|
||||||
|
$table->string('section')->nullable();
|
||||||
|
$table->string('label');
|
||||||
|
$table->text('help_text')->nullable();
|
||||||
|
$table->string('answer_type'); // text, number, boolean, date, single_choice, multi_choice, score_item, calculated
|
||||||
|
$table->json('options')->nullable();
|
||||||
|
$table->boolean('is_required')->default(false);
|
||||||
|
$table->unsignedInteger('sort_order')->default(0);
|
||||||
|
$table->string('score_key')->nullable();
|
||||||
|
$table->json('validation')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['template_id', 'code']);
|
||||||
|
$table->index(['template_id', 'sort_order']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('care_assessments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->uuid('uuid')->unique();
|
||||||
|
$table->string('owner_ref')->index();
|
||||||
|
$table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete();
|
||||||
|
$table->foreignId('branch_id')->nullable()->constrained('care_branches')->nullOnDelete();
|
||||||
|
$table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete();
|
||||||
|
// Frozen template version for historical integrity.
|
||||||
|
$table->foreignId('template_id')->constrained('care_assessment_templates')->restrictOnDelete();
|
||||||
|
$table->foreignId('consultation_id')->nullable()->constrained('care_consultations')->nullOnDelete();
|
||||||
|
$table->foreignId('visit_id')->nullable()->constrained('care_visits')->nullOnDelete();
|
||||||
|
// patient_pathway_id added in PR 5 when pathway tables exist.
|
||||||
|
$table->foreignId('practitioner_id')->nullable()->constrained('care_practitioners')->nullOnDelete();
|
||||||
|
$table->string('status')->default('draft'); // draft, completed, cancelled
|
||||||
|
$table->timestamp('assessed_at')->nullable();
|
||||||
|
$table->timestamp('completed_at')->nullable();
|
||||||
|
$table->string('completed_by')->nullable();
|
||||||
|
$table->string('started_by')->nullable();
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->index(['patient_id', 'assessed_at']);
|
||||||
|
$table->index(['consultation_id']);
|
||||||
|
$table->index(['owner_ref', 'status']);
|
||||||
|
$table->index(['template_id', 'status']);
|
||||||
|
$table->index(['patient_id', 'template_id', 'status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('care_assessment_answers', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('owner_ref')->index();
|
||||||
|
$table->foreignId('assessment_id')->constrained('care_assessments')->cascadeOnDelete();
|
||||||
|
$table->foreignId('question_id')->constrained('care_assessment_questions')->restrictOnDelete();
|
||||||
|
$table->text('value_text')->nullable();
|
||||||
|
$table->decimal('value_number', 12, 4)->nullable();
|
||||||
|
$table->boolean('value_boolean')->nullable();
|
||||||
|
$table->dateTime('value_date')->nullable();
|
||||||
|
$table->json('value_json')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['assessment_id', 'question_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('care_assessment_answers');
|
||||||
|
Schema::dropIfExists('care_assessments');
|
||||||
|
Schema::dropIfExists('care_assessment_questions');
|
||||||
|
Schema::dropIfExists('care_assessment_templates');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/** PR 4 — materialize instrument scores on assessment complete. */
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('care_assessment_scores', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('owner_ref')->index();
|
||||||
|
$table->foreignId('assessment_id')->constrained('care_assessments')->cascadeOnDelete();
|
||||||
|
$table->string('template_code')->index();
|
||||||
|
$table->decimal('total_score', 12, 4)->nullable();
|
||||||
|
$table->decimal('max_score', 12, 4)->nullable();
|
||||||
|
$table->json('subscores')->nullable();
|
||||||
|
$table->string('severity_label')->nullable();
|
||||||
|
$table->timestamp('computed_at');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique('assessment_id');
|
||||||
|
$table->index(['owner_ref', 'template_code', 'computed_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('care_assessment_scores');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PR 5 — clinical pathway catalog + patient pathway activation.
|
||||||
|
* Pathway bindings use template_code only (no template_id FK).
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('care_clinical_pathways', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->uuid('uuid')->unique();
|
||||||
|
$table->string('code')->unique();
|
||||||
|
$table->string('name');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->json('match_rules')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->unsignedInteger('sort_order')->default(0);
|
||||||
|
$table->json('meta')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->index(['is_active', 'sort_order']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('care_pathway_templates', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('pathway_id')->constrained('care_clinical_pathways')->cascadeOnDelete();
|
||||||
|
$table->string('template_code');
|
||||||
|
$table->boolean('is_required_on_activation')->default(false);
|
||||||
|
$table->string('phase')->default('any'); // acute, follow_up, any
|
||||||
|
$table->unsignedInteger('sort_order')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['pathway_id', 'template_code']);
|
||||||
|
$table->index(['template_code']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('care_patient_pathways', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->uuid('uuid')->unique();
|
||||||
|
$table->string('owner_ref')->index();
|
||||||
|
$table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete();
|
||||||
|
$table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete();
|
||||||
|
$table->foreignId('pathway_id')->constrained('care_clinical_pathways')->restrictOnDelete();
|
||||||
|
$table->string('status')->default('active'); // active, resolved, inactive
|
||||||
|
$table->timestamp('activated_at');
|
||||||
|
$table->string('activated_by')->nullable();
|
||||||
|
$table->foreignId('activation_consultation_id')->nullable()->constrained('care_consultations')->nullOnDelete();
|
||||||
|
$table->string('activation_diagnosis_text')->nullable();
|
||||||
|
$table->timestamp('resolved_at')->nullable();
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->index(['patient_id', 'status']);
|
||||||
|
$table->index(['patient_id', 'pathway_id', 'status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('care_assessments', function (Blueprint $table) {
|
||||||
|
$table->foreignId('patient_pathway_id')
|
||||||
|
->nullable()
|
||||||
|
->after('visit_id')
|
||||||
|
->constrained('care_patient_pathways')
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('care_assessments', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('patient_pathway_id');
|
||||||
|
});
|
||||||
|
Schema::dropIfExists('care_patient_pathways');
|
||||||
|
Schema::dropIfExists('care_pathway_templates');
|
||||||
|
Schema::dropIfExists('care_clinical_pathways');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\AssessmentQuestion;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads platform assessment content packs from database/data/assessments/*.json.
|
||||||
|
* Upserts by (code, version); replaces questions for that template version.
|
||||||
|
*/
|
||||||
|
class AssessmentTemplateSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$dir = database_path('data/assessments');
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
$this->command?->warn("No assessment packs directory at {$dir}");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = collect(File::files($dir))
|
||||||
|
->filter(fn ($file) => str_ends_with(strtolower($file->getFilename()), '.json'))
|
||||||
|
->sortBy(fn ($file) => $file->getFilename())
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($files->isEmpty()) {
|
||||||
|
$this->command?->warn('No assessment JSON packs found.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$this->seedPack($file->getPathname());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function seedPack(string $path): AssessmentTemplate
|
||||||
|
{
|
||||||
|
$raw = File::get($path);
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (! is_array($data) || ! isset($data['template'], $data['questions'])) {
|
||||||
|
throw new RuntimeException("Invalid assessment pack: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$t = $data['template'];
|
||||||
|
foreach (['code', 'name', 'category', 'version'] as $required) {
|
||||||
|
if (! array_key_exists($required, $t)) {
|
||||||
|
throw new RuntimeException("Assessment pack missing template.{$required}: {$path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($t, $data, $path) {
|
||||||
|
$template = AssessmentTemplate::withTrashed()
|
||||||
|
->whereNull('organization_id')
|
||||||
|
->where('code', $t['code'])
|
||||||
|
->where('version', (int) $t['version'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'organization_id' => null,
|
||||||
|
'code' => $t['code'],
|
||||||
|
'name' => $t['name'],
|
||||||
|
'category' => $t['category'],
|
||||||
|
'description' => $t['description'] ?? null,
|
||||||
|
'version' => (int) $t['version'],
|
||||||
|
'is_current' => (bool) ($t['is_current'] ?? true),
|
||||||
|
'is_active' => (bool) ($t['is_active'] ?? true),
|
||||||
|
'scoring_strategy' => $t['scoring_strategy'] ?? null,
|
||||||
|
'meta' => $t['meta'] ?? null,
|
||||||
|
'deleted_at' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($template) {
|
||||||
|
if ($template->trashed()) {
|
||||||
|
$template->restore();
|
||||||
|
}
|
||||||
|
$template->fill(collect($attributes)->except('deleted_at')->all())->save();
|
||||||
|
} else {
|
||||||
|
$template = AssessmentTemplate::create(collect($attributes)->except('deleted_at')->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($template->is_current) {
|
||||||
|
AssessmentTemplate::query()
|
||||||
|
->whereNull('organization_id')
|
||||||
|
->where('code', $template->code)
|
||||||
|
->where('id', '!=', $template->id)
|
||||||
|
->where('is_current', true)
|
||||||
|
->update(['is_current' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace questions for this template version (stable codes within version).
|
||||||
|
AssessmentQuestion::query()->where('template_id', $template->id)->delete();
|
||||||
|
|
||||||
|
$sort = 0;
|
||||||
|
foreach ($data['questions'] as $q) {
|
||||||
|
if (empty($q['code']) || empty($q['label']) || empty($q['answer_type'])) {
|
||||||
|
throw new RuntimeException("Invalid question in pack {$path}");
|
||||||
|
}
|
||||||
|
$sort++;
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'code' => $q['code'],
|
||||||
|
'section' => $q['section'] ?? null,
|
||||||
|
'label' => $q['label'],
|
||||||
|
'help_text' => $q['help_text'] ?? null,
|
||||||
|
'answer_type' => $q['answer_type'],
|
||||||
|
'options' => $q['options'] ?? null,
|
||||||
|
'is_required' => (bool) ($q['is_required'] ?? false),
|
||||||
|
'sort_order' => (int) ($q['sort_order'] ?? $sort),
|
||||||
|
'score_key' => $q['score_key'] ?? null,
|
||||||
|
'validation' => $q['validation'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command?->info(sprintf(
|
||||||
|
'Seeded assessment pack %s v%d (%d questions)',
|
||||||
|
$template->code,
|
||||||
|
$template->version,
|
||||||
|
count($data['questions']),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $template->fresh('questions');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\ClinicalPathway;
|
||||||
|
use App\Models\PathwayTemplate;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads platform pathway packs from database/data/pathways/*.json.
|
||||||
|
* Bindings store template_code only (resolve is_current at assessment start).
|
||||||
|
*/
|
||||||
|
class ClinicalPathwaySeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$dir = database_path('data/pathways');
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
$this->command?->warn("No pathway packs directory at {$dir}");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = collect(File::files($dir))
|
||||||
|
->filter(fn ($file) => str_ends_with(strtolower($file->getFilename()), '.json'))
|
||||||
|
->sortBy(fn ($file) => $file->getFilename())
|
||||||
|
->values();
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$this->seedPack($file->getPathname());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function seedPack(string $path): ClinicalPathway
|
||||||
|
{
|
||||||
|
$data = json_decode(File::get($path), true);
|
||||||
|
if (! is_array($data) || ! isset($data['pathway'])) {
|
||||||
|
throw new RuntimeException("Invalid pathway pack: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$p = $data['pathway'];
|
||||||
|
if (empty($p['code']) || empty($p['name'])) {
|
||||||
|
throw new RuntimeException("Pathway pack missing code/name: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($p, $data, $path) {
|
||||||
|
$pathway = ClinicalPathway::withTrashed()->where('code', $p['code'])->first();
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'code' => $p['code'],
|
||||||
|
'name' => $p['name'],
|
||||||
|
'description' => $p['description'] ?? null,
|
||||||
|
'match_rules' => $p['match_rules'] ?? null,
|
||||||
|
'is_active' => (bool) ($p['is_active'] ?? true),
|
||||||
|
'sort_order' => (int) ($p['sort_order'] ?? 0),
|
||||||
|
'meta' => $p['meta'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($pathway) {
|
||||||
|
if ($pathway->trashed()) {
|
||||||
|
$pathway->restore();
|
||||||
|
}
|
||||||
|
$pathway->fill($attributes)->save();
|
||||||
|
} else {
|
||||||
|
$pathway = ClinicalPathway::create($attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathwayTemplate::query()->where('pathway_id', $pathway->id)->delete();
|
||||||
|
|
||||||
|
foreach ($data['templates'] ?? [] as $i => $binding) {
|
||||||
|
if (empty($binding['template_code'])) {
|
||||||
|
throw new RuntimeException("Pathway pack binding missing template_code: {$path}");
|
||||||
|
}
|
||||||
|
PathwayTemplate::create([
|
||||||
|
'pathway_id' => $pathway->id,
|
||||||
|
'template_code' => $binding['template_code'],
|
||||||
|
'is_required_on_activation' => (bool) ($binding['is_required_on_activation'] ?? false),
|
||||||
|
'phase' => $binding['phase'] ?? PathwayTemplate::PHASE_ANY,
|
||||||
|
'sort_order' => (int) ($binding['sort_order'] ?? ($i + 1)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command?->info(sprintf(
|
||||||
|
'Seeded pathway %s (%d template bindings)',
|
||||||
|
$pathway->code,
|
||||||
|
count($data['templates'] ?? []),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $pathway->fresh('templates');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class DatabaseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->call([
|
||||||
|
AssessmentTemplateSeeder::class,
|
||||||
|
ClinicalPathwaySeeder::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Assessment instrument licensing review (pre-GA)
|
||||||
|
|
||||||
|
**Status:** Working checklist for legal/clinical review before commercial GA packaging
|
||||||
|
**Date:** 2026-07-16
|
||||||
|
**Related:** `docs/layered-clinical-assessment-engine.md`, content packs under `database/data/assessments/`
|
||||||
|
|
||||||
|
This document lists third-party and platform-authored instruments shipped as Care content packs. **Shipping code and seed data does not replace a formal legal review** for redistribution, branding, or clinical product claims in your jurisdiction.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Risk tier | Instruments | Guidance |
|
||||||
|
|-----------|-------------|----------|
|
||||||
|
| **Platform-authored** | universal_intake, stroke_swallow, stroke_clinical, diabetes_core, outcome_core, disease “_core” packs (HF, CKD, HTN, asthma, dementia, cancer, pregnancy, orthopaedics) | Own checklist content; still not a substitute for local clinical governance |
|
||||||
|
| **Public domain / widely free clinical tools** | NIHSS (NINDS/NIH origin) | Confirm training/certification requirements for clinical use; attribution in pack `meta.attribution` |
|
||||||
|
| **Standard scales — verify commercial rights** | mRS, Barthel, GCS, CAT (if used), mMRC, MMSE, MoCA, UPDRS, Hoehn & Yahr, NYHA, ECOG | Legal review before selling packs as a branded clinical product; some require licenses for electronic redistribution |
|
||||||
|
| **Names only / user-entered scores** | MoCA/MMSE/UPDRS fields in dementia/PD packs are **score entry fields**, not full electronic instruments | Prefer facility-owned paper/electronic licensed tools; Care stores the numeric result |
|
||||||
|
|
||||||
|
## Pack-by-pack notes
|
||||||
|
|
||||||
|
### Platform-authored (lower IP risk)
|
||||||
|
|
||||||
|
- `universal_intake`, `outcome_core`, `stroke_clinical`, `stroke_swallow`, `diabetes_core`
|
||||||
|
- `heart_failure_core`, `ckd_core`, `hypertension_core`, `asthma_core`, `cancer_core`, `pregnancy_core`, `orthopaedics_core`, `dementia_core` (except MMSE/MoCA entry)
|
||||||
|
|
||||||
|
**Action:** Clinical validation by medical lead; no third-party license expected for checklist wording authored by Ladill.
|
||||||
|
|
||||||
|
### NIH Stroke Scale (`nihss`)
|
||||||
|
|
||||||
|
- Origin: NIH/NINDS public clinical tool
|
||||||
|
- Pack includes item wording adapted for electronic capture
|
||||||
|
- **Action:** Confirm local training; keep attribution; do not claim NIH endorsement
|
||||||
|
|
||||||
|
### Modified Rankin Scale (`mrs`)
|
||||||
|
|
||||||
|
- Widely used disability scale
|
||||||
|
- **Action:** Verify electronic redistribution / commercial packaging rights with counsel before GA
|
||||||
|
|
||||||
|
### Barthel Index (`barthel`)
|
||||||
|
|
||||||
|
- ADL scale (Mahoney & Barthel lineage)
|
||||||
|
- **Action:** Licensing review for commercial electronic forms
|
||||||
|
|
||||||
|
### Glasgow Coma Scale (`gcs`)
|
||||||
|
|
||||||
|
- Teasdale & Jennett
|
||||||
|
- **Action:** Confirm any trademark/use restrictions for branded products; clinical use is standard
|
||||||
|
|
||||||
|
### COPD pack (`copd_core`)
|
||||||
|
|
||||||
|
- Includes **mMRC** scored item and free-entry **CAT** total (not full CAT questionnaire)
|
||||||
|
- **Action:** Full CAT electronic form may require GSK/community licensing — current pack only stores a numeric CAT score
|
||||||
|
|
||||||
|
### Parkinson's (`parkinsons_core`)
|
||||||
|
|
||||||
|
- Hoehn & Yahr stages; optional **UPDRS total** as a number (not full UPDRS form)
|
||||||
|
- **Action:** Full MDS-UPDRS electronic use requires MDS permission
|
||||||
|
|
||||||
|
### Dementia (`dementia_core`)
|
||||||
|
|
||||||
|
- Optional **MMSE** / **MoCA** numeric fields only
|
||||||
|
- **Action:** Do not embed full MMSE/MoCA item banks without licenses; MoCA especially is restricted
|
||||||
|
|
||||||
|
### Heart failure (`heart_failure_core`)
|
||||||
|
|
||||||
|
- **NYHA** class as score_item
|
||||||
|
- **Action:** NYHA is a functional classification; low typical IP risk; still document clinical source
|
||||||
|
|
||||||
|
### Cancer (`cancer_core`)
|
||||||
|
|
||||||
|
- **ECOG** performance status
|
||||||
|
- **Action:** Standard oncology scale; confirm branding claims
|
||||||
|
|
||||||
|
## Pre-GA checklist
|
||||||
|
|
||||||
|
1. [ ] Medical lead signs off clinical appropriateness of each pack for target markets
|
||||||
|
2. [ ] Counsel reviews packs marked “verify commercial rights” above
|
||||||
|
3. [ ] Attribution strings in JSON `meta.attribution` remain accurate
|
||||||
|
4. [ ] Marketing copy does **not** claim “certified NIHSS training” or endorsement by instrument owners without agreement
|
||||||
|
5. [ ] For MoCA/MMSE/UPDRS/CAT: either obtain licenses for full forms or keep score-entry-only fields (current design)
|
||||||
|
6. [ ] Facility BAAs/privacy policies cover export of assessments (including FHIR export)
|
||||||
|
|
||||||
|
## Export & interoperability
|
||||||
|
|
||||||
|
FHIR R4 export (`FhirAssessmentExporter`) emits Questionnaire + QuestionnaireResponse for interchange. It does not grant rights to redistribute third-party scale text outside Care; recipients remain responsible for their own compliance.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
Escalate open license questions to product + legal before enabling assessments engine by default for all production orgs.
|
||||||
File diff suppressed because it is too large
Load Diff
+259
-2
@@ -1,8 +1,11 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: Ladill Care API
|
title: Ladill Care API
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: Healthcare management API at care.ladill.com
|
description: |
|
||||||
|
Healthcare management API at care.ladill.com.
|
||||||
|
Assessment and pathway endpoints require organization `settings.rollout.assessments_engine = true`.
|
||||||
|
Public IDs are UUIDs (never internal integer FKs).
|
||||||
servers:
|
servers:
|
||||||
- url: https://care.ladill.com/api/v1
|
- url: https://care.ladill.com/api/v1
|
||||||
paths:
|
paths:
|
||||||
@@ -16,6 +19,13 @@ paths:
|
|||||||
summary: List patients
|
summary: List patients
|
||||||
post:
|
post:
|
||||||
summary: Register patient
|
summary: Register patient
|
||||||
|
/patients/{patient}:
|
||||||
|
get:
|
||||||
|
summary: Patient dashboard
|
||||||
|
put:
|
||||||
|
summary: Update patient
|
||||||
|
delete:
|
||||||
|
summary: Archive patient
|
||||||
/appointments:
|
/appointments:
|
||||||
get:
|
get:
|
||||||
summary: List appointments
|
summary: List appointments
|
||||||
@@ -29,3 +39,250 @@ paths:
|
|||||||
summary: List pharmacy inventory
|
summary: List pharmacy inventory
|
||||||
post:
|
post:
|
||||||
summary: Add drug
|
summary: Add drug
|
||||||
|
|
||||||
|
/assessment-templates:
|
||||||
|
get:
|
||||||
|
summary: List current system assessment templates
|
||||||
|
tags: [Assessments]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Template catalog with questions
|
||||||
|
'404':
|
||||||
|
description: Assessments engine not enabled
|
||||||
|
|
||||||
|
/patients/{patient}/assessments:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PatientUuid'
|
||||||
|
get:
|
||||||
|
summary: List assessments for a patient
|
||||||
|
tags: [Assessments]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
parameters:
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema: { type: string, enum: [draft, completed, cancelled] }
|
||||||
|
- name: template_code
|
||||||
|
in: query
|
||||||
|
schema: { type: string }
|
||||||
|
- name: category
|
||||||
|
in: query
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Paginated assessments
|
||||||
|
post:
|
||||||
|
summary: Start assessment (idempotent draft)
|
||||||
|
tags: [Assessments]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [template_code]
|
||||||
|
properties:
|
||||||
|
template_code: { type: string, example: nihss }
|
||||||
|
consultation_uuid: { type: string, format: uuid, nullable: true }
|
||||||
|
visit_uuid: { type: string, format: uuid, nullable: true }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Existing draft returned
|
||||||
|
'201':
|
||||||
|
description: New draft created
|
||||||
|
'403':
|
||||||
|
description: Capture not allowed for role/template
|
||||||
|
|
||||||
|
/consultations/{consultation}/assessments:
|
||||||
|
post:
|
||||||
|
summary: Start assessment linked to consultation
|
||||||
|
tags: [Assessments]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ConsultationUuid'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [template_code]
|
||||||
|
properties:
|
||||||
|
template_code: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Existing draft
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
|
||||||
|
/assessments/{assessment}:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/AssessmentUuid'
|
||||||
|
get:
|
||||||
|
summary: Get assessment with answers and score
|
||||||
|
tags: [Assessments]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Assessment detail
|
||||||
|
put:
|
||||||
|
summary: Save draft answers (keyed by question code)
|
||||||
|
tags: [Assessments]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
answers:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
example: { chief_complaint: "Headache", pain_score: 4 }
|
||||||
|
notes: { type: string, nullable: true }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Updated
|
||||||
|
'422':
|
||||||
|
description: Not draft or validation error
|
||||||
|
|
||||||
|
/assessments/{assessment}/complete:
|
||||||
|
post:
|
||||||
|
summary: Complete assessment (validates required; materializes score if strategy set)
|
||||||
|
tags: [Assessments]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/AssessmentUuid'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Completed with optional score
|
||||||
|
'422':
|
||||||
|
description: Missing required answers or already completed
|
||||||
|
|
||||||
|
/assessments/{assessment}/cancel:
|
||||||
|
post:
|
||||||
|
summary: Cancel draft assessment
|
||||||
|
tags: [Assessments]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/AssessmentUuid'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Cancelled
|
||||||
|
|
||||||
|
/assessments/{assessment}/fhir:
|
||||||
|
get:
|
||||||
|
summary: Export assessment as FHIR R4 Bundle (Questionnaire + QuestionnaireResponse)
|
||||||
|
tags: [Assessments]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/AssessmentUuid'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: application/fhir+json Bundle
|
||||||
|
content:
|
||||||
|
application/fhir+json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
|
||||||
|
/pathways:
|
||||||
|
get:
|
||||||
|
summary: List active clinical pathway catalog
|
||||||
|
tags: [Pathways]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Pathway catalog with template bindings
|
||||||
|
|
||||||
|
/patients/{patient}/pathways:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PatientUuid'
|
||||||
|
get:
|
||||||
|
summary: Active and historical patient pathways
|
||||||
|
tags: [Pathways]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Active + history
|
||||||
|
post:
|
||||||
|
summary: Activate pathway (creates required draft assessments)
|
||||||
|
tags: [Pathways]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [pathway_code]
|
||||||
|
properties:
|
||||||
|
pathway_code: { type: string, example: stroke }
|
||||||
|
consultation_uuid: { type: string, format: uuid, nullable: true }
|
||||||
|
activation_diagnosis_text: { type: string, nullable: true }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Activated (idempotent if already active)
|
||||||
|
|
||||||
|
/patients/{patient}/pathways/{patientPathway}/deactivate:
|
||||||
|
post:
|
||||||
|
summary: Deactivate active pathway
|
||||||
|
tags: [Pathways]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PatientUuid'
|
||||||
|
- name: patientPathway
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Deactivated
|
||||||
|
|
||||||
|
/consultations/{consultation}/pathway-suggestions:
|
||||||
|
get:
|
||||||
|
summary: Suggest pathways from persisted diagnoses only
|
||||||
|
tags: [Pathways]
|
||||||
|
security: [{ sanctum: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ConsultationUuid'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Ranked suggestions with match reasons
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pathway_code: { type: string }
|
||||||
|
pathway_name: { type: string }
|
||||||
|
rank: { type: integer }
|
||||||
|
match_reason: { type: string }
|
||||||
|
already_active: { type: boolean }
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
sanctum:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
parameters:
|
||||||
|
PatientUuid:
|
||||||
|
name: patient
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
ConsultationUuid:
|
||||||
|
name: consultation
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
AssessmentUuid:
|
||||||
|
name: assessment
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
|||||||
@@ -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>
|
</section>
|
||||||
@endif
|
@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))
|
@if (! $isCompleted && ($canManage || $canVitals))
|
||||||
<form method="POST" action="{{ route('care.consultations.update', $consultation) }}" enctype="multipart/form-data" class="mt-6 space-y-6">
|
<form method="POST" action="{{ route('care.consultations.update', $consultation) }}" enctype="multipart/form-data" class="mt-6 space-y-6">
|
||||||
@csrf @method('PUT')
|
@csrf @method('PUT')
|
||||||
@@ -135,7 +328,12 @@
|
|||||||
|
|
||||||
@if ($canManage)
|
@if ($canManage)
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-6">
|
<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 class="mt-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700">Symptoms</label>
|
<label class="block text-sm font-medium text-slate-700">Symptoms</label>
|
||||||
@@ -188,8 +386,13 @@
|
|||||||
</section>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<button type="submit" class="btn-primary">Save</button>
|
<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 & 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>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -211,7 +414,7 @@
|
|||||||
|
|
||||||
@if ($consultation->symptoms || $consultation->clinical_notes)
|
@if ($consultation->symptoms || $consultation->clinical_notes)
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-6">
|
<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)
|
@if ($consultation->symptoms)
|
||||||
<p class="mt-4 text-sm"><span class="font-medium text-slate-700">Symptoms:</span> {{ $consultation->symptoms }}</p>
|
<p class="mt-4 text-sm"><span class="font-medium text-slate-700">Symptoms:</span> {{ $consultation->symptoms }}</p>
|
||||||
@endif
|
@endif
|
||||||
@@ -221,6 +424,34 @@
|
|||||||
</section>
|
</section>
|
||||||
@endif
|
@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())
|
@if ($consultation->diagnoses->isNotEmpty())
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-6">
|
<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>
|
<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>
|
<p class="mt-1 text-slate-400">No prescriptions yet</p>
|
||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</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 & trends</a>
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,27 +26,114 @@
|
|||||||
</select>
|
</select>
|
||||||
<button type="submit" class="btn-primary">Apply</button>
|
<button type="submit" class="btn-primary">Apply</button>
|
||||||
</form>
|
</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']))
|
@if ($type === 'clinical' && isset($data['diagnoses']))
|
||||||
<table class="min-w-full text-sm">
|
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||||
<thead><tr><th class="py-2 text-left">Diagnosis</th><th class="py-2 text-right">Count</th></tr></thead>
|
<table class="min-w-full text-sm">
|
||||||
<tbody>
|
<thead><tr><th class="py-2 text-left">Diagnosis</th><th class="py-2 text-right">Count</th></tr></thead>
|
||||||
@forelse ($data['diagnoses'] as $row)
|
<tbody>
|
||||||
<tr class="border-t border-slate-50"><td class="py-2">{{ $row->description }}</td><td class="py-2 text-right">{{ $row->total }}</td></tr>
|
@forelse ($data['diagnoses'] as $row)
|
||||||
@empty
|
<tr class="border-t border-slate-50"><td class="py-2">{{ $row->description }}</td><td class="py-2 text-right">{{ $row->total }}</td></tr>
|
||||||
<tr><td colspan="2" class="py-4 text-slate-500">No diagnoses in period.</td></tr>
|
@empty
|
||||||
@endforelse
|
<tr><td colspan="2" class="py-4 text-slate-500">No diagnoses in period.</td></tr>
|
||||||
</tbody>
|
@endforelse
|
||||||
</table>
|
</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
|
@else
|
||||||
<dl class="grid gap-4 sm:grid-cols-2">
|
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||||
@foreach ($data as $key => $value)
|
<dl class="grid gap-4 sm:grid-cols-2">
|
||||||
<div class="rounded-xl border border-slate-100 bg-slate-50 p-4">
|
@foreach ($data as $key => $value)
|
||||||
<dt class="text-xs uppercase text-slate-500">{{ str_replace('_', ' ', $key) }}</dt>
|
<div class="rounded-xl border border-slate-100 bg-slate-50 p-4">
|
||||||
<dd class="mt-1 text-2xl font-semibold text-slate-900">{{ $formatValue($key, $value) }}</dd>
|
<dt class="text-xs uppercase text-slate-500">{{ str_replace('_', ' ', $key) }}</dt>
|
||||||
</div>
|
<dd class="mt-1 text-2xl font-semibold text-slate-900">{{ $formatValue($key, $value) }}</dd>
|
||||||
@endforeach
|
</div>
|
||||||
</dl>
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</section>
|
</section>
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Api\AppointmentController;
|
use App\Http\Controllers\Api\AppointmentController;
|
||||||
|
use App\Http\Controllers\Api\AssessmentController;
|
||||||
use App\Http\Controllers\Api\BillController;
|
use App\Http\Controllers\Api\BillController;
|
||||||
use App\Http\Controllers\Api\ConsultationController;
|
use App\Http\Controllers\Api\ConsultationController;
|
||||||
use App\Http\Controllers\Api\DrugController;
|
use App\Http\Controllers\Api\DrugController;
|
||||||
use App\Http\Controllers\Api\InvestigationController;
|
use App\Http\Controllers\Api\InvestigationController;
|
||||||
|
use App\Http\Controllers\Api\PathwayController;
|
||||||
use App\Http\Controllers\Api\PatientController;
|
use App\Http\Controllers\Api\PatientController;
|
||||||
use App\Http\Controllers\Api\PrescriptionController;
|
use App\Http\Controllers\Api\PrescriptionController;
|
||||||
use App\Http\Controllers\Api\QueueController;
|
use App\Http\Controllers\Api\QueueController;
|
||||||
@@ -59,4 +61,21 @@ Route::middleware(['auth:sanctum', 'care.setup'])->prefix('v1')->group(function
|
|||||||
Route::get('/drugs', [DrugController::class, 'index'])->name('api.drugs.index');
|
Route::get('/drugs', [DrugController::class, 'index'])->name('api.drugs.index');
|
||||||
Route::post('/drugs', [DrugController::class, 'store'])->name('api.drugs.store');
|
Route::post('/drugs', [DrugController::class, 'store'])->name('api.drugs.store');
|
||||||
Route::post('/prescriptions/{prescription}/dispense-stock', [DrugController::class, 'dispense'])->name('api.prescriptions.dispense-stock');
|
Route::post('/prescriptions/{prescription}/dispense-stock', [DrugController::class, 'dispense'])->name('api.prescriptions.dispense-stock');
|
||||||
|
|
||||||
|
// Clinical assessments & pathways (gated by assessments_engine rollout)
|
||||||
|
Route::get('/assessment-templates', [AssessmentController::class, 'templates'])->name('api.assessment-templates.index');
|
||||||
|
Route::get('/patients/{patient}/assessments', [AssessmentController::class, 'index'])->name('api.assessments.index');
|
||||||
|
Route::post('/patients/{patient}/assessments', [AssessmentController::class, 'store'])->name('api.assessments.store');
|
||||||
|
Route::post('/consultations/{consultation}/assessments', [AssessmentController::class, 'storeForConsultation'])->name('api.consultations.assessments.store');
|
||||||
|
Route::get('/assessments/{assessment}', [AssessmentController::class, 'show'])->name('api.assessments.show');
|
||||||
|
Route::put('/assessments/{assessment}', [AssessmentController::class, 'update'])->name('api.assessments.update');
|
||||||
|
Route::post('/assessments/{assessment}/complete', [AssessmentController::class, 'complete'])->name('api.assessments.complete');
|
||||||
|
Route::post('/assessments/{assessment}/cancel', [AssessmentController::class, 'cancel'])->name('api.assessments.cancel');
|
||||||
|
Route::get('/assessments/{assessment}/fhir', [AssessmentController::class, 'fhir'])->name('api.assessments.fhir');
|
||||||
|
|
||||||
|
Route::get('/pathways', [PathwayController::class, 'catalog'])->name('api.pathways.catalog');
|
||||||
|
Route::get('/patients/{patient}/pathways', [PathwayController::class, 'index'])->name('api.pathways.index');
|
||||||
|
Route::post('/patients/{patient}/pathways', [PathwayController::class, 'store'])->name('api.pathways.store');
|
||||||
|
Route::post('/patients/{patient}/pathways/{patientPathway}/deactivate', [PathwayController::class, 'deactivate'])->name('api.pathways.deactivate');
|
||||||
|
Route::get('/consultations/{consultation}/pathway-suggestions', [PathwayController::class, 'suggestions'])->name('api.consultations.pathway-suggestions');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\NotificationController;
|
|||||||
use App\Http\Controllers\Care\AuditLogController;
|
use App\Http\Controllers\Care\AuditLogController;
|
||||||
use App\Http\Controllers\Care\AppointmentController;
|
use App\Http\Controllers\Care\AppointmentController;
|
||||||
use App\Http\Controllers\Care\AppointmentMeetController;
|
use App\Http\Controllers\Care\AppointmentMeetController;
|
||||||
|
use App\Http\Controllers\Care\AssessmentController;
|
||||||
use App\Http\Controllers\Care\BillController;
|
use App\Http\Controllers\Care\BillController;
|
||||||
use App\Http\Controllers\Care\BranchController;
|
use App\Http\Controllers\Care\BranchController;
|
||||||
use App\Http\Controllers\Care\ConsultationController;
|
use App\Http\Controllers\Care\ConsultationController;
|
||||||
@@ -16,8 +17,10 @@ use App\Http\Controllers\Care\InvestigationController;
|
|||||||
use App\Http\Controllers\Care\InvestigationTypeController;
|
use App\Http\Controllers\Care\InvestigationTypeController;
|
||||||
use App\Http\Controllers\Care\MemberController;
|
use App\Http\Controllers\Care\MemberController;
|
||||||
use App\Http\Controllers\Care\OnboardingController;
|
use App\Http\Controllers\Care\OnboardingController;
|
||||||
|
use App\Http\Controllers\Care\OutcomeController;
|
||||||
use App\Http\Controllers\Care\PatientController;
|
use App\Http\Controllers\Care\PatientController;
|
||||||
use App\Http\Controllers\Care\PatientMessageController;
|
use App\Http\Controllers\Care\PatientMessageController;
|
||||||
|
use App\Http\Controllers\Care\PathwayController;
|
||||||
use App\Http\Controllers\Care\PractitionerController;
|
use App\Http\Controllers\Care\PractitionerController;
|
||||||
use App\Http\Controllers\Care\PrescriptionController;
|
use App\Http\Controllers\Care\PrescriptionController;
|
||||||
use App\Http\Controllers\Care\QueueController;
|
use App\Http\Controllers\Care\QueueController;
|
||||||
@@ -141,6 +144,27 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
|||||||
Route::put('/consultations/{consultation}', [ConsultationController::class, 'update'])->name('care.consultations.update');
|
Route::put('/consultations/{consultation}', [ConsultationController::class, 'update'])->name('care.consultations.update');
|
||||||
Route::post('/consultations/{consultation}/complete', [ConsultationController::class, 'complete'])->name('care.consultations.complete');
|
Route::post('/consultations/{consultation}/complete', [ConsultationController::class, 'complete'])->name('care.consultations.complete');
|
||||||
|
|
||||||
|
// Clinical assessments (gated by CareFeatures::assessments_engine)
|
||||||
|
Route::get('/patients/{patient}/assessments', [AssessmentController::class, 'index'])->name('care.assessments.index');
|
||||||
|
Route::get('/patients/{patient}/assessments/create', [AssessmentController::class, 'create'])->name('care.assessments.create');
|
||||||
|
Route::post('/patients/{patient}/assessments', [AssessmentController::class, 'store'])->name('care.assessments.store');
|
||||||
|
Route::post('/consultations/{consultation}/assessments', [AssessmentController::class, 'storeForConsultation'])->name('care.consultations.assessments.store');
|
||||||
|
Route::get('/assessments/{assessment}', [AssessmentController::class, 'show'])->name('care.assessments.show');
|
||||||
|
Route::put('/assessments/{assessment}', [AssessmentController::class, 'update'])->name('care.assessments.update');
|
||||||
|
Route::post('/assessments/{assessment}/complete', [AssessmentController::class, 'complete'])->name('care.assessments.complete');
|
||||||
|
Route::post('/assessments/{assessment}/cancel', [AssessmentController::class, 'cancel'])->name('care.assessments.cancel');
|
||||||
|
Route::get('/assessments/{assessment}/fhir', [AssessmentController::class, 'fhirExport'])->name('care.assessments.fhir');
|
||||||
|
|
||||||
|
// Clinical pathways (Layer 2)
|
||||||
|
Route::get('/patients/{patient}/pathways', [PathwayController::class, 'index'])->name('care.pathways.index');
|
||||||
|
Route::post('/patients/{patient}/pathways', [PathwayController::class, 'store'])->name('care.pathways.store');
|
||||||
|
Route::post('/patients/{patient}/pathways/{patientPathway}/deactivate', [PathwayController::class, 'deactivate'])->name('care.pathways.deactivate');
|
||||||
|
Route::get('/consultations/{consultation}/pathway-suggestions', [PathwayController::class, 'suggestions'])->name('care.consultations.pathway-suggestions');
|
||||||
|
|
||||||
|
// Layer 4 — outcomes & patient trends (free per-patient; org analytics Enterprise)
|
||||||
|
Route::get('/patients/{patient}/outcomes', [OutcomeController::class, 'index'])->name('care.outcomes.index');
|
||||||
|
Route::post('/patients/{patient}/outcomes', [OutcomeController::class, 'store'])->name('care.outcomes.store');
|
||||||
|
|
||||||
Route::get('/settings', [SettingsController::class, 'edit'])->name('care.settings');
|
Route::get('/settings', [SettingsController::class, 'edit'])->name('care.settings');
|
||||||
Route::put('/settings', [SettingsController::class, 'update'])->name('care.settings.update');
|
Route::put('/settings', [SettingsController::class, 'update'])->name('care.settings.update');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\ClinicalPathway;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Diagnosis;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use Database\Seeders\AssessmentTemplateSeeder;
|
||||||
|
use Database\Seeders\ClinicalPathwaySeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CareAssessmentApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Branch $branch;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected Member $member;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->user = User::create([
|
||||||
|
'public_id' => 'api-assess-user',
|
||||||
|
'name' => 'API User',
|
||||||
|
'email' => 'api-assess@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'name' => 'API Clinic',
|
||||||
|
'slug' => 'api-clinic',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'plan' => 'pro',
|
||||||
|
'plan_expires_at' => now()->addMonth()->toIso8601String(),
|
||||||
|
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->member = Member::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'role' => 'doctor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->branch = Branch::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_number' => 'LC-API-001',
|
||||||
|
'first_name' => 'Ama',
|
||||||
|
'last_name' => 'Api',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
$this->seed(ClinicalPathwaySeeder::class);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_lists_templates_and_404_when_flag_off(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/v1/assessment-templates')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.0.code', fn ($c) => is_string($c));
|
||||||
|
|
||||||
|
$this->organization->update([
|
||||||
|
'settings' => array_merge($this->organization->settings ?? [], [
|
||||||
|
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => false],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/assessment-templates')->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_assessment_lifecycle_with_uuids(): void
|
||||||
|
{
|
||||||
|
$create = $this->postJson("/api/v1/patients/{$this->patient->uuid}/assessments", [
|
||||||
|
'template_code' => 'mrs',
|
||||||
|
])->assertSuccessful();
|
||||||
|
|
||||||
|
$uuid = $create->json('uuid');
|
||||||
|
$this->assertNotEmpty($uuid);
|
||||||
|
|
||||||
|
$updated = $this->putJson("/api/v1/assessments/{$uuid}", [
|
||||||
|
'answers' => ['mrs_score' => '3'],
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$this->assertEquals(3, (float) $updated->json('answers.mrs_score'));
|
||||||
|
|
||||||
|
$completed = $this->postJson("/api/v1/assessments/{$uuid}/complete")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('status', 'completed');
|
||||||
|
|
||||||
|
$this->assertEquals(3, (float) $completed->json('score.total_score'));
|
||||||
|
|
||||||
|
$this->putJson("/api/v1/assessments/{$uuid}", [
|
||||||
|
'answers' => ['mrs_score' => '4'],
|
||||||
|
])->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_pathway_suggestions_and_activate(): void
|
||||||
|
{
|
||||||
|
$visit = Visit::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Visit::STATUS_OPEN,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$consultation = Consultation::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Consultation::STATUS_DRAFT,
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Diagnosis::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'consultation_id' => $consultation->id,
|
||||||
|
'code' => 'I63.9',
|
||||||
|
'description' => 'Cerebral infarction',
|
||||||
|
'is_primary' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson("/api/v1/consultations/{$consultation->uuid}/pathway-suggestions")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.0.pathway_code', 'stroke');
|
||||||
|
|
||||||
|
$this->postJson("/api/v1/patients/{$this->patient->uuid}/pathways", [
|
||||||
|
'pathway_code' => 'stroke',
|
||||||
|
'consultation_uuid' => $consultation->uuid,
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('pathway_code', 'stroke')
|
||||||
|
->assertJsonPath('status', 'active');
|
||||||
|
|
||||||
|
$this->getJson("/api/v1/patients/{$this->patient->uuid}/pathways")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('active.0.pathway_code', 'stroke');
|
||||||
|
|
||||||
|
$this->assertGreaterThanOrEqual(2, Assessment::where('status', Assessment::STATUS_DRAFT)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_tenant_isolation(): void
|
||||||
|
{
|
||||||
|
$other = User::create([
|
||||||
|
'public_id' => 'other-api',
|
||||||
|
'name' => 'Other',
|
||||||
|
'email' => 'other-api@example.com',
|
||||||
|
]);
|
||||||
|
$otherOrg = Organization::create([
|
||||||
|
'owner_ref' => $other->public_id,
|
||||||
|
'name' => 'Other',
|
||||||
|
'slug' => 'other-api',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
Member::create([
|
||||||
|
'owner_ref' => $other->public_id,
|
||||||
|
'organization_id' => $otherOrg->id,
|
||||||
|
'user_ref' => $other->public_id,
|
||||||
|
'role' => 'doctor',
|
||||||
|
]);
|
||||||
|
$otherPatient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $other->public_id,
|
||||||
|
'organization_id' => $otherOrg->id,
|
||||||
|
'patient_number' => 'LC-OTHER',
|
||||||
|
'first_name' => 'X',
|
||||||
|
'last_name' => 'Y',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson("/api/v1/patients/{$otherPatient->uuid}/assessments")->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_catalog_includes_pr10_pathways(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/v1/pathways')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonFragment(['code' => 'copd'])
|
||||||
|
->assertJsonFragment(['code' => 'heart_failure'])
|
||||||
|
->assertJsonFragment(['code' => 'dementia']);
|
||||||
|
|
||||||
|
$this->assertNotNull(ClinicalPathway::findByCode('pregnancy'));
|
||||||
|
$this->assertNotNull(ClinicalPathway::findByCode('orthopaedics'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentQuestion;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CareAssessmentCaptureTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Branch $branch;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected Member $member;
|
||||||
|
|
||||||
|
protected AssessmentTemplate $universal;
|
||||||
|
|
||||||
|
protected AssessmentTemplate $nihss;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->user = User::create([
|
||||||
|
'public_id' => 'assess-capture-user',
|
||||||
|
'name' => 'Capture User',
|
||||||
|
'email' => 'capture@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'name' => 'Capture Clinic',
|
||||||
|
'slug' => 'capture-clinic',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'rollout' => [
|
||||||
|
CareFeatures::ASSESSMENTS_ENGINE => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->member = Member::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'role' => 'doctor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->branch = Branch::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_number' => 'LC-CAP-001',
|
||||||
|
'first_name' => 'Ama',
|
||||||
|
'last_name' => 'Mensah',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->universal = AssessmentTemplate::create([
|
||||||
|
'code' => 'universal_intake',
|
||||||
|
'name' => 'Universal Intake',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_UNIVERSAL,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
'meta' => ['capture_roles' => ['doctor', 'nurse']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $this->universal->id,
|
||||||
|
'code' => 'chief_complaint',
|
||||||
|
'label' => 'Chief complaint',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_TEXT,
|
||||||
|
'is_required' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $this->universal->id,
|
||||||
|
'code' => 'pain_score',
|
||||||
|
'label' => 'Pain score',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_SCALE,
|
||||||
|
'options' => ['min' => 0, 'max' => 10],
|
||||||
|
'is_required' => false,
|
||||||
|
'sort_order' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->nihss = AssessmentTemplate::create([
|
||||||
|
'code' => 'nihss',
|
||||||
|
'name' => 'NIH Stroke Scale',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_DISEASE,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
'scoring_strategy' => 'sum_items',
|
||||||
|
'meta' => ['capture_roles' => ['doctor']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $this->nihss->id,
|
||||||
|
'code' => 'loc',
|
||||||
|
'label' => 'Level of consciousness',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
'options' => [
|
||||||
|
'choices' => [
|
||||||
|
['code' => '0', 'label' => 'Alert', 'score' => 0],
|
||||||
|
['code' => '1', 'label' => 'Not alert', 'score' => 1],
|
||||||
|
['code' => '2', 'label' => 'Obtunded', 'score' => 2],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'is_required' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setMemberRole(string $role): void
|
||||||
|
{
|
||||||
|
$this->member->update(['role' => $role]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_routes_404_when_feature_flag_disabled(): void
|
||||||
|
{
|
||||||
|
$this->organization->update([
|
||||||
|
'settings' => ['onboarded' => true, 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => false]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.assessments.index', $this->patient))
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_doctor_can_start_nihss(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('doctor');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), [
|
||||||
|
'template_code' => 'nihss',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->assertNotNull($assessment);
|
||||||
|
$this->assertSame(Assessment::STATUS_DRAFT, $assessment->status);
|
||||||
|
$this->assertSame($this->nihss->id, $assessment->template_id);
|
||||||
|
$this->assertDatabaseHas('care_audit_logs', ['action' => 'assessment.started']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nurse_can_start_universal_but_not_nihss(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('nurse');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), [
|
||||||
|
'template_code' => 'universal_intake',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame(1, Assessment::count());
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), [
|
||||||
|
'template_code' => 'nihss',
|
||||||
|
])
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_hospital_admin_can_start_nihss(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('hospital_admin');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), [
|
||||||
|
'template_code' => 'nihss',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame(1, Assessment::where('template_id', $this->nihss->id)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_super_admin_can_start_nihss(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('super_admin');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), [
|
||||||
|
'template_code' => 'nihss',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame(1, Assessment::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_start_is_idempotent_for_same_draft(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('doctor');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$first = Assessment::first();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss'])
|
||||||
|
->assertRedirect(route('care.assessments.show', $first));
|
||||||
|
|
||||||
|
$this->assertSame(1, Assessment::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_save_answers_complete_and_immutable(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('doctor');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'universal_intake'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => [
|
||||||
|
'chief_complaint' => 'Headache',
|
||||||
|
'pain_score' => 4,
|
||||||
|
],
|
||||||
|
'notes' => 'Initial',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('care.assessments.show', $assessment));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('care_assessment_answers', [
|
||||||
|
'assessment_id' => $assessment->id,
|
||||||
|
'value_text' => 'Headache',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('care_assessment_answers', [
|
||||||
|
'assessment_id' => $assessment->id,
|
||||||
|
'value_number' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment->refresh();
|
||||||
|
$this->assertSame(Assessment::STATUS_COMPLETED, $assessment->status);
|
||||||
|
$this->assertNotNull($assessment->completed_at);
|
||||||
|
$this->assertDatabaseHas('care_audit_logs', ['action' => 'assessment.completed']);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => ['chief_complaint' => 'Changed'],
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('status');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertSessionHasErrors('status');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_complete_requires_required_answers(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('nurse');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'universal_intake'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertSessionHasErrors('answers.chief_complaint');
|
||||||
|
|
||||||
|
$this->assertSame(Assessment::STATUS_DRAFT, $assessment->fresh()->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cancel_draft_only(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('doctor');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'universal_intake'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.cancel', $assessment))
|
||||||
|
->assertRedirect(route('care.assessments.index', $this->patient));
|
||||||
|
|
||||||
|
$this->assertSame(Assessment::STATUS_CANCELLED, $assessment->fresh()->status);
|
||||||
|
$this->assertDatabaseHas('care_audit_logs', ['action' => 'assessment.cancelled']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_consultation_scoped_start_links_consultation_and_visit(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('doctor');
|
||||||
|
|
||||||
|
$visit = Visit::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Visit::STATUS_OPEN,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$consultation = Consultation::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Consultation::STATUS_DRAFT,
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.consultations.assessments.store', $consultation), [
|
||||||
|
'template_code' => 'universal_intake',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->assertSame($consultation->id, $assessment->consultation_id);
|
||||||
|
$this->assertSame($visit->id, $assessment->visit_id);
|
||||||
|
$this->assertSame($this->branch->id, $assessment->branch_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_public_bodies_use_consultation_uuid(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('doctor');
|
||||||
|
|
||||||
|
$visit = Visit::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Visit::STATUS_OPEN,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$consultation = Consultation::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Consultation::STATUS_DRAFT,
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), [
|
||||||
|
'template_code' => 'universal_intake',
|
||||||
|
'consultation_uuid' => $consultation->uuid,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->assertSame($consultation->id, $assessment->consultation_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_tenant_isolation_returns_404(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('doctor');
|
||||||
|
|
||||||
|
$otherUser = User::create([
|
||||||
|
'public_id' => 'other-owner',
|
||||||
|
'name' => 'Other',
|
||||||
|
'email' => 'other@example.com',
|
||||||
|
]);
|
||||||
|
$otherOrg = Organization::create([
|
||||||
|
'owner_ref' => $otherUser->public_id,
|
||||||
|
'name' => 'Other Clinic',
|
||||||
|
'slug' => 'other-clinic',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => ['onboarded' => true, 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true]],
|
||||||
|
]);
|
||||||
|
Member::create([
|
||||||
|
'owner_ref' => $otherUser->public_id,
|
||||||
|
'organization_id' => $otherOrg->id,
|
||||||
|
'user_ref' => $otherUser->public_id,
|
||||||
|
'role' => 'doctor',
|
||||||
|
]);
|
||||||
|
$otherPatient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $otherUser->public_id,
|
||||||
|
'organization_id' => $otherOrg->id,
|
||||||
|
'patient_number' => 'LC-OTHER',
|
||||||
|
'first_name' => 'Other',
|
||||||
|
'last_name' => 'Patient',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.assessments.index', $otherPatient))
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_score_item_stores_numeric_score_from_choice_code(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('doctor');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => ['loc' => '2'],
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('care_assessment_answers', [
|
||||||
|
'assessment_id' => $assessment->id,
|
||||||
|
'value_number' => 2,
|
||||||
|
'value_text' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_receptionist_cannot_view_assessments(): void
|
||||||
|
{
|
||||||
|
$this->setMemberRole('receptionist');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.assessments.index', $this->patient))
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentAnswer;
|
||||||
|
use App\Models\AssessmentQuestion;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PR 1 — schema, models, relations, catalog uniqueness (no service/UI yet).
|
||||||
|
*/
|
||||||
|
class CareAssessmentEngineTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Branch $branch;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->user = User::create([
|
||||||
|
'public_id' => 'test-user-assess-001',
|
||||||
|
'name' => 'Assessment Test User',
|
||||||
|
'email' => 'assess@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'name' => 'Assess Clinic',
|
||||||
|
'slug' => 'assess-clinic',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => ['onboarded' => true],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Member::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'role' => 'doctor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->branch = Branch::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_number' => 'LC-2026-ASSESS-001',
|
||||||
|
'first_name' => 'Ama',
|
||||||
|
'last_name' => 'Mensah',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_template_auto_generates_uuid_and_has_no_owner_ref(): void
|
||||||
|
{
|
||||||
|
$template = AssessmentTemplate::create([
|
||||||
|
'code' => 'universal_intake',
|
||||||
|
'name' => 'Universal Intake',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_UNIVERSAL,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
'meta' => ['capture_roles' => ['nurse', 'doctor']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotEmpty($template->uuid);
|
||||||
|
$this->assertTrue(Str::isUuid($template->uuid));
|
||||||
|
$this->assertNull($template->organization_id);
|
||||||
|
$this->assertFalse(isset($template->getAttributes()['owner_ref']));
|
||||||
|
$this->assertSame(['capture_roles' => ['nurse', 'doctor']], $template->meta);
|
||||||
|
$this->assertSame('uuid', $template->getRouteKeyName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_system_template_code_version_is_unique(): void
|
||||||
|
{
|
||||||
|
AssessmentTemplate::create([
|
||||||
|
'code' => 'nihss',
|
||||||
|
'name' => 'NIHSS v1',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_DISEASE,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(QueryException::class);
|
||||||
|
|
||||||
|
AssessmentTemplate::create([
|
||||||
|
'code' => 'nihss',
|
||||||
|
'name' => 'NIHSS v1 duplicate',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_DISEASE,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_same_code_different_versions_allowed(): void
|
||||||
|
{
|
||||||
|
AssessmentTemplate::create([
|
||||||
|
'code' => 'mrs',
|
||||||
|
'name' => 'mRS v1',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_DISEASE,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$v2 = AssessmentTemplate::create([
|
||||||
|
'code' => 'mrs',
|
||||||
|
'name' => 'mRS v2',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_DISEASE,
|
||||||
|
'version' => 2,
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(2, AssessmentTemplate::where('code', 'mrs')->count());
|
||||||
|
$this->assertTrue($v2->is_current);
|
||||||
|
$this->assertSame($v2->id, AssessmentTemplate::currentSystemByCode('mrs')?->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_question_unique_per_template_and_ordered_relation(): void
|
||||||
|
{
|
||||||
|
$template = AssessmentTemplate::create([
|
||||||
|
'code' => 'fixture_scale',
|
||||||
|
'name' => 'Fixture',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_SCREENING,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'code' => 'item_b',
|
||||||
|
'label' => 'Item B',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
'sort_order' => 2,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'code' => 'item_a',
|
||||||
|
'label' => 'Item A',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$codes = $template->questions()->pluck('code')->all();
|
||||||
|
$this->assertSame(['item_a', 'item_b'], $codes);
|
||||||
|
|
||||||
|
$this->expectException(QueryException::class);
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'code' => 'item_a',
|
||||||
|
'label' => 'Duplicate',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_TEXT,
|
||||||
|
'sort_order' => 3,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assessment_instance_relations_and_owned_scope(): void
|
||||||
|
{
|
||||||
|
$template = AssessmentTemplate::create([
|
||||||
|
'code' => 'universal_intake',
|
||||||
|
'name' => 'Universal Intake',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_UNIVERSAL,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$question = AssessmentQuestion::create([
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'code' => 'chief_complaint',
|
||||||
|
'label' => 'Chief complaint',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_TEXT,
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$visit = Visit::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Visit::STATUS_OPEN,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$consultation = Consultation::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Consultation::STATUS_DRAFT,
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assessment = Assessment::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'consultation_id' => $consultation->id,
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'status' => Assessment::STATUS_DRAFT,
|
||||||
|
'started_by' => $this->user->public_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotEmpty($assessment->uuid);
|
||||||
|
$this->assertTrue(Str::isUuid($assessment->uuid));
|
||||||
|
$this->assertTrue($assessment->isDraft());
|
||||||
|
$this->assertFalse($assessment->isCompleted());
|
||||||
|
|
||||||
|
AssessmentAnswer::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'assessment_id' => $assessment->id,
|
||||||
|
'question_id' => $question->id,
|
||||||
|
'value_text' => 'Headache for 2 days',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(1, $assessment->answers()->count());
|
||||||
|
$this->assertSame('Headache for 2 days', $assessment->answers->first()->authoritativeValue());
|
||||||
|
$this->assertTrue($this->patient->assessments->contains($assessment));
|
||||||
|
$this->assertTrue($consultation->assessments->contains($assessment));
|
||||||
|
$this->assertTrue($visit->assessments->contains($assessment));
|
||||||
|
$this->assertSame($template->id, $assessment->template->id);
|
||||||
|
|
||||||
|
$this->assertSame(1, Assessment::query()->owned($this->user->public_id)->count());
|
||||||
|
$this->assertSame(0, Assessment::query()->owned('other-owner')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_answer_unique_per_assessment_and_question(): void
|
||||||
|
{
|
||||||
|
$template = AssessmentTemplate::create([
|
||||||
|
'code' => 'fixture',
|
||||||
|
'name' => 'Fixture',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_UNIVERSAL,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$question = AssessmentQuestion::create([
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'code' => 'pain_score',
|
||||||
|
'label' => 'Pain',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_NUMBER,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assessment = Assessment::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'status' => Assessment::STATUS_DRAFT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentAnswer::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'assessment_id' => $assessment->id,
|
||||||
|
'question_id' => $question->id,
|
||||||
|
'value_number' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(QueryException::class);
|
||||||
|
AssessmentAnswer::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'assessment_id' => $assessment->id,
|
||||||
|
'question_id' => $question->id,
|
||||||
|
'value_number' => 7,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_deleting_assessment_cascades_answers_but_not_template(): void
|
||||||
|
{
|
||||||
|
$template = AssessmentTemplate::create([
|
||||||
|
'code' => 'cascade_fixture',
|
||||||
|
'name' => 'Cascade',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_UNIVERSAL,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$question = AssessmentQuestion::create([
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'code' => 'q1',
|
||||||
|
'label' => 'Q1',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_BOOLEAN,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assessment = Assessment::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'status' => Assessment::STATUS_DRAFT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentAnswer::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'assessment_id' => $assessment->id,
|
||||||
|
'question_id' => $question->id,
|
||||||
|
'value_boolean' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assessmentId = $assessment->id;
|
||||||
|
$assessment->forceDelete();
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('care_assessments', ['id' => $assessmentId]);
|
||||||
|
$this->assertDatabaseMissing('care_assessment_answers', ['assessment_id' => $assessmentId]);
|
||||||
|
$this->assertDatabaseHas('care_assessment_templates', ['id' => $template->id]);
|
||||||
|
$this->assertDatabaseHas('care_assessment_questions', ['id' => $question->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use App\Services\Care\FhirAssessmentExporter;
|
||||||
|
use Database\Seeders\AssessmentTemplateSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CareAssessmentExtrasTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->user = User::create([
|
||||||
|
'public_id' => 'extras-user',
|
||||||
|
'name' => 'Extras',
|
||||||
|
'email' => 'extras@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'name' => 'Extras Clinic',
|
||||||
|
'slug' => 'extras-clinic',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'plan' => 'enterprise',
|
||||||
|
'plan_expires_at' => now()->addMonth()->toIso8601String(),
|
||||||
|
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Member::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'role' => 'hospital_admin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Branch::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'patient_number' => 'LC-EX-001',
|
||||||
|
'first_name' => 'Extra',
|
||||||
|
'last_name' => 'Patient',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_enterprise_assessment_analytics_report(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'mrs'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), ['answers' => ['mrs_score' => '2']]);
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment));
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.reports.show', ['type' => 'assessments']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Assessment analytics')
|
||||||
|
->assertSee('By template')
|
||||||
|
->assertSee('mrs');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pro_plan_blocked_from_assessment_analytics_without_feature(): void
|
||||||
|
{
|
||||||
|
// Pro can access paid reports middleware, but lacks assessment_analytics (Enterprise-only).
|
||||||
|
$this->organization->update([
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'plan' => 'pro',
|
||||||
|
'plan_expires_at' => now()->addMonth()->toIso8601String(),
|
||||||
|
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.reports.show', ['type' => 'assessments']))
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_fhir_export_web_and_api(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'mrs'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), ['answers' => ['mrs_score' => '1']]);
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment));
|
||||||
|
|
||||||
|
$bundle = app(FhirAssessmentExporter::class)->exportBundle($assessment->fresh(['template.questions', 'answers.question', 'patient', 'score']));
|
||||||
|
$this->assertSame('Bundle', $bundle['resourceType']);
|
||||||
|
$this->assertSame('Questionnaire', $bundle['entry'][0]['resource']['resourceType']);
|
||||||
|
$this->assertSame('QuestionnaireResponse', $bundle['entry'][1]['resource']['resourceType']);
|
||||||
|
$this->assertSame('completed', $bundle['entry'][1]['resource']['status']);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.assessments.fhir', $assessment).'?download=1')
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->user);
|
||||||
|
$this->getJson('/api/v1/assessments/'.$assessment->uuid.'/fhir')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('resourceType', 'Bundle')
|
||||||
|
->assertJsonPath('entry.1.resource.resourceType', 'QuestionnaireResponse');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentQuestion;
|
||||||
|
use App\Models\AssessmentScore;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use App\Services\Care\ScoringService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CareAssessmentScoringTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Branch $branch;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected Member $member;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->user = User::create([
|
||||||
|
'public_id' => 'scoring-user',
|
||||||
|
'name' => 'Scoring User',
|
||||||
|
'email' => 'scoring@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'name' => 'Score Clinic',
|
||||||
|
'slug' => 'score-clinic',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->member = Member::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'role' => 'doctor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->branch = Branch::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_number' => 'LC-SCORE-001',
|
||||||
|
'first_name' => 'Ama',
|
||||||
|
'last_name' => 'Score',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sum_items_materializes_total_and_max(): void
|
||||||
|
{
|
||||||
|
$template = AssessmentTemplate::create([
|
||||||
|
'code' => 'nihss_fixture',
|
||||||
|
'name' => 'NIHSS fixture',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_DISEASE,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
'scoring_strategy' => 'sum_items',
|
||||||
|
'meta' => ['capture_roles' => ['doctor']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'code' => 'loc',
|
||||||
|
'label' => 'LOC',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
'options' => [
|
||||||
|
'choices' => [
|
||||||
|
['code' => '0', 'label' => 'Alert', 'score' => 0],
|
||||||
|
['code' => '2', 'label' => 'Obtunded', 'score' => 2],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'is_required' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
'score_key' => 'loc',
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'code' => 'motor',
|
||||||
|
'label' => 'Motor',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
'options' => [
|
||||||
|
'max' => 4,
|
||||||
|
'choices' => [
|
||||||
|
['code' => '0', 'label' => 'None', 'score' => 0],
|
||||||
|
['code' => '3', 'label' => 'Severe', 'score' => 3],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'is_required' => true,
|
||||||
|
'sort_order' => 2,
|
||||||
|
'score_key' => 'motor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss_fixture'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => ['loc' => '2', 'motor' => '3'],
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$score = AssessmentScore::where('assessment_id', $assessment->id)->first();
|
||||||
|
$this->assertNotNull($score);
|
||||||
|
$this->assertEquals(5, (float) $score->total_score);
|
||||||
|
$this->assertEquals(6, (float) $score->max_score); // 2 + 4
|
||||||
|
$this->assertSame('nihss_fixture', $score->template_code);
|
||||||
|
$this->assertEquals(2, $score->subscores['loc']);
|
||||||
|
$this->assertEquals(3, $score->subscores['motor']);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.assessments.show', $assessment))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Score')
|
||||||
|
->assertSee('5');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_single_value_strategy_for_mrs_style(): void
|
||||||
|
{
|
||||||
|
$template = AssessmentTemplate::create([
|
||||||
|
'code' => 'mrs_fixture',
|
||||||
|
'name' => 'mRS fixture',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_DISEASE,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
'scoring_strategy' => 'single_value',
|
||||||
|
'meta' => ['capture_roles' => ['doctor']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'code' => 'mrs_score',
|
||||||
|
'label' => 'mRS',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
'options' => [
|
||||||
|
'min' => 0,
|
||||||
|
'max' => 6,
|
||||||
|
'choices' => [
|
||||||
|
['code' => '0', 'label' => 'None', 'score' => 0],
|
||||||
|
['code' => '3', 'label' => 'Moderate', 'score' => 3],
|
||||||
|
['code' => '6', 'label' => 'Dead', 'score' => 6],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'is_required' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
'score_key' => 'total',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'mrs_fixture'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->from(route('care.assessments.show', $assessment))
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => ['mrs_score' => '3'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$preview = app(ScoringService::class)->preview($assessment->fresh(['template.questions', 'answers.question']));
|
||||||
|
$this->assertEquals(3, $preview['total']);
|
||||||
|
$this->assertEquals(6, $preview['max']);
|
||||||
|
|
||||||
|
$score = AssessmentScore::first();
|
||||||
|
$this->assertEquals(3, (float) $score->total_score);
|
||||||
|
$this->assertEquals(6, (float) $score->max_score);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_null_scoring_strategy_skips_score_row(): void
|
||||||
|
{
|
||||||
|
$template = AssessmentTemplate::create([
|
||||||
|
'code' => 'no_score',
|
||||||
|
'name' => 'No score',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_UNIVERSAL,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
'scoring_strategy' => null,
|
||||||
|
'meta' => ['capture_roles' => ['doctor', 'nurse']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'code' => 'note',
|
||||||
|
'label' => 'Note',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_TEXT,
|
||||||
|
'is_required' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'no_score'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => ['note' => 'hello'],
|
||||||
|
]);
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame(0, AssessmentScore::count());
|
||||||
|
$this->assertTrue($assessment->fresh()->isCompleted());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_complete_fails_when_score_items_missing(): void
|
||||||
|
{
|
||||||
|
$template = AssessmentTemplate::create([
|
||||||
|
'code' => 'need_scores',
|
||||||
|
'name' => 'Need scores',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_DISEASE,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
'scoring_strategy' => 'sum_items',
|
||||||
|
'meta' => ['capture_roles' => ['doctor']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $template->id,
|
||||||
|
'code' => 'item_a',
|
||||||
|
'label' => 'A',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
'options' => ['choices' => [['code' => '1', 'label' => '1', 'score' => 1]]],
|
||||||
|
'is_required' => false,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'need_scores'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->from(route('care.assessments.show', $assessment))
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertSessionHasErrors('answers.item_a');
|
||||||
|
|
||||||
|
$this->assertSame(Assessment::STATUS_DRAFT, $assessment->fresh()->status);
|
||||||
|
$this->assertSame(0, AssessmentScore::count());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\ClinicalPathway;
|
||||||
|
use Database\Seeders\AssessmentTemplateSeeder;
|
||||||
|
use Database\Seeders\ClinicalPathwaySeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/** PR 10 — additional specialty content packs seed cleanly. */
|
||||||
|
class CareContentPacksTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_all_core_assessment_packs_seed(): void
|
||||||
|
{
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
|
||||||
|
$expected = [
|
||||||
|
'universal_intake',
|
||||||
|
'nihss',
|
||||||
|
'mrs',
|
||||||
|
'barthel',
|
||||||
|
'gcs',
|
||||||
|
'stroke_swallow',
|
||||||
|
'stroke_clinical',
|
||||||
|
'diabetes_core',
|
||||||
|
'outcome_core',
|
||||||
|
'heart_failure_core',
|
||||||
|
'ckd_core',
|
||||||
|
'hypertension_core',
|
||||||
|
'asthma_core',
|
||||||
|
'copd_core',
|
||||||
|
'dementia_core',
|
||||||
|
'parkinsons_core',
|
||||||
|
'cancer_core',
|
||||||
|
'pregnancy_core',
|
||||||
|
'orthopaedics_core',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($expected as $code) {
|
||||||
|
$this->assertNotNull(
|
||||||
|
AssessmentTemplate::currentSystemByCode($code),
|
||||||
|
"Missing template {$code}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_all_pathways_seed_with_required_bindings(): void
|
||||||
|
{
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
$this->seed(ClinicalPathwaySeeder::class);
|
||||||
|
|
||||||
|
$expected = [
|
||||||
|
'stroke',
|
||||||
|
'diabetes',
|
||||||
|
'heart_failure',
|
||||||
|
'ckd',
|
||||||
|
'hypertension',
|
||||||
|
'asthma',
|
||||||
|
'copd',
|
||||||
|
'dementia',
|
||||||
|
'parkinsons',
|
||||||
|
'cancer',
|
||||||
|
'pregnancy',
|
||||||
|
'orthopaedics',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($expected as $code) {
|
||||||
|
$pathway = ClinicalPathway::findByCode($code);
|
||||||
|
$this->assertNotNull($pathway, "Missing pathway {$code}");
|
||||||
|
$this->assertGreaterThanOrEqual(
|
||||||
|
1,
|
||||||
|
$pathway->templates()->where('is_required_on_activation', true)->count(),
|
||||||
|
"Pathway {$code} needs a required template"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_copd_and_heart_failure_scoring_templates(): void
|
||||||
|
{
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
|
||||||
|
$copd = AssessmentTemplate::currentSystemByCode('copd_core');
|
||||||
|
$hf = AssessmentTemplate::currentSystemByCode('heart_failure_core');
|
||||||
|
|
||||||
|
$this->assertSame('single_value', $copd->scoring_strategy);
|
||||||
|
$this->assertSame('single_value', $hf->scoring_strategy);
|
||||||
|
$this->assertTrue($copd->questions()->where('code', 'mmrc')->exists());
|
||||||
|
$this->assertTrue($hf->questions()->where('code', 'nyha')->exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\ClinicalPathway;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Diagnosis;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\PatientPathway;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use App\Services\Care\PathwayMatcher;
|
||||||
|
use App\Services\Care\PathwayService;
|
||||||
|
use Database\Seeders\AssessmentTemplateSeeder;
|
||||||
|
use Database\Seeders\ClinicalPathwaySeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/** PR 7 — diabetes_core + diabetes pathway. */
|
||||||
|
class CareDiabetesPathwayTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Branch $branch;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected Member $member;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->user = User::create([
|
||||||
|
'public_id' => 'dm-user',
|
||||||
|
'name' => 'DM User',
|
||||||
|
'email' => 'dm@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'name' => 'DM Clinic',
|
||||||
|
'slug' => 'dm-clinic',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->member = Member::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'role' => 'doctor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->branch = Branch::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_number' => 'LC-DM-001',
|
||||||
|
'first_name' => 'Abena',
|
||||||
|
'last_name' => 'Darko',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
$this->seed(ClinicalPathwaySeeder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_diabetes_pack_and_pathway_seeded(): void
|
||||||
|
{
|
||||||
|
$template = AssessmentTemplate::currentSystemByCode('diabetes_core');
|
||||||
|
$this->assertNotNull($template);
|
||||||
|
$this->assertTrue($template->questions()->where('code', 'foot_assessment')->exists());
|
||||||
|
$this->assertTrue($template->questions()->where('code', 'hba1c')->exists());
|
||||||
|
|
||||||
|
$pathway = ClinicalPathway::findByCode('diabetes');
|
||||||
|
$this->assertNotNull($pathway);
|
||||||
|
$this->assertTrue(
|
||||||
|
$pathway->templates()->where('template_code', 'diabetes_core')->where('is_required_on_activation', true)->exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_matcher_matches_type2_and_e11(): void
|
||||||
|
{
|
||||||
|
$matcher = app(PathwayMatcher::class);
|
||||||
|
|
||||||
|
$byCode = $matcher->match([['code' => 'E11.9', 'description' => '']]);
|
||||||
|
$this->assertTrue($byCode->contains(fn ($r) => $r['pathway_code'] === 'diabetes'));
|
||||||
|
|
||||||
|
$byKw = $matcher->match([['code' => '', 'description' => 'Type 2 diabetes mellitus']]);
|
||||||
|
$this->assertTrue($byKw->contains(fn ($r) => $r['pathway_code'] === 'diabetes'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_activate_diabetes_creates_core_draft_and_comorbidity_with_stroke(): void
|
||||||
|
{
|
||||||
|
$visit = Visit::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Visit::STATUS_OPEN,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$consultation = Consultation::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Consultation::STATUS_DRAFT,
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Diagnosis::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'consultation_id' => $consultation->id,
|
||||||
|
'code' => 'E11.9',
|
||||||
|
'description' => 'Type 2 diabetes mellitus',
|
||||||
|
'is_primary' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.pathways.store', $this->patient), [
|
||||||
|
'pathway_code' => 'diabetes',
|
||||||
|
'consultation_uuid' => $consultation->uuid,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame(1, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count());
|
||||||
|
$this->assertTrue(
|
||||||
|
Assessment::query()->whereHas('template', fn ($q) => $q->where('code', 'diabetes_core'))->exists()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Comorbidity: also activate stroke
|
||||||
|
app(PathwayService::class)->activate(
|
||||||
|
$this->patient,
|
||||||
|
ClinicalPathway::findByCode('stroke'),
|
||||||
|
$this->user->public_id,
|
||||||
|
$this->member,
|
||||||
|
['consultation' => $consultation, 'actor' => $this->user->public_id],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(2, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count());
|
||||||
|
$this->assertGreaterThanOrEqual(3, Assessment::where('status', Assessment::STATUS_DRAFT)->count()); // dm + nihss + mrs
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.consultations.show', $consultation))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Diabetes')
|
||||||
|
->assertSee('Stroke')
|
||||||
|
->assertSee('diabetes_core');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nurse_can_complete_diabetes_core(): void
|
||||||
|
{
|
||||||
|
$this->member->update(['role' => 'nurse']);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'diabetes_core'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => [
|
||||||
|
'foot_assessment' => 'normal',
|
||||||
|
'hba1c' => 7.2,
|
||||||
|
'diet_adherence' => 'good',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertTrue($assessment->fresh()->isCompleted());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use Database\Seeders\AssessmentTemplateSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/** PR 8 — outcome_core + patient trends. */
|
||||||
|
class CareOutcomeTrendTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected Member $member;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->user = User::create([
|
||||||
|
'public_id' => 'outcome-user',
|
||||||
|
'name' => 'Outcome User',
|
||||||
|
'email' => 'outcome@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'name' => 'Outcome Clinic',
|
||||||
|
'slug' => 'outcome-clinic',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'plan' => 'free',
|
||||||
|
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->member = Member::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'role' => 'nurse',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Branch::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'patient_number' => 'LC-OUT-001',
|
||||||
|
'first_name' => 'Kofi',
|
||||||
|
'last_name' => 'Owusu',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_outcome_core_seeded(): void
|
||||||
|
{
|
||||||
|
$t = AssessmentTemplate::currentSystemByCode('outcome_core');
|
||||||
|
$this->assertNotNull($t);
|
||||||
|
$this->assertSame(AssessmentTemplate::CATEGORY_OUTCOME, $t->category);
|
||||||
|
$this->assertTrue($t->questions()->where('code', 'quality_of_life')->exists());
|
||||||
|
$this->assertTrue($t->questions()->where('code', 'falls_count')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nurse_can_record_outcomes_and_see_trends_on_free_plan(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.outcomes.store', $this->patient))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->assertSame('outcome_core', $assessment->template->code);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => [
|
||||||
|
'quality_of_life' => 7,
|
||||||
|
'pain_score' => 3,
|
||||||
|
'falls_count' => 1,
|
||||||
|
'hospital_admissions_since_last' => 0,
|
||||||
|
'medication_adherence' => 'good',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.outcomes.index', $this->patient))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Longitudinal outcomes')
|
||||||
|
->assertSee('Quality of life trend')
|
||||||
|
->assertSee('7')
|
||||||
|
->assertSee('Pain score trend')
|
||||||
|
->assertSee('3')
|
||||||
|
->assertSee('Enterprise'); // free plan notice
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.patients.show', $this->patient))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Outcomes');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_second_outcome_builds_series(): void
|
||||||
|
{
|
||||||
|
foreach ([[5, 4], [8, 2]] as [$qol, $pain]) {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.outcomes.store', $this->patient))
|
||||||
|
->assertRedirect();
|
||||||
|
$assessment = Assessment::query()->where('status', Assessment::STATUS_DRAFT)->latest('id')->first();
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => [
|
||||||
|
'quality_of_life' => $qol,
|
||||||
|
'pain_score' => $pain,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent start when draft exists would reuse — after complete, second store creates new.
|
||||||
|
$this->assertSame(2, Assessment::where('status', Assessment::STATUS_COMPLETED)->count());
|
||||||
|
|
||||||
|
$html = $this->actingAs($this->user)
|
||||||
|
->get(route('care.outcomes.index', $this->patient))
|
||||||
|
->assertOk()
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('8', $html);
|
||||||
|
$this->assertStringContainsString('5', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentQuestion;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\ClinicalPathway;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Diagnosis;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\PatientPathway;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use App\Services\Care\PathwayMatcher;
|
||||||
|
use App\Services\Care\PathwayService;
|
||||||
|
use Database\Seeders\ClinicalPathwaySeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CarePathwayTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Branch $branch;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected Member $member;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->user = User::create([
|
||||||
|
'public_id' => 'pathway-user',
|
||||||
|
'name' => 'Pathway User',
|
||||||
|
'email' => 'pathway@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'name' => 'Pathway Clinic',
|
||||||
|
'slug' => 'pathway-clinic',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->member = Member::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'role' => 'doctor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->branch = Branch::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_number' => 'LC-PATH-001',
|
||||||
|
'first_name' => 'Kojo',
|
||||||
|
'last_name' => 'Stroke',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_clinical_pathway_seeder_loads_stroke(): void
|
||||||
|
{
|
||||||
|
$this->seed(ClinicalPathwaySeeder::class);
|
||||||
|
|
||||||
|
$pathway = ClinicalPathway::findByCode('stroke');
|
||||||
|
$this->assertNotNull($pathway);
|
||||||
|
$this->assertTrue($pathway->is_active);
|
||||||
|
$this->assertContains('I63', $pathway->match_rules['icd_prefixes'] ?? []);
|
||||||
|
$this->assertGreaterThanOrEqual(2, $pathway->templates()->count());
|
||||||
|
$this->assertTrue($pathway->templates->contains('template_code', 'nihss'));
|
||||||
|
$this->assertTrue($pathway->templates->contains('template_code', 'mrs'));
|
||||||
|
$required = $pathway->templates()->where('is_required_on_activation', true)->pluck('template_code')->all();
|
||||||
|
$this->assertEqualsCanonicalizing(['nihss', 'mrs'], $required);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_matcher_icd_keyword_exclude_and_no_match(): void
|
||||||
|
{
|
||||||
|
$this->seed(ClinicalPathwaySeeder::class);
|
||||||
|
$matcher = app(PathwayMatcher::class);
|
||||||
|
|
||||||
|
$icd = $matcher->match([['code' => 'I63.9', 'description' => '']]);
|
||||||
|
$this->assertCount(1, $icd);
|
||||||
|
$this->assertSame('stroke', $icd[0]['pathway_code']);
|
||||||
|
$this->assertSame(100, $icd[0]['rank']);
|
||||||
|
$this->assertStringStartsWith('icd_prefix:', $icd[0]['match_reason']);
|
||||||
|
|
||||||
|
$kw = $matcher->match([['code' => '', 'description' => 'Ischaemic stroke']]);
|
||||||
|
$this->assertCount(1, $kw);
|
||||||
|
$this->assertSame(50, $kw[0]['rank']);
|
||||||
|
$this->assertStringContainsString('keyword:', $kw[0]['match_reason']);
|
||||||
|
|
||||||
|
$tia = $matcher->match([['code' => 'G45.9', 'description' => 'TIA']]);
|
||||||
|
$this->assertCount(1, $tia);
|
||||||
|
$this->assertSame('stroke', $tia[0]['pathway_code']);
|
||||||
|
|
||||||
|
$none = $matcher->match([['code' => '', 'description' => 'History unclear']]);
|
||||||
|
$this->assertCount(0, $none);
|
||||||
|
|
||||||
|
$excluded = $matcher->match([['code' => '', 'description' => 'Family history of stroke']]);
|
||||||
|
$this->assertCount(0, $excluded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_activate_is_idempotent_and_creates_required_drafts(): void
|
||||||
|
{
|
||||||
|
$this->seed(ClinicalPathwaySeeder::class);
|
||||||
|
|
||||||
|
// Minimal current templates so activation can draft them.
|
||||||
|
foreach (['nihss' => 'NIHSS', 'mrs' => 'mRS'] as $code => $name) {
|
||||||
|
$t = AssessmentTemplate::create([
|
||||||
|
'code' => $code,
|
||||||
|
'name' => $name,
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_DISEASE,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => true,
|
||||||
|
'scoring_strategy' => $code === 'mrs' ? 'single_value' : 'sum_items',
|
||||||
|
'meta' => ['capture_roles' => ['doctor']],
|
||||||
|
]);
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $t->id,
|
||||||
|
'code' => 'item',
|
||||||
|
'label' => 'Item',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
'options' => ['choices' => [['code' => '1', 'label' => '1', 'score' => 1]], 'max' => 1],
|
||||||
|
'is_required' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$visit = Visit::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Visit::STATUS_OPEN,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$consultation = Consultation::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Consultation::STATUS_DRAFT,
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Diagnosis::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'consultation_id' => $consultation->id,
|
||||||
|
'code' => 'I63.9',
|
||||||
|
'description' => 'Cerebral infarction',
|
||||||
|
'is_primary' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.pathways.store', $this->patient), [
|
||||||
|
'pathway_code' => 'stroke',
|
||||||
|
'consultation_uuid' => $consultation->uuid,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('care.pathways.index', $this->patient));
|
||||||
|
|
||||||
|
$this->assertSame(1, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count());
|
||||||
|
$pp = PatientPathway::first();
|
||||||
|
$this->assertStringContainsString('I63.9', (string) $pp->activation_diagnosis_text);
|
||||||
|
$this->assertSame($consultation->id, $pp->activation_consultation_id);
|
||||||
|
$this->assertSame(2, Assessment::where('status', Assessment::STATUS_DRAFT)->count());
|
||||||
|
$this->assertTrue(Assessment::where('patient_pathway_id', $pp->id)->exists());
|
||||||
|
$this->assertDatabaseHas('care_audit_logs', ['action' => 'pathway.activated']);
|
||||||
|
|
||||||
|
// Idempotent re-activate
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.pathways.store', $this->patient), [
|
||||||
|
'pathway_code' => 'stroke',
|
||||||
|
'consultation_uuid' => $consultation->uuid,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame(1, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count());
|
||||||
|
$this->assertSame(2, Assessment::where('status', Assessment::STATUS_DRAFT)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_suggestions_use_persisted_diagnoses_only(): void
|
||||||
|
{
|
||||||
|
$this->seed(ClinicalPathwaySeeder::class);
|
||||||
|
|
||||||
|
$visit = Visit::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Visit::STATUS_OPEN,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$consultation = Consultation::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Consultation::STATUS_DRAFT,
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->getJson(route('care.consultations.pathway-suggestions', $consultation))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data', []);
|
||||||
|
|
||||||
|
Diagnosis::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'consultation_id' => $consultation->id,
|
||||||
|
'code' => null,
|
||||||
|
'description' => 'Ischaemic stroke',
|
||||||
|
'is_primary' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->getJson(route('care.consultations.pathway-suggestions', $consultation))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.0.pathway_code', 'stroke')
|
||||||
|
->assertJsonPath('data.0.already_active', false);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.consultations.show', $consultation))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Clinical pathways')
|
||||||
|
->assertSee('Stroke')
|
||||||
|
->assertSee('keyword:');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_deactivate_pathway(): void
|
||||||
|
{
|
||||||
|
$this->seed(ClinicalPathwaySeeder::class);
|
||||||
|
$pathway = ClinicalPathway::findByCode('stroke');
|
||||||
|
|
||||||
|
$pp = app(PathwayService::class)->activate(
|
||||||
|
$this->patient,
|
||||||
|
$pathway,
|
||||||
|
$this->user->public_id,
|
||||||
|
$this->member,
|
||||||
|
['actor' => $this->user->public_id, 'activation_diagnosis_text' => 'stroke'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.pathways.deactivate', [$this->patient, $pp]))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame(PatientPathway::STATUS_INACTIVE, $pp->fresh()->status);
|
||||||
|
$this->assertDatabaseHas('care_audit_logs', ['action' => 'pathway.deactivated']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nurse_cannot_activate_pathway(): void
|
||||||
|
{
|
||||||
|
$this->seed(ClinicalPathwaySeeder::class);
|
||||||
|
$this->member->update(['role' => 'nurse']);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.pathways.store', $this->patient), [
|
||||||
|
'pathway_code' => 'stroke',
|
||||||
|
])
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_template_version_bump_still_resolves_pathway_binding(): void
|
||||||
|
{
|
||||||
|
$this->seed(ClinicalPathwaySeeder::class);
|
||||||
|
|
||||||
|
AssessmentTemplate::create([
|
||||||
|
'code' => 'nihss',
|
||||||
|
'name' => 'NIHSS v1',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_DISEASE,
|
||||||
|
'version' => 1,
|
||||||
|
'is_current' => false,
|
||||||
|
'meta' => ['capture_roles' => ['doctor']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$v2 = AssessmentTemplate::create([
|
||||||
|
'code' => 'nihss',
|
||||||
|
'name' => 'NIHSS v2',
|
||||||
|
'category' => AssessmentTemplate::CATEGORY_DISEASE,
|
||||||
|
'version' => 2,
|
||||||
|
'is_current' => true,
|
||||||
|
'meta' => ['capture_roles' => ['doctor']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssessmentQuestion::create([
|
||||||
|
'template_id' => $v2->id,
|
||||||
|
'code' => 'item',
|
||||||
|
'label' => 'Item',
|
||||||
|
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||||
|
'options' => ['choices' => [['code' => '1', 'label' => '1', 'score' => 1]]],
|
||||||
|
'is_required' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only nihss required for this test — remove mrs binding temporarily via pathway seed already has both.
|
||||||
|
// Activate stroke; mrs missing is skipped; nihss v2 should draft.
|
||||||
|
$pathway = ClinicalPathway::findByCode('stroke');
|
||||||
|
$pp = app(PathwayService::class)->activate(
|
||||||
|
$this->patient,
|
||||||
|
$pathway,
|
||||||
|
$this->user->public_id,
|
||||||
|
$this->member,
|
||||||
|
['actor' => $this->user->public_id],
|
||||||
|
);
|
||||||
|
|
||||||
|
$draft = Assessment::where('patient_pathway_id', $pp->id)->where('template_id', $v2->id)->first();
|
||||||
|
$this->assertNotNull($draft);
|
||||||
|
$this->assertSame(2, $draft->template->version);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentScore;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\ClinicalPathway;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use Database\Seeders\AssessmentTemplateSeeder;
|
||||||
|
use Database\Seeders\ClinicalPathwaySeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/** PR 6b — Barthel, GCS, swallow, stroke_clinical. */
|
||||||
|
class CareStrokeExtendedTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->user = User::create([
|
||||||
|
'public_id' => 'stroke-ext-user',
|
||||||
|
'name' => 'Stroke Ext',
|
||||||
|
'email' => 'stroke-ext@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'name' => 'Ext Clinic',
|
||||||
|
'slug' => 'ext-clinic',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Member::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'role' => 'doctor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Branch::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'patient_number' => 'LC-SEXT-001',
|
||||||
|
'first_name' => 'Yaw',
|
||||||
|
'last_name' => 'Mensah',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
$this->seed(ClinicalPathwaySeeder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stroke_pathway_includes_extended_optional_instruments(): void
|
||||||
|
{
|
||||||
|
$pathway = ClinicalPathway::findByCode('stroke');
|
||||||
|
$codes = $pathway->templates()->pluck('template_code')->all();
|
||||||
|
|
||||||
|
$this->assertContains('barthel', $codes);
|
||||||
|
$this->assertContains('gcs', $codes);
|
||||||
|
$this->assertContains('stroke_swallow', $codes);
|
||||||
|
$this->assertContains('stroke_clinical', $codes);
|
||||||
|
|
||||||
|
$required = $pathway->templates()->where('is_required_on_activation', true)->pluck('template_code')->all();
|
||||||
|
$this->assertEqualsCanonicalizing(['nihss', 'mrs'], $required);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_gcs_golden_score_e4_v5_m6_is_fifteen(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'gcs'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => ['eye' => '4', 'verbal' => '5', 'motor' => '6'],
|
||||||
|
]);
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$score = AssessmentScore::first();
|
||||||
|
$this->assertEquals(15, (float) $score->total_score);
|
||||||
|
$this->assertEquals(15, (float) $score->max_score);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_barthel_fully_independent_is_one_hundred(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'barthel'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$answers = [
|
||||||
|
'feeding' => '10',
|
||||||
|
'bathing' => '5',
|
||||||
|
'grooming' => '5',
|
||||||
|
'dressing' => '10',
|
||||||
|
'bowels' => '10',
|
||||||
|
'bladder' => '10',
|
||||||
|
'toilet' => '10',
|
||||||
|
'transfers' => '15',
|
||||||
|
'mobility' => '15',
|
||||||
|
'stairs' => '10',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), ['answers' => $answers]);
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertEquals(100, (float) AssessmentScore::first()->total_score);
|
||||||
|
$this->assertEquals(100, (float) AssessmentScore::first()->max_score);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stroke_clinical_records_tia_subtype(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'stroke_clinical'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => [
|
||||||
|
'stroke_subtype' => 'tia',
|
||||||
|
'thrombolysis_status' => 'not_indicated',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertTrue($assessment->fresh()->isCompleted());
|
||||||
|
$this->assertSame(0, AssessmentScore::count()); // no scoring strategy
|
||||||
|
$subtype = $assessment->fresh()->answers->first(
|
||||||
|
fn ($a) => $a->question?->code === 'stroke_subtype'
|
||||||
|
);
|
||||||
|
$this->assertSame('tia', $subtype->value_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_all_extended_templates_seeded(): void
|
||||||
|
{
|
||||||
|
foreach (['barthel', 'gcs', 'stroke_swallow', 'stroke_clinical'] as $code) {
|
||||||
|
$this->assertNotNull(AssessmentTemplate::currentSystemByCode($code), $code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentScore;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\ClinicalPathway;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Diagnosis;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\PatientPathway;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use App\Services\Care\ScoringService;
|
||||||
|
use Database\Seeders\AssessmentTemplateSeeder;
|
||||||
|
use Database\Seeders\ClinicalPathwaySeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PR 6 — Stroke MVP content packs (NIHSS + mRS) and M2 vertical slice.
|
||||||
|
*/
|
||||||
|
class CareStrokeMvpTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Branch $branch;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected Member $member;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->user = User::create([
|
||||||
|
'public_id' => 'stroke-mvp-user',
|
||||||
|
'name' => 'Stroke MVP',
|
||||||
|
'email' => 'stroke@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'name' => 'Stroke Clinic',
|
||||||
|
'slug' => 'stroke-clinic',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->member = Member::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'role' => 'doctor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->branch = Branch::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_number' => 'LC-STROKE-001',
|
||||||
|
'first_name' => 'Efua',
|
||||||
|
'last_name' => 'Asante',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
$this->seed(ClinicalPathwaySeeder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_seed_packs_load_nihss_and_mrs(): void
|
||||||
|
{
|
||||||
|
$nihss = AssessmentTemplate::currentSystemByCode('nihss');
|
||||||
|
$mrs = AssessmentTemplate::currentSystemByCode('mrs');
|
||||||
|
|
||||||
|
$this->assertNotNull($nihss);
|
||||||
|
$this->assertNotNull($mrs);
|
||||||
|
$this->assertSame('sum_items', $nihss->scoring_strategy);
|
||||||
|
$this->assertSame('single_value', $mrs->scoring_strategy);
|
||||||
|
$this->assertSame(15, $nihss->questions()->count());
|
||||||
|
$this->assertSame(1, $mrs->questions()->count());
|
||||||
|
$this->assertSame(['doctor'], $nihss->meta['capture_roles'] ?? null);
|
||||||
|
|
||||||
|
$pathway = ClinicalPathway::findByCode('stroke');
|
||||||
|
$this->assertNotNull($pathway);
|
||||||
|
$codes = $pathway->templates()->pluck('template_code')->all();
|
||||||
|
$this->assertContains('nihss', $codes);
|
||||||
|
$this->assertContains('mrs', $codes);
|
||||||
|
$required = $pathway->templates()->where('is_required_on_activation', true)->pluck('template_code')->all();
|
||||||
|
$this->assertEqualsCanonicalizing(['nihss', 'mrs'], $required);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_golden_mrs_score_three(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'mrs'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => ['mrs_score' => '3'],
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$score = AssessmentScore::where('assessment_id', $assessment->id)->first();
|
||||||
|
$this->assertNotNull($score);
|
||||||
|
$this->assertEquals(3, (float) $score->total_score);
|
||||||
|
$this->assertEquals(6, (float) $score->max_score);
|
||||||
|
$this->assertSame('mrs', $score->template_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_golden_nihss_fixture_total_twelve_max_forty_two(): void
|
||||||
|
{
|
||||||
|
// Moderate left hemisphere pattern → total 12; max 42.
|
||||||
|
$answers = [
|
||||||
|
'1a_loc' => '1',
|
||||||
|
'1b_loc_questions' => '1',
|
||||||
|
'1c_loc_commands' => '0',
|
||||||
|
'2_gaze' => '0',
|
||||||
|
'3_visual' => '1',
|
||||||
|
'4_facial' => '2',
|
||||||
|
'5a_motor_arm_left' => '2',
|
||||||
|
'5b_motor_arm_right' => '0',
|
||||||
|
'6a_motor_leg_left' => '2',
|
||||||
|
'6b_motor_leg_right' => '0',
|
||||||
|
'7_ataxia' => '0',
|
||||||
|
'8_sensory' => '1',
|
||||||
|
'9_language' => '1',
|
||||||
|
'10_dysarthria' => '1',
|
||||||
|
'11_extinction' => '0',
|
||||||
|
];
|
||||||
|
// 1+1+0+0+1+2+2+0+2+0+0+1+1+1+0 = 12
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), ['answers' => $answers])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$preview = app(ScoringService::class)->preview(
|
||||||
|
$assessment->fresh(['template.questions', 'answers.question'])
|
||||||
|
);
|
||||||
|
$this->assertEquals(12.0, $preview['total']);
|
||||||
|
$this->assertEquals(42.0, $preview['max']);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$score = AssessmentScore::first();
|
||||||
|
$this->assertEquals(12, (float) $score->total_score);
|
||||||
|
$this->assertEquals(42, (float) $score->max_score);
|
||||||
|
$this->assertArrayHasKey('loc', $score->subscores);
|
||||||
|
$this->assertEquals(2.0, (float) $score->subscores['loc']); // 1a+1b+1c = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_m2_e2e_activate_stroke_complete_nihss_and_mrs(): void
|
||||||
|
{
|
||||||
|
$visit = Visit::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Visit::STATUS_OPEN,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$consultation = Consultation::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Consultation::STATUS_DRAFT,
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Diagnosis::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'consultation_id' => $consultation->id,
|
||||||
|
'code' => 'I63.9',
|
||||||
|
'description' => 'Cerebral infarction, unspecified',
|
||||||
|
'is_primary' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.consultations.show', $consultation))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Stroke')
|
||||||
|
->assertSee('Activate');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.pathways.store', $this->patient), [
|
||||||
|
'pathway_code' => 'stroke',
|
||||||
|
'consultation_uuid' => $consultation->uuid,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame(1, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count());
|
||||||
|
$this->assertSame(2, Assessment::where('status', Assessment::STATUS_DRAFT)->count());
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.consultations.show', $consultation))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Pathway instruments')
|
||||||
|
->assertSee('NIH Stroke Scale')
|
||||||
|
->assertSee('Modified Rankin Scale')
|
||||||
|
->assertSee('Continue');
|
||||||
|
|
||||||
|
$nihss = Assessment::query()
|
||||||
|
->whereHas('template', fn ($q) => $q->where('code', 'nihss'))
|
||||||
|
->first();
|
||||||
|
$mrs = Assessment::query()
|
||||||
|
->whereHas('template', fn ($q) => $q->where('code', 'mrs'))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$this->assertNotNull($nihss);
|
||||||
|
$this->assertNotNull($mrs);
|
||||||
|
$this->assertSame($consultation->id, $nihss->consultation_id);
|
||||||
|
|
||||||
|
// Minimal valid NIHSS (all zeros) → total 0.
|
||||||
|
$zeroAnswers = collect($nihss->template->questions)->mapWithKeys(
|
||||||
|
fn ($q) => [$q->code => '0']
|
||||||
|
)->all();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $nihss), ['answers' => $zeroAnswers])
|
||||||
|
->assertRedirect();
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $nihss))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertEquals(0, (float) AssessmentScore::where('assessment_id', $nihss->id)->value('total_score'));
|
||||||
|
$this->assertEquals(42, (float) AssessmentScore::where('assessment_id', $nihss->id)->value('max_score'));
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $mrs), ['answers' => ['mrs_score' => '2']])
|
||||||
|
->assertRedirect();
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $mrs))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertEquals(2, (float) AssessmentScore::where('assessment_id', $mrs->id)->value('total_score'));
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.consultations.show', $consultation))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('score 0')
|
||||||
|
->assertSee('score 2');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nurse_cannot_start_seeded_nihss(): void
|
||||||
|
{
|
||||||
|
$this->member->update(['role' => 'nurse']);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss'])
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Assessment;
|
||||||
|
use App\Models\AssessmentQuestion;
|
||||||
|
use App\Models\AssessmentTemplate;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\CareFeatures;
|
||||||
|
use Database\Seeders\AssessmentTemplateSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CareUniversalIntakeTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Branch $branch;
|
||||||
|
|
||||||
|
protected Patient $patient;
|
||||||
|
|
||||||
|
protected Member $member;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->user = User::create([
|
||||||
|
'public_id' => 'universal-intake-user',
|
||||||
|
'name' => 'Intake User',
|
||||||
|
'email' => 'intake@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'name' => 'Intake Clinic',
|
||||||
|
'slug' => 'intake-clinic',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->member = Member::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'role' => 'nurse',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->branch = Branch::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patient = Patient::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_number' => 'LC-INTAKE-001',
|
||||||
|
'first_name' => 'Kwame',
|
||||||
|
'last_name' => 'Boateng',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assessment_template_seeder_loads_universal_intake_pack(): void
|
||||||
|
{
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
|
||||||
|
$template = AssessmentTemplate::currentSystemByCode('universal_intake');
|
||||||
|
$this->assertNotNull($template);
|
||||||
|
$this->assertSame(1, $template->version);
|
||||||
|
$this->assertSame(AssessmentTemplate::CATEGORY_UNIVERSAL, $template->category);
|
||||||
|
$this->assertNull($template->organization_id);
|
||||||
|
$this->assertSame(['doctor', 'nurse'], $template->meta['capture_roles'] ?? null);
|
||||||
|
|
||||||
|
$codes = $template->questions()->pluck('code')->all();
|
||||||
|
$this->assertContains('chief_complaint', $codes);
|
||||||
|
$this->assertContains('hpi', $codes);
|
||||||
|
$this->assertContains('pain_score', $codes);
|
||||||
|
$this->assertContains('smoking_status', $codes);
|
||||||
|
$this->assertContains('mobility', $codes);
|
||||||
|
$this->assertContains('baseline_labs_summary', $codes);
|
||||||
|
|
||||||
|
$chief = $template->questions()->where('code', 'chief_complaint')->first();
|
||||||
|
$this->assertTrue($chief->is_required);
|
||||||
|
$this->assertSame(AssessmentQuestion::TYPE_TEXTAREA, $chief->answer_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_seeder_is_idempotent_and_replaces_questions(): void
|
||||||
|
{
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
$firstId = AssessmentTemplate::currentSystemByCode('universal_intake')->id;
|
||||||
|
$questionCount = AssessmentQuestion::where('template_id', $firstId)->count();
|
||||||
|
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
|
||||||
|
$this->assertSame(1, AssessmentTemplate::where('code', 'universal_intake')->count());
|
||||||
|
$this->assertSame($firstId, AssessmentTemplate::currentSystemByCode('universal_intake')->id);
|
||||||
|
$this->assertSame($questionCount, AssessmentQuestion::where('template_id', $firstId)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nurse_can_complete_seeded_universal_intake_on_consultation(): void
|
||||||
|
{
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
|
||||||
|
$visit = Visit::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Visit::STATUS_OPEN,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$consultation = Consultation::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'status' => Consultation::STATUS_DRAFT,
|
||||||
|
'started_at' => now(),
|
||||||
|
'symptoms' => 'Free-text headache narrative',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Nurse has vitals + assessments.capture; free-text symptoms fields require consultations.manage.
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.consultations.show', $consultation))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Universal intake')
|
||||||
|
->assertSee('narrative source of truth', false)
|
||||||
|
->assertSee('Start universal intake');
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.consultations.assessments.store', $consultation), [
|
||||||
|
'template_code' => 'universal_intake',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment = Assessment::first();
|
||||||
|
$this->assertNotNull($assessment);
|
||||||
|
$this->assertSame($consultation->id, $assessment->consultation_id);
|
||||||
|
$this->assertSame('universal_intake', $assessment->template->code);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->put(route('care.assessments.update', $assessment), [
|
||||||
|
'answers' => [
|
||||||
|
'chief_complaint' => 'Severe headache for 2 days',
|
||||||
|
'pain_score' => 7,
|
||||||
|
'smoking_status' => 'never',
|
||||||
|
'mobility' => 'independent',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->post(route('care.assessments.complete', $assessment))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$assessment->refresh();
|
||||||
|
$this->assertTrue($assessment->isCompleted());
|
||||||
|
|
||||||
|
// Dual narrative: free-text symptoms unchanged by structured intake.
|
||||||
|
$this->assertSame('Free-text headache narrative', $consultation->fresh()->symptoms);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.patients.show', $this->patient))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Universal intake')
|
||||||
|
->assertSee('Severe headache for 2 days');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_form_lists_seeded_universal_for_nurse(): void
|
||||||
|
{
|
||||||
|
$this->seed(AssessmentTemplateSeeder::class);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('care.assessments.create', $this->patient))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Universal Intake')
|
||||||
|
->assertSee('universal_intake');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user