Restrict lab catalog pricing to admins only.
Deploy Ladill Care / deploy (push) Successful in 38s

Split catalog CRUD onto lab.catalog.manage so technicians keep lab.manage for queue and results workflow without setting investigation prices.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 18:58:57 +00:00
co-authored by Cursor
parent 65e54d1253
commit 656f402e8f
12 changed files with 128 additions and 17 deletions
+2 -2
View File
@@ -12,11 +12,11 @@ clinical record is scoped to a platform account (`owner_ref`) and organization.
- **Patients** — registration, medical history, documents, visit timeline
- **Appointments & queue** — booking, walk-in, check-in, department queue
- **Consultations** — vitals, diagnoses, investigations, prescriptions
- **Laboratory** test catalog, sample collection, results, approval
- **Laboratory** — sample collection, results, approval; admins manage test catalog & pricing
- **Pharmacy** — dispensing queue, drug inventory, batch stock tracking
- **Billing** — visit invoices, line items, cash/MoMo payments, and online checkout via your own Paystack / Flutterwave / Hubtel (Pro)
- **Reports** — patients, appointments, lab, finance, clinical (CSV export)
- **Admin** — branches, departments, practitioners, members, audit log
- **Admin** — branches, departments, practitioners, members, lab catalog pricing, audit log
- **Devices** — barcode wedge scanners (all plans), agent-connected vitals/lab hardware (Pro); see `docs/devices.md`
## Platform integration
@@ -20,7 +20,7 @@ class InvestigationTypeController extends Controller
public function index(Request $request): View
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeAbility($request, 'lab.catalog.manage');
$organization = $this->organization($request);
$types = InvestigationType::query()
@@ -38,7 +38,7 @@ class InvestigationTypeController extends Controller
public function create(Request $request): View
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeAbility($request, 'lab.catalog.manage');
return view('care.lab.catalog.create', [
'categories' => config('care.investigation_categories'),
@@ -47,7 +47,7 @@ class InvestigationTypeController extends Controller
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeAbility($request, 'lab.catalog.manage');
$type = $this->investigations->createType(
$this->organization($request),
@@ -61,7 +61,7 @@ class InvestigationTypeController extends Controller
public function edit(Request $request, InvestigationType $investigationType): View
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeAbility($request, 'lab.catalog.manage');
$this->authorizeType($request, $investigationType);
return view('care.lab.catalog.edit', [
@@ -72,7 +72,7 @@ class InvestigationTypeController extends Controller
public function update(Request $request, InvestigationType $investigationType): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeAbility($request, 'lab.catalog.manage');
$this->authorizeType($request, $investigationType);
$this->investigations->updateType($investigationType, $this->ownerRef($request), $this->validatedTypeData($request));
@@ -61,6 +61,7 @@ class SettingsController extends Controller
'canViewTeam' => $permissions->can($member, 'admin.members.view'),
'canViewDevices' => $permissions->can($member, 'devices.view'),
'canViewDisplays' => $permissions->can($member, 'displays.view'),
'canManageLabCatalog' => $permissions->can($member, 'lab.catalog.manage'),
'hasBranchesFeature' => $plans->hasFeature($organization, 'branches'),
'hasClinicalDevicesFeature' => $plans->hasFeature($organization, 'clinical_devices'),
'canUseQueueIntegration' => $plans->canUseQueueIntegration($organization),
+2 -2
View File
@@ -158,10 +158,10 @@ class AfiaService
- Appointments: schedule visits and manage walk-ins.
- Queue: waiting patients and active consultations doctors start consultations from here.
- Consultations: record vitals, symptoms, clinical notes, diagnoses, lab requests, and prescriptions; complete the encounter when done.
- Laboratory: investigation catalog, request tests from consultations, sample collection, results entry, and approval.
- Laboratory: sample collection, results entry, and approval from the lab queue. Admins manage the investigation catalog and test prices in Settings / Lab catalog.
- Prescriptions & pharmacy queue: prescribe during consultations; pharmacists dispense from the queue.
- Billing: generate bills from completed visits, record payments, and print receipts.
- Settings & admin: branches, departments, members, practitioners, and audit logs.
- Settings & admin: branches, departments, members, practitioners, lab catalog pricing, and audit logs.
Rules:
- Only answer questions about Ladill Care patients, appointments, queue, consultations, lab, pharmacy, and billing.
+10 -1
View File
@@ -6,7 +6,15 @@ use App\Models\Member;
class CarePermissions
{
/** @var array<string, list<string>> */
/**
* Role abilities.
*
* Lab catalog pricing uses `lab.catalog.manage` (admins only via `*`).
* Lab technicians keep `lab.manage` for queue / sample / process / results
* not catalog CRUD or price edits.
*
* @var array<string, list<string>>
*/
protected array $roleAbilities = [
'super_admin' => ['*'],
'hospital_admin' => ['*'],
@@ -56,6 +64,7 @@ class CarePermissions
'admin.practitioners.view', 'admin.practitioners.manage',
'admin.members.view', 'admin.members.manage',
'settings.view', 'settings.manage',
'lab.catalog.manage',
'devices.view', 'devices.manage',
'displays.view', 'displays.manage',
'audit.view', 'audit.export',
@@ -1,6 +1,9 @@
<x-app-layout title="Investigation catalog">
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-slate-900">Investigation catalog</h1>
<div>
<h1 class="text-xl font-semibold text-slate-900">Investigation catalog</h1>
<p class="mt-1 text-sm text-slate-500">Admins manage test types and prices. Lab technicians process samples from the laboratory queue.</p>
</div>
<a href="{{ route('care.lab.catalog.create') }}" class="btn-primary">Add test</a>
</div>
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
@@ -6,7 +6,7 @@
</div>
<div class="flex gap-2">
<a href="{{ route('care.lab.requests.index') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm">All requests</a>
@if (app(\App\Services\Care\CarePermissions::class)->can(auth()->user() ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user(), $organization) : null, 'lab.manage'))
@if (app(\App\Services\Care\CarePermissions::class)->can(auth()->user() ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user(), $organization) : null, 'lab.catalog.manage'))
<a href="{{ route('care.lab.catalog.index') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm">Catalog</a>
@endif
</div>
+13 -1
View File
@@ -48,7 +48,7 @@
</div>
</x-settings.card>
@if ($canViewBranches || $canViewTeam || $canViewDevices || $canViewDisplays)
@if ($canViewBranches || $canViewTeam || $canViewDevices || $canViewDisplays || ($canManageLabCatalog ?? false))
<x-settings.card title="Branches & team" description="Locations, staff access, and clinical hardware for this facility. Multi-branch management requires Care Pro.">
<ul class="space-y-3">
@if ($canViewBranches)
@@ -126,6 +126,18 @@
<span class="text-slate-400">&rarr;</span>
</a>
</li>
@if (! empty($hasPaidPlan) && ($canManageLabCatalog ?? false))
<li>
<a href="{{ route('care.lab.catalog.index') }}"
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
<span>
Laboratory catalog
<span class="mt-0.5 block text-xs text-slate-500">Investigation types and lab test prices (admins only)</span>
</span>
<span class="text-slate-400">&rarr;</span>
</a>
</li>
@endif
<li>
<a href="{{ route('care.settings.modules') }}"
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
@@ -1,7 +1,7 @@
<x-app-layout title="GP pricing · Settings">
<x-settings.page
title="GP products & services"
description="Prices for general practice consultations and clinic services at {{ $organization->name }}. Drug prices are managed in Inventory; lab tests in Laboratory catalog."
description="Prices for general practice consultations and clinic services at {{ $organization->name }}. Drug prices are managed in Inventory; lab test prices are set by admins in Laboratory catalog."
>
<p class="text-sm">
<a href="{{ route('care.settings') }}" class="font-medium text-indigo-600 hover:text-indigo-800"> Facility settings</a>
@@ -14,7 +14,7 @@
<p class="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ session('success') }}</p>
@endif
<x-settings.card title="Status" description="Activate modules from the modules list. Drug prices stay in Inventory; lab test prices in Laboratory catalog.">
<x-settings.card title="Status" description="Activate modules from the modules list. Drug prices stay in Inventory; lab test prices are set by admins in Laboratory catalog.">
<div class="flex flex-wrap items-center gap-3 text-sm">
@if ($enabled)
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-800">Active</span>
+5 -1
View File
@@ -52,7 +52,7 @@
// Lab, pharmacy, billing, and Queue service counters are Pro/Enterprise modules.
if (! empty($hasPaidPlan)) {
if ($permissions->can($member, 'lab.view') && $permissions->handlesFloorCare($member)) {
$nav[] = ['name' => 'Laboratory', 'route' => route('care.lab.queue.index'), 'active' => request()->routeIs('care.lab.*'),
$nav[] = ['name' => 'Laboratory', 'route' => route('care.lab.queue.index'), 'active' => request()->routeIs('care.lab.*') && ! request()->routeIs('care.lab.catalog.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714a2.25 2.25 0 0 0 .659 1.591L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-2.47 2.47a2.25 2.25 0 0 1-1.59.659H9.06a2.25 2.25 0 0 1-1.591-.659L5 14.5m14 0V17a2.25 2.25 0 0 1-2.25 2.25H7.25A2.25 2.25 0 0 1 5 17v-2.5" />'];
}
@@ -98,6 +98,10 @@
}
$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.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714a2.25 2.25 0 0 0 .659 1.591L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-2.47 2.47a2.25 2.25 0 0 1-1.59.659H9.06a2.25 2.25 0 0 1-1.591-.659L5 14.5m14 0V17a2.25 2.25 0 0 1-2.25 2.25H7.25A2.25 2.25 0 0 1 5 17v-2.5" />'];
}
if ($permissions->can($member, 'admin.departments.view')) {
$adminNav[] = ['name' => 'Departments', 'route' => route('care.departments.index'), 'active' => request()->routeIs('care.departments.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />'];
+83 -1
View File
@@ -201,7 +201,89 @@ class CareLabTest extends TestCase
$this->actingAs($this->user)
->get(route('care.lab.queue.index', ['branch_id' => $this->branch->id]))
->assertOk()
->assertSee('Lab work queue');
->assertSee('Lab work queue')
->assertDontSee('Catalog');
}
public function test_lab_technician_cannot_manage_catalog(): void
{
Member::where('user_ref', $this->user->public_id)->update(['role' => 'lab_technician']);
$this->actingAs($this->user)
->get(route('care.lab.catalog.index'))
->assertForbidden();
$this->actingAs($this->user)
->get(route('care.lab.catalog.create'))
->assertForbidden();
$this->actingAs($this->user)
->post(route('care.lab.catalog.store'), [
'name' => 'Unauthorized Test',
'category' => 'blood',
'price_minor' => 1000,
])
->assertForbidden();
$this->actingAs($this->user)
->get(route('care.lab.catalog.edit', $this->investigationType))
->assertForbidden();
$this->actingAs($this->user)
->put(route('care.lab.catalog.update', $this->investigationType), [
'name' => 'Hacked Price',
'category' => 'blood',
'price_minor' => 1,
])
->assertForbidden();
}
public function test_hospital_admin_can_manage_catalog(): void
{
Member::where('user_ref', $this->user->public_id)->update(['role' => 'hospital_admin']);
$this->actingAs($this->user)
->get(route('care.lab.catalog.index'))
->assertOk()
->assertSee('Investigation catalog');
$this->actingAs($this->user)
->get(route('care.lab.catalog.create'))
->assertOk();
$this->actingAs($this->user)
->post(route('care.lab.catalog.store'), [
'name' => 'Lipid Panel',
'code' => 'LIP',
'category' => 'blood',
'price_minor' => 4500,
'is_active' => true,
])
->assertRedirect(route('care.lab.catalog.index'));
$this->assertDatabaseHas('care_investigation_types', [
'organization_id' => $this->organization->id,
'name' => 'Lipid Panel',
'price_minor' => 4500,
]);
$this->actingAs($this->user)
->put(route('care.lab.catalog.update', $this->investigationType), [
'name' => 'Fasting Blood Glucose',
'code' => 'FBG',
'category' => 'blood',
'unit' => 'mmol/L',
'reference_low' => 3.9,
'reference_high' => 6.1,
'price_minor' => 3000,
'is_active' => true,
])
->assertRedirect(route('care.lab.catalog.index'));
$this->assertDatabaseHas('care_investigation_types', [
'id' => $this->investigationType->id,
'price_minor' => 3000,
]);
}
public function test_api_can_request_investigation(): void