Gate specialty modules by staff role with view and refer levels.
Deploy Ladill Care / deploy (push) Successful in 37s
Deploy Ladill Care / deploy (push) Successful in 37s
Give nurses, lab techs, pharmacists, and GPs broad access to day-to-day modules, keep restricted specialties for specialists (with support nurses), and let GPs view results or refer without full clinical edit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,6 +37,12 @@ class DentistryWorkspaceController extends Controller
|
||||
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'dentistry'), 403);
|
||||
}
|
||||
|
||||
protected function assertDentistryManage(Request $request, SpecialtyModuleService $modules): void
|
||||
{
|
||||
$organization = $this->organization($request);
|
||||
abort_unless($modules->memberCanManage($organization, $this->member($request), 'dentistry'), 403);
|
||||
}
|
||||
|
||||
protected function assertVisit(Request $request, Visit $visit): void
|
||||
{
|
||||
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
|
||||
@@ -50,7 +56,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalChartService $charts,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -93,7 +99,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalTreatmentPlanService $plans,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -128,7 +134,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalTreatmentPlanService $plans,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
@@ -153,7 +159,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalProcedureService $procedures,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -194,7 +200,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalVisitNoteService $notes,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -225,7 +231,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalProcedureService $procedures,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -335,7 +341,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalChartService $charts,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -371,7 +377,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalTreatmentPlanService $plans,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -408,7 +414,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalTreatmentPlanService $plans,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
@@ -435,7 +441,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalTreatmentPlanService $plans,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -473,7 +479,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalTreatmentPlanService $plans,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
@@ -503,7 +509,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalImagingService $imaging,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
@@ -532,7 +538,7 @@ class DentistryWorkspaceController extends Controller
|
||||
SpecialtyVisitStageService $stages,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -564,7 +570,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalPerioService $perio,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -608,7 +614,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalLabCaseService $labCases,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -651,7 +657,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalLabCaseService $labCases,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -689,7 +695,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalRecallService $recalls,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -727,7 +733,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalRecallService $recalls,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
@@ -762,7 +768,7 @@ class DentistryWorkspaceController extends Controller
|
||||
DentalRecallService $recalls,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertDentistryManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
|
||||
@@ -27,6 +27,12 @@ class EmergencyWorkspaceController extends Controller
|
||||
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'emergency'), 403);
|
||||
}
|
||||
|
||||
protected function assertEmergencyManage(Request $request, SpecialtyModuleService $modules): void
|
||||
{
|
||||
$organization = $this->organization($request);
|
||||
abort_unless($modules->memberCanManage($organization, $this->member($request), 'emergency'), 403);
|
||||
}
|
||||
|
||||
protected function assertVisit(Request $request, Visit $visit): void
|
||||
{
|
||||
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
|
||||
@@ -40,7 +46,7 @@ class EmergencyWorkspaceController extends Controller
|
||||
SpecialtyVisitStageService $stages,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertEmergencyAccess($request, $modules);
|
||||
$this->assertEmergencyManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -72,7 +78,7 @@ class EmergencyWorkspaceController extends Controller
|
||||
EmergencyVitalsService $vitals,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertEmergencyAccess($request, $modules);
|
||||
$this->assertEmergencyManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -112,7 +118,7 @@ class EmergencyWorkspaceController extends Controller
|
||||
EmergencyWorkflowService $workflow,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertEmergencyAccess($request, $modules);
|
||||
$this->assertEmergencyManage($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
|
||||
@@ -104,6 +104,7 @@ class SpecialtyModuleController extends Controller
|
||||
$organization = $this->organization($request);
|
||||
$member = $this->member($request);
|
||||
abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
|
||||
abort_unless($modules->memberCanManage($organization, $member, $module), 403);
|
||||
abort_unless($visit->organization_id === $organization->id, 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -142,6 +143,7 @@ class SpecialtyModuleController extends Controller
|
||||
$organization = $this->organization($request);
|
||||
$member = $this->member($request);
|
||||
abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
|
||||
abort_unless($modules->memberCanManage($organization, $member, $module), 403);
|
||||
abort_unless($visit->organization_id === $organization->id, 404);
|
||||
$this->authorizeBranch($request, (int) $visit->branch_id);
|
||||
|
||||
@@ -256,6 +258,7 @@ class SpecialtyModuleController extends Controller
|
||||
$organization = $this->organization($request);
|
||||
$member = $this->member($request);
|
||||
abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
|
||||
abort_unless($modules->memberCanManage($organization, $member, $module), 403);
|
||||
abort_unless($visit->organization_id === $organization->id, 404);
|
||||
|
||||
$tab = (string) $request->input('tab', '');
|
||||
@@ -345,6 +348,7 @@ class SpecialtyModuleController extends Controller
|
||||
$organization = $this->organization($request);
|
||||
$member = $this->member($request);
|
||||
abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
|
||||
abort_unless($modules->memberCanManage($organization, $member, $module), 403);
|
||||
abort_unless($visit->organization_id === $organization->id, 404);
|
||||
abort_unless($visit->patient_id, 422);
|
||||
|
||||
@@ -388,6 +392,7 @@ class SpecialtyModuleController extends Controller
|
||||
$member = $this->member($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
|
||||
abort_unless($modules->memberCanManage($organization, $member, $module), 403);
|
||||
abort_unless($queueBridge->isEnabled($organization), 404);
|
||||
|
||||
$resolver = app(OrganizationResolver::class);
|
||||
@@ -473,6 +478,74 @@ class SpecialtyModuleController extends Controller
|
||||
return back()->with('success', 'Called '.$ticket['ticket_number'].' — '.$name.'.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refer a patient into this specialty queue (walk-in) without full clinical edit access.
|
||||
*/
|
||||
public function refer(
|
||||
Request $request,
|
||||
string $module,
|
||||
SpecialtyModuleService $modules,
|
||||
AppointmentService $appointments,
|
||||
): RedirectResponse {
|
||||
$definition = $modules->definition($module);
|
||||
abort_unless($definition, 404);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
$member = $this->member($request);
|
||||
abort_unless($modules->memberCanRefer($organization, $member, $module), 403);
|
||||
|
||||
$owner = $this->ownerRef($request);
|
||||
$branchScope = app(OrganizationResolver::class)->branchScope($member);
|
||||
|
||||
$validated = $request->validate([
|
||||
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
|
||||
'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'],
|
||||
'reason' => ['nullable', 'string', 'max:500'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$branchId = (int) ($validated['branch_id'] ?? $branchScope ?? 0);
|
||||
abort_unless($branchId > 0, 422);
|
||||
$this->authorizeBranch($request, $branchId);
|
||||
|
||||
$department = Department::owned($owner)
|
||||
->where('type', $definition['department_type'] ?? 'general')
|
||||
->where('branch_id', $branchId)
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (! $department) {
|
||||
return back()->with('error', 'No active '.($definition['label'] ?? $module).' department at this branch.');
|
||||
}
|
||||
|
||||
$patient = \App\Models\Patient::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->findOrFail((int) $validated['patient_id']);
|
||||
|
||||
$appointment = $appointments->walkIn($organization, $owner, [
|
||||
'patient_id' => $patient->id,
|
||||
'branch_id' => $branchId,
|
||||
'department_id' => $department->id,
|
||||
'reason' => $validated['reason'] ?? ('Referral to '.($definition['label'] ?? $module)),
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
], $owner);
|
||||
|
||||
\App\Services\Care\AuditLogger::record(
|
||||
$owner,
|
||||
'specialty.referral',
|
||||
$organization->id,
|
||||
$owner,
|
||||
Appointment::class,
|
||||
$appointment->id,
|
||||
['module' => $module, 'department_id' => $department->id],
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('care.queue.index')
|
||||
->with('success', 'Referred '.$patient->fullName().' to '.($definition['label'] ?? $module).'.');
|
||||
}
|
||||
|
||||
protected function renderShell(
|
||||
Request $request,
|
||||
string $module,
|
||||
@@ -489,6 +562,10 @@ class SpecialtyModuleController extends Controller
|
||||
$member = $this->member($request);
|
||||
abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
|
||||
|
||||
$canManageSpecialty = $modules->memberCanManage($organization, $member, $module);
|
||||
$canReferSpecialty = $modules->memberCanRefer($organization, $member, $module);
|
||||
$specialtyAccessLevel = $modules->memberAccessLevel($organization, $member, $module);
|
||||
|
||||
$owner = $this->ownerRef($request);
|
||||
$resolver = app(OrganizationResolver::class);
|
||||
$branchScope = $resolver->branchScope($member);
|
||||
@@ -559,8 +636,8 @@ class SpecialtyModuleController extends Controller
|
||||
$services = $shell->provisionedServices($organization, $module);
|
||||
|
||||
$permissions = app(\App\Services\Care\CarePermissions::class);
|
||||
$canConsult = $permissions->can($member, 'consultations.manage');
|
||||
$canManageQueue = $permissions->can($member, 'appointments.manage');
|
||||
$canConsult = $permissions->can($member, 'consultations.manage') && $canManageSpecialty;
|
||||
$canManageQueue = $permissions->can($member, 'appointments.manage') && $canManageSpecialty;
|
||||
$branchLabel = $branchId
|
||||
? (string) (\App\Models\Branch::owned($owner)->whereKey($branchId)->value('name') ?? '')
|
||||
: '';
|
||||
@@ -803,6 +880,9 @@ class SpecialtyModuleController extends Controller
|
||||
'lockToPractitioner' => $practitionerScope !== null,
|
||||
'canConsult' => $canConsult,
|
||||
'canManageQueue' => $canManageQueue,
|
||||
'canManageSpecialty' => $canManageSpecialty,
|
||||
'canReferSpecialty' => $canReferSpecialty,
|
||||
'specialtyAccessLevel' => $specialtyAccessLevel,
|
||||
'queueIntegration' => $branchId
|
||||
? $queueBridge->listMeta($organization, $module, $branchId)
|
||||
: ['enabled' => false],
|
||||
|
||||
@@ -135,26 +135,131 @@ class SpecialtyModuleService
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled modules the member may open (role allowlist + doctor specialty assignment).
|
||||
* Enabled modules the member may open (manage, view, or refer).
|
||||
*
|
||||
* @return list<array{key: string, definition: array<string, mixed>}>
|
||||
* @return list<array{key: string, definition: array<string, mixed>, access_level: string}>
|
||||
*/
|
||||
public function enabledModulesForMember(Organization $organization, ?Member $member): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->enabledModules($organization),
|
||||
fn (array $item) => $this->memberCanAccess($organization, $member, $item['key']),
|
||||
));
|
||||
$out = [];
|
||||
foreach ($this->enabledModules($organization) as $item) {
|
||||
$level = $this->memberAccessLevel($organization, $member, $item['key']);
|
||||
if ($level === 'none') {
|
||||
continue;
|
||||
}
|
||||
$out[] = array_merge($item, ['access_level' => $level]);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the member may see/serve this specialty module.
|
||||
* Doctors must be linked to a practitioner desk in that specialty department.
|
||||
* Receptionists keep queue/ops visibility; facility admins do not staff specialty floors.
|
||||
* @return 'general'|'limited'|'restricted'
|
||||
*/
|
||||
public function accessTier(string $key): string
|
||||
{
|
||||
$tier = (string) ($this->definition($key)['access'] ?? 'general');
|
||||
|
||||
return in_array($tier, ['general', 'limited', 'restricted'], true) ? $tier : 'general';
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest access for this member on the module.
|
||||
*
|
||||
* @return 'none'|'view'|'refer'|'manage'
|
||||
*/
|
||||
public function memberAccessLevel(Organization $organization, ?Member $member, string $key): string
|
||||
{
|
||||
if (! $this->isEnabled($organization, $key)) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
$definition = $this->definition($key);
|
||||
if (! $definition || ! $member) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
if (app(CarePermissions::class)->isAdmin($member)) {
|
||||
return 'manage';
|
||||
}
|
||||
|
||||
if ($this->memberCanManage($organization, $member, $key)) {
|
||||
return 'manage';
|
||||
}
|
||||
|
||||
$canRefer = $this->roleListed($member, $definition['refer_roles'] ?? []);
|
||||
$canView = $this->roleListed($member, $definition['view_roles'] ?? []);
|
||||
|
||||
if ($canRefer && $canView) {
|
||||
return 'refer';
|
||||
}
|
||||
if ($canRefer) {
|
||||
return 'refer';
|
||||
}
|
||||
if ($canView) {
|
||||
return 'view';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the member may see this specialty module in nav / open workspace (any level).
|
||||
*/
|
||||
public function memberCanAccess(Organization $organization, ?Member $member, string $key): bool
|
||||
{
|
||||
if (! $this->isEnabled($organization, $key)) {
|
||||
return $this->memberAccessLevel($organization, $member, $key) !== 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Full clinical / queue edit access for the module.
|
||||
*/
|
||||
public function memberCanManage(Organization $organization, ?Member $member, string $key): bool
|
||||
{
|
||||
if (! $this->isEnabled($organization, $key) || ! $member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (app(CarePermissions::class)->isAdmin($member)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$definition = $this->definition($key);
|
||||
if (! $definition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tier = $this->accessTier($key);
|
||||
$role = (string) $member->role;
|
||||
$manageRoles = $definition['roles'] ?? [];
|
||||
$supportRoles = $definition['support_roles'] ?? [];
|
||||
|
||||
if ($tier === 'restricted') {
|
||||
if ($this->roleListed($member, $supportRoles)) {
|
||||
return true;
|
||||
}
|
||||
if ($role === 'doctor' && $this->roleListed($member, $manageRoles)) {
|
||||
return $this->doctorAssignedToModule($organization, $member, $key)
|
||||
|| $this->doctorSpecialtyMatchesModule($organization, $member, $key);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// general + limited: role allowlist is enough (GPs = doctor without specialty desk).
|
||||
return $this->roleListed($member, $manageRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read results / history / workspace without clinical edit.
|
||||
*/
|
||||
public function memberCanView(Organization $organization, ?Member $member, string $key): bool
|
||||
{
|
||||
if ($this->memberCanManage($organization, $member, $key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->isEnabled($organization, $key) || ! $member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -163,25 +268,36 @@ class SpecialtyModuleService
|
||||
return false;
|
||||
}
|
||||
|
||||
if (app(CarePermissions::class)->isAdmin($member)) {
|
||||
return $this->roleListed($member, $definition['view_roles'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refer a patient into the specialty queue without full clinical edit.
|
||||
*/
|
||||
public function memberCanRefer(Organization $organization, ?Member $member, string $key): bool
|
||||
{
|
||||
if ($this->memberCanManage($organization, $member, $key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$roles = $definition['roles'] ?? [];
|
||||
if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) {
|
||||
if (! $this->isEnabled($organization, $key) || ! $member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($member && $member->role === 'doctor') {
|
||||
// Default Pro surfaces (Emergency, Blood Bank) are available to all doctors.
|
||||
if ($this->isDefaultOnPaidPlans($key)) {
|
||||
return true;
|
||||
$definition = $this->definition($key);
|
||||
if (! $definition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->doctorAssignedToModule($organization, $member, $key);
|
||||
return $this->roleListed($member, $definition['refer_roles'] ?? []);
|
||||
}
|
||||
|
||||
return true;
|
||||
/**
|
||||
* @param list<string>|mixed $roles
|
||||
*/
|
||||
protected function roleListed(Member $member, mixed $roles): bool
|
||||
{
|
||||
return is_array($roles) && $roles !== [] && in_array($member->role, $roles, true);
|
||||
}
|
||||
|
||||
public function doctorAssignedToModule(Organization $organization, Member $member, string $key): bool
|
||||
@@ -201,9 +317,77 @@ class SpecialtyModuleService
|
||||
? array_map('intval', $provisionedIds)
|
||||
: [];
|
||||
|
||||
foreach ($this->practitionersForMember($organization, $member) as $practitioner) {
|
||||
if ($provisionedIds !== [] && in_array((int) $practitioner->department_id, $provisionedIds, true)) {
|
||||
return true;
|
||||
}
|
||||
if ($departmentType !== '' && $practitioner->department?->type === $departmentType) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match practitioner.specialty text to module specialist_keywords (restricted specialists).
|
||||
*/
|
||||
public function doctorSpecialtyMatchesModule(Organization $organization, Member $member, string $key): bool
|
||||
{
|
||||
$definition = $this->definition($key);
|
||||
if (! $definition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keywords = $definition['specialist_keywords'] ?? [];
|
||||
if (! is_array($keywords) || $keywords === []) {
|
||||
$keywords = $definition['queue_keywords'] ?? [];
|
||||
}
|
||||
if (! is_array($keywords) || $keywords === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->practitionersForMember($organization, $member) as $practitioner) {
|
||||
$hay = strtolower(trim((string) ($practitioner->specialty ?? '')));
|
||||
if ($hay === '' || $this->isGeneralPracticeSpecialty($hay)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($keywords as $keyword) {
|
||||
$needle = strtolower((string) $keyword);
|
||||
if ($needle !== '' && str_contains($hay, $needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function isGeneralPracticeSpecialty(string $specialtyLower): bool
|
||||
{
|
||||
if (in_array($specialtyLower, [
|
||||
'general practice',
|
||||
'general',
|
||||
'gp',
|
||||
'family medicine',
|
||||
'internal medicine',
|
||||
'nursing',
|
||||
], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_contains($specialtyLower, 'general practice')
|
||||
|| str_contains($specialtyLower, 'family medicine');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<int, Practitioner>
|
||||
*/
|
||||
protected function practitionersForMember(Organization $organization, Member $member)
|
||||
{
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($member);
|
||||
|
||||
$practitioners = Practitioner::owned((string) $organization->owner_ref)
|
||||
return Practitioner::owned((string) $organization->owner_ref)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->where(function ($query) use ($member) {
|
||||
@@ -219,17 +403,6 @@ class SpecialtyModuleService
|
||||
|
||||
return in_array((int) $branchId, $practitioner->assignedBranchIds(), true);
|
||||
});
|
||||
|
||||
foreach ($practitioners as $practitioner) {
|
||||
if ($provisionedIds !== [] && in_array((int) $practitioner->department_id, $provisionedIds, true)) {
|
||||
return true;
|
||||
}
|
||||
if ($departmentType !== '' && $practitioner->department?->type === $departmentType) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function canManage(Organization $organization): bool
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
* Specialty practice modules (Settings → Modules).
|
||||
* Pro-gated via plans.*.features specialty_modules.
|
||||
*
|
||||
* Access tiers (RBAC via Care member roles — do not invent new roles):
|
||||
* - general: broad day-to-day access for listed roles (GPs = doctor)
|
||||
* - limited: GPs + nurses (and listed roles) without specialist assignment
|
||||
* - restricted: specialist doctors (department / specialty keywords) + optional support_roles;
|
||||
* other clinicians use view_roles / refer_roles
|
||||
*
|
||||
* default_on_paid_plans: always on for Pro/Enterprise (sidebar + access).
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
@@ -18,7 +24,8 @@ return [
|
||||
'queue_prefix' => 'ER',
|
||||
'nav_label' => 'Emergency',
|
||||
'queue_keywords' => ['emerg', 'casualty', 'trauma', 'er', 'a&e'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'general',
|
||||
'roles' => ['doctor', 'nurse', 'lab_technician', 'pharmacist', 'receptionist'],
|
||||
'default_on_paid_plans' => true,
|
||||
],
|
||||
'blood_bank' => [
|
||||
@@ -30,7 +37,8 @@ return [
|
||||
'queue_prefix' => 'BB',
|
||||
'nav_label' => 'Blood Bank',
|
||||
'queue_keywords' => ['blood', 'transfusion', 'crossmatch', 'donor'],
|
||||
'roles' => ['doctor', 'nurse', 'lab_technician', 'receptionist'],
|
||||
'access' => 'general',
|
||||
'roles' => ['lab_technician', 'nurse', 'doctor', 'receptionist'],
|
||||
'default_on_paid_plans' => true,
|
||||
],
|
||||
'dentistry' => [
|
||||
@@ -42,7 +50,9 @@ return [
|
||||
'queue_prefix' => 'DEN',
|
||||
'nav_label' => 'Dentistry',
|
||||
'queue_keywords' => ['dent', 'dental', 'oral'],
|
||||
'access' => 'limited',
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'specialist_keywords' => ['dent', 'dental', 'oral', 'odont'],
|
||||
],
|
||||
'ophthalmology' => [
|
||||
'label' => 'Eye care',
|
||||
@@ -53,7 +63,9 @@ return [
|
||||
'queue_prefix' => 'EYE',
|
||||
'nav_label' => 'Eye care',
|
||||
'queue_keywords' => ['eye', 'ophthal', 'optom'],
|
||||
'access' => 'limited',
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'specialist_keywords' => ['eye', 'ophthal', 'optom'],
|
||||
],
|
||||
'physiotherapy' => [
|
||||
'label' => 'Physiotherapy',
|
||||
@@ -64,6 +76,7 @@ return [
|
||||
'queue_prefix' => 'PHY',
|
||||
'nav_label' => 'Physiotherapy',
|
||||
'queue_keywords' => ['physio', 'rehab', 'therapy'],
|
||||
'access' => 'general',
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
],
|
||||
'maternity' => [
|
||||
@@ -75,7 +88,8 @@ return [
|
||||
'queue_prefix' => 'MAT',
|
||||
'nav_label' => 'Maternity',
|
||||
'queue_keywords' => ['matern', 'antenatal', 'obstetric'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'general',
|
||||
'roles' => ['doctor', 'nurse', 'pharmacist', 'receptionist'],
|
||||
],
|
||||
'radiology' => [
|
||||
'label' => 'Radiology',
|
||||
@@ -86,7 +100,8 @@ return [
|
||||
'queue_prefix' => 'RAD',
|
||||
'nav_label' => 'Radiology',
|
||||
'queue_keywords' => ['radio', 'imaging', 'x-ray', 'xray', 'ultrasound', 'ct', 'mri'],
|
||||
'roles' => ['doctor', 'lab_technician', 'receptionist'],
|
||||
'access' => 'general',
|
||||
'roles' => ['doctor', 'nurse', 'lab_technician', 'receptionist'],
|
||||
],
|
||||
'cardiology' => [
|
||||
'label' => 'Cardiology',
|
||||
@@ -97,7 +112,12 @@ return [
|
||||
'queue_prefix' => 'CAR',
|
||||
'nav_label' => 'Cardiology',
|
||||
'queue_keywords' => ['cardio', 'heart', 'ecg', 'echo'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => ['nurse'],
|
||||
'view_roles' => ['doctor', 'nurse', 'lab_technician'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'specialist_keywords' => ['cardio', 'cardiology', 'heart'],
|
||||
],
|
||||
'psychiatry' => [
|
||||
'label' => 'Psychiatry',
|
||||
@@ -108,7 +128,12 @@ return [
|
||||
'queue_prefix' => 'PSY',
|
||||
'nav_label' => 'Psychiatry',
|
||||
'queue_keywords' => ['psych', 'mental', 'behaviour'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => ['nurse'],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'specialist_keywords' => ['psych', 'psychiatry', 'mental'],
|
||||
],
|
||||
'pediatrics' => [
|
||||
'label' => 'Pediatrics',
|
||||
@@ -119,6 +144,7 @@ return [
|
||||
'queue_prefix' => 'PED',
|
||||
'nav_label' => 'Pediatrics',
|
||||
'queue_keywords' => ['pedia', 'paedia', 'child', 'infant'],
|
||||
'access' => 'general',
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
],
|
||||
'orthopedics' => [
|
||||
@@ -130,7 +156,12 @@ return [
|
||||
'queue_prefix' => 'ORT',
|
||||
'nav_label' => 'Orthopedics',
|
||||
'queue_keywords' => ['ortho', 'fracture', 'bone', 'joint'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => [],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'specialist_keywords' => ['ortho', 'orthopedic', 'orthopaedic', 'fracture'],
|
||||
],
|
||||
'ent' => [
|
||||
'label' => 'ENT',
|
||||
@@ -141,7 +172,12 @@ return [
|
||||
'queue_prefix' => 'ENT',
|
||||
'nav_label' => 'ENT',
|
||||
'queue_keywords' => ['ent', 'ear', 'nose', 'throat', 'otolaryng'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => [],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'specialist_keywords' => ['ent', 'otolaryng', 'ear', 'nose', 'throat'],
|
||||
],
|
||||
'oncology' => [
|
||||
'label' => 'Oncology',
|
||||
@@ -152,7 +188,12 @@ return [
|
||||
'queue_prefix' => 'ONC',
|
||||
'nav_label' => 'Oncology',
|
||||
'queue_keywords' => ['oncol', 'cancer', 'chemo'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => ['nurse'],
|
||||
'view_roles' => ['doctor', 'nurse', 'pharmacist'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'specialist_keywords' => ['oncol', 'cancer', 'chemo'],
|
||||
],
|
||||
'renal' => [
|
||||
'label' => 'Renal Care',
|
||||
@@ -163,7 +204,12 @@ return [
|
||||
'queue_prefix' => 'REN',
|
||||
'nav_label' => 'Renal Care',
|
||||
'queue_keywords' => ['renal', 'dialysis', 'nephro', 'kidney'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => ['nurse'],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'specialist_keywords' => ['renal', 'nephro', 'dialysis', 'kidney'],
|
||||
],
|
||||
'surgery' => [
|
||||
'label' => 'Surgery',
|
||||
@@ -174,7 +220,12 @@ return [
|
||||
'queue_prefix' => 'SUR',
|
||||
'nav_label' => 'Surgery',
|
||||
'queue_keywords' => ['surg', 'theatre', 'operat', 'pre-op'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => ['nurse'],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'specialist_keywords' => ['surg', 'theatre', 'operat'],
|
||||
],
|
||||
'vaccination' => [
|
||||
'label' => 'Vaccination & Immunization',
|
||||
@@ -185,6 +236,7 @@ return [
|
||||
'queue_prefix' => 'VAX',
|
||||
'nav_label' => 'Vaccination',
|
||||
'queue_keywords' => ['vaccin', 'immun', 'epi', 'injection'],
|
||||
'access' => 'general',
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
],
|
||||
'pathology' => [
|
||||
@@ -196,7 +248,9 @@ return [
|
||||
'queue_prefix' => 'PATH',
|
||||
'nav_label' => 'Pathology',
|
||||
'queue_keywords' => ['pathol', 'histo', 'cytol', 'biopsy'],
|
||||
'roles' => ['doctor', 'lab_technician', 'receptionist'],
|
||||
'access' => 'general',
|
||||
'roles' => ['lab_technician', 'doctor', 'nurse', 'receptionist'],
|
||||
'specialist_keywords' => ['pathol', 'histo', 'cytol'],
|
||||
],
|
||||
'infusion' => [
|
||||
'label' => 'Infusion Centre',
|
||||
@@ -207,7 +261,8 @@ return [
|
||||
'queue_prefix' => 'INF',
|
||||
'nav_label' => 'Infusion',
|
||||
'queue_keywords' => ['infusion', 'iv', 'drip', 'day care'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'general',
|
||||
'roles' => ['doctor', 'nurse', 'pharmacist', 'receptionist'],
|
||||
],
|
||||
'dermatology' => [
|
||||
'label' => 'Dermatology',
|
||||
@@ -218,7 +273,12 @@ return [
|
||||
'queue_prefix' => 'DER',
|
||||
'nav_label' => 'Dermatology',
|
||||
'queue_keywords' => ['derma', 'skin', 'rash'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => [],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'specialist_keywords' => ['derma', 'skin'],
|
||||
],
|
||||
'podiatry' => [
|
||||
'label' => 'Podiatry',
|
||||
@@ -229,7 +289,12 @@ return [
|
||||
'queue_prefix' => 'POD',
|
||||
'nav_label' => 'Podiatry',
|
||||
'queue_keywords' => ['podiat', 'foot', 'diabetic foot'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => [],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'specialist_keywords' => ['podiat', 'foot'],
|
||||
],
|
||||
'fertility' => [
|
||||
'label' => 'Fertility',
|
||||
@@ -240,7 +305,12 @@ return [
|
||||
'queue_prefix' => 'FER',
|
||||
'nav_label' => 'Fertility',
|
||||
'queue_keywords' => ['fertil', 'ivf', 'reproduct'],
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => [],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'specialist_keywords' => ['fertil', 'ivf', 'reproduct'],
|
||||
],
|
||||
'child_welfare' => [
|
||||
'label' => 'Child Welfare Clinic',
|
||||
@@ -251,6 +321,7 @@ return [
|
||||
'queue_prefix' => 'CWC',
|
||||
'nav_label' => 'Child Welfare',
|
||||
'queue_keywords' => ['cwc', 'child welfare', 'well baby', 'growth'],
|
||||
'access' => 'general',
|
||||
'roles' => ['doctor', 'nurse', 'receptionist'],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -205,7 +205,14 @@
|
||||
@endphp
|
||||
<a href="{{ $href }}"
|
||||
class="rounded-2xl border border-slate-200 bg-white px-4 py-4 transition hover:border-indigo-300 hover:shadow-sm">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="text-sm font-semibold text-slate-900">{{ $item['definition']['nav_label'] ?? $item['definition']['label'] }}</p>
|
||||
@if (! empty($item['access_level']) && $item['access_level'] !== 'manage' && ! $toSettings)
|
||||
<span class="shrink-0 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800">
|
||||
{{ $item['access_level'] === 'refer' ? 'View + refer' : 'View' }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ $item['definition']['description'] ?? '' }}</p>
|
||||
@if ($toSettings)
|
||||
<p class="mt-2 text-[11px] font-medium text-indigo-600">Settings & pricing →</p>
|
||||
|
||||
@@ -58,25 +58,25 @@
|
||||
@endif
|
||||
|
||||
@if ($workspaceVisit)
|
||||
@if ($moduleKey === 'dentistry' && $currentStage && isset($stageFlow[$currentStage]))
|
||||
@if (($canManageSpecialty ?? true) && $moduleKey === 'dentistry' && $currentStage && isset($stageFlow[$currentStage]))
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.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 ($moduleKey === 'dentistry' && ! $currentStage)
|
||||
@elseif (($canManageSpecialty ?? true) && $moduleKey === 'dentistry' && ! $currentStage)
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $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 ($moduleKey === 'emergency' && $currentStage && isset($stageFlow[$currentStage]) && ($stageFlow[$currentStage]['next'] ?? null) !== $currentStage)
|
||||
@elseif (($canManageSpecialty ?? true) && $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 ($moduleKey === 'emergency' && ! $currentStage)
|
||||
@elseif (($canManageSpecialty ?? true) && $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">
|
||||
@@ -84,7 +84,7 @@
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if ($canCompleteConsultation)
|
||||
@if (($canManageSpecialty ?? true) && $canCompleteConsultation)
|
||||
<button
|
||||
type="button"
|
||||
class="{{ $btnPrimary }}"
|
||||
@@ -99,7 +99,7 @@
|
||||
class="{{ $btnSecondary }}">
|
||||
Open chart
|
||||
</a>
|
||||
@elseif ($canStartConsultation)
|
||||
@elseif (($canManageSpecialty ?? true) && $canStartConsultation)
|
||||
<form method="POST" action="{{ route('care.specialty.consultation.start', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" class="w-full" @click.stop>
|
||||
@csrf
|
||||
<button type="submit" class="{{ $btnPrimary }}">
|
||||
@@ -107,6 +107,15 @@
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@if (($canReferSpecialty ?? false) && ! ($canManageSpecialty ?? true) && $workspaceVisit->patient)
|
||||
<form method="POST" action="{{ route('care.specialty.refer', $moduleKey) }}" class="w-full space-y-2" @click.stop>
|
||||
@csrf
|
||||
<input type="hidden" name="patient_id" value="{{ $workspaceVisit->patient->id }}">
|
||||
<input type="hidden" name="branch_id" value="{{ $workspaceVisit->branch_id }}">
|
||||
<input type="text" name="reason" placeholder="Referral reason" class="w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">
|
||||
<button type="submit" class="{{ $btnAccent }}">Refer to specialty queue</button>
|
||||
</form>
|
||||
@endif
|
||||
@if ($workspaceVisit->patient)
|
||||
<a href="{{ route('care.patients.show', $workspaceVisit->patient) }}" class="{{ $btnSecondary }}">
|
||||
Patient chart
|
||||
|
||||
@@ -29,6 +29,25 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! ($canManageSpecialty ?? true))
|
||||
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
||||
@forelse ($clinicalFields as $field)
|
||||
@php
|
||||
$name = $field['name'];
|
||||
$value = $values[$name] ?? null;
|
||||
if (is_bool($value)) {
|
||||
$value = $value ? 'Yes' : 'No';
|
||||
}
|
||||
@endphp
|
||||
<div @class(['sm:col-span-2' => ($field['type'] ?? '') === 'textarea'])>
|
||||
<dt class="text-slate-500">{{ $field['label'] }}</dt>
|
||||
<dd class="mt-0.5 font-medium text-slate-900 whitespace-pre-wrap">{{ $value !== null && $value !== '' ? $value : '—' }}</dd>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-slate-500">No clinical record yet.</p>
|
||||
@endforelse
|
||||
</dl>
|
||||
@else
|
||||
<form method="POST" action="{{ route('care.specialty.clinical.save', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" class="mt-4 space-y-3">
|
||||
@csrf
|
||||
<input type="hidden" name="tab" value="{{ $activeTab }}">
|
||||
@@ -103,4 +122,5 @@
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
@@ -16,11 +16,26 @@
|
||||
@if ($moduleKey === 'dentistry')
|
||||
<a href="{{ route('care.specialty.dentistry.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
|
||||
@endif
|
||||
@if ($moduleKey === 'emergency')
|
||||
<a href="{{ route('care.specialty.emergency.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 ($canManageSpecialty ?? true)
|
||||
<a href="{{ route('care.specialty.billing', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">Billing</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (($specialtyAccessLevel ?? 'manage') !== 'manage')
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
@if (($specialtyAccessLevel ?? '') === 'refer')
|
||||
You have <strong>view + refer</strong> access. You can review results and refer patients into this specialty queue; clinical editing is limited to specialists and supporting staff.
|
||||
@else
|
||||
You have <strong>view-only</strong> access. Results and history are available; clinical edits require specialty access.
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="lg:grid lg:grid-cols-[minmax(0,1fr)_16rem] lg:items-start lg:gap-6">
|
||||
<div class="min-w-0 space-y-4">
|
||||
@include('care.specialty.sections.workspace')
|
||||
|
||||
@@ -76,12 +76,25 @@
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />'];
|
||||
}
|
||||
|
||||
// Specialty modules stay enabled for clinical depth, but patient flow lives on Queue only.
|
||||
// Specialty modules — role-gated clinical surfaces for floor staff.
|
||||
if ($permissions->handlesFloorCare($member) && $organization) {
|
||||
app(\App\Services\Care\SpecialtyModuleService::class)->ensureDefaultModulesProvisioned(
|
||||
$specialtyService = app(\App\Services\Care\SpecialtyModuleService::class);
|
||||
$specialtyService->ensureDefaultModulesProvisioned(
|
||||
$organization,
|
||||
(string) $organization->owner_ref,
|
||||
);
|
||||
$specialtyIcon = '<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.714c0 .846.344 1.657.955 2.247L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-1.659 1.659a2.25 2.25 0 0 1-1.591.659H8.25" />';
|
||||
foreach ($specialtyService->enabledModulesForMember($organization, $member) as $item) {
|
||||
$key = $item['key'];
|
||||
$label = $item['definition']['nav_label'] ?? $item['definition']['label'] ?? $key;
|
||||
$nav[] = [
|
||||
'name' => $label,
|
||||
'route' => route('care.specialty.workspace', $key),
|
||||
'active' => request()->routeIs('care.specialty.*') && request()->route('module') === $key,
|
||||
'icon' => $specialtyIcon,
|
||||
'group' => 'specialty',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +121,12 @@
|
||||
@endphp
|
||||
|
||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
@php $specialtyHeadingShown = false; @endphp
|
||||
@foreach ($nav as $item)
|
||||
@if (($item['group'] ?? null) === 'specialty' && ! $specialtyHeadingShown)
|
||||
<p class="mb-1 mt-4 px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Specialty</p>
|
||||
@php $specialtyHeadingShown = true; @endphp
|
||||
@endif
|
||||
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
<svg class="{{ $item['iconSizeClass'] ?? 'h-[18px] w-[18px]' }} shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" viewBox="{{ $item['iconViewBox'] ?? '0 0 24 24' }}" fill="none" stroke="currentColor" stroke-width="{{ $item['iconStrokeWidth'] ?? 1.5 }}">{!! $item['icon'] !!}</svg>
|
||||
<span>{{ $item['name'] }}</span>
|
||||
|
||||
@@ -147,6 +147,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
Route::get('/specialty/emergency/workspace/{visit}/print', [EmergencyWorkspaceController::class, 'printSummary'])->name('care.specialty.emergency.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');
|
||||
|
||||
Route::get('/lab/requests', [InvestigationController::class, 'index'])->name('care.lab.requests.index');
|
||||
Route::get('/lab/queue', [InvestigationController::class, 'queue'])->name('care.lab.queue.index');
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
<?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\Practitioner;
|
||||
use App\Models\User;
|
||||
use App\Services\Care\SpecialtyModuleService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CareSpecialtyAccessTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $owner;
|
||||
|
||||
protected Organization $organization;
|
||||
|
||||
protected Branch $branch;
|
||||
|
||||
protected SpecialtyModuleService $modules;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||
|
||||
$this->owner = User::create([
|
||||
'public_id' => 'access-owner',
|
||||
'name' => 'Owner',
|
||||
'email' => 'access-owner@example.com',
|
||||
]);
|
||||
|
||||
$this->organization = Organization::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'name' => 'Access Clinic',
|
||||
'slug' => 'access-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',
|
||||
]);
|
||||
|
||||
$this->branch = Branch::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'name' => 'Main',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->modules = app(SpecialtyModuleService::class);
|
||||
$this->modules->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id);
|
||||
foreach (['cardiology', 'infusion', 'pathology', 'dentistry'] as $key) {
|
||||
$this->modules->activate($this->organization->fresh(), $this->owner->public_id, $key);
|
||||
}
|
||||
$this->organization->refresh();
|
||||
}
|
||||
|
||||
protected function makeStaff(string $role, string $suffix): array
|
||||
{
|
||||
$user = User::create([
|
||||
'public_id' => 'access-'.$suffix,
|
||||
'name' => ucfirst($role).' '.$suffix,
|
||||
'email' => $suffix.'@example.com',
|
||||
]);
|
||||
$member = Member::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'user_ref' => $user->public_id,
|
||||
'role' => $role,
|
||||
'branch_id' => $this->branch->id,
|
||||
]);
|
||||
|
||||
return [$user, $member];
|
||||
}
|
||||
|
||||
public function test_nurse_and_pharmacist_manage_general_modules(): void
|
||||
{
|
||||
[, $nurse] = $this->makeStaff('nurse', 'nurse1');
|
||||
[, $pharmacist] = $this->makeStaff('pharmacist', 'rx1');
|
||||
[, $lab] = $this->makeStaff('lab_technician', 'lab1');
|
||||
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $nurse, 'emergency'));
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $pharmacist, 'emergency'));
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $pharmacist, 'infusion'));
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'pathology'));
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'blood_bank'));
|
||||
$this->assertFalse($this->modules->memberCanManage($this->organization, $pharmacist, 'cardiology'));
|
||||
}
|
||||
|
||||
public function test_gp_doctor_manages_general_but_only_views_restricted(): void
|
||||
{
|
||||
[$doctor, $member] = $this->makeStaff('doctor', 'gp1');
|
||||
Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'member_id' => $member->id,
|
||||
'user_ref' => $doctor->public_id,
|
||||
'name' => 'Dr GP',
|
||||
'specialty' => 'General Practice',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'emergency'));
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'dentistry'));
|
||||
$this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'cardiology'));
|
||||
$this->assertTrue($this->modules->memberCanView($this->organization, $member, 'cardiology'));
|
||||
$this->assertTrue($this->modules->memberCanRefer($this->organization, $member, 'cardiology'));
|
||||
$this->assertSame('refer', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology'));
|
||||
}
|
||||
|
||||
public function test_cardiologist_manages_restricted_cardiology(): void
|
||||
{
|
||||
[$doctor, $member] = $this->makeStaff('doctor', 'cardio1');
|
||||
$dept = Department::query()->where('type', 'cardiology')->firstOrFail();
|
||||
Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'department_id' => $dept->id,
|
||||
'member_id' => $member->id,
|
||||
'user_ref' => $doctor->public_id,
|
||||
'name' => 'Dr Cardio',
|
||||
'specialty' => 'Cardiology',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'cardiology'));
|
||||
$this->assertSame('manage', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology'));
|
||||
}
|
||||
|
||||
public function test_gp_can_refer_into_restricted_specialty_queue(): void
|
||||
{
|
||||
[$doctor, $member] = $this->makeStaff('doctor', 'gp2');
|
||||
Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'member_id' => $member->id,
|
||||
'user_ref' => $doctor->public_id,
|
||||
'name' => 'Dr GP2',
|
||||
'specialty' => 'General Practice',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$patient = Patient::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-REF-1',
|
||||
'first_name' => 'Ama',
|
||||
'last_name' => 'Refer',
|
||||
'gender' => 'female',
|
||||
'date_of_birth' => '1990-01-01',
|
||||
]);
|
||||
|
||||
$this->actingAs($doctor)
|
||||
->post(route('care.specialty.refer', 'cardiology'), [
|
||||
'patient_id' => $patient->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'reason' => 'Chest pain review',
|
||||
])
|
||||
->assertRedirect(route('care.queue.index'));
|
||||
|
||||
$this->assertDatabaseHas('care_appointments', [
|
||||
'patient_id' => $patient->id,
|
||||
'reason' => 'Chest pain review',
|
||||
'status' => Appointment::STATUS_WAITING,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_gp_cannot_save_clinical_on_restricted_module(): void
|
||||
{
|
||||
[$doctor, $member] = $this->makeStaff('doctor', 'gp3');
|
||||
Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'member_id' => $member->id,
|
||||
'user_ref' => $doctor->public_id,
|
||||
'name' => 'Dr GP3',
|
||||
'specialty' => 'General Practice',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$dept = Department::query()->where('type', 'cardiology')->firstOrFail();
|
||||
$patient = Patient::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-CARD-1',
|
||||
'first_name' => 'Kofi',
|
||||
'last_name' => 'Heart',
|
||||
'gender' => 'male',
|
||||
'date_of_birth' => '1980-01-01',
|
||||
]);
|
||||
$visit = \App\Models\Visit::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'status' => \App\Models\Visit::STATUS_IN_PROGRESS,
|
||||
'checked_in_at' => now(),
|
||||
]);
|
||||
Appointment::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'department_id' => $dept->id,
|
||||
'visit_id' => $visit->id,
|
||||
'type' => Appointment::TYPE_WALK_IN,
|
||||
'status' => Appointment::STATUS_IN_CONSULTATION,
|
||||
'scheduled_at' => now(),
|
||||
'checked_in_at' => now(),
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($doctor)
|
||||
->get(route('care.specialty.workspace', ['module' => 'cardiology', 'visit' => $visit]))
|
||||
->assertOk()
|
||||
->assertSee('view + refer');
|
||||
|
||||
$this->actingAs($doctor)
|
||||
->post(route('care.specialty.clinical.save', ['module' => 'cardiology', 'visit' => $visit]), [
|
||||
'tab' => 'exam',
|
||||
'payload' => [
|
||||
'chief_complaint' => 'Should not save',
|
||||
],
|
||||
])
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_admin_keeps_full_manage_access(): void
|
||||
{
|
||||
$admin = Member::query()->where('user_ref', $this->owner->public_id)->firstOrFail();
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $admin, 'cardiology'));
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $admin, 'emergency'));
|
||||
}
|
||||
}
|
||||
@@ -231,14 +231,14 @@ class CareSpecialtyModulesTest extends TestCase
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->get(route('care.specialty.show', 'dentistry'))
|
||||
->assertForbidden();
|
||||
->assertRedirect(route('care.queue.index'));
|
||||
|
||||
$this->actingAs($nurse)
|
||||
->get(route('care.specialty.show', 'dentistry'))
|
||||
->assertRedirect(route('care.queue.index'));
|
||||
}
|
||||
|
||||
public function test_unassigned_doctor_cannot_open_specialty_module(): void
|
||||
public function test_unassigned_gp_can_open_limited_specialty_module(): void
|
||||
{
|
||||
Http::fake();
|
||||
app(SpecialtyModuleService::class)->activate($this->organization, $this->owner->public_id, 'dentistry');
|
||||
@@ -258,7 +258,7 @@ class CareSpecialtyModulesTest extends TestCase
|
||||
|
||||
$this->actingAs($doctor)
|
||||
->get(route('care.specialty.show', 'dentistry'))
|
||||
->assertForbidden();
|
||||
->assertRedirect(route('care.queue.index'));
|
||||
}
|
||||
|
||||
public function test_assigned_specialty_doctor_can_open_module(): void
|
||||
@@ -302,11 +302,20 @@ class CareSpecialtyModulesTest extends TestCase
|
||||
'dentistry',
|
||||
),
|
||||
);
|
||||
// Restricted modules stay off for GPs without specialty assignment.
|
||||
$this->assertFalse(
|
||||
app(SpecialtyModuleService::class)->memberCanAccess(
|
||||
app(SpecialtyModuleService::class)->memberCanManage(
|
||||
$this->organization->fresh(),
|
||||
$member,
|
||||
'maternity',
|
||||
'cardiology',
|
||||
),
|
||||
);
|
||||
app(SpecialtyModuleService::class)->activate($this->organization->fresh(), $this->owner->public_id, 'cardiology');
|
||||
$this->assertTrue(
|
||||
app(SpecialtyModuleService::class)->memberCanRefer(
|
||||
$this->organization->fresh(),
|
||||
$member,
|
||||
'cardiology',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -348,7 +357,7 @@ class CareSpecialtyModulesTest extends TestCase
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->get(route('care.specialty.show', 'emergency'))
|
||||
->assertForbidden();
|
||||
->assertRedirect(route('care.queue.index'));
|
||||
|
||||
$this->actingAs($nurse)
|
||||
->get(route('care.specialty.show', 'emergency'))
|
||||
|
||||
Reference in New Issue
Block a user