Initial Ladill Queue release — enterprise QMS standalone app.
Deploy Ladill Queue / deploy (push) Successful in 56s

Phases 1–6: tickets, counters, displays, appointments, workflows, rules, analytics, reports, feedback, admin, device API, and Gitea deploy workflow for queue.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-29 20:19:52 +00:00
co-authored by Cursor
commit cca98eefd2
297 changed files with 27263 additions and 0 deletions
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Services\Qms\AnalyticsService;
use App\Services\Qms\OrganizationResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AnalyticsController extends Controller
{
use ScopesApiToAccount;
public function overview(Request $request, AnalyticsService $analytics): JsonResponse
{
$this->authorizeAbility($request, 'reports.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
return response()->json([
'data' => $analytics->overview($this->ownerRef($request), $organization->id, $branchScope),
'trend' => $analytics->dailyTrend($this->ownerRef($request), $organization->id, 14, $branchScope),
]);
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\QueueAppointment;
use App\Services\Qms\AppointmentService;
use App\Services\Qms\AuditLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class AppointmentController extends Controller
{
use ScopesApiToAccount;
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'appointments.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$items = QueueAppointment::owned($owner)
->where('organization_id', $organization->id)
->orderBy('scheduled_at')
->paginate(50);
return response()->json(['data' => $items]);
}
public function store(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$validated = $request->validate([
'branch_id' => ['required', 'exists:queue_branches,id'],
'service_queue_id' => ['nullable', 'exists:queue_service_queues,id'],
'customer_name' => ['required', 'string', 'max:255'],
'customer_phone' => ['nullable', 'string', 'max:50'],
'customer_email' => ['nullable', 'email', 'max:255'],
'scheduled_at' => ['required', 'date'],
]);
$appointment = QueueAppointment::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
...$validated,
'status' => 'scheduled',
'reference' => 'APT-'.Str::upper(Str::random(6)),
]);
AuditLogger::record($owner, 'appointment.created', $organization->id, $owner, QueueAppointment::class, $appointment->id);
return response()->json(['data' => $appointment], 201);
}
public function checkIn(Request $request, QueueAppointment $appointment): JsonResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeOwner($request, $appointment);
$ticket = app(AppointmentService::class)->checkIn($appointment, $this->ownerRef($request));
return response()->json(['data' => \App\Services\Qms\TicketPresenter::toArray($ticket)]);
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\Branch;
use App\Services\Qms\AuditLogger;
use App\Services\Qms\PlanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BranchController extends Controller
{
use ScopesApiToAccount;
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'admin.branches.view');
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $this->organization($request)->id)
->orderBy('name')
->get();
return response()->json(['data' => $branches]);
}
public function store(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$currentCount = Branch::owned($owner)->where('organization_id', $organization->id)->count();
abort_unless(app(PlanService::class)->canAddBranch($organization, $currentCount), 422, 'Branch limit reached for current plan.');
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:500'],
'phone' => ['nullable', 'string', 'max:50'],
]);
$branch = Branch::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
...$validated,
'is_active' => true,
]);
AuditLogger::record($owner, 'branch.created', $organization->id, $owner, Branch::class, $branch->id);
return response()->json(['data' => $branch], 201);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use App\Models\Member;
use App\Models\Organization;
use App\Services\Qms\OrganizationResolver;
use App\Services\Qms\QmsPermissions;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
trait ScopesApiToAccount
{
protected function ownerRef(Request $request): string
{
return (string) $request->user()->public_id;
}
protected function organization(Request $request): Organization
{
$organization = app(OrganizationResolver::class)->resolveForUser($request->user());
abort_unless($organization, 404);
return $organization;
}
protected function member(Request $request): ?Member
{
return app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
}
protected function authorizeAbility(Request $request, string $ability): void
{
abort_unless(
app(QmsPermissions::class)->can($this->member($request), $ability),
403,
);
}
protected function authorizeOwner(Request $request, Model $model): void
{
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\Counter;
use App\Models\ServiceQueue;
use App\Services\Qms\AuditLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CounterController extends Controller
{
use ScopesApiToAccount;
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'counters.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$counters = Counter::owned($owner)
->where('organization_id', $organization->id)
->with('serviceQueues')
->orderBy('name')
->get()
->map(fn (Counter $c) => [
'uuid' => $c->uuid,
'name' => $c->name,
'code' => $c->code,
'status' => $c->status,
'queues' => $c->serviceQueues->pluck('name'),
]);
return response()->json(['data' => $counters]);
}
public function store(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'counters.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'branch_id' => ['required', 'integer'],
'department_id' => ['nullable', 'integer'],
'queue_ids' => ['nullable', 'array'],
'queue_ids.*' => ['uuid'],
]);
$counter = Counter::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
'branch_id' => $validated['branch_id'],
'department_id' => $validated['department_id'] ?? null,
'name' => $validated['name'],
'code' => $validated['code'] ?? null,
'status' => 'offline',
'is_active' => true,
]);
if (! empty($validated['queue_ids'])) {
$queueIds = ServiceQueue::owned($owner)->whereIn('uuid', $validated['queue_ids'])->pluck('id');
$counter->serviceQueues()->sync($queueIds);
}
AuditLogger::record($owner, 'counter.created', $organization->id, $owner, Counter::class, $counter->id);
return response()->json(['data' => ['uuid' => $counter->uuid, 'name' => $counter->name]], 201);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Qms\DeviceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DeviceHeartbeatController extends Controller
{
public function __construct(
protected DeviceService $devices,
) {}
public function __invoke(Request $request): JsonResponse
{
$token = $request->bearerToken() ?? $request->input('device_token');
abort_unless($token, 401, 'Device token required.');
$device = $this->devices->findByToken($token);
abort_unless($device, 401, 'Invalid device token.');
$this->devices->recordHeartbeat($device);
return response()->json([
'status' => 'ok',
'device_id' => $device->uuid,
'server_time' => now()->toIso8601String(),
]);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Services\Qms\QueueEngine;
use App\Services\Qms\TicketPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PublicQueueController extends Controller
{
public function __construct(
protected QueueEngine $engine,
) {}
public function join(Request $request): JsonResponse
{
$validated = $request->validate([
'queue_uuid' => ['required', 'uuid'],
'customer_name' => ['nullable', 'string', 'max:255'],
'customer_phone' => ['required', 'string', 'max:50'],
'customer_email' => ['nullable', 'email'],
'priority' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_priorities')))],
]);
$queue = ServiceQueue::where('uuid', $validated['queue_uuid'])
->where('is_active', true)
->where('is_paused', false)
->firstOrFail();
$ticket = $this->engine->issueTicket($queue, $queue->owner_ref, [
'customer_name' => $validated['customer_name'] ?? null,
'customer_phone' => $validated['customer_phone'],
'customer_email' => $validated['customer_email'] ?? null,
'priority' => $validated['priority'] ?? 'walk_in',
'source' => 'mobile',
]);
return response()->json(['data' => TicketPresenter::toArray($ticket)], 201);
}
public function show(string $qrToken): JsonResponse
{
$ticket = Ticket::where('qr_token', $qrToken)->with(['serviceQueue', 'counter'])->firstOrFail();
$ahead = Ticket::query()
->where('service_queue_id', $ticket->service_queue_id)
->where('status', 'waiting')
->where('issued_at', '<', $ticket->issued_at)
->count();
$data = TicketPresenter::toArray($ticket, false);
$data['customers_ahead'] = $ahead;
return response()->json(['data' => $data]);
}
public function delay(Request $request, string $qrToken): JsonResponse
{
$ticket = Ticket::where('qr_token', $qrToken)->whereIn('status', ['waiting', 'on_hold'])->firstOrFail();
$validated = $request->validate(['minutes' => ['required', 'integer', 'min:5', 'max:120']]);
$ticket = $this->engine->delayArrival($ticket, $validated['minutes']);
return response()->json(['data' => TicketPresenter::toArray($ticket)]);
}
public function cancel(string $qrToken): JsonResponse
{
$ticket = Ticket::where('qr_token', $qrToken)->whereIn('status', ['waiting', 'on_hold', 'called'])->firstOrFail();
$ticket = $this->engine->cancel($ticket);
return response()->json(['data' => TicketPresenter::toArray($ticket)]);
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\QueueRule;
use App\Models\ServiceQueue;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class QueueRuleController extends Controller
{
use ScopesApiToAccount;
public function index(Request $request, ServiceQueue $serviceQueue): JsonResponse
{
$this->authorizeAbility($request, 'rules.view');
$this->authorizeOwner($request, $serviceQueue);
$rules = QueueRule::owned($this->ownerRef($request))
->where('service_queue_id', $serviceQueue->id)
->orderBy('sort_order')
->get();
return response()->json(['data' => $rules]);
}
public function store(Request $request, ServiceQueue $serviceQueue): JsonResponse
{
$this->authorizeAbility($request, 'rules.manage');
$this->authorizeOwner($request, $serviceQueue);
$validated = $request->validate([
'rule_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.rule_types')))],
'conditions' => ['nullable', 'array'],
'actions' => ['nullable', 'array'],
]);
$rule = QueueRule::create([
'owner_ref' => $this->ownerRef($request),
'service_queue_id' => $serviceQueue->id,
'rule_type' => $validated['rule_type'],
'conditions' => $validated['conditions'] ?? [],
'actions' => $validated['actions'] ?? [],
'sort_order' => QueueRule::where('service_queue_id', $serviceQueue->id)->max('sort_order') + 1,
'is_active' => true,
]);
return response()->json(['data' => $rule], 201);
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Services\Qms\OrganizationResolver;
use App\Services\Qms\ReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class ReportController extends Controller
{
use ScopesApiToAccount;
public function show(Request $request, string $type, ReportService $reports): JsonResponse
{
abort_unless(array_key_exists($type, config('qms.report_types')), 404);
$this->authorizeAbility($request, 'reports.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
$from = Carbon::parse($request->input('from', now()->subDays(30)->toDateString()))->startOfDay();
$to = Carbon::parse($request->input('to', now()->toDateString()))->endOfDay();
$owner = $this->ownerRef($request);
$data = match ($type) {
'tickets' => $reports->ticketsReport($owner, $organization->id, $from, $to, $branchId),
'wait_times' => $reports->waitTimesReport($owner, $organization->id, $from, $to, $branchId),
'counters' => $reports->countersReport($owner, $organization->id, $from, $to, $branchId),
'appointments' => $reports->appointmentsReport($owner, $organization->id, $from, $to, $branchId),
'feedback' => $reports->feedbackReport($owner, $organization->id, $from, $to, $branchId),
'service_sessions' => $reports->serviceSessionsReport($owner, $organization->id, $from, $to, $branchId),
default => abort(404),
};
return response()->json(['data' => $data, 'from' => $from->toDateString(), 'to' => $to->toDateString()]);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api;
use App\Events\ServiceEventOccurred;
use App\Http\Controllers\Controller;
use App\Services\Events\ServiceEventSignature;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class ServiceEventController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$secret = (string) config('service_events.inbound_secret');
$signature = (string) $request->header('X-Ladill-Signature', '');
if (! ServiceEventSignature::verify($request->getContent(), $signature, $secret)) {
return response()->json(['error' => 'invalid signature'], 401);
}
$eventId = (string) $request->header('X-Ladill-Event-Id', (string) $request->input('id', ''));
if ($eventId !== '') {
if (Cache::has("svcevt:{$eventId}")) {
return response()->json(['status' => 'duplicate']);
}
Cache::put("svcevt:{$eventId}", true, now()->addDay());
}
event(new ServiceEventOccurred((string) $request->input('event'), (array) $request->input('data', [])));
return response()->json(['status' => 'accepted']);
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\Counter;
use App\Models\ServiceQueue;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceQueueController extends Controller
{
use ScopesApiToAccount;
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'queues.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$queues = ServiceQueue::owned($owner)
->where('organization_id', $organization->id)
->with(['branch', 'department'])
->orderBy('name')
->get()
->map(fn (ServiceQueue $q) => [
'uuid' => $q->uuid,
'name' => $q->name,
'prefix' => $q->prefix,
'strategy' => $q->strategy,
'is_active' => $q->is_active,
'is_paused' => $q->is_paused,
'branch' => $q->branch?->name,
]);
return response()->json(['data' => $queues]);
}
public function waiting(Request $request, ServiceQueue $serviceQueue): JsonResponse
{
$this->authorizeAbility($request, 'queues.view');
$this->authorizeOwner($request, $serviceQueue);
$tickets = app(\App\Services\Qms\QueueEngine::class)->waitingTickets($serviceQueue);
return response()->json([
'data' => array_map(fn ($t) => \App\Services\Qms\TicketPresenter::toArray($t), $tickets),
]);
}
public function pause(Request $request, ServiceQueue $serviceQueue): JsonResponse
{
$this->authorizeAbility($request, 'queues.manage');
$this->authorizeOwner($request, $serviceQueue);
$queue = app(\App\Services\Qms\QueueEngine::class)->pauseQueue($serviceQueue, $this->ownerRef($request));
return response()->json(['data' => ['uuid' => $queue->uuid, 'is_paused' => $queue->is_paused]]);
}
public function resume(Request $request, ServiceQueue $serviceQueue): JsonResponse
{
$this->authorizeAbility($request, 'queues.manage');
$this->authorizeOwner($request, $serviceQueue);
$queue = app(\App\Services\Qms\QueueEngine::class)->resumeQueue($serviceQueue, $this->ownerRef($request));
return response()->json(['data' => ['uuid' => $queue->uuid, 'is_paused' => $queue->is_paused]]);
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Services\Qms\QueueEngine;
use App\Services\Qms\TicketPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TicketController extends Controller
{
use ScopesApiToAccount;
public function __construct(
protected QueueEngine $engine,
) {}
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'tickets.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$tickets = Ticket::owned($owner)
->where('organization_id', $organization->id)
->when($request->query('queue'), fn ($q, $uuid) => $q->whereHas('serviceQueue', fn ($sq) => $sq->where('uuid', $uuid)))
->when($request->query('status'), fn ($q, $status) => $q->where('status', $status))
->with(['serviceQueue', 'counter'])
->latest('issued_at')
->paginate(50);
return response()->json([
'data' => $tickets->getCollection()->map(fn (Ticket $t) => TicketPresenter::toArray($t)),
'meta' => [
'current_page' => $tickets->currentPage(),
'last_page' => $tickets->lastPage(),
'total' => $tickets->total(),
],
]);
}
public function store(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'tickets.issue');
$owner = $this->ownerRef($request);
$validated = $request->validate([
'service_queue_id' => ['required', 'uuid'],
'customer_name' => ['nullable', 'string', 'max:255'],
'customer_phone' => ['nullable', 'string', 'max:50'],
'customer_email' => ['nullable', 'email', 'max:255'],
'priority' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_priorities')))],
'source' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_sources')))],
]);
$queue = ServiceQueue::where('uuid', $validated['service_queue_id'])->firstOrFail();
$this->authorizeOwner($request, $queue);
$ticket = $this->engine->issueTicket($queue, $owner, [
...$validated,
'source' => $validated['source'] ?? 'api',
'actor_ref' => $owner,
]);
return response()->json(['data' => TicketPresenter::toArray($ticket)], 201);
}
public function show(Request $request, Ticket $ticket): JsonResponse
{
$this->authorizeAbility($request, 'tickets.view');
$this->authorizeOwner($request, $ticket);
return response()->json(['data' => TicketPresenter::toArray($ticket->load(['serviceQueue', 'counter']))]);
}
public function callNext(Request $request, ServiceQueue $serviceQueue): JsonResponse
{
$this->authorizeAbility($request, 'console.use');
$this->authorizeOwner($request, $serviceQueue);
$validated = $request->validate([
'counter_id' => ['required', 'uuid'],
]);
$counter = \App\Models\Counter::where('uuid', $validated['counter_id'])->firstOrFail();
$this->authorizeOwner($request, $counter);
$ticket = $this->engine->callNext($serviceQueue, $counter, $this->ownerRef($request));
return response()->json(['data' => $ticket ? TicketPresenter::toArray($ticket) : null]);
}
public function action(Request $request, Ticket $ticket): JsonResponse
{
$this->authorizeAbility($request, 'tickets.manage');
$this->authorizeOwner($request, $ticket);
$actor = $this->ownerRef($request);
$validated = $request->validate([
'action' => ['required', 'string', 'in:recall,start,hold,skip,complete,no_show,cancel,delay'],
'minutes' => ['nullable', 'integer', 'min:5', 'max:120'],
'to_queue_id' => ['nullable', 'uuid'],
'reason' => ['nullable', 'string', 'max:500'],
]);
$result = match ($validated['action']) {
'recall' => $this->engine->recall($ticket, $actor),
'start' => $this->engine->startServing($ticket, $actor),
'hold' => $this->engine->hold($ticket, $actor),
'skip' => $this->engine->skip($ticket, $actor),
'complete' => $this->engine->completeTicket($ticket, $actor),
'no_show' => $this->engine->markNoShow($ticket, $actor),
'cancel' => $this->engine->cancel($ticket, $actor),
'delay' => $this->engine->delayArrival($ticket, (int) ($validated['minutes'] ?? 15), $actor),
default => $ticket,
};
if ($validated['action'] === 'transfer' && ! empty($validated['to_queue_id'])) {
$toQueue = ServiceQueue::where('uuid', $validated['to_queue_id'])->firstOrFail();
$result = $this->engine->transfer($ticket, $toQueue, null, $validated['reason'] ?? null, $actor);
}
return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter']))]);
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\Workflow;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WorkflowController extends Controller
{
use ScopesApiToAccount;
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'workflows.view');
$workflows = Workflow::owned($this->ownerRef($request))
->where('organization_id', $this->organization($request)->id)
->with('steps.serviceQueue')
->orderBy('name')
->get();
return response()->json(['data' => $workflows]);
}
public function show(Request $request, Workflow $workflow): JsonResponse
{
$this->authorizeAbility($request, 'workflows.view');
$this->authorizeOwner($request, $workflow);
return response()->json(['data' => $workflow->load('steps.serviceQueue')]);
}
}
@@ -0,0 +1,293 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* "Sign in with Ladill" Authorization Code + PKCE against auth.ladill.com.
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
*/
class SsoLoginController extends Controller
{
/**
* Max consecutive failed callbacks before we stop bouncing back into the
* authorize flow and show an error page instead (prevents an infinite
* /sso/callback /sso/connect redirect loop on a persistent upstream error).
*/
private const MAX_SSO_ATTEMPTS = 3;
public function connect(Request $request): RedirectResponse|View
{
$intended = (string) $request->query('redirect', route('qms.dashboard'));
if (Auth::check()) {
return $this->safeRedirect($intended, route('qms.dashboard'));
}
if (! $request->boolean('fallback')) {
$request->session()->forget('sso.attempts');
}
if ($this->attemptSilentRefresh($request, $intended)) {
return $this->safeRedirect($intended, route('qms.dashboard'));
}
$verifier = Str::random(64);
$state = Str::random(40);
$request->session()->put('sso.verifier', $verifier);
$request->session()->put('sso.state', $state);
$request->session()->put('sso.intended', $intended);
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$query = [
'response_type' => 'code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'scope' => 'openid profile email',
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
];
$loginHint = (string) $request->session()->get('sso.login_hint', '');
if ($loginHint !== '') {
$query['login_hint'] = $loginHint;
}
if (! $request->boolean('interactive')) {
$query['prompt'] = 'none';
}
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
return redirect()->away($authorizeUrl);
}
public function callback(Request $request): RedirectResponse
{
$intended = (string) $request->session()->get('sso.intended', route('qms.dashboard'));
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->away((string) config('ladill.marketing_url'));
}
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')));
}
if (! $request->filled('code')
|| $request->query('state') !== $request->session()->pull('sso.state')) {
return $this->finishCallback($request, $intended, 'invalid_state');
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'authorization_code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'code' => (string) $request->query('code'),
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
]);
if ($tokenRes->failed()) {
return $this->finishCallback($request, $intended, 'token_exchange_failed');
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return $this->finishCallback($request, $intended, 'userinfo_failed');
}
Auth::login($user, remember: true);
$request->session()->regenerate();
$request->session()->forget('sso.attempts');
return $this->finishCallback($request, $intended, null);
}
public function failed(Request $request): View
{
return view('auth.sso-error', [
'reason' => (string) $request->session()->get('sso.error', ''),
]);
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
// Per-app sign-out: end only this app's session and keep the platform
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently.
return redirect()->route('qms.signed-out');
}
/** Platform session ended — clear this app and offer silent sign-in again. */
public function platformSignedOut(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => (string) $request->query('redirect', ''),
]);
}
public function logoutBridge(Request $request): View
{
$return = $this->safeReturnUrl((string) $request->query('return', ''));
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
'embedded' => 1,
'return' => $return,
]);
return view('auth.sso-logout-bridge', [
'hubUrl' => $hubUrl,
'return' => $return,
'authOrigin' => 'https://'.config('app.auth_domain'),
]);
}
public function frontchannelLogout(Request $request): Response|RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$return = (string) $request->query('return', '');
$root = (string) config('app.platform_domain', 'ladill.com');
$host = parse_url($return, PHP_URL_HOST);
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($return);
}
return response('', 204);
}
private function attemptSilentRefresh(Request $request, string $intended): bool
{
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
if ($refreshToken === '') {
return false;
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'scope' => 'openid profile email',
]);
if ($tokenRes->failed()) {
$request->session()->forget('sso.refresh_token');
return false;
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return false;
}
Auth::login($user, remember: true);
$request->session()->put('sso.intended', $intended);
return true;
}
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
{
$refreshToken = (string) $tokenRes->json('refresh_token', '');
if ($refreshToken !== '') {
$request->session()->put('sso.refresh_token', $refreshToken);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return null;
}
$email = (string) ($claims->json('email') ?: '');
if ($email !== '') {
$request->session()->put('sso.login_hint', $email);
}
return User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
}
private function finishCallback(Request $request, string $intended, ?string $error = null): RedirectResponse
{
if ($error) {
$attempts = (int) $request->session()->get('sso.attempts', 0) + 1;
if ($attempts >= self::MAX_SSO_ATTEMPTS) {
$request->session()->forget(['sso.attempts', 'sso.state', 'sso.verifier', 'sso.intended']);
$request->session()->flash('sso.error', $error);
return redirect()->route('sso.failed');
}
$request->session()->put('sso.attempts', $attempts);
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]);
}
return $this->safeRedirect($intended, route('qms.dashboard'));
}
private function safeReturnUrl(string $url): string
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return $url;
}
return route('qms.signed-out');
}
private function safeRedirect(string $url, string $fallback): RedirectResponse
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($url);
}
return redirect()->away($fallback);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request): View
{
$notifications = $request->user()
->notifications()
->latest()
->paginate(20);
return view('notifications.index', compact('notifications'));
}
public function unread(Request $request): JsonResponse
{
$notifications = $request->user()
->unreadNotifications()
->latest()
->take(10)
->get()
->map(fn ($n) => [
'id' => $n->id,
'type' => class_basename($n->type),
'title' => $n->data['title'] ?? 'Notification',
'message' => $n->data['message'] ?? '',
'icon' => $n->data['icon'] ?? 'bell',
'url' => $n->data['url'] ?? null,
'created_at' => $n->created_at->diffForHumans(),
]);
return response()->json([
'notifications' => $notifications,
'unread_count' => $request->user()->unreadNotifications()->count(),
]);
}
public function markAsRead(Request $request, string $id): JsonResponse
{
$notification = $request->user()
->notifications()
->where('id', $id)
->first();
if ($notification) {
$notification->markAsRead();
}
return response()->json(['success' => true]);
}
public function markAllAsRead(Request $request): JsonResponse
{
$request->user()->unreadNotifications->markAsRead();
return response()->json(['success' => true]);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Qms\AnalyticsService;
use App\Services\Qms\OrganizationResolver;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AnalyticsController extends Controller
{
use ScopesToAccount;
public function __construct(
protected AnalyticsService $analytics,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'reports.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$overview = $this->analytics->overview($owner, $organization->id, $branchScope);
$trend = $this->analytics->dailyTrend($owner, $organization->id, 14, $branchScope);
$branches = Branch::owned($owner)
->where('organization_id', $organization->id)
->orderBy('name')
->get();
return view('qms.analytics.index', compact('overview', 'trend', 'organization', 'branches', 'branchScope'));
}
}
@@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\QueueAppointment;
use App\Models\ServiceQueue;
use App\Services\Qms\AppointmentService;
use App\Services\Qms\AuditLogger;
use App\Services\Qms\OrganizationResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
class AppointmentController extends Controller
{
use ScopesToAccount;
public function __construct(
protected AppointmentService $appointments,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'appointments.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$appointments = QueueAppointment::owned($owner)
->where('organization_id', $organization->id)
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->when($request->query('status'), fn ($q, $s) => $q->where('status', $s))
->with(['serviceQueue', 'branch', 'ticket'])
->orderBy('scheduled_at')
->paginate(30);
return view('qms.appointments.index', compact('appointments', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'appointments.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$branches = Branch::owned($owner)->where('organization_id', $organization->id)->where('is_active', true)->orderBy('name')->get();
$queues = ServiceQueue::owned($owner)->where('organization_id', $organization->id)->where('is_active', true)->orderBy('name')->get();
return view('qms.appointments.create', [
'organization' => $organization,
'branches' => $branches,
'queues' => $queues,
'modes' => config('qms.appointment_modes'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$validated = $request->validate([
'branch_id' => ['required', 'exists:queue_branches,id'],
'service_queue_id' => ['nullable', 'exists:queue_service_queues,id'],
'customer_name' => ['required', 'string', 'max:255'],
'customer_phone' => ['nullable', 'string', 'max:50'],
'customer_email' => ['nullable', 'email', 'max:255'],
'scheduled_at' => ['required', 'date'],
'mode' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))],
]);
$appointment = QueueAppointment::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
...$validated,
'mode' => $validated['mode'] ?? $organization->settings['appointment_mode'] ?? 'hybrid',
'status' => 'scheduled',
'reference' => 'APT-'.Str::upper(Str::random(6)),
]);
AuditLogger::record($owner, 'appointment.created', $organization->id, $owner, QueueAppointment::class, $appointment->id);
return redirect()->route('qms.appointments.index')->with('success', 'Appointment scheduled.');
}
public function edit(Request $request, QueueAppointment $appointment): View
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeOwner($request, $appointment);
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
return view('qms.appointments.edit', [
'appointment' => $appointment,
'branches' => Branch::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get(),
'queues' => ServiceQueue::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get(),
'modes' => config('qms.appointment_modes'),
]);
}
public function update(Request $request, QueueAppointment $appointment): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeOwner($request, $appointment);
abort_unless($appointment->status === 'scheduled', 422, 'Only scheduled appointments can be edited.');
$validated = $request->validate([
'branch_id' => ['required', 'exists:queue_branches,id'],
'service_queue_id' => ['nullable', 'exists:queue_service_queues,id'],
'customer_name' => ['required', 'string', 'max:255'],
'customer_phone' => ['nullable', 'string', 'max:50'],
'customer_email' => ['nullable', 'email', 'max:255'],
'scheduled_at' => ['required', 'date'],
'mode' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))],
]);
$appointment->update($validated);
AuditLogger::record($this->ownerRef($request), 'appointment.created', $appointment->organization_id, $this->ownerRef($request), QueueAppointment::class, $appointment->id, ['updated' => true]);
return redirect()->route('qms.appointments.index')->with('success', 'Appointment updated.');
}
public function checkIn(Request $request, QueueAppointment $appointment): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeOwner($request, $appointment);
$ticket = $this->appointments->checkIn($appointment, $this->ownerRef($request));
return redirect()->route('qms.tickets.show', $ticket)->with('success', 'Appointment checked in. Ticket issued.');
}
public function cancel(Request $request, QueueAppointment $appointment): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeOwner($request, $appointment);
$appointment->update(['status' => 'cancelled']);
AuditLogger::record($this->ownerRef($request), 'appointment.cancelled', $appointment->organization_id, $this->ownerRef($request), QueueAppointment::class, $appointment->id);
return back()->with('success', 'Appointment cancelled.');
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\AuditLog;
use App\Services\Qms\QmsPermissions;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AuditLogController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'audit.view');
$organization = $this->organization($request);
$logs = $this->query($request, $organization)->paginate(50)->withQueryString();
return view('qms.audit.index', [
'logs' => $logs,
'organization' => $organization,
'actions' => config('qms.audit_actions'),
'canExport' => app(QmsPermissions::class)->can($this->member($request), 'audit.export'),
]);
}
public function export(Request $request): StreamedResponse
{
$this->authorizeAbility($request, 'audit.export');
$organization = $this->organization($request);
$filename = 'queue-audit-'.now()->format('Y-m-d-His').'.csv';
return response()->streamDownload(function () use ($request, $organization) {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['Time', 'Action', 'Actor', 'Subject', 'Metadata', 'IP']);
$this->query($request, $organization)->chunk(200, function ($logs) use ($handle) {
foreach ($logs as $log) {
fputcsv($handle, [
$log->created_at?->toDateTimeString(),
$log->action,
$log->actor_ref ?? '—',
$log->subject_type ? class_basename($log->subject_type).' #'.$log->subject_id : '—',
json_encode($log->metadata ?? []),
$log->ip_address ?? '—',
]);
}
});
fclose($handle);
}, $filename, ['Content-Type' => 'text/csv']);
}
protected function query(Request $request, $organization)
{
return AuditLog::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->when($request->action, fn ($q, $action) => $q->where('action', $action))
->when($request->from, fn ($q, $from) => $q->where('created_at', '>=', Carbon::parse($from)->startOfDay()))
->when($request->to, fn ($q, $to) => $q->where('created_at', '<=', Carbon::parse($to)->endOfDay()))
->when($request->q, function ($q, $search) {
$q->where(function ($inner) use ($search) {
$inner->where('action', 'like', "%{$search}%")
->orWhere('metadata', 'like', "%{$search}%");
});
})
->orderByDesc('created_at');
}
}
@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Qms\AuditLogger;
use App\Services\Qms\PlanService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BranchController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.branches.view');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->withCount('departments')
->orderBy('name')
->get();
return view('qms.admin.branches.index', compact('branches', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'admin.branches.manage');
return view('qms.admin.branches.create', ['organization' => $this->organization($request)]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$currentCount = Branch::owned($owner)
->where('organization_id', $organization->id)
->count();
if (! app(PlanService::class)->canAddBranch($organization, $currentCount)) {
return back()->withInput()->with('error', 'Your plan allows one branch. Upgrade to Pro for unlimited branches.');
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:500'],
'phone' => ['nullable', 'string', 'max:50'],
]);
$branch = Branch::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
...$validated,
'is_active' => true,
]);
AuditLogger::record($owner, 'branch.created', $organization->id, $owner, Branch::class, $branch->id);
return redirect()->route('qms.branches.index')->with('success', 'Branch created.');
}
public function edit(Request $request, Branch $branch): View
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch);
return view('qms.admin.branches.edit', compact('branch'));
}
public function update(Request $request, Branch $branch): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:500'],
'phone' => ['nullable', 'string', 'max:50'],
'is_active' => ['boolean'],
]);
$branch->update([
...$validated,
'is_active' => $request->boolean('is_active', true),
]);
AuditLogger::record($this->ownerRef($request), 'branch.updated', $branch->organization_id, $this->ownerRef($request), Branch::class, $branch->id);
return redirect()->route('qms.branches.index')->with('success', 'Branch updated.');
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Qms\Concerns;
use App\Models\Member;
use App\Models\Organization;
use App\Services\Qms\OrganizationResolver;
use App\Services\Qms\QmsPermissions;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
trait ScopesToAccount
{
protected function ownerRef(Request $request): string
{
return (string) $request->user()->public_id;
}
protected function organization(Request $request): Organization
{
$organization = $request->attributes->get('qms.organization')
?? app(OrganizationResolver::class)->resolveForUser($request->user());
abort_unless($organization, 404);
return $organization;
}
protected function member(Request $request): ?Member
{
return $request->attributes->get('qms.member')
?? app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
}
protected function authorizeAbility(Request $request, string $ability): void
{
abort_unless(
app(QmsPermissions::class)->can($this->member($request), $ability),
403,
);
}
protected function authorizeOwner(Request $request, Model $model): void
{
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
}
protected function scopeToBranch(Request $request, Builder $query, string $column = 'branch_id'): Builder
{
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null) {
$query->where($column, $branchId);
}
return $query;
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
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\QueueEngine;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ConsoleController extends Controller
{
use ScopesToAccount;
public function __construct(
protected QueueEngine $engine,
) {}
public function show(Request $request, Counter $counter): View
{
$this->authorizeAbility($request, 'console.use');
$this->authorizeOwner($request, $counter);
$owner = $this->ownerRef($request);
$counter->load(['serviceQueues', 'branch']);
$queues = $counter->serviceQueues;
$currentTicket = Ticket::owned($owner)
->where('counter_id', $counter->id)
->whereIn('status', ['called', 'serving', 'on_hold'])
->with('serviceQueue')
->latest('called_at')
->first();
$waitingByQueue = collect($queues)->mapWithKeys(function (ServiceQueue $queue) {
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();
return view('qms.console.show', compact('counter', 'queues', 'currentTicket', 'waitingByQueue', 'allQueues'));
}
public function action(Request $request, Counter $counter): RedirectResponse
{
$this->authorizeAbility($request, 'console.use');
$this->authorizeOwner($request, $counter);
$actor = $this->ownerRef($request);
$validated = $request->validate([
'action' => ['required', 'string'],
'queue_id' => ['nullable', 'integer'],
'ticket_id' => ['nullable', 'integer'],
'to_queue_id' => ['nullable', 'integer'],
'reason' => ['nullable', 'string', 'max:500'],
]);
match ($validated['action']) {
'call_next' => $this->handleCallNext($counter, (int) $validated['queue_id'], $actor),
'recall' => $this->handleTicketAction($validated['ticket_id'], 'recall', $actor),
'start' => $this->handleTicketAction($validated['ticket_id'], 'start', $actor),
'hold' => $this->handleTicketAction($validated['ticket_id'], 'hold', $actor),
'skip' => $this->handleTicketAction($validated['ticket_id'], 'skip', $actor),
'complete' => $this->handleTicketAction($validated['ticket_id'], 'complete', $actor),
'no_show' => $this->handleTicketAction($validated['ticket_id'], 'no_show', $actor),
'transfer' => $this->handleTransfer($validated['ticket_id'], (int) $validated['to_queue_id'], $counter, $validated['reason'] ?? null, $actor),
'available' => $counter->update(['status' => 'available']),
'offline' => $counter->update(['status' => 'offline']),
default => null,
};
return back();
}
protected function handleCallNext(Counter $counter, int $queueId, string $actor): void
{
$queue = ServiceQueue::findOrFail($queueId);
$this->engine->callNext($queue, $counter, $actor);
}
protected function handleTicketAction(?int $ticketId, string $action, string $actor): void
{
if (! $ticketId) {
return;
}
$ticket = Ticket::findOrFail($ticketId);
match ($action) {
'recall' => $this->engine->recall($ticket, $actor),
'start' => $this->engine->startServing($ticket, $actor),
'hold' => $this->engine->hold($ticket, $actor),
'skip' => $this->engine->skip($ticket, $actor),
'complete' => $this->engine->completeTicket($ticket, $actor),
'no_show' => $this->engine->markNoShow($ticket, $actor),
default => null,
};
}
protected function handleTransfer(?int $ticketId, int $toQueueId, Counter $counter, ?string $reason, string $actor): void
{
if (! $ticketId || ! $toQueueId) {
return;
}
$ticket = Ticket::findOrFail($ticketId);
$toQueue = ServiceQueue::findOrFail($toQueueId);
$this->engine->transfer($ticket, $toQueue, $counter, $reason, $actor);
}
}
@@ -0,0 +1,128 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Counter;
use App\Models\ServiceQueue;
use App\Services\Qms\AuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CounterController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'counters.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$counters = Counter::owned($owner)
->where('organization_id', $organization->id)
->with(['branch', 'serviceQueues'])
->when(true, fn ($q) => $this->scopeToBranch($request, $q))
->orderBy('name')
->paginate(20);
return view('qms.counters.index', compact('counters', 'organization'));
}
public function show(Request $request, Counter $counter): View
{
$this->authorizeAbility($request, 'counters.view');
$this->authorizeOwner($request, $counter);
$counter->load(['branch', 'serviceQueues']);
return view('qms.counters.show', compact('counter'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'counters.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$branches = Branch::owned($owner)->where('organization_id', $organization->id)->where('is_active', true)->orderBy('name')->get();
$queues = ServiceQueue::owned($owner)->where('organization_id', $organization->id)->where('is_active', true)->orderBy('name')->get();
return view('qms.counters.create', compact('organization', 'branches', 'queues'));
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'counters.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$validated = $this->validatedCounter($request);
$counter = Counter::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
'branch_id' => $validated['branch_id'],
'name' => $validated['name'],
'code' => $validated['code'] ?? null,
'status' => 'available',
'is_active' => true,
]);
if (! empty($validated['queue_ids'])) {
$counter->serviceQueues()->sync($validated['queue_ids']);
}
AuditLogger::record($owner, 'counter.created', $organization->id, $owner, Counter::class, $counter->id);
return redirect()->route('qms.counters.show', $counter)->with('success', 'Counter created.');
}
public function edit(Request $request, Counter $counter): View
{
$this->authorizeAbility($request, 'counters.manage');
$this->authorizeOwner($request, $counter);
$owner = $this->ownerRef($request);
return view('qms.counters.edit', [
'counter' => $counter->load('serviceQueues'),
'branches' => Branch::owned($owner)->where('organization_id', $counter->organization_id)->orderBy('name')->get(),
'queues' => ServiceQueue::owned($owner)->where('organization_id', $counter->organization_id)->orderBy('name')->get(),
]);
}
public function update(Request $request, Counter $counter): RedirectResponse
{
$this->authorizeAbility($request, 'counters.manage');
$this->authorizeOwner($request, $counter);
$validated = $this->validatedCounter($request);
$counter->update([
'name' => $validated['name'],
'code' => $validated['code'] ?? null,
'branch_id' => $validated['branch_id'],
'is_active' => $request->boolean('is_active', $counter->is_active),
]);
$counter->serviceQueues()->sync($validated['queue_ids'] ?? []);
AuditLogger::record($this->ownerRef($request), 'counter.updated', $counter->organization_id, $this->ownerRef($request), Counter::class, $counter->id);
return redirect()->route('qms.counters.show', $counter)->with('success', 'Counter updated.');
}
/**
* @return array<string, mixed>
*/
protected function validatedCounter(Request $request): array
{
return $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'branch_id' => ['required', 'exists:queue_branches,id'],
'queue_ids' => ['nullable', 'array'],
'queue_ids.*' => ['integer', 'exists:queue_service_queues,id'],
]);
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Member;
use App\Models\ServiceQueue;
use App\Services\Qms\DashboardStats;
use App\Services\Qms\OrganizationResolver;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DashboardController extends Controller
{
use ScopesToAccount;
public function __construct(
protected DashboardStats $stats,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'dashboard.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$branchQuery = Branch::owned($owner)->where('organization_id', $organization->id);
$this->scopeToBranch($request, $branchQuery);
$orgStats = [
'branches' => (clone $branchQuery)->where('is_active', true)->count(),
'team_members' => Member::owned($owner)->where('organization_id', $organization->id)->count(),
'queues' => ServiceQueue::owned($owner)
->where('organization_id', $organization->id)
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->where('is_active', true)
->count(),
];
$operational = $this->stats->forOrganization($owner, $organization->id, $branchScope);
$branches = (clone $branchQuery)->withCount('serviceQueues')->orderBy('name')->get();
return view('qms.dashboard', compact('organization', 'orgStats', 'branches', 'operational'));
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Department;
use App\Services\Qms\AuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DepartmentController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.departments.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$departments = Department::owned($owner)
->whereHas('branch', fn ($q) => $q->where('organization_id', $organization->id))
->with('branch')
->orderBy('name')
->get();
return view('qms.admin.departments.index', [
'departments' => $departments,
'organization' => $organization,
'types' => config('qms.department_types'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'admin.departments.manage');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('qms.admin.departments.create', [
'organization' => $organization,
'branches' => $branches,
'types' => config('qms.department_types'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'admin.departments.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'branch_id' => ['required', 'integer', 'exists:queue_branches,id'],
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.department_types')))],
]);
$branch = Branch::owned($owner)->findOrFail($validated['branch_id']);
abort_unless($branch->organization_id === $organization->id, 404);
$department = Department::create([
'owner_ref' => $owner,
'branch_id' => $branch->id,
'name' => $validated['name'],
'type' => $validated['type'],
'is_active' => true,
]);
AuditLogger::record($owner, 'department.created', $organization->id, $owner, Department::class, $department->id);
return redirect()->route('qms.departments.index')->with('success', 'Department created.');
}
public function edit(Request $request, Department $department): View
{
$this->authorizeAbility($request, 'admin.departments.manage');
$this->authorizeOwner($request, $department);
return view('qms.admin.departments.edit', [
'department' => $department,
'types' => config('qms.department_types'),
]);
}
public function update(Request $request, Department $department): RedirectResponse
{
$this->authorizeAbility($request, 'admin.departments.manage');
$this->authorizeOwner($request, $department);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.department_types')))],
'is_active' => ['boolean'],
]);
$department->update([
...$validated,
'is_active' => $request->boolean('is_active', true),
]);
AuditLogger::record(
$this->ownerRef($request),
'department.updated',
$department->branch->organization_id,
$this->ownerRef($request),
Department::class,
$department->id,
);
return redirect()->route('qms.departments.index')->with('success', 'Department updated.');
}
public function destroy(Request $request, Department $department): RedirectResponse
{
$this->authorizeAbility($request, 'admin.departments.manage');
$this->authorizeOwner($request, $department);
$department->load('branch');
$organizationId = $department->branch->organization_id;
$departmentId = $department->id;
$department->delete();
AuditLogger::record($this->ownerRef($request), 'department.deleted', $organizationId, $this->ownerRef($request), Department::class, $departmentId);
return redirect()->route('qms.departments.index')->with('success', 'Department removed.');
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Device;
use App\Models\ServiceQueue;
use App\Services\Qms\AuditLogger;
use App\Services\Qms\DeviceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DeviceController extends Controller
{
use ScopesToAccount;
public function __construct(
protected DeviceService $devices,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'displays.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$devices = Device::owned($owner)
->where('organization_id', $organization->id)
->orderBy('name')
->paginate(20);
return view('qms.devices.index', compact('devices', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'displays.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$branches = Branch::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get();
return view('qms.devices.create', [
'organization' => $organization,
'branches' => $branches,
'deviceTypes' => config('qms.device_types'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'displays.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.device_types')))],
'branch_id' => ['nullable', 'exists:queue_branches,id'],
]);
$device = Device::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
'branch_id' => $validated['branch_id'] ?? null,
'name' => $validated['name'],
'type' => $validated['type'],
'status' => 'offline',
]);
$token = $this->devices->generateToken($device);
AuditLogger::record($owner, 'device.created', $organization->id, $owner, Device::class, $device->id);
return redirect()->route('qms.devices.index')
->with('success', 'Device registered. Token: '.$token);
}
public function regenerateToken(Request $request, Device $device): RedirectResponse
{
$this->authorizeAbility($request, 'displays.manage');
$this->authorizeOwner($request, $device);
$token = $this->devices->generateToken($device);
return back()->with('success', 'New device token: '.$token);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Services\Qms\DisplayService;
use App\Services\Qms\VoiceAnnouncementService;
use Illuminate\Http\JsonResponse;
use Illuminate\View\View;
class DisplayPublicController extends Controller
{
public function __construct(
protected DisplayService $displays,
protected VoiceAnnouncementService $voice,
) {}
public function show(string $token): View
{
$screen = $this->displays->findByToken($token) ?? abort(404);
$this->displays->touch($screen);
return view('qms.display.public', [
'screen' => $screen,
'dataUrl' => route('qms.display.data', $token),
]);
}
public function data(string $token): JsonResponse
{
$screen = $this->displays->findByToken($token) ?? abort(404);
$this->displays->touch($screen);
$payload = $this->displays->payload($screen);
foreach ($payload['announcements'] as $announcement) {
if (isset($announcement['id'])) {
$model = \App\Models\VoiceAnnouncement::find($announcement['id']);
if ($model) {
$this->voice->markPlayed($model);
}
}
}
return response()->json($payload);
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\DisplayScreen;
use App\Models\ServiceQueue;
use App\Services\Qms\AuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DisplayScreenController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'displays.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$screens = DisplayScreen::owned($owner)
->where('organization_id', $organization->id)
->orderBy('name')
->paginate(20);
return view('qms.displays.index', compact('screens', 'organization'));
}
public function show(Request $request, DisplayScreen $display): View
{
$this->authorizeAbility($request, 'displays.view');
$this->authorizeOwner($request, $display);
$display->load('branch');
return view('qms.displays.show', compact('display'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'displays.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
return view('qms.displays.create', [
'organization' => $organization,
'branches' => Branch::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get(),
'queues' => ServiceQueue::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get(),
'layouts' => config('qms.display_layouts'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'displays.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$validated = $this->validatedDisplay($request);
$screen = DisplayScreen::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
'branch_id' => $validated['branch_id'],
'name' => $validated['name'],
'layout' => $validated['layout'],
'service_queue_ids' => $validated['queue_ids'] ?? [],
'is_active' => true,
]);
AuditLogger::record($owner, 'display.created', $organization->id, $owner, DisplayScreen::class, $screen->id);
return redirect()->route('qms.displays.show', $screen)
->with('success', 'Display created. URL: '.route('qms.display.public', $screen->access_token));
}
public function edit(Request $request, DisplayScreen $display): View
{
$this->authorizeAbility($request, 'displays.manage');
$this->authorizeOwner($request, $display);
$owner = $this->ownerRef($request);
return view('qms.displays.edit', [
'display' => $display,
'branches' => Branch::owned($owner)->where('organization_id', $display->organization_id)->orderBy('name')->get(),
'queues' => ServiceQueue::owned($owner)->where('organization_id', $display->organization_id)->orderBy('name')->get(),
'layouts' => config('qms.display_layouts'),
]);
}
public function update(Request $request, DisplayScreen $display): RedirectResponse
{
$this->authorizeAbility($request, 'displays.manage');
$this->authorizeOwner($request, $display);
$validated = $this->validatedDisplay($request);
$display->update([
'name' => $validated['name'],
'branch_id' => $validated['branch_id'],
'layout' => $validated['layout'],
'service_queue_ids' => $validated['queue_ids'] ?? [],
'is_active' => $request->boolean('is_active', $display->is_active),
]);
AuditLogger::record($this->ownerRef($request), 'display.updated', $display->organization_id, $this->ownerRef($request), DisplayScreen::class, $display->id);
return redirect()->route('qms.displays.show', $display)->with('success', 'Display updated.');
}
/**
* @return array<string, mixed>
*/
protected function validatedDisplay(Request $request): array
{
return $request->validate([
'name' => ['required', 'string', 'max:255'],
'branch_id' => ['required', 'exists:queue_branches,id'],
'layout' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.display_layouts')))],
'queue_ids' => ['nullable', 'array'],
'queue_ids.*' => ['integer', 'exists:queue_service_queues,id'],
]);
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\CustomerFeedback;
use App\Services\Qms\OrganizationResolver;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FeedbackAdminController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'feedback.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$feedback = CustomerFeedback::owned($owner)
->where('organization_id', $organization->id)
->when($branchScope, fn ($q) => $q->whereHas('ticket', fn ($t) => $t->where('branch_id', $branchScope)))
->with(['ticket', 'counter'])
->latest()
->paginate(30);
$avgRating = CustomerFeedback::owned($owner)
->where('organization_id', $organization->id)
->when($branchScope, fn ($q) => $q->whereHas('ticket', fn ($t) => $t->where('branch_id', $branchScope)))
->avg('rating');
return view('qms.feedback.index', [
'feedback' => $feedback,
'organization' => $organization,
'avgRating' => round((float) $avgRating, 1),
]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Models\CustomerFeedback;
use App\Models\Ticket;
use App\Services\Qms\AuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FeedbackController extends Controller
{
public function show(string $qrToken): View
{
$ticket = Ticket::where('qr_token', $qrToken)->firstOrFail();
abort_unless($ticket->status === 'completed', 404);
$existing = CustomerFeedback::where('ticket_id', $ticket->id)->first();
return view('qms.feedback.show', compact('ticket', 'existing'));
}
public function store(Request $request, string $qrToken): RedirectResponse
{
$ticket = Ticket::where('qr_token', $qrToken)->firstOrFail();
abort_unless($ticket->status === 'completed', 404);
abort_if(CustomerFeedback::where('ticket_id', $ticket->id)->exists(), 422, 'Feedback already submitted.');
$validated = $request->validate([
'rating' => ['required', 'integer', 'min:1', 'max:5'],
'service_quality' => ['nullable', 'integer', 'min:1', 'max:5'],
'wait_experience' => ['nullable', 'integer', 'min:1', 'max:5'],
'staff_professionalism' => ['nullable', 'integer', 'min:1', 'max:5'],
'comments' => ['nullable', 'string', 'max:2000'],
]);
$feedback = CustomerFeedback::create([
'owner_ref' => $ticket->owner_ref,
'organization_id' => $ticket->organization_id,
'ticket_id' => $ticket->id,
'counter_id' => $ticket->counter_id,
...$validated,
]);
AuditLogger::record($ticket->owner_ref, 'feedback.submitted', $ticket->organization_id, null, CustomerFeedback::class, $feedback->id);
return back()->with('success', 'Thank you for your feedback.');
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Models\ServiceQueue;
use App\Services\Qms\DeviceService;
use App\Services\Qms\QueueEngine;
use App\Services\Qms\TicketPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class KioskDeviceController extends Controller
{
public function __construct(
protected QueueEngine $engine,
protected DeviceService $devices,
) {}
public function show(Request $request): View
{
$device = $request->attributes->get('qms.device');
$queues = ServiceQueue::query()
->where('organization_id', $device->organization_id)
->when($device->branch_id, fn ($q) => $q->where('branch_id', $device->branch_id))
->where('is_active', true)
->orderBy('name')
->get();
$this->devices->recordHeartbeat($device);
return view('qms.kiosk.device', compact('device', 'queues'));
}
public function issue(Request $request): JsonResponse
{
$device = $request->attributes->get('qms.device');
$this->devices->recordHeartbeat($device);
$validated = $request->validate([
'queue_id' => ['required', 'integer', 'exists:queue_service_queues,id'],
'customer_name' => ['nullable', 'string', 'max:255'],
'customer_phone' => ['nullable', 'string', 'max:50'],
'priority' => ['nullable', 'string'],
]);
$queue = ServiceQueue::findOrFail($validated['queue_id']);
abort_unless($queue->organization_id === $device->organization_id, 403);
$ticket = $this->engine->issueTicket($queue, $device->owner_ref, [
'customer_name' => $validated['customer_name'] ?? null,
'customer_phone' => $validated['customer_phone'] ?? null,
'priority' => $validated['priority'] ?? 'walk_in',
'source' => 'kiosk',
]);
return response()->json(['data' => TicketPresenter::toArray($ticket)]);
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Member;
use App\Services\Qms\AuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MemberController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.members.view');
$organization = $this->organization($request);
$members = Member::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->with('branch')
->orderBy('created_at')
->get();
return view('qms.admin.members.index', [
'members' => $members,
'organization' => $organization,
'roles' => config('qms.roles'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'admin.members.manage');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('qms.admin.members.create', [
'organization' => $organization,
'branches' => $branches,
'roles' => config('qms.roles'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'admin.members.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'user_ref' => ['required', 'string', 'max:255'],
'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.roles')))],
'branch_id' => ['nullable', 'integer', 'exists:queue_branches,id'],
]);
$member = Member::updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $validated['user_ref'],
],
[
'owner_ref' => $owner,
'role' => $validated['role'],
'branch_id' => $validated['branch_id'] ?? null,
],
);
AuditLogger::record($owner, 'member.created', $organization->id, $owner, Member::class, $member->id);
return redirect()->route('qms.members.index')->with('success', 'Member saved.');
}
public function destroy(Request $request, Member $member): RedirectResponse
{
$this->authorizeAbility($request, 'admin.members.manage');
$this->authorizeOwner($request, $member);
abort_if($member->user_ref === $this->ownerRef($request), 422, 'You cannot remove yourself.');
$memberId = $member->id;
$organizationId = $member->organization_id;
$member->delete();
AuditLogger::record($this->ownerRef($request), 'member.deleted', $organizationId, $this->ownerRef($request), Member::class, $memberId);
return redirect()->route('qms.members.index')->with('success', 'Member removed.');
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Services\Qms\QueueEngine;
use App\Services\Qms\TicketPresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MobileQueueController extends Controller
{
public function __construct(
protected QueueEngine $engine,
) {}
public function joinForm(): View
{
return view('qms.mobile.join');
}
public function join(Request $request): RedirectResponse
{
$validated = $request->validate([
'queue_uuid' => ['required', 'uuid'],
'customer_name' => ['nullable', 'string', 'max:255'],
'customer_phone' => ['required', 'string', 'max:50'],
]);
$queue = ServiceQueue::where('uuid', $validated['queue_uuid'])->where('is_active', true)->firstOrFail();
$ticket = $this->engine->issueTicket($queue, $queue->owner_ref, [
'customer_name' => $validated['customer_name'] ?? null,
'customer_phone' => $validated['customer_phone'],
'source' => 'mobile',
]);
return redirect()->route('qms.mobile.show', $ticket->qr_token);
}
public function show(string $qrToken): View
{
$ticket = Ticket::where('qr_token', $qrToken)->with(['serviceQueue', 'counter'])->firstOrFail();
$ahead = Ticket::query()
->where('service_queue_id', $ticket->service_queue_id)
->where('status', 'waiting')
->where('issued_at', '<', $ticket->issued_at)
->count();
return view('qms.mobile.show', [
'ticket' => $ticket,
'presented' => TicketPresenter::toArray($ticket, false),
'customersAhead' => $ahead,
'pollUrl' => route('qms.mobile.data', $qrToken),
]);
}
public function data(string $qrToken): \Illuminate\Http\JsonResponse
{
$ticket = Ticket::where('qr_token', $qrToken)->with(['serviceQueue', 'counter'])->firstOrFail();
$ahead = Ticket::query()
->where('service_queue_id', $ticket->service_queue_id)
->where('status', 'waiting')
->where('issued_at', '<', $ticket->issued_at)
->count();
return response()->json([
'data' => TicketPresenter::toArray($ticket, false),
'customers_ahead' => $ahead,
]);
}
public function delay(Request $request, string $qrToken): RedirectResponse
{
$ticket = Ticket::where('qr_token', $qrToken)->whereIn('status', ['waiting', 'on_hold'])->firstOrFail();
$validated = $request->validate(['minutes' => ['required', 'integer', 'min:5', 'max:120']]);
$this->engine->delayArrival($ticket, $validated['minutes']);
return back()->with('success', 'Arrival delayed.');
}
public function cancel(string $qrToken): RedirectResponse
{
$ticket = Ticket::where('qr_token', $qrToken)->whereIn('status', ['waiting', 'on_hold', 'called'])->firstOrFail();
$this->engine->cancel($ticket);
return redirect()->route('qms.mobile.join')->with('success', 'Ticket cancelled.');
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Services\Qms\OrganizationResolver;
use App\Support\OrganizationBranding;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class OnboardingController extends Controller
{
use ScopesToAccount;
public function __construct(
protected OrganizationResolver $organizations,
) {}
public function show(Request $request): View|RedirectResponse
{
if ($this->organizations->isOnboarded($request->user())) {
return redirect()->route('qms.dashboard');
}
return view('qms.onboarding.show', [
'user' => $request->user(),
'timezones' => timezone_identifiers_list(),
'industries' => config('qms.industry_templates'),
'appointmentModes' => config('qms.appointment_modes'),
]);
}
public function store(Request $request): RedirectResponse
{
if ($this->organizations->isOnboarded($request->user())) {
return redirect()->route('qms.dashboard');
}
$validated = $request->validate([
'organization_name' => ['required', 'string', 'max:255'],
'industry' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.industry_templates')))],
'appointment_mode' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))],
'branch_name' => ['required', 'string', 'max:255'],
'branch_address' => ['nullable', 'string', 'max:500'],
'branch_phone' => ['nullable', 'string', 'max:50'],
'timezone' => ['required', 'timezone'],
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
]);
$organization = $this->organizations->completeOnboarding($request->user(), $validated);
if ($request->hasFile('logo')) {
$organization->update([
'logo_path' => OrganizationBranding::storeLogo($organization, $request->file('logo')),
]);
}
return redirect()->route('qms.dashboard')->with('success', 'Welcome to Ladill Queue!');
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Services\Qms\OrganizationResolver;
use Illuminate\Http\Request;
use Illuminate\View\View;
class QueueBoardController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'queues.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$queues = ServiceQueue::owned($owner)
->where('organization_id', $organization->id)
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->where('is_active', true)
->with('branch')
->orderBy('name')
->get();
$waiting = Ticket::owned($owner)
->where('organization_id', $organization->id)
->where('status', 'waiting')
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->with('serviceQueue')
->orderBy('issued_at')
->limit(100)
->get();
$serving = Ticket::owned($owner)
->where('organization_id', $organization->id)
->whereIn('status', ['called', 'serving'])
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->with(['serviceQueue', 'counter'])
->orderByDesc('called_at')
->limit(20)
->get();
return view('qms.board.index', compact('organization', 'queues', 'waiting', 'serving'));
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\QueueRule;
use App\Models\ServiceQueue;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class QueueRuleController extends Controller
{
use ScopesToAccount;
public function index(Request $request, ServiceQueue $serviceQueue): View
{
$this->authorizeAbility($request, 'rules.view');
$this->authorizeOwner($request, $serviceQueue);
$rules = QueueRule::owned($this->ownerRef($request))
->where('service_queue_id', $serviceQueue->id)
->orderBy('sort_order')
->get();
return view('qms.rules.index', [
'queue' => $serviceQueue,
'rules' => $rules,
'ruleTypes' => config('qms.rule_types'),
]);
}
public function store(Request $request, ServiceQueue $serviceQueue): RedirectResponse
{
$this->authorizeAbility($request, 'rules.manage');
$this->authorizeOwner($request, $serviceQueue);
$validated = $request->validate([
'rule_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.rule_types')))],
'waiting_threshold' => ['nullable', 'integer', 'min:1'],
'overflow_queue_id' => ['nullable', 'exists:queue_service_queues,id'],
'priority' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_priorities')))],
'source' => ['nullable', 'string'],
]);
$conditions = [];
$actions = [];
if ($validated['rule_type'] === 'overflow') {
$conditions['waiting_threshold'] = (int) ($validated['waiting_threshold'] ?? 10);
$actions['overflow_queue_id'] = $validated['overflow_queue_id'] ?? null;
} elseif ($validated['rule_type'] === 'priority_boost') {
$conditions['source'] = $validated['source'] ?? null;
$actions['priority'] = $validated['priority'] ?? 'vip';
}
QueueRule::create([
'owner_ref' => $this->ownerRef($request),
'service_queue_id' => $serviceQueue->id,
'rule_type' => $validated['rule_type'],
'conditions' => $conditions,
'actions' => $actions,
'sort_order' => QueueRule::where('service_queue_id', $serviceQueue->id)->max('sort_order') + 1,
'is_active' => true,
]);
return back()->with('success', 'Rule added.');
}
public function destroy(Request $request, ServiceQueue $serviceQueue, QueueRule $rule): RedirectResponse
{
$this->authorizeAbility($request, 'rules.manage');
$this->authorizeOwner($request, $serviceQueue);
abort_unless($rule->service_queue_id === $serviceQueue->id, 404);
$rule->delete();
return back()->with('success', 'Rule removed.');
}
}
@@ -0,0 +1,142 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Qms\OrganizationResolver;
use App\Services\Qms\QmsPermissions;
use App\Services\Qms\ReportService;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ReportController extends Controller
{
use ScopesToAccount;
public function __construct(
protected ReportService $reports,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'reports.view');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('qms.reports.index', [
'organization' => $organization,
'branches' => $branches,
'reports' => config('qms.report_types'),
]);
}
public function show(Request $request, string $type): View
{
$this->authorizeReport($request, $type);
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
[$from, $to] = $this->dateRange($request);
$owner = $this->ownerRef($request);
$data = match ($type) {
'tickets' => $this->reports->ticketsReport($owner, $organization->id, $from, $to, $branchId),
'wait_times' => $this->reports->waitTimesReport($owner, $organization->id, $from, $to, $branchId),
'counters' => $this->reports->countersReport($owner, $organization->id, $from, $to, $branchId),
'appointments' => $this->reports->appointmentsReport($owner, $organization->id, $from, $to, $branchId),
'feedback' => $this->reports->feedbackReport($owner, $organization->id, $from, $to, $branchId),
'service_sessions' => $this->reports->serviceSessionsReport($owner, $organization->id, $from, $to, $branchId),
default => abort(404),
};
$branches = Branch::owned($owner)
->where('organization_id', $organization->id)
->orderBy('name')
->get();
return view('qms.reports.show', [
'type' => $type,
'label' => config('qms.report_types')[$type] ?? $type,
'data' => $data,
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'branchId' => $branchId,
'branches' => $branches,
'canExport' => app(QmsPermissions::class)->can($this->member($request), 'reports.export'),
]);
}
public function export(Request $request, string $type): StreamedResponse
{
$this->authorizeReport($request, $type);
abort_unless(app(QmsPermissions::class)->can($this->member($request), 'reports.export'), 403);
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
[$from, $to] = $this->dateRange($request);
$owner = $this->ownerRef($request);
$data = match ($type) {
'tickets' => $this->reports->ticketsReport($owner, $organization->id, $from, $to, $branchId),
'wait_times' => $this->reports->waitTimesReport($owner, $organization->id, $from, $to, $branchId),
'counters' => $this->reports->countersReport($owner, $organization->id, $from, $to, $branchId),
'appointments' => $this->reports->appointmentsReport($owner, $organization->id, $from, $to, $branchId),
'feedback' => $this->reports->feedbackReport($owner, $organization->id, $from, $to, $branchId),
'service_sessions' => $this->reports->serviceSessionsReport($owner, $organization->id, $from, $to, $branchId),
default => abort(404),
};
$filename = "queue-report-{$type}-".now()->format('Y-m-d').'.csv';
return response()->streamDownload(function () use ($data, $type) {
$handle = fopen('php://output', 'w');
if ($type === 'wait_times' && isset($data['hourly'])) {
fputcsv($handle, ['Hour', 'Avg wait (seconds)']);
foreach ($data['hourly'] as $row) {
fputcsv($handle, [$row['hour'], $row['avg_wait_seconds']]);
}
} elseif ($type === 'counters' && isset($data['counters'])) {
fputcsv($handle, ['Counter', 'Served']);
foreach ($data['counters'] as $row) {
fputcsv($handle, [$row['name'], $row['served']]);
}
} else {
fputcsv($handle, ['Metric', 'Value']);
foreach ($data as $key => $value) {
if (is_array($value)) {
continue;
}
fputcsv($handle, [$key, $value]);
}
}
fclose($handle);
}, $filename, ['Content-Type' => 'text/csv']);
}
protected function authorizeReport(Request $request, string $type): void
{
abort_unless(array_key_exists($type, config('qms.report_types')), 404);
$this->authorizeAbility($request, 'reports.view');
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
protected function dateRange(Request $request): array
{
$from = Carbon::parse($request->input('from', now()->subDays(30)->toDateString()))->startOfDay();
$to = Carbon::parse($request->input('to', now()->toDateString()))->endOfDay();
return [$from, $to];
}
}
@@ -0,0 +1,170 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Services\Qms\AuditLogger;
use App\Services\Qms\PlanService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ServiceQueueController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'queues.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$queues = ServiceQueue::owned($owner)
->where('organization_id', $organization->id)
->with(['branch', 'department'])
->when(true, fn ($q) => $this->scopeToBranch($request, $q))
->orderBy('name')
->paginate(20);
return view('qms.queues.index', compact('queues', 'organization'));
}
public function show(Request $request, ServiceQueue $serviceQueue): View
{
$this->authorizeAbility($request, 'queues.view');
$this->authorizeOwner($request, $serviceQueue);
$serviceQueue->load(['branch', 'department']);
$waiting = Ticket::owned($this->ownerRef($request))
->where('service_queue_id', $serviceQueue->id)
->where('status', 'waiting')
->count();
return view('qms.queues.show', [
'queue' => $serviceQueue,
'waiting' => $waiting,
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'queues.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$branches = Branch::owned($owner)
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('qms.queues.create', [
'organization' => $organization,
'branches' => $branches,
'strategies' => config('qms.queue_strategies'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'queues.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$currentCount = ServiceQueue::owned($owner)->where('organization_id', $organization->id)->count();
if (! app(PlanService::class)->canAddQueue($organization, $currentCount)) {
return back()->withInput()->with('error', 'Your plan allows '.$currentCount.' queues. Upgrade to Pro for unlimited queues.');
}
$validated = $this->validatedQueue($request);
abort_unless(
Branch::owned($owner)->where('organization_id', $organization->id)->where('id', $validated['branch_id'])->exists(),
404,
);
$queue = ServiceQueue::create([
...$validated,
'owner_ref' => $owner,
'organization_id' => $organization->id,
'color' => $validated['color'] ?? '#4f46e5',
'avg_service_seconds' => $validated['avg_service_seconds'] ?? 300,
'is_active' => $request->boolean('is_active', true),
]);
AuditLogger::record($owner, 'service_queue.created', $organization->id, $owner, ServiceQueue::class, $queue->id);
return redirect()->route('qms.queues.show', $queue)->with('success', 'Queue created.');
}
public function edit(Request $request, ServiceQueue $serviceQueue): View
{
$this->authorizeAbility($request, 'queues.manage');
$this->authorizeOwner($request, $serviceQueue);
$owner = $this->ownerRef($request);
return view('qms.queues.edit', [
'queue' => $serviceQueue,
'branches' => Branch::owned($owner)->where('organization_id', $serviceQueue->organization_id)->orderBy('name')->get(),
'strategies' => config('qms.queue_strategies'),
]);
}
public function update(Request $request, ServiceQueue $serviceQueue): RedirectResponse
{
$this->authorizeAbility($request, 'queues.manage');
$this->authorizeOwner($request, $serviceQueue);
$validated = $this->validatedQueue($request, updating: true);
$serviceQueue->update([
...$validated,
'color' => $validated['color'] ?? $serviceQueue->color,
'is_active' => $request->boolean('is_active', $serviceQueue->is_active),
]);
AuditLogger::record($this->ownerRef($request), 'service_queue.updated', $serviceQueue->organization_id, $this->ownerRef($request), ServiceQueue::class, $serviceQueue->id);
return redirect()->route('qms.queues.show', $serviceQueue)->with('success', 'Queue updated.');
}
public function pause(Request $request, ServiceQueue $serviceQueue): RedirectResponse
{
$this->authorizeAbility($request, 'queues.manage');
$this->authorizeOwner($request, $serviceQueue);
app(\App\Services\Qms\QueueEngine::class)->pauseQueue($serviceQueue, $this->ownerRef($request));
return back()->with('success', 'Queue paused.');
}
public function resume(Request $request, ServiceQueue $serviceQueue): RedirectResponse
{
$this->authorizeAbility($request, 'queues.manage');
$this->authorizeOwner($request, $serviceQueue);
app(\App\Services\Qms\QueueEngine::class)->resumeQueue($serviceQueue, $this->ownerRef($request));
return back()->with('success', 'Queue resumed.');
}
/**
* @return array<string, mixed>
*/
protected function validatedQueue(Request $request, bool $updating = false): array
{
return $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:1000'],
'branch_id' => ['required', 'exists:queue_branches,id'],
'department_id' => ['nullable', 'exists:queue_departments,id'],
'color' => ['nullable', 'string', 'max:20'],
'prefix' => [$updating ? 'sometimes' : 'required', 'string', 'max:10'],
'strategy' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.queue_strategies')))],
'avg_service_seconds' => ['nullable', 'integer', 'min:60', 'max:7200'],
'max_capacity' => ['nullable', 'integer', 'min:1'],
'is_active' => ['boolean'],
]);
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Qms\AuditLogger;
use App\Services\Qms\QmsPermissions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SettingsController extends Controller
{
use ScopesToAccount;
public function edit(Request $request): View
{
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$canManage = app(QmsPermissions::class)->can($this->member($request), 'settings.manage');
$branchCount = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->count();
return view('qms.settings.edit', [
'organization' => $organization,
'canManage' => $canManage,
'branchCount' => $branchCount,
'appointmentModes' => config('qms.appointment_modes'),
'industries' => config('qms.industry_templates'),
]);
}
public function update(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'timezone' => ['required', 'timezone'],
'appointment_mode' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))],
'industry' => ['nullable', 'string'],
'notifications_enabled' => ['boolean'],
]);
$settings = $organization->settings ?? [];
$settings['appointment_mode'] = $validated['appointment_mode'];
$settings['industry'] = $validated['industry'] ?? $settings['industry'] ?? null;
$settings['notifications_enabled'] = $request->boolean('notifications_enabled', true);
$organization->update([
'name' => $validated['name'],
'timezone' => $validated['timezone'],
'settings' => $settings,
]);
AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, \App\Models\Organization::class, $organization->id);
return back()->with('success', 'Settings saved.');
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Services\Qms\QueueEngine;
use App\Services\Qms\TicketPresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class TicketController extends Controller
{
use ScopesToAccount;
public function __construct(
protected QueueEngine $engine,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'tickets.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$tickets = Ticket::owned($owner)
->where('organization_id', $organization->id)
->with(['serviceQueue', 'counter'])
->when($request->query('status'), fn ($q, $s) => $q->where('status', $s))
->latest('issued_at')
->paginate(30);
$queues = ServiceQueue::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get();
return view('qms.tickets.index', compact('tickets', 'queues', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'tickets.issue');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$queues = ServiceQueue::owned($owner)
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('qms.tickets.create', compact('queues', 'organization'));
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'tickets.issue');
$owner = $this->ownerRef($request);
$validated = $request->validate([
'service_queue_id' => ['required', 'exists:queue_service_queues,id'],
'customer_name' => ['nullable', 'string', 'max:255'],
'customer_phone' => ['nullable', 'string', 'max:50'],
'priority' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_priorities')))],
]);
$queue = ServiceQueue::findOrFail($validated['service_queue_id']);
$this->authorizeOwner($request, $queue);
$ticket = $this->engine->issueTicket($queue, $owner, [
...$validated,
'source' => 'reception',
'actor_ref' => $owner,
]);
return redirect()->route('qms.tickets.show', $ticket)->with('success', 'Ticket issued.');
}
public function show(Request $request, Ticket $ticket): View
{
$this->authorizeAbility($request, 'tickets.view');
$this->authorizeOwner($request, $ticket);
$ticket->load(['serviceQueue', 'counter', 'events']);
return view('qms.tickets.show', [
'ticket' => $ticket,
'presented' => TicketPresenter::toArray($ticket),
]);
}
public function print(Request $request, Ticket $ticket): View
{
$this->authorizeAbility($request, 'tickets.view');
$this->authorizeOwner($request, $ticket);
$ticket->load(['serviceQueue', 'branch']);
return view('qms.tickets.print', compact('ticket'));
}
}
@@ -0,0 +1,162 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\ServiceQueue;
use App\Models\Workflow;
use App\Models\WorkflowStep;
use App\Services\Qms\AuditLogger;
use App\Services\Qms\WorkflowService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class WorkflowController extends Controller
{
use ScopesToAccount;
public function __construct(
protected WorkflowService $workflows,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'workflows.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$workflows = Workflow::owned($owner)
->where('organization_id', $organization->id)
->withCount('steps')
->orderBy('name')
->get();
return view('qms.workflows.index', compact('workflows', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'workflows.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
return view('qms.workflows.create', [
'organization' => $organization,
'branches' => Branch::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get(),
'queues' => ServiceQueue::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get(),
'templates' => config('qms.industry_templates'),
'workflowTemplates' => config('qms.workflow_templates'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'workflows.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'branch_id' => ['nullable', 'exists:queue_branches,id'],
'industry_template' => ['nullable', 'string'],
'steps' => ['required', 'array', 'min:1'],
'steps.*.name' => ['required', 'string', 'max:255'],
'steps.*.service_queue_id' => ['nullable', 'exists:queue_service_queues,id'],
]);
$workflow = Workflow::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'branch_id' => $validated['branch_id'] ?? null,
'industry_template' => $validated['industry_template'] ?? null,
'is_active' => true,
]);
foreach ($validated['steps'] as $i => $step) {
WorkflowStep::create([
'workflow_id' => $workflow->id,
'name' => $step['name'],
'service_queue_id' => $step['service_queue_id'] ?? null,
'sort_order' => $i + 1,
]);
}
AuditLogger::record($owner, 'workflow.created', $organization->id, $owner, Workflow::class, $workflow->id);
return redirect()->route('qms.workflows.index')->with('success', 'Workflow created.');
}
public function show(Request $request, Workflow $workflow): View
{
$this->authorizeAbility($request, 'workflows.view');
$this->authorizeOwner($request, $workflow);
$workflow->load(['steps.serviceQueue', 'branch']);
return view('qms.workflows.show', compact('workflow'));
}
public function edit(Request $request, Workflow $workflow): View
{
$this->authorizeAbility($request, 'workflows.manage');
$this->authorizeOwner($request, $workflow);
$owner = $this->ownerRef($request);
return view('qms.workflows.edit', [
'workflow' => $workflow->load('steps'),
'branches' => Branch::owned($owner)->where('organization_id', $workflow->organization_id)->orderBy('name')->get(),
'queues' => ServiceQueue::owned($owner)->where('organization_id', $workflow->organization_id)->orderBy('name')->get(),
]);
}
public function update(Request $request, Workflow $workflow): RedirectResponse
{
$this->authorizeAbility($request, 'workflows.manage');
$this->authorizeOwner($request, $workflow);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'branch_id' => ['nullable', 'exists:queue_branches,id'],
'is_active' => ['boolean'],
'steps' => ['required', 'array', 'min:1'],
'steps.*.name' => ['required', 'string', 'max:255'],
'steps.*.service_queue_id' => ['nullable', 'exists:queue_service_queues,id'],
]);
$workflow->update([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'branch_id' => $validated['branch_id'] ?? null,
'is_active' => $request->boolean('is_active', $workflow->is_active),
]);
$workflow->steps()->delete();
foreach ($validated['steps'] as $i => $step) {
WorkflowStep::create([
'workflow_id' => $workflow->id,
'name' => $step['name'],
'service_queue_id' => $step['service_queue_id'] ?? null,
'sort_order' => $i + 1,
]);
}
AuditLogger::record($this->ownerRef($request), 'workflow.updated', $workflow->organization_id, $this->ownerRef($request), Workflow::class, $workflow->id);
return redirect()->route('qms.workflows.show', $workflow)->with('success', 'Workflow updated.');
}
public function destroy(Request $request, Workflow $workflow): RedirectResponse
{
$this->authorizeAbility($request, 'workflows.manage');
$this->authorizeOwner($request, $workflow);
$workflow->delete();
return redirect()->route('qms.workflows.index')->with('success', 'Workflow removed.');
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use App\Services\Qms\DeviceService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateQmsDevice
{
public function __construct(
protected DeviceService $devices,
) {}
public function handle(Request $request, Closure $next, ?string $type = null): Response
{
$token = (string) ($request->route('token') ?? $request->header('X-Device-Token', ''));
$device = $this->devices->findByToken($token);
abort_unless($device, 401);
if ($type && $device->type !== $type) {
abort(403);
}
$request->attributes->set('qms.device', $device);
return $next($request);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Generic service-to-service auth for internal APIs. Validates the bearer token
* against per-consumer keys in config("{namespace}.service_api_keys") with a
* constant-time compare, and records the caller. Usage: auth.service:crm.
*/
class AuthenticateService
{
public function handle(Request $request, Closure $next, string $namespace = 'crm'): Response
{
$token = (string) $request->bearerToken();
$caller = null;
if ($token !== '') {
foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) {
if (is_string($key) && $key !== '' && hash_equals($key, $token)) {
$caller = $name;
break;
}
}
}
if ($caller === null) {
return response()->json(['error' => 'Unauthorized.'], 401);
}
$request->attributes->set('service_caller', $caller);
return $next($request);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use App\Services\Qms\OrganizationResolver;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureOrganizationSetup
{
public function __construct(
protected OrganizationResolver $organizations,
) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
if ($request->routeIs('qms.onboarding*')) {
return $next($request);
}
if (! $this->organizations->isOnboarded($user)) {
return redirect()->route('qms.onboarding.show');
}
return $next($request);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
/**
* App sessions are subordinate to the platform (auth.ladill.com) session.
* When the platform session ends, clear this app's local session too.
*/
class EnsurePlatformSession
{
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return $next($request);
}
$authDomain = trim((string) config('app.auth_domain', ''));
if ($authDomain === '') {
return $next($request);
}
$cookieHeader = (string) $request->headers->get('Cookie', '');
if ($cookieHeader === '') {
return $this->clearAppSession($request);
}
try {
$response = Http::withHeaders(['Cookie' => $cookieHeader])
->timeout(3)
->get('https://'.$authDomain.'/sso/ping');
} catch (\Throwable) {
return $next($request);
}
if ($response->status() === 401) {
return $this->clearAppSession($request);
}
return $next($request);
}
private function clearAppSession(Request $request): Response
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => $request->fullUrl(),
]);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Services\Qms\OrganizationResolver;
use App\Services\Qms\QmsPermissions;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureQmsAbility
{
public function __construct(
protected OrganizationResolver $organizations,
protected QmsPermissions $permissions,
) {}
public function handle(Request $request, Closure $next, string $ability): Response
{
$user = $request->user();
abort_unless($user, 403);
$organization = $this->organizations->resolveForUser($user);
abort_unless($organization, 403);
$member = $this->organizations->memberFor($user, $organization);
abort_unless($this->permissions->can($member, $ability), 403);
$request->attributes->set('qms.organization', $organization);
$request->attributes->set('qms.member', $member);
return $next($request);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
/**
* Shares the signed-in account with views. CRM data is scoped per account by
* the user's public_id (owner_ref); today every user acts as their own
* account (team support graduates here later, like the platform pattern).
*/
class SetActingAccount
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
$request->attributes->set('actingAccount', $user);
if (! $request->is('api/*')) {
View::share('actingAccount', $user);
}
}
return $next($request);
}
}