Initial Ladill Queue release — enterprise QMS standalone app.
Deploy Ladill Queue / deploy (push) Successful in 56s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user