diff --git a/.cursor/rules/specialty-modules.mdc b/.cursor/rules/specialty-modules.mdc index 4cb754c..69bdcd4 100644 --- a/.cursor/rules/specialty-modules.mdc +++ b/.cursor/rules/specialty-modules.mdc @@ -21,7 +21,7 @@ Full standard: `docs/specialty-module-design-standard.md`. ## Current reality -Specialty modules use the **shared specialty shell** on the Care Queue Engine (`SpecialtyShellService`, Overview / Visits / History / Billing / Workspace). Stage maps and service catalogs live in `config/care_specialty_shell.php`. Deepen clinical forms per module in Phase 3 — do **not** invent one-off layouts. +Specialty modules use the **shared specialty shell** on the Care Queue Engine. Clinical depth uses Visit-scoped `SpecialtyClinicalRecord` JSON (Emergency / Blood Bank / Dentistry first). Add new module forms via field schemas in `SpecialtyClinicalRecordService` — do **not** invent one-off layouts or queue backends. ## When adding or deepening a module diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 84b73cb..4c6d79e 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -6,9 +6,11 @@ use App\Http\Controllers\Care\Concerns\ScopesToAccount; use App\Http\Controllers\Controller; use App\Models\Appointment; use App\Models\Department; +use App\Models\PatientAttachment; use App\Models\Visit; use App\Services\Care\CareQueueBridge; use App\Services\Care\OrganizationResolver; +use App\Services\Care\SpecialtyClinicalRecordService; use App\Services\Care\SpecialtyModuleService; use App\Services\Care\SpecialtyShellService; use Illuminate\Http\RedirectResponse; @@ -105,6 +107,102 @@ class SpecialtyModuleController extends Controller return back()->with('success', 'Added to invoice '.$bill->invoice_number.'.'); } + public function saveClinical( + Request $request, + string $module, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + ): RedirectResponse { + $definition = $modules->definition($module); + abort_unless($definition, 404); + + $organization = $this->organization($request); + $member = $this->member($request); + abort_unless($modules->memberCanAccess($organization, $member, $module), 403); + abort_unless($visit->organization_id === $organization->id, 404); + + $tab = (string) $request->input('tab', ''); + $recordType = $clinical->recordTypeForTab($module, $tab); + abort_unless($recordType, 422); + + // Normalize checkbox booleans before validation. + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor($module, $recordType) as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload]); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + 'stage_code' => ['nullable', 'string', 'max:64'], + 'status' => ['nullable', 'in:draft,active,completed'], + ], $clinical->validationRules($module, $recordType))); + + $record = $clinical->upsert( + $organization, + $visit, + $module, + $recordType, + $clinical->payloadFromRequest($validated), + $this->ownerRef($request), + $this->ownerRef($request), + $validated['stage_code'] ?? null, + $validated['status'] ?? \App\Models\SpecialtyClinicalRecord::STATUS_ACTIVE, + ); + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => $module, + 'visit' => $visit, + 'tab' => $tab, + ]) + ->with('success', 'Saved '.$record->record_type.' record.'); + } + + public function uploadDocument( + Request $request, + string $module, + Visit $visit, + SpecialtyModuleService $modules, + ): RedirectResponse { + $definition = $modules->definition($module); + abort_unless($definition, 404); + + $organization = $this->organization($request); + $member = $this->member($request); + abort_unless($modules->memberCanAccess($organization, $member, $module), 403); + abort_unless($visit->organization_id === $organization->id, 404); + abort_unless($visit->patient_id, 422); + + $validated = $request->validate([ + 'attachment' => ['required', 'file', 'max:10240', 'mimes:pdf,jpeg,jpg,png,webp'], + ]); + + $file = $validated['attachment']; + $path = $file->store("care/patients/{$visit->patient_id}/attachments", 'public'); + + PatientAttachment::create([ + 'owner_ref' => $this->ownerRef($request), + 'patient_id' => $visit->patient_id, + 'file_path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getMimeType(), + 'document_type' => 'specialty_'.$module, + 'uploaded_by' => $this->ownerRef($request), + ]); + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => $module, + 'visit' => $visit, + 'tab' => 'documents', + ]) + ->with('success', 'Document uploaded.'); + } + public function callNext( Request $request, string $module, @@ -215,11 +313,23 @@ class SpecialtyModuleController extends Controller $workspaceVisit = null; $patientHeader = null; $timeline = []; + $clinicalRecord = null; + $clinicalFields = []; + $clinicalAlerts = []; + $visitDocuments = collect(); $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); + $clinical = app(SpecialtyClinicalRecordService::class); if ($visit) { abort_unless($visit->organization_id === $organization->id, 404); - $visit->load(['patient.allergies', 'patient.emergencyContacts', 'appointment.practitioner', 'bill.lineItems']); + $visit->load([ + 'patient.allergies', + 'patient.emergencyContacts', + 'patient.attachments', + 'appointment.practitioner', + 'bill.lineItems', + 'consultations', + ]); $workspaceVisit = $visit; if ($visit->patient) { $patientHeader = array_merge( @@ -231,6 +341,14 @@ class SpecialtyModuleController extends Controller } elseif ($section === 'workspace') { $first = $openVisits->first(); if ($first?->patient) { + $first->load([ + 'patient.allergies', + 'patient.emergencyContacts', + 'patient.attachments', + 'appointment.practitioner', + 'bill.lineItems', + 'consultations', + ]); $workspaceVisit = $first; $patientHeader = array_merge( ['patient' => $first->patient, 'visit' => $first, 'appointment' => $first->appointment], @@ -240,6 +358,29 @@ class SpecialtyModuleController extends Controller } } + if ($workspaceVisit && $section === 'workspace') { + $recordType = $clinical->recordTypeForTab($module, $activeTab); + if ($recordType) { + $clinicalRecord = $clinical->findForVisit($workspaceVisit, $module, $recordType); + $clinicalFields = $clinical->fieldsFor($module, $recordType); + } + $clinicalAlerts = $clinical->alertsForVisit($workspaceVisit, $module); + if ($patientHeader !== null) { + $patientHeader['clinical_alerts'] = $clinicalAlerts; + } + $visitDocuments = $workspaceVisit->patient + ? $workspaceVisit->patient->attachments() + ->where(function ($q) use ($module) { + $q->where('document_type', 'specialty_'.$module) + ->orWhere('document_type', 'other') + ->orWhereNull('document_type'); + }) + ->orderByDesc('id') + ->limit(20) + ->get() + : collect(); + } + return view('care.specialty.shell', [ 'organization' => $organization, 'moduleKey' => $module, @@ -259,6 +400,10 @@ class SpecialtyModuleController extends Controller 'workspaceVisit' => $workspaceVisit, 'patientHeader' => $patientHeader, 'timeline' => $timeline, + 'clinicalRecord' => $clinicalRecord, + 'clinicalFields' => $clinicalFields, + 'clinicalAlerts' => $clinicalAlerts, + 'visitDocuments' => $visitDocuments, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'queueIntegration' => $branchId diff --git a/app/Models/SpecialtyClinicalRecord.php b/app/Models/SpecialtyClinicalRecord.php new file mode 100644 index 0000000..d353314 --- /dev/null +++ b/app/Models/SpecialtyClinicalRecord.php @@ -0,0 +1,86 @@ + 'array', + 'alerts' => 'array', + 'recorded_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (SpecialtyClinicalRecord $record) { + if (! $record->uuid) { + $record->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 visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function consultation(): BelongsTo + { + return $this->belongsTo(Consultation::class, 'consultation_id'); + } + + /** + * @return list + */ + public function activeAlerts(): array + { + $alerts = $this->alerts ?? []; + + return is_array($alerts) ? array_values($alerts) : []; + } +} diff --git a/app/Services/Care/SpecialtyClinicalRecordService.php b/app/Services/Care/SpecialtyClinicalRecordService.php new file mode 100644 index 0000000..74f21ec --- /dev/null +++ b/app/Services/Care/SpecialtyClinicalRecordService.php @@ -0,0 +1,372 @@ + + */ + public function recordTypesForModule(string $moduleKey): array + { + return match ($moduleKey) { + 'emergency' => [ + 'triage' => 'triage', + 'clinical_notes' => 'clinical_note', + ], + 'blood_bank' => [ + 'requests' => 'blood_request', + 'inventory' => 'inventory_note', + ], + 'dentistry' => [ + 'odontogram' => 'odontogram', + 'clinical_notes' => 'clinical_note', + ], + default => [ + 'clinical_notes' => 'clinical_note', + ], + }; + } + + public function recordTypeForTab(string $moduleKey, string $tab): ?string + { + return $this->recordTypesForModule($moduleKey)[$tab] ?? null; + } + + /** + * Field schemas for forms (AI-ready structured data). + * + * @return list, required?: bool, rows?: int}> + */ + public function fieldsFor(string $moduleKey, string $recordType): array + { + $key = "{$moduleKey}.{$recordType}"; + + return match ($key) { + 'emergency.triage' => [ + ['name' => 'acuity', 'label' => 'Triage acuity', 'type' => 'select', 'required' => true, 'options' => ['1 - Resuscitation', '2 - Emergency', '3 - Urgent', '4 - Semi-urgent', '5 - Non-urgent']], + ['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true], + ['name' => 'onset', 'label' => 'Onset', 'type' => 'text'], + ['name' => 'airway_ok', 'label' => 'Airway patent', 'type' => 'boolean'], + ['name' => 'breathing_ok', 'label' => 'Breathing adequate', 'type' => 'boolean'], + ['name' => 'circulation_ok', 'label' => 'Circulation stable', 'type' => 'boolean'], + ['name' => 'gcs', 'label' => 'GCS', 'type' => 'number'], + ['name' => 'pain_score', 'label' => 'Pain score (0–10)', 'type' => 'number'], + ['name' => 'mode_of_arrival', 'label' => 'Mode of arrival', 'type' => 'select', 'options' => ['Walk-in', 'Ambulance', 'Police', 'Referral', 'Other']], + ['name' => 'disposition_intent', 'label' => 'Disposition intent', 'type' => 'select', 'options' => ['Resus', 'Treatment bay', 'Observation', 'Discharge', 'Admit', 'Transfer']], + ['name' => 'notes', 'label' => 'Triage notes', 'type' => 'textarea', 'rows' => 3], + ], + 'emergency.clinical_note' => [ + ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'rows' => 4, 'required' => true], + ['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3], + ['name' => 'working_diagnosis', 'label' => 'Working diagnosis', 'type' => 'text'], + ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 3], + ['name' => 'procedures', 'label' => 'Procedures performed', 'type' => 'textarea', 'rows' => 2], + ], + 'blood_bank.blood_request' => [ + ['name' => 'product', 'label' => 'Product', 'type' => 'select', 'required' => true, 'options' => ['Whole blood', 'Packed RBC', 'Platelets', 'FFP', 'Cryoprecipitate']], + ['name' => 'units', 'label' => 'Units requested', 'type' => 'number', 'required' => true], + ['name' => 'urgency', 'label' => 'Urgency', 'type' => 'select', 'required' => true, 'options' => ['Routine', 'Urgent', 'Emergency / massive']], + ['name' => 'patient_blood_group', 'label' => 'Patient blood group', 'type' => 'select', 'options' => ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-', 'Unknown']], + ['name' => 'indication', 'label' => 'Clinical indication', 'type' => 'textarea', 'rows' => 2, 'required' => true], + ['name' => 'crossmatch_status', 'label' => 'Cross-match status', 'type' => 'select', 'options' => ['Not started', 'In progress', 'Compatible', 'Incompatible', 'Issued']], + ['name' => 'issued_units', 'label' => 'Units issued', 'type' => 'number'], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], + ], + 'blood_bank.inventory_note' => [ + ['name' => 'whole_blood_units', 'label' => 'Whole blood on hand', 'type' => 'number'], + ['name' => 'packed_rbc_units', 'label' => 'Packed RBC on hand', 'type' => 'number'], + ['name' => 'platelet_units', 'label' => 'Platelets on hand', 'type' => 'number'], + ['name' => 'ffp_units', 'label' => 'FFP on hand', 'type' => 'number'], + ['name' => 'low_stock_alert', 'label' => 'Flag low stock', 'type' => 'boolean'], + ['name' => 'notes', 'label' => 'Inventory notes', 'type' => 'textarea', 'rows' => 3], + ], + 'dentistry.odontogram' => [ + ['name' => 'teeth_affected', 'label' => 'Teeth affected (FDI numbers, comma-separated)', 'type' => 'text', 'required' => true], + ['name' => 'findings', 'label' => 'Chart findings', 'type' => 'textarea', 'rows' => 3, 'required' => true], + ['name' => 'planned_procedures', 'label' => 'Planned procedures', 'type' => 'textarea', 'rows' => 2], + ['name' => 'completed_procedures', 'label' => 'Completed this visit', 'type' => 'textarea', 'rows' => 2], + ['name' => 'anesthesia', 'label' => 'Anesthesia', 'type' => 'select', 'options' => ['None', 'Local', 'Sedation', 'GA']], + ['name' => 'occlusion_notes', 'label' => 'Occlusion / bite notes', 'type' => 'text'], + ], + 'dentistry.clinical_note' => [ + ['name' => 'history', 'label' => 'History', 'type' => 'textarea', 'rows' => 3, 'required' => true], + ['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3], + ['name' => 'working_diagnosis', 'label' => 'Diagnosis', 'type' => 'text'], + ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 2], + ], + default => [ + ['name' => 'notes', 'label' => 'Clinical notes', 'type' => 'textarea', 'rows' => 4, 'required' => true], + ], + }; + } + + public function findForVisit(Visit $visit, string $moduleKey, string $recordType): ?SpecialtyClinicalRecord + { + return SpecialtyClinicalRecord::owned($visit->owner_ref) + ->where('visit_id', $visit->id) + ->where('module_key', $moduleKey) + ->where('record_type', $recordType) + ->first(); + } + + /** + * @param array $payload + */ + public function upsert( + Organization $organization, + Visit $visit, + string $moduleKey, + string $recordType, + array $payload, + string $ownerRef, + ?string $actorRef = null, + ?string $stageCode = null, + string $status = SpecialtyClinicalRecord::STATUS_ACTIVE, + ): SpecialtyClinicalRecord { + $visit->loadMissing('patient'); + $sanitized = $this->sanitizePayload($moduleKey, $recordType, $payload); + $alerts = $this->deriveAlerts($moduleKey, $recordType, $sanitized); + + $record = $this->findForVisit($visit, $moduleKey, $recordType); + + $attributes = [ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'branch_id' => $visit->branch_id, + 'patient_id' => $visit->patient_id, + 'consultation_id' => $visit->consultations()->orderByDesc('id')->value('id'), + 'module_key' => $moduleKey, + 'record_type' => $recordType, + 'status' => $status, + 'stage_code' => $stageCode, + 'payload' => $sanitized, + 'alerts' => $alerts, + 'recorded_by' => $actorRef ?? $ownerRef, + 'recorded_at' => now(), + ]; + + if ($record) { + $record->update($attributes); + + return $record->fresh(); + } + + return SpecialtyClinicalRecord::create(array_merge($attributes, [ + 'visit_id' => $visit->id, + ])); + } + + /** + * @param array $input + * @return array + */ + public function sanitizePayload(string $moduleKey, string $recordType, array $input): array + { + $allowed = collect($this->fieldsFor($moduleKey, $recordType))->pluck('name')->all(); + $out = []; + + foreach ($allowed as $name) { + if (! array_key_exists($name, $input)) { + continue; + } + $value = $input[$name]; + if (is_string($value)) { + $value = trim($value); + } + if ($value === '' || $value === null) { + continue; + } + if ($value === '1' || $value === '0' || $value === 'true' || $value === 'false') { + // keep as string for form round-trip; normalize booleans below via field type + } + $out[$name] = $value; + } + + foreach ($this->fieldsFor($moduleKey, $recordType) as $field) { + $name = $field['name']; + if (! array_key_exists($name, $out)) { + continue; + } + if (($field['type'] ?? '') === 'boolean') { + $out[$name] = filter_var($out[$name], FILTER_VALIDATE_BOOLEAN); + } + if (($field['type'] ?? '') === 'number') { + $out[$name] = is_numeric($out[$name]) ? 0 + $out[$name] : null; + if ($out[$name] === null) { + unset($out[$name]); + } + } + } + + return $out; + } + + /** + * @param array $payload + * @return list + */ + public function deriveAlerts(string $moduleKey, string $recordType, array $payload): array + { + $alerts = []; + + if ($moduleKey === 'emergency' && $recordType === 'triage') { + $acuity = (string) ($payload['acuity'] ?? ''); + if (str_starts_with($acuity, '1') || str_starts_with($acuity, '2')) { + $alerts[] = [ + 'code' => 'er.high_acuity', + 'severity' => 'critical', + 'message' => 'High-acuity triage — prioritise resus / emergency bay.', + ]; + } + if (($payload['airway_ok'] ?? true) === false + || ($payload['breathing_ok'] ?? true) === false + || ($payload['circulation_ok'] ?? true) === false) { + $alerts[] = [ + 'code' => 'er.abc_compromise', + 'severity' => 'critical', + 'message' => 'ABC compromise flagged on triage.', + ]; + } + $pain = (int) ($payload['pain_score'] ?? 0); + if ($pain >= 8) { + $alerts[] = [ + 'code' => 'er.severe_pain', + 'severity' => 'warning', + 'message' => 'Severe pain score (≥8).', + ]; + } + } + + if ($moduleKey === 'blood_bank' && $recordType === 'blood_request') { + $urgency = (string) ($payload['urgency'] ?? ''); + if (str_contains(strtolower($urgency), 'emergency') || str_contains(strtolower($urgency), 'massive')) { + $alerts[] = [ + 'code' => 'bb.massive_transfusion', + 'severity' => 'critical', + 'message' => 'Emergency / massive transfusion request.', + ]; + } + if (($payload['crossmatch_status'] ?? '') === 'Incompatible') { + $alerts[] = [ + 'code' => 'bb.incompatible', + 'severity' => 'critical', + 'message' => 'Cross-match incompatible — do not issue.', + ]; + } + } + + if ($moduleKey === 'blood_bank' && $recordType === 'inventory_note') { + if (! empty($payload['low_stock_alert'])) { + $alerts[] = [ + 'code' => 'bb.low_stock', + 'severity' => 'warning', + 'message' => 'Blood products flagged as low stock.', + ]; + } + foreach (['whole_blood_units', 'packed_rbc_units', 'platelet_units'] as $key) { + if (isset($payload[$key]) && (int) $payload[$key] <= 2) { + $alerts[] = [ + 'code' => 'bb.unit_critical', + 'severity' => 'warning', + 'message' => str_replace('_', ' ', $key).' at or below 2 units.', + ]; + } + } + } + + return $alerts; + } + + /** + * @return list + */ + public function forVisit(Visit $visit, string $moduleKey): array + { + return SpecialtyClinicalRecord::owned($visit->owner_ref) + ->where('visit_id', $visit->id) + ->where('module_key', $moduleKey) + ->orderBy('record_type') + ->get() + ->all(); + } + + /** + * @return list + */ + public function alertsForVisit(Visit $visit, string $moduleKey): array + { + $alerts = []; + foreach ($this->forVisit($visit, $moduleKey) as $record) { + foreach ($record->activeAlerts() as $alert) { + $alerts[] = $alert; + } + } + + return $alerts; + } + + public function countOpenByType(Organization $organization, string $moduleKey, string $recordType, ?int $branchId = null): int + { + return SpecialtyClinicalRecord::owned($organization->owner_ref) + ->where('organization_id', $organization->id) + ->where('module_key', $moduleKey) + ->where('record_type', $recordType) + ->whereIn('status', [SpecialtyClinicalRecord::STATUS_DRAFT, SpecialtyClinicalRecord::STATUS_ACTIVE]) + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->count(); + } + + /** + * Validation rules derived from field schema. + * + * @return array> + */ + public function validationRules(string $moduleKey, string $recordType): array + { + $rules = []; + foreach ($this->fieldsFor($moduleKey, $recordType) as $field) { + $name = 'payload.'.$field['name']; + $fieldRules = []; + if (! empty($field['required'])) { + $fieldRules[] = 'required'; + } else { + $fieldRules[] = 'nullable'; + } + $fieldRules[] = match ($field['type'] ?? 'text') { + 'number' => 'numeric', + 'boolean' => 'boolean', + 'textarea', 'text', 'select' => 'string', + default => 'string', + }; + if (($field['type'] ?? '') === 'select' && ! empty($field['options'])) { + $fieldRules[] = 'in:'.implode(',', $field['options']); + } + if (in_array($field['type'] ?? '', ['text', 'textarea'], true)) { + $fieldRules[] = 'max:5000'; + } + $rules[$name] = $fieldRules; + } + + return $rules; + } + + /** + * @param array $validated + * @return array + */ + public function payloadFromRequest(array $validated): array + { + return Arr::get($validated, 'payload', []); + } +} diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php index f648d8e..5270e1e 100644 --- a/app/Services/Care/SpecialtyShellService.php +++ b/app/Services/Care/SpecialtyShellService.php @@ -248,12 +248,21 @@ class SpecialtyShellService ]; })->all(); + $clinical = app(SpecialtyClinicalRecordService::class); + $clinicalOpen = match ($moduleKey) { + 'emergency' => $clinical->countOpenByType($organization, 'emergency', 'triage', $branchScope), + 'blood_bank' => $clinical->countOpenByType($organization, 'blood_bank', 'blood_request', $branchScope), + 'dentistry' => $clinical->countOpenByType($organization, 'dentistry', 'odontogram', $branchScope), + default => 0, + }; + return [ 'waiting' => $waiting, 'in_progress' => $inProgress, 'completed_today' => $completedToday, 'open_visits' => $openVisits, 'revenue_today_minor' => $revenueToday, + 'clinical_open' => $clinicalOpen, 'stages' => $stages, ]; } diff --git a/config/care.php b/config/care.php index 6bb073a..2b6fc32 100644 --- a/config/care.php +++ b/config/care.php @@ -236,6 +236,9 @@ return [ 'insurance_card' => 'Insurance card', 'lab_report' => 'Lab report', 'referral' => 'Referral letter', + 'specialty_emergency' => 'Emergency document', + 'specialty_blood_bank' => 'Blood bank document', + 'specialty_dentistry' => 'Dental document', 'other' => 'Other', ], diff --git a/database/migrations/2026_07_18_120000_create_care_specialty_clinical_records_table.php b/database/migrations/2026_07_18_120000_create_care_specialty_clinical_records_table.php new file mode 100644 index 0000000..c5ce494 --- /dev/null +++ b/database/migrations/2026_07_18_120000_create_care_specialty_clinical_records_table.php @@ -0,0 +1,40 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref', 64)->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->nullable()->constrained('care_branches')->nullOnDelete(); + $table->foreignId('visit_id')->constrained('care_visits')->cascadeOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + $table->foreignId('consultation_id')->nullable()->constrained('care_consultations')->nullOnDelete(); + $table->string('module_key', 64)->index(); + $table->string('record_type', 64); + $table->string('status', 32)->default('draft')->index(); + $table->string('stage_code', 64)->nullable(); + $table->json('payload')->nullable(); + $table->json('alerts')->nullable(); + $table->string('recorded_by')->nullable(); + $table->timestamp('recorded_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['visit_id', 'module_key', 'record_type'], 'care_specialty_clinical_unique'); + $table->index(['organization_id', 'module_key', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_specialty_clinical_records'); + } +}; diff --git a/docs/care-queue-engine-and-specialty-plan.md b/docs/care-queue-engine-and-specialty-plan.md index 6e91c99..c719204 100644 --- a/docs/care-queue-engine-and-specialty-plan.md +++ b/docs/care-queue-engine-and-specialty-plan.md @@ -1,6 +1,6 @@ # Care Queue Engine + Specialty Modules — Unified Plan -**Status:** Phase 2 complete (shell); Phase 3 next +**Status:** Phase 3 in progress (clinical depth on shell) **Date:** 2026-07-18 **Repos:** ladill-care (primary), ladill-queue (healthcare removal) **Related:** `docs/specialty-module-design-standard.md` @@ -19,7 +19,14 @@ - `SpecialtyShellService` + `config/care_specialty_shell.php` (Emergency, Blood Bank, Dentistry stage maps & catalogs; defaults for others) - Patient header + timeline partials reused on specialty workspace and patient chart - Activate seeds billable services; workspace Billing tab posts into Billing Engine -- Exit: Emergency (+ Dentistry) demonstrate the standard on the in-app queue engine + +## Phase 3 notes (implementation) + +- `care_specialty_clinical_records` — Visit-scoped structured JSON + derived alerts +- Emergency triage/notes, Blood Bank request/inventory, Dentistry odontogram/notes on existing workspace tabs +- Documents tab uploads to patient attachments (`specialty_{module}`) +- KPI strip includes open clinical records; no new queue backends +- Remaining modules inherit generic clinical_notes until rolled onto the same form pattern --- diff --git a/docs/specialty-module-design-standard.md b/docs/specialty-module-design-standard.md index 1d08ca6..2f72ac2 100644 --- a/docs/specialty-module-design-standard.md +++ b/docs/specialty-module-design-standard.md @@ -200,7 +200,8 @@ Specialty modules share a **specialty shell** on the Care Queue Engine. Per-modu | Visit-backed specialty visits | Exists (open visits + history lists) | | Service catalog → Billing Engine | Exists (seed on activate; add line from workspace Billing tab) | | Specialty KPIs | Exists (overview strip) | -| Deep clinical forms / order sets / documents | Phase 3 | +| Deep clinical forms / order sets / documents | Phase 3 started — Emergency, Blood Bank, Dentistry structured records + docs upload; others inherit generic notes | +| Specialty clinical alerts | Exists (derived from triage / blood request / inventory payloads) | **Program plan:** `docs/care-queue-engine-and-specialty-plan.md` (Phase 2 shell complete; Phase 3 rolls remaining modules onto clinical depth). diff --git a/resources/views/care/partials/patient-header.blade.php b/resources/views/care/partials/patient-header.blade.php index c67ef03..117490f 100644 --- a/resources/views/care/partials/patient-header.blade.php +++ b/resources/views/care/partials/patient-header.blade.php @@ -6,6 +6,7 @@ 'allergies' => [], 'insuranceSummary' => null, 'emergency' => false, + 'clinicalAlerts' => [], 'currency' => 'GHS', ]) @@ -74,4 +75,17 @@ @endforeach @endif + + @if (! empty($clinicalAlerts)) +
+ Alerts + @foreach ($clinicalAlerts as $alert) +

($alert['severity'] ?? '') === 'critical', + 'text-amber-800' => ($alert['severity'] ?? '') !== 'critical', + ])>{{ $alert['message'] ?? '' }}

+ @endforeach +
+ @endif diff --git a/resources/views/care/specialty/partials/clinical-form.blade.php b/resources/views/care/specialty/partials/clinical-form.blade.php new file mode 100644 index 0000000..d0c0473 --- /dev/null +++ b/resources/views/care/specialty/partials/clinical-form.blade.php @@ -0,0 +1,106 @@ +@php + $values = old('payload', $clinicalRecord?->payload ?? []); +@endphp + +
+
+
+

{{ $workspaceTabs[$activeTab] ?? 'Clinical' }}

+

Structured specialty record on this visit episode.

+
+ @if ($clinicalRecord) + + {{ $clinicalRecord->status }} + + @endif +
+ + @if (! empty($clinicalAlerts)) +
+ @foreach ($clinicalAlerts as $alert) +
($alert['severity'] ?? '') === 'critical', + 'bg-amber-50 text-amber-900 border border-amber-100' => ($alert['severity'] ?? '') !== 'critical', + ])> + {{ $alert['message'] ?? '' }} +
+ @endforeach +
+ @endif + +
+ @csrf + + + + @if (! empty($stages)) +
+ + +
+ @endif + + @foreach ($clinicalFields as $field) + @php + $name = $field['name']; + $value = $values[$name] ?? ''; + $id = 'field_'.$name; + @endphp +
+ + + @if (($field['type'] ?? '') === 'textarea') + + @elseif (($field['type'] ?? '') === 'select') + + @elseif (($field['type'] ?? '') === 'boolean') + + @elseif (($field['type'] ?? '') === 'number') + + @else + + @endif + + @error('payload.'.$name) +

{{ $message }}

+ @enderror +
+ @endforeach + +
+ + @if ($workspaceVisit->appointment) + Appointment + @endif +
+
+
diff --git a/resources/views/care/specialty/sections/overview.blade.php b/resources/views/care/specialty/sections/overview.blade.php index 848f2bc..f64d05c 100644 --- a/resources/views/care/specialty/sections/overview.blade.php +++ b/resources/views/care/specialty/sections/overview.blade.php @@ -1,8 +1,8 @@ -{{-- KPI strip --}} -
+
@foreach ([ ['Waiting', $kpis['waiting'] ?? 0, 'text-amber-700'], ['In care', $kpis['in_progress'] ?? 0, 'text-sky-700'], + ['Clinical open', $kpis['clinical_open'] ?? 0, 'text-rose-700'], ['Completed today', $kpis['completed_today'] ?? 0, 'text-emerald-700'], ['Open visits', $kpis['open_visits'] ?? 0, 'text-indigo-700'], ['Paid today', ($currency ?? 'GHS').' '.number_format(($kpis['revenue_today_minor'] ?? 0) / 100, 2), 'text-slate-800'], diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index f1b516b..7d6f6b4 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -7,6 +7,7 @@ 'allergies' => $patientHeader['allergies'] ?? [], 'insuranceSummary' => $patientHeader['insurance_summary'] ?? null, 'emergency' => $patientHeader['emergency'] ?? false, + 'clinicalAlerts' => $patientHeader['clinical_alerts'] ?? ($clinicalAlerts ?? []), 'currency' => $currency ?? 'GHS', ]) @endif @@ -67,28 +68,48 @@
@endif - @elseif (in_array($activeTab, ['clinical_notes', 'triage', 'odontogram', 'requests', 'inventory'], true)) -
-

{{ $workspaceTabs[$activeTab] ?? 'Clinical' }}

-

- Structured clinical forms for this tab ship in Phase 3. Use the patient chart and appointment for notes today. -

- @if ($workspaceVisit->appointment) - Open appointment - @endif -
+ @elseif (! empty($clinicalFields)) + @include('care.specialty.partials.clinical-form') @elseif ($activeTab === 'orders')

Orders

-

Order lab / imaging from the consultation flow. Specialty-specific order sets arrive in Phase 3.

- Lab requests +

Order lab / imaging from the consultation flow. Specialty order sets can extend this later.

+
+ Lab requests + @if ($workspaceVisit->appointment) + Appointment + @endif +
@elseif ($activeTab === 'documents')

Documents

-

Consent forms and specialty documents will attach here. Patient attachments remain on the chart.

+

Specialty documents attach to the patient chart for this episode.

+ +
+ @csrf + + +
+ +
    + @forelse ($visitDocuments ?? [] as $doc) +
  • +
    +

    {{ $doc->original_name }}

    +

    {{ $doc->document_type }} · {{ $doc->created_at?->format('d M Y H:i') }}

    +
    + @if ($doc->file_path) + Open + @endif +
  • + @empty +
  • No documents yet.
  • + @endforelse +
+ @if ($workspaceVisit->patient) - Patient attachments + Patient chart @endif
@else @@ -117,6 +138,17 @@
+ + @if (! empty($clinicalAlerts)) +
+

Clinical alerts

+ @foreach ($clinicalAlerts as $alert) +

+ {{ $alert['message'] ?? '' }} +

+ @endforeach +
+ @endif @endif diff --git a/routes/web.php b/routes/web.php index a86a8f4..1f74f92 100644 --- a/routes/web.php +++ b/routes/web.php @@ -105,6 +105,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/specialty/{module}/history', [SpecialtyModuleController::class, 'history'])->name('care.specialty.history'); Route::get('/specialty/{module}/billing', [SpecialtyModuleController::class, 'billing'])->name('care.specialty.billing'); Route::get('/specialty/{module}/workspace/{visit?}', [SpecialtyModuleController::class, 'workspace'])->name('care.specialty.workspace'); + Route::post('/specialty/{module}/workspace/{visit}/clinical', [SpecialtyModuleController::class, 'saveClinical'])->name('care.specialty.clinical.save'); + Route::post('/specialty/{module}/workspace/{visit}/documents', [SpecialtyModuleController::class, 'uploadDocument'])->name('care.specialty.documents.upload'); Route::post('/specialty/{module}/workspace/{visit}/services', [SpecialtyModuleController::class, 'addService'])->name('care.specialty.services.add'); Route::get('/specialty/{module}', [SpecialtyModuleController::class, 'show'])->name('care.specialty.show'); Route::post('/specialty/{module}/call-next', [SpecialtyModuleController::class, 'callNext'])->name('care.specialty.call-next'); diff --git a/tests/Feature/CareSpecialtyClinicalTest.php b/tests/Feature/CareSpecialtyClinicalTest.php new file mode 100644 index 0000000..06ee8c8 --- /dev/null +++ b/tests/Feature/CareSpecialtyClinicalTest.php @@ -0,0 +1,255 @@ +withoutMiddleware(EnsurePlatformSession::class); + config(['care.queue.driver' => 'native']); + + $this->owner = User::create([ + 'public_id' => 'clinical-owner', + 'name' => 'Owner', + 'email' => 'clinical@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Clinical Clinic', + 'slug' => 'clinical-clinic', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'clinic', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'queue_integration_enabled' => true, + ], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'hospital_admin', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + } + + /** + * @return array{0: Visit, 1: Department} + */ + protected function activateWithVisit(string $module, string $departmentType): array + { + app(SpecialtyModuleService::class)->activate( + $this->organization, + $this->owner->public_id, + $module, + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', $departmentType) + ->firstOrFail(); + + $patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-CL-1', + 'first_name' => 'Kofi', + 'last_name' => 'Asante', + 'gender' => 'male', + 'date_of_birth' => '1985-03-01', + ]); + + $visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'department_id' => $department->id, + 'visit_id' => $visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'scheduled_at' => now(), + 'waiting_at' => now(), + ]); + + return [$visit, $department]; + } + + public function test_emergency_triage_form_saves_structured_record_and_alerts(): void + { + [$visit] = $this->activateWithVisit('emergency', 'emergency'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', ['module' => 'emergency', 'visit' => $visit, 'tab' => 'triage'])) + ->assertOk() + ->assertSee('Triage acuity') + ->assertSee('Chief complaint'); + + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', ['module' => 'emergency', 'visit' => $visit]), [ + 'tab' => 'triage', + 'stage_code' => 'arrival', + 'status' => 'active', + 'payload' => [ + 'acuity' => '1 - Resuscitation', + 'chief_complaint' => 'Chest pain', + 'airway_ok' => '0', + 'breathing_ok' => '1', + 'circulation_ok' => '1', + 'pain_score' => '9', + 'mode_of_arrival' => 'Ambulance', + 'disposition_intent' => 'Resus', + 'notes' => 'Immediate attention', + ], + ]) + ->assertRedirect(); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $visit->id) + ->where('record_type', 'triage') + ->first(); + + $this->assertNotNull($record); + $this->assertSame('Chest pain', $record->payload['chief_complaint'] ?? null); + $this->assertFalse((bool) ($record->payload['airway_ok'] ?? true)); + $this->assertNotEmpty($record->alerts); + $this->assertTrue(collect($record->alerts)->contains(fn ($a) => ($a['code'] ?? '') === 'er.high_acuity')); + $this->assertTrue(collect($record->alerts)->contains(fn ($a) => ($a['code'] ?? '') === 'er.abc_compromise')); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', ['module' => 'emergency', 'visit' => $visit, 'tab' => 'triage'])) + ->assertOk() + ->assertSee('High-acuity triage') + ->assertSee('ABC compromise'); + } + + public function test_blood_bank_request_and_dentistry_odontogram_save(): void + { + [$bbVisit] = $this->activateWithVisit('blood_bank', 'blood_bank'); + + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', ['module' => 'blood_bank', 'visit' => $bbVisit]), [ + 'tab' => 'requests', + 'payload' => [ + 'product' => 'Packed RBC', + 'units' => '2', + 'urgency' => 'Emergency / massive', + 'patient_blood_group' => 'O+', + 'indication' => 'Trauma bleed', + 'crossmatch_status' => 'Not started', + ], + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('care_specialty_clinical_records', [ + 'visit_id' => $bbVisit->id, + 'module_key' => 'blood_bank', + 'record_type' => 'blood_request', + ]); + + app(SpecialtyModuleService::class)->activate( + $this->organization->fresh(), + $this->owner->public_id, + 'dentistry', + ); + + $dentalDept = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'dental') + ->firstOrFail(); + + $patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-CL-2', + 'first_name' => 'Ama', + 'last_name' => 'Boateng', + 'gender' => 'female', + 'date_of_birth' => '1990-01-01', + ]); + + $denVisit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'department_id' => $dentalDept->id, + 'visit_id' => $denVisit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'scheduled_at' => now(), + 'waiting_at' => now(), + ]); + + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', ['module' => 'dentistry', 'visit' => $denVisit]), [ + 'tab' => 'odontogram', + 'payload' => [ + 'teeth_affected' => '16, 26', + 'findings' => 'Caries on occlusal surfaces', + 'planned_procedures' => 'Fillings', + 'anesthesia' => 'Local', + ], + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('care_specialty_clinical_records', [ + 'visit_id' => $denVisit->id, + 'module_key' => 'dentistry', + 'record_type' => 'odontogram', + ]); + } +}