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,22 @@
<?php
namespace App\Console\Commands;
use App\Services\Qms\DeviceService;
use Illuminate\Console\Command;
class MarkDevicesOfflineCommand extends Command
{
protected $signature = 'qms:mark-devices-offline';
protected $description = 'Mark queue devices offline when heartbeat is stale';
public function handle(DeviceService $devices): int
{
$count = $devices->markStaleOffline();
$this->info("Marked {$count} device(s) offline.");
return self::SUCCESS;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Models\QueueAppointment;
use App\Services\Qms\QueueNotificationService;
use Illuminate\Console\Command;
class SendAppointmentRemindersCommand extends Command
{
protected $signature = 'qms:send-appointment-reminders {--minutes=60 : Remind appointments this many minutes ahead}';
protected $description = 'Send SMS/email reminders for upcoming appointments';
public function handle(QueueNotificationService $notifications): int
{
$minutes = (int) $this->option('minutes');
$from = now();
$to = now()->addMinutes($minutes);
$appointments = QueueAppointment::query()
->where('status', 'scheduled')
->whereBetween('scheduled_at', [$from, $to])
->where(function ($q) {
$q->whereNull('metadata')
->orWhereNull('metadata->reminder_sent');
})
->get();
$count = 0;
foreach ($appointments as $appointment) {
$notifications->appointmentReminder($appointment);
$metadata = $appointment->metadata ?? [];
$metadata['reminder_sent'] = now()->toIso8601String();
$appointment->update(['metadata' => $metadata]);
$count++;
}
$this->info("Sent {$count} appointment reminder(s).");
return self::SUCCESS;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ServiceEventOccurred
{
use Dispatchable, SerializesModels;
/**
* @param array<string, mixed> $data
*/
public function __construct(
public readonly string $name,
public readonly array $data = [],
) {}
}
@@ -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);
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Listeners;
use App\Events\ServiceEventOccurred;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class PlatformServiceEventListener
{
public function handle(ServiceEventOccurred $event): void
{
match ($event->name) {
'user.deleted' => $this->handleUserDeleted($event),
'user.suspended' => $this->handleUserSuspended($event),
'organization.updated' => $this->handleOrganizationUpdated($event),
default => null,
};
}
protected function handleUserDeleted(ServiceEventOccurred $event): void
{
$publicId = (string) ($event->data['user'] ?? '');
if ($publicId === '') {
return;
}
DB::transaction(function () use ($publicId) {
Member::where('user_ref', $publicId)->delete();
User::where('public_id', $publicId)->delete();
});
Log::info('PlatformServiceEventListener: user deleted', ['public_id' => $publicId]);
}
protected function handleUserSuspended(ServiceEventOccurred $event): void
{
$publicId = (string) ($event->data['user'] ?? '');
if ($publicId === '') {
return;
}
$user = User::where('public_id', $publicId)->first();
if ($user) {
$user->tokens()->delete();
}
Log::info('PlatformServiceEventListener: user suspended', ['public_id' => $publicId]);
}
protected function handleOrganizationUpdated(ServiceEventOccurred $event): void
{
$ownerRef = (string) ($event->data['owner'] ?? $event->data['user'] ?? '');
if ($ownerRef === '') {
return;
}
$organization = Organization::owned($ownerRef)->first();
if (! $organization) {
return;
}
$updates = array_filter([
'name' => $event->data['name'] ?? null,
'timezone' => $event->data['timezone'] ?? null,
], fn ($value) => $value !== null && $value !== '');
if ($updates === []) {
return;
}
$organization->update($updates);
Log::info('PlatformServiceEventListener: organization updated', [
'organization_id' => $organization->id,
'owner_ref' => $ownerRef,
]);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ContactMessageMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public string $subjectLine,
public string $bodyText,
public ?string $fromName = null,
public ?string $replyToAddress = null,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->subjectLine,
replyTo: $this->replyToAddress ? [$this->replyToAddress] : [],
);
}
public function content(): Content
{
return new Content(
view: 'email.contact-message',
with: [
'bodyText' => $this->bodyText,
'fromName' => $this->fromName,
],
);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AuditLog extends Model
{
use BelongsToOwner;
protected $table = 'queue_audit_logs';
public $timestamps = false;
protected $fillable = [
'owner_ref', 'organization_id', 'actor_ref', 'action',
'subject_type', 'subject_id', 'metadata', 'ip_address', 'created_at',
];
protected function casts(): array
{
return [
'metadata' => 'array',
'created_at' => 'datetime',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public static function record(
string $ownerRef,
string $action,
?int $organizationId = null,
?string $actorRef = null,
?string $subjectType = null,
?int $subjectId = null,
?array $metadata = null,
): self {
return static::create([
'owner_ref' => $ownerRef,
'organization_id' => $organizationId,
'actor_ref' => $actorRef,
'action' => $action,
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'metadata' => $metadata,
'ip_address' => request()?->ip(),
'created_at' => now(),
]);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Branch extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'queue_branches';
protected $fillable = [
'owner_ref', 'organization_id', 'name', 'code', 'address', 'phone', 'is_active',
];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function departments(): HasMany
{
return $this->hasMany(Department::class, 'branch_id');
}
public function serviceQueues(): HasMany
{
return $this->hasMany(ServiceQueue::class, 'branch_id');
}
public function counters(): HasMany
{
return $this->hasMany(Counter::class, 'branch_id');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToOwner
{
public function scopeOwned(Builder $query, string $ownerRef): Builder
{
return $query->where($this->getTable().'.owner_ref', $ownerRef);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Counter extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'queue_counters';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'department_id',
'name', 'code', 'status', 'settings', 'is_active',
];
protected function casts(): array
{
return [
'settings' => 'array',
'is_active' => 'boolean',
];
}
protected static function booted(): void
{
static::creating(function (self $counter) {
$counter->uuid ??= (string) Str::uuid();
});
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class, 'department_id');
}
public function serviceQueues(): BelongsToMany
{
return $this->belongsToMany(ServiceQueue::class, 'queue_counter_service_queue', 'counter_id', 'service_queue_id')
->withPivot('priority');
}
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'counter_id');
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class CustomerFeedback extends Model
{
use BelongsToOwner;
protected $table = 'queue_customer_feedback';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'ticket_id', 'counter_id',
'rating', 'service_quality', 'wait_experience', 'staff_professionalism', 'comments',
];
protected static function booted(): void
{
static::creating(function (self $model) {
$model->uuid ??= (string) Str::uuid();
});
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
public function counter(): BelongsTo
{
return $this->belongsTo(Counter::class, 'counter_id');
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Department extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'queue_departments';
protected $fillable = [
'owner_ref', 'branch_id', 'name', 'type', 'is_active',
];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Device extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'queue_devices';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id',
'name', 'type', 'status', 'device_token', 'config', 'last_online_at',
];
protected function casts(): array
{
return [
'config' => 'array',
'last_online_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (self $device) {
$device->uuid ??= (string) Str::uuid();
});
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class DisplayScreen extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'queue_display_screens';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id',
'name', 'layout', 'access_token', 'service_queue_ids', 'settings', 'is_active', 'last_seen_at',
];
protected function casts(): array
{
return [
'service_queue_ids' => 'array',
'settings' => 'array',
'is_active' => 'boolean',
'last_seen_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (self $screen) {
$screen->uuid ??= (string) Str::uuid();
$screen->access_token ??= Str::random(48);
});
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Member extends Model
{
use BelongsToOwner;
protected $table = 'queue_members';
protected $fillable = ['owner_ref', 'organization_id', 'user_ref', 'role', 'branch_id'];
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function hasRole(string ...$roles): bool
{
return in_array($this->role, $roles, true);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Organization extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'queue_organizations';
protected $fillable = [
'owner_ref', 'name', 'slug', 'logo_path', 'timezone', 'settings',
];
protected function casts(): array
{
return ['settings' => 'array'];
}
public function branches(): HasMany
{
return $this->hasMany(Branch::class, 'organization_id');
}
public function members(): HasMany
{
return $this->hasMany(Member::class, 'organization_id');
}
public function serviceQueues(): HasMany
{
return $this->hasMany(ServiceQueue::class, 'organization_id');
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class QueueAppointment extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'queue_appointments';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'service_queue_id', 'ticket_id',
'customer_name', 'customer_phone', 'customer_email', 'status', 'mode',
'scheduled_at', 'checked_in_at', 'reference', 'metadata',
];
protected function casts(): array
{
return [
'scheduled_at' => 'datetime',
'checked_in_at' => 'datetime',
'metadata' => 'array',
];
}
protected static function booted(): void
{
static::creating(function (self $model) {
$model->uuid ??= (string) Str::uuid();
});
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function serviceQueue(): BelongsTo
{
return $this->belongsTo(ServiceQueue::class, 'service_queue_id');
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QueueRule extends Model
{
use BelongsToOwner;
protected $table = 'queue_queue_rules';
protected $fillable = [
'owner_ref', 'service_queue_id', 'rule_type', 'conditions', 'actions', 'sort_order', 'is_active',
];
protected function casts(): array
{
return [
'conditions' => 'array',
'actions' => 'array',
'is_active' => 'boolean',
];
}
public function serviceQueue(): BelongsTo
{
return $this->belongsTo(ServiceQueue::class, 'service_queue_id');
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class ServiceQueue extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'queue_service_queues';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'department_id',
'name', 'description', 'color', 'prefix', 'strategy',
'max_capacity', 'avg_service_seconds', 'operating_hours', 'priority_rules',
'settings', 'is_active', 'is_paused', 'ticket_sequence',
];
protected function casts(): array
{
return [
'operating_hours' => 'array',
'priority_rules' => 'array',
'settings' => 'array',
'is_active' => 'boolean',
'is_paused' => 'boolean',
];
}
protected static function booted(): void
{
static::creating(function (self $queue) {
$queue->uuid ??= (string) Str::uuid();
});
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class, 'department_id');
}
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'service_queue_id');
}
public function counters(): BelongsToMany
{
return $this->belongsToMany(Counter::class, 'queue_counter_service_queue', 'service_queue_id', 'counter_id')
->withPivot('priority');
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ServiceSession extends Model
{
use BelongsToOwner;
protected $table = 'queue_service_sessions';
protected $fillable = [
'owner_ref', 'ticket_id', 'counter_id', 'staff_ref',
'started_at', 'ended_at', 'duration_seconds',
];
protected function casts(): array
{
return [
'started_at' => 'datetime',
'ended_at' => 'datetime',
];
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
public function counter(): BelongsTo
{
return $this->belongsTo(Counter::class, 'counter_id');
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Ticket extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'queue_tickets';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'service_queue_id', 'counter_id',
'ticket_number', 'status', 'priority', 'position', 'customer_name', 'customer_phone',
'customer_email', 'source', 'qr_token', 'barcode', 'estimated_wait_seconds',
'issued_at', 'called_at', 'serving_started_at', 'completed_at', 'metadata',
];
protected function casts(): array
{
return [
'metadata' => 'array',
'issued_at' => 'datetime',
'called_at' => 'datetime',
'serving_started_at' => 'datetime',
'completed_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (self $ticket) {
$ticket->uuid ??= (string) Str::uuid();
$ticket->qr_token ??= Str::random(32);
});
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function serviceQueue(): BelongsTo
{
return $this->belongsTo(ServiceQueue::class, 'service_queue_id');
}
public function counter(): BelongsTo
{
return $this->belongsTo(Counter::class, 'counter_id');
}
public function events(): HasMany
{
return $this->hasMany(TicketEvent::class, 'ticket_id');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketEvent extends Model
{
use BelongsToOwner;
protected $table = 'queue_ticket_events';
public $timestamps = false;
protected $fillable = [
'owner_ref', 'ticket_id', 'event', 'actor_ref', 'counter_id', 'metadata', 'created_at',
];
protected function casts(): array
{
return [
'metadata' => 'array',
'created_at' => 'datetime',
];
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
public function counter(): BelongsTo
{
return $this->belongsTo(Counter::class, 'counter_id');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
/**
* Thin local mirror of the platform identity (auth.ladill.com owns users).
* Keyed by the OIDC `sub` (public_id); every CRM record is scoped to it.
*/
class User extends Authenticatable
{
use HasApiTokens, Notifiable;
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password', 'last_app_active_at'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'last_app_active_at' => 'datetime',
'password' => 'hashed',
];
}
/** The owner reference used to scope every CRM record to this account. */
public function ownerRef(): string
{
return (string) $this->public_id;
}
public function avatarUrl(): ?string
{
$url = trim((string) $this->avatar_url);
return $url !== '' ? $url : null;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class VoiceAnnouncement extends Model
{
use BelongsToOwner;
protected $table = 'queue_voice_announcements';
protected $fillable = [
'owner_ref', 'organization_id', 'branch_id', 'ticket_id',
'locale', 'message', 'voice', 'volume', 'repeat_count', 'status', 'played_at',
];
protected function casts(): array
{
return [
'played_at' => 'datetime',
];
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Workflow extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'queue_workflows';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id',
'name', 'description', 'industry_template', 'is_active', 'settings',
];
protected function casts(): array
{
return [
'settings' => 'array',
'is_active' => 'boolean',
];
}
protected static function booted(): void
{
static::creating(function (self $model) {
$model->uuid ??= (string) Str::uuid();
});
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function steps(): HasMany
{
return $this->hasMany(WorkflowStep::class, 'workflow_id')->orderBy('sort_order');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WorkflowStep extends Model
{
protected $table = 'queue_workflow_steps';
protected $fillable = [
'workflow_id', 'service_queue_id', 'name', 'sort_order', 'settings',
];
protected function casts(): array
{
return ['settings' => 'array'];
}
public function workflow(): BelongsTo
{
return $this->belongsTo(Workflow::class, 'workflow_id');
}
public function serviceQueue(): BelongsTo
{
return $this->belongsTo(ServiceQueue::class, 'service_queue_id');
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Notifications\Qms;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class QueueStaffNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public string $title,
public string $message,
public ?string $url = null,
public string $icon = 'bell',
) {}
/**
* @return list<string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'title' => $this->title,
'message' => $this->message,
'url' => $this->url,
'icon' => $this->icon,
];
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Providers;
use App\Events\ServiceEventOccurred;
use App\Listeners\PlatformServiceEventListener;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Event::listen(ServiceEventOccurred::class, PlatformServiceEventListener::class);
RateLimiter::for('kiosk-device', function (Request $request) {
$key = $request->route('token') ?? $request->ip();
return Limit::perMinute(30)->by((string) $key);
});
View::composer(['partials.topbar'], function ($view) {
$view->with(\App\Support\MobileTopbar::resolve());
});
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
/**
* Client for the platform Billing HTTP API the one UserWallet lives on the
* platform; CRM only consumes it. Amounts are integer minor units; the user is
* identified by public_id. Authenticates with the per-consumer config('billing.api_key').
*/
class BillingClient
{
private function base(): string
{
return rtrim((string) config('billing.api_url'), '/');
}
private function token(): string
{
return (string) (config('billing.api_key') ?? '');
}
public function balanceMinor(string $publicId): int
{
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
->get($this->base().'/balance', ['user' => $publicId]);
$res->throw();
return (int) ($res->json('balance_minor') ?? 0);
}
public function canAfford(string $publicId, int $amountMinor): bool
{
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
->get($this->base().'/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor]);
$res->throw();
return (bool) ($res->json('affordable') ?? false);
}
/**
* Debit the wallet. Returns true on success, false on insufficient balance
* (HTTP 402). Idempotent by $reference.
*/
public function debit(string $publicId, int $amountMinor, string $source, string $reference, ?string $description = null): bool
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
'user' => $publicId,
'amount_minor' => $amountMinor,
'service' => (string) config('billing.service', 'crm'),
'source' => $source,
'reference' => $reference,
'description' => $description,
], static fn ($v) => $v !== null));
if ($res->status() === 402) {
return false;
}
$res->throw();
return true;
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Services\Billing;
use App\Models\CrmPurchase;
use Illuminate\Support\Str;
class OneTimePurchaseService
{
public function __construct(private readonly BillingClient $billing)
{
}
/** @return array<string, mixed>|null */
public function product(string $productKey): ?array
{
$product = config('crm_products.'.$productKey);
return is_array($product) ? $product : null;
}
public function hasPurchased(string $ownerRef, string $productKey): bool
{
return CrmPurchase::has($ownerRef, $productKey);
}
public function priceMinor(string $productKey): int
{
return (int) ($this->product($productKey)['price_minor'] ?? 0);
}
public function purchase(string $ownerRef, string $productKey): bool
{
if ($this->hasPurchased($ownerRef, $productKey)) {
return true;
}
$product = $this->product($productKey);
if (! $product) {
return false;
}
$costMinor = (int) ($product['price_minor'] ?? 0);
if ($costMinor > 0) {
try {
if (! $this->billing->canAfford($ownerRef, $costMinor)) {
return false;
}
$charged = $this->billing->debit(
$ownerRef,
$costMinor,
'purchase',
'crm-purchase-'.$productKey.'-'.$ownerRef,
'CRM: '.($product['name'] ?? $productKey),
);
if (! $charged) {
return false;
}
} catch (\Throwable) {
return false;
}
}
CrmPurchase::create([
'owner_ref' => $ownerRef,
'product_key' => $productKey,
'cost_minor' => $costMinor,
'purchased_at' => now(),
]);
return true;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Services\Comms;
use App\Mail\ContactMessageMail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
* Outbound email to contacts. Uses the app's configured mailer (set MAIL_* to
* a Ladill Bird SMTP credential in production so mail leaves as the account's
* sender). Failures are swallowed + logged; the caller decides what to record.
*/
class EmailService
{
public function send(string $to, string $subject, string $body, ?string $fromName = null, ?string $replyTo = null): bool
{
if (! filter_var($to, FILTER_VALIDATE_EMAIL)) {
return false;
}
try {
Mail::to($to)->send(new ContactMessageMail($subject, $body, $fromName, $replyTo));
return true;
} catch (\Throwable $e) {
Log::warning('CRM email send failed', ['to' => $to, 'error' => $e->getMessage()]);
return false;
}
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Services\Comms;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Outbound SMS to contacts via Arkesel (the platform SMS provider). Low-volume,
* best-effort transactional send mirrors the platform's direct Arkesel calls;
* uses the shared ARKESEL_API_KEY. Failures are swallowed + logged.
*/
class SmsService
{
public function send(string $to, string $message): bool
{
$apiKey = (string) config('arkesel.api_key', '');
$sender = (string) config('arkesel.sender_id', 'Ladill');
$msisdn = $this->normalise($to);
if ($apiKey === '' || $msisdn === null) {
return false;
}
try {
$res = Http::timeout(20)
->withHeaders(['api-key' => $apiKey])
->acceptJson()
->post(rtrim((string) config('arkesel.base_url', 'https://sms.arkesel.com'), '/').'/api/v2/sms/send', [
'sender' => $sender,
'message' => $message,
'recipients' => [$msisdn],
]);
return $res->successful() && ($res->json('status') === 'success');
} catch (\Throwable $e) {
Log::warning('CRM SMS send failed', ['to' => $msisdn, 'error' => $e->getMessage()]);
return false;
}
}
/** Normalise to E.164-without-plus (e.g. 233XXXXXXXXX). */
private function normalise(string $to): ?string
{
$digits = preg_replace('/\D+/', '', $to) ?? '';
$country = (string) config('arkesel.default_country_code', '233');
if (strlen($digits) < 9) {
return null;
}
if (str_starts_with($digits, $country)) {
return $digits;
}
if (str_starts_with($digits, '0')) {
return $country.substr($digits, 1);
}
if (strlen($digits) === 9) {
return $country.$digits;
}
return $digits;
}
}
@@ -0,0 +1,112 @@
<?php
namespace App\Services\CrossApp;
use App\Models\Customer;
use App\Models\Deal;
use App\Support\CrmPrefillCodec;
use App\Support\LadillAppUrl;
class CrossAppLinkService
{
public function invoiceFromDeal(Deal $deal): string
{
$deal->loadMissing(['customer', 'lines']);
$customer = $deal->customer;
$lines = $deal->lines->map(fn ($line) => [
'description' => $line->description,
'quantity' => (float) $line->quantity,
'unit_price' => number_format($line->unit_price_minor / 100, 2, '.', ''),
])->values()->all();
if ($lines === [] && (int) $deal->value_minor > 0) {
$lines = [[
'description' => $deal->title,
'quantity' => 1,
'unit_price' => number_format($deal->value_minor / 100, 2, '.', ''),
]];
}
$prefill = CrmPrefillCodec::encode([
'kind' => 'invoice',
'crm_customer_id' => $customer?->id,
'client_name' => $customer?->name ?: $deal->title,
'client_email' => $customer?->email,
'client_address' => $customer ? $this->formatAddress($customer) : null,
'notes' => 'Invoice for CRM deal: '.$deal->title,
'payment_enabled' => true,
'lines' => $lines,
]);
return LadillAppUrl::connect('invoice', '/invoices/create?prefill='.urlencode($prefill));
}
public function merchantStorefrontFromDeal(Deal $deal): string
{
$deal->loadMissing('customer');
$amount = number_format(((int) $deal->value_minor) / 100, 2, '.', '');
$itemName = $deal->title;
$prefill = CrmPrefillCodec::encode([
'kind' => 'merchant_storefront',
'type' => 'shop',
'label' => 'Payment — '.$deal->title,
'shop_title' => $deal->customer?->company ?: $deal->title,
'sections' => [[
'name' => 'Payment',
'items' => [[
'name' => $itemName,
'description' => $deal->notes ?: 'Payment for '.$deal->title,
'price' => $amount,
'image_path' => '',
]],
]],
'accepts_payment' => true,
]);
return LadillAppUrl::connect('merchant', '/storefronts/create?prefill='.urlencode($prefill));
}
public function businessQrFromContact(Customer $contact): string
{
$prefill = CrmPrefillCodec::encode([
'kind' => 'qr_business',
'type' => 'business',
'label' => 'Card — '.$contact->name,
'name' => $contact->company ?: $contact->name,
'phone' => $contact->phone,
'email' => $contact->email,
'address' => $this->formatAddress($contact, singleLine: true),
]);
return LadillAppUrl::connect('qrplus', '/qr-codes/create?prefill='.urlencode($prefill));
}
public function eventsHubForContact(Customer $contact): string
{
$query = http_build_query(array_filter([
'search' => $contact->company ?: $contact->name,
]));
return LadillAppUrl::connect('events', '/attendees'.($query !== '' ? '?'.$query : ''));
}
private function formatAddress(Customer $contact, bool $singleLine = false): ?string
{
$parts = array_filter([
$contact->address_line1,
$contact->address_line2,
trim(implode(', ', array_filter([$contact->city, $contact->region, $contact->country]))),
]);
if ($parts === []) {
return null;
}
return $singleLine
? implode(', ', $parts)
: implode("\n", $parts);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Services\Events;
class ServiceEventSignature
{
public static function sign(string $body, string $secret): string
{
return 'sha256='.hash_hmac('sha256', $body, $secret);
}
public static function verify(string $body, string $signature, string $secret): bool
{
if ($secret === '' || $signature === '') {
return false;
}
return hash_equals(self::sign($body, $secret), $signature);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Services\Qms;
use App\Models\CustomerFeedback;
use App\Models\QueueAppointment;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use Illuminate\Support\Carbon;
class AnalyticsService
{
public function __construct(
protected DashboardStats $dashboard,
) {}
/**
* @return array<string, mixed>
*/
public function overview(string $ownerRef, int $organizationId, ?int $branchId = null): array
{
$stats = $this->dashboard->forOrganization($ownerRef, $organizationId, $branchId);
$today = Ticket::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereDate('issued_at', today());
$bySource = (clone $today)
->selectRaw('source, COUNT(*) as total')
->groupBy('source')
->pluck('total', 'source')
->all();
$byPriority = (clone $today)
->selectRaw('priority, COUNT(*) as total')
->groupBy('priority')
->pluck('total', 'priority')
->all();
$appointmentsToday = QueueAppointment::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereDate('scheduled_at', today())
->count();
$feedbackAvg = CustomerFeedback::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->whereHas('ticket', fn ($t) => $t->where('branch_id', $branchId)))
->where('created_at', '>=', now()->subDays(30))
->avg('rating');
$queueLoad = ServiceQueue::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where('is_active', true)
->withCount(['tickets as waiting_count' => fn ($q) => $q->where('status', 'waiting')])
->orderByDesc('waiting_count')
->limit(5)
->get()
->map(fn ($q) => ['name' => $q->name, 'waiting' => $q->waiting_count])
->all();
return array_merge($stats, [
'tickets_by_source' => $bySource,
'tickets_by_priority' => $byPriority,
'appointments_today' => $appointmentsToday,
'feedback_avg_30d' => round((float) $feedbackAvg, 1),
'busiest_queues' => $queueLoad,
]);
}
/**
* @return list<array{date: string, issued: int, completed: int}>
*/
public function dailyTrend(string $ownerRef, int $organizationId, int $days = 14, ?int $branchId = null): array
{
$from = now()->subDays($days - 1)->startOfDay();
$issued = Ticket::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where('issued_at', '>=', $from)
->selectRaw('DATE(issued_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
$completed = Ticket::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where('status', 'completed')
->where('completed_at', '>=', $from)
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
$trend = [];
for ($i = 0; $i < $days; $i++) {
$date = $from->copy()->addDays($i)->toDateString();
$trend[] = [
'date' => $date,
'issued' => (int) ($issued[$date] ?? 0),
'completed' => (int) ($completed[$date] ?? 0),
];
}
return $trend;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Services\Qms;
use App\Models\QueueAppointment;
use App\Models\ServiceQueue;
use App\Models\Ticket;
class AppointmentService
{
public function __construct(
protected QueueEngine $engine,
protected QueueNotificationService $notifications,
) {}
public function checkIn(QueueAppointment $appointment, ?string $actorRef = null): Ticket
{
abort_unless($appointment->status === 'scheduled', 422, 'Appointment cannot be checked in.');
$queue = $appointment->serviceQueue ?? $this->defaultQueue($appointment);
abort_unless($queue, 422, 'No queue configured for this appointment.');
$ticket = $this->engine->issueTicket($queue, $appointment->owner_ref, [
'customer_name' => $appointment->customer_name,
'customer_phone' => $appointment->customer_phone,
'customer_email' => $appointment->customer_email,
'priority' => 'appointment',
'source' => 'appointment',
'actor_ref' => $actorRef,
'metadata' => ['appointment_id' => $appointment->id],
]);
$appointment->update([
'status' => 'checked_in',
'checked_in_at' => now(),
'ticket_id' => $ticket->id,
'service_queue_id' => $queue->id,
]);
AuditLogger::record($appointment->owner_ref, 'appointment.checked_in', $appointment->organization_id, $actorRef, QueueAppointment::class, $appointment->id);
$org = $appointment->organization;
if ($org) {
app(StaffNotifier::class)->queueEvent(
$org,
'Appointment checked in',
"{$appointment->customer_name} checked in for {$appointment->scheduled_at->format('H:i')}.",
route('qms.tickets.show', $ticket),
'calendar',
);
}
return $ticket;
}
protected function defaultQueue(QueueAppointment $appointment): ?ServiceQueue
{
return ServiceQueue::owned($appointment->owner_ref)
->where('branch_id', $appointment->branch_id)
->where('is_active', true)
->orderBy('name')
->first();
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Qms;
use App\Models\AuditLog;
use App\Models\Member;
class AuditLogger
{
public static function record(
string $ownerRef,
string $action,
?int $organizationId = null,
?string $actorRef = null,
?string $subjectType = null,
?int $subjectId = null,
?array $metadata = null,
): AuditLog {
return AuditLog::record(
$ownerRef,
$action,
$organizationId,
$actorRef,
$subjectType,
$subjectId,
$metadata,
);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Services\Qms;
use App\Models\Branch;
use App\Models\Ticket;
use App\Support\DatabaseTime;
class DashboardStats
{
/**
* @return array<string, int|float>
*/
public function forOrganization(string $ownerRef, int $organizationId, ?int $branchId = null): array
{
$ticketQuery = Ticket::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId));
$todayQuery = (clone $ticketQuery)->whereDate('issued_at', today());
$waiting = (clone $ticketQuery)->where('status', 'waiting')->count();
$serving = (clone $ticketQuery)->whereIn('status', ['called', 'serving'])->count();
$servedToday = (clone $todayQuery)->where('status', 'completed')->count();
$noShowsToday = (clone $todayQuery)->where('status', 'no_show')->count();
$waitDiff = DatabaseTime::diffSeconds('issued_at', 'called_at');
$avgWait = (clone $todayQuery)
->where('status', 'completed')
->whereNotNull('called_at')
->selectRaw("AVG({$waitDiff}) as avg_wait")
->value('avg_wait');
$activeBranches = Branch::owned($ownerRef)
->where('organization_id', $organizationId)
->where('is_active', true)
->when($branchId, fn ($q) => $q->where('id', $branchId))
->count();
return [
'waiting' => $waiting,
'serving' => $serving,
'served_today' => $servedToday,
'no_shows_today' => $noShowsToday,
'avg_wait_seconds' => (int) round((float) $avgWait),
'active_branches' => $activeBranches,
];
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Services\Qms;
use App\Models\Device;
use App\Models\Ticket;
use Illuminate\Support\Str;
class DeviceService
{
public function findByToken(string $token): ?Device
{
return Device::query()
->where('device_token', $token)
->first();
}
public function recordHeartbeat(Device $device): void
{
$device->update([
'status' => 'online',
'last_online_at' => now(),
]);
}
public function markStaleOffline(int $minutes = 10): int
{
return Device::query()
->where('status', 'online')
->where(function ($q) use ($minutes) {
$q->whereNull('last_online_at')
->orWhere('last_online_at', '<', now()->subMinutes($minutes));
})
->update(['status' => 'offline']);
}
public function generateToken(Device $device): string
{
$token = Str::random(48);
$device->update(['device_token' => $token]);
return $token;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Services\Qms;
use App\Models\DisplayScreen;
use App\Models\ServiceQueue;
use App\Models\Ticket;
class DisplayService
{
public function findByToken(string $token): ?DisplayScreen
{
return DisplayScreen::query()
->where('access_token', $token)
->where('is_active', true)
->first();
}
public function touch(DisplayScreen $screen): void
{
$screen->update(['last_seen_at' => now()]);
}
/**
* @return array<string, mixed>
*/
public function payload(DisplayScreen $screen): array
{
$queueIds = $screen->service_queue_ids ?? [];
$queues = ServiceQueue::query()
->whereIn('id', $queueIds)
->where('is_active', true)
->get();
$nowServing = Ticket::query()
->whereIn('service_queue_id', $queueIds)
->whereIn('status', ['called', 'serving'])
->with(['counter', 'serviceQueue'])
->orderByDesc('called_at')
->limit(5)
->get()
->map(fn (Ticket $t) => [
'ticket_number' => $t->ticket_number,
'queue_name' => $t->serviceQueue?->name,
'counter' => $t->counter?->name,
]);
$waiting = Ticket::query()
->whereIn('service_queue_id', $queueIds)
->where('status', 'waiting')
->count();
$avgWait = (int) Ticket::query()
->whereIn('service_queue_id', $queueIds)
->where('status', 'completed')
->whereDate('issued_at', today())
->whereNotNull('called_at')
->selectRaw('AVG(TIMESTAMPDIFF(SECOND, issued_at, called_at)) as avg_wait')
->value('avg_wait');
return [
'screen' => [
'name' => $screen->name,
'layout' => $screen->layout,
],
'now_serving' => $nowServing,
'waiting_count' => $waiting,
'estimated_wait_minutes' => $avgWait > 0 ? (int) ceil($avgWait / 60) : null,
'queues' => $queues->map(fn (ServiceQueue $q) => [
'name' => $q->name,
'prefix' => $q->prefix,
'is_paused' => $q->is_paused,
]),
'announcements' => app(VoiceAnnouncementService::class)->pendingForBranch((int) $screen->branch_id),
'updated_at' => now()->toIso8601String(),
];
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Services\Qms;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Str;
class OrganizationResolver
{
public function resolveForUser(User $user): ?Organization
{
$ref = $user->ownerRef();
$member = Member::where('user_ref', $ref)->first();
if ($member) {
return Organization::find($member->organization_id);
}
return Organization::owned($ref)->first();
}
public function forUser(User $user): Organization
{
return $this->resolveForUser($user)
?? throw new \RuntimeException('No organization for user.');
}
public function isOnboarded(User $user): bool
{
$organization = $this->resolveForUser($user);
return $organization !== null
&& (bool) data_get($organization->settings, 'onboarded', false);
}
public function memberFor(User $user, ?Organization $organization = null): ?Member
{
$organization ??= $this->resolveForUser($user);
if (! $organization) {
return null;
}
return Member::where('organization_id', $organization->id)
->where('user_ref', $user->ownerRef())
->first();
}
public function ensureOwnerMember(User $user, Organization $organization): Member
{
return Member::firstOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $user->ownerRef(),
],
[
'owner_ref' => $user->ownerRef(),
'role' => 'org_admin',
],
);
}
/**
* @param array<string, mixed> $data
*/
public function completeOnboarding(User $user, array $data): Organization
{
$ref = $user->ownerRef();
$organization = Organization::create([
'owner_ref' => $ref,
'name' => $data['organization_name'],
'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6),
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
'settings' => [
'onboarded' => true,
'industry' => $data['industry'] ?? 'custom',
'appointment_mode' => $data['appointment_mode'] ?? 'hybrid',
],
]);
$this->ensureOwnerMember($user, $organization);
$branch = Branch::create([
'owner_ref' => $ref,
'organization_id' => $organization->id,
'name' => $data['branch_name'],
'address' => $data['branch_address'] ?? null,
'phone' => $data['branch_phone'] ?? null,
'is_active' => true,
]);
Department::create([
'owner_ref' => $ref,
'branch_id' => $branch->id,
'name' => 'General Reception',
'type' => 'reception',
'is_active' => true,
]);
AuditLogger::record($ref, 'organization.created', $organization->id, $ref, Organization::class, $organization->id);
AuditLogger::record($ref, 'branch.created', $organization->id, $ref, Branch::class, $branch->id);
return $organization;
}
public function branchScope(?Member $member): ?int
{
if ($member === null) {
return null;
}
if (in_array($member->role, ['super_admin', 'org_admin', 'supervisor'], true)) {
return null;
}
return $member->branch_id;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Services\Qms;
use App\Models\Organization;
class PlanService
{
public function plan(Organization $organization): string
{
return (string) data_get($organization->settings, 'plan', 'free');
}
public function isPro(Organization $organization): bool
{
return $this->plan($organization) === 'pro';
}
public function maxBranches(Organization $organization): ?int
{
return config('qms.plans.'.$this->plan($organization).'.max_branches');
}
public function maxQueues(Organization $organization): ?int
{
return config('qms.plans.'.$this->plan($organization).'.max_queues');
}
public function canAddBranch(Organization $organization, int $currentCount): bool
{
$max = $this->maxBranches($organization);
return $max === null || $currentCount < $max;
}
public function canAddQueue(Organization $organization, int $currentCount): bool
{
$max = $this->maxQueues($organization);
return $max === null || $currentCount < $max;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Services\Qms;
use App\Models\Member;
class QmsPermissions
{
/** @var array<string, list<string>> */
protected array $roleAbilities = [
'super_admin' => ['*'],
'org_admin' => ['*'],
'branch_manager' => [
'dashboard.view', 'queues.view', 'queues.manage', 'tickets.view', 'tickets.manage',
'counters.view', 'counters.manage', 'console.use', 'displays.view', 'displays.manage',
'appointments.view', 'appointments.manage', 'workflows.view', 'workflows.manage',
'rules.view', 'rules.manage', 'reports.view', 'reports.export', 'feedback.view',
'admin.branches.view', 'admin.branches.manage', 'admin.departments.view', 'admin.departments.manage',
'admin.members.view', 'admin.members.manage', 'settings.view', 'settings.manage', 'audit.view', 'audit.export',
],
'receptionist' => [
'dashboard.view', 'queues.view', 'tickets.view', 'tickets.issue',
'appointments.view', 'appointments.manage',
],
'queue_operator' => [
'dashboard.view', 'queues.view', 'queues.manage', 'tickets.view', 'tickets.manage',
'console.use', 'displays.view',
],
'counter_staff' => [
'dashboard.view', 'queues.view', 'tickets.view', 'console.use',
],
'department_manager' => [
'dashboard.view', 'queues.view', 'queues.manage', 'tickets.view', 'tickets.manage',
'counters.view', 'console.use', 'reports.view',
],
'security' => [
'dashboard.view', 'tickets.view',
],
'supervisor' => [
'dashboard.view', 'queues.view', 'tickets.view', 'tickets.manage',
'counters.view', 'console.use', 'reports.view', 'reports.export',
'feedback.view', 'audit.view', 'audit.export',
],
];
public function can(?Member $member, string $ability): bool
{
if ($member === null) {
return false;
}
$abilities = $this->roleAbilities[$member->role] ?? [];
if (in_array('*', $abilities, true)) {
return true;
}
return in_array($ability, $abilities, true);
}
public function isAdmin(?Member $member): bool
{
return $member !== null && in_array($member->role, ['super_admin', 'org_admin'], true);
}
}
+386
View File
@@ -0,0 +1,386 @@
<?php
namespace App\Services\Qms;
use App\Models\Counter;
use App\Models\Organization;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Models\TicketEvent;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class QueueEngine
{
public function issueTicket(
ServiceQueue $queue,
string $ownerRef,
array $attributes = [],
): Ticket {
return DB::transaction(function () use ($queue, $ownerRef, $attributes) {
$queue = ServiceQueue::query()->lockForUpdate()->findOrFail($queue->id);
if ($queue->is_paused || ! $queue->is_active) {
throw new \RuntimeException('Queue is not accepting tickets.');
}
$sourceQueueId = $queue->id;
[$queue, $attributes] = app(QueueRuleService::class)->apply($queue, $attributes);
app(WorkflowService::class)->attachOnIssue($queue, $attributes);
if ($queue->id !== $sourceQueueId) {
$queue = ServiceQueue::query()->lockForUpdate()->findOrFail($queue->id);
}
if ($queue->is_paused || ! $queue->is_active) {
throw new \RuntimeException('Queue is not accepting tickets.');
}
if ($queue->max_capacity) {
$waiting = Ticket::query()
->where('service_queue_id', $queue->id)
->whereIn('status', ['waiting', 'called', 'serving', 'on_hold'])
->count();
if ($waiting >= $queue->max_capacity) {
throw new \RuntimeException('Queue has reached maximum capacity.');
}
}
$queue->increment('ticket_sequence');
$queue->refresh();
$number = sprintf('%s%03d', $queue->prefix, $queue->ticket_sequence);
$waitingCount = Ticket::query()
->where('service_queue_id', $queue->id)
->where('status', 'waiting')
->count();
$estimatedWait = $waitingCount * (int) $queue->avg_service_seconds;
$ticket = Ticket::create([
'owner_ref' => $ownerRef,
'organization_id' => $queue->organization_id,
'branch_id' => $queue->branch_id,
'service_queue_id' => $queue->id,
'ticket_number' => $number,
'status' => 'waiting',
'priority' => $attributes['priority'] ?? 'walk_in',
'position' => $waitingCount + 1,
'customer_name' => $attributes['customer_name'] ?? null,
'customer_phone' => $attributes['customer_phone'] ?? null,
'customer_email' => $attributes['customer_email'] ?? null,
'source' => $attributes['source'] ?? 'kiosk',
'barcode' => $attributes['barcode'] ?? Str::upper(Str::random(10)),
'estimated_wait_seconds' => $estimatedWait,
'issued_at' => now(),
'metadata' => $attributes['metadata'] ?? null,
]);
$this->recordEvent($ticket, 'issued', $attributes['actor_ref'] ?? null);
AuditLogger::record(
$ownerRef,
'ticket.issued',
$queue->organization_id,
$attributes['actor_ref'] ?? null,
Ticket::class,
$ticket->id,
['ticket_number' => $number, 'queue' => $queue->name],
);
$ticket = $ticket->fresh(['serviceQueue', 'branch']);
app(QueueNotificationService::class)->ticketIssued($ticket);
return $ticket;
});
}
public function callNext(ServiceQueue $queue, Counter $counter, ?string $actorRef = null): ?Ticket
{
$ticket = $this->nextWaitingTicket($queue);
if (! $ticket) {
return null;
}
return $this->callTicket($ticket, $counter, $actorRef);
}
public function callTicket(Ticket $ticket, Counter $counter, ?string $actorRef = null): Ticket
{
$ticket->update([
'status' => 'called',
'counter_id' => $counter->id,
'called_at' => now(),
]);
$counter->update(['status' => 'busy']);
$this->recordEvent($ticket, 'called', $actorRef, $counter->id);
$this->audit($ticket, 'ticket.called', $actorRef, ['counter_id' => $counter->id]);
app(VoiceAnnouncementService::class)->announceCalled($ticket, $counter);
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
app(QueueNotificationService::class)->ticketCalled($ticket);
$this->notifyWaitingPositions($ticket->serviceQueue);
return $ticket;
}
public function recall(Ticket $ticket, ?string $actorRef = null): Ticket
{
$ticket->load(['serviceQueue', 'counter']);
$this->recordEvent($ticket, 'recalled', $actorRef, $ticket->counter_id);
$this->audit($ticket, 'ticket.recalled', $actorRef);
if ($ticket->counter) {
app(VoiceAnnouncementService::class)->announceCalled($ticket, $ticket->counter);
}
return $ticket->fresh(['serviceQueue', 'counter']);
}
public function startServing(Ticket $ticket, ?string $actorRef = null): Ticket
{
$ticket->update([
'status' => 'serving',
'serving_started_at' => now(),
]);
$this->recordEvent($ticket, 'serving', $actorRef, $ticket->counter_id);
app(ServiceSessionTracker::class)->start($ticket, $actorRef);
return $ticket->fresh();
}
public function hold(Ticket $ticket, ?string $actorRef = null): Ticket
{
$ticket->update(['status' => 'on_hold']);
$this->recordEvent($ticket, 'held', $actorRef, $ticket->counter_id);
$this->audit($ticket, 'ticket.held', $actorRef);
return $ticket->fresh();
}
public function skip(Ticket $ticket, ?string $actorRef = null): Ticket
{
$ticket->update([
'status' => 'waiting',
'counter_id' => null,
'called_at' => null,
]);
$this->recordEvent($ticket, 'skipped', $actorRef);
$this->audit($ticket, 'ticket.skipped', $actorRef);
return $ticket->fresh();
}
public function completeTicket(Ticket $ticket, ?string $actorRef = null): Ticket
{
$ticket->update([
'status' => 'completed',
'completed_at' => now(),
]);
if ($ticket->counter_id) {
Counter::where('id', $ticket->counter_id)->update(['status' => 'available']);
}
$this->recordEvent($ticket, 'completed', $actorRef, $ticket->counter_id);
$this->audit($ticket, 'ticket.completed', $actorRef);
app(ServiceSessionTracker::class)->endForTicket($ticket);
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
app(QueueNotificationService::class)->ticketCompleted($ticket);
app(WorkflowService::class)->advanceAfterCompletion($ticket);
return $ticket;
}
public function markNoShow(Ticket $ticket, ?string $actorRef = null): Ticket
{
$ticket->update([
'status' => 'no_show',
'completed_at' => now(),
]);
if ($ticket->counter_id) {
Counter::where('id', $ticket->counter_id)->update(['status' => 'available']);
}
$this->recordEvent($ticket, 'no_show', $actorRef, $ticket->counter_id);
$this->audit($ticket, 'ticket.no_show', $actorRef);
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
app(QueueNotificationService::class)->ticketMissed($ticket);
return $ticket;
}
public function cancel(Ticket $ticket, ?string $actorRef = null): Ticket
{
$ticket->update(['status' => 'cancelled', 'completed_at' => now()]);
$this->recordEvent($ticket, 'cancelled', $actorRef, $ticket->counter_id);
$this->audit($ticket, 'ticket.cancelled', $actorRef);
return $ticket->fresh();
}
public function transfer(
Ticket $ticket,
ServiceQueue $toQueue,
?Counter $counter = null,
?string $reason = null,
?string $actorRef = null,
): Ticket {
$fromQueueId = $ticket->service_queue_id;
$ticket->update([
'service_queue_id' => $toQueue->id,
'branch_id' => $toQueue->branch_id,
'status' => 'waiting',
'counter_id' => null,
'called_at' => null,
'serving_started_at' => null,
]);
DB::table('queue_ticket_transfers')->insert([
'owner_ref' => $ticket->owner_ref,
'ticket_id' => $ticket->id,
'from_service_queue_id' => $fromQueueId,
'to_service_queue_id' => $toQueue->id,
'from_counter_id' => $counter?->id,
'reason' => $reason,
'transferred_by' => $actorRef,
'transferred_at' => now(),
]);
$this->recordEvent($ticket, 'transferred', $actorRef, $counter?->id, [
'to_queue_id' => $toQueue->id,
'reason' => $reason,
]);
$this->audit($ticket, 'ticket.transferred', $actorRef, ['to_queue' => $toQueue->name]);
return $ticket->fresh(['serviceQueue']);
}
public function pauseQueue(ServiceQueue $queue, ?string $actorRef = null): ServiceQueue
{
$queue->update(['is_paused' => true]);
AuditLogger::record($queue->owner_ref, 'service_queue.paused', $queue->organization_id, $actorRef, ServiceQueue::class, $queue->id);
$org = Organization::find($queue->organization_id);
if ($org) {
app(StaffNotifier::class)->queueEvent(
$org,
'Queue paused',
"{$queue->name} is no longer accepting tickets.",
route('qms.queues.show', $queue),
'pause',
);
}
return $queue->fresh();
}
public function resumeQueue(ServiceQueue $queue, ?string $actorRef = null): ServiceQueue
{
$queue->update(['is_paused' => false]);
AuditLogger::record($queue->owner_ref, 'service_queue.resumed', $queue->organization_id, $actorRef, ServiceQueue::class, $queue->id);
return $queue->fresh();
}
public function delayArrival(Ticket $ticket, int $minutes, ?string $actorRef = null): Ticket
{
$metadata = $ticket->metadata ?? [];
$metadata['delayed_until'] = now()->addMinutes($minutes)->toIso8601String();
$ticket->update(['metadata' => $metadata]);
$this->recordEvent($ticket, 'delayed', $actorRef, null, ['minutes' => $minutes]);
return $ticket->fresh();
}
/**
* @return list<Ticket>
*/
public function waitingTickets(ServiceQueue $queue, int $limit = 50): array
{
return Ticket::query()
->where('service_queue_id', $queue->id)
->where('status', 'waiting')
->orderByRaw($this->priorityOrderSql())
->orderBy('issued_at')
->limit($limit)
->get()
->all();
}
protected function nextWaitingTicket(ServiceQueue $queue): ?Ticket
{
return Ticket::query()
->where('service_queue_id', $queue->id)
->where('status', 'waiting')
->orderByRaw($this->priorityOrderSql())
->orderBy('issued_at')
->first();
}
protected function priorityOrderSql(): string
{
$priorityOrder = array_keys(config('qms.ticket_priorities'));
if (\Illuminate\Support\Facades\DB::connection()->getDriverName() === 'sqlite') {
$cases = collect($priorityOrder)
->map(fn ($p, $i) => "WHEN priority = '{$p}' THEN {$i}")
->implode(' ');
return "CASE {$cases} ELSE 99 END";
}
return 'FIELD(priority, '.implode(',', array_map(fn ($p) => "'{$p}'", $priorityOrder)).')';
}
protected function notifyWaitingPositions(?ServiceQueue $queue): void
{
if (! $queue) {
return;
}
$notifications = app(QueueNotificationService::class);
foreach ($this->waitingTickets($queue, 20) as $position => $waitingTicket) {
$notifications->customersAhead($waitingTicket, $position);
}
}
protected function audit(Ticket $ticket, string $action, ?string $actorRef, ?array $metadata = null): void
{
AuditLogger::record(
$ticket->owner_ref,
$action,
$ticket->organization_id,
$actorRef,
Ticket::class,
$ticket->id,
$metadata,
);
}
/**
* @param array<string, mixed>|null $metadata
*/
protected function recordEvent(
Ticket $ticket,
string $event,
?string $actorRef = null,
?int $counterId = null,
?array $metadata = null,
): void {
TicketEvent::create([
'owner_ref' => $ticket->owner_ref,
'ticket_id' => $ticket->id,
'event' => $event,
'actor_ref' => $actorRef,
'counter_id' => $counterId,
'metadata' => $metadata,
'created_at' => now(),
]);
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Services\Qms;
use App\Models\Ticket;
use App\Services\Comms\EmailService;
use App\Services\Comms\SmsService;
use Illuminate\Support\Facades\Log;
class QueueNotificationService
{
public function __construct(
protected SmsService $sms,
protected EmailService $email,
) {}
public function ticketIssued(Ticket $ticket): void
{
$this->notify($ticket, 'issued', $this->message('issued', $ticket));
}
public function ticketCalled(Ticket $ticket): void
{
$counter = $ticket->counter?->name ?? 'the counter';
$this->notify($ticket, 'called', $this->message('called', $ticket, [':counter' => $counter]));
}
public function customersAhead(Ticket $ticket, int $ahead): void
{
if ($ahead > 2) {
return;
}
$this->notify($ticket, 'almost_ready', $this->message('almost_ready', $ticket, [':ahead' => (string) $ahead]));
}
public function ticketCompleted(Ticket $ticket): void
{
$feedbackUrl = route('qms.feedback.show', $ticket->qr_token);
$this->notify($ticket, 'completed', $this->message('completed', $ticket, [':feedback_url' => $feedbackUrl]));
}
public function ticketMissed(Ticket $ticket): void
{
$this->notify($ticket, 'missed', $this->message('missed', $ticket));
}
public function appointmentReminder(\App\Models\QueueAppointment $appointment): void
{
$message = "Reminder: your appointment at {$appointment->scheduled_at->format('M j, H:i')} is coming up. Ref: {$appointment->reference}";
$this->sendToContact($appointment->customer_phone, $appointment->customer_email, $message);
}
protected function notify(Ticket $ticket, string $event, string $message): void
{
if (! data_get($ticket->serviceQueue?->settings, 'notifications_enabled', true)) {
return;
}
$this->sendToContact($ticket->customer_phone, $ticket->customer_email, $message);
Log::info('Queue notification sent', [
'event' => $event,
'ticket' => $ticket->ticket_number,
]);
}
protected function sendToContact(?string $phone, ?string $email, string $message): void
{
if ($phone) {
$this->sms->send($phone, $message);
}
if ($email) {
$this->email->send($email, 'Ladill Queue update', $message);
}
}
/**
* @param array<string, string> $replace
*/
protected function message(string $key, Ticket $ticket, array $replace = []): string
{
$template = config("qms.notification_templates.{$key}", 'Queue update for ticket :ticket.');
$replace = array_merge([
':ticket' => $ticket->ticket_number,
':queue' => $ticket->serviceQueue?->name ?? 'Queue',
], $replace);
return str_replace(array_keys($replace), array_values($replace), $template);
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace App\Services\Qms;
use App\Models\QueueRule;
use App\Models\ServiceQueue;
use App\Models\Ticket;
class QueueRuleService
{
/**
* Apply active rules and return possibly modified queue + attributes.
*
* @param array<string, mixed> $attributes
* @return array{0: ServiceQueue, 1: array<string, mixed>}
*/
public function apply(ServiceQueue $queue, array $attributes): array
{
$rules = QueueRule::owned($queue->owner_ref)
->where('service_queue_id', $queue->id)
->where('is_active', true)
->orderBy('sort_order')
->get();
foreach ($rules as $rule) {
match ($rule->rule_type) {
'priority_boost' => $this->applyPriorityBoost($rule, $attributes),
'overflow' => [$queue, $attributes] = $this->applyOverflow($queue, $rule, $attributes),
'routing' => [$queue, $attributes] = $this->applyRouting($queue, $rule, $attributes),
'capacity' => [$queue, $attributes] = $this->applyCapacity($queue, $rule, $attributes),
default => null,
};
}
return [$queue, $attributes];
}
/**
* @param array<string, mixed> $attributes
*/
protected function applyPriorityBoost(QueueRule $rule, array &$attributes): void
{
$boostTo = data_get($rule->actions, 'priority');
$when = data_get($rule->conditions, 'source');
if ($boostTo && (! $when || ($attributes['source'] ?? null) === $when)) {
$attributes['priority'] = $boostTo;
}
}
/**
* @param array<string, mixed> $attributes
* @return array{0: ServiceQueue, 1: array<string, mixed>}
*/
protected function applyOverflow(ServiceQueue $queue, QueueRule $rule, array $attributes): array
{
$threshold = (int) data_get($rule->conditions, 'waiting_threshold', 0);
$overflowQueueId = data_get($rule->actions, 'overflow_queue_id');
if (! $overflowQueueId || $threshold <= 0) {
return [$queue, $attributes];
}
if ($this->waitingCount($queue) >= $threshold) {
$overflow = $this->activeQueue($overflowQueueId);
if ($overflow) {
return [$overflow, $attributes];
}
}
return [$queue, $attributes];
}
/**
* @param array<string, mixed> $attributes
* @return array{0: ServiceQueue, 1: array<string, mixed>}
*/
protected function applyRouting(ServiceQueue $queue, QueueRule $rule, array $attributes): array
{
$targetId = data_get($rule->actions, 'target_queue_id');
if (! $targetId) {
return [$queue, $attributes];
}
$whenSource = data_get($rule->conditions, 'source');
$whenPriority = data_get($rule->conditions, 'priority');
if ($whenSource && ($attributes['source'] ?? null) !== $whenSource) {
return [$queue, $attributes];
}
if ($whenPriority && ($attributes['priority'] ?? null) !== $whenPriority) {
return [$queue, $attributes];
}
$target = $this->activeQueue((int) $targetId);
return $target ? [$target, $attributes] : [$queue, $attributes];
}
/**
* @param array<string, mixed> $attributes
* @return array{0: ServiceQueue, 1: array<string, mixed>}
*/
protected function applyCapacity(ServiceQueue $queue, QueueRule $rule, array $attributes): array
{
$maxWaiting = (int) data_get($rule->conditions, 'max_waiting', 0);
if ($maxWaiting <= 0) {
return [$queue, $attributes];
}
if ($this->waitingCount($queue) < $maxWaiting) {
return [$queue, $attributes];
}
$whenFull = data_get($rule->actions, 'when_full', 'reject');
if ($whenFull === 'reject') {
throw new \RuntimeException('Queue has reached the configured waiting limit.');
}
$overflowId = data_get($rule->actions, 'overflow_queue_id');
if ($overflowId) {
$overflow = $this->activeQueue((int) $overflowId);
if ($overflow) {
return [$overflow, $attributes];
}
}
throw new \RuntimeException('Queue has reached the configured waiting limit.');
}
protected function waitingCount(ServiceQueue $queue): int
{
return Ticket::query()
->where('service_queue_id', $queue->id)
->where('status', 'waiting')
->count();
}
protected function activeQueue(int $queueId): ?ServiceQueue
{
$target = ServiceQueue::find($queueId);
return ($target && $target->is_active && ! $target->is_paused) ? $target : null;
}
}
+166
View File
@@ -0,0 +1,166 @@
<?php
namespace App\Services\Qms;
use App\Models\Branch;
use App\Models\Counter;
use App\Models\CustomerFeedback;
use App\Models\QueueAppointment;
use App\Models\ServiceQueue;
use App\Models\ServiceSession;
use App\Models\Ticket;
use App\Support\DatabaseTime;
use Illuminate\Support\Carbon;
class ReportService
{
/**
* @return array<string, mixed>
*/
public function ticketsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$base = Ticket::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('issued_at', [$from, $to]);
$waitDiff = DatabaseTime::diffSeconds('issued_at', 'called_at');
$serviceDiff = DatabaseTime::diffSeconds('serving_started_at', 'completed_at');
return [
'total_issued' => (clone $base)->count(),
'completed' => (clone $base)->where('status', 'completed')->count(),
'no_show' => (clone $base)->where('status', 'no_show')->count(),
'cancelled' => (clone $base)->where('status', 'cancelled')->count(),
'avg_wait_seconds' => (int) round((float) (clone $base)
->where('status', 'completed')
->whereNotNull('called_at')
->selectRaw("AVG({$waitDiff}) as v")
->value('v')),
'avg_service_seconds' => (int) round((float) (clone $base)
->where('status', 'completed')
->whereNotNull('serving_started_at')
->whereNotNull('completed_at')
->selectRaw("AVG({$serviceDiff}) as v")
->value('v')),
];
}
/**
* @return array<string, mixed>
*/
public function waitTimesReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$hourExpr = DatabaseTime::hourOf('issued_at');
$waitDiff = DatabaseTime::diffSeconds('issued_at', 'called_at');
$rows = Ticket::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('issued_at', [$from, $to])
->where('status', 'completed')
->whereNotNull('called_at')
->selectRaw("{$hourExpr} as hour, AVG({$waitDiff}) as avg_wait")
->groupBy('hour')
->orderBy('hour')
->get();
$peak = $rows->sortByDesc('avg_wait')->first();
return [
'hourly' => $rows->map(fn ($r) => ['hour' => (int) $r->hour, 'avg_wait_seconds' => (int) round((float) $r->avg_wait)])->values()->all(),
'peak_hour' => $peak ? (int) $peak->hour : null,
'peak_avg_wait_seconds' => $peak ? (int) round((float) $peak->avg_wait) : 0,
];
}
/**
* @return array<string, mixed>
*/
public function countersReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$counters = Counter::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->withCount(['tickets as served_count' => function ($q) use ($from, $to) {
$q->where('status', 'completed')
->whereBetween('completed_at', [$from, $to]);
}])
->orderByDesc('served_count')
->get();
return [
'counters' => $counters->map(fn ($c) => [
'name' => $c->name,
'served' => $c->served_count,
])->all(),
];
}
/**
* @return array<string, mixed>
*/
public function appointmentsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$base = QueueAppointment::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('scheduled_at', [$from, $to]);
return [
'total' => (clone $base)->count(),
'checked_in' => (clone $base)->where('status', 'checked_in')->count(),
'completed' => (clone $base)->where('status', 'completed')->count(),
'cancelled' => (clone $base)->where('status', 'cancelled')->count(),
'no_show' => (clone $base)->where('status', 'no_show')->count(),
];
}
/**
* @return array<string, mixed>
*/
public function feedbackReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$base = CustomerFeedback::owned($ownerRef)
->where('organization_id', $organizationId)
->whereBetween('created_at', [$from, $to])
->when($branchId, function ($q) use ($branchId) {
$q->whereHas('ticket', fn ($t) => $t->where('branch_id', $branchId));
});
$count = (clone $base)->count();
$avgRating = (clone $base)->avg('rating');
return [
'responses' => $count,
'avg_rating' => $count ? round((float) $avgRating, 1) : 0,
'avg_service_quality' => round((float) (clone $base)->avg('service_quality'), 1),
'avg_wait_experience' => round((float) (clone $base)->avg('wait_experience'), 1),
'avg_staff_professionalism' => round((float) (clone $base)->avg('staff_professionalism'), 1),
];
}
/**
* @return array<string, mixed>
*/
public function serviceSessionsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$base = ServiceSession::owned($ownerRef)
->whereBetween('started_at', [$from, $to])
->whereHas('ticket', function ($q) use ($organizationId, $branchId) {
$q->where('organization_id', $organizationId);
if ($branchId) {
$q->where('branch_id', $branchId);
}
});
$completed = (clone $base)->whereNotNull('ended_at');
return [
'sessions' => (clone $base)->count(),
'completed_sessions' => (clone $completed)->count(),
'avg_duration_seconds' => (int) round((float) (clone $completed)->avg('duration_seconds')),
'total_service_seconds' => (int) (clone $completed)->sum('duration_seconds'),
];
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Services\Qms;
use App\Models\ServiceSession;
use App\Models\Ticket;
class ServiceSessionTracker
{
public function start(Ticket $ticket, ?string $staffRef = null): ServiceSession
{
return ServiceSession::create([
'owner_ref' => $ticket->owner_ref,
'ticket_id' => $ticket->id,
'counter_id' => $ticket->counter_id,
'staff_ref' => $staffRef,
'started_at' => now(),
]);
}
public function endForTicket(Ticket $ticket): ?ServiceSession
{
$session = ServiceSession::query()
->where('ticket_id', $ticket->id)
->whereNull('ended_at')
->latest('started_at')
->first();
if (! $session) {
return null;
}
$ended = now();
$session->update([
'ended_at' => $ended,
'duration_seconds' => $session->started_at->diffInSeconds($ended),
]);
return $session->fresh();
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services\Qms;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Notifications\Qms\QueueStaffNotification;
use Illuminate\Notifications\Notification;
class StaffNotifier
{
/**
* @param list<string> $roles
*/
public function notifyOrganization(
Organization $organization,
Notification $notification,
array $roles = ['org_admin', 'branch_manager', 'supervisor'],
): void {
$refs = Member::query()
->where('organization_id', $organization->id)
->whereIn('role', $roles)
->pluck('user_ref');
User::query()
->whereIn('public_id', $refs)
->each(fn (User $user) => $user->notify($notification));
}
public function queueEvent(
Organization $organization,
string $title,
string $message,
?string $url = null,
string $icon = 'bell',
): void {
$this->notifyOrganization($organization, new QueueStaffNotification($title, $message, $url, $icon));
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services\Qms;
use App\Models\Ticket;
class TicketPresenter
{
/**
* @return array<string, mixed>
*/
public static function toArray(Ticket $ticket, bool $includeQr = true): array
{
return [
'uuid' => $ticket->uuid,
'ticket_number' => $ticket->ticket_number,
'status' => $ticket->status,
'priority' => $ticket->priority,
'position' => $ticket->position,
'customer_name' => $ticket->customer_name,
'customer_phone' => $ticket->customer_phone,
'source' => $ticket->source,
'estimated_wait_seconds' => $ticket->estimated_wait_seconds,
'issued_at' => $ticket->issued_at?->toIso8601String(),
'called_at' => $ticket->called_at?->toIso8601String(),
'queue' => $ticket->serviceQueue ? [
'uuid' => $ticket->serviceQueue->uuid,
'name' => $ticket->serviceQueue->name,
'prefix' => $ticket->serviceQueue->prefix,
'color' => $ticket->serviceQueue->color,
] : null,
'counter' => $ticket->counter ? [
'uuid' => $ticket->counter->uuid,
'name' => $ticket->counter->name,
] : null,
'track_url' => $includeQr ? route('qms.mobile.show', $ticket->qr_token) : null,
'barcode' => $ticket->barcode,
];
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Services\Qms;
use App\Models\Counter;
use App\Models\Ticket;
use App\Models\VoiceAnnouncement;
use Illuminate\Support\Str;
class VoiceAnnouncementService
{
public function announceCalled(Ticket $ticket, Counter $counter, ?string $locale = null): VoiceAnnouncement
{
$locale ??= data_get($ticket->serviceQueue?->settings, 'announcement_locale', 'en');
$message = $this->buildMessage($ticket, $counter, $locale);
return VoiceAnnouncement::create([
'owner_ref' => $ticket->owner_ref,
'organization_id' => $ticket->organization_id,
'branch_id' => $ticket->branch_id,
'ticket_id' => $ticket->id,
'locale' => $locale,
'message' => $message,
'voice' => data_get($ticket->serviceQueue?->settings, 'voice', 'female'),
'volume' => (int) data_get($ticket->serviceQueue?->settings, 'announcement_volume', 80),
'repeat_count' => (int) data_get($ticket->serviceQueue?->settings, 'announcement_repeat', 1),
'status' => 'pending',
]);
}
protected function buildMessage(Ticket $ticket, Counter $counter, string $locale): string
{
$templates = config('qms.announcement_templates');
$template = $templates[$locale] ?? $templates['en'];
return str_replace(
[':ticket', ':counter'],
[$ticket->ticket_number, $counter->name],
$template,
);
}
/**
* @return list<array<string, mixed>>
*/
public function pendingForBranch(int $branchId, int $limit = 10): array
{
return VoiceAnnouncement::query()
->where('branch_id', $branchId)
->where('status', 'pending')
->orderBy('id')
->limit($limit)
->get()
->map(fn (VoiceAnnouncement $a) => [
'id' => $a->id,
'message' => $a->message,
'locale' => $a->locale,
'voice' => $a->voice,
'volume' => $a->volume,
'repeat_count' => $a->repeat_count,
])
->all();
}
public function markPlayed(VoiceAnnouncement $announcement): void
{
$announcement->update(['status' => 'played', 'played_at' => now()]);
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Services\Qms;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Models\Workflow;
use App\Models\WorkflowStep;
class WorkflowService
{
public function __construct(
protected QueueEngine $engine,
) {}
/**
* @param array<string, mixed> $attributes
*/
public function attachOnIssue(ServiceQueue $queue, array &$attributes): void
{
if (data_get($attributes, 'metadata.workflow_id')) {
return;
}
$workflow = Workflow::query()
->where('owner_ref', $queue->owner_ref)
->where('organization_id', $queue->organization_id)
->where('is_active', true)
->when($queue->branch_id, fn ($q) => $q->where(fn ($inner) => $inner->whereNull('branch_id')->orWhere('branch_id', $queue->branch_id)))
->whereHas('steps', fn ($q) => $q->where('service_queue_id', $queue->id))
->first();
if (! $workflow) {
return;
}
$firstStep = $workflow->steps()
->where('service_queue_id', $queue->id)
->orderBy('sort_order')
->first();
if (! $firstStep) {
return;
}
$metadata = $attributes['metadata'] ?? [];
$metadata['workflow_id'] = $workflow->id;
$metadata['workflow_step'] = $firstStep->sort_order;
$attributes['metadata'] = $metadata;
}
/** Advance completed ticket to the next workflow step queue, if configured. */
public function advanceAfterCompletion(Ticket $ticket): ?Ticket
{
$workflowId = data_get($ticket->metadata, 'workflow_id');
$stepOrder = (int) data_get($ticket->metadata, 'workflow_step', 0);
if (! $workflowId) {
return null;
}
$workflow = Workflow::find($workflowId);
if (! $workflow || ! $workflow->is_active) {
return null;
}
$nextStep = WorkflowStep::where('workflow_id', $workflow->id)
->where('sort_order', '>', $stepOrder)
->orderBy('sort_order')
->first();
if (! $nextStep?->service_queue_id) {
return null;
}
$toQueue = ServiceQueue::find($nextStep->service_queue_id);
if (! $toQueue) {
return null;
}
$transferred = $this->engine->transfer($ticket, $toQueue, null, 'Workflow advance', null);
$transferred->update([
'metadata' => array_merge($transferred->metadata ?? [], [
'workflow_id' => $workflow->id,
'workflow_step' => $nextStep->sort_order,
]),
]);
return $transferred;
}
/**
* @return array<int, array{name: string, queue_id: int|null, sort_order: int}>
*/
public function industryTemplate(string $template): array
{
return config("qms.workflow_templates.{$template}", config('qms.workflow_templates.custom', []));
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\DB;
class DatabaseTime
{
public static function diffSeconds(string $fromColumn, string $toColumn): string
{
return match (DB::connection()->getDriverName()) {
'sqlite' => "CAST((julianday({$toColumn}) - julianday({$fromColumn})) * 86400 AS INTEGER)",
default => "TIMESTAMPDIFF(SECOND, {$fromColumn}, {$toColumn})",
};
}
public static function hourOf(string $column): string
{
return match (DB::connection()->getDriverName()) {
'sqlite' => "CAST(strftime('%H', {$column}) AS INTEGER)",
default => "HOUR({$column})",
};
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Support;
final class LadillAppUrl
{
public static function connect(string $appSubdomain, string $path = '/'): string
{
$root = (string) config('app.platform_domain', 'ladill.com');
$path = '/'.ltrim($path, '/');
$target = "https://{$appSubdomain}.{$root}{$path}";
return "https://{$appSubdomain}.{$root}/sso/connect?redirect=".urlencode($target);
}
}

Some files were not shown because too many files have changed in this diff Show More