Add full Blood Bank specialty suite on shared shell.
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:
isaacclad
2026-07-18 21:36:57 +00:00
co-authored by Cursor
parent 847d63c1c2
commit f688a48c37
19 changed files with 1590 additions and 30 deletions
@@ -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;
}
+13 -3
View File
@@ -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,
@@ -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)) {
+19
View File
@@ -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' => [
],
+4
View File
@@ -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>
+6
View File
@@ -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');
+298
View File
@@ -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());
}
}