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:
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">→</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">→</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>
|
||||
|
||||
@@ -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" />'];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user