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,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.');
}
}