From f688a48c372218038d2eb49c34c3d280e298fe41 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 18 Jul 2026 21:36:57 +0000 Subject: [PATCH] Add full Blood Bank specialty suite on shared shell. Mirror Emergency: stage workflow, issue/transfusion records, analytics, print, and action-menu stage flow without a parallel EHR. Co-authored-by: Cursor --- .../Care/BloodBankWorkspaceController.php | 318 ++++++++++++++++++ .../Care/SpecialtyModuleController.php | 61 +++- .../BloodBank/BloodBankAnalyticsService.php | 195 +++++++++++ .../BloodBank/BloodBankWorkflowService.php | 94 ++++++ .../Care/SpecialtyClinicalRecordService.php | 26 +- app/Services/Care/SpecialtyShellService.php | 16 +- .../Care/SpecialtyVisitStageService.php | 8 + config/care_specialty_clinical.php | 19 ++ config/care_specialty_shell.php | 4 + .../care/specialty/blood-bank/print.blade.php | 83 +++++ .../specialty/blood-bank/reports.blade.php | 86 +++++ .../blood-bank/workspace-issue.blade.php | 100 ++++++ .../blood-bank/workspace-overview.blade.php | 143 ++++++++ .../workspace-transfusion.blade.php | 90 +++++ .../specialty/partials/actions-menu.blade.php | 68 ++-- .../specialty/sections/workspace.blade.php | 2 + .../views/care/specialty/shell.blade.php | 3 + routes/web.php | 6 + tests/Feature/CareBloodBankSuiteTest.php | 298 ++++++++++++++++ 19 files changed, 1590 insertions(+), 30 deletions(-) create mode 100644 app/Http/Controllers/Care/BloodBankWorkspaceController.php create mode 100644 app/Services/Care/BloodBank/BloodBankAnalyticsService.php create mode 100644 app/Services/Care/BloodBank/BloodBankWorkflowService.php create mode 100644 resources/views/care/specialty/blood-bank/print.blade.php create mode 100644 resources/views/care/specialty/blood-bank/reports.blade.php create mode 100644 resources/views/care/specialty/blood-bank/workspace-issue.blade.php create mode 100644 resources/views/care/specialty/blood-bank/workspace-overview.blade.php create mode 100644 resources/views/care/specialty/blood-bank/workspace-transfusion.blade.php create mode 100644 tests/Feature/CareBloodBankSuiteTest.php diff --git a/app/Http/Controllers/Care/BloodBankWorkspaceController.php b/app/Http/Controllers/Care/BloodBankWorkspaceController.php new file mode 100644 index 0000000..2aafbef --- /dev/null +++ b/app/Http/Controllers/Care/BloodBankWorkspaceController.php @@ -0,0 +1,318 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'blood_bank'), 403); + } + + protected function assertBloodBankManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'blood_bank'), 403); + } + + protected function assertVisit(Request $request, Visit $visit): void + { + abort_unless($visit->organization_id === $this->organization($request)->id, 404); + $this->authorizeBranch($request, (int) $visit->branch_id); + } + + public function setStage( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyVisitStageService $stages, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertBloodBankManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'blood_bank', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'blood_bank', 'visit' => $visit, 'tab' => 'overview']) + ->with('success', 'Visit stage updated.'); + } + + public function confirmIssue( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + SpecialtyShellService $shell, + BloodBankWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertBloodBankManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('blood_bank', 'issue_note') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'issue']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + 'bill_product' => ['nullable', 'boolean'], + 'bill_crossmatch' => ['nullable', 'boolean'], + ], $clinical->validationRules('blood_bank', 'issue_note'))); + + $issuePayload = $clinical->payloadFromRequest($validated); + $units = max(1, (int) ($issuePayload['units_issued'] ?? 1)); + + $record = $clinical->upsert( + $organization, + $visit, + 'blood_bank', + 'issue_note', + $issuePayload, + $owner, + $owner, + BloodBankWorkflowService::STAGE_ISSUE, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + // Keep the request record in sync with issued units / cross-match status. + $requestRecord = $clinical->findForVisit($visit, 'blood_bank', 'blood_request'); + if ($requestRecord) { + $requestPayload = array_merge($requestRecord->payload ?? [], [ + 'issued_units' => $units, + 'crossmatch_status' => 'Issued', + 'product' => $issuePayload['product'] ?? ($requestRecord->payload['product'] ?? null), + ]); + $clinical->upsert( + $organization, + $visit, + 'blood_bank', + 'blood_request', + $requestPayload, + $owner, + $owner, + BloodBankWorkflowService::STAGE_ISSUE, + SpecialtyClinicalRecord::STATUS_ACTIVE, + ); + } + + try { + $stages->setStage( + $organization, + $visit, + 'blood_bank', + BloodBankWorkflowService::STAGE_ISSUE, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + // Stage already issue or map empty. + } + + if ($request->boolean('bill_crossmatch')) { + try { + $shell->addCatalogServiceToVisit($organization, $visit, 'blood_bank', 'bb.crossmatch', $owner, $owner); + } catch (\InvalidArgumentException) { + } + } + + if ($request->boolean('bill_product', true)) { + $serviceCode = $workflow->serviceCodeForProduct($issuePayload['product'] ?? null); + if ($serviceCode) { + try { + $shell->addCatalogServiceToVisit( + $organization, + $visit, + 'blood_bank', + $serviceCode, + $owner, + $owner, + $units, + ); + } catch (\InvalidArgumentException) { + } + } + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'blood_bank', 'visit' => $visit, 'tab' => 'issue']) + ->with('success', 'Product issue confirmed.'); + } + + public function saveTransfusion( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + BloodBankWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertBloodBankManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('blood_bank', 'transfusion_note') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'transfusion']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('blood_bank', 'transfusion_note'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'blood_bank', + 'transfusion_note', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + BloodBankWorkflowService::STAGE_TRANSFUSION, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'blood_bank', + BloodBankWorkflowService::STAGE_TRANSFUSION, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'blood_bank', + BloodBankWorkflowService::STAGE_COMPLETED, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + $visit->refresh(); + if ($visit->status !== Visit::STATUS_COMPLETED) { + $visit->update([ + 'status' => Visit::STATUS_COMPLETED, + 'completed_at' => $visit->completed_at ?? now(), + ]); + } + + $appointment = $visit->appointment; + if ($appointment && $appointment->status !== \App\Models\Appointment::STATUS_COMPLETED) { + $appointment->update([ + 'status' => \App\Models\Appointment::STATUS_COMPLETED, + 'completed_at' => $appointment->completed_at ?? now(), + ]); + } + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'blood_bank', 'visit' => $visit, 'tab' => 'transfusion']) + ->with('success', 'Transfusion record saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + BloodBankAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertBloodBankAccess($request, $modules); + + $organization = $this->organization($request); + $branchScope = app(\App\Services\Care\OrganizationResolver::class)->branchScope($this->member($request)); + + $report = $analytics->report( + $organization, + $this->ownerRef($request), + $branchScope, + $request->query('from'), + $request->query('to'), + ); + + return view('care.specialty.blood-bank.reports', [ + 'moduleKey' => 'blood_bank', + 'definition' => $modules->definition('blood_bank'), + 'shellNav' => $shell->navItems('blood_bank'), + 'report' => $report, + 'currency' => strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS'))), + ]); + } + + public function printSummary( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertBloodBankAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch', 'bill.lineItems']); + + return view('care.specialty.blood-bank.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'bloodRequest' => $clinical->findForVisit($visit, 'blood_bank', 'blood_request'), + 'inventoryNote' => $clinical->findForVisit($visit, 'blood_bank', 'inventory_note'), + 'issueNote' => $clinical->findForVisit($visit, 'blood_bank', 'issue_note'), + 'transfusionNote' => $clinical->findForVisit($visit, 'blood_bank', 'transfusion_note'), + ]); + } +} diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 18e46ce..9ced2a4 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -169,6 +169,7 @@ class SpecialtyModuleController extends Controller $preferredTab = match ($module) { 'dentistry' => 'odontogram', 'emergency' => 'triage', + 'blood_bank' => 'requests', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -226,7 +227,7 @@ class SpecialtyModuleController extends Controller } catch (\InvalidArgumentException) { // Stage map may be empty for some modules. } - } elseif ($nextStage && in_array($visit->specialty_stage, ['waiting', 'arrival'], true)) { + } elseif ($nextStage && in_array($visit->specialty_stage, ['waiting', 'arrival', 'request'], true)) { try { $stageService->setStage($organization, $visit, $module, $nextStage, $owner, $owner); $visit = $visit->fresh(); @@ -239,6 +240,7 @@ class SpecialtyModuleController extends Controller $preferredTab = match ($module) { 'dentistry' => 'odontogram', 'emergency' => 'triage', + 'blood_bank' => 'requests', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -335,6 +337,42 @@ class SpecialtyModuleController extends Controller } } + if ($module === 'blood_bank' && $recordType === 'blood_request') { + $workflow = app(\App\Services\Care\BloodBank\BloodBankWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = $workflow->stageFromRequest($record->payload ?? []); + $current = $visit->specialty_stage; + if (! $current || in_array($current, ['request', 'waiting'], true)) { + try { + $stageService->setStage( + $organization, + $visit, + 'blood_bank', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested !== $current && in_array($suggested, ['crossmatch', 'issue'], true)) { + // Advance when cross-match / issue status progresses on an existing request. + $order = ['request' => 0, 'crossmatch' => 1, 'issue' => 2, 'transfusion' => 3, 'completed' => 4]; + if (($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage( + $organization, + $visit, + 'blood_bank', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } + } + } + return redirect() ->route('care.specialty.workspace', [ 'module' => $module, @@ -726,6 +764,12 @@ class SpecialtyModuleController extends Controller $emergencyLatestVitals = null; $emergencyStageCodes = []; $emergencyStageFlow = []; + $bloodBankRequest = null; + $bloodBankInventory = null; + $bloodBankIssue = null; + $bloodBankTransfusion = null; + $bloodBankStageCodes = []; + $bloodBankStageFlow = []; $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); $clinical = app(SpecialtyClinicalRecordService::class); @@ -842,6 +886,15 @@ class SpecialtyModuleController extends Controller $emergencyStageFlow = app(\App\Services\Care\Emergency\EmergencyWorkflowService::class)->stageFlow(); } + if ($module === 'blood_bank') { + $bloodBankRequest = $clinical->findForVisit($workspaceVisit, 'blood_bank', 'blood_request'); + $bloodBankInventory = $clinical->findForVisit($workspaceVisit, 'blood_bank', 'inventory_note'); + $bloodBankIssue = $clinical->findForVisit($workspaceVisit, 'blood_bank', 'issue_note'); + $bloodBankTransfusion = $clinical->findForVisit($workspaceVisit, 'blood_bank', 'transfusion_note'); + $bloodBankStageCodes = collect($shell->stages('blood_bank'))->pluck('code')->all(); + $bloodBankStageFlow = app(\App\Services\Care\BloodBank\BloodBankWorkflowService::class)->stageFlow(); + } + if ($patientHeader !== null) { $patientHeader['clinical_alerts'] = $clinicalAlerts; } @@ -921,6 +974,12 @@ class SpecialtyModuleController extends Controller 'emergencyLatestVitals' => $emergencyLatestVitals, 'emergencyStageCodes' => $emergencyStageCodes, 'emergencyStageFlow' => $emergencyStageFlow, + 'bloodBankRequest' => $bloodBankRequest, + 'bloodBankInventory' => $bloodBankInventory, + 'bloodBankIssue' => $bloodBankIssue, + 'bloodBankTransfusion' => $bloodBankTransfusion, + 'bloodBankStageCodes' => $bloodBankStageCodes, + 'bloodBankStageFlow' => $bloodBankStageFlow, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'branchLabel' => $branchLabel, diff --git a/app/Services/Care/BloodBank/BloodBankAnalyticsService.php b/app/Services/Care/BloodBank/BloodBankAnalyticsService.php new file mode 100644 index 0000000..6475866 --- /dev/null +++ b/app/Services/Care/BloodBank/BloodBankAnalyticsService.php @@ -0,0 +1,195 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'blood_bank', $ownerRef, $branchId); + + $appointmentBase = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds)) + ->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0')) + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)); + + $visitIds = (clone $appointmentBase)->whereNotNull('visit_id')->pluck('visit_id'); + + $requestsToday = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'blood_bank') + ->where('record_type', 'blood_request') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->where('recorded_at', '>=', $todayStart) + ->count(); + + $completedToday = (clone $appointmentBase) + ->where('status', Appointment::STATUS_COMPLETED) + ->where('completed_at', '>=', $todayStart) + ->count(); + + $requestRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'blood_bank') + ->where('record_type', 'blood_request') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $urgencyMix = $requestRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['urgency'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $urgency) => [ + 'urgency' => $urgency, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $openVisitIds = Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->pluck('id'); + + $highUrgencyOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'blood_bank') + ->where('record_type', 'blood_request') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(function (SpecialtyClinicalRecord $r) { + $urgency = strtolower((string) ($r->payload['urgency'] ?? '')); + + return str_contains($urgency, 'urgent') + || str_contains($urgency, 'emergency') + || str_contains($urgency, 'massive'); + }) + ->count(); + + $issueRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'blood_bank') + ->where('record_type', 'issue_note') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $productsIssued = $issueRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['product'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $product) => [ + 'product' => $product, + 'total' => $rows->sum(fn (SpecialtyClinicalRecord $r) => (int) ($r->payload['units_issued'] ?? 0)), + 'issues' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + // Fall back to request issued_units when no dedicated issue notes exist. + if ($productsIssued->isEmpty()) { + $productsIssued = $requestRecords + ->filter(fn (SpecialtyClinicalRecord $r) => (int) ($r->payload['issued_units'] ?? 0) > 0) + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['product'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $product) => [ + 'product' => $product, + 'total' => $rows->sum(fn (SpecialtyClinicalRecord $r) => (int) ($r->payload['issued_units'] ?? 0)), + 'issues' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + } + + $lowStockFlags = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'blood_bank') + ->where('record_type', 'inventory_note') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get() + ->filter(function (SpecialtyClinicalRecord $r) { + if (! empty($r->payload['low_stock_alert'])) { + return true; + } + foreach (['whole_blood_units', 'packed_rbc_units', 'platelet_units', 'ffp_units'] as $key) { + if (isset($r->payload[$key]) && (int) $r->payload[$key] <= 2) { + return true; + } + } + + return false; + }) + ->count(); + + $serviceLabels = collect($this->shell->provisionedServices($organization, 'blood_bank')) + ->pluck('label') + ->filter() + ->values() + ->all(); + + $revenueByService = BillLineItem::query() + ->where('owner_ref', $ownerRef) + ->where('source_type', 'specialty_service') + ->whereIn('description', $serviceLabels ?: ['__none__']) + ->whereBetween('created_at', [$rangeFrom, $rangeTo]) + ->whereHas('bill', function ($q) use ($organization, $visitIds) { + $q->where('organization_id', $organization->id) + ->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds) + ->whereNotIn('status', [Bill::STATUS_VOID]); + }) + ->selectRaw('description, count(*) as total, sum(total_minor) as amount_minor') + ->groupBy('description') + ->orderByDesc('amount_minor') + ->get(); + + return [ + 'requests_today' => $requestsToday, + 'completed_today' => $completedToday, + 'high_urgency_open' => $highUrgencyOpen, + 'urgency_mix' => $urgencyMix, + 'products_issued' => $productsIssued, + 'low_stock_flags' => $lowStockFlags, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/BloodBank/BloodBankWorkflowService.php b/app/Services/Care/BloodBank/BloodBankWorkflowService.php new file mode 100644 index 0000000..801b6e5 --- /dev/null +++ b/app/Services/Care/BloodBank/BloodBankWorkflowService.php @@ -0,0 +1,94 @@ + $payload + */ + public function stageFromRequest(array $payload): string + { + $crossmatch = strtolower((string) ($payload['crossmatch_status'] ?? '')); + $issued = (int) ($payload['issued_units'] ?? 0); + $urgency = strtolower((string) ($payload['urgency'] ?? '')); + + if ($issued > 0 || $crossmatch === 'issued') { + return self::STAGE_ISSUE; + } + + if (in_array($crossmatch, ['compatible', 'incompatible', 'in progress'], true)) { + return self::STAGE_CROSSMATCH; + } + + if (str_contains($urgency, 'emergency') || str_contains($urgency, 'massive')) { + return self::STAGE_CROSSMATCH; + } + + return self::STAGE_REQUEST; + } + + /** + * Whether a transfusion payload should close the visit episode. + * + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return $outcome !== '' && ( + str_contains($outcome, 'completed') + || str_contains($outcome, 'stopped') + || str_contains($outcome, 'complete') + ); + } + + /** + * Map product label to catalog service code for billing. + */ + public function serviceCodeForProduct(?string $product): ?string + { + $normalized = strtolower(trim((string) $product)); + + return match (true) { + str_contains($normalized, 'whole') => 'bb.whole_blood', + str_contains($normalized, 'packed') || str_contains($normalized, 'rbc') => 'bb.packed_rbc', + str_contains($normalized, 'platelet') => 'bb.platelets', + str_contains($normalized, 'ffp') || str_contains($normalized, 'plasma') => 'bb.ffp', + str_contains($normalized, 'cryo') => 'bb.cryo', + default => null, + }; + } + + /** + * Default stage progression for action buttons. + * + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_REQUEST => ['next' => self::STAGE_CROSSMATCH, 'label' => 'Start cross-match'], + self::STAGE_CROSSMATCH => ['next' => self::STAGE_ISSUE, 'label' => 'Ready to issue'], + self::STAGE_ISSUE => ['next' => self::STAGE_TRANSFUSION, 'label' => 'Start transfusion'], + self::STAGE_TRANSFUSION => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'], + self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'], + ]; + } +} diff --git a/app/Services/Care/SpecialtyClinicalRecordService.php b/app/Services/Care/SpecialtyClinicalRecordService.php index da3c37d..8d865b5 100644 --- a/app/Services/Care/SpecialtyClinicalRecordService.php +++ b/app/Services/Care/SpecialtyClinicalRecordService.php @@ -196,6 +196,12 @@ class SpecialtyClinicalRecordService 'severity' => 'critical', 'message' => 'Emergency / massive transfusion request.', ]; + } elseif (str_contains(strtolower($urgency), 'urgent')) { + $alerts[] = [ + 'code' => 'bb.urgent_request', + 'severity' => 'warning', + 'message' => 'Urgent blood product request.', + ]; } if (($payload['crossmatch_status'] ?? '') === 'Incompatible') { $alerts[] = [ @@ -214,7 +220,7 @@ class SpecialtyClinicalRecordService 'message' => 'Blood products flagged as low stock.', ]; } - foreach (['whole_blood_units', 'packed_rbc_units', 'platelet_units'] as $key) { + foreach (['whole_blood_units', 'packed_rbc_units', 'platelet_units', 'ffp_units', 'cryo_units'] as $key) { if (isset($payload[$key]) && (int) $payload[$key] <= 2) { $alerts[] = [ 'code' => 'bb.unit_critical', @@ -225,6 +231,24 @@ class SpecialtyClinicalRecordService } } + if ($moduleKey === 'blood_bank' && $recordType === 'transfusion_note') { + $reaction = strtolower((string) ($payload['reaction'] ?? '')); + if (str_contains($reaction, 'severe') || str_contains($reaction, 'stop')) { + $alerts[] = [ + 'code' => 'bb.transfusion_reaction', + 'severity' => 'critical', + 'message' => 'Severe transfusion reaction — stop and escalate.', + ]; + } + if (($payload['vitals_ok'] ?? true) === false) { + $alerts[] = [ + 'code' => 'bb.vitals_concern', + 'severity' => 'warning', + 'message' => 'Transfusion vitals not acceptable.', + ]; + } + } + return $alerts; } diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php index bc79c54..191105e 100644 --- a/app/Services/Care/SpecialtyShellService.php +++ b/app/Services/Care/SpecialtyShellService.php @@ -270,7 +270,7 @@ class SpecialtyShellService ) { $code = (string) ($stage['code'] ?? ''); - if (in_array($moduleKey, ['dentistry', 'emergency'], true) && $visitIds->isNotEmpty()) { + if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank'], true) && $visitIds->isNotEmpty()) { $count = Visit::owned($ownerRef) ->where('organization_id', $organization->id) ->whereIn('id', $visitIds) @@ -502,6 +502,7 @@ class SpecialtyShellService string $serviceCode, string $ownerRef, ?string $actorRef = null, + int $quantity = 1, ): Bill { $service = collect($this->provisionedServices($organization, $moduleKey)) ->first(fn ($s) => ($s['code'] ?? '') === $serviceCode); @@ -510,12 +511,21 @@ class SpecialtyShellService throw new \InvalidArgumentException("Unknown specialty service [{$serviceCode}]."); } - $bill = $this->bills->generateFromVisit($visit, $ownerRef, $actorRef); + // Reuse an open visit bill without syncLineItemsFromVisit — that sync deletes + // prior specialty_service lines and would wipe multi-item issue/billing batches. + $bill = Bill::owned($ownerRef) + ->where('visit_id', $visit->id) + ->whereNotIn('status', [Bill::STATUS_VOID]) + ->first(); + + if (! $bill) { + $bill = $this->bills->generateFromVisit($visit, $ownerRef, $actorRef); + } return $this->bills->addManualLineItem($bill, $ownerRef, [ 'type' => $service['type'] ?? 'misc', 'description' => $service['label'], - 'quantity' => 1, + 'quantity' => max(1, $quantity), 'unit_price_minor' => (int) ($service['amount_minor'] ?? 0), 'source_type' => 'specialty_service', 'source_id' => null, diff --git a/app/Services/Care/SpecialtyVisitStageService.php b/app/Services/Care/SpecialtyVisitStageService.php index 05ed4e1..b8c54b8 100644 --- a/app/Services/Care/SpecialtyVisitStageService.php +++ b/app/Services/Care/SpecialtyVisitStageService.php @@ -88,6 +88,14 @@ class SpecialtyVisitStageService : ($stages[1] ?? ($stages[0] ?? null)); } + if ($moduleKey === 'blood_bank') { + $stages = $this->allowedStages($moduleKey); + + return in_array(\App\Services\Care\BloodBank\BloodBankWorkflowService::STAGE_CROSSMATCH, $stages, true) + ? \App\Services\Care\BloodBank\BloodBankWorkflowService::STAGE_CROSSMATCH + : ($stages[1] ?? ($stages[0] ?? null)); + } + $stages = $this->allowedStages($moduleKey); foreach (['chair', 'in_care', 'exam', 'assessment', 'treatment'] as $preferred) { if (in_array($preferred, $stages, true)) { diff --git a/config/care_specialty_clinical.php b/config/care_specialty_clinical.php index 158a03d..9f95fd6 100644 --- a/config/care_specialty_clinical.php +++ b/config/care_specialty_clinical.php @@ -20,6 +20,8 @@ return [ 'blood_bank' => [ 'requests' => 'blood_request', 'inventory' => 'inventory_note', + 'issue' => 'issue_note', + 'transfusion' => 'transfusion_note', ], 'dentistry' => [ ], @@ -158,9 +160,26 @@ return [ ['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' => 'cryo_units', 'label' => 'Cryoprecipitate on hand', 'type' => 'number'], ['name' => 'low_stock_alert', 'label' => 'Flag low stock', 'type' => 'boolean'], ['name' => 'notes', 'label' => 'Inventory notes', 'type' => 'textarea', 'rows' => 3], ], + 'issue_note' => [ + ['name' => 'product', 'label' => 'Product', 'type' => 'select', 'required' => true, 'options' => ['Whole blood', 'Packed RBC', 'Platelets', 'FFP', 'Cryoprecipitate']], + ['name' => 'units_issued', 'label' => 'Units issued', 'type' => 'number', 'required' => true], + ['name' => 'bag_numbers', 'label' => 'Bag / unit numbers', 'type' => 'text'], + ['name' => 'issued_to', 'label' => 'Issued to (ward / clinician)', 'type' => 'text'], + ['name' => 'issued_at', 'label' => 'Issued at', 'type' => 'text'], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], + ], + 'transfusion_note' => [ + ['name' => 'started_at', 'label' => 'Started at', 'type' => 'text'], + ['name' => 'units_transfused', 'label' => 'Units transfused', 'type' => 'number'], + ['name' => 'vitals_ok', 'label' => 'Vitals acceptable', 'type' => 'boolean'], + ['name' => 'reaction', 'label' => 'Reaction', 'type' => 'select', 'options' => ['None', 'Mild (fever / itch)', 'Moderate', 'Severe / stop transfusion']], + ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['In progress', 'Completed uneventfully', 'Stopped — reaction', 'Stopped — other']], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 3], + ], ], 'dentistry' => [ ], diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index a3774c6..45b601c 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -87,11 +87,15 @@ return [ ['code' => 'bb.whole_blood', 'label' => 'Whole blood unit', 'amount_minor' => 12000, 'type' => 'misc'], ['code' => 'bb.packed_rbc', 'label' => 'Packed RBC unit', 'amount_minor' => 15000, 'type' => 'misc'], ['code' => 'bb.platelets', 'label' => 'Platelets unit', 'amount_minor' => 18000, 'type' => 'misc'], + ['code' => 'bb.ffp', 'label' => 'FFP unit', 'amount_minor' => 14000, 'type' => 'misc'], + ['code' => 'bb.cryo', 'label' => 'Cryoprecipitate unit', 'amount_minor' => 16000, 'type' => 'misc'], ], 'workspace_tabs' => [ 'overview' => 'Overview', 'requests' => 'Requests', 'inventory' => 'Inventory', + 'issue' => 'Issue', + 'transfusion' => 'Transfusion', 'billing' => 'Billing', 'documents' => 'Documents', ], diff --git a/resources/views/care/specialty/blood-bank/print.blade.php b/resources/views/care/specialty/blood-bank/print.blade.php new file mode 100644 index 0000000..7bac936 --- /dev/null +++ b/resources/views/care/specialty/blood-bank/print.blade.php @@ -0,0 +1,83 @@ + + + + + Blood Bank summary · {{ $patient->fullName() }} + + + +

Back

+ +

{{ $patient->fullName() }}

+

+ {{ $patient->patient_number }} + · Visit #{{ $visit->id }} + · Stage {{ $visit->specialty_stage ?? '—' }} + · {{ $visit->checked_in_at?->format('d M Y H:i') ?? '—' }} +

+ +

Blood request

+ @if ($bloodRequest) +
+
Product
{{ $bloodRequest->payload['product'] ?? '—' }}
+
Units requested
{{ $bloodRequest->payload['units'] ?? '—' }}
+
Urgency
{{ $bloodRequest->payload['urgency'] ?? '—' }}
+
Blood group
{{ $bloodRequest->payload['patient_blood_group'] ?? '—' }}
+
Indication
{{ $bloodRequest->payload['indication'] ?? '—' }}
+
Cross-match
{{ $bloodRequest->payload['crossmatch_status'] ?? '—' }}
+
Issued units
{{ $bloodRequest->payload['issued_units'] ?? '—' }}
+
Notes
{{ $bloodRequest->payload['notes'] ?? '—' }}
+
+ @else +

No blood request recorded.

+ @endif + +

Issue

+ @if ($issueNote) +
+
Product
{{ $issueNote->payload['product'] ?? '—' }}
+
Units issued
{{ $issueNote->payload['units_issued'] ?? '—' }}
+
Bag numbers
{{ $issueNote->payload['bag_numbers'] ?? '—' }}
+
Issued to
{{ $issueNote->payload['issued_to'] ?? '—' }}
+
Issued at
{{ $issueNote->payload['issued_at'] ?? '—' }}
+
Notes
{{ $issueNote->payload['notes'] ?? '—' }}
+
+ @else +

No issue recorded.

+ @endif + +

Transfusion

+ @if ($transfusionNote) +
+
Started
{{ $transfusionNote->payload['started_at'] ?? '—' }}
+
Units transfused
{{ $transfusionNote->payload['units_transfused'] ?? '—' }}
+
Reaction
{{ $transfusionNote->payload['reaction'] ?? '—' }}
+
Outcome
{{ $transfusionNote->payload['outcome'] ?? '—' }}
+
Notes
{{ $transfusionNote->payload['notes'] ?? '—' }}
+
+ @else +

No transfusion record.

+ @endif + + @if ($visit->bill) +

Billing ({{ $visit->bill->invoice_number }})

+
+ @foreach ($visit->bill->lineItems as $line) +
{{ $line->description }}
+
{{ number_format($line->total_minor / 100, 2) }}
+ @endforeach +
+ @endif + + + + diff --git a/resources/views/care/specialty/blood-bank/reports.blade.php b/resources/views/care/specialty/blood-bank/reports.blade.php new file mode 100644 index 0000000..75477e5 --- /dev/null +++ b/resources/views/care/specialty/blood-bank/reports.blade.php @@ -0,0 +1,86 @@ + +
+
+
+

Blood Bank reports

+

Requests, urgency mix, products issued, low stock, and bb.* revenue.

+
+ ← Specialty home +
+ +
+
+ + +
+
+ + +
+ +
+ +
+
+

Requests today

+

{{ number_format($report['requests_today']) }}

+
+
+

Completed today

+

{{ number_format($report['completed_today']) }}

+
+
+

High-urgency open

+

{{ number_format($report['high_urgency_open']) }}

+
+
+

Low stock flags

+

{{ number_format($report['low_stock_flags']) }}

+
+
+ +
+
+

Urgency mix

+
    + @forelse ($report['urgency_mix'] as $row) +
  • + {{ $row['urgency'] }} + {{ $row['total'] }} +
  • + @empty +
  • No requests in range.
  • + @endforelse +
+
+ +
+

Products issued

+
    + @forelse ($report['products_issued'] as $row) +
  • + {{ $row['product'] }} ×{{ $row['issues'] }} + {{ $row['total'] }} units +
  • + @empty +
  • No issues in range.
  • + @endforelse +
+
+
+ +
+

Blood Bank service revenue

+
    + @forelse ($report['revenue_by_service'] as $row) +
  • + {{ $row->description }} ×{{ $row->total }} + {{ $currency }} {{ number_format($row->amount_minor / 100, 2) }} +
  • + @empty +
  • No Blood Bank bill lines in range.
  • + @endforelse +
+
+
+
diff --git a/resources/views/care/specialty/blood-bank/workspace-issue.blade.php b/resources/views/care/specialty/blood-bank/workspace-issue.blade.php new file mode 100644 index 0000000..91163b6 --- /dev/null +++ b/resources/views/care/specialty/blood-bank/workspace-issue.blade.php @@ -0,0 +1,100 @@ +@php + $issue = $bloodBankIssue; + $payload = $issue?->payload ?? []; + $requestPayload = $bloodBankRequest?->payload ?? []; + $canManage = $canManageClinical ?? $canConsult ?? false; + $defaults = [ + 'product' => $payload['product'] ?? ($requestPayload['product'] ?? ''), + 'units_issued' => $payload['units_issued'] ?? ($requestPayload['units'] ?? ''), + 'bag_numbers' => $payload['bag_numbers'] ?? '', + 'issued_to' => $payload['issued_to'] ?? '', + 'issued_at' => $payload['issued_at'] ?? now()->format('Y-m-d H:i'), + 'notes' => $payload['notes'] ?? '', + ]; +@endphp + +
+
+
+

Product issue

+

Confirm units issued and optionally bill Blood Bank catalog services.

+
+ @if ($issue) + Issued recorded + @endif +
+ + @if ($canManage) +
+ @csrf + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ @elseif ($issue) +
+
+
Product
+
{{ $payload['product'] ?? '—' }}
+
+
+
Units issued
+
{{ $payload['units_issued'] ?? '—' }}
+
+
+
Bag numbers
+
{{ $payload['bag_numbers'] ?? '—' }}
+
+
+
Issued to
+
{{ $payload['issued_to'] ?? '—' }}
+
+
+ @else +

No issue recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/blood-bank/workspace-overview.blade.php b/resources/views/care/specialty/blood-bank/workspace-overview.blade.php new file mode 100644 index 0000000..b1d1bdc --- /dev/null +++ b/resources/views/care/specialty/blood-bank/workspace-overview.blade.php @@ -0,0 +1,143 @@ +@php + $requestRecord = $bloodBankRequest; + $payload = $requestRecord?->payload ?? []; + $issuePayload = $bloodBankIssue?->payload ?? []; + $inventoryPayload = $bloodBankInventory?->payload ?? []; + $currentStage = $workspaceVisit->specialty_stage; + $stageFlow = $bloodBankStageFlow ?? []; + $canManage = $canConsult ?? false; + $urgency = (string) ($payload['urgency'] ?? ''); + $isHighUrgency = str_contains(strtolower($urgency), 'urgent') + || str_contains(strtolower($urgency), 'emergency') + || str_contains(strtolower($urgency), 'massive'); +@endphp + +
+
+
+

Blood Bank overview

+

Open request, urgency, cross-match status, and visit stage.

+
+ +
+ +
+
+

Request

+

{{ $payload['product'] ?? 'No request' }}

+

+ {{ isset($payload['units']) ? $payload['units'].' unit(s)' : '—' }} + · {{ $payload['patient_blood_group'] ?? 'Group —' }} +

+
+
+

Urgency / cross-match

+

$isHighUrgency, + 'text-slate-900' => ! $isHighUrgency, + ])> + {{ $urgency !== '' ? $urgency : 'Not set' }} +

+

{{ $payload['crossmatch_status'] ?? 'Cross-match not started' }}

+
+
+

Issued

+

+ {{ $issuePayload['units_issued'] ?? $payload['issued_units'] ?? '0' }} unit(s) +

+

+ {{ $issuePayload['product'] ?? ($payload['product'] ?? '—') }} +

+
+
+ +
+

Visit stage

+

+ {{ $currentStage ? ucfirst(str_replace('_', ' ', $currentStage)) : 'Not set' }} +

+
+ @foreach (($bloodBankStageCodes ?: ['request', 'crossmatch', 'issue', 'transfusion', 'completed']) as $stageCode) + @if ($canManage) +
+ @csrf + + +
+ @else + $currentStage === $stageCode, + 'border border-slate-200 bg-white text-slate-400' => $currentStage !== $stageCode, + ]) + @if ($currentStage !== $stageCode) aria-disabled="true" title="View-only access" @endif + > + {{ ucfirst(str_replace('_', ' ', $stageCode)) }} + + @endif + @endforeach + @if ($canManage && $currentStage && isset($stageFlow[$currentStage]) && $stageFlow[$currentStage]['next'] !== $currentStage) +
+ @csrf + + +
+ @endif +
+
+ +
+
+
Indication
+
{{ $payload['indication'] ?? '—' }}
+
+
+
Inventory flag
+
+ @if (! empty($inventoryPayload['low_stock_alert'])) + Low stock flagged + @else + {{ isset($inventoryPayload['packed_rbc_units']) ? 'Stock noted' : '—' }} + @endif +
+
+
+
Queue
+
+ {{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }} + @if ($workspaceVisit->appointment?->queue_ticket_status) + ({{ $workspaceVisit->appointment->queue_ticket_status }}) + @endif +
+
+
+
Checked in
+
{{ $workspaceVisit->checked_in_at?->format('d M Y H:i') ?? '—' }}
+
+
+ + @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

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

+ @endforeach +
+ @endif +
diff --git a/resources/views/care/specialty/blood-bank/workspace-transfusion.blade.php b/resources/views/care/specialty/blood-bank/workspace-transfusion.blade.php new file mode 100644 index 0000000..b0d5c4a --- /dev/null +++ b/resources/views/care/specialty/blood-bank/workspace-transfusion.blade.php @@ -0,0 +1,90 @@ +@php + $transfusion = $bloodBankTransfusion; + $payload = $transfusion?->payload ?? []; + $canManage = $canManageClinical ?? $canConsult ?? false; + $defaults = [ + 'started_at' => $payload['started_at'] ?? now()->format('Y-m-d H:i'), + 'units_transfused' => $payload['units_transfused'] ?? '', + 'vitals_ok' => old('payload.vitals_ok', $payload['vitals_ok'] ?? true), + 'reaction' => $payload['reaction'] ?? 'None', + 'outcome' => $payload['outcome'] ?? '', + 'notes' => $payload['notes'] ?? '', + ]; +@endphp + +
+
+
+

Transfusion

+

Document transfusion progress, reactions, and outcome.

+
+
+ + @if ($canManage) +
+ @csrf + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+ + +
+ + +
+ @elseif ($transfusion) +
+
+
Outcome
+
{{ $payload['outcome'] ?? '—' }}
+
+
+
Reaction
+
{{ $payload['reaction'] ?? '—' }}
+
+
+
Units transfused
+
{{ $payload['units_transfused'] ?? '—' }}
+
+
+
Notes
+
{{ $payload['notes'] ?? '—' }}
+
+
+ @else +

No transfusion record yet.

+ @endif +
diff --git a/resources/views/care/specialty/partials/actions-menu.blade.php b/resources/views/care/specialty/partials/actions-menu.blade.php index c567750..5bd9b46 100644 --- a/resources/views/care/specialty/partials/actions-menu.blade.php +++ b/resources/views/care/specialty/partials/actions-menu.blade.php @@ -30,21 +30,51 @@ && $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED; $chartTab = collect($workspaceTabs ?? []) ->keys() - ->first(fn ($key) => ! in_array($key, ['overview', 'timeline', 'plan', 'treat', 'notes', 'imaging', 'orders', 'billing', 'documents', 'perio', 'lab', 'recalls', 'vitals', 'disposition', 'observation'], true)) - ?: ($moduleKey === 'emergency' ? 'triage' : 'odontogram'); - $stageFlow = $moduleKey === 'emergency' - ? ($emergencyStageFlow ?? [ + ->first(fn ($key) => ! in_array($key, ['overview', 'timeline', 'plan', 'treat', 'notes', 'imaging', 'orders', 'billing', 'documents', 'perio', 'lab', 'recalls', 'vitals', 'disposition', 'observation', 'issue', 'transfusion', 'inventory'], true)) + ?: match ($moduleKey) { + 'emergency' => 'triage', + 'blood_bank' => 'requests', + default => 'odontogram', + }; + $stageRoute = match ($moduleKey) { + 'dentistry' => 'care.specialty.dentistry.stage', + 'emergency' => 'care.specialty.emergency.stage', + 'blood_bank' => 'care.specialty.blood-bank.stage', + default => null, + }; + $stageFlow = match ($moduleKey) { + 'emergency' => ($emergencyStageFlow ?? [ 'arrival' => ['next' => 'treatment', 'label' => 'Move to treatment'], 'resus' => ['next' => 'treatment', 'label' => 'Move to treatment bay'], 'treatment' => ['next' => 'observation', 'label' => 'Move to observation'], 'observation' => ['next' => 'disposition', 'label' => 'Ready for disposition'], - ]) - : [ + ]), + 'blood_bank' => ($bloodBankStageFlow ?? [ + 'request' => ['next' => 'crossmatch', 'label' => 'Start cross-match'], + 'crossmatch' => ['next' => 'issue', 'label' => 'Ready to issue'], + 'issue' => ['next' => 'transfusion', 'label' => 'Start transfusion'], + 'transfusion' => ['next' => 'completed', 'label' => 'Complete visit'], + ]), + 'dentistry' => [ 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], 'procedure' => ['next' => 'recovery', 'label' => 'Move to recovery'], 'recovery' => ['next' => 'completed', 'label' => 'Complete visit'], - ]; + ], + default => [], + }; + $defaultStartStage = match ($moduleKey) { + 'emergency' => 'arrival', + 'blood_bank' => 'request', + 'dentistry' => 'chair', + default => null, + }; + $defaultStartLabel = match ($moduleKey) { + 'emergency' => 'Start triage', + 'blood_bank' => 'Start request', + 'dentistry' => 'Seat at chair', + default => 'Start', + }; $currentStage = $workspaceVisit?->specialty_stage; @endphp @@ -67,29 +97,17 @@ @endif @if ($workspaceVisit) - @if ($canAdvanceStage && $moduleKey === 'dentistry' && $currentStage && isset($stageFlow[$currentStage])) -
+ @if ($canAdvanceStage && $stageRoute && $currentStage && isset($stageFlow[$currentStage]) && ($stageFlow[$currentStage]['next'] ?? null) !== $currentStage) + @csrf
- @elseif ($canAdvanceStage && $moduleKey === 'dentistry' && ! $currentStage) -
+ @elseif ($canAdvanceStage && $stageRoute && ! $currentStage && $defaultStartStage) + @csrf - - -
- @elseif ($canAdvanceStage && $moduleKey === 'emergency' && $currentStage && isset($stageFlow[$currentStage]) && ($stageFlow[$currentStage]['next'] ?? null) !== $currentStage) -
- @csrf - - -
- @elseif ($canAdvanceStage && $moduleKey === 'emergency' && ! $currentStage) -
- @csrf - - + +
@endif diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index 376377a..5a3f622 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -48,6 +48,8 @@ @include('care.specialty.dentistry.workspace-'.$activeTab) @elseif ($moduleKey === 'emergency' && in_array($activeTab, ['overview', 'vitals', 'disposition'], true)) @include('care.specialty.emergency.workspace-'.$activeTab) + @elseif ($moduleKey === 'blood_bank' && in_array($activeTab, ['overview', 'issue', 'transfusion'], true)) + @include('care.specialty.blood-bank.workspace-'.$activeTab) @elseif ($activeTab === 'billing')

diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index ee87781..bd6c727 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -23,6 +23,9 @@ @if ($moduleKey === 'emergency') Reports @endif + @if ($moduleKey === 'blood_bank') + Reports + @endif History @if ($canManageClinical ?? $canManageSpecialty ?? true) Billing diff --git a/routes/web.php b/routes/web.php index d4a717e..194ac56 100644 --- a/routes/web.php +++ b/routes/web.php @@ -37,6 +37,7 @@ use App\Http\Controllers\Care\SettingsController; use App\Http\Controllers\Care\SettingsGpPricingController; use App\Http\Controllers\Care\SettingsModulesController; use App\Http\Controllers\Care\DentistryWorkspaceController; +use App\Http\Controllers\Care\BloodBankWorkspaceController; use App\Http\Controllers\Care\EmergencyWorkspaceController; use App\Http\Controllers\Care\SpecialtyModuleController; use App\Http\Controllers\Care\VisitWorkflowController; @@ -119,6 +120,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/specialty/{module}/billing', [SpecialtyModuleController::class, 'billing'])->name('care.specialty.billing'); Route::get('/specialty/dentistry/reports', [DentistryWorkspaceController::class, 'reports'])->name('care.specialty.dentistry.reports'); Route::get('/specialty/emergency/reports', [EmergencyWorkspaceController::class, 'reports'])->name('care.specialty.emergency.reports'); + Route::get('/specialty/blood-bank/reports', [BloodBankWorkspaceController::class, 'reports'])->name('care.specialty.blood-bank.reports'); 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'); @@ -148,6 +150,10 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/specialty/emergency/workspace/{visit}/vitals', [EmergencyWorkspaceController::class, 'saveVitals'])->name('care.specialty.emergency.vitals'); Route::post('/specialty/emergency/workspace/{visit}/disposition', [EmergencyWorkspaceController::class, 'saveDisposition'])->name('care.specialty.emergency.disposition'); Route::get('/specialty/emergency/workspace/{visit}/print', [EmergencyWorkspaceController::class, 'printSummary'])->name('care.specialty.emergency.print'); + Route::post('/specialty/blood-bank/workspace/{visit}/stage', [BloodBankWorkspaceController::class, 'setStage'])->name('care.specialty.blood-bank.stage'); + Route::post('/specialty/blood-bank/workspace/{visit}/issue', [BloodBankWorkspaceController::class, 'confirmIssue'])->name('care.specialty.blood-bank.issue'); + Route::post('/specialty/blood-bank/workspace/{visit}/transfusion', [BloodBankWorkspaceController::class, 'saveTransfusion'])->name('care.specialty.blood-bank.transfusion'); + Route::get('/specialty/blood-bank/workspace/{visit}/print', [BloodBankWorkspaceController::class, 'printSummary'])->name('care.specialty.blood-bank.print'); 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}/refer', [SpecialtyModuleController::class, 'refer'])->name('care.specialty.refer'); diff --git a/tests/Feature/CareBloodBankSuiteTest.php b/tests/Feature/CareBloodBankSuiteTest.php new file mode 100644 index 0000000..1cf49a9 --- /dev/null +++ b/tests/Feature/CareBloodBankSuiteTest.php @@ -0,0 +1,298 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'bb-owner', + 'name' => 'Owner', + 'email' => 'bb-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Blood Bank Clinic', + 'slug' => 'blood-bank-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', + 'branch_id' => null, + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + app(SpecialtyModuleService::class)->activate( + $this->organization, + $this->owner->public_id, + 'blood_bank', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'blood_bank') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-BB-SUITE', + 'first_name' => 'Ama', + 'last_name' => 'Mensah', + 'gender' => 'female', + 'date_of_birth' => '1992-03-15', + ]); + + $this->visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + 'specialty_stage' => 'request', + ]); + + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'department_id' => $department->id, + 'visit_id' => $this->visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_IN_CONSULTATION, + 'scheduled_at' => now(), + 'waiting_at' => now(), + 'checked_in_at' => now(), + 'started_at' => now(), + ]); + } + + public function test_overview_and_requests_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'blood_bank', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Blood Bank overview') + ->assertSee('Visit stage') + ->assertSee('BB reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'blood_bank', + 'visit' => $this->visit, + 'tab' => 'requests', + ])) + ->assertOk() + ->assertSee('Product') + ->assertSee('Units requested'); + } + + public function test_request_sets_stage_and_alerts(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'blood_bank', + 'visit' => $this->visit, + ]), [ + 'tab' => 'requests', + 'payload' => [ + 'product' => 'Packed RBC', + 'units' => '2', + 'urgency' => 'Emergency / massive', + 'patient_blood_group' => 'O+', + 'indication' => 'Trauma bleed', + 'crossmatch_status' => 'In progress', + ], + ]) + ->assertRedirect(); + + $this->assertSame('crossmatch', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'blood_request') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('bb.massive_transfusion', $codes); + } + + public function test_stage_advance_issue_and_transfusion(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'blood_bank', + 'visit' => $this->visit, + ]), [ + 'tab' => 'requests', + 'payload' => [ + 'product' => 'Packed RBC', + 'units' => '2', + 'urgency' => 'Urgent', + 'patient_blood_group' => 'A+', + 'indication' => 'Anaemia', + 'crossmatch_status' => 'Compatible', + ], + ]) + ->assertRedirect(); + + $this->actingAs($this->owner) + ->post(route('care.specialty.blood-bank.stage', $this->visit), [ + 'stage' => 'crossmatch', + ]) + ->assertRedirect(); + + $this->assertSame('crossmatch', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.blood-bank.issue', $this->visit), [ + 'tab' => 'issue', + 'bill_product' => '1', + 'bill_crossmatch' => '1', + 'payload' => [ + 'product' => 'Packed RBC', + 'units_issued' => '2', + 'bag_numbers' => 'BB-1001, BB-1002', + 'issued_to' => 'Ward A', + 'issued_at' => now()->format('Y-m-d H:i'), + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'blood_bank', + 'visit' => $this->visit, + 'tab' => 'issue', + ])); + + $this->assertSame('issue', $this->visit->fresh()->specialty_stage); + $this->assertDatabaseHas('care_specialty_clinical_records', [ + 'visit_id' => $this->visit->id, + 'record_type' => 'issue_note', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + + $this->visit->load('bill.lineItems'); + $this->assertNotNull($this->visit->bill); + $this->visit->load('bill.lineItems'); + $this->assertNotNull($this->visit->bill); + $descriptions = $this->visit->bill->lineItems->pluck('description')->all(); + $this->assertContains('Packed RBC unit', $descriptions); + $this->assertContains('Cross-match', $descriptions); + $packed = $this->visit->bill->lineItems->firstWhere('description', 'Packed RBC unit'); + $this->assertSame(2, (int) $packed->quantity); + + $this->actingAs($this->owner) + ->post(route('care.specialty.blood-bank.transfusion', $this->visit), [ + 'tab' => 'transfusion', + 'payload' => [ + 'started_at' => now()->format('Y-m-d H:i'), + 'units_transfused' => '2', + 'vitals_ok' => '1', + 'reaction' => 'None', + 'outcome' => 'Completed uneventfully', + 'notes' => 'Tolerated well', + ], + ]) + ->assertRedirect(); + + $this->assertSame('completed', $this->visit->fresh()->specialty_stage); + $this->assertSame(Visit::STATUS_COMPLETED, $this->visit->fresh()->status); + $this->assertDatabaseHas('care_specialty_clinical_records', [ + 'visit_id' => $this->visit->id, + 'record_type' => 'transfusion_note', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_inventory_low_stock_and_reports_print(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'blood_bank', + 'visit' => $this->visit, + ]), [ + 'tab' => 'inventory', + 'payload' => [ + 'whole_blood_units' => '1', + 'packed_rbc_units' => '8', + 'platelet_units' => '4', + 'ffp_units' => '3', + 'low_stock_alert' => '1', + ], + ]) + ->assertRedirect(); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'inventory_note') + ->firstOrFail(); + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('bb.low_stock', $codes); + $this->assertContains('bb.unit_critical', $codes); + + $this->actingAs($this->owner) + ->get(route('care.specialty.blood-bank.reports')) + ->assertOk() + ->assertSee('Blood Bank reports') + ->assertSee('Requests today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.blood-bank.print', $this->visit)) + ->assertOk() + ->assertSee('Blood Bank summary') + ->assertSee($this->patient->fullName()); + } +}