Add full Blood Bank specialty suite on shared shell.
Deploy Ladill Care / deploy (push) Successful in 37s
Deploy Ladill Care / deploy (push) Successful in 37s
Mirror Emergency: stage workflow, issue/transfusion records, analytics, print, and action-menu stage flow without a parallel EHR. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SpecialtyClinicalRecord;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Care\BloodBank\BloodBankAnalyticsService;
|
||||
use App\Services\Care\BloodBank\BloodBankWorkflowService;
|
||||
use App\Services\Care\SpecialtyClinicalRecordService;
|
||||
use App\Services\Care\SpecialtyModuleService;
|
||||
use App\Services\Care\SpecialtyShellService;
|
||||
use App\Services\Care\SpecialtyVisitStageService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BloodBankWorkspaceController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
protected function assertBloodBankAccess(Request $request, SpecialtyModuleService $modules): void
|
||||
{
|
||||
$organization = $this->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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care\BloodBank;
|
||||
|
||||
use App\Models\Appointment;
|
||||
use App\Models\Bill;
|
||||
use App\Models\BillLineItem;
|
||||
use App\Models\Organization;
|
||||
use App\Models\SpecialtyClinicalRecord;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Care\SpecialtyShellService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BloodBankAnalyticsService
|
||||
{
|
||||
public function __construct(
|
||||
protected SpecialtyShellService $shell,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* requests_today: int,
|
||||
* completed_today: int,
|
||||
* high_urgency_open: int,
|
||||
* urgency_mix: Collection,
|
||||
* products_issued: Collection,
|
||||
* low_stock_flags: int,
|
||||
* revenue_by_service: Collection,
|
||||
* from: string,
|
||||
* to: string
|
||||
* }
|
||||
*/
|
||||
public function report(
|
||||
Organization $organization,
|
||||
string $ownerRef,
|
||||
?int $branchId = null,
|
||||
?string $from = null,
|
||||
?string $to = null,
|
||||
): array {
|
||||
$todayStart = now()->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care\BloodBank;
|
||||
|
||||
/**
|
||||
* Map blood request / issue payloads onto Blood Bank visit stages.
|
||||
*/
|
||||
class BloodBankWorkflowService
|
||||
{
|
||||
public const STAGE_REQUEST = 'request';
|
||||
|
||||
public const STAGE_CROSSMATCH = 'crossmatch';
|
||||
|
||||
public const STAGE_ISSUE = 'issue';
|
||||
|
||||
public const STAGE_TRANSFUSION = 'transfusion';
|
||||
|
||||
public const STAGE_COMPLETED = 'completed';
|
||||
|
||||
/**
|
||||
* Suggested next visit stage after a blood request payload is saved.
|
||||
*
|
||||
* @param array<string, mixed> $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<string, mixed> $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<string, array{next: string, label: string}>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}].");
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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' => [
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Blood Bank summary · {{ $patient->fullName() }}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; color: #0f172a; margin: 2rem; }
|
||||
h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
|
||||
h2 { font-size: .95rem; margin: 1.25rem 0 .5rem; border-bottom: 1px solid #e2e8f0; padding-bottom: .25rem; }
|
||||
.meta { color: #64748b; font-size: .875rem; }
|
||||
dl { display: grid; grid-template-columns: 10rem 1fr; gap: .35rem .75rem; font-size: .875rem; }
|
||||
dt { color: #64748b; }
|
||||
dd { margin: 0; font-weight: 500; }
|
||||
@media print { .no-print { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p class="no-print"><a href="{{ route('care.specialty.workspace', ['module' => 'blood_bank', 'visit' => $visit]) }}">Back</a></p>
|
||||
|
||||
<h1>{{ $patient->fullName() }}</h1>
|
||||
<p class="meta">
|
||||
{{ $patient->patient_number }}
|
||||
· Visit #{{ $visit->id }}
|
||||
· Stage {{ $visit->specialty_stage ?? '—' }}
|
||||
· {{ $visit->checked_in_at?->format('d M Y H:i') ?? '—' }}
|
||||
</p>
|
||||
|
||||
<h2>Blood request</h2>
|
||||
@if ($bloodRequest)
|
||||
<dl>
|
||||
<dt>Product</dt><dd>{{ $bloodRequest->payload['product'] ?? '—' }}</dd>
|
||||
<dt>Units requested</dt><dd>{{ $bloodRequest->payload['units'] ?? '—' }}</dd>
|
||||
<dt>Urgency</dt><dd>{{ $bloodRequest->payload['urgency'] ?? '—' }}</dd>
|
||||
<dt>Blood group</dt><dd>{{ $bloodRequest->payload['patient_blood_group'] ?? '—' }}</dd>
|
||||
<dt>Indication</dt><dd>{{ $bloodRequest->payload['indication'] ?? '—' }}</dd>
|
||||
<dt>Cross-match</dt><dd>{{ $bloodRequest->payload['crossmatch_status'] ?? '—' }}</dd>
|
||||
<dt>Issued units</dt><dd>{{ $bloodRequest->payload['issued_units'] ?? '—' }}</dd>
|
||||
<dt>Notes</dt><dd>{{ $bloodRequest->payload['notes'] ?? '—' }}</dd>
|
||||
</dl>
|
||||
@else
|
||||
<p class="meta">No blood request recorded.</p>
|
||||
@endif
|
||||
|
||||
<h2>Issue</h2>
|
||||
@if ($issueNote)
|
||||
<dl>
|
||||
<dt>Product</dt><dd>{{ $issueNote->payload['product'] ?? '—' }}</dd>
|
||||
<dt>Units issued</dt><dd>{{ $issueNote->payload['units_issued'] ?? '—' }}</dd>
|
||||
<dt>Bag numbers</dt><dd>{{ $issueNote->payload['bag_numbers'] ?? '—' }}</dd>
|
||||
<dt>Issued to</dt><dd>{{ $issueNote->payload['issued_to'] ?? '—' }}</dd>
|
||||
<dt>Issued at</dt><dd>{{ $issueNote->payload['issued_at'] ?? '—' }}</dd>
|
||||
<dt>Notes</dt><dd>{{ $issueNote->payload['notes'] ?? '—' }}</dd>
|
||||
</dl>
|
||||
@else
|
||||
<p class="meta">No issue recorded.</p>
|
||||
@endif
|
||||
|
||||
<h2>Transfusion</h2>
|
||||
@if ($transfusionNote)
|
||||
<dl>
|
||||
<dt>Started</dt><dd>{{ $transfusionNote->payload['started_at'] ?? '—' }}</dd>
|
||||
<dt>Units transfused</dt><dd>{{ $transfusionNote->payload['units_transfused'] ?? '—' }}</dd>
|
||||
<dt>Reaction</dt><dd>{{ $transfusionNote->payload['reaction'] ?? '—' }}</dd>
|
||||
<dt>Outcome</dt><dd>{{ $transfusionNote->payload['outcome'] ?? '—' }}</dd>
|
||||
<dt>Notes</dt><dd>{{ $transfusionNote->payload['notes'] ?? '—' }}</dd>
|
||||
</dl>
|
||||
@else
|
||||
<p class="meta">No transfusion record.</p>
|
||||
@endif
|
||||
|
||||
@if ($visit->bill)
|
||||
<h2>Billing ({{ $visit->bill->invoice_number }})</h2>
|
||||
<dl>
|
||||
@foreach ($visit->bill->lineItems as $line)
|
||||
<dt>{{ $line->description }}</dt>
|
||||
<dd>{{ number_format($line->total_minor / 100, 2) }}</dd>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
|
||||
<script>window.print();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
<x-app-layout title="Blood Bank reports">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Blood Bank reports</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Requests, urgency mix, products issued, low stock, and bb.* revenue.</p>
|
||||
</div>
|
||||
<a href="{{ route('care.specialty.workspace', 'blood_bank') }}" class="text-sm font-medium text-indigo-600">← Specialty home</a>
|
||||
</div>
|
||||
|
||||
<form method="GET" class="flex flex-wrap items-end gap-3 rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">From</label>
|
||||
<input type="date" name="from" value="{{ $report['from'] ?? '' }}" class="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">To</label>
|
||||
<input type="date" name="to" value="{{ $report['to'] ?? '' }}" class="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<button type="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white">Apply</button>
|
||||
</form>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Requests today</p>
|
||||
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">{{ number_format($report['requests_today']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Completed today</p>
|
||||
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">{{ number_format($report['completed_today']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">High-urgency open</p>
|
||||
<p class="mt-2 text-2xl font-semibold tabular-nums text-rose-700">{{ number_format($report['high_urgency_open']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Low stock flags</p>
|
||||
<p class="mt-2 text-2xl font-semibold tabular-nums text-amber-800">{{ number_format($report['low_stock_flags']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Urgency mix</h2>
|
||||
<ul class="mt-3 space-y-2 text-sm">
|
||||
@forelse ($report['urgency_mix'] as $row)
|
||||
<li class="flex justify-between gap-3">
|
||||
<span>{{ $row['urgency'] }}</span>
|
||||
<span class="tabular-nums text-slate-600">{{ $row['total'] }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-slate-500">No requests in range.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Products issued</h2>
|
||||
<ul class="mt-3 space-y-2 text-sm">
|
||||
@forelse ($report['products_issued'] as $row)
|
||||
<li class="flex justify-between gap-3">
|
||||
<span>{{ $row['product'] }} <span class="text-slate-400">×{{ $row['issues'] }}</span></span>
|
||||
<span class="tabular-nums text-slate-600">{{ $row['total'] }} units</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-slate-500">No issues in range.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Blood Bank service revenue</h2>
|
||||
<ul class="mt-3 space-y-2 text-sm">
|
||||
@forelse ($report['revenue_by_service'] as $row)
|
||||
<li class="flex justify-between gap-3">
|
||||
<span>{{ $row->description }} <span class="text-slate-400">×{{ $row->total }}</span></span>
|
||||
<span class="tabular-nums">{{ $currency }} {{ number_format($row->amount_minor / 100, 2) }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-slate-500">No Blood Bank bill lines in range.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -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
|
||||
|
||||
<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">Product issue</h3>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Confirm units issued and optionally bill Blood Bank catalog services.</p>
|
||||
</div>
|
||||
@if ($issue)
|
||||
<span class="rounded-lg bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-800">Issued recorded</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($canManage)
|
||||
<form method="POST" action="{{ route('care.specialty.blood-bank.issue', $workspaceVisit) }}" class="mt-4 space-y-4">
|
||||
@csrf
|
||||
<input type="hidden" name="tab" value="issue">
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Product</label>
|
||||
<select name="payload[product]" required class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
<option value="">Select…</option>
|
||||
@foreach (['Whole blood', 'Packed RBC', 'Platelets', 'FFP', 'Cryoprecipitate'] as $option)
|
||||
<option value="{{ $option }}" @selected(old('payload.product', $defaults['product']) === $option)>{{ $option }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Units issued</label>
|
||||
<input type="number" name="payload[units_issued]" min="1" required value="{{ old('payload.units_issued', $defaults['units_issued']) }}" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Bag / unit numbers</label>
|
||||
<input type="text" name="payload[bag_numbers]" value="{{ old('payload.bag_numbers', $defaults['bag_numbers']) }}" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Issued to (ward / clinician)</label>
|
||||
<input type="text" name="payload[issued_to]" value="{{ old('payload.issued_to', $defaults['issued_to']) }}" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Issued at</label>
|
||||
<input type="text" name="payload[issued_at]" value="{{ old('payload.issued_at', $defaults['issued_at']) }}" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Notes</label>
|
||||
<textarea name="payload[notes]" rows="2" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">{{ old('payload.notes', $defaults['notes']) }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="bill_product" value="1" checked class="rounded border-slate-300">
|
||||
Bill product units (bb.*)
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="bill_crossmatch" value="1" class="rounded border-slate-300">
|
||||
Bill cross-match
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Confirm issue</button>
|
||||
</form>
|
||||
@elseif ($issue)
|
||||
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-slate-500">Product</dt>
|
||||
<dd class="font-medium text-slate-900">{{ $payload['product'] ?? '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Units issued</dt>
|
||||
<dd class="font-medium text-slate-900">{{ $payload['units_issued'] ?? '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Bag numbers</dt>
|
||||
<dd class="font-medium text-slate-900">{{ $payload['bag_numbers'] ?? '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Issued to</dt>
|
||||
<dd class="font-medium text-slate-900">{{ $payload['issued_to'] ?? '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@else
|
||||
<p class="mt-3 text-sm text-slate-500">No issue recorded yet.</p>
|
||||
@endif
|
||||
</section>
|
||||
@@ -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
|
||||
|
||||
<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">Blood Bank overview</h3>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Open request, urgency, cross-match status, and visit stage.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 text-sm">
|
||||
<a href="{{ route('care.specialty.blood-bank.print', $workspaceVisit) }}" target="_blank" class="font-medium text-indigo-600">Print summary</a>
|
||||
<a href="{{ route('care.specialty.blood-bank.reports') }}" class="font-medium text-indigo-600">BB reports</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Request</p>
|
||||
<p class="mt-1 text-sm font-semibold text-slate-900">{{ $payload['product'] ?? 'No request' }}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
{{ isset($payload['units']) ? $payload['units'].' unit(s)' : '—' }}
|
||||
· {{ $payload['patient_blood_group'] ?? 'Group —' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Urgency / cross-match</p>
|
||||
<p @class([
|
||||
'mt-1 text-sm font-semibold',
|
||||
'text-rose-700' => $isHighUrgency,
|
||||
'text-slate-900' => ! $isHighUrgency,
|
||||
])>
|
||||
{{ $urgency !== '' ? $urgency : 'Not set' }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ $payload['crossmatch_status'] ?? 'Cross-match not started' }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Issued</p>
|
||||
<p class="mt-1 text-sm font-semibold text-slate-900">
|
||||
{{ $issuePayload['units_issued'] ?? $payload['issued_units'] ?? '0' }} unit(s)
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
{{ $issuePayload['product'] ?? ($payload['product'] ?? '—') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Visit stage</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-900">
|
||||
{{ $currentStage ? ucfirst(str_replace('_', ' ', $currentStage)) : 'Not set' }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
@foreach (($bloodBankStageCodes ?: ['request', 'crossmatch', 'issue', 'transfusion', 'completed']) as $stageCode)
|
||||
@if ($canManage)
|
||||
<form method="POST" action="{{ route('care.specialty.blood-bank.stage', $workspaceVisit) }}">
|
||||
@csrf
|
||||
<input type="hidden" name="stage" value="{{ $stageCode }}">
|
||||
<button type="submit"
|
||||
@class([
|
||||
'rounded-lg px-2.5 py-1 text-xs font-semibold',
|
||||
'bg-indigo-600 text-white' => $currentStage === $stageCode,
|
||||
'border border-slate-200 bg-white text-slate-700 hover:bg-slate-50' => $currentStage !== $stageCode,
|
||||
])>
|
||||
{{ ucfirst(str_replace('_', ' ', $stageCode)) }}
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<span
|
||||
@class([
|
||||
'rounded-lg px-2.5 py-1 text-xs font-semibold',
|
||||
'bg-indigo-600 text-white' => $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)) }}
|
||||
</span>
|
||||
@endif
|
||||
@endforeach
|
||||
@if ($canManage && $currentStage && isset($stageFlow[$currentStage]) && $stageFlow[$currentStage]['next'] !== $currentStage)
|
||||
<form method="POST" action="{{ route('care.specialty.blood-bank.stage', $workspaceVisit) }}">
|
||||
@csrf
|
||||
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
|
||||
<button type="submit" class="rounded-lg bg-emerald-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-emerald-700">
|
||||
{{ $stageFlow[$currentStage]['label'] }} →
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-slate-500">Indication</dt>
|
||||
<dd class="font-medium text-slate-900">{{ $payload['indication'] ?? '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Inventory flag</dt>
|
||||
<dd class="font-medium text-slate-900">
|
||||
@if (! empty($inventoryPayload['low_stock_alert']))
|
||||
<span class="text-amber-800">Low stock flagged</span>
|
||||
@else
|
||||
{{ isset($inventoryPayload['packed_rbc_units']) ? 'Stock noted' : '—' }}
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Queue</dt>
|
||||
<dd class="font-medium text-slate-900">
|
||||
{{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }}
|
||||
@if ($workspaceVisit->appointment?->queue_ticket_status)
|
||||
<span class="text-slate-500">({{ $workspaceVisit->appointment->queue_ticket_status }})</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Checked in</dt>
|
||||
<dd class="font-medium text-slate-900">{{ $workspaceVisit->checked_in_at?->format('d M Y H:i') ?? '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if (! empty($clinicalAlerts))
|
||||
<div class="mt-4 space-y-1.5 border-t border-slate-100 pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">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>
|
||||
@@ -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
|
||||
|
||||
<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">Transfusion</h3>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Document transfusion progress, reactions, and outcome.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($canManage)
|
||||
<form method="POST" action="{{ route('care.specialty.blood-bank.transfusion', $workspaceVisit) }}" class="mt-4 space-y-4">
|
||||
@csrf
|
||||
<input type="hidden" name="tab" value="transfusion">
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Started at</label>
|
||||
<input type="text" name="payload[started_at]" value="{{ old('payload.started_at', $defaults['started_at']) }}" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Units transfused</label>
|
||||
<input type="number" name="payload[units_transfused]" min="0" value="{{ old('payload.units_transfused', $defaults['units_transfused']) }}" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Reaction</label>
|
||||
<select name="payload[reaction]" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
@foreach (['None', 'Mild (fever / itch)', 'Moderate', 'Severe / stop transfusion'] as $option)
|
||||
<option value="{{ $option }}" @selected(old('payload.reaction', $defaults['reaction']) === $option)>{{ $option }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Outcome</label>
|
||||
<select name="payload[outcome]" required class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
<option value="">Select…</option>
|
||||
@foreach (['In progress', 'Completed uneventfully', 'Stopped — reaction', 'Stopped — other'] as $option)
|
||||
<option value="{{ $option }}" @selected(old('payload.outcome', $defaults['outcome']) === $option)>{{ $option }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="inline-flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="payload[vitals_ok]" value="1" @checked(old('payload.vitals_ok', $defaults['vitals_ok'])) class="rounded border-slate-300">
|
||||
Pre-/intra-transfusion vitals acceptable
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Notes</label>
|
||||
<textarea name="payload[notes]" rows="3" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">{{ old('payload.notes', $defaults['notes']) }}</textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Save transfusion</button>
|
||||
</form>
|
||||
@elseif ($transfusion)
|
||||
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-slate-500">Outcome</dt>
|
||||
<dd class="font-medium text-slate-900">{{ $payload['outcome'] ?? '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Reaction</dt>
|
||||
<dd class="font-medium text-slate-900">{{ $payload['reaction'] ?? '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Units transfused</dt>
|
||||
<dd class="font-medium text-slate-900">{{ $payload['units_transfused'] ?? '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Notes</dt>
|
||||
<dd class="font-medium text-slate-900">{{ $payload['notes'] ?? '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@else
|
||||
<p class="mt-3 text-sm text-slate-500">No transfusion record yet.</p>
|
||||
@endif
|
||||
</section>
|
||||
@@ -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]))
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}" class="w-full" @click.stop>
|
||||
@if ($canAdvanceStage && $stageRoute && $currentStage && isset($stageFlow[$currentStage]) && ($stageFlow[$currentStage]['next'] ?? null) !== $currentStage)
|
||||
<form method="POST" action="{{ route($stageRoute, $workspaceVisit) }}" class="w-full" @click.stop>
|
||||
@csrf
|
||||
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
|
||||
<button type="submit" class="{{ $btnAccent }}">{{ $stageFlow[$currentStage]['label'] }}</button>
|
||||
</form>
|
||||
@elseif ($canAdvanceStage && $moduleKey === 'dentistry' && ! $currentStage)
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}" class="w-full" @click.stop>
|
||||
@elseif ($canAdvanceStage && $stageRoute && ! $currentStage && $defaultStartStage)
|
||||
<form method="POST" action="{{ route($stageRoute, $workspaceVisit) }}" class="w-full" @click.stop>
|
||||
@csrf
|
||||
<input type="hidden" name="stage" value="chair">
|
||||
<button type="submit" class="{{ $btnAccent }}">Seat at chair</button>
|
||||
</form>
|
||||
@elseif ($canAdvanceStage && $moduleKey === 'emergency' && $currentStage && isset($stageFlow[$currentStage]) && ($stageFlow[$currentStage]['next'] ?? null) !== $currentStage)
|
||||
<form method="POST" action="{{ route('care.specialty.emergency.stage', $workspaceVisit) }}" class="w-full" @click.stop>
|
||||
@csrf
|
||||
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
|
||||
<button type="submit" class="{{ $btnAccent }}">{{ $stageFlow[$currentStage]['label'] }}</button>
|
||||
</form>
|
||||
@elseif ($canAdvanceStage && $moduleKey === 'emergency' && ! $currentStage)
|
||||
<form method="POST" action="{{ route('care.specialty.emergency.stage', $workspaceVisit) }}" class="w-full" @click.stop>
|
||||
@csrf
|
||||
<input type="hidden" name="stage" value="arrival">
|
||||
<button type="submit" class="{{ $btnAccent }}">Start triage</button>
|
||||
<input type="hidden" name="stage" value="{{ $defaultStartStage }}">
|
||||
<button type="submit" class="{{ $btnAccent }}">{{ $defaultStartLabel }}</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -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')
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h3 class="text-sm font-semibold text-slate-900">
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
@if ($moduleKey === 'emergency')
|
||||
<a href="{{ route('care.specialty.emergency.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
|
||||
@endif
|
||||
@if ($moduleKey === 'blood_bank')
|
||||
<a href="{{ route('care.specialty.blood-bank.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
|
||||
@endif
|
||||
<a href="{{ route('care.specialty.history', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">History</a>
|
||||
@if ($canManageClinical ?? $canManageSpecialty ?? true)
|
||||
<a href="{{ route('care.specialty.billing', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">Billing</a>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<?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 CareBloodBankSuiteTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $owner;
|
||||
|
||||
protected Organization $organization;
|
||||
|
||||
protected Branch $branch;
|
||||
|
||||
protected Patient $patient;
|
||||
|
||||
protected Visit $visit;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user