From 5d9d333170cdd098804d2807beaa5ddcf5736137 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sun, 19 Jul 2026 14:18:47 +0000 Subject: [PATCH] Add Lab and Blood Bank manager roles with admin UIs. Give lab_manager catalog/ops admin access and blood_bank_manager a branch-level stock register plus specialty admin surface, while keeping technicians on clinical queues. Co-authored-by: Cursor --- .../Care/BloodBankAdminController.php | 96 +++++++++++ .../Care/BloodBankWorkspaceController.php | 38 ++++- .../Controllers/Care/LabAdminController.php | 76 +++++++++ .../Controllers/Care/MemberController.php | 2 +- .../Care/PractitionerController.php | 2 +- .../Care/ServiceQueueController.php | 3 +- .../Care/SpecialtyModuleController.php | 5 +- app/Models/BloodBankStock.php | 41 +++++ .../BloodBank/BloodBankInventoryService.php | 149 ++++++++++++++++++ app/Services/Care/BranchContext.php | 2 + app/Services/Care/CarePermissions.php | 18 ++- app/Support/DemoWorld.php | 28 ++++ config/care.php | 2 + config/care_specialty_modules.php | 7 +- ...000_create_care_blood_bank_stock_table.php | 36 +++++ .../care/blood-bank/admin/index.blade.php | 120 ++++++++++++++ .../views/care/lab/admin/index.blade.php | 95 +++++++++++ .../views/care/lab/catalog/index.blade.php | 7 +- .../views/care/lab/queue/index.blade.php | 1 + resources/views/care/settings/edit.blade.php | 12 +- .../blood-bank/workspace-overview.blade.php | 2 +- .../views/care/specialty/shell.blade.php | 8 + resources/views/partials/sidebar.blade.php | 8 +- routes/web.php | 6 + tests/Feature/CareBloodBankSuiteTest.php | 78 +++++++++ tests/Feature/CareLabTest.php | 44 ++++++ 26 files changed, 869 insertions(+), 17 deletions(-) create mode 100644 app/Http/Controllers/Care/BloodBankAdminController.php create mode 100644 app/Http/Controllers/Care/LabAdminController.php create mode 100644 app/Models/BloodBankStock.php create mode 100644 app/Services/Care/BloodBank/BloodBankInventoryService.php create mode 100644 database/migrations/2026_07_19_140000_create_care_blood_bank_stock_table.php create mode 100644 resources/views/care/blood-bank/admin/index.blade.php create mode 100644 resources/views/care/lab/admin/index.blade.php diff --git a/app/Http/Controllers/Care/BloodBankAdminController.php b/app/Http/Controllers/Care/BloodBankAdminController.php new file mode 100644 index 0000000..97768f6 --- /dev/null +++ b/app/Http/Controllers/Care/BloodBankAdminController.php @@ -0,0 +1,96 @@ +authorizeAbility($request, 'blood_bank.manage'); + $organization = $this->organization($request); + abort_unless($modules->isEnabled($organization, 'blood_bank'), 404); + + $member = $this->member($request); + $branches = $this->branchContext->branches($request, $organization, $member); + $branchId = $this->branchContext->resolve($request, $organization, $member); + $branchScope = $branchId > 0 ? $branchId : null; + + $overview = $this->inventory->adminOverview( + $organization, + $this->ownerRef($request), + $branchScope, + ); + + $report = $this->analytics->report( + $organization, + $this->ownerRef($request), + $branchScope, + ); + + return view('care.blood-bank.admin.index', [ + 'organization' => $organization, + 'branches' => $branches, + 'branchId' => $branchId, + 'canSwitchBranch' => $this->branchContext->canSwitch($member), + 'overview' => $overview, + 'report' => $report, + 'isAdmin' => app(CarePermissions::class)->isAdmin($member), + ]); + } + + public function updateStock( + Request $request, + SpecialtyModuleService $modules, + ): RedirectResponse { + $this->authorizeAbility($request, 'blood_bank.manage'); + $organization = $this->organization($request); + abort_unless($modules->isEnabled($organization, 'blood_bank'), 404); + + $member = $this->member($request); + $branchId = $this->branchContext->resolve($request, $organization, $member); + $branchScope = $branchId > 0 ? $branchId : null; + + $validated = $request->validate([ + 'stock' => ['required', 'array'], + 'stock.*.product_code' => ['required', 'string', 'max:64'], + 'stock.*.units_on_hand' => ['required', 'integer', 'min:0', 'max:99999'], + 'stock.*.reorder_level' => ['required', 'integer', 'min:0', 'max:9999'], + 'stock.*.notes' => ['nullable', 'string', 'max:2000'], + ]); + + $this->inventory->updateStock( + $organization, + $this->ownerRef($request), + $branchScope, + array_values($validated['stock']), + $this->actorRef($request), + ); + + return redirect() + ->route('care.blood-bank.admin.index', array_filter([ + 'branch_id' => $branchId > 0 ? $branchId : null, + ])) + ->with('success', 'Blood Bank operational inventory updated.'); + } +} diff --git a/app/Http/Controllers/Care/BloodBankWorkspaceController.php b/app/Http/Controllers/Care/BloodBankWorkspaceController.php index 2aafbef..e1c537b 100644 --- a/app/Http/Controllers/Care/BloodBankWorkspaceController.php +++ b/app/Http/Controllers/Care/BloodBankWorkspaceController.php @@ -8,6 +8,7 @@ use App\Models\SpecialtyClinicalRecord; use App\Models\Visit; use App\Services\Care\BloodBank\BloodBankAnalyticsService; use App\Services\Care\BloodBank\BloodBankWorkflowService; +use App\Services\Care\CarePermissions; use App\Services\Care\SpecialtyClinicalRecordService; use App\Services\Care\SpecialtyModuleService; use App\Services\Care\SpecialtyShellService; @@ -38,13 +39,40 @@ class BloodBankWorkspaceController extends Controller $this->authorizeBranch($request, (int) $visit->branch_id); } + /** + * Doctors use consultations.manage; Blood Bank managers use blood_bank.manage. + */ + protected function authorizeBloodBankClinical(Request $request): void + { + $permissions = app(CarePermissions::class); + $member = $this->member($request); + abort_unless( + $permissions->can($member, 'blood_bank.manage') + || $permissions->can($member, 'consultations.manage'), + 403, + ); + } + + protected function authorizeBloodBankViewAbility(Request $request): void + { + $permissions = app(CarePermissions::class); + $member = $this->member($request); + abort_unless( + $permissions->can($member, 'blood_bank.view') + || $permissions->can($member, 'blood_bank.manage') + || $permissions->can($member, 'consultations.view') + || $permissions->can($member, 'consultations.manage'), + 403, + ); + } + public function setStage( Request $request, Visit $visit, SpecialtyModuleService $modules, SpecialtyVisitStageService $stages, ): RedirectResponse { - $this->authorizeAbility($request, 'consultations.manage'); + $this->authorizeBloodBankClinical($request); $this->assertBloodBankManage($request, $modules); $this->assertVisit($request, $visit); @@ -79,7 +107,7 @@ class BloodBankWorkspaceController extends Controller SpecialtyShellService $shell, BloodBankWorkflowService $workflow, ): RedirectResponse { - $this->authorizeAbility($request, 'consultations.manage'); + $this->authorizeBloodBankClinical($request); $this->assertBloodBankManage($request, $modules); $this->assertVisit($request, $visit); @@ -187,7 +215,7 @@ class BloodBankWorkspaceController extends Controller SpecialtyVisitStageService $stages, BloodBankWorkflowService $workflow, ): RedirectResponse { - $this->authorizeAbility($request, 'consultations.manage'); + $this->authorizeBloodBankClinical($request); $this->assertBloodBankManage($request, $modules); $this->assertVisit($request, $visit); @@ -271,7 +299,7 @@ class BloodBankWorkspaceController extends Controller SpecialtyShellService $shell, BloodBankAnalyticsService $analytics, ): View { - $this->authorizeAbility($request, 'consultations.view'); + $this->authorizeBloodBankViewAbility($request); $this->assertBloodBankAccess($request, $modules); $organization = $this->organization($request); @@ -300,7 +328,7 @@ class BloodBankWorkspaceController extends Controller SpecialtyModuleService $modules, SpecialtyClinicalRecordService $clinical, ): View { - $this->authorizeAbility($request, 'consultations.view'); + $this->authorizeBloodBankViewAbility($request); $this->assertBloodBankAccess($request, $modules); $this->assertVisit($request, $visit); diff --git a/app/Http/Controllers/Care/LabAdminController.php b/app/Http/Controllers/Care/LabAdminController.php new file mode 100644 index 0000000..c5d8b0e --- /dev/null +++ b/app/Http/Controllers/Care/LabAdminController.php @@ -0,0 +1,76 @@ +authorizeAbility($request, 'lab.catalog.manage'); + $organization = $this->organization($request); + $member = $this->member($request); + $owner = $this->ownerRef($request); + $branches = $this->branchContext->branches($request, $organization, $member); + $branchId = $this->branchContext->resolve($request, $organization, $member); + $branchScope = $branchId > 0 ? $branchId : null; + + $catalogCount = InvestigationType::query() + ->where('organization_id', $organization->id) + ->count(); + + $activeCatalogCount = InvestigationType::query() + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->count(); + + $queueCounts = InvestigationRequest::owned($owner) + ->where('organization_id', $organization->id) + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->whereIn('status', [ + InvestigationRequest::STATUS_PENDING, + InvestigationRequest::STATUS_SAMPLE_COLLECTED, + InvestigationRequest::STATUS_IN_PROGRESS, + InvestigationRequest::STATUS_AWAITING_REVIEW, + ]) + ->select('status', DB::raw('count(*) as total')) + ->groupBy('status') + ->pluck('total', 'status'); + + $openQueue = (int) $queueCounts->sum(); + $queue = $branchScope + ? $this->investigations->workQueue($owner, $branchScope) + : collect(); + + return view('care.lab.admin.index', [ + 'organization' => $organization, + 'branches' => $branches, + 'branchId' => $branchId, + 'canSwitchBranch' => $this->branchContext->canSwitch($member), + 'catalogCount' => $catalogCount, + 'activeCatalogCount' => $activeCatalogCount, + 'openQueue' => $openQueue, + 'queueCounts' => $queueCounts, + 'recentQueue' => $queue->take(8), + 'statuses' => config('care.investigation_statuses'), + 'isAdmin' => app(CarePermissions::class)->isAdmin($member), + ]); + } +} diff --git a/app/Http/Controllers/Care/MemberController.php b/app/Http/Controllers/Care/MemberController.php index 97a88b0..66380e2 100644 --- a/app/Http/Controllers/Care/MemberController.php +++ b/app/Http/Controllers/Care/MemberController.php @@ -31,7 +31,7 @@ class MemberController extends Controller ->get(); $adminRoles = ['super_admin', 'hospital_admin']; - $clinicalRoles = ['doctor', 'nurse', 'lab_technician', 'pharmacist']; + $clinicalRoles = ['doctor', 'nurse', 'lab_technician', 'lab_manager', 'pharmacist', 'blood_bank_manager']; $heroStats = [ 'total' => $members->count(), diff --git a/app/Http/Controllers/Care/PractitionerController.php b/app/Http/Controllers/Care/PractitionerController.php index b73c499..12ba8ad 100644 --- a/app/Http/Controllers/Care/PractitionerController.php +++ b/app/Http/Controllers/Care/PractitionerController.php @@ -210,7 +210,7 @@ class PractitionerController extends Controller { return Member::owned($owner) ->where('organization_id', $organizationId) - ->whereIn('role', ['doctor', 'nurse', 'lab_technician', 'pharmacist', 'hospital_admin', 'super_admin']) + ->whereIn('role', ['doctor', 'nurse', 'lab_technician', 'lab_manager', 'pharmacist', 'blood_bank_manager', 'hospital_admin', 'super_admin']) ->orderBy('user_ref') ->get(); } diff --git a/app/Http/Controllers/Care/ServiceQueueController.php b/app/Http/Controllers/Care/ServiceQueueController.php index 909a9fe..4dfdf26 100644 --- a/app/Http/Controllers/Care/ServiceQueueController.php +++ b/app/Http/Controllers/Care/ServiceQueueController.php @@ -60,7 +60,8 @@ class ServiceQueueController extends Controller return match ($member?->role) { 'cashier' => 'care.bills.index', 'pharmacist' => 'care.prescriptions.queue', - 'lab_technician' => 'care.lab.queue.index', + 'lab_technician', 'lab_manager' => 'care.lab.queue.index', + 'blood_bank_manager' => 'care.blood-bank.admin.index', 'receptionist', 'nurse' => 'care.appointments.index', default => 'care.dashboard', }; diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index f2b53b4..5ffbad4 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -670,7 +670,10 @@ class SpecialtyModuleController extends Controller // Ability-based for every role (doctor, nurse, receptionist, lab, pharmacist, …). // Module canManage alone is not enough — flags must match what mutate routes authorize. $canConsult = $permissions->can($member, 'consultations.manage') && $canManageSpecialty; - $canAdvanceStage = $canConsult; + $canAdvanceStage = $canConsult + || ($module === 'blood_bank' + && $canManageSpecialty + && $permissions->can($member, 'blood_bank.manage')); $canStartConsultation = $canConsult; $canCallNext = $canManageSpecialty; $canManageClinical = $canManageSpecialty; diff --git a/app/Models/BloodBankStock.php b/app/Models/BloodBankStock.php new file mode 100644 index 0000000..f044563 --- /dev/null +++ b/app/Models/BloodBankStock.php @@ -0,0 +1,41 @@ +belongsTo(Organization::class, 'organization_id'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class, 'branch_id'); + } + + public function isLowStock(): bool + { + return (int) $this->units_on_hand <= (int) $this->reorder_level; + } +} diff --git a/app/Services/Care/BloodBank/BloodBankInventoryService.php b/app/Services/Care/BloodBank/BloodBankInventoryService.php new file mode 100644 index 0000000..622fa0b --- /dev/null +++ b/app/Services/Care/BloodBank/BloodBankInventoryService.php @@ -0,0 +1,149 @@ + + */ + public function catalogProducts(): array + { + return [ + 'whole_blood' => ['label' => 'Whole blood', 'reorder_level' => 2], + 'packed_rbc' => ['label' => 'Packed RBC', 'reorder_level' => 2], + 'platelets' => ['label' => 'Platelets', 'reorder_level' => 2], + 'ffp' => ['label' => 'FFP', 'reorder_level' => 2], + 'cryo' => ['label' => 'Cryoprecipitate', 'reorder_level' => 1], + ]; + } + + /** + * Ensure catalog rows exist for the branch, then return them ordered. + * + * @return Collection + */ + public function ensureStockRows( + Organization $organization, + string $ownerRef, + ?int $branchId, + ): Collection { + foreach ($this->catalogProducts() as $code => $meta) { + BloodBankStock::query()->firstOrCreate( + [ + 'organization_id' => $organization->id, + 'branch_id' => $branchId, + 'product_code' => $code, + ], + [ + 'owner_ref' => $ownerRef, + 'product_label' => $meta['label'], + 'units_on_hand' => 0, + 'reorder_level' => $meta['reorder_level'], + ], + ); + } + + return BloodBankStock::owned($ownerRef) + ->where('organization_id', $organization->id) + ->when( + $branchId === null, + fn ($q) => $q->whereNull('branch_id'), + fn ($q) => $q->where('branch_id', $branchId), + ) + ->orderBy('product_label') + ->get(); + } + + /** + * @param list $rows + * @return Collection + */ + public function updateStock( + Organization $organization, + string $ownerRef, + ?int $branchId, + array $rows, + ?string $actorRef = null, + ): Collection { + $allowed = array_keys($this->catalogProducts()); + + return DB::transaction(function () use ($organization, $ownerRef, $branchId, $rows, $actorRef, $allowed) { + $this->ensureStockRows($organization, $ownerRef, $branchId); + + foreach ($rows as $row) { + $code = (string) ($row['product_code'] ?? ''); + if (! in_array($code, $allowed, true)) { + continue; + } + + $stock = BloodBankStock::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('product_code', $code) + ->when( + $branchId === null, + fn ($q) => $q->whereNull('branch_id'), + fn ($q) => $q->where('branch_id', $branchId), + ) + ->first(); + + if (! $stock) { + continue; + } + + $stock->update([ + 'units_on_hand' => max(0, (int) ($row['units_on_hand'] ?? 0)), + 'reorder_level' => max(0, (int) ($row['reorder_level'] ?? $stock->reorder_level)), + 'notes' => isset($row['notes']) ? (string) $row['notes'] : $stock->notes, + 'updated_by' => $actorRef, + ]); + } + + return $this->ensureStockRows($organization, $ownerRef, $branchId); + }); + } + + /** + * @return array{ + * stock: Collection, + * low_stock: Collection, + * low_stock_count: int, + * total_units: int, + * recent_notes: Collection + * } + */ + public function adminOverview( + Organization $organization, + string $ownerRef, + ?int $branchId, + ): array { + $stock = $this->ensureStockRows($organization, $ownerRef, $branchId); + $lowStock = $stock->filter(fn (BloodBankStock $row) => $row->isLowStock())->values(); + + $recentNotes = 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)) + ->with(['visit.patient']) + ->orderByDesc('recorded_at') + ->limit(8) + ->get(); + + return [ + 'stock' => $stock, + 'low_stock' => $lowStock, + 'low_stock_count' => $lowStock->count(), + 'total_units' => (int) $stock->sum('units_on_hand'), + 'recent_notes' => $recentNotes, + ]; + } +} diff --git a/app/Services/Care/BranchContext.php b/app/Services/Care/BranchContext.php index 5ff65a8..02efa15 100644 --- a/app/Services/Care/BranchContext.php +++ b/app/Services/Care/BranchContext.php @@ -38,6 +38,8 @@ class BranchContext 'nurse', 'pharmacist', 'lab_technician', + 'lab_manager', + 'blood_bank_manager', 'cashier', 'receptionist', ], true)) { diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index 930cd4f..2be5d0b 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -9,10 +9,13 @@ class CarePermissions /** * Role abilities. * - * Lab catalog pricing uses `lab.catalog.manage` (admins only via `*`). + * Lab catalog pricing uses `lab.catalog.manage` (admins via `*`, plus `lab_manager`). * Lab technicians keep `lab.manage` for queue / sample / process / results — * not catalog CRUD or price edits. * + * Blood Bank operational inventory uses `blood_bank.manage` (managers + admins). + * Clinical visit stage/issue for managers also requires specialty module manage. + * * @var array> */ protected array $roleAbilities = [ @@ -42,11 +45,21 @@ class CarePermissions 'dashboard.view', 'patients.view', 'lab.view', 'lab.manage', 'service_queues.console', ], + 'lab_manager' => [ + 'dashboard.view', 'patients.view', 'lab.view', 'lab.manage', + 'lab.catalog.manage', + 'service_queues.console', + ], 'pharmacist' => [ 'dashboard.view', 'patients.view', 'prescriptions.view', 'prescriptions.dispense', 'pharmacy.view', 'pharmacy.manage', 'service_queues.console', ], + 'blood_bank_manager' => [ + 'dashboard.view', 'patients.view', + 'blood_bank.view', 'blood_bank.manage', + 'service_queues.console', + ], // Cashiers run the Billing desk (bills / payments). They do not get // Ladill POS app access — see filterAllowedAppSlugs() / DemoWorld. 'cashier' => [ @@ -67,6 +80,7 @@ class CarePermissions 'admin.members.view', 'admin.members.manage', 'settings.view', 'settings.manage', 'lab.catalog.manage', + 'blood_bank.view', 'blood_bank.manage', 'devices.view', 'devices.manage', 'displays.view', 'displays.manage', 'audit.view', 'audit.export', @@ -106,7 +120,9 @@ class CarePermissions 'doctor', 'nurse', 'lab_technician', + 'lab_manager', 'pharmacist', + 'blood_bank_manager', 'receptionist', 'cashier', ], true); diff --git a/app/Support/DemoWorld.php b/app/Support/DemoWorld.php index d41833d..71b90cb 100644 --- a/app/Support/DemoWorld.php +++ b/app/Support/DemoWorld.php @@ -282,6 +282,20 @@ final class DemoWorld 'apps' => ['care'], 'roles' => ['care' => 'lab_technician'], ], + [ + 'key' => 'lab_manager', + 'email' => 'demo-pro-lab-manager@ladill.com', + 'name' => 'Ama Lab Manager (Pro)', + 'apps' => ['care'], + 'roles' => ['care' => 'lab_manager'], + ], + [ + 'key' => 'blood_bank_manager', + 'email' => 'demo-pro-blood-bank-manager@ladill.com', + 'name' => 'Kofi Blood Bank Manager (Pro)', + 'apps' => ['care'], + 'roles' => ['care' => 'blood_bank_manager'], + ], [ 'key' => 'cashier', 'email' => 'demo-pro-cashier@ladill.com', @@ -370,6 +384,20 @@ final class DemoWorld 'apps' => ['care'], 'roles' => ['care' => 'lab_technician'], ], + [ + 'key' => 'lab_manager', + 'email' => 'demo-enterprise-lab-manager@ladill.com', + 'name' => 'Ama Lab Manager', + 'apps' => ['care'], + 'roles' => ['care' => 'lab_manager'], + ], + [ + 'key' => 'blood_bank_manager', + 'email' => 'demo-enterprise-blood-bank-manager@ladill.com', + 'name' => 'Kofi Blood Bank Manager', + 'apps' => ['care'], + 'roles' => ['care' => 'blood_bank_manager'], + ], [ 'key' => 'cashier', 'email' => 'demo-enterprise-cashier@ladill.com', diff --git a/config/care.php b/config/care.php index 4ef4c6c..6b7ae00 100644 --- a/config/care.php +++ b/config/care.php @@ -9,7 +9,9 @@ return [ 'doctor' => 'Doctor', 'nurse' => 'Nurse', 'lab_technician' => 'Laboratory Technician', + 'lab_manager' => 'Laboratory Manager', 'pharmacist' => 'Pharmacist', + 'blood_bank_manager' => 'Blood Bank Manager', 'cashier' => 'Cashier', 'accountant' => 'Accountant', ], diff --git a/config/care_specialty_modules.php b/config/care_specialty_modules.php index 3bcc494..da5f2dd 100644 --- a/config/care_specialty_modules.php +++ b/config/care_specialty_modules.php @@ -4,12 +4,15 @@ * Specialty practice modules (Settings → Modules). * Pro-gated via plans.*.features specialty_modules. * - * Access tiers (RBAC via Care member roles — do not invent new roles): + * Access tiers (RBAC via Care member roles): * - general: broad day-to-day access for listed roles (GPs = doctor without specialty desk) * - limited: GPs + nurses (and listed roles); desk specialists only when specialty keywords match * - restricted: specialist doctors (department / specialty keywords) + optional support_roles; * GPs use view_roles / refer_roles; desk specialists only see matching modules * + * Prefer existing clinical role slugs on allowlists. Dedicated managers (e.g. blood_bank_manager, + * lab_manager) are intentional product roles — add them to the relevant module roles lists. + * * Desk specialists (doctor whose practitioner specialty matches specialist_keywords, or who is * assigned to the module department) only see modules relevant to their work — non-matching * modules are access "none" (hidden from sidebar). Nurses / receptionists keep role allowlists. @@ -44,7 +47,7 @@ return [ 'icon' => 'droplet', 'queue_keywords' => ['blood', 'transfusion', 'crossmatch', 'donor'], 'access' => 'general', - 'roles' => ['lab_technician', 'nurse', 'doctor', 'receptionist'], + 'roles' => ['blood_bank_manager', 'lab_technician', 'nurse', 'doctor', 'receptionist'], 'default_on_paid_plans' => true, ], 'dentistry' => [ diff --git a/database/migrations/2026_07_19_140000_create_care_blood_bank_stock_table.php b/database/migrations/2026_07_19_140000_create_care_blood_bank_stock_table.php new file mode 100644 index 0000000..9a9b0a0 --- /dev/null +++ b/database/migrations/2026_07_19_140000_create_care_blood_bank_stock_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->nullable()->constrained('care_branches')->nullOnDelete(); + $table->string('product_code', 64); + $table->string('product_label'); + $table->unsignedInteger('units_on_hand')->default(0); + $table->unsignedInteger('reorder_level')->default(2); + $table->text('notes')->nullable(); + $table->string('updated_by')->nullable(); + $table->timestamps(); + + $table->unique( + ['organization_id', 'branch_id', 'product_code'], + 'care_bb_stock_org_branch_product_unique', + ); + $table->index(['organization_id', 'branch_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_blood_bank_stock'); + } +}; diff --git a/resources/views/care/blood-bank/admin/index.blade.php b/resources/views/care/blood-bank/admin/index.blade.php new file mode 100644 index 0000000..38b2300 --- /dev/null +++ b/resources/views/care/blood-bank/admin/index.blade.php @@ -0,0 +1,120 @@ + +
+ + + Clinical list + Reports + + + + @if (($canSwitchBranch ?? false) && isset($branches) && $branches->count() > 1) +
+ + +
+ @elseif (isset($branches) && $branches->isNotEmpty()) +

Branch: {{ $branches->firstWhere('id', $branchId)?->name ?? $branches->first()->name }}

+ @endif + + @if ($overview['low_stock']->isNotEmpty()) +
+ {{ $overview['low_stock']->count() }} product(s) at or below reorder level. +
+ @endif + +
+
+
+

Operational inventory

+

Branch-level units on hand — separate from visit-scoped clinical inventory notes.

+
+
+ +
+ @csrf + @if ($branchId > 0) + + @endif + +
+ + + + + + + + + + + @foreach ($overview['stock'] as $i => $row) + $row->isLowStock()])> + + + + + + @endforeach + +
ProductUnits on handReorder levelNotes
+ {{ $row->product_label }} + @if ($row->isLowStock()) + Low + @endif + + + + + + + +
+
+ +
+ +
+
+
+ +
+

Recent visit inventory notes

+

Clinical notes from Blood Bank visits (not the operational register above).

+
    + @forelse ($overview['recent_notes'] as $note) +
  • +
    +

    + {{ $note->visit?->patient?->fullName() ?? 'Visit #'.$note->visit_id }} +

    +

    + {{ $note->recorded_at?->format('d M Y H:i') ?? '—' }} + @if (! empty($note->payload['low_stock_alert'])) + · Low stock flagged + @endif +

    +
    + @if ($note->visit_id) + Open + @endif +
  • + @empty +
  • No recent inventory notes.
  • + @endforelse +
+
+
+
diff --git a/resources/views/care/lab/admin/index.blade.php b/resources/views/care/lab/admin/index.blade.php new file mode 100644 index 0000000..c977a89 --- /dev/null +++ b/resources/views/care/lab/admin/index.blade.php @@ -0,0 +1,95 @@ + +
+ + + Manage catalog + Lab queue + Add test + + + + @if (($canSwitchBranch ?? false) && isset($branches) && $branches->count() > 1) +
+ + +
+ @elseif (isset($branches) && $branches->isNotEmpty()) +

Branch: {{ $branches->firstWhere('id', $branchId)?->name ?? $branches->first()->name }}

+ @endif + +
+
+

Queue by status

+
    + @foreach ([ + \App\Models\InvestigationRequest::STATUS_PENDING, + \App\Models\InvestigationRequest::STATUS_SAMPLE_COLLECTED, + \App\Models\InvestigationRequest::STATUS_IN_PROGRESS, + \App\Models\InvestigationRequest::STATUS_AWAITING_REVIEW, + ] as $status) +
  • + {{ $statuses[$status] ?? $status }} + {{ number_format($queueCounts[$status] ?? 0) }} +
  • + @endforeach +
+ +
+ +
+

Catalog management

+

Add or edit investigation types and prices. Technicians process samples from the queue; managers own the catalog.

+ +
+
+ +
+

Current queue snapshot

+
+ + + + + + + + + + + @forelse ($recentQueue as $item) + + + + + + + @empty + + @endforelse + +
PatientTestStatus
{{ $item->patient?->fullName() ?? '—' }}{{ $item->investigationType?->name ?? '—' }}{{ $statuses[$item->status] ?? $item->status }} + Open +
No open lab work for this branch.
+
+
+
+
diff --git a/resources/views/care/lab/catalog/index.blade.php b/resources/views/care/lab/catalog/index.blade.php index b62f8b0..077e5de 100644 --- a/resources/views/care/lab/catalog/index.blade.php +++ b/resources/views/care/lab/catalog/index.blade.php @@ -2,9 +2,12 @@

Investigation catalog

-

Admins manage test types and prices. Lab technicians process samples from the laboratory queue.

+

Managers and admins set test types and prices. Lab technicians process samples from the laboratory queue.

+
+ - Add test
diff --git a/resources/views/care/lab/queue/index.blade.php b/resources/views/care/lab/queue/index.blade.php index 84a3937..548d239 100644 --- a/resources/views/care/lab/queue/index.blade.php +++ b/resources/views/care/lab/queue/index.blade.php @@ -7,6 +7,7 @@
All requests @if (app(\App\Services\Care\CarePermissions::class)->can(auth()->user() ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user(), $organization) : null, 'lab.catalog.manage')) + Lab admin Catalog @endif
diff --git a/resources/views/care/settings/edit.blade.php b/resources/views/care/settings/edit.blade.php index 5ee4600..5e113cc 100644 --- a/resources/views/care/settings/edit.blade.php +++ b/resources/views/care/settings/edit.blade.php @@ -127,12 +127,22 @@ @if (! empty($hasPaidPlan) && ($canManageLabCatalog ?? false)) +
  • + + + Laboratory admin + Queue overview, catalog entry points, and lab ops + + + +
  • Laboratory catalog - Investigation types and lab test prices (admins only) + Investigation types and lab test prices diff --git a/resources/views/care/specialty/blood-bank/workspace-overview.blade.php b/resources/views/care/specialty/blood-bank/workspace-overview.blade.php index b1d1bdc..788b09d 100644 --- a/resources/views/care/specialty/blood-bank/workspace-overview.blade.php +++ b/resources/views/care/specialty/blood-bank/workspace-overview.blade.php @@ -5,7 +5,7 @@ $inventoryPayload = $bloodBankInventory?->payload ?? []; $currentStage = $workspaceVisit->specialty_stage; $stageFlow = $bloodBankStageFlow ?? []; - $canManage = $canConsult ?? false; + $canManage = $canAdvanceStage ?? $canConsult ?? false; $urgency = (string) ($payload['urgency'] ?? ''); $isHighUrgency = str_contains(strtolower($urgency), 'urgent') || str_contains(strtolower($urgency), 'emergency') diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index f0354f0..e0f19fc 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -25,6 +25,14 @@ @endif @if ($moduleKey === 'blood_bank') Reports + @php + $bbAdminMember = auth()->user() + ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user(), $organization) + : null; + @endphp + @if (app(\App\Services\Care\CarePermissions::class)->can($bbAdminMember, 'blood_bank.manage')) + Admin + @endif @endif History @if ($canManageClinical ?? $canManageSpecialty ?? true) diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index efc9f72..4010cef 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -103,8 +103,14 @@ $adminNav = []; if (! empty($hasPaidPlan) && $permissions->can($member, 'lab.catalog.manage')) { - $adminNav[] = ['name' => 'Lab catalog', 'route' => route('care.lab.catalog.index'), 'active' => request()->routeIs('care.lab.catalog.*'), + $adminNav[] = ['name' => 'Lab admin', 'route' => route('care.lab.admin.index'), 'active' => request()->routeIs('care.lab.admin.*'), 'icon' => '']; + $adminNav[] = ['name' => 'Lab catalog', 'route' => route('care.lab.catalog.index'), 'active' => request()->routeIs('care.lab.catalog.*'), + 'icon' => '']; + } + if (! empty($hasPaidPlan) && $permissions->can($member, 'blood_bank.manage')) { + $adminNav[] = ['name' => 'Blood Bank admin', 'route' => route('care.blood-bank.admin.index'), 'active' => request()->routeIs('care.blood-bank.admin.*'), + 'icon' => '']; } if ($permissions->can($member, 'admin.departments.view')) { $adminNav[] = ['name' => 'Departments', 'route' => route('care.departments.index'), 'active' => request()->routeIs('care.departments.*'), diff --git a/routes/web.php b/routes/web.php index 194ac56..52d76a5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -37,7 +37,9 @@ 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\BloodBankAdminController; use App\Http\Controllers\Care\BloodBankWorkspaceController; +use App\Http\Controllers\Care\LabAdminController; use App\Http\Controllers\Care\EmergencyWorkspaceController; use App\Http\Controllers\Care\SpecialtyModuleController; use App\Http\Controllers\Care\VisitWorkflowController; @@ -158,6 +160,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { 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'); + Route::get('/lab/admin', [LabAdminController::class, 'index'])->name('care.lab.admin.index'); Route::get('/lab/requests', [InvestigationController::class, 'index'])->name('care.lab.requests.index'); Route::get('/lab/queue', [InvestigationController::class, 'queue'])->name('care.lab.queue.index'); Route::post('/lab/queue/call-next', [InvestigationController::class, 'callNext'])->name('care.lab.queue.call-next'); @@ -177,6 +180,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/lab/catalog/{investigationType}/edit', [InvestigationTypeController::class, 'edit'])->name('care.lab.catalog.edit'); Route::put('/lab/catalog/{investigationType}', [InvestigationTypeController::class, 'update'])->name('care.lab.catalog.update'); + Route::get('/blood-bank/admin', [BloodBankAdminController::class, 'index'])->name('care.blood-bank.admin.index'); + Route::post('/blood-bank/admin/stock', [BloodBankAdminController::class, 'updateStock'])->name('care.blood-bank.admin.stock.update'); + Route::get('/prescriptions', [PrescriptionController::class, 'index'])->name('care.prescriptions.index'); Route::get('/prescriptions/queue', [PrescriptionController::class, 'queue'])->name('care.prescriptions.queue'); Route::post('/prescriptions/queue/call-next', [PrescriptionController::class, 'callNext'])->name('care.prescriptions.call-next'); diff --git a/tests/Feature/CareBloodBankSuiteTest.php b/tests/Feature/CareBloodBankSuiteTest.php index 1cf49a9..ab31ada 100644 --- a/tests/Feature/CareBloodBankSuiteTest.php +++ b/tests/Feature/CareBloodBankSuiteTest.php @@ -295,4 +295,82 @@ class CareBloodBankSuiteTest extends TestCase ->assertSee('Blood Bank summary') ->assertSee($this->patient->fullName()); } + + public function test_blood_bank_manager_can_manage_admin_inventory(): void + { + Member::query()->where('user_ref', $this->owner->public_id)->update([ + 'role' => 'blood_bank_manager', + 'branch_id' => $this->branch->id, + ]); + + $this->actingAs($this->owner) + ->get(route('care.blood-bank.admin.index', ['branch_id' => $this->branch->id])) + ->assertOk() + ->assertSee('Blood Bank admin') + ->assertSee('Operational inventory'); + + $this->actingAs($this->owner) + ->post(route('care.blood-bank.admin.stock.update'), [ + 'branch_id' => $this->branch->id, + 'stock' => [ + [ + 'product_code' => 'packed_rbc', + 'units_on_hand' => 12, + 'reorder_level' => 3, + 'notes' => 'Restocked', + ], + [ + 'product_code' => 'whole_blood', + 'units_on_hand' => 1, + 'reorder_level' => 2, + 'notes' => null, + ], + [ + 'product_code' => 'platelets', + 'units_on_hand' => 4, + 'reorder_level' => 2, + ], + [ + 'product_code' => 'ffp', + 'units_on_hand' => 5, + 'reorder_level' => 2, + ], + [ + 'product_code' => 'cryo', + 'units_on_hand' => 2, + 'reorder_level' => 1, + ], + ], + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('care_blood_bank_stock', [ + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'product_code' => 'packed_rbc', + 'units_on_hand' => 12, + 'reorder_level' => 3, + ]); + + $this->actingAs($this->owner) + ->get(route('care.specialty.show', 'blood_bank')) + ->assertOk(); + + $this->actingAs($this->owner) + ->get(route('care.specialty.blood-bank.reports')) + ->assertOk() + ->assertSee('Blood Bank reports'); + } + + public function test_lab_technician_cannot_access_blood_bank_admin(): void + { + Member::query()->where('user_ref', $this->owner->public_id)->update([ + 'role' => 'lab_technician', + 'branch_id' => $this->branch->id, + ]); + + $this->actingAs($this->owner) + ->get(route('care.blood-bank.admin.index')) + ->assertForbidden(); + } } diff --git a/tests/Feature/CareLabTest.php b/tests/Feature/CareLabTest.php index d4cd430..9f22df8 100644 --- a/tests/Feature/CareLabTest.php +++ b/tests/Feature/CareLabTest.php @@ -286,6 +286,50 @@ class CareLabTest extends TestCase ]); } + public function test_lab_manager_can_access_admin_and_catalog(): void + { + Member::where('user_ref', $this->user->public_id)->update([ + 'role' => 'lab_manager', + 'branch_id' => $this->branch->id, + ]); + + $this->actingAs($this->user) + ->get(route('care.lab.admin.index', ['branch_id' => $this->branch->id])) + ->assertOk() + ->assertSee('Lab admin') + ->assertSee('Manage catalog'); + + $this->actingAs($this->user) + ->get(route('care.lab.catalog.index')) + ->assertOk() + ->assertSee('Investigation catalog'); + + $this->actingAs($this->user) + ->post(route('care.lab.catalog.store'), [ + 'name' => 'Manager Panel', + 'code' => 'MGR', + 'category' => 'blood', + 'price_minor' => 2200, + 'is_active' => true, + ]) + ->assertRedirect(route('care.lab.catalog.index')); + + $this->assertDatabaseHas('care_investigation_types', [ + 'organization_id' => $this->organization->id, + 'name' => 'Manager Panel', + 'price_minor' => 2200, + ]); + } + + public function test_lab_technician_cannot_access_lab_admin(): void + { + Member::where('user_ref', $this->user->public_id)->update(['role' => 'lab_technician']); + + $this->actingAs($this->user) + ->get(route('care.lab.admin.index')) + ->assertForbidden(); + } + public function test_api_can_request_investigation(): void { Sanctum::actingAs($this->user);