Embed Ladill Queue on role pages and add specialty modules under Settings.
Deploy Ladill Care / deploy (push) Successful in 53s

Remove the standalone Service Queues nav so call-next/now-serving lives on clinical queue, appointments, pharmacy, lab, and specialty surfaces; Pro orgs can activate dentistry and related modules with branch departments and queue stubs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 15:55:47 +00:00
co-authored by Cursor
parent e0a7a64d38
commit dec282d25d
30 changed files with 1552 additions and 35 deletions
@@ -11,6 +11,7 @@ use App\Models\Patient;
use App\Models\Practitioner;
use App\Services\Care\AppointmentService;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\ServiceQueuePresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -21,13 +22,15 @@ class AppointmentController extends Controller
public function __construct(
protected AppointmentService $appointments,
protected ServiceQueuePresenter $serviceQueues,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'appointments.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$appointments = $this->appointments->list(
$this->ownerRef($request),
@@ -58,6 +61,13 @@ class AppointmentController extends Controller
'practitioners' => $practitioners,
'statuses' => config('care.appointment_statuses'),
'heroStats' => $heroStats,
'serviceQueuePanel' => $this->serviceQueues->panel(
$request,
$organization,
$member,
'clinical',
$request->input('counter_uuid'),
),
]);
}
@@ -12,6 +12,8 @@ use App\Services\Care\AppointmentService;
use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\ReportService;
use App\Services\Care\ServiceQueuePresenter;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\View\View;
@@ -24,6 +26,8 @@ class DashboardController extends Controller
protected ReportService $reports,
protected CarePermissions $permissions,
protected AppointmentService $appointments,
protected ServiceQueuePresenter $serviceQueues,
protected SpecialtyModuleService $specialtyModules,
) {}
public function index(Request $request): View
@@ -76,6 +80,12 @@ class DashboardController extends Controller
? $this->patientQueueForMember($request, $organization->id, $owner, $member, $branchScope)
: [collect(), collect(), null];
$serviceQueueContext = match ($member?->role) {
'pharmacist' => 'pharmacy',
'lab_technician' => 'laboratory',
default => 'clinical',
};
return view('care.dashboard', [
'organization' => $organization,
'member' => $member,
@@ -93,6 +103,14 @@ class DashboardController extends Controller
'patientQueue' => $queue,
'inConsultation' => $inConsultation,
'practitionerId' => $practitionerId,
'serviceQueuePanel' => $this->serviceQueues->panel(
$request,
$organization,
$member,
$serviceQueueContext,
$request->input('counter_uuid'),
),
'specialtyModules' => $this->specialtyModules->enabledModules($organization),
]);
}
@@ -11,6 +11,7 @@ use App\Models\InvestigationType;
use App\Models\Member;
use App\Services\Care\InvestigationService;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\ServiceQueuePresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -21,6 +22,7 @@ class InvestigationController extends Controller
public function __construct(
protected InvestigationService $investigations,
protected ServiceQueuePresenter $serviceQueues,
) {}
public function index(Request $request): View
@@ -74,6 +76,13 @@ class InvestigationController extends Controller
->where('organization_id', $organization->id)
->orderBy('role')
->get(),
'serviceQueuePanel' => $this->serviceQueues->panel(
$request,
$organization,
$this->member($request),
'laboratory',
$request->input('counter_uuid'),
),
]);
}
@@ -10,6 +10,7 @@ use App\Models\Practitioner;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PharmacyService;
use App\Services\Care\PrescriptionService;
use App\Services\Care\ServiceQueuePresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -21,6 +22,7 @@ class PrescriptionController extends Controller
public function __construct(
protected PrescriptionService $prescriptions,
protected PharmacyService $pharmacy,
protected ServiceQueuePresenter $serviceQueues,
) {}
public function index(Request $request): View
@@ -65,6 +67,13 @@ class PrescriptionController extends Controller
'queue' => $queue,
'drugs' => $drugs,
'canDispense' => $canDispense,
'serviceQueuePanel' => $this->serviceQueues->panel(
$request,
$organization,
$this->member($request),
'pharmacy',
$request->input('counter_uuid'),
),
]);
}
+11 -1
View File
@@ -10,6 +10,7 @@ use App\Models\Practitioner;
use App\Services\Care\AppointmentService;
use App\Services\Care\ConsultationService;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\ServiceQueuePresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -21,13 +22,15 @@ class QueueController extends Controller
public function __construct(
protected AppointmentService $appointments,
protected ConsultationService $consultations,
protected ServiceQueuePresenter $serviceQueues,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'appointments.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
@@ -82,6 +85,13 @@ class QueueController extends Controller
'canManageQueue' => $canManageQueue,
'canConsult' => $canConsult,
'heroStats' => $heroStats,
'serviceQueuePanel' => $this->serviceQueues->panel(
$request,
$organization,
$member,
'clinical',
$request->input('counter_uuid'),
),
]);
}
@@ -4,7 +4,7 @@ namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Care\ServiceQueuePresenter;
use App\Services\Queue\QueueClient;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\RedirectResponse;
@@ -15,40 +15,19 @@ class ServiceQueueController extends Controller
{
use ScopesToAccount;
public function index(Request $request, QueueClient $queue): View|RedirectResponse
public function index(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'service_queues.console');
$organization = $this->organization($request);
if (! $this->integrationEnabled($organization)) {
return redirect()->route('care.settings')->with('error', 'Enable Ladill Queue integration in settings first.');
}
$owner = $this->ownerRef($request);
try {
$counters = $queue->counters($owner);
} catch (RequestException $e) {
if ($queue->configured() && $e->response?->status() === 403) {
try {
$queue->syncIntegration($owner, true);
$counters = $queue->counters($owner);
} catch (RequestException) {
return redirect()->route('care.settings')->with('error', 'Ladill Queue integration is not enabled. Save settings with Queue integration checked, and enable Care in Queue settings.');
}
} else {
return redirect()->route('care.settings')->with('error', 'Could not reach Ladill Queue. Check API keys and enable integration in Queue settings.');
}
}
return view('care.service-queues.index', compact('counters', 'organization'));
// Natural Queue: counters live on role pages (clinical queue, pharmacy, lab, specialties).
return redirect()->route('care.queue.index');
}
public function console(Request $request, string $counterUuid, QueueClient $queue): View|RedirectResponse
{
$this->authorizeAbility($request, 'service_queues.console');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$owner = (string) $organization->owner_ref;
if (! $this->integrationEnabled($organization)) {
return redirect()->route('care.settings');
@@ -57,7 +36,7 @@ class ServiceQueueController extends Controller
try {
$state = $queue->console($owner, $counterUuid);
} catch (RequestException) {
return redirect()->route('care.service-queues.index')->with('error', 'Counter console unavailable.');
return redirect()->route('care.queue.index')->with('error', 'Counter console unavailable.');
}
return view('care.service-queues.console', [
@@ -70,7 +49,8 @@ class ServiceQueueController extends Controller
public function action(Request $request, string $counterUuid, QueueClient $queue): RedirectResponse
{
$this->authorizeAbility($request, 'service_queues.console');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$owner = (string) $organization->owner_ref;
$validated = $request->validate([
'action' => ['required', 'string'],
@@ -58,6 +58,7 @@ class SettingsController extends Controller
'hasBranchesFeature' => $plans->hasFeature($organization, 'branches'),
'hasClinicalDevicesFeature' => $plans->hasFeature($organization, 'clinical_devices'),
'canUseQueueIntegration' => $plans->canUseQueueIntegration($organization),
'canUseSpecialtyModules' => $plans->canUseSpecialtyModules($organization),
'assessmentsEngineEnabled' => $careFeatures->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE),
'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(),
'messagingEmailReady' => $suiteEmailReady || $credential->hasValidBird(),
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Services\Care\PlanService;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SettingsModulesController extends Controller
{
use ScopesToAccount;
public function edit(Request $request, SpecialtyModuleService $modules, PlanService $plans): View
{
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$member = $this->member($request);
$canManage = app(\App\Services\Care\CarePermissions::class)->can($member, 'settings.manage');
return view('care.settings.modules', [
'organization' => $organization,
'canManage' => $canManage,
'canUseSpecialtyModules' => $modules->canManage($organization),
'catalog' => $modules->catalog(),
'enabled' => collect($modules->enabledKeys($organization))->flip()->all(),
'planKey' => $plans->planKey($organization),
]);
}
public function update(Request $request, SpecialtyModuleService $modules): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
if (! $modules->canManage($organization)) {
return redirect()
->route('care.pro.index')
->with('upsell', 'Specialty modules are part of Care Pro. Upgrade to activate dentistry, eye care, and other specialty practice surfaces.');
}
$keys = array_keys($modules->catalog());
$desired = [];
foreach ($keys as $key) {
$desired[$key] = $request->boolean("modules.{$key}");
}
$result = $modules->sync($organization, $this->ownerRef($request), $desired);
if ($result['errors'] !== []) {
return back()->with('error', 'Some modules could not be updated: '.implode(' ', $result['errors']));
}
$parts = [];
if ($result['activated'] !== []) {
$parts[] = 'Activated '.count($result['activated']).' module(s)';
}
if ($result['deactivated'] !== []) {
$parts[] = 'Deactivated '.count($result['deactivated']).' module(s)';
}
return back()->with('success', $parts !== [] ? implode('. ', $parts).'.' : 'Modules saved.');
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Appointment;
use App\Models\Department;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\ServiceQueuePresenter;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SpecialtyModuleController extends Controller
{
use ScopesToAccount;
public function show(
Request $request,
string $module,
SpecialtyModuleService $modules,
ServiceQueuePresenter $presenter,
): View {
$definition = $modules->definition($module);
abort_unless($definition, 404);
$organization = $this->organization($request);
abort_unless($modules->isEnabled($organization, $module), 404);
$roles = $definition['roles'] ?? [];
$member = $this->member($request);
if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) {
abort(403);
}
$owner = $this->ownerRef($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$departments = Department::owned($owner)
->where('type', $definition['department_type'] ?? 'general')
->where('is_active', true)
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->with('branch')
->orderBy('name')
->get();
$departmentIds = $departments->pluck('id')->all();
$waiting = Appointment::owned($owner)
->where('organization_id', $organization->id)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds))
->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0'))
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->with(['patient', 'practitioner', 'department'])
->orderBy('queue_position')
->orderBy('checked_in_at')
->limit(25)
->get();
$serviceQueuePanel = $presenter->panel(
$request,
$organization,
$member,
$module,
$request->input('counter_uuid'),
);
return view('care.specialty.show', [
'organization' => $organization,
'moduleKey' => $module,
'definition' => $definition,
'departments' => $departments,
'waiting' => $waiting,
'queueStubs' => $modules->queueStubsFor($organization, $module),
'serviceQueuePanel' => $serviceQueuePanel,
]);
}
}
+3
View File
@@ -21,6 +21,7 @@ class CarePermissions
'investigations.request', 'prescriptions.manage', 'lab.results.view',
'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage',
'service_queues.console',
],
'nurse' => [
'dashboard.view', 'patients.view', 'appointments.view',
@@ -30,10 +31,12 @@ class CarePermissions
],
'lab_technician' => [
'dashboard.view', 'patients.view', 'lab.view', 'lab.manage',
'service_queues.console',
],
'pharmacist' => [
'dashboard.view', 'patients.view', 'prescriptions.view', 'prescriptions.dispense',
'pharmacy.view', 'pharmacy.manage',
'service_queues.console',
],
'cashier' => [
'dashboard.view', 'patients.view', 'bills.view', 'bills.manage', 'payments.manage',
+17
View File
@@ -102,6 +102,9 @@ class DemoTenantSeeder
$branches = $this->seedBranches($organization, $ownerRef, $volumes);
$this->seedStaffMembers($organization, $ownerRef, $plan, $branches);
$departments = $this->seedDepartments($branches, $ownerRef, $volumes);
if (in_array($plan, ['pro', 'enterprise'], true)) {
$this->seedSpecialtyModules($organization, $ownerRef);
}
$practitioners = $this->seedPractitioners($organization, $branches, $departments, $ownerRef, $volumes);
$patients = $this->seedPatients($organization, $branches, $ownerRef, $volumes);
$appointmentCount = $this->seedAppointmentsAndClinical(
@@ -310,6 +313,11 @@ class DemoTenantSeeder
'plan_expires_at' => now()->addYear()->toIso8601String(),
'billed_branches' => max(1, $billedBranches),
'demo' => true,
// Pro/Enterprise demos: Queue on natural role pages + dentistry specialty module.
'queue_integration_enabled' => in_array($plan, ['pro', 'enterprise'], true),
'specialty_modules' => in_array($plan, ['pro', 'enterprise'], true)
? ['dentistry' => true]
: [],
]);
if ($organization) {
@@ -445,6 +453,15 @@ class DemoTenantSeeder
return $byBranch;
}
private function seedSpecialtyModules(Organization $organization, string $ownerRef): void
{
try {
app(SpecialtyModuleService::class)->activate($organization->fresh(), $ownerRef, 'dentistry');
} catch (\Throwable) {
// Demo seed should not fail if Queue API is unreachable; settings flag remains.
}
}
/**
* @param list<Branch> $branches
* @param array<int, list<Department>> $departments
+6
View File
@@ -53,6 +53,12 @@ class PlanService
return $this->hasPaidPlan($organization);
}
/** Specialty practice modules (dentistry, eye care, …) — Pro/Enterprise expansions. */
public function canUseSpecialtyModules(Organization $organization): bool
{
return $this->hasFeature($organization, 'specialty_modules');
}
/** Own payment gateway (Paystack / Flutterwave / Hubtel) for encounter billing requires Pro or Enterprise. */
public function canUsePaymentGateway(Organization $organization): bool
{
+249
View File
@@ -0,0 +1,249 @@
<?php
namespace App\Services\Care;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Services\Queue\QueueClient;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Request;
/**
* Loads Ladill Queue console state for embedding on role-native pages
* (clinical queue, pharmacy, lab, specialty modules) instead of a standalone Service Queues nav.
*/
class ServiceQueuePresenter
{
/** @var array<string, list<string>> */
protected array $contextKeywords = [
'clinical' => ['reception', 'consult', 'outpatient', 'general', 'doctor', 'nurse', 'triage', 'waiting'],
'pharmacy' => ['pharm', 'dispens', 'drug', 'rx'],
'laboratory' => ['lab', 'sample', 'patholog', 'investig'],
'dentistry' => ['dent', 'dental', 'oral'],
'ophthalmology' => ['eye', 'ophthal', 'optom'],
'physiotherapy' => ['physio', 'rehab', 'therapy'],
'maternity' => ['matern', 'antenatal', 'obstetric'],
'radiology' => ['radio', 'imaging', 'x-ray', 'xray', 'ultrasound', 'ct', 'mri'],
];
public function __construct(
protected QueueClient $queue,
protected CarePermissions $permissions,
protected PlanService $plans,
protected OrganizationResolver $resolver,
) {}
/**
* @return array{
* enabled: bool,
* counters: list<array<string, mixed>>,
* counter_uuid: ?string,
* state: ?array<string, mixed>,
* stubs: list<array<string, mixed>>,
* error: ?string,
* context: string
* }|null
*/
public function panel(
Request $request,
Organization $organization,
?Member $member,
string $context = 'clinical',
?string $preferredCounterUuid = null,
): ?array {
if (! $this->shouldShow($organization, $member)) {
return null;
}
$owner = (string) $organization->owner_ref;
$branchScope = $this->resolver->branchScope($member);
$branchName = null;
if ($branchScope) {
$branchName = Branch::owned($owner)->where('id', $branchScope)->value('name');
}
$stubs = $this->stubsForContext($organization, $context, $branchScope);
if (! $this->queue->configured()) {
return [
'enabled' => true,
'counters' => [],
'counter_uuid' => null,
'state' => null,
'stubs' => $stubs,
'error' => 'Ladill Queue API is not configured on this Care instance.',
'context' => $context,
];
}
try {
$counters = $this->queue->counters($owner);
} catch (RequestException $e) {
if ($e->response?->status() === 403) {
try {
$this->queue->syncIntegration($owner, true);
$counters = $this->queue->counters($owner);
} catch (RequestException) {
return [
'enabled' => true,
'counters' => [],
'counter_uuid' => null,
'state' => null,
'stubs' => $stubs,
'error' => 'Queue integration is not linked. Re-save Settings with Queue enabled.',
'context' => $context,
];
}
} else {
return [
'enabled' => true,
'counters' => [],
'counter_uuid' => null,
'state' => null,
'stubs' => $stubs,
'error' => 'Could not reach Ladill Queue.',
'context' => $context,
];
}
}
$counters = $this->filterCounters($counters, $branchName, $context);
$counterUuid = $preferredCounterUuid
?: (string) ($counters[0]['uuid'] ?? '');
if ($counterUuid !== '' && ! collect($counters)->contains(fn ($c) => ($c['uuid'] ?? '') === $counterUuid)) {
$counterUuid = (string) ($counters[0]['uuid'] ?? '');
}
$state = null;
if ($counterUuid !== '') {
try {
$state = $this->queue->console($owner, $counterUuid);
} catch (RequestException) {
return [
'enabled' => true,
'counters' => $counters,
'counter_uuid' => $counterUuid,
'state' => null,
'stubs' => $stubs,
'error' => 'Counter console unavailable.',
'context' => $context,
];
}
}
return [
'enabled' => true,
'counters' => $counters,
'counter_uuid' => $counterUuid !== '' ? $counterUuid : null,
'state' => $state,
'stubs' => $stubs,
'error' => null,
'context' => $context,
];
}
public function shouldShow(Organization $organization, ?Member $member): bool
{
if (! $member) {
return false;
}
if (! $this->plans->canUseQueueIntegration($organization)) {
return false;
}
if (! data_get($organization->settings, 'queue_integration_enabled')) {
return false;
}
return $this->permissions->can($member, 'service_queues.console');
}
/**
* @param list<array<string, mixed>> $counters
* @return list<array<string, mixed>>
*/
protected function filterCounters(array $counters, ?string $branchName, string $context): array
{
$keywords = $this->keywordsFor($context);
$filtered = collect($counters);
if ($branchName) {
$branchFiltered = $filtered->filter(function ($counter) use ($branchName) {
$counterBranch = (string) ($counter['branch'] ?? '');
return $counterBranch === '' || strcasecmp($counterBranch, $branchName) === 0;
});
if ($branchFiltered->isNotEmpty()) {
$filtered = $branchFiltered;
}
}
if ($keywords !== []) {
$keywordFiltered = $filtered->filter(function ($counter) use ($keywords) {
$haystack = strtolower(implode(' ', [
(string) ($counter['name'] ?? ''),
collect($counter['queues'] ?? [])->pluck('name')->implode(' '),
]));
foreach ($keywords as $keyword) {
if ($keyword !== '' && str_contains($haystack, $keyword)) {
return true;
}
}
return false;
});
// Fall back to branch-scoped counters when no keyword match (demo / generic counters).
if ($keywordFiltered->isNotEmpty()) {
$filtered = $keywordFiltered;
}
}
return $filtered->values()->all();
}
/**
* @return list<string>
*/
protected function keywordsFor(string $context): array
{
if (isset($this->contextKeywords[$context])) {
return $this->contextKeywords[$context];
}
$module = config("care.specialty_modules.{$context}.queue_keywords");
return is_array($module) ? $module : [];
}
/**
* @return list<array<string, mixed>>
*/
protected function stubsForContext(Organization $organization, string $context, ?int $branchId): array
{
if (! array_key_exists($context, config('care.specialty_modules', []))) {
return [];
}
$queues = data_get($organization->settings, "specialty_module_provisioning.{$context}.queues", []);
if (! is_array($queues)) {
return [];
}
return array_values(array_filter($queues, function ($queue) use ($branchId) {
if (! ($queue['active'] ?? false)) {
return false;
}
if ($branchId !== null && (int) ($queue['branch_id'] ?? 0) !== $branchId) {
return false;
}
return true;
}));
}
}
@@ -0,0 +1,291 @@
<?php
namespace App\Services\Care;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Organization;
use App\Services\Queue\QueueClient;
use Illuminate\Support\Facades\Log;
/**
* Activate/deactivate specialty practice modules (dentistry, eye care, ).
*
* Persistence: organization.settings.specialty_modules[key] = bool
* Provisioning: departments per branch + Care-side queue stubs (and best-effort Queue API sync).
* Deactivate hides UI and marks queues inactive; does not destroy clinical history.
*
* Plan gate: Pro/Enterprise via PlanService feature specialty_modules (same tier as queue_integration).
*/
class SpecialtyModuleService
{
public function __construct(
protected PlanService $plans,
protected QueueClient $queue,
) {}
/**
* @return array<string, array<string, mixed>>
*/
public function catalog(): array
{
return config('care.specialty_modules', []);
}
public function definition(string $key): ?array
{
$catalog = $this->catalog();
return $catalog[$key] ?? null;
}
public function isEnabled(Organization $organization, string $key): bool
{
if (! $this->definition($key)) {
return false;
}
return (bool) data_get($organization->settings, "specialty_modules.{$key}", false);
}
/**
* @return list<string>
*/
public function enabledKeys(Organization $organization): array
{
$enabled = [];
foreach (array_keys($this->catalog()) as $key) {
if ($this->isEnabled($organization, $key)) {
$enabled[] = $key;
}
}
return $enabled;
}
/**
* @return list<array{key: string, definition: array<string, mixed>}>
*/
public function enabledModules(Organization $organization): array
{
$out = [];
foreach ($this->enabledKeys($organization) as $key) {
$definition = $this->definition($key);
if ($definition) {
$out[] = ['key' => $key, 'definition' => $definition];
}
}
return $out;
}
public function canManage(Organization $organization): bool
{
return $this->plans->hasFeature($organization, 'specialty_modules');
}
/**
* @param array<string, bool> $desired module key => enabled
* @return array{activated: list<string>, deactivated: list<string>, errors: list<string>}
*/
public function sync(Organization $organization, string $ownerRef, array $desired): array
{
$activated = [];
$deactivated = [];
$errors = [];
foreach ($this->catalog() as $key => $definition) {
$want = (bool) ($desired[$key] ?? false);
$have = $this->isEnabled($organization, $key);
if ($want === $have) {
continue;
}
try {
if ($want) {
$this->activate($organization, $ownerRef, $key);
$activated[] = $key;
} else {
$this->deactivate($organization, $ownerRef, $key);
$deactivated[] = $key;
}
$organization->refresh();
} catch (\Throwable $e) {
Log::warning('specialty_module.sync_failed', [
'key' => $key,
'want' => $want,
'message' => $e->getMessage(),
]);
$errors[] = ($definition['label'] ?? $key).': '.$e->getMessage();
}
}
return compact('activated', 'deactivated', 'errors');
}
public function activate(Organization $organization, string $ownerRef, string $key): void
{
$definition = $this->definition($key);
if (! $definition) {
throw new \InvalidArgumentException("Unknown specialty module [{$key}].");
}
if (! $this->canManage($organization)) {
throw new \RuntimeException('Specialty modules require Care Pro or Enterprise.');
}
$settings = $organization->settings ?? [];
$modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : [];
$modules[$key] = true;
$settings['specialty_modules'] = $modules;
$departments = $this->provisionDepartments($organization, $ownerRef, $definition);
$queueStubs = $this->provisionQueueStubs($organization, $ownerRef, $key, $definition);
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
? $settings['specialty_module_provisioning']
: [];
$provisioning[$key] = [
'active' => true,
'department_ids' => $departments,
'queues' => $queueStubs,
'activated_at' => now()->toIso8601String(),
];
$settings['specialty_module_provisioning'] = $provisioning;
$organization->update(['settings' => $settings]);
if (data_get($organization->fresh()->settings, 'queue_integration_enabled') && $this->queue->configured()) {
$this->queue->syncSpecialtyQueueStubs($ownerRef, $queueStubs);
}
}
public function deactivate(Organization $organization, string $ownerRef, string $key): void
{
if (! $this->definition($key)) {
throw new \InvalidArgumentException("Unknown specialty module [{$key}].");
}
$settings = $organization->settings ?? [];
$modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : [];
$modules[$key] = false;
$settings['specialty_modules'] = $modules;
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
? $settings['specialty_module_provisioning']
: [];
$record = is_array($provisioning[$key] ?? null) ? $provisioning[$key] : [];
$departmentIds = $record['department_ids'] ?? [];
if (is_array($departmentIds) && $departmentIds !== []) {
Department::owned($ownerRef)
->whereIn('id', $departmentIds)
->update(['is_active' => false]);
}
$queues = is_array($record['queues'] ?? null) ? $record['queues'] : [];
foreach ($queues as $i => $queue) {
$queues[$i]['active'] = false;
}
$provisioning[$key] = array_merge($record, [
'active' => false,
'queues' => $queues,
'deactivated_at' => now()->toIso8601String(),
]);
$settings['specialty_module_provisioning'] = $provisioning;
$organization->update(['settings' => $settings]);
}
/**
* @param array<string, mixed> $definition
* @return list<int>
*/
protected function provisionDepartments(Organization $organization, string $ownerRef, array $definition): array
{
$type = (string) ($definition['department_type'] ?? 'general');
$name = (string) ($definition['department_name'] ?? $definition['label'] ?? 'Specialty');
$branches = Branch::owned($ownerRef)
->where('organization_id', $organization->id)
->where('is_active', true)
->get();
$ids = [];
foreach ($branches as $branch) {
$department = Department::withTrashed()
->owned($ownerRef)
->where('branch_id', $branch->id)
->where('type', $type)
->first();
if ($department) {
if ($department->trashed()) {
$department->restore();
}
$department->update([
'name' => $name,
'is_active' => true,
]);
} else {
$department = Department::create([
'owner_ref' => $ownerRef,
'branch_id' => $branch->id,
'name' => $name,
'type' => $type,
'is_active' => true,
]);
}
$ids[] = (int) $department->id;
}
return $ids;
}
/**
* Care-side queue stubs (branch-aware). Full counter creation lives in Queue admin;
* stubs document intended queues and feed embedded panels / future API sync.
*
* @param array<string, mixed> $definition
* @return list<array<string, mixed>>
*/
protected function provisionQueueStubs(
Organization $organization,
string $ownerRef,
string $key,
array $definition,
): array {
$branches = Branch::owned($ownerRef)
->where('organization_id', $organization->id)
->where('is_active', true)
->get();
$stubs = [];
foreach ($branches as $branch) {
$stubs[] = [
'module' => $key,
'branch_id' => $branch->id,
'branch_name' => $branch->name,
'name' => (string) ($definition['queue_name'] ?? $definition['label']),
'prefix' => (string) ($definition['queue_prefix'] ?? strtoupper(substr($key, 0, 3))),
'active' => true,
'synced' => false,
];
}
return $stubs;
}
/**
* @return list<array<string, mixed>>
*/
public function queueStubsFor(Organization $organization, string $key): array
{
$queues = data_get($organization->settings, "specialty_module_provisioning.{$key}.queues", []);
return is_array($queues) ? array_values(array_filter($queues, fn ($q) => (bool) ($q['active'] ?? false))) : [];
}
}
+31
View File
@@ -122,4 +122,35 @@ class QueueClient
return (array) ($response->json('data') ?? []);
}
/**
* Best-effort specialty queue sync. Care integration API is read/console-only today,
* so stubs remain Care-side until Queue exposes create-queue for service tokens.
*
* @param list<array<string, mixed>> $stubs
* @return list<array<string, mixed>>
*/
public function syncSpecialtyQueueStubs(string $owner, array $stubs): array
{
if (! $this->configured() || $stubs === []) {
return $stubs;
}
try {
$existing = collect($this->counters($owner))
->flatMap(fn ($c) => $c['queues'] ?? [])
->map(fn ($q) => strtolower((string) ($q['name'] ?? '')))
->filter()
->all();
} catch (\Throwable) {
return $stubs;
}
foreach ($stubs as $i => $stub) {
$name = strtolower((string) ($stub['name'] ?? ''));
$stubs[$i]['synced'] = $name !== '' && in_array($name, $existing, true);
}
return $stubs;
}
}
+66
View File
@@ -23,9 +23,73 @@ return [
'pharmacy' => 'Pharmacy',
'maternity' => 'Maternity',
'dental' => 'Dental',
'ophthalmology' => 'Ophthalmology / Eye care',
'physiotherapy' => 'Physiotherapy',
],
/*
| Specialty practice modules (Settings Modules).
| Pro-gated via plans.*.features specialty_modules expansions beyond core
| outpatient/lab/pharmacy belong on paid plans, matching queue_integration.
*/
'specialty_modules' => [
'dentistry' => [
'label' => 'Dentistry',
'description' => 'Dental chairs, oral consults, and dentistry waiting lists.',
'department_type' => 'dental',
'department_name' => 'Dentistry',
'queue_name' => 'Dentistry',
'queue_prefix' => 'DEN',
'nav_label' => 'Dentistry',
'queue_keywords' => ['dent', 'dental', 'oral'],
'roles' => ['doctor', 'nurse', 'receptionist', 'hospital_admin', 'super_admin'],
],
'ophthalmology' => [
'label' => 'Eye care',
'description' => 'Ophthalmology and optometry clinics with dedicated queues.',
'department_type' => 'ophthalmology',
'department_name' => 'Eye care',
'queue_name' => 'Eye care',
'queue_prefix' => 'EYE',
'nav_label' => 'Eye care',
'queue_keywords' => ['eye', 'ophthal', 'optom'],
'roles' => ['doctor', 'nurse', 'receptionist', 'hospital_admin', 'super_admin'],
],
'physiotherapy' => [
'label' => 'Physiotherapy',
'description' => 'Rehab sessions and physiotherapy treatment queues.',
'department_type' => 'physiotherapy',
'department_name' => 'Physiotherapy',
'queue_name' => 'Physiotherapy',
'queue_prefix' => 'PHY',
'nav_label' => 'Physiotherapy',
'queue_keywords' => ['physio', 'rehab', 'therapy'],
'roles' => ['doctor', 'nurse', 'receptionist', 'hospital_admin', 'super_admin'],
],
'maternity' => [
'label' => 'Maternity / Antenatal',
'description' => 'Antenatal clinics, maternity wards, and related queues.',
'department_type' => 'maternity',
'department_name' => 'Maternity',
'queue_name' => 'Maternity',
'queue_prefix' => 'MAT',
'nav_label' => 'Maternity',
'queue_keywords' => ['matern', 'antenatal', 'obstetric'],
'roles' => ['doctor', 'nurse', 'receptionist', 'hospital_admin', 'super_admin'],
],
'radiology' => [
'label' => 'Radiology',
'description' => 'Imaging requests and radiology service counters.',
'department_type' => 'radiology',
'department_name' => 'Radiology',
'queue_name' => 'Radiology',
'queue_prefix' => 'RAD',
'nav_label' => 'Radiology',
'queue_keywords' => ['radio', 'imaging', 'x-ray', 'xray', 'ultrasound', 'ct', 'mri'],
'roles' => ['doctor', 'lab_technician', 'receptionist', 'hospital_admin', 'super_admin'],
],
],
'audit_actions' => [
'organization.created' => 'Organization created',
'organization.updated' => 'Organization updated',
@@ -288,6 +352,7 @@ return [
'billing',
'queue_integration',
'clinical_devices',
'specialty_modules',
],
],
'enterprise' => [
@@ -303,6 +368,7 @@ return [
'billing',
'queue_integration',
'clinical_devices',
'specialty_modules',
// KD-18: org-level multi-patient assessment/outcome analytics
'assessment_analytics',
],
@@ -85,5 +85,7 @@
</div>
<div>{{ $appointments->links() }}</div>
@include('care.service-queues._panel')
</div>
</x-app-layout>
+21
View File
@@ -192,6 +192,27 @@
</section>
@endif
@if (! empty($serviceQueuePanel['enabled']))
<div class="mt-6">
@include('care.service-queues._panel')
</div>
@endif
@if (! empty($specialtyModules))
<section class="mt-6">
<h2 class="text-sm font-semibold text-slate-900">Specialty modules</h2>
<div class="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($specialtyModules as $item)
<a href="{{ route('care.specialty.show', $item['key']) }}"
class="rounded-2xl border border-slate-200 bg-white px-4 py-4 transition hover:border-indigo-300 hover:shadow-sm">
<p class="text-sm font-semibold text-slate-900">{{ $item['definition']['nav_label'] ?? $item['definition']['label'] }}</p>
<p class="mt-1 text-xs text-slate-500">{{ $item['definition']['description'] ?? '' }}</p>
</a>
@endforeach
</div>
</section>
@endif
@if (count($organizationCards) > 0)
@php
$orgCols = match (count($organizationCards)) {
@@ -42,4 +42,8 @@
<p class="rounded-2xl border border-dashed border-slate-200 bg-white p-8 text-center text-sm text-slate-500">Queue is empty.</p>
@endforelse
</div>
<div class="mt-6">
@include('care.service-queues._panel')
</div>
</x-app-layout>
@@ -56,4 +56,8 @@
<p class="rounded-2xl border border-dashed border-slate-200 bg-white p-8 text-center text-sm text-slate-500">No prescriptions in queue.</p>
@endforelse
</div>
<div class="mt-6">
@include('care.service-queues._panel')
</div>
</x-app-layout>
@@ -78,5 +78,7 @@
</div>
</section>
</div>
@include('care.service-queues._panel')
</div>
</x-app-layout>
@@ -0,0 +1,145 @@
{{-- Compact Ladill Queue panel for role-native pages (not a standalone Service Queues nav). --}}
@php
$panel = $serviceQueuePanel ?? null;
@endphp
@if (! empty($panel['enabled']))
@php
$counterUuid = $panel['counter_uuid'] ?? null;
$state = $panel['state'] ?? null;
$counters = $panel['counters'] ?? [];
$current = $state['current_ticket'] ?? null;
$queues = $state['queues'] ?? [];
$waiting = $state['waiting_by_queue'] ?? [];
$counter = $state['counter'] ?? [];
$stubs = $panel['stubs'] ?? [];
$statusLabel = match ($current['status'] ?? null) {
'called' => 'Called',
'serving' => 'Serving',
'on_hold' => 'On hold',
default => $current['status'] ?? null,
};
@endphp
<section class="rounded-2xl border border-indigo-100 bg-gradient-to-br from-indigo-50/80 via-white to-white shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-indigo-100 px-5 py-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Service counter</p>
<h2 class="mt-0.5 text-sm font-semibold text-slate-900">
{{ $counter['name'] ?? 'Ladill Queue' }}
@if (! empty($counter['branch']))
<span class="font-normal text-slate-500">· {{ $counter['branch'] }}</span>
@endif
</h2>
</div>
<div class="flex flex-wrap items-center gap-2">
@if (count($counters) > 1 && $counterUuid)
<form method="GET" class="flex items-center gap-2">
@foreach (request()->except('counter_uuid') as $key => $value)
@if (is_scalar($value))
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endif
@endforeach
<select name="counter_uuid" class="rounded-lg border-slate-300 text-xs" onchange="this.form.submit()">
@foreach ($counters as $option)
<option value="{{ $option['uuid'] }}" @selected(($option['uuid'] ?? '') === $counterUuid)>{{ $option['name'] ?? 'Counter' }}</option>
@endforeach
</select>
</form>
@endif
@if ($counterUuid)
<a href="{{ route('care.service-queues.console', $counterUuid) }}" class="text-xs font-medium text-indigo-600 hover:text-indigo-800">Full console</a>
@endif
</div>
</div>
<div class="space-y-4 px-5 py-4">
@if (! empty($panel['error']))
<p class="rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-900">{{ $panel['error'] }}</p>
@endif
@if ($current && $counterUuid)
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Now serving</p>
<p class="mt-1 text-3xl font-bold tracking-tight text-slate-900">{{ $current['ticket_number'] }}</p>
<p class="mt-1 text-sm text-slate-600">{{ $current['customer_name'] ?? 'Walk-in' }} · {{ $current['queue']['name'] ?? '' }}</p>
</div>
@if ($statusLabel)
<span class="rounded-full bg-indigo-100 px-2.5 py-1 text-xs font-semibold text-indigo-700">{{ $statusLabel }}</span>
@endif
</div>
<div class="flex flex-wrap gap-2">
@foreach ([
'start' => 'Start',
'complete' => 'Complete',
'recall' => 'Recall',
'skip' => 'Skip',
] as $action => $label)
<form method="POST" action="{{ route('care.service-queues.action', $counterUuid) }}">
@csrf
<input type="hidden" name="action" value="{{ $action }}">
<input type="hidden" name="ticket_uuid" value="{{ $current['uuid'] }}">
<button type="submit" class="{{ $action === 'complete' || $action === 'start' ? 'btn-primary' : 'btn-secondary' }} text-xs">{{ $label }}</button>
</form>
@endforeach
</div>
@elseif ($counterUuid)
<p class="text-sm text-slate-600">No active ticket. Call the next patient from a queue below.</p>
@elseif (count($stubs))
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p class="font-medium text-slate-800">Specialty queues ready</p>
<ul class="mt-2 space-y-1 text-xs">
@foreach ($stubs as $stub)
<li>
{{ $stub['name'] ?? 'Queue' }}
@if (! empty($stub['branch_name'])) · {{ $stub['branch_name'] }} @endif
@if (! empty($stub['prefix']))
<span class="font-mono text-slate-500">({{ $stub['prefix'] }})</span>
@endif
@if (! empty($stub['synced']))
<span class="text-emerald-600">· linked</span>
@else
<span class="text-slate-400">· create in Queue admin if missing</span>
@endif
</li>
@endforeach
</ul>
</div>
@elseif (! $panel['error'])
<p class="text-sm text-slate-500">No counters found for this branch. Create counters in Queue admin, then refresh.</p>
@endif
@if ($counterUuid && count($queues))
<div class="grid gap-3 sm:grid-cols-2">
@foreach ($queues as $queue)
@php $waitingTickets = $waiting[$queue['uuid']] ?? []; @endphp
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="flex items-center justify-between gap-2">
<div>
<p class="text-sm font-semibold text-slate-900">{{ $queue['name'] }}</p>
<p class="text-xs text-slate-500">{{ count($waitingTickets) }} waiting</p>
</div>
<form method="POST" action="{{ route('care.service-queues.action', $counterUuid) }}">
@csrf
<input type="hidden" name="action" value="call_next">
<input type="hidden" name="queue_uuid" value="{{ $queue['uuid'] }}">
<button type="submit" class="btn-primary text-xs">Call next</button>
</form>
</div>
@if (count($waitingTickets))
<ul class="mt-2 space-y-1 border-t border-slate-50 pt-2">
@foreach (array_slice($waitingTickets, 0, 3) as $ticket)
<li class="flex justify-between text-xs text-slate-600">
<span class="font-mono font-semibold text-slate-800">{{ $ticket['ticket_number'] }}</span>
<span class="truncate pl-2">{{ $ticket['customer_name'] ?? 'Walk-in' }}</span>
</li>
@endforeach
</ul>
@endif
</div>
@endforeach
</div>
@endif
</div>
</section>
@endif
@@ -18,7 +18,7 @@
<x-app-layout title="Console · {{ $counter['name'] ?? 'Counter' }}">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<a href="{{ route('care.service-queues.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Service queues</a>
<a href="{{ route('care.queue.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Patient queue</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">{{ $counter['name'] ?? 'Counter' }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ $counter['branch'] ?? '' }} · Ladill Queue console</p>
</div>
+14 -1
View File
@@ -101,6 +101,19 @@
</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">
<span>
Modules
@if (empty($canUseSpecialtyModules))
<span class="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Pro</span>
@endif
<span class="mt-0.5 block text-xs text-slate-500">Dentistry, eye care, physiotherapy, maternity, radiology</span>
</span>
<span class="text-slate-400">&rarr;</span>
</a>
</li>
</ul>
</x-settings.card>
@endif
@@ -194,7 +207,7 @@
</div>
</x-settings.card>
<x-settings.card title="Integrations" description="Connect Ladill Queue for service counters. Requires Care Pro or Enterprise — and Care must also be enabled in Queue settings.">
<x-settings.card title="Integrations" description="Connect Ladill Queue for service counters on clinical, pharmacy, and lab pages. Requires Care Pro or Enterprise — and Care must also be enabled in Queue settings.">
@if (! empty($canUseQueueIntegration))
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="queue_integration_enabled" value="1" @checked(data_get($organization->settings, 'queue_integration_enabled'))>
@@ -0,0 +1,53 @@
<x-app-layout title="Modules · Settings">
<x-settings.page title="Specialty modules" description="Activate specialty practice surfaces for {{ $organization->name }}. Departments and queues are provisioned per branch; deactivating hides UI without deleting clinical history.">
<p class="text-sm">
<a href="{{ route('care.settings') }}" class="font-medium text-indigo-600 hover:text-indigo-800"> Facility settings</a>
</p>
@if (session('success'))
<p class="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ session('success') }}</p>
@endif
@if (session('error'))
<p class="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-800">{{ session('error') }}</p>
@endif
@if (! $canUseSpecialtyModules)
<x-settings.card title="Pro feature" description="Specialty modules require Care Pro or Enterprise.">
<p class="text-sm text-slate-600">Unlock dentistry, eye care, physiotherapy, maternity, and radiology practice pages with matching departments and queue stubs.</p>
<a href="{{ route('care.pro.index') }}" class="btn-primary mt-4 inline-flex text-sm">View plans</a>
</x-settings.card>
@else
<form method="POST" action="{{ route('care.settings.modules.update') }}" class="space-y-6">
@csrf
@method('PUT')
<div class="grid gap-4 sm:grid-cols-2">
@foreach ($catalog as $key => $module)
<label class="flex cursor-pointer flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-indigo-200 {{ ! empty($enabled[$key]) ? 'ring-1 ring-indigo-200' : '' }}">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-slate-900">{{ $module['label'] }}</p>
<p class="mt-1 text-xs text-slate-500">{{ $module['description'] }}</p>
</div>
<input type="checkbox" name="modules[{{ $key }}]" value="1"
@checked(old("modules.{$key}", ! empty($enabled[$key])))
@disabled(! $canManage)
class="mt-0.5 rounded border-slate-300">
</div>
<p class="mt-3 text-[11px] uppercase tracking-wide text-slate-400">
Dept · {{ $module['department_name'] ?? $module['label'] }}
· Queue · {{ $module['queue_name'] ?? $module['label'] }}
</p>
</label>
@endforeach
</div>
@if ($canManage)
<div class="flex justify-end">
<button type="submit" class="btn-primary">Save modules</button>
</div>
@endif
</form>
@endif
</x-settings.page>
</x-app-layout>
@@ -0,0 +1,67 @@
<x-app-layout title="{{ $definition['label'] }}">
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Specialty module</p>
<h1 class="mt-1 text-xl font-semibold text-slate-900">{{ $definition['label'] }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ $definition['description'] ?? '' }}</p>
</div>
<a href="{{ route('care.settings.modules') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Manage modules</a>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Departments</h2>
<ul class="mt-3 space-y-2">
@forelse ($departments as $department)
<li class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-3 py-2 text-sm">
<span class="font-medium text-slate-800">{{ $department->name }}</span>
<span class="text-xs text-slate-500">{{ $department->branch?->name }}</span>
</li>
@empty
<li class="text-sm text-slate-500">No active departments for this module at your branch.</li>
@endforelse
</ul>
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Queue stubs</h2>
<ul class="mt-3 space-y-2 text-sm">
@forelse ($queueStubs as $stub)
<li class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span class="font-medium text-slate-800">{{ $stub['name'] ?? 'Queue' }}</span>
@if (! empty($stub['prefix']))
<span class="font-mono text-xs text-slate-500">({{ $stub['prefix'] }})</span>
@endif
<span class="mt-0.5 block text-xs text-slate-500">{{ $stub['branch_name'] ?? '' }}</span>
</li>
@empty
<li class="text-sm text-slate-500">No queue stubs for your branch.</li>
@endforelse
</ul>
</section>
</div>
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between gap-3">
<h2 class="text-sm font-semibold text-slate-900">Waiting patients</h2>
<a href="{{ route('care.appointments.index') }}" class="text-xs font-medium text-indigo-600">Appointments</a>
</div>
<div class="mt-3 space-y-2">
@forelse ($waiting as $appointment)
<div class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-3 py-3 text-sm">
<div>
<p class="font-medium text-slate-900">{{ $appointment->patient?->fullName() }}</p>
<p class="text-xs text-slate-500">{{ $appointment->department?->name }} · {{ $appointment->practitioner?->name ?? 'Unassigned' }}</p>
</div>
<a href="{{ route('care.appointments.show', $appointment) }}" class="text-sky-600">View</a>
</div>
@empty
<p class="rounded-xl border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-slate-500">No patients waiting in this specialty.</p>
@endforelse
</div>
</section>
@include('care.service-queues._panel')
</div>
</x-app-layout>
+14 -3
View File
@@ -74,9 +74,20 @@
'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" />'];
}
if ($organization && data_get($organization->settings, 'queue_integration_enabled') && $permissions->can($member, 'service_queues.console')) {
$nav[] = ['name' => 'Service queues', 'route' => route('care.service-queues.index'), 'active' => request()->routeIs('care.service-queues.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 0 1 0 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 0 1 0-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375Z" />'];
// Specialty modules (Settings → Modules) — no separate Service Queues nav;
// Ladill Queue embeds on clinical / pharmacy / lab / specialty pages.
$specialtyModules = app(\App\Services\Care\SpecialtyModuleService::class)->enabledModules($organization);
foreach ($specialtyModules as $item) {
$roles = $item['definition']['roles'] ?? [];
if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) {
continue;
}
$nav[] = [
'name' => $item['definition']['nav_label'] ?? $item['definition']['label'],
'route' => route('care.specialty.show', $item['key']),
'active' => request()->routeIs('care.specialty.show') && request()->route('module') === $item['key'],
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />',
];
}
}
+6
View File
@@ -28,6 +28,8 @@ use App\Http\Controllers\Care\QueueController;
use App\Http\Controllers\Care\ProController;
use App\Http\Controllers\Care\ReportController;
use App\Http\Controllers\Care\ServiceQueueController;
use App\Http\Controllers\Care\SettingsModulesController;
use App\Http\Controllers\Care\SpecialtyModuleController;
use App\Http\Controllers\Care\IntegrationsController;
use App\Http\Controllers\Care\IssueReportController;
use App\Http\Controllers\Care\SettingsController;
@@ -93,6 +95,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/service-queues/console/{counterUuid}', [ServiceQueueController::class, 'console'])->name('care.service-queues.console');
Route::post('/service-queues/console/{counterUuid}/action', [ServiceQueueController::class, 'action'])->name('care.service-queues.action');
Route::get('/specialty/{module}', [SpecialtyModuleController::class, 'show'])->name('care.specialty.show');
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::get('/lab/requests/{investigation}', [InvestigationController::class, 'show'])->name('care.lab.requests.show');
@@ -170,6 +174,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/settings', [SettingsController::class, 'edit'])->name('care.settings');
Route::put('/settings', [SettingsController::class, 'update'])->name('care.settings.update');
Route::get('/settings/modules', [SettingsModulesController::class, 'edit'])->name('care.settings.modules');
Route::put('/settings/modules', [SettingsModulesController::class, 'update'])->name('care.settings.modules.update');
Route::get('/integrations', [IntegrationsController::class, 'edit'])->name('care.integrations');
Route::post('/integrations/sms', [IntegrationsController::class, 'saveSms'])->name('care.integrations.sms.save');
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class CareNaturalQueueTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config([
'care.queue.api_url' => 'https://queue.test/api/v1',
'care.queue.api_key' => 'care-test-key',
]);
$this->owner = User::create([
'public_id' => 'care-queue-owner',
'name' => 'Owner',
'email' => 'queue-owner@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->owner->public_id,
'name' => 'Queue Clinic',
'slug' => 'queue-clinic',
'settings' => [
'onboarded' => true,
'facility_type' => 'clinic',
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'queue_integration_enabled' => true,
],
]);
Member::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->owner->public_id,
'role' => 'hospital_admin',
]);
Branch::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
}
protected function fakeQueueApi(): void
{
Http::fake(function (\Illuminate\Http\Client\Request $request) {
$url = $request->url();
if (str_contains($url, '/counters/') && str_contains($url, '/console')) {
return Http::response([
'data' => [
'counter' => ['name' => 'Reception desk', 'branch' => 'Main', 'status' => 'available'],
'current_ticket' => null,
'queues' => [['uuid' => '22222222-2222-2222-2222-222222222222', 'name' => 'Consultation']],
'waiting_by_queue' => [],
'transfer_queues' => [],
],
], 200);
}
if (str_contains($url, '/counters')) {
return Http::response([
'data' => [[
'uuid' => '11111111-1111-1111-1111-111111111111',
'name' => 'Reception desk',
'branch' => 'Main',
'status' => 'available',
'queues' => [['uuid' => '22222222-2222-2222-2222-222222222222', 'name' => 'Consultation']],
]],
], 200);
}
return Http::response(['message' => 'unexpected '.$url], 500);
});
}
public function test_sidebar_does_not_show_service_queues_nav(): void
{
$this->fakeQueueApi();
$this->actingAs($this->owner)
->get(route('care.dashboard'))
->assertOk()
->assertDontSee('Service queues', false);
}
public function test_service_queues_index_redirects_to_clinical_queue(): void
{
$this->actingAs($this->owner)
->get(route('care.service-queues.index'))
->assertRedirect(route('care.queue.index'));
}
public function test_clinical_queue_embeds_service_counter_panel(): void
{
$this->fakeQueueApi();
$this->actingAs($this->owner)
->get(route('care.queue.index'))
->assertOk()
->assertSee('Service counter')
->assertSee('Reception desk')
->assertSee('Call next');
}
public function test_pharmacist_sees_queue_panel_on_pharmacy_page(): void
{
$this->fakeQueueApi();
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'pharmacist']);
$this->actingAs($this->owner)
->get(route('care.prescriptions.queue'))
->assertOk()
->assertSee('Service counter');
}
public function test_lab_page_embeds_service_counter_when_integration_on(): void
{
$this->fakeQueueApi();
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'lab_technician']);
$this->actingAs($this->owner)
->get(route('care.lab.queue.index'))
->assertOk()
->assertSee('Service counter');
}
public function test_branch_scoped_member_panel_prefers_matching_branch_counter(): void
{
Http::fake(function (\Illuminate\Http\Client\Request $request) {
$url = $request->url();
if (str_contains($url, '/counters/') && str_contains($url, '/console')) {
return Http::response([
'data' => [
'counter' => ['name' => 'Main reception', 'branch' => 'Main'],
'current_ticket' => null,
'queues' => [['uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'name' => 'Consultation']],
'waiting_by_queue' => [],
'transfer_queues' => [],
],
], 200);
}
if (str_contains($url, '/counters')) {
return Http::response([
'data' => [
[
'uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'name' => 'Other site desk',
'branch' => 'Other',
'queues' => [['name' => 'Consultation']],
],
[
'uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'name' => 'Main reception',
'branch' => 'Main',
'queues' => [['name' => 'Consultation']],
],
],
], 200);
}
return Http::response(['message' => 'unexpected '.$url], 500);
});
$branch = Branch::query()->first();
Member::query()->where('user_ref', $this->owner->public_id)->update([
'role' => 'receptionist',
'branch_id' => $branch->id,
]);
$this->actingAs($this->owner)
->get(route('care.queue.index'))
->assertOk()
->assertSee('Main reception')
->assertDontSee('Other site desk');
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class CareSpecialtyModulesTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->owner = User::create([
'public_id' => 'care-modules-owner',
'name' => 'Owner',
'email' => 'modules-owner@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->owner->public_id,
'name' => 'Modules Clinic',
'slug' => 'modules-clinic',
'settings' => [
'onboarded' => true,
'facility_type' => 'clinic',
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
],
]);
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,
]);
}
public function test_activating_dentistry_creates_department_and_queue_stubs(): void
{
Http::fake();
$service = app(SpecialtyModuleService::class);
$service->activate($this->organization, $this->owner->public_id, 'dentistry');
$this->organization->refresh();
$this->assertTrue((bool) data_get($this->organization->settings, 'specialty_modules.dentistry'));
$this->assertDatabaseHas('care_departments', [
'branch_id' => $this->branch->id,
'type' => 'dental',
'name' => 'Dentistry',
'is_active' => true,
]);
$stubs = data_get($this->organization->settings, 'specialty_module_provisioning.dentistry.queues');
$this->assertIsArray($stubs);
$this->assertNotEmpty($stubs);
$this->assertSame('Dentistry', $stubs[0]['name']);
$this->assertSame($this->branch->id, $stubs[0]['branch_id']);
}
public function test_deactivating_hides_department_without_deleting(): void
{
Http::fake();
$service = app(SpecialtyModuleService::class);
$service->activate($this->organization, $this->owner->public_id, 'dentistry');
$department = Department::query()->where('type', 'dental')->firstOrFail();
$service->deactivate($this->organization->fresh(), $this->owner->public_id, 'dentistry');
$this->organization->refresh();
$this->assertFalse((bool) data_get($this->organization->settings, 'specialty_modules.dentistry'));
$this->assertFalse($department->fresh()->is_active);
$this->assertDatabaseHas('care_departments', ['id' => $department->id]);
}
public function test_free_plan_cannot_activate_modules_via_settings(): void
{
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'plan' => 'free',
'plan_expires_at' => null,
]),
]);
$this->actingAs($this->owner)
->put(route('care.settings.modules.update'), [
'modules' => ['dentistry' => '1'],
])
->assertRedirect(route('care.pro.index'))
->assertSessionHas('upsell');
}
public function test_modules_settings_page_lists_catalog(): void
{
$this->actingAs($this->owner)
->get(route('care.settings.modules'))
->assertOk()
->assertSee('Dentistry')
->assertSee('Eye care')
->assertSee('Physiotherapy')
->assertSee('Maternity')
->assertSee('Radiology');
}
public function test_specialty_page_visible_when_module_enabled(): void
{
Http::fake();
app(SpecialtyModuleService::class)->activate($this->organization, $this->owner->public_id, 'dentistry');
$this->actingAs($this->owner)
->get(route('care.specialty.show', 'dentistry'))
->assertOk()
->assertSee('Dentistry')
->assertSee('Departments');
}
}