Embed Ladill Queue on role pages and add specialty modules under Settings.
Deploy Ladill Care / deploy (push) Successful in 53s
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:
@@ -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'),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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))) : [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">→</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>
|
||||
@@ -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" />',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user