Gate ticket handoff as Pro; reorder settings cards.
Deploy Ladill Queue / deploy (push) Successful in 1m19s
Deploy Ladill Queue / deploy (push) Successful in 1m19s
Transfer between queues requires Pro (console UI, API, and plans copy). Branches & team now sits after Organization on the settings page.
This commit is contained in:
@@ -5,8 +5,10 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Counter;
|
||||
use App\Models\Organization;
|
||||
use App\Models\ServiceQueue;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\Qms\PlanService;
|
||||
use App\Services\Qms\QueueEngine;
|
||||
use App\Services\Qms\TicketPresenter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -18,6 +20,7 @@ class ConsoleApiController extends Controller
|
||||
|
||||
public function __construct(
|
||||
protected QueueEngine $engine,
|
||||
protected PlanService $plans,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Counter $counter): JsonResponse
|
||||
@@ -25,6 +28,8 @@ class ConsoleApiController extends Controller
|
||||
$this->authorizeAbility($request, 'console.use');
|
||||
$this->authorizeOwner($request, $counter);
|
||||
$owner = $this->ownerRef($request);
|
||||
$organization = $this->organization($request);
|
||||
$canTransfer = $this->plans->hasFeature($organization, 'transfer');
|
||||
|
||||
$counter->load(['serviceQueues', 'branch']);
|
||||
|
||||
@@ -42,12 +47,14 @@ class ConsoleApiController extends Controller
|
||||
)];
|
||||
});
|
||||
|
||||
$allQueues = ServiceQueue::owned($owner)
|
||||
->where('organization_id', $counter->organization_id)
|
||||
->where('branch_id', $counter->branch_id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get(['uuid', 'name', 'prefix']);
|
||||
$allQueues = $canTransfer
|
||||
? ServiceQueue::owned($owner)
|
||||
->where('organization_id', $counter->organization_id)
|
||||
->where('branch_id', $counter->branch_id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get(['uuid', 'name', 'prefix'])
|
||||
: collect();
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
@@ -65,6 +72,7 @@ class ConsoleApiController extends Controller
|
||||
]),
|
||||
'waiting_by_queue' => $waitingByQueue,
|
||||
'transfer_queues' => $allQueues,
|
||||
'can_transfer' => $canTransfer,
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -137,6 +145,12 @@ class ConsoleApiController extends Controller
|
||||
?string $reason,
|
||||
string $actor,
|
||||
): Ticket {
|
||||
$organization = Organization::query()->findOrFail($counter->organization_id);
|
||||
abort_unless(
|
||||
$this->plans->hasFeature($organization, 'transfer'),
|
||||
403,
|
||||
'Handing off tickets between queues requires Queue Pro.',
|
||||
);
|
||||
abort_if($toQueueUuid === '', 422, 'to_queue_uuid is required for transfer.');
|
||||
|
||||
$toQueue = ServiceQueue::where('uuid', $toQueueUuid)->firstOrFail();
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ServiceQueue;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\Qms\PlanService;
|
||||
use App\Services\Qms\QueueEngine;
|
||||
use App\Services\Qms\TicketPresenter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -17,6 +18,7 @@ class TicketController extends Controller
|
||||
|
||||
public function __construct(
|
||||
protected QueueEngine $engine,
|
||||
protected PlanService $plans,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
@@ -108,6 +110,11 @@ class TicketController extends Controller
|
||||
]);
|
||||
|
||||
if ($validated['action'] === 'transfer') {
|
||||
abort_unless(
|
||||
$this->plans->hasFeature($this->organization($request), 'transfer'),
|
||||
403,
|
||||
'Handing off tickets between queues requires Queue Pro.',
|
||||
);
|
||||
$toQueue = ServiceQueue::where('uuid', $validated['to_queue_id'])->firstOrFail();
|
||||
$this->authorizeOwner($request, $toQueue);
|
||||
$result = $this->engine->transfer($ticket, $toQueue, null, $validated['reason'] ?? null, $actor);
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
|
||||
use App\Models\Counter;
|
||||
use App\Models\ServiceQueue;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\Qms\OrganizationResolver;
|
||||
use App\Services\Qms\PlanService;
|
||||
use App\Services\Qms\QueueEngine;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -19,6 +19,7 @@ class ConsoleController extends Controller
|
||||
|
||||
public function __construct(
|
||||
protected QueueEngine $engine,
|
||||
protected PlanService $plans,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Counter $counter): View
|
||||
@@ -26,6 +27,7 @@ class ConsoleController extends Controller
|
||||
$this->authorizeAbility($request, 'console.use');
|
||||
$this->authorizeOwner($request, $counter);
|
||||
$owner = $this->ownerRef($request);
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$counter->load(['serviceQueues', 'branch']);
|
||||
$queues = $counter->serviceQueues;
|
||||
@@ -41,14 +43,24 @@ class ConsoleController extends Controller
|
||||
return [$queue->id => $this->engine->waitingTickets($queue, 10)];
|
||||
});
|
||||
|
||||
$allQueues = ServiceQueue::owned($owner)
|
||||
->where('organization_id', $counter->organization_id)
|
||||
->where('branch_id', $counter->branch_id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$canTransfer = $this->plans->hasFeature($organization, 'transfer');
|
||||
$allQueues = $canTransfer
|
||||
? ServiceQueue::owned($owner)
|
||||
->where('organization_id', $counter->organization_id)
|
||||
->where('branch_id', $counter->branch_id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
return view('qms.console.show', compact('counter', 'queues', 'currentTicket', 'waitingByQueue', 'allQueues'));
|
||||
return view('qms.console.show', compact(
|
||||
'counter',
|
||||
'queues',
|
||||
'currentTicket',
|
||||
'waitingByQueue',
|
||||
'allQueues',
|
||||
'canTransfer',
|
||||
));
|
||||
}
|
||||
|
||||
public function action(Request $request, Counter $counter): RedirectResponse
|
||||
@@ -65,6 +77,12 @@ class ConsoleController extends Controller
|
||||
'reason' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
if ($validated['action'] === 'transfer' && ! $this->plans->hasFeature($this->organization($request), 'transfer')) {
|
||||
return redirect()
|
||||
->route('qms.pro.index')
|
||||
->with('error', 'Handing off tickets between queues requires Queue Pro.');
|
||||
}
|
||||
|
||||
try {
|
||||
$message = match ($validated['action']) {
|
||||
'call_next' => $this->handleCallNext($counter, (int) $validated['queue_id'], $actor),
|
||||
|
||||
@@ -202,6 +202,7 @@ return [
|
||||
'displays',
|
||||
'branches',
|
||||
'team',
|
||||
'transfer',
|
||||
'routing',
|
||||
'integrations',
|
||||
'reports',
|
||||
@@ -219,6 +220,7 @@ return [
|
||||
'displays',
|
||||
'branches',
|
||||
'team',
|
||||
'transfer',
|
||||
'routing',
|
||||
'integrations',
|
||||
'reports',
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
@php
|
||||
$handoffQueues = $allQueues->where('id', '!=', $currentTicket->service_queue_id)->values();
|
||||
@endphp
|
||||
@if ($handoffQueues->isNotEmpty())
|
||||
@if ($canTransfer && $handoffQueues->isNotEmpty())
|
||||
<form method="POST" action="{{ route('qms.console.action', $counter) }}" class="mt-4 border-t border-indigo-100 pt-4">
|
||||
@csrf
|
||||
<input type="hidden" name="action" value="transfer">
|
||||
@@ -82,6 +82,15 @@
|
||||
<button type="submit" class="btn-primary text-sm">Hand off</button>
|
||||
</div>
|
||||
</form>
|
||||
@elseif (! $canTransfer)
|
||||
<div class="mt-4 border-t border-indigo-100 pt-4">
|
||||
<p class="text-sm font-medium text-slate-800">
|
||||
Hand off to another queue
|
||||
<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>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-slate-500">Pass customers between services while keeping their ticket number. Available on Queue Pro.</p>
|
||||
<a href="{{ route('qms.pro.index') }}" class="mt-3 inline-flex text-sm font-medium text-indigo-600 hover:text-indigo-800">Upgrade to Pro →</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700">
|
||||
<li>Multi-branch locations</li>
|
||||
<li>Team roles & invitations</li>
|
||||
<li>Hand off tickets between queues</li>
|
||||
<li>Advanced routing rules</li>
|
||||
<li>Unlimited kiosks & displays</li>
|
||||
<li>Ladill-provided queue kiosk, display and audio system</li>
|
||||
|
||||
@@ -21,43 +21,6 @@
|
||||
</x-settings.card>
|
||||
@endif
|
||||
|
||||
@if ($canViewBranches || $canViewTeam)
|
||||
<x-settings.card title="Branches & team" description="Locations and staff access for this organization. Multi-branch and team require Queue Pro.">
|
||||
<ul class="space-y-3">
|
||||
@if ($canViewBranches)
|
||||
<li>
|
||||
<a href="{{ route('qms.branches.index') }}"
|
||||
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
|
||||
<span>
|
||||
Branches
|
||||
@if (! $hasBranchesFeature)
|
||||
<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">Locations and sites for queues and counters</span>
|
||||
</span>
|
||||
<span class="text-slate-400">→</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@if ($canViewTeam)
|
||||
<li>
|
||||
<a href="{{ route('qms.members.index') }}"
|
||||
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
|
||||
<span>
|
||||
Team
|
||||
@if (! $hasTeamFeature)
|
||||
<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">Invite colleagues and assign Queue roles</span>
|
||||
</span>
|
||||
<span class="text-slate-400">→</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</x-settings.card>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('qms.settings.update') }}" enctype="multipart/form-data" class="space-y-6">
|
||||
@csrf @method('PUT')
|
||||
|
||||
@@ -111,6 +74,43 @@
|
||||
</div>
|
||||
</x-settings.card>
|
||||
|
||||
@if ($canViewBranches || $canViewTeam)
|
||||
<x-settings.card title="Branches & team" description="Locations and staff access for this organization. Multi-branch and team require Queue Pro.">
|
||||
<ul class="space-y-3">
|
||||
@if ($canViewBranches)
|
||||
<li>
|
||||
<a href="{{ route('qms.branches.index') }}"
|
||||
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
|
||||
<span>
|
||||
Branches
|
||||
@if (! $hasBranchesFeature)
|
||||
<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">Locations and sites for queues and counters</span>
|
||||
</span>
|
||||
<span class="text-slate-400">→</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@if ($canViewTeam)
|
||||
<li>
|
||||
<a href="{{ route('qms.members.index') }}"
|
||||
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
|
||||
<span>
|
||||
Team
|
||||
@if (! $hasTeamFeature)
|
||||
<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">Invite colleagues and assign Queue roles</span>
|
||||
</span>
|
||||
<span class="text-slate-400">→</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</x-settings.card>
|
||||
@endif
|
||||
|
||||
<x-settings.card title="Integrations" description="Allow Ladill Care and Frontdesk to manage queues in-app via the Queue API. Both apps must also enable integration in their settings.">
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Counter;
|
||||
use App\Models\ServiceQueue;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Services\Qms\OrganizationResolver;
|
||||
use App\Services\Qms\QueueEngine;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TicketTransferProTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $owner;
|
||||
|
||||
protected \App\Models\Organization $organization;
|
||||
|
||||
protected Branch $branch;
|
||||
|
||||
protected ServiceQueue $queue;
|
||||
|
||||
protected ServiceQueue $lab;
|
||||
|
||||
protected Counter $counter;
|
||||
|
||||
protected Ticket $ticket;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||
|
||||
$this->owner = User::create([
|
||||
'public_id' => 'queue-transfer-owner-001',
|
||||
'name' => 'Owner',
|
||||
'email' => 'transfer-owner@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
|
||||
$this->organization = app(OrganizationResolver::class)->completeOnboarding($this->owner, [
|
||||
'organization_name' => 'Transfer Org',
|
||||
'industry' => 'healthcare',
|
||||
'appointment_mode' => 'hybrid',
|
||||
'branch_name' => 'Main',
|
||||
'timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
$this->branch = Branch::query()->where('organization_id', $this->organization->id)->firstOrFail();
|
||||
|
||||
$this->queue = ServiceQueue::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'Consultation',
|
||||
'prefix' => 'A',
|
||||
'strategy' => 'fifo',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->lab = ServiceQueue::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'Lab',
|
||||
'prefix' => 'L',
|
||||
'strategy' => 'fifo',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->counter = Counter::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'Desk 1',
|
||||
'status' => 'available',
|
||||
'is_active' => true,
|
||||
]);
|
||||
$this->counter->serviceQueues()->attach($this->queue->id);
|
||||
|
||||
$engine = app(QueueEngine::class);
|
||||
$this->ticket = $engine->issueTicket($this->queue, $this->owner->public_id, ['source' => 'staff']);
|
||||
$engine->callTicket($this->ticket, $this->counter, $this->owner->public_id);
|
||||
$engine->startServing($this->ticket->fresh(), $this->owner->public_id);
|
||||
$this->ticket = $this->ticket->fresh();
|
||||
}
|
||||
|
||||
public function test_free_console_shows_transfer_as_pro_feature(): void
|
||||
{
|
||||
$this->actingAs($this->owner)
|
||||
->get(route('qms.console.show', $this->counter))
|
||||
->assertOk()
|
||||
->assertSee('Hand off to another queue')
|
||||
->assertSee('Pro')
|
||||
->assertSee('Upgrade to Pro')
|
||||
->assertDontSee('name="action" value="transfer"', false);
|
||||
}
|
||||
|
||||
public function test_free_plan_cannot_transfer_via_console(): void
|
||||
{
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('qms.console.action', $this->counter), [
|
||||
'action' => 'transfer',
|
||||
'ticket_id' => $this->ticket->id,
|
||||
'to_queue_id' => $this->lab->id,
|
||||
'reason' => 'Lab tests',
|
||||
])
|
||||
->assertRedirect(route('qms.pro.index'))
|
||||
->assertSessionHas('error');
|
||||
|
||||
$this->assertSame($this->queue->id, $this->ticket->fresh()->service_queue_id);
|
||||
}
|
||||
|
||||
public function test_pro_plan_can_transfer_via_console(): void
|
||||
{
|
||||
$this->organization->update([
|
||||
'settings' => array_merge($this->organization->settings ?? [], [
|
||||
'plan' => 'pro',
|
||||
'plan_expires_at' => now()->addMonth()->toIso8601String(),
|
||||
'pro_billed_branches' => 1,
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('qms.console.action', $this->counter), [
|
||||
'action' => 'transfer',
|
||||
'ticket_id' => $this->ticket->id,
|
||||
'to_queue_id' => $this->lab->id,
|
||||
'reason' => 'Lab tests',
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHas('success');
|
||||
|
||||
$this->assertSame($this->lab->id, $this->ticket->fresh()->service_queue_id);
|
||||
$this->assertSame('waiting', $this->ticket->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_pro_console_shows_handoff_form(): void
|
||||
{
|
||||
$this->organization->update([
|
||||
'settings' => array_merge($this->organization->settings ?? [], [
|
||||
'plan' => 'pro',
|
||||
'plan_expires_at' => now()->addMonth()->toIso8601String(),
|
||||
'pro_billed_branches' => 1,
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->get(route('qms.console.show', $this->counter))
|
||||
->assertOk()
|
||||
->assertSee('Hand off')
|
||||
->assertSee('Lab')
|
||||
->assertDontSee('Upgrade to Pro →');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user