Add specialty clinical records on the shared shell (Phase 3).
Deploy Ladill Care / deploy (push) Failing after 1m2s

Visit-scoped structured forms for Emergency triage, Blood Bank requests,
and Dentistry odontogram with derived alerts, document upload, and KPI counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 10:09:32 +00:00
co-authored by Cursor
parent 0181221fe8
commit 57358e4206
15 changed files with 1093 additions and 21 deletions
+1 -1
View File
@@ -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
@@ -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
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class SpecialtyClinicalRecord extends Model
{
use BelongsToOwner, SoftDeletes;
public const STATUS_DRAFT = 'draft';
public const STATUS_ACTIVE = 'active';
public const STATUS_COMPLETED = 'completed';
protected $table = 'care_specialty_clinical_records';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'patient_id',
'consultation_id', 'module_key', 'record_type', 'status', 'stage_code',
'payload', 'alerts', 'recorded_by', 'recorded_at',
];
protected function casts(): array
{
return [
'payload' => '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<array{code: string, severity: string, message: string}>
*/
public function activeAlerts(): array
{
$alerts = $this->alerts ?? [];
return is_array($alerts) ? array_values($alerts) : [];
}
}
@@ -0,0 +1,372 @@
<?php
namespace App\Services\Care;
use App\Models\Organization;
use App\Models\SpecialtyClinicalRecord;
use App\Models\Visit;
use Illuminate\Support\Arr;
/**
* Visit-scoped specialty clinical records (structured JSON payloads + alerts).
*/
class SpecialtyClinicalRecordService
{
/**
* Map workspace tab record_type for modules that have clinical forms.
*
* @return array<string, string>
*/
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<array{name: string, label: string, type: string, options?: list<string>, 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 (010)', '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<string, mixed> $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<string, mixed> $input
* @return array<string, mixed>
*/
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<string, mixed> $payload
* @return list<array{code: string, severity: string, message: string}>
*/
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<SpecialtyClinicalRecord>
*/
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<array{code: string, severity: string, message: string}>
*/
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<string, list<string>>
*/
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<string, mixed> $validated
* @return array<string, mixed>
*/
public function payloadFromRequest(array $validated): array
{
return Arr::get($validated, 'payload', []);
}
}
@@ -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,
];
}
+3
View File
@@ -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',
],
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('care_specialty_clinical_records', function (Blueprint $table) {
$table->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');
}
};
+9 -2
View File
@@ -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
---
+2 -1
View File
@@ -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).
@@ -6,6 +6,7 @@
'allergies' => [],
'insuranceSummary' => null,
'emergency' => false,
'clinicalAlerts' => [],
'currency' => 'GHS',
])
@@ -74,4 +75,17 @@
@endforeach
</div>
@endif
@if (! empty($clinicalAlerts))
<div class="mt-3 space-y-1.5 border-t border-slate-100 pt-3">
<span class="text-xs font-semibold uppercase tracking-wide text-amber-700">Alerts</span>
@foreach ($clinicalAlerts as $alert)
<p @class([
'text-xs font-medium',
'text-rose-700' => ($alert['severity'] ?? '') === 'critical',
'text-amber-800' => ($alert['severity'] ?? '') !== 'critical',
])>{{ $alert['message'] ?? '' }}</p>
@endforeach
</div>
@endif
</section>
@@ -0,0 +1,106 @@
@php
$values = old('payload', $clinicalRecord?->payload ?? []);
@endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-slate-900">{{ $workspaceTabs[$activeTab] ?? 'Clinical' }}</h3>
<p class="mt-0.5 text-xs text-slate-500">Structured specialty record on this visit episode.</p>
</div>
@if ($clinicalRecord)
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-600">
{{ $clinicalRecord->status }}
</span>
@endif
</div>
@if (! empty($clinicalAlerts))
<div class="mt-3 space-y-1.5">
@foreach ($clinicalAlerts as $alert)
<div @class([
'rounded-xl px-3 py-2 text-xs font-medium',
'bg-rose-50 text-rose-800 border border-rose-100' => ($alert['severity'] ?? '') === 'critical',
'bg-amber-50 text-amber-900 border border-amber-100' => ($alert['severity'] ?? '') !== 'critical',
])>
{{ $alert['message'] ?? '' }}
</div>
@endforeach
</div>
@endif
<form method="POST" action="{{ route('care.specialty.clinical.save', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" class="mt-4 space-y-3">
@csrf
<input type="hidden" name="tab" value="{{ $activeTab }}">
<input type="hidden" name="status" value="active">
@if (! empty($stages))
<div>
<label class="block text-xs font-medium text-slate-600">Current stage</label>
<select name="stage_code" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
<option value=""></option>
@foreach ($stages as $stage)
<option value="{{ $stage['code'] }}" @selected(old('stage_code', $clinicalRecord?->stage_code) === ($stage['code'] ?? ''))>
{{ $stage['label'] ?? $stage['code'] }}
</option>
@endforeach
</select>
</div>
@endif
@foreach ($clinicalFields as $field)
@php
$name = $field['name'];
$value = $values[$name] ?? '';
$id = 'field_'.$name;
@endphp
<div>
<label for="{{ $id }}" class="block text-xs font-medium text-slate-600">
{{ $field['label'] }}
@if (! empty($field['required'])) <span class="text-rose-500">*</span> @endif
</label>
@if (($field['type'] ?? '') === 'textarea')
<textarea id="{{ $id }}" name="payload[{{ $name }}]" rows="{{ $field['rows'] ?? 3 }}"
@if (! empty($field['required'])) required @endif
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">{{ $value }}</textarea>
@elseif (($field['type'] ?? '') === 'select')
<select id="{{ $id }}" name="payload[{{ $name }}]"
@if (! empty($field['required'])) required @endif
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
<option value="">Select…</option>
@foreach ($field['options'] ?? [] as $option)
<option value="{{ $option }}" @selected((string) $value === (string) $option)>{{ $option }}</option>
@endforeach
</select>
@elseif (($field['type'] ?? '') === 'boolean')
<label class="mt-2 flex items-center gap-2 text-sm text-slate-700">
<input type="hidden" name="payload[{{ $name }}]" value="0">
<input id="{{ $id }}" type="checkbox" name="payload[{{ $name }}]" value="1" @checked(filter_var($value, FILTER_VALIDATE_BOOLEAN))
class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
Yes
</label>
@elseif (($field['type'] ?? '') === 'number')
<input id="{{ $id }}" type="number" step="any" name="payload[{{ $name }}]" value="{{ $value }}"
@if (! empty($field['required'])) required @endif
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
@else
<input id="{{ $id }}" type="text" name="payload[{{ $name }}]" value="{{ $value }}"
@if (! empty($field['required'])) required @endif
class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
@endif
@error('payload.'.$name)
<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>
@enderror
</div>
@endforeach
<div class="flex flex-wrap gap-2 pt-2">
<button type="submit" class="btn-primary">Save record</button>
@if ($workspaceVisit->appointment)
<a href="{{ route('care.appointments.show', $workspaceVisit->appointment) }}" class="rounded-xl border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Appointment</a>
@endif
</div>
</form>
</section>
@@ -1,8 +1,8 @@
{{-- KPI strip --}}
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
@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'],
@@ -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 @@
</div>
@endif
</section>
@elseif (in_array($activeTab, ['clinical_notes', 'triage', 'odontogram', 'requests', 'inventory'], true))
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">{{ $workspaceTabs[$activeTab] ?? 'Clinical' }}</h3>
<p class="mt-2 text-sm text-slate-500">
Structured clinical forms for this tab ship in Phase 3. Use the patient chart and appointment for notes today.
</p>
@if ($workspaceVisit->appointment)
<a href="{{ route('care.appointments.show', $workspaceVisit->appointment) }}" class="mt-3 inline-flex text-sm font-medium text-indigo-600">Open appointment</a>
@endif
</section>
@elseif (! empty($clinicalFields))
@include('care.specialty.partials.clinical-form')
@elseif ($activeTab === 'orders')
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Orders</h3>
<p class="mt-2 text-sm text-slate-500">Order lab / imaging from the consultation flow. Specialty-specific order sets arrive in Phase 3.</p>
<a href="{{ route('care.lab.requests.index') }}" class="mt-3 inline-flex text-sm font-medium text-indigo-600">Lab requests</a>
<p class="mt-2 text-sm text-slate-500">Order lab / imaging from the consultation flow. Specialty order sets can extend this later.</p>
<div class="mt-3 flex flex-wrap gap-3">
<a href="{{ route('care.lab.requests.index') }}" class="text-sm font-medium text-indigo-600">Lab requests</a>
@if ($workspaceVisit->appointment)
<a href="{{ route('care.appointments.show', $workspaceVisit->appointment) }}" class="text-sm font-medium text-indigo-600">Appointment</a>
@endif
</div>
</section>
@elseif ($activeTab === 'documents')
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Documents</h3>
<p class="mt-2 text-sm text-slate-500">Consent forms and specialty documents will attach here. Patient attachments remain on the chart.</p>
<p class="mt-1 text-xs text-slate-500">Specialty documents attach to the patient chart for this episode.</p>
<form method="POST" action="{{ route('care.specialty.documents.upload', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" enctype="multipart/form-data" class="mt-4 space-y-3">
@csrf
<input type="file" name="attachment" required accept=".pdf,.jpg,.jpeg,.png,.webp" class="block w-full text-sm">
<button type="submit" class="btn-primary">Upload document</button>
</form>
<ul class="mt-4 space-y-2 text-sm">
@forelse ($visitDocuments ?? [] as $doc)
<li class="flex items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<div class="min-w-0">
<p class="truncate font-medium text-slate-800">{{ $doc->original_name }}</p>
<p class="text-xs text-slate-500">{{ $doc->document_type }} · {{ $doc->created_at?->format('d M Y H:i') }}</p>
</div>
@if ($doc->file_path)
<a href="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($doc->file_path) }}" target="_blank" class="shrink-0 text-xs font-medium text-sky-600">Open</a>
@endif
</li>
@empty
<li class="text-slate-500">No documents yet.</li>
@endforelse
</ul>
@if ($workspaceVisit->patient)
<a href="{{ route('care.patients.show', $workspaceVisit->patient) }}" class="mt-3 inline-flex text-sm font-medium text-indigo-600">Patient attachments</a>
<a href="{{ route('care.patients.show', $workspaceVisit->patient) }}" class="mt-3 inline-flex text-sm font-medium text-indigo-600">Patient chart</a>
@endif
</section>
@else
@@ -117,6 +138,17 @@
</dd>
</div>
</dl>
@if (! empty($clinicalAlerts))
<div class="mt-4 space-y-1.5 border-t border-slate-100 pt-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Clinical alerts</p>
@foreach ($clinicalAlerts as $alert)
<p class="text-sm {{ ($alert['severity'] ?? '') === 'critical' ? 'text-rose-700' : 'text-amber-800' }}">
{{ $alert['message'] ?? '' }}
</p>
@endforeach
</div>
@endif
</section>
@endif
</div>
+2
View File
@@ -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');
+255
View File
@@ -0,0 +1,255 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\SpecialtyClinicalRecord;
use App\Models\User;
use App\Models\Visit;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CareSpecialtyClinicalTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected function setUp(): void
{
parent::setUp();
$this->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',
]);
}
}