Add specialty clinical records on the shared shell (Phase 3).
Deploy Ladill Care / deploy (push) Failing after 1m2s
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:
@@ -21,7 +21,7 @@ Full standard: `docs/specialty-module-design-standard.md`.
|
|||||||
|
|
||||||
## Current reality
|
## 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
|
## When adding or deepening a module
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Appointment;
|
use App\Models\Appointment;
|
||||||
use App\Models\Department;
|
use App\Models\Department;
|
||||||
|
use App\Models\PatientAttachment;
|
||||||
use App\Models\Visit;
|
use App\Models\Visit;
|
||||||
use App\Services\Care\CareQueueBridge;
|
use App\Services\Care\CareQueueBridge;
|
||||||
use App\Services\Care\OrganizationResolver;
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\SpecialtyClinicalRecordService;
|
||||||
use App\Services\Care\SpecialtyModuleService;
|
use App\Services\Care\SpecialtyModuleService;
|
||||||
use App\Services\Care\SpecialtyShellService;
|
use App\Services\Care\SpecialtyShellService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -105,6 +107,102 @@ class SpecialtyModuleController extends Controller
|
|||||||
return back()->with('success', 'Added to invoice '.$bill->invoice_number.'.');
|
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(
|
public function callNext(
|
||||||
Request $request,
|
Request $request,
|
||||||
string $module,
|
string $module,
|
||||||
@@ -215,11 +313,23 @@ class SpecialtyModuleController extends Controller
|
|||||||
$workspaceVisit = null;
|
$workspaceVisit = null;
|
||||||
$patientHeader = null;
|
$patientHeader = null;
|
||||||
$timeline = [];
|
$timeline = [];
|
||||||
|
$clinicalRecord = null;
|
||||||
|
$clinicalFields = [];
|
||||||
|
$clinicalAlerts = [];
|
||||||
|
$visitDocuments = collect();
|
||||||
$activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview');
|
$activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview');
|
||||||
|
$clinical = app(SpecialtyClinicalRecordService::class);
|
||||||
|
|
||||||
if ($visit) {
|
if ($visit) {
|
||||||
abort_unless($visit->organization_id === $organization->id, 404);
|
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;
|
$workspaceVisit = $visit;
|
||||||
if ($visit->patient) {
|
if ($visit->patient) {
|
||||||
$patientHeader = array_merge(
|
$patientHeader = array_merge(
|
||||||
@@ -231,6 +341,14 @@ class SpecialtyModuleController extends Controller
|
|||||||
} elseif ($section === 'workspace') {
|
} elseif ($section === 'workspace') {
|
||||||
$first = $openVisits->first();
|
$first = $openVisits->first();
|
||||||
if ($first?->patient) {
|
if ($first?->patient) {
|
||||||
|
$first->load([
|
||||||
|
'patient.allergies',
|
||||||
|
'patient.emergencyContacts',
|
||||||
|
'patient.attachments',
|
||||||
|
'appointment.practitioner',
|
||||||
|
'bill.lineItems',
|
||||||
|
'consultations',
|
||||||
|
]);
|
||||||
$workspaceVisit = $first;
|
$workspaceVisit = $first;
|
||||||
$patientHeader = array_merge(
|
$patientHeader = array_merge(
|
||||||
['patient' => $first->patient, 'visit' => $first, 'appointment' => $first->appointment],
|
['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', [
|
return view('care.specialty.shell', [
|
||||||
'organization' => $organization,
|
'organization' => $organization,
|
||||||
'moduleKey' => $module,
|
'moduleKey' => $module,
|
||||||
@@ -259,6 +400,10 @@ class SpecialtyModuleController extends Controller
|
|||||||
'workspaceVisit' => $workspaceVisit,
|
'workspaceVisit' => $workspaceVisit,
|
||||||
'patientHeader' => $patientHeader,
|
'patientHeader' => $patientHeader,
|
||||||
'timeline' => $timeline,
|
'timeline' => $timeline,
|
||||||
|
'clinicalRecord' => $clinicalRecord,
|
||||||
|
'clinicalFields' => $clinicalFields,
|
||||||
|
'clinicalAlerts' => $clinicalAlerts,
|
||||||
|
'visitDocuments' => $visitDocuments,
|
||||||
'queueStubs' => $modules->queueStubsFor($organization, $module),
|
'queueStubs' => $modules->queueStubsFor($organization, $module),
|
||||||
'branchId' => $branchId,
|
'branchId' => $branchId,
|
||||||
'queueIntegration' => $branchId
|
'queueIntegration' => $branchId
|
||||||
|
|||||||
@@ -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 (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<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();
|
})->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 [
|
return [
|
||||||
'waiting' => $waiting,
|
'waiting' => $waiting,
|
||||||
'in_progress' => $inProgress,
|
'in_progress' => $inProgress,
|
||||||
'completed_today' => $completedToday,
|
'completed_today' => $completedToday,
|
||||||
'open_visits' => $openVisits,
|
'open_visits' => $openVisits,
|
||||||
'revenue_today_minor' => $revenueToday,
|
'revenue_today_minor' => $revenueToday,
|
||||||
|
'clinical_open' => $clinicalOpen,
|
||||||
'stages' => $stages,
|
'stages' => $stages,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ return [
|
|||||||
'insurance_card' => 'Insurance card',
|
'insurance_card' => 'Insurance card',
|
||||||
'lab_report' => 'Lab report',
|
'lab_report' => 'Lab report',
|
||||||
'referral' => 'Referral letter',
|
'referral' => 'Referral letter',
|
||||||
|
'specialty_emergency' => 'Emergency document',
|
||||||
|
'specialty_blood_bank' => 'Blood bank document',
|
||||||
|
'specialty_dentistry' => 'Dental document',
|
||||||
'other' => 'Other',
|
'other' => 'Other',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
+40
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Care Queue Engine + Specialty Modules — Unified Plan
|
# 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
|
**Date:** 2026-07-18
|
||||||
**Repos:** ladill-care (primary), ladill-queue (healthcare removal)
|
**Repos:** ladill-care (primary), ladill-queue (healthcare removal)
|
||||||
**Related:** `docs/specialty-module-design-standard.md`
|
**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)
|
- `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
|
- Patient header + timeline partials reused on specialty workspace and patient chart
|
||||||
- Activate seeds billable services; workspace Billing tab posts into Billing Engine
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
| Visit-backed specialty visits | Exists (open visits + history lists) |
|
||||||
| Service catalog → Billing Engine | Exists (seed on activate; add line from workspace Billing tab) |
|
| Service catalog → Billing Engine | Exists (seed on activate; add line from workspace Billing tab) |
|
||||||
| Specialty KPIs | Exists (overview strip) |
|
| 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).
|
**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' => [],
|
'allergies' => [],
|
||||||
'insuranceSummary' => null,
|
'insuranceSummary' => null,
|
||||||
'emergency' => false,
|
'emergency' => false,
|
||||||
|
'clinicalAlerts' => [],
|
||||||
'currency' => 'GHS',
|
'currency' => 'GHS',
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -74,4 +75,17 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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>
|
</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-3 2xl:grid-cols-6">
|
||||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
|
||||||
@foreach ([
|
@foreach ([
|
||||||
['Waiting', $kpis['waiting'] ?? 0, 'text-amber-700'],
|
['Waiting', $kpis['waiting'] ?? 0, 'text-amber-700'],
|
||||||
['In care', $kpis['in_progress'] ?? 0, 'text-sky-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'],
|
['Completed today', $kpis['completed_today'] ?? 0, 'text-emerald-700'],
|
||||||
['Open visits', $kpis['open_visits'] ?? 0, 'text-indigo-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'],
|
['Paid today', ($currency ?? 'GHS').' '.number_format(($kpis['revenue_today_minor'] ?? 0) / 100, 2), 'text-slate-800'],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
'allergies' => $patientHeader['allergies'] ?? [],
|
'allergies' => $patientHeader['allergies'] ?? [],
|
||||||
'insuranceSummary' => $patientHeader['insurance_summary'] ?? null,
|
'insuranceSummary' => $patientHeader['insurance_summary'] ?? null,
|
||||||
'emergency' => $patientHeader['emergency'] ?? false,
|
'emergency' => $patientHeader['emergency'] ?? false,
|
||||||
|
'clinicalAlerts' => $patientHeader['clinical_alerts'] ?? ($clinicalAlerts ?? []),
|
||||||
'currency' => $currency ?? 'GHS',
|
'currency' => $currency ?? 'GHS',
|
||||||
])
|
])
|
||||||
@endif
|
@endif
|
||||||
@@ -67,28 +68,48 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</section>
|
</section>
|
||||||
@elseif (in_array($activeTab, ['clinical_notes', 'triage', 'odontogram', 'requests', 'inventory'], true))
|
@elseif (! empty($clinicalFields))
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
@include('care.specialty.partials.clinical-form')
|
||||||
<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 ($activeTab === 'orders')
|
@elseif ($activeTab === 'orders')
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
<h3 class="text-sm font-semibold text-slate-900">Orders</h3>
|
<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>
|
<p class="mt-2 text-sm text-slate-500">Order lab / imaging from the consultation flow. Specialty order sets can extend this later.</p>
|
||||||
<a href="{{ route('care.lab.requests.index') }}" class="mt-3 inline-flex text-sm font-medium text-indigo-600">Lab requests</a>
|
<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>
|
</section>
|
||||||
@elseif ($activeTab === 'documents')
|
@elseif ($activeTab === 'documents')
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
<h3 class="text-sm font-semibold text-slate-900">Documents</h3>
|
<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)
|
@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
|
@endif
|
||||||
</section>
|
</section>
|
||||||
@else
|
@else
|
||||||
@@ -117,6 +138,17 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</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>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}/history', [SpecialtyModuleController::class, 'history'])->name('care.specialty.history');
|
||||||
Route::get('/specialty/{module}/billing', [SpecialtyModuleController::class, 'billing'])->name('care.specialty.billing');
|
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::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::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::get('/specialty/{module}', [SpecialtyModuleController::class, 'show'])->name('care.specialty.show');
|
||||||
Route::post('/specialty/{module}/call-next', [SpecialtyModuleController::class, 'callNext'])->name('care.specialty.call-next');
|
Route::post('/specialty/{module}/call-next', [SpecialtyModuleController::class, 'callNext'])->name('care.specialty.call-next');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user