Files
ladill-care/app/Http/Controllers/Care/ConsultationController.php
T
isaaccladandCursor 40325144bb
Deploy Ladill Care / deploy (push) Successful in 46s
Open Prescribe from consultation as a modal/bottomsheet.
Reuse the shared prescription form in x-modal so desktop gets a popup and mobile gets the existing bottomsheet behavior, then return to the consultation after create.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 14:58:58 +00:00

333 lines
15 KiB
PHP

<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\Assessment;
use App\Models\AssessmentTemplate;
use App\Models\Consultation;
use App\Models\InvestigationType;
use App\Models\Practitioner;
use App\Services\Care\AssessmentService;
use App\Services\Care\BranchContext;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\ConsultationReturnContext;
use App\Services\Care\ConsultationService;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PathwayService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ConsultationController extends Controller
{
use ScopesToAccount;
public function __construct(
protected ConsultationService $consultations,
) {}
public function show(Request $request, Consultation $consultation): View
{
$this->authorizeAbility($request, 'consultations.view');
$this->authorizeConsultation($request, $consultation);
$consultation->load([
'patient', 'practitioner', 'visit', 'appointment',
'vitalSigns', 'diagnoses', 'documents',
'investigationRequests.investigationType', 'prescriptions.items',
]);
app(ConsultationReturnContext::class)->remember($consultation);
$permissions = app(CarePermissions::class);
$member = $this->member($request);
$organization = $this->organization($request);
$canManage = $permissions->can($member, 'consultations.manage');
$canVitals = $permissions->can($member, 'vitals.manage');
$canRequestInvestigations = $permissions->can($member, 'investigations.request');
$canPrescribe = $permissions->can($member, 'prescriptions.manage');
$canGenerateBill = $permissions->can($member, 'bills.manage');
$careFeatures = app(CareFeatures::class);
$assessmentsEnabled = $careFeatures->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE);
if ($assessmentsEnabled) {
$careFeatures->ensureAssessmentCatalog();
}
$canViewAssessments = $assessmentsEnabled && $permissions->can($member, 'assessments.view');
$canCaptureAssessment = $assessmentsEnabled && (
$permissions->can($member, 'assessments.capture')
|| $permissions->can($member, 'assessments.manage')
);
$consultationAssessments = collect();
$startableTemplates = collect();
$universalTemplate = null;
$universalAssessment = null;
$canCaptureUniversal = false;
$canManagePathways = $assessmentsEnabled && $permissions->can($member, 'pathways.manage');
$pathwaySuggestions = collect();
$activePathways = collect();
if ($canViewAssessments) {
$consultationAssessments = Assessment::query()
->owned($this->ownerRef($request))
->where('consultation_id', $consultation->id)
->with(['template', 'answers.question'])
->orderByDesc('created_at')
->get();
$universalTemplate = AssessmentTemplate::currentSystemByCode('universal_intake');
if ($universalTemplate) {
$universalAssessment = $consultationAssessments
->first(fn (Assessment $a) => $a->template_id === $universalTemplate->id)
?? Assessment::query()
->owned($this->ownerRef($request))
->where('patient_id', $consultation->patient_id)
->where('template_id', $universalTemplate->id)
->whereIn('status', [Assessment::STATUS_DRAFT, Assessment::STATUS_COMPLETED])
->with(['template', 'answers.question'])
->orderByDesc('created_at')
->first();
}
if ($canCaptureAssessment) {
$startableTemplates = AssessmentTemplate::query()
->system()
->current()
->orderBy('category')
->orderBy('name')
->get()
// Universal has a dedicated CTA; keep other templates in the general list.
->reject(fn (AssessmentTemplate $t) => $t->code === 'universal_intake')
->values();
if ($universalTemplate) {
try {
app(AssessmentService::class)
->assertCaptureAllowed($member, $universalTemplate);
$canCaptureUniversal = true;
} catch (\Throwable) {
$canCaptureUniversal = false;
}
}
}
$pathwayService = app(PathwayService::class);
$activePathways = $pathwayService->activeFor($consultation->patient)
->load(['pathway.templates', 'assessments.template', 'assessments.score']);
// Suggestions from **persisted** diagnoses only (after save).
$pathwaySuggestions = $pathwayService->suggest(
$consultation->patient,
$consultation->diagnoses,
);
// Instruments for active pathways (e.g. stroke → NIHSS, mRS) with draft/complete status.
$pathwayInstruments = collect();
foreach ($activePathways as $patientPathway) {
foreach ($patientPathway->pathway->templates as $binding) {
$template = AssessmentTemplate::currentSystemByCode($binding->template_code);
$assessment = $patientPathway->assessments
->first(fn (Assessment $a) => $a->template?->code === $binding->template_code)
?? $consultationAssessments
->first(fn (Assessment $a) => $a->template?->code === $binding->template_code)
?? ($template
? Assessment::query()
->owned($this->ownerRef($request))
->where('patient_id', $consultation->patient_id)
->where('template_id', $template->id)
->with(['template', 'score'])
->orderByDesc('created_at')
->first()
: null);
$pathwayInstruments->push([
'pathway_code' => $patientPathway->pathway->code,
'pathway_name' => $patientPathway->pathway->name,
'template_code' => $binding->template_code,
'template_name' => $template?->name ?? $binding->template_code,
'required' => $binding->is_required_on_activation,
'phase' => $binding->phase,
'template' => $template,
'assessment' => $assessment,
'pack_missing' => $template === null,
]);
}
}
}
$practitioners = Practitioner::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
$investigationTypes = InvestigationType::query()
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
$queueIntegration = ['enabled' => false];
$queueBranchId = null;
$queueBridge = app(CareQueueBridge::class);
if ($permissions->handlesFloorCare($member) && $queueBridge->isEnabled($organization)) {
$queueBranchId = app(BranchContext::class)->resolve($request, $organization, $member)
?: (int) ($consultation->practitioner?->branch_id
?: $consultation->visit?->branch_id
?: $consultation->appointment?->branch_id
?: 0);
if ($queueBranchId > 0) {
$queueIntegration = $queueBridge->listMeta(
$organization,
CareQueueContexts::CONSULTATION,
$queueBranchId,
);
}
}
return view('care.consultations.show', [
'consultation' => $consultation,
'practitioners' => $practitioners,
'investigationTypes' => $investigationTypes,
'canManage' => $canManage,
'canVitals' => $canVitals,
'canRequestInvestigations' => $canRequestInvestigations,
'canPrescribe' => $canPrescribe,
'canGenerateBill' => $canGenerateBill,
'isCompleted' => $consultation->status === Consultation::STATUS_COMPLETED,
'assessmentsEnabled' => $assessmentsEnabled,
'canViewAssessments' => $canViewAssessments,
'canCaptureAssessment' => $canCaptureAssessment,
'consultationAssessments' => $consultationAssessments,
'startableTemplates' => $startableTemplates,
'assessmentStatuses' => config('care.assessment_statuses'),
'universalTemplate' => $universalTemplate,
'universalAssessment' => $universalAssessment,
'canCaptureUniversal' => $canCaptureUniversal,
'canManagePathways' => $canManagePathways,
'pathwaySuggestions' => $pathwaySuggestions,
'activePathways' => $activePathways,
'pathwayInstruments' => $pathwayInstruments ?? collect(),
'queueIntegration' => $queueIntegration,
'queueBranchId' => $queueBranchId,
'medicationRoutes' => config('care.medication_routes'),
]);
}
public function update(Request $request, Consultation $consultation): RedirectResponse
{
$canManage = app(CarePermissions::class)
->can($this->member($request), 'consultations.manage');
$canVitals = app(CarePermissions::class)
->can($this->member($request), 'vitals.manage');
abort_unless($canManage || $canVitals, 403);
$this->authorizeConsultation($request, $consultation);
abort_if($consultation->status === Consultation::STATUS_COMPLETED, 422);
$validated = $this->validatedConsultationData($request, $canManage, $canVitals);
$this->consultations->save(
$consultation,
$this->ownerRef($request),
$validated,
$this->ownerRef($request),
);
$message = 'Consultation saved.';
if ($canManage && $request->boolean('suggest_pathways')) {
$consultation->load('diagnoses');
$count = $consultation->diagnoses->filter(fn ($d) => filled($d->description) || filled($d->code))->count();
$message = $count > 0
? 'Diagnoses saved — review pathway suggestions below.'
: 'Consultation saved. Add and save diagnoses to see pathway suggestions.';
}
return back()->with('success', $message);
}
public function complete(Request $request, Consultation $consultation): RedirectResponse
{
$this->authorizeAbility($request, 'consultations.manage');
$this->authorizeConsultation($request, $consultation);
$completed = $this->consultations->complete(
$consultation,
$this->ownerRef($request),
$this->ownerRef($request),
);
app(ConsultationReturnContext::class)->forget();
$completed->loadMissing(['appointment.organization']);
if ($completed->appointment
&& $completed->appointment->organization
&& app(CareFeatures::class)->enabled(
$completed->appointment->organization,
CareFeatures::WORKFLOW_ENGINE,
)) {
return redirect()->route('care.appointments.show', $completed->appointment)
->with('success', 'Consultation completed. Continue the patient journey from the next stage.');
}
return redirect()->route('care.queue.index')
->with('success', 'Consultation completed.');
}
protected function authorizeConsultation(Request $request, Consultation $consultation): void
{
$this->authorizeOwner($request, $consultation);
$consultation->loadMissing('visit');
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $consultation->visit->branch_id !== $branchId) {
abort(404);
}
}
/**
* @return array<string, mixed>
*/
protected function validatedConsultationData(Request $request, bool $canManage, bool $canVitals): array
{
$rules = [];
if ($canManage) {
$rules = array_merge($rules, [
'symptoms' => ['nullable', 'string', 'max:10000'],
'clinical_notes' => ['nullable', 'string', 'max:20000'],
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
'diagnoses' => ['nullable', 'array'],
'diagnoses.*.code' => ['nullable', 'string', 'max:50'],
'diagnoses.*.description' => ['nullable', 'string', 'max:500'],
'diagnoses.*.is_primary' => ['nullable', 'boolean'],
'diagnoses.*.notes' => ['nullable', 'string', 'max:2000'],
'documents' => ['nullable', 'array'],
'documents.*' => ['file', 'max:10240', 'mimes:pdf,jpeg,png,jpg,webp'],
]);
$canVitals = true;
}
if ($canVitals) {
$rules['vitals'] = ['nullable', 'array'];
$rules['vitals.bp_systolic'] = ['nullable', 'integer', 'min:50', 'max:300'];
$rules['vitals.bp_diastolic'] = ['nullable', 'integer', 'min:30', 'max:200'];
$rules['vitals.pulse'] = ['nullable', 'integer', 'min:20', 'max:250'];
$rules['vitals.temperature'] = ['nullable', 'numeric', 'min:30', 'max:45'];
$rules['vitals.weight_kg'] = ['nullable', 'numeric', 'min:0', 'max:500'];
$rules['vitals.height_cm'] = ['nullable', 'numeric', 'min:0', 'max:300'];
$rules['vitals.spo2'] = ['nullable', 'integer', 'min:50', 'max:100'];
$rules['vitals.respiratory_rate'] = ['nullable', 'integer', 'min:5', 'max:80'];
}
return $request->validate($rules);
}
}