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,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[compose.yaml]
|
||||||
|
indent_size = 4
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
APP_NAME="Ladill Queue"
|
||||||
|
APP_ENV=production
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_URL=https://queue.ladill.com
|
||||||
|
|
||||||
|
PLATFORM_URL=https://ladill.com
|
||||||
|
PLATFORM_DOMAIN=ladill.com
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_LEVEL=error
|
||||||
|
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=ladill_queue
|
||||||
|
DB_USERNAME=ladill_queue
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
# Idle web session length in minutes (1440 = 24 hours of inactivity).
|
||||||
|
SESSION_LIFETIME=1440
|
||||||
|
CACHE_STORE=database
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
# --- Ladill SSO (Sign in with Ladill — auth.ladill.com OIDC client) ---
|
||||||
|
LADILL_SSO_CLIENT_ID=
|
||||||
|
LADILL_SSO_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Platform APIs this app consumes (per-consumer service keys) ---
|
||||||
|
BILLING_API_URL=https://ladill.com/api/billing
|
||||||
|
BILLING_API_KEY_QUEUE=
|
||||||
|
IDENTITY_API_URL=https://ladill.com/api
|
||||||
|
IDENTITY_API_KEY_QUEUE=
|
||||||
|
|
||||||
|
# Afia AI assistant (uses platform relay via IDENTITY_API_KEY when AFIA_API_KEY is unset)
|
||||||
|
AFIA_ENABLED=true
|
||||||
|
# AFIA_API_KEY=
|
||||||
|
# AFIA_PLATFORM_API_URL=https://ladill.com/api
|
||||||
|
# AFIA_PLATFORM_API_KEY=
|
||||||
|
|
||||||
|
# Platform events webhook (user/org lifecycle from the monolith)
|
||||||
|
SERVICE_EVENTS_INBOUND_SECRET=
|
||||||
|
|
||||||
|
# --- Inbound service API keys (sibling Ladill apps calling Queue) ---
|
||||||
|
QUEUE_API_KEY_POS=
|
||||||
|
QUEUE_API_KEY_CRM=
|
||||||
|
QUEUE_API_KEY_CARE=
|
||||||
|
QUEUE_API_KEY_FRONTDESK=
|
||||||
|
|
||||||
|
# Optional: platform DB read for SSO user resolution (same host as monolith)
|
||||||
|
# PLATFORM_DB_DATABASE=ladilldb
|
||||||
|
# PLATFORM_DB_USERNAME=
|
||||||
|
# PLATFORM_DB_PASSWORD=
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
.styleci.yml export-ignore
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
name: Deploy Ladill Queue
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-queue
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: deploy
|
||||||
|
env:
|
||||||
|
NODE_ROOT: /tmp/ladill-node-22-r1
|
||||||
|
NODE_VERSION: "22.14.0"
|
||||||
|
RELEASE_ARCHIVE: /tmp/ladill-queue-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||||
|
WORKSPACE: /tmp/${{ gitea.repository_owner }}-queue-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
LADILL_APP_ROOT: /var/www/ladill-queue
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
shell: bash {0}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
DEPLOY_GITEA_TOKEN="$(cat /home/deploy/.ladill-deploy-gitea-token)"
|
||||||
|
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
rm -rf "$WORKSPACE"
|
||||||
|
mkdir -p "$WORKSPACE"
|
||||||
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
git \
|
||||||
|
-c credential.helper= \
|
||||||
|
-c http.extraHeader="Authorization: token ${DEPLOY_GITEA_TOKEN}" \
|
||||||
|
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
|
||||||
|
"$REPO_URL" "$WORKSPACE"
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
shell: bash {0}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
if [ ! -x "$NODE_ROOT/bin/node" ]; then
|
||||||
|
rm -rf "$NODE_ROOT"
|
||||||
|
mkdir -p "$NODE_ROOT"
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64|amd64) NODE_ARCH="x64" ;;
|
||||||
|
aarch64|arm64) NODE_ARCH="arm64" ;;
|
||||||
|
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
||||||
|
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
|
||||||
|
fi
|
||||||
|
"$NODE_ROOT/bin/node" -v
|
||||||
|
|
||||||
|
- name: Build frontend assets
|
||||||
|
shell: bash {0}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
cd "$WORKSPACE"
|
||||||
|
export PATH="$NODE_ROOT/bin:$PATH"
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Build release archive
|
||||||
|
shell: bash {0}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
cd "$WORKSPACE"
|
||||||
|
printf '%s\n' "${{ gitea.sha }}" > REVISION
|
||||||
|
rm -f "$RELEASE_ARCHIVE"
|
||||||
|
tar -czf "$RELEASE_ARCHIVE" \
|
||||||
|
--exclude=.git --exclude=.gitea --exclude=.github --exclude=.env \
|
||||||
|
--exclude=node_modules --exclude=vendor --exclude=storage --exclude=tests .
|
||||||
|
|
||||||
|
- name: Deploy release
|
||||||
|
shell: bash {0}
|
||||||
|
env:
|
||||||
|
LADILL_RELEASE_ARCHIVE: /tmp/ladill-queue-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
||||||
|
bash "$WORKSPACE/deploy/deploy.sh"
|
||||||
|
|
||||||
|
- name: Ensure nginx vhost
|
||||||
|
shell: bash {0}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
NGINX_SCRIPT="$WORKSPACE/deployment/setup-service-subdomain-nginx.sh"
|
||||||
|
if [ ! -f "$NGINX_SCRIPT" ]; then
|
||||||
|
NGINX_SCRIPT="/var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$NGINX_SCRIPT" ]; then
|
||||||
|
echo "WARN: setup-service-subdomain-nginx.sh not found — configure queue.ladill.com vhost manually"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if sudo -n bash "$NGINX_SCRIPT" queue --app /var/www/ladill-queue/current; then
|
||||||
|
echo "nginx vhost updated for queue.ladill.com"
|
||||||
|
else
|
||||||
|
echo "WARN: nginx vhost step skipped (deploy user cannot sudo) — vhost must already be provisioned"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
shell: bash {0}
|
||||||
|
run: |
|
||||||
|
rm -rf "$WORKSPACE"
|
||||||
|
rm -f "$RELEASE_ARCHIVE"
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
/.fleet
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.phpunit.cache
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
/auth.json
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/storage/framework/views/*
|
||||||
|
!/storage/framework/views/.gitignore
|
||||||
|
/vendor
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Native mobile apps — kept locally, not tracked in this repo
|
||||||
|
/apps
|
||||||
|
|
||||||
|
/database/database.sqlite
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Ladill Queue — deploy runbook
|
||||||
|
|
||||||
|
Enterprise queue management at **queue.ladill.com** (tickets, counters, displays, appointments, analytics).
|
||||||
|
|
||||||
|
## 1. Gitea repo + CI
|
||||||
|
|
||||||
|
- Repo: **ladill-queue** (`isaacclad/ladill-queue`).
|
||||||
|
- Canonical repo: `/Users/isaacclad/Forty8Tech/ladill-queue` (not nested under the monorepo).
|
||||||
|
- App root on server: `/var/www/ladill-queue`.
|
||||||
|
|
||||||
|
## 2. Server app-slot + database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -d -o deploy -g www-data /var/www/ladill-queue
|
||||||
|
sudo install -d -o deploy -g www-data /var/www/ladill-queue/{releases,shared}
|
||||||
|
sudo chown -R deploy:www-data /var/www/ladill-queue
|
||||||
|
sudo mysql -e "CREATE DATABASE ladill_queue CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
|
sudo mysql -e "CREATE USER 'ladill_queue'@'127.0.0.1' IDENTIFIED BY '<pw>';"
|
||||||
|
sudo mysql -e "GRANT ALL ON ladill_queue.* TO 'ladill_queue'@'127.0.0.1'; FLUSH PRIVILEGES;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Platform wiring (monolith)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/ladill.com/current
|
||||||
|
php artisan ladill:onboard-app queue --run-dns
|
||||||
|
php artisan passport:client --name="Ladill Queue" --redirect_uri=https://queue.ladill.com/sso/callback
|
||||||
|
php artisan ladill:launcher:sync --propagate
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Shared `.env` (`/var/www/ladill-queue/shared/.env`)
|
||||||
|
|
||||||
|
Set `APP_URL=https://queue.ladill.com`, MySQL `ladill_queue` credentials, SSO client, billing/identity keys, and `SERVICE_EVENTS_INBOUND_SECRET`.
|
||||||
|
|
||||||
|
## 5. nginx + TLS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deployment/setup-service-subdomain-nginx.sh queue --app /var/www/ladill-queue/current
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Scheduler
|
||||||
|
|
||||||
|
Ensure cron runs `php artisan schedule:run` for:
|
||||||
|
|
||||||
|
- `qms:mark-devices-offline` (every 5 min)
|
||||||
|
- `qms:send-appointment-reminders` (every 15 min)
|
||||||
|
|
||||||
|
## 7. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://queue.ladill.com/api/health
|
||||||
|
curl -sI https://queue.ladill.com/login | head -1
|
||||||
|
```
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Ladill CRM
|
||||||
|
|
||||||
|
The Ladill **sales CRM** at `crm.ladill.com` — a full SSO web app for managing
|
||||||
|
**contacts, leads, deals, activities and products** — *and* the shared
|
||||||
|
Customers/Leads/Products service consumed over HTTP by Ladill Invoice, Merchant,
|
||||||
|
Mini and future apps, so contact + catalog data is owned in one place and reused
|
||||||
|
everywhere.
|
||||||
|
|
||||||
|
Every record is scoped to one platform account via `owner_ref` (the user's
|
||||||
|
`public_id` / OIDC `sub`).
|
||||||
|
|
||||||
|
## App (web UI — Sign in with Ladill)
|
||||||
|
|
||||||
|
Authenticates against `auth.ladill.com` (OIDC Authorization Code + PKCE; see
|
||||||
|
`Auth\SsoLoginController`). Local session is subordinate to the platform session
|
||||||
|
(`platform.session` middleware) and joins Single Logout via
|
||||||
|
`/sso/logout-frontchannel`.
|
||||||
|
|
||||||
|
- **Dashboard** — KPIs (contacts, open leads, open deals, pipeline value),
|
||||||
|
tasks due, recent activity.
|
||||||
|
- **Contacts** — the contact book, with a per-contact **timeline** (notes,
|
||||||
|
tasks, logged calls/meetings, sent email + SMS) and cross-product events
|
||||||
|
(invoices, payments, orders) pushed from sibling apps.
|
||||||
|
- **Leads** — list + **kanban board** (`new → contacted → qualified`), convert
|
||||||
|
to a contact.
|
||||||
|
- **Deals** — drag-free **pipeline** kanban across configurable stages
|
||||||
|
(`config/crm.pipeline_stages`), won/lost tracking.
|
||||||
|
- **Activities** — notes, tasks (with due dates / completion), calls, meetings.
|
||||||
|
- **Products & services** — reusable catalog items.
|
||||||
|
- **Comms** — email contacts (via the app mailer / Ladill Bird SMTP) and SMS
|
||||||
|
(Termii); each send is logged on the timeline.
|
||||||
|
- **Reports** — pipeline by stage, win rate, won value over time, leads by
|
||||||
|
status, activity mix.
|
||||||
|
|
||||||
|
## Service API (first-party, service-key auth)
|
||||||
|
|
||||||
|
Authenticate with a per-consumer service key:
|
||||||
|
`Authorization: Bearer <CRM_API_KEY_*>` (see `config/crm.php`,
|
||||||
|
`App\Http\Middleware\AuthenticateService`, alias `auth.service:crm`). Every
|
||||||
|
request must pass `owner` (query or body) to scope data to the end user.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/customers?owner=<sub>&search=
|
||||||
|
POST /api/customers {owner,name,email,phone,company,...}
|
||||||
|
... (show/update/destroy)
|
||||||
|
|
||||||
|
GET /api/leads?owner=<sub>&status=
|
||||||
|
POST /api/leads {owner,name,status,estimated_value_minor,...}
|
||||||
|
POST /api/leads/{id}/convert {owner} -> creates/links a customer
|
||||||
|
|
||||||
|
GET /api/products?owner=<sub>&type=&active=
|
||||||
|
POST /api/products {owner,name,type,unit_price_minor,currency,tax_rate}
|
||||||
|
|
||||||
|
POST /api/timeline {owner,event,title,external_id,amount_minor,
|
||||||
|
currency,url,customer_id|customer_email,...}
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /api/timeline` is how sibling products push "what happened" events onto a
|
||||||
|
contact's timeline (`invoice.sent`, `payment.received`, `order.paid`). The
|
||||||
|
`source` is derived from the service key (never client-supplied) and the write
|
||||||
|
is idempotent per `(owner, source, external_id, event)`. Reference consumer:
|
||||||
|
`ladill-invoice` `App\Services\Crm\CrmClient::pushTimeline()`.
|
||||||
|
|
||||||
|
No key → 401; missing `owner` → 422; cross-owner access → 404.
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
cp .env.example .env && php artisan key:generate
|
||||||
|
# sqlite for local: set DB_CONNECTION=sqlite and `touch database/database.sqlite`
|
||||||
|
php artisan migrate
|
||||||
|
npm install && npm run build # or `npm run dev`
|
||||||
|
php artisan serve
|
||||||
|
php artisan test
|
||||||
|
```
|
||||||
|
|
||||||
|
See `DEPLOY.md` for production cutover.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Qms\DeviceService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class MarkDevicesOfflineCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'qms:mark-devices-offline';
|
||||||
|
|
||||||
|
protected $description = 'Mark queue devices offline when heartbeat is stale';
|
||||||
|
|
||||||
|
public function handle(DeviceService $devices): int
|
||||||
|
{
|
||||||
|
$count = $devices->markStaleOffline();
|
||||||
|
|
||||||
|
$this->info("Marked {$count} device(s) offline.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\QueueAppointment;
|
||||||
|
use App\Services\Qms\QueueNotificationService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class SendAppointmentRemindersCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'qms:send-appointment-reminders {--minutes=60 : Remind appointments this many minutes ahead}';
|
||||||
|
|
||||||
|
protected $description = 'Send SMS/email reminders for upcoming appointments';
|
||||||
|
|
||||||
|
public function handle(QueueNotificationService $notifications): int
|
||||||
|
{
|
||||||
|
$minutes = (int) $this->option('minutes');
|
||||||
|
$from = now();
|
||||||
|
$to = now()->addMinutes($minutes);
|
||||||
|
|
||||||
|
$appointments = QueueAppointment::query()
|
||||||
|
->where('status', 'scheduled')
|
||||||
|
->whereBetween('scheduled_at', [$from, $to])
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('metadata')
|
||||||
|
->orWhereNull('metadata->reminder_sent');
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
foreach ($appointments as $appointment) {
|
||||||
|
$notifications->appointmentReminder($appointment);
|
||||||
|
$metadata = $appointment->metadata ?? [];
|
||||||
|
$metadata['reminder_sent'] = now()->toIso8601String();
|
||||||
|
$appointment->update(['metadata' => $metadata]);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Sent {$count} appointment reminder(s).");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class ServiceEventOccurred
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $name,
|
||||||
|
public readonly array $data = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Qms\AnalyticsService;
|
||||||
|
use App\Services\Qms\OrganizationResolver;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AnalyticsController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function overview(Request $request, AnalyticsService $analytics): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'reports.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $analytics->overview($this->ownerRef($request), $organization->id, $branchScope),
|
||||||
|
'trend' => $analytics->dailyTrend($this->ownerRef($request), $organization->id, 14, $branchScope),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\QueueAppointment;
|
||||||
|
use App\Services\Qms\AppointmentService;
|
||||||
|
use App\Services\Qms\AuditLogger;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class AppointmentController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.view');
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$items = QueueAppointment::owned($owner)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->orderBy('scheduled_at')
|
||||||
|
->paginate(50);
|
||||||
|
|
||||||
|
return response()->json(['data' => $items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'branch_id' => ['required', 'exists:queue_branches,id'],
|
||||||
|
'service_queue_id' => ['nullable', 'exists:queue_service_queues,id'],
|
||||||
|
'customer_name' => ['required', 'string', 'max:255'],
|
||||||
|
'customer_phone' => ['nullable', 'string', 'max:50'],
|
||||||
|
'customer_email' => ['nullable', 'email', 'max:255'],
|
||||||
|
'scheduled_at' => ['required', 'date'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$appointment = QueueAppointment::create([
|
||||||
|
'owner_ref' => $owner,
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
...$validated,
|
||||||
|
'status' => 'scheduled',
|
||||||
|
'reference' => 'APT-'.Str::upper(Str::random(6)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($owner, 'appointment.created', $organization->id, $owner, QueueAppointment::class, $appointment->id);
|
||||||
|
|
||||||
|
return response()->json(['data' => $appointment], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkIn(Request $request, QueueAppointment $appointment): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$this->authorizeOwner($request, $appointment);
|
||||||
|
|
||||||
|
$ticket = app(AppointmentService::class)->checkIn($appointment, $this->ownerRef($request));
|
||||||
|
|
||||||
|
return response()->json(['data' => \App\Services\Qms\TicketPresenter::toArray($ticket)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Services\Qms\AuditLogger;
|
||||||
|
use App\Services\Qms\PlanService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class BranchController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.branches.view');
|
||||||
|
$branches = Branch::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $this->organization($request)->id)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json(['data' => $branches]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$currentCount = Branch::owned($owner)->where('organization_id', $organization->id)->count();
|
||||||
|
abort_unless(app(PlanService::class)->canAddBranch($organization, $currentCount), 422, 'Branch limit reached for current plan.');
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'code' => ['nullable', 'string', 'max:50'],
|
||||||
|
'address' => ['nullable', 'string', 'max:500'],
|
||||||
|
'phone' => ['nullable', 'string', 'max:50'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$branch = Branch::create([
|
||||||
|
'owner_ref' => $owner,
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
...$validated,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($owner, 'branch.created', $organization->id, $owner, Branch::class, $branch->id);
|
||||||
|
|
||||||
|
return response()->json(['data' => $branch], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Services\Qms\OrganizationResolver;
|
||||||
|
use App\Services\Qms\QmsPermissions;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
trait ScopesApiToAccount
|
||||||
|
{
|
||||||
|
protected function ownerRef(Request $request): string
|
||||||
|
{
|
||||||
|
return (string) $request->user()->public_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function organization(Request $request): Organization
|
||||||
|
{
|
||||||
|
$organization = app(OrganizationResolver::class)->resolveForUser($request->user());
|
||||||
|
abort_unless($organization, 404);
|
||||||
|
|
||||||
|
return $organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function member(Request $request): ?Member
|
||||||
|
{
|
||||||
|
return app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAbility(Request $request, string $ability): void
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
app(QmsPermissions::class)->can($this->member($request), $ability),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeOwner(Request $request, Model $model): void
|
||||||
|
{
|
||||||
|
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Counter;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use App\Services\Qms\AuditLogger;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CounterController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'counters.view');
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$counters = Counter::owned($owner)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->with('serviceQueues')
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(fn (Counter $c) => [
|
||||||
|
'uuid' => $c->uuid,
|
||||||
|
'name' => $c->name,
|
||||||
|
'code' => $c->code,
|
||||||
|
'status' => $c->status,
|
||||||
|
'queues' => $c->serviceQueues->pluck('name'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => $counters]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'counters.manage');
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'code' => ['nullable', 'string', 'max:50'],
|
||||||
|
'branch_id' => ['required', 'integer'],
|
||||||
|
'department_id' => ['nullable', 'integer'],
|
||||||
|
'queue_ids' => ['nullable', 'array'],
|
||||||
|
'queue_ids.*' => ['uuid'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$counter = Counter::create([
|
||||||
|
'owner_ref' => $owner,
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'branch_id' => $validated['branch_id'],
|
||||||
|
'department_id' => $validated['department_id'] ?? null,
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'code' => $validated['code'] ?? null,
|
||||||
|
'status' => 'offline',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! empty($validated['queue_ids'])) {
|
||||||
|
$queueIds = ServiceQueue::owned($owner)->whereIn('uuid', $validated['queue_ids'])->pluck('id');
|
||||||
|
$counter->serviceQueues()->sync($queueIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLogger::record($owner, 'counter.created', $organization->id, $owner, Counter::class, $counter->id);
|
||||||
|
|
||||||
|
return response()->json(['data' => ['uuid' => $counter->uuid, 'name' => $counter->name]], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Qms\DeviceService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class DeviceHeartbeatController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected DeviceService $devices,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$token = $request->bearerToken() ?? $request->input('device_token');
|
||||||
|
abort_unless($token, 401, 'Device token required.');
|
||||||
|
|
||||||
|
$device = $this->devices->findByToken($token);
|
||||||
|
abort_unless($device, 401, 'Invalid device token.');
|
||||||
|
|
||||||
|
$this->devices->recordHeartbeat($device);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'ok',
|
||||||
|
'device_id' => $device->uuid,
|
||||||
|
'server_time' => now()->toIso8601String(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Services\Qms\QueueEngine;
|
||||||
|
use App\Services\Qms\TicketPresenter;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PublicQueueController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected QueueEngine $engine,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function join(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'queue_uuid' => ['required', 'uuid'],
|
||||||
|
'customer_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'customer_phone' => ['required', 'string', 'max:50'],
|
||||||
|
'customer_email' => ['nullable', 'email'],
|
||||||
|
'priority' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_priorities')))],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$queue = ServiceQueue::where('uuid', $validated['queue_uuid'])
|
||||||
|
->where('is_active', true)
|
||||||
|
->where('is_paused', false)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$ticket = $this->engine->issueTicket($queue, $queue->owner_ref, [
|
||||||
|
'customer_name' => $validated['customer_name'] ?? null,
|
||||||
|
'customer_phone' => $validated['customer_phone'],
|
||||||
|
'customer_email' => $validated['customer_email'] ?? null,
|
||||||
|
'priority' => $validated['priority'] ?? 'walk_in',
|
||||||
|
'source' => 'mobile',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => TicketPresenter::toArray($ticket)], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $qrToken): JsonResponse
|
||||||
|
{
|
||||||
|
$ticket = Ticket::where('qr_token', $qrToken)->with(['serviceQueue', 'counter'])->firstOrFail();
|
||||||
|
|
||||||
|
$ahead = Ticket::query()
|
||||||
|
->where('service_queue_id', $ticket->service_queue_id)
|
||||||
|
->where('status', 'waiting')
|
||||||
|
->where('issued_at', '<', $ticket->issued_at)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$data = TicketPresenter::toArray($ticket, false);
|
||||||
|
$data['customers_ahead'] = $ahead;
|
||||||
|
|
||||||
|
return response()->json(['data' => $data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delay(Request $request, string $qrToken): JsonResponse
|
||||||
|
{
|
||||||
|
$ticket = Ticket::where('qr_token', $qrToken)->whereIn('status', ['waiting', 'on_hold'])->firstOrFail();
|
||||||
|
|
||||||
|
$validated = $request->validate(['minutes' => ['required', 'integer', 'min:5', 'max:120']]);
|
||||||
|
$ticket = $this->engine->delayArrival($ticket, $validated['minutes']);
|
||||||
|
|
||||||
|
return response()->json(['data' => TicketPresenter::toArray($ticket)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(string $qrToken): JsonResponse
|
||||||
|
{
|
||||||
|
$ticket = Ticket::where('qr_token', $qrToken)->whereIn('status', ['waiting', 'on_hold', 'called'])->firstOrFail();
|
||||||
|
$ticket = $this->engine->cancel($ticket);
|
||||||
|
|
||||||
|
return response()->json(['data' => TicketPresenter::toArray($ticket)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\QueueRule;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class QueueRuleController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function index(Request $request, ServiceQueue $serviceQueue): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'rules.view');
|
||||||
|
$this->authorizeOwner($request, $serviceQueue);
|
||||||
|
|
||||||
|
$rules = QueueRule::owned($this->ownerRef($request))
|
||||||
|
->where('service_queue_id', $serviceQueue->id)
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json(['data' => $rules]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, ServiceQueue $serviceQueue): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'rules.manage');
|
||||||
|
$this->authorizeOwner($request, $serviceQueue);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'rule_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.rule_types')))],
|
||||||
|
'conditions' => ['nullable', 'array'],
|
||||||
|
'actions' => ['nullable', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rule = QueueRule::create([
|
||||||
|
'owner_ref' => $this->ownerRef($request),
|
||||||
|
'service_queue_id' => $serviceQueue->id,
|
||||||
|
'rule_type' => $validated['rule_type'],
|
||||||
|
'conditions' => $validated['conditions'] ?? [],
|
||||||
|
'actions' => $validated['actions'] ?? [],
|
||||||
|
'sort_order' => QueueRule::where('service_queue_id', $serviceQueue->id)->max('sort_order') + 1,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => $rule], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Qms\OrganizationResolver;
|
||||||
|
use App\Services\Qms\ReportService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class ReportController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function show(Request $request, string $type, ReportService $reports): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless(array_key_exists($type, config('qms.report_types')), 404);
|
||||||
|
$this->authorizeAbility($request, 'reports.view');
|
||||||
|
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
|
||||||
|
$from = Carbon::parse($request->input('from', now()->subDays(30)->toDateString()))->startOfDay();
|
||||||
|
$to = Carbon::parse($request->input('to', now()->toDateString()))->endOfDay();
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$data = match ($type) {
|
||||||
|
'tickets' => $reports->ticketsReport($owner, $organization->id, $from, $to, $branchId),
|
||||||
|
'wait_times' => $reports->waitTimesReport($owner, $organization->id, $from, $to, $branchId),
|
||||||
|
'counters' => $reports->countersReport($owner, $organization->id, $from, $to, $branchId),
|
||||||
|
'appointments' => $reports->appointmentsReport($owner, $organization->id, $from, $to, $branchId),
|
||||||
|
'feedback' => $reports->feedbackReport($owner, $organization->id, $from, $to, $branchId),
|
||||||
|
'service_sessions' => $reports->serviceSessionsReport($owner, $organization->id, $from, $to, $branchId),
|
||||||
|
default => abort(404),
|
||||||
|
};
|
||||||
|
|
||||||
|
return response()->json(['data' => $data, 'from' => $from->toDateString(), 'to' => $to->toDateString()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Events\ServiceEventOccurred;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Events\ServiceEventSignature;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class ServiceEventController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$secret = (string) config('service_events.inbound_secret');
|
||||||
|
$signature = (string) $request->header('X-Ladill-Signature', '');
|
||||||
|
|
||||||
|
if (! ServiceEventSignature::verify($request->getContent(), $signature, $secret)) {
|
||||||
|
return response()->json(['error' => 'invalid signature'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$eventId = (string) $request->header('X-Ladill-Event-Id', (string) $request->input('id', ''));
|
||||||
|
if ($eventId !== '') {
|
||||||
|
if (Cache::has("svcevt:{$eventId}")) {
|
||||||
|
return response()->json(['status' => 'duplicate']);
|
||||||
|
}
|
||||||
|
Cache::put("svcevt:{$eventId}", true, now()->addDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
event(new ServiceEventOccurred((string) $request->input('event'), (array) $request->input('data', [])));
|
||||||
|
|
||||||
|
return response()->json(['status' => 'accepted']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Counter;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ServiceQueueController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'queues.view');
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$queues = ServiceQueue::owned($owner)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->with(['branch', 'department'])
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(fn (ServiceQueue $q) => [
|
||||||
|
'uuid' => $q->uuid,
|
||||||
|
'name' => $q->name,
|
||||||
|
'prefix' => $q->prefix,
|
||||||
|
'strategy' => $q->strategy,
|
||||||
|
'is_active' => $q->is_active,
|
||||||
|
'is_paused' => $q->is_paused,
|
||||||
|
'branch' => $q->branch?->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => $queues]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function waiting(Request $request, ServiceQueue $serviceQueue): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'queues.view');
|
||||||
|
$this->authorizeOwner($request, $serviceQueue);
|
||||||
|
|
||||||
|
$tickets = app(\App\Services\Qms\QueueEngine::class)->waitingTickets($serviceQueue);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => array_map(fn ($t) => \App\Services\Qms\TicketPresenter::toArray($t), $tickets),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pause(Request $request, ServiceQueue $serviceQueue): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'queues.manage');
|
||||||
|
$this->authorizeOwner($request, $serviceQueue);
|
||||||
|
|
||||||
|
$queue = app(\App\Services\Qms\QueueEngine::class)->pauseQueue($serviceQueue, $this->ownerRef($request));
|
||||||
|
|
||||||
|
return response()->json(['data' => ['uuid' => $queue->uuid, 'is_paused' => $queue->is_paused]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resume(Request $request, ServiceQueue $serviceQueue): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'queues.manage');
|
||||||
|
$this->authorizeOwner($request, $serviceQueue);
|
||||||
|
|
||||||
|
$queue = app(\App\Services\Qms\QueueEngine::class)->resumeQueue($serviceQueue, $this->ownerRef($request));
|
||||||
|
|
||||||
|
return response()->json(['data' => ['uuid' => $queue->uuid, 'is_paused' => $queue->is_paused]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Services\Qms\QueueEngine;
|
||||||
|
use App\Services\Qms\TicketPresenter;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TicketController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected QueueEngine $engine,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'tickets.view');
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$tickets = Ticket::owned($owner)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->when($request->query('queue'), fn ($q, $uuid) => $q->whereHas('serviceQueue', fn ($sq) => $sq->where('uuid', $uuid)))
|
||||||
|
->when($request->query('status'), fn ($q, $status) => $q->where('status', $status))
|
||||||
|
->with(['serviceQueue', 'counter'])
|
||||||
|
->latest('issued_at')
|
||||||
|
->paginate(50);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $tickets->getCollection()->map(fn (Ticket $t) => TicketPresenter::toArray($t)),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $tickets->currentPage(),
|
||||||
|
'last_page' => $tickets->lastPage(),
|
||||||
|
'total' => $tickets->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'tickets.issue');
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'service_queue_id' => ['required', 'uuid'],
|
||||||
|
'customer_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'customer_phone' => ['nullable', 'string', 'max:50'],
|
||||||
|
'customer_email' => ['nullable', 'email', 'max:255'],
|
||||||
|
'priority' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_priorities')))],
|
||||||
|
'source' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_sources')))],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$queue = ServiceQueue::where('uuid', $validated['service_queue_id'])->firstOrFail();
|
||||||
|
$this->authorizeOwner($request, $queue);
|
||||||
|
|
||||||
|
$ticket = $this->engine->issueTicket($queue, $owner, [
|
||||||
|
...$validated,
|
||||||
|
'source' => $validated['source'] ?? 'api',
|
||||||
|
'actor_ref' => $owner,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => TicketPresenter::toArray($ticket)], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Ticket $ticket): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'tickets.view');
|
||||||
|
$this->authorizeOwner($request, $ticket);
|
||||||
|
|
||||||
|
return response()->json(['data' => TicketPresenter::toArray($ticket->load(['serviceQueue', 'counter']))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callNext(Request $request, ServiceQueue $serviceQueue): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'console.use');
|
||||||
|
$this->authorizeOwner($request, $serviceQueue);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'counter_id' => ['required', 'uuid'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$counter = \App\Models\Counter::where('uuid', $validated['counter_id'])->firstOrFail();
|
||||||
|
$this->authorizeOwner($request, $counter);
|
||||||
|
|
||||||
|
$ticket = $this->engine->callNext($serviceQueue, $counter, $this->ownerRef($request));
|
||||||
|
|
||||||
|
return response()->json(['data' => $ticket ? TicketPresenter::toArray($ticket) : null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function action(Request $request, Ticket $ticket): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'tickets.manage');
|
||||||
|
$this->authorizeOwner($request, $ticket);
|
||||||
|
$actor = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'action' => ['required', 'string', 'in:recall,start,hold,skip,complete,no_show,cancel,delay'],
|
||||||
|
'minutes' => ['nullable', 'integer', 'min:5', 'max:120'],
|
||||||
|
'to_queue_id' => ['nullable', 'uuid'],
|
||||||
|
'reason' => ['nullable', 'string', 'max:500'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = match ($validated['action']) {
|
||||||
|
'recall' => $this->engine->recall($ticket, $actor),
|
||||||
|
'start' => $this->engine->startServing($ticket, $actor),
|
||||||
|
'hold' => $this->engine->hold($ticket, $actor),
|
||||||
|
'skip' => $this->engine->skip($ticket, $actor),
|
||||||
|
'complete' => $this->engine->completeTicket($ticket, $actor),
|
||||||
|
'no_show' => $this->engine->markNoShow($ticket, $actor),
|
||||||
|
'cancel' => $this->engine->cancel($ticket, $actor),
|
||||||
|
'delay' => $this->engine->delayArrival($ticket, (int) ($validated['minutes'] ?? 15), $actor),
|
||||||
|
default => $ticket,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($validated['action'] === 'transfer' && ! empty($validated['to_queue_id'])) {
|
||||||
|
$toQueue = ServiceQueue::where('uuid', $validated['to_queue_id'])->firstOrFail();
|
||||||
|
$result = $this->engine->transfer($ticket, $toQueue, null, $validated['reason'] ?? null, $actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter']))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Workflow;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class WorkflowController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'workflows.view');
|
||||||
|
$workflows = Workflow::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $this->organization($request)->id)
|
||||||
|
->with('steps.serviceQueue')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json(['data' => $workflows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Workflow $workflow): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'workflows.view');
|
||||||
|
$this->authorizeOwner($request, $workflow);
|
||||||
|
|
||||||
|
return response()->json(['data' => $workflow->load('steps.serviceQueue')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Client\Response as HttpResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sign in with Ladill" — Authorization Code + PKCE against auth.ladill.com.
|
||||||
|
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
|
||||||
|
*/
|
||||||
|
class SsoLoginController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Max consecutive failed callbacks before we stop bouncing back into the
|
||||||
|
* authorize flow and show an error page instead (prevents an infinite
|
||||||
|
* /sso/callback ↔ /sso/connect redirect loop on a persistent upstream error).
|
||||||
|
*/
|
||||||
|
private const MAX_SSO_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
public function connect(Request $request): RedirectResponse|View
|
||||||
|
{
|
||||||
|
$intended = (string) $request->query('redirect', route('qms.dashboard'));
|
||||||
|
|
||||||
|
if (Auth::check()) {
|
||||||
|
return $this->safeRedirect($intended, route('qms.dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->boolean('fallback')) {
|
||||||
|
$request->session()->forget('sso.attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->attemptSilentRefresh($request, $intended)) {
|
||||||
|
return $this->safeRedirect($intended, route('qms.dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$verifier = Str::random(64);
|
||||||
|
$state = Str::random(40);
|
||||||
|
$request->session()->put('sso.verifier', $verifier);
|
||||||
|
$request->session()->put('sso.state', $state);
|
||||||
|
$request->session()->put('sso.intended', $intended);
|
||||||
|
|
||||||
|
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||||
|
|
||||||
|
$query = [
|
||||||
|
'response_type' => 'code',
|
||||||
|
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||||
|
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||||
|
'scope' => 'openid profile email',
|
||||||
|
'state' => $state,
|
||||||
|
'code_challenge' => $challenge,
|
||||||
|
'code_challenge_method' => 'S256',
|
||||||
|
];
|
||||||
|
|
||||||
|
$loginHint = (string) $request->session()->get('sso.login_hint', '');
|
||||||
|
if ($loginHint !== '') {
|
||||||
|
$query['login_hint'] = $loginHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->boolean('interactive')) {
|
||||||
|
$query['prompt'] = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
|
||||||
|
|
||||||
|
return redirect()->away($authorizeUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$intended = (string) $request->session()->get('sso.intended', route('qms.dashboard'));
|
||||||
|
|
||||||
|
if ($request->filled('error')) {
|
||||||
|
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
|
||||||
|
&& ! $request->boolean('interactive')) {
|
||||||
|
return redirect()->away((string) config('ladill.marketing_url'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->filled('code')
|
||||||
|
|| $request->query('state') !== $request->session()->pull('sso.state')) {
|
||||||
|
return $this->finishCallback($request, $intended, 'invalid_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||||
|
|
||||||
|
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||||
|
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||||
|
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||||
|
'code' => (string) $request->query('code'),
|
||||||
|
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
|
||||||
|
]);
|
||||||
|
if ($tokenRes->failed()) {
|
||||||
|
return $this->finishCallback($request, $intended, 'token_exchange_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||||
|
if (! $user) {
|
||||||
|
return $this->finishCallback($request, $intended, 'userinfo_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::login($user, remember: true);
|
||||||
|
$request->session()->regenerate();
|
||||||
|
$request->session()->forget('sso.attempts');
|
||||||
|
|
||||||
|
return $this->finishCallback($request, $intended, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failed(Request $request): View
|
||||||
|
{
|
||||||
|
return view('auth.sso-error', [
|
||||||
|
'reason' => (string) $request->session()->get('sso.error', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
// Per-app sign-out: end only this app's session and keep the platform
|
||||||
|
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently.
|
||||||
|
return redirect()->route('qms.signed-out');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Platform session ended — clear this app and offer silent sign-in again. */
|
||||||
|
public function platformSignedOut(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('sso.connect', [
|
||||||
|
'redirect' => (string) $request->query('redirect', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logoutBridge(Request $request): View
|
||||||
|
{
|
||||||
|
$return = $this->safeReturnUrl((string) $request->query('return', ''));
|
||||||
|
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
|
||||||
|
'embedded' => 1,
|
||||||
|
'return' => $return,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return view('auth.sso-logout-bridge', [
|
||||||
|
'hubUrl' => $hubUrl,
|
||||||
|
'return' => $return,
|
||||||
|
'authOrigin' => 'https://'.config('app.auth_domain'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function frontchannelLogout(Request $request): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
$return = (string) $request->query('return', '');
|
||||||
|
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||||
|
$host = parse_url($return, PHP_URL_HOST);
|
||||||
|
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||||
|
return redirect()->away($return);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response('', 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attemptSilentRefresh(Request $request, string $intended): bool
|
||||||
|
{
|
||||||
|
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
|
||||||
|
if ($refreshToken === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||||
|
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||||
|
'grant_type' => 'refresh_token',
|
||||||
|
'refresh_token' => $refreshToken,
|
||||||
|
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||||
|
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||||
|
'scope' => 'openid profile email',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($tokenRes->failed()) {
|
||||||
|
$request->session()->forget('sso.refresh_token');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::login($user, remember: true);
|
||||||
|
$request->session()->put('sso.intended', $intended);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
|
||||||
|
{
|
||||||
|
$refreshToken = (string) $tokenRes->json('refresh_token', '');
|
||||||
|
if ($refreshToken !== '') {
|
||||||
|
$request->session()->put('sso.refresh_token', $refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||||
|
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
|
||||||
|
if ($claims->failed() || ! $claims->json('sub')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = (string) ($claims->json('email') ?: '');
|
||||||
|
if ($email !== '') {
|
||||||
|
$request->session()->put('sso.login_hint', $email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::updateOrCreate(
|
||||||
|
['public_id' => (string) $claims->json('sub')],
|
||||||
|
[
|
||||||
|
'name' => $claims->json('name'),
|
||||||
|
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
|
||||||
|
'avatar_url' => $claims->json('picture'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finishCallback(Request $request, string $intended, ?string $error = null): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($error) {
|
||||||
|
$attempts = (int) $request->session()->get('sso.attempts', 0) + 1;
|
||||||
|
|
||||||
|
if ($attempts >= self::MAX_SSO_ATTEMPTS) {
|
||||||
|
$request->session()->forget(['sso.attempts', 'sso.state', 'sso.verifier', 'sso.intended']);
|
||||||
|
$request->session()->flash('sso.error', $error);
|
||||||
|
|
||||||
|
return redirect()->route('sso.failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('sso.attempts', $attempts);
|
||||||
|
|
||||||
|
return redirect()->route('sso.connect', [
|
||||||
|
'redirect' => $intended,
|
||||||
|
'interactive' => 1,
|
||||||
|
'fallback' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->safeRedirect($intended, route('qms.dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeReturnUrl(string $url): string
|
||||||
|
{
|
||||||
|
$host = parse_url($url, PHP_URL_HOST);
|
||||||
|
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||||
|
|
||||||
|
if (is_string($host) && str_starts_with($url, 'https://')
|
||||||
|
&& ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route('qms.signed-out');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeRedirect(string $url, string $fallback): RedirectResponse
|
||||||
|
{
|
||||||
|
$host = parse_url($url, PHP_URL_HOST);
|
||||||
|
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||||
|
|
||||||
|
if (is_string($host) && str_starts_with($url, 'https://')
|
||||||
|
&& ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||||
|
return redirect()->away($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->away($fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Events\ServiceEventOccurred;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class PlatformServiceEventListener
|
||||||
|
{
|
||||||
|
public function handle(ServiceEventOccurred $event): void
|
||||||
|
{
|
||||||
|
match ($event->name) {
|
||||||
|
'user.deleted' => $this->handleUserDeleted($event),
|
||||||
|
'user.suspended' => $this->handleUserSuspended($event),
|
||||||
|
'organization.updated' => $this->handleOrganizationUpdated($event),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handleUserDeleted(ServiceEventOccurred $event): void
|
||||||
|
{
|
||||||
|
$publicId = (string) ($event->data['user'] ?? '');
|
||||||
|
if ($publicId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($publicId) {
|
||||||
|
Member::where('user_ref', $publicId)->delete();
|
||||||
|
User::where('public_id', $publicId)->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
Log::info('PlatformServiceEventListener: user deleted', ['public_id' => $publicId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handleUserSuspended(ServiceEventOccurred $event): void
|
||||||
|
{
|
||||||
|
$publicId = (string) ($event->data['user'] ?? '');
|
||||||
|
if ($publicId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::where('public_id', $publicId)->first();
|
||||||
|
if ($user) {
|
||||||
|
$user->tokens()->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('PlatformServiceEventListener: user suspended', ['public_id' => $publicId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handleOrganizationUpdated(ServiceEventOccurred $event): void
|
||||||
|
{
|
||||||
|
$ownerRef = (string) ($event->data['owner'] ?? $event->data['user'] ?? '');
|
||||||
|
if ($ownerRef === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$organization = Organization::owned($ownerRef)->first();
|
||||||
|
if (! $organization) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updates = array_filter([
|
||||||
|
'name' => $event->data['name'] ?? null,
|
||||||
|
'timezone' => $event->data['timezone'] ?? null,
|
||||||
|
], fn ($value) => $value !== null && $value !== '');
|
||||||
|
|
||||||
|
if ($updates === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$organization->update($updates);
|
||||||
|
|
||||||
|
Log::info('PlatformServiceEventListener: organization updated', [
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class ContactMessageMail extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $subjectLine,
|
||||||
|
public string $bodyText,
|
||||||
|
public ?string $fromName = null,
|
||||||
|
public ?string $replyToAddress = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: $this->subjectLine,
|
||||||
|
replyTo: $this->replyToAddress ? [$this->replyToAddress] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
view: 'email.contact-message',
|
||||||
|
with: [
|
||||||
|
'bodyText' => $this->bodyText,
|
||||||
|
'fromName' => $this->fromName,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class AuditLog extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'queue_audit_logs';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'organization_id', 'actor_ref', 'action',
|
||||||
|
'subject_type', 'subject_id', 'metadata', 'ip_address', 'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'metadata' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function record(
|
||||||
|
string $ownerRef,
|
||||||
|
string $action,
|
||||||
|
?int $organizationId = null,
|
||||||
|
?string $actorRef = null,
|
||||||
|
?string $subjectType = null,
|
||||||
|
?int $subjectId = null,
|
||||||
|
?array $metadata = null,
|
||||||
|
): self {
|
||||||
|
return static::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $organizationId,
|
||||||
|
'actor_ref' => $actorRef,
|
||||||
|
'action' => $action,
|
||||||
|
'subject_type' => $subjectType,
|
||||||
|
'subject_id' => $subjectId,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'ip_address' => request()?->ip(),
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Branch extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'queue_branches';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'organization_id', 'name', 'code', 'address', 'phone', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['is_active' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function departments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Department::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serviceQueues(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ServiceQueue::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function counters(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Counter::class, 'branch_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
trait BelongsToOwner
|
||||||
|
{
|
||||||
|
public function scopeOwned(Builder $query, string $ownerRef): Builder
|
||||||
|
{
|
||||||
|
return $query->where($this->getTable().'.owner_ref', $ownerRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Counter extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'queue_counters';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'department_id',
|
||||||
|
'name', 'code', 'status', 'settings', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'settings' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (self $counter) {
|
||||||
|
$counter->uuid ??= (string) Str::uuid();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function department(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Department::class, 'department_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serviceQueues(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(ServiceQueue::class, 'queue_counter_service_queue', 'counter_id', 'service_queue_id')
|
||||||
|
->withPivot('priority');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tickets(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ticket::class, 'counter_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class CustomerFeedback extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'queue_customer_feedback';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'ticket_id', 'counter_id',
|
||||||
|
'rating', 'service_quality', 'wait_experience', 'staff_professionalism', 'comments',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (self $model) {
|
||||||
|
$model->uuid ??= (string) Str::uuid();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class, 'ticket_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function counter(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Counter::class, 'counter_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Department extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'queue_departments';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'branch_id', 'name', 'type', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['is_active' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Device extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'queue_devices';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id',
|
||||||
|
'name', 'type', 'status', 'device_token', 'config', 'last_online_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'config' => 'array',
|
||||||
|
'last_online_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (self $device) {
|
||||||
|
$device->uuid ??= (string) Str::uuid();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class DisplayScreen extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'queue_display_screens';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id',
|
||||||
|
'name', 'layout', 'access_token', 'service_queue_ids', 'settings', 'is_active', 'last_seen_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'service_queue_ids' => 'array',
|
||||||
|
'settings' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'last_seen_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (self $screen) {
|
||||||
|
$screen->uuid ??= (string) Str::uuid();
|
||||||
|
$screen->access_token ??= Str::random(48);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Member extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'queue_members';
|
||||||
|
|
||||||
|
protected $fillable = ['owner_ref', 'organization_id', 'user_ref', 'role', 'branch_id'];
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasRole(string ...$roles): bool
|
||||||
|
{
|
||||||
|
return in_array($this->role, $roles, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Organization extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'queue_organizations';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'name', 'slug', 'logo_path', 'timezone', 'settings',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['settings' => 'array'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branches(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Branch::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function members(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Member::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serviceQueues(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ServiceQueue::class, 'organization_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class QueueAppointment extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'queue_appointments';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'service_queue_id', 'ticket_id',
|
||||||
|
'customer_name', 'customer_phone', 'customer_email', 'status', 'mode',
|
||||||
|
'scheduled_at', 'checked_in_at', 'reference', 'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'scheduled_at' => 'datetime',
|
||||||
|
'checked_in_at' => 'datetime',
|
||||||
|
'metadata' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (self $model) {
|
||||||
|
$model->uuid ??= (string) Str::uuid();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serviceQueue(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ServiceQueue::class, 'service_queue_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class, 'ticket_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class QueueRule extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'queue_queue_rules';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'service_queue_id', 'rule_type', 'conditions', 'actions', 'sort_order', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'conditions' => 'array',
|
||||||
|
'actions' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serviceQueue(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ServiceQueue::class, 'service_queue_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ServiceQueue extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'queue_service_queues';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'department_id',
|
||||||
|
'name', 'description', 'color', 'prefix', 'strategy',
|
||||||
|
'max_capacity', 'avg_service_seconds', 'operating_hours', 'priority_rules',
|
||||||
|
'settings', 'is_active', 'is_paused', 'ticket_sequence',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'operating_hours' => 'array',
|
||||||
|
'priority_rules' => 'array',
|
||||||
|
'settings' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'is_paused' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (self $queue) {
|
||||||
|
$queue->uuid ??= (string) Str::uuid();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function department(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Department::class, 'department_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tickets(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ticket::class, 'service_queue_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function counters(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Counter::class, 'queue_counter_service_queue', 'service_queue_id', 'counter_id')
|
||||||
|
->withPivot('priority');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ServiceSession extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'queue_service_sessions';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'ticket_id', 'counter_id', 'staff_ref',
|
||||||
|
'started_at', 'ended_at', 'duration_seconds',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'ended_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class, 'ticket_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function counter(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Counter::class, 'counter_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Ticket extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'queue_tickets';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'service_queue_id', 'counter_id',
|
||||||
|
'ticket_number', 'status', 'priority', 'position', 'customer_name', 'customer_phone',
|
||||||
|
'customer_email', 'source', 'qr_token', 'barcode', 'estimated_wait_seconds',
|
||||||
|
'issued_at', 'called_at', 'serving_started_at', 'completed_at', 'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'metadata' => 'array',
|
||||||
|
'issued_at' => 'datetime',
|
||||||
|
'called_at' => 'datetime',
|
||||||
|
'serving_started_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (self $ticket) {
|
||||||
|
$ticket->uuid ??= (string) Str::uuid();
|
||||||
|
$ticket->qr_token ??= Str::random(32);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serviceQueue(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ServiceQueue::class, 'service_queue_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function counter(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Counter::class, 'counter_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function events(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TicketEvent::class, 'ticket_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class TicketEvent extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'queue_ticket_events';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'ticket_id', 'event', 'actor_ref', 'counter_id', 'metadata', 'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'metadata' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class, 'ticket_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function counter(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Counter::class, 'counter_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin local mirror of the platform identity (auth.ladill.com owns users).
|
||||||
|
* Keyed by the OIDC `sub` (public_id); every CRM record is scoped to it.
|
||||||
|
*/
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
use HasApiTokens, Notifiable;
|
||||||
|
|
||||||
|
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password', 'last_app_active_at'];
|
||||||
|
|
||||||
|
protected $hidden = ['password', 'remember_token'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'last_app_active_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The owner reference used to scope every CRM record to this account. */
|
||||||
|
public function ownerRef(): string
|
||||||
|
{
|
||||||
|
return (string) $this->public_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function avatarUrl(): ?string
|
||||||
|
{
|
||||||
|
$url = trim((string) $this->avatar_url);
|
||||||
|
|
||||||
|
return $url !== '' ? $url : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class VoiceAnnouncement extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'queue_voice_announcements';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'organization_id', 'branch_id', 'ticket_id',
|
||||||
|
'locale', 'message', 'voice', 'volume', 'repeat_count', 'status', 'played_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'played_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class, 'ticket_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Workflow extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'queue_workflows';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id',
|
||||||
|
'name', 'description', 'industry_template', 'is_active', 'settings',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'settings' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (self $model) {
|
||||||
|
$model->uuid ??= (string) Str::uuid();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function steps(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WorkflowStep::class, 'workflow_id')->orderBy('sort_order');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class WorkflowStep extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'queue_workflow_steps';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'workflow_id', 'service_queue_id', 'name', 'sort_order', 'settings',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['settings' => 'array'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workflow(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workflow::class, 'workflow_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serviceQueue(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ServiceQueue::class, 'service_queue_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications\Qms;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class QueueStaffNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $title,
|
||||||
|
public string $message,
|
||||||
|
public ?string $url = null,
|
||||||
|
public string $icon = 'bell',
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => $this->title,
|
||||||
|
'message' => $this->message,
|
||||||
|
'url' => $this->url,
|
||||||
|
'icon' => $this->icon,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Events\ServiceEventOccurred;
|
||||||
|
use App\Listeners\PlatformServiceEventListener;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Event::listen(ServiceEventOccurred::class, PlatformServiceEventListener::class);
|
||||||
|
|
||||||
|
RateLimiter::for('kiosk-device', function (Request $request) {
|
||||||
|
$key = $request->route('token') ?? $request->ip();
|
||||||
|
|
||||||
|
return Limit::perMinute(30)->by((string) $key);
|
||||||
|
});
|
||||||
|
|
||||||
|
View::composer(['partials.topbar'], function ($view) {
|
||||||
|
$view->with(\App\Support\MobileTopbar::resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for the platform Billing HTTP API — the one UserWallet lives on the
|
||||||
|
* platform; CRM only consumes it. Amounts are integer minor units; the user is
|
||||||
|
* identified by public_id. Authenticates with the per-consumer config('billing.api_key').
|
||||||
|
*/
|
||||||
|
class BillingClient
|
||||||
|
{
|
||||||
|
private function base(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('billing.api_url'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function token(): string
|
||||||
|
{
|
||||||
|
return (string) (config('billing.api_key') ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function balanceMinor(string $publicId): int
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
|
||||||
|
->get($this->base().'/balance', ['user' => $publicId]);
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return (int) ($res->json('balance_minor') ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAfford(string $publicId, int $amountMinor): bool
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
|
||||||
|
->get($this->base().'/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor]);
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return (bool) ($res->json('affordable') ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debit the wallet. Returns true on success, false on insufficient balance
|
||||||
|
* (HTTP 402). Idempotent by $reference.
|
||||||
|
*/
|
||||||
|
public function debit(string $publicId, int $amountMinor, string $source, string $reference, ?string $description = null): bool
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
|
||||||
|
'user' => $publicId,
|
||||||
|
'amount_minor' => $amountMinor,
|
||||||
|
'service' => (string) config('billing.service', 'crm'),
|
||||||
|
'source' => $source,
|
||||||
|
'reference' => $reference,
|
||||||
|
'description' => $description,
|
||||||
|
], static fn ($v) => $v !== null));
|
||||||
|
|
||||||
|
if ($res->status() === 402) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use App\Models\CrmPurchase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class OneTimePurchaseService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly BillingClient $billing)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null */
|
||||||
|
public function product(string $productKey): ?array
|
||||||
|
{
|
||||||
|
$product = config('crm_products.'.$productKey);
|
||||||
|
|
||||||
|
return is_array($product) ? $product : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPurchased(string $ownerRef, string $productKey): bool
|
||||||
|
{
|
||||||
|
return CrmPurchase::has($ownerRef, $productKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function priceMinor(string $productKey): int
|
||||||
|
{
|
||||||
|
return (int) ($this->product($productKey)['price_minor'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purchase(string $ownerRef, string $productKey): bool
|
||||||
|
{
|
||||||
|
if ($this->hasPurchased($ownerRef, $productKey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product = $this->product($productKey);
|
||||||
|
if (! $product) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$costMinor = (int) ($product['price_minor'] ?? 0);
|
||||||
|
|
||||||
|
if ($costMinor > 0) {
|
||||||
|
try {
|
||||||
|
if (! $this->billing->canAfford($ownerRef, $costMinor)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$charged = $this->billing->debit(
|
||||||
|
$ownerRef,
|
||||||
|
$costMinor,
|
||||||
|
'purchase',
|
||||||
|
'crm-purchase-'.$productKey.'-'.$ownerRef,
|
||||||
|
'CRM: '.($product['name'] ?? $productKey),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $charged) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CrmPurchase::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'product_key' => $productKey,
|
||||||
|
'cost_minor' => $costMinor,
|
||||||
|
'purchased_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Comms;
|
||||||
|
|
||||||
|
use App\Mail\ContactMessageMail;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound email to contacts. Uses the app's configured mailer (set MAIL_* to
|
||||||
|
* a Ladill Bird SMTP credential in production so mail leaves as the account's
|
||||||
|
* sender). Failures are swallowed + logged; the caller decides what to record.
|
||||||
|
*/
|
||||||
|
class EmailService
|
||||||
|
{
|
||||||
|
public function send(string $to, string $subject, string $body, ?string $fromName = null, ?string $replyTo = null): bool
|
||||||
|
{
|
||||||
|
if (! filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Mail::to($to)->send(new ContactMessageMail($subject, $body, $fromName, $replyTo));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('CRM email send failed', ['to' => $to, 'error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Comms;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound SMS to contacts via Arkesel (the platform SMS provider). Low-volume,
|
||||||
|
* best-effort transactional send — mirrors the platform's direct Arkesel calls;
|
||||||
|
* uses the shared ARKESEL_API_KEY. Failures are swallowed + logged.
|
||||||
|
*/
|
||||||
|
class SmsService
|
||||||
|
{
|
||||||
|
public function send(string $to, string $message): bool
|
||||||
|
{
|
||||||
|
$apiKey = (string) config('arkesel.api_key', '');
|
||||||
|
$sender = (string) config('arkesel.sender_id', 'Ladill');
|
||||||
|
|
||||||
|
$msisdn = $this->normalise($to);
|
||||||
|
if ($apiKey === '' || $msisdn === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$res = Http::timeout(20)
|
||||||
|
->withHeaders(['api-key' => $apiKey])
|
||||||
|
->acceptJson()
|
||||||
|
->post(rtrim((string) config('arkesel.base_url', 'https://sms.arkesel.com'), '/').'/api/v2/sms/send', [
|
||||||
|
'sender' => $sender,
|
||||||
|
'message' => $message,
|
||||||
|
'recipients' => [$msisdn],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $res->successful() && ($res->json('status') === 'success');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('CRM SMS send failed', ['to' => $msisdn, 'error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise to E.164-without-plus (e.g. 233XXXXXXXXX). */
|
||||||
|
private function normalise(string $to): ?string
|
||||||
|
{
|
||||||
|
$digits = preg_replace('/\D+/', '', $to) ?? '';
|
||||||
|
$country = (string) config('arkesel.default_country_code', '233');
|
||||||
|
|
||||||
|
if (strlen($digits) < 9) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($digits, $country)) {
|
||||||
|
return $digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($digits, '0')) {
|
||||||
|
return $country.substr($digits, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($digits) === 9) {
|
||||||
|
return $country.$digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $digits;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\CrossApp;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\Deal;
|
||||||
|
use App\Support\CrmPrefillCodec;
|
||||||
|
use App\Support\LadillAppUrl;
|
||||||
|
|
||||||
|
class CrossAppLinkService
|
||||||
|
{
|
||||||
|
public function invoiceFromDeal(Deal $deal): string
|
||||||
|
{
|
||||||
|
$deal->loadMissing(['customer', 'lines']);
|
||||||
|
|
||||||
|
$customer = $deal->customer;
|
||||||
|
$lines = $deal->lines->map(fn ($line) => [
|
||||||
|
'description' => $line->description,
|
||||||
|
'quantity' => (float) $line->quantity,
|
||||||
|
'unit_price' => number_format($line->unit_price_minor / 100, 2, '.', ''),
|
||||||
|
])->values()->all();
|
||||||
|
|
||||||
|
if ($lines === [] && (int) $deal->value_minor > 0) {
|
||||||
|
$lines = [[
|
||||||
|
'description' => $deal->title,
|
||||||
|
'quantity' => 1,
|
||||||
|
'unit_price' => number_format($deal->value_minor / 100, 2, '.', ''),
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefill = CrmPrefillCodec::encode([
|
||||||
|
'kind' => 'invoice',
|
||||||
|
'crm_customer_id' => $customer?->id,
|
||||||
|
'client_name' => $customer?->name ?: $deal->title,
|
||||||
|
'client_email' => $customer?->email,
|
||||||
|
'client_address' => $customer ? $this->formatAddress($customer) : null,
|
||||||
|
'notes' => 'Invoice for CRM deal: '.$deal->title,
|
||||||
|
'payment_enabled' => true,
|
||||||
|
'lines' => $lines,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return LadillAppUrl::connect('invoice', '/invoices/create?prefill='.urlencode($prefill));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function merchantStorefrontFromDeal(Deal $deal): string
|
||||||
|
{
|
||||||
|
$deal->loadMissing('customer');
|
||||||
|
|
||||||
|
$amount = number_format(((int) $deal->value_minor) / 100, 2, '.', '');
|
||||||
|
$itemName = $deal->title;
|
||||||
|
|
||||||
|
$prefill = CrmPrefillCodec::encode([
|
||||||
|
'kind' => 'merchant_storefront',
|
||||||
|
'type' => 'shop',
|
||||||
|
'label' => 'Payment — '.$deal->title,
|
||||||
|
'shop_title' => $deal->customer?->company ?: $deal->title,
|
||||||
|
'sections' => [[
|
||||||
|
'name' => 'Payment',
|
||||||
|
'items' => [[
|
||||||
|
'name' => $itemName,
|
||||||
|
'description' => $deal->notes ?: 'Payment for '.$deal->title,
|
||||||
|
'price' => $amount,
|
||||||
|
'image_path' => '',
|
||||||
|
]],
|
||||||
|
]],
|
||||||
|
'accepts_payment' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return LadillAppUrl::connect('merchant', '/storefronts/create?prefill='.urlencode($prefill));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function businessQrFromContact(Customer $contact): string
|
||||||
|
{
|
||||||
|
$prefill = CrmPrefillCodec::encode([
|
||||||
|
'kind' => 'qr_business',
|
||||||
|
'type' => 'business',
|
||||||
|
'label' => 'Card — '.$contact->name,
|
||||||
|
'name' => $contact->company ?: $contact->name,
|
||||||
|
'phone' => $contact->phone,
|
||||||
|
'email' => $contact->email,
|
||||||
|
'address' => $this->formatAddress($contact, singleLine: true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return LadillAppUrl::connect('qrplus', '/qr-codes/create?prefill='.urlencode($prefill));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function eventsHubForContact(Customer $contact): string
|
||||||
|
{
|
||||||
|
$query = http_build_query(array_filter([
|
||||||
|
'search' => $contact->company ?: $contact->name,
|
||||||
|
]));
|
||||||
|
|
||||||
|
return LadillAppUrl::connect('events', '/attendees'.($query !== '' ? '?'.$query : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatAddress(Customer $contact, bool $singleLine = false): ?string
|
||||||
|
{
|
||||||
|
$parts = array_filter([
|
||||||
|
$contact->address_line1,
|
||||||
|
$contact->address_line2,
|
||||||
|
trim(implode(', ', array_filter([$contact->city, $contact->region, $contact->country]))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($parts === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $singleLine
|
||||||
|
? implode(', ', $parts)
|
||||||
|
: implode("\n", $parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Events;
|
||||||
|
|
||||||
|
class ServiceEventSignature
|
||||||
|
{
|
||||||
|
public static function sign(string $body, string $secret): string
|
||||||
|
{
|
||||||
|
return 'sha256='.hash_hmac('sha256', $body, $secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verify(string $body, string $signature, string $secret): bool
|
||||||
|
{
|
||||||
|
if ($secret === '' || $signature === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash_equals(self::sign($body, $secret), $signature);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\CustomerFeedback;
|
||||||
|
use App\Models\QueueAppointment;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class AnalyticsService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected DashboardStats $dashboard,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function overview(string $ownerRef, int $organizationId, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$stats = $this->dashboard->forOrganization($ownerRef, $organizationId, $branchId);
|
||||||
|
|
||||||
|
$today = Ticket::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereDate('issued_at', today());
|
||||||
|
|
||||||
|
$bySource = (clone $today)
|
||||||
|
->selectRaw('source, COUNT(*) as total')
|
||||||
|
->groupBy('source')
|
||||||
|
->pluck('total', 'source')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$byPriority = (clone $today)
|
||||||
|
->selectRaw('priority, COUNT(*) as total')
|
||||||
|
->groupBy('priority')
|
||||||
|
->pluck('total', 'priority')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$appointmentsToday = QueueAppointment::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereDate('scheduled_at', today())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$feedbackAvg = CustomerFeedback::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->whereHas('ticket', fn ($t) => $t->where('branch_id', $branchId)))
|
||||||
|
->where('created_at', '>=', now()->subDays(30))
|
||||||
|
->avg('rating');
|
||||||
|
|
||||||
|
$queueLoad = ServiceQueue::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->where('is_active', true)
|
||||||
|
->withCount(['tickets as waiting_count' => fn ($q) => $q->where('status', 'waiting')])
|
||||||
|
->orderByDesc('waiting_count')
|
||||||
|
->limit(5)
|
||||||
|
->get()
|
||||||
|
->map(fn ($q) => ['name' => $q->name, 'waiting' => $q->waiting_count])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return array_merge($stats, [
|
||||||
|
'tickets_by_source' => $bySource,
|
||||||
|
'tickets_by_priority' => $byPriority,
|
||||||
|
'appointments_today' => $appointmentsToday,
|
||||||
|
'feedback_avg_30d' => round((float) $feedbackAvg, 1),
|
||||||
|
'busiest_queues' => $queueLoad,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{date: string, issued: int, completed: int}>
|
||||||
|
*/
|
||||||
|
public function dailyTrend(string $ownerRef, int $organizationId, int $days = 14, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$from = now()->subDays($days - 1)->startOfDay();
|
||||||
|
|
||||||
|
$issued = Ticket::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->where('issued_at', '>=', $from)
|
||||||
|
->selectRaw('DATE(issued_at) as d, COUNT(*) as c')
|
||||||
|
->groupBy('d')
|
||||||
|
->pluck('c', 'd');
|
||||||
|
|
||||||
|
$completed = Ticket::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->where('status', 'completed')
|
||||||
|
->where('completed_at', '>=', $from)
|
||||||
|
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
||||||
|
->groupBy('d')
|
||||||
|
->pluck('c', 'd');
|
||||||
|
|
||||||
|
$trend = [];
|
||||||
|
for ($i = 0; $i < $days; $i++) {
|
||||||
|
$date = $from->copy()->addDays($i)->toDateString();
|
||||||
|
$trend[] = [
|
||||||
|
'date' => $date,
|
||||||
|
'issued' => (int) ($issued[$date] ?? 0),
|
||||||
|
'completed' => (int) ($completed[$date] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $trend;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\QueueAppointment;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
|
||||||
|
class AppointmentService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected QueueEngine $engine,
|
||||||
|
protected QueueNotificationService $notifications,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function checkIn(QueueAppointment $appointment, ?string $actorRef = null): Ticket
|
||||||
|
{
|
||||||
|
abort_unless($appointment->status === 'scheduled', 422, 'Appointment cannot be checked in.');
|
||||||
|
|
||||||
|
$queue = $appointment->serviceQueue ?? $this->defaultQueue($appointment);
|
||||||
|
abort_unless($queue, 422, 'No queue configured for this appointment.');
|
||||||
|
|
||||||
|
$ticket = $this->engine->issueTicket($queue, $appointment->owner_ref, [
|
||||||
|
'customer_name' => $appointment->customer_name,
|
||||||
|
'customer_phone' => $appointment->customer_phone,
|
||||||
|
'customer_email' => $appointment->customer_email,
|
||||||
|
'priority' => 'appointment',
|
||||||
|
'source' => 'appointment',
|
||||||
|
'actor_ref' => $actorRef,
|
||||||
|
'metadata' => ['appointment_id' => $appointment->id],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$appointment->update([
|
||||||
|
'status' => 'checked_in',
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'service_queue_id' => $queue->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($appointment->owner_ref, 'appointment.checked_in', $appointment->organization_id, $actorRef, QueueAppointment::class, $appointment->id);
|
||||||
|
|
||||||
|
$org = $appointment->organization;
|
||||||
|
if ($org) {
|
||||||
|
app(StaffNotifier::class)->queueEvent(
|
||||||
|
$org,
|
||||||
|
'Appointment checked in',
|
||||||
|
"{$appointment->customer_name} checked in for {$appointment->scheduled_at->format('H:i')}.",
|
||||||
|
route('qms.tickets.show', $ticket),
|
||||||
|
'calendar',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defaultQueue(QueueAppointment $appointment): ?ServiceQueue
|
||||||
|
{
|
||||||
|
return ServiceQueue::owned($appointment->owner_ref)
|
||||||
|
->where('branch_id', $appointment->branch_id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Member;
|
||||||
|
|
||||||
|
class AuditLogger
|
||||||
|
{
|
||||||
|
public static function record(
|
||||||
|
string $ownerRef,
|
||||||
|
string $action,
|
||||||
|
?int $organizationId = null,
|
||||||
|
?string $actorRef = null,
|
||||||
|
?string $subjectType = null,
|
||||||
|
?int $subjectId = null,
|
||||||
|
?array $metadata = null,
|
||||||
|
): AuditLog {
|
||||||
|
return AuditLog::record(
|
||||||
|
$ownerRef,
|
||||||
|
$action,
|
||||||
|
$organizationId,
|
||||||
|
$actorRef,
|
||||||
|
$subjectType,
|
||||||
|
$subjectId,
|
||||||
|
$metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Support\DatabaseTime;
|
||||||
|
|
||||||
|
class DashboardStats
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, int|float>
|
||||||
|
*/
|
||||||
|
public function forOrganization(string $ownerRef, int $organizationId, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$ticketQuery = Ticket::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId));
|
||||||
|
|
||||||
|
$todayQuery = (clone $ticketQuery)->whereDate('issued_at', today());
|
||||||
|
|
||||||
|
$waiting = (clone $ticketQuery)->where('status', 'waiting')->count();
|
||||||
|
$serving = (clone $ticketQuery)->whereIn('status', ['called', 'serving'])->count();
|
||||||
|
$servedToday = (clone $todayQuery)->where('status', 'completed')->count();
|
||||||
|
$noShowsToday = (clone $todayQuery)->where('status', 'no_show')->count();
|
||||||
|
|
||||||
|
$waitDiff = DatabaseTime::diffSeconds('issued_at', 'called_at');
|
||||||
|
$avgWait = (clone $todayQuery)
|
||||||
|
->where('status', 'completed')
|
||||||
|
->whereNotNull('called_at')
|
||||||
|
->selectRaw("AVG({$waitDiff}) as avg_wait")
|
||||||
|
->value('avg_wait');
|
||||||
|
|
||||||
|
$activeBranches = Branch::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->where('is_active', true)
|
||||||
|
->when($branchId, fn ($q) => $q->where('id', $branchId))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'waiting' => $waiting,
|
||||||
|
'serving' => $serving,
|
||||||
|
'served_today' => $servedToday,
|
||||||
|
'no_shows_today' => $noShowsToday,
|
||||||
|
'avg_wait_seconds' => (int) round((float) $avgWait),
|
||||||
|
'active_branches' => $activeBranches,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class DeviceService
|
||||||
|
{
|
||||||
|
public function findByToken(string $token): ?Device
|
||||||
|
{
|
||||||
|
return Device::query()
|
||||||
|
->where('device_token', $token)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordHeartbeat(Device $device): void
|
||||||
|
{
|
||||||
|
$device->update([
|
||||||
|
'status' => 'online',
|
||||||
|
'last_online_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markStaleOffline(int $minutes = 10): int
|
||||||
|
{
|
||||||
|
return Device::query()
|
||||||
|
->where('status', 'online')
|
||||||
|
->where(function ($q) use ($minutes) {
|
||||||
|
$q->whereNull('last_online_at')
|
||||||
|
->orWhere('last_online_at', '<', now()->subMinutes($minutes));
|
||||||
|
})
|
||||||
|
->update(['status' => 'offline']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateToken(Device $device): string
|
||||||
|
{
|
||||||
|
$token = Str::random(48);
|
||||||
|
$device->update(['device_token' => $token]);
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\DisplayScreen;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
|
||||||
|
class DisplayService
|
||||||
|
{
|
||||||
|
public function findByToken(string $token): ?DisplayScreen
|
||||||
|
{
|
||||||
|
return DisplayScreen::query()
|
||||||
|
->where('access_token', $token)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function touch(DisplayScreen $screen): void
|
||||||
|
{
|
||||||
|
$screen->update(['last_seen_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function payload(DisplayScreen $screen): array
|
||||||
|
{
|
||||||
|
$queueIds = $screen->service_queue_ids ?? [];
|
||||||
|
$queues = ServiceQueue::query()
|
||||||
|
->whereIn('id', $queueIds)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$nowServing = Ticket::query()
|
||||||
|
->whereIn('service_queue_id', $queueIds)
|
||||||
|
->whereIn('status', ['called', 'serving'])
|
||||||
|
->with(['counter', 'serviceQueue'])
|
||||||
|
->orderByDesc('called_at')
|
||||||
|
->limit(5)
|
||||||
|
->get()
|
||||||
|
->map(fn (Ticket $t) => [
|
||||||
|
'ticket_number' => $t->ticket_number,
|
||||||
|
'queue_name' => $t->serviceQueue?->name,
|
||||||
|
'counter' => $t->counter?->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$waiting = Ticket::query()
|
||||||
|
->whereIn('service_queue_id', $queueIds)
|
||||||
|
->where('status', 'waiting')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$avgWait = (int) Ticket::query()
|
||||||
|
->whereIn('service_queue_id', $queueIds)
|
||||||
|
->where('status', 'completed')
|
||||||
|
->whereDate('issued_at', today())
|
||||||
|
->whereNotNull('called_at')
|
||||||
|
->selectRaw('AVG(TIMESTAMPDIFF(SECOND, issued_at, called_at)) as avg_wait')
|
||||||
|
->value('avg_wait');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'screen' => [
|
||||||
|
'name' => $screen->name,
|
||||||
|
'layout' => $screen->layout,
|
||||||
|
],
|
||||||
|
'now_serving' => $nowServing,
|
||||||
|
'waiting_count' => $waiting,
|
||||||
|
'estimated_wait_minutes' => $avgWait > 0 ? (int) ceil($avgWait / 60) : null,
|
||||||
|
'queues' => $queues->map(fn (ServiceQueue $q) => [
|
||||||
|
'name' => $q->name,
|
||||||
|
'prefix' => $q->prefix,
|
||||||
|
'is_paused' => $q->is_paused,
|
||||||
|
]),
|
||||||
|
'announcements' => app(VoiceAnnouncementService::class)->pendingForBranch((int) $screen->branch_id),
|
||||||
|
'updated_at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Department;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class OrganizationResolver
|
||||||
|
{
|
||||||
|
public function resolveForUser(User $user): ?Organization
|
||||||
|
{
|
||||||
|
$ref = $user->ownerRef();
|
||||||
|
|
||||||
|
$member = Member::where('user_ref', $ref)->first();
|
||||||
|
if ($member) {
|
||||||
|
return Organization::find($member->organization_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Organization::owned($ref)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forUser(User $user): Organization
|
||||||
|
{
|
||||||
|
return $this->resolveForUser($user)
|
||||||
|
?? throw new \RuntimeException('No organization for user.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isOnboarded(User $user): bool
|
||||||
|
{
|
||||||
|
$organization = $this->resolveForUser($user);
|
||||||
|
|
||||||
|
return $organization !== null
|
||||||
|
&& (bool) data_get($organization->settings, 'onboarded', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function memberFor(User $user, ?Organization $organization = null): ?Member
|
||||||
|
{
|
||||||
|
$organization ??= $this->resolveForUser($user);
|
||||||
|
if (! $organization) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Member::where('organization_id', $organization->id)
|
||||||
|
->where('user_ref', $user->ownerRef())
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensureOwnerMember(User $user, Organization $organization): Member
|
||||||
|
{
|
||||||
|
return Member::firstOrCreate(
|
||||||
|
[
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'user_ref' => $user->ownerRef(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'owner_ref' => $user->ownerRef(),
|
||||||
|
'role' => 'org_admin',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function completeOnboarding(User $user, array $data): Organization
|
||||||
|
{
|
||||||
|
$ref = $user->ownerRef();
|
||||||
|
|
||||||
|
$organization = Organization::create([
|
||||||
|
'owner_ref' => $ref,
|
||||||
|
'name' => $data['organization_name'],
|
||||||
|
'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6),
|
||||||
|
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'industry' => $data['industry'] ?? 'custom',
|
||||||
|
'appointment_mode' => $data['appointment_mode'] ?? 'hybrid',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->ensureOwnerMember($user, $organization);
|
||||||
|
|
||||||
|
$branch = Branch::create([
|
||||||
|
'owner_ref' => $ref,
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'name' => $data['branch_name'],
|
||||||
|
'address' => $data['branch_address'] ?? null,
|
||||||
|
'phone' => $data['branch_phone'] ?? null,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Department::create([
|
||||||
|
'owner_ref' => $ref,
|
||||||
|
'branch_id' => $branch->id,
|
||||||
|
'name' => 'General Reception',
|
||||||
|
'type' => 'reception',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ref, 'organization.created', $organization->id, $ref, Organization::class, $organization->id);
|
||||||
|
AuditLogger::record($ref, 'branch.created', $organization->id, $ref, Branch::class, $branch->id);
|
||||||
|
|
||||||
|
return $organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branchScope(?Member $member): ?int
|
||||||
|
{
|
||||||
|
if ($member === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($member->role, ['super_admin', 'org_admin', 'supervisor'], true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $member->branch_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
|
||||||
|
class PlanService
|
||||||
|
{
|
||||||
|
public function plan(Organization $organization): string
|
||||||
|
{
|
||||||
|
return (string) data_get($organization->settings, 'plan', 'free');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPro(Organization $organization): bool
|
||||||
|
{
|
||||||
|
return $this->plan($organization) === 'pro';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maxBranches(Organization $organization): ?int
|
||||||
|
{
|
||||||
|
return config('qms.plans.'.$this->plan($organization).'.max_branches');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maxQueues(Organization $organization): ?int
|
||||||
|
{
|
||||||
|
return config('qms.plans.'.$this->plan($organization).'.max_queues');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAddBranch(Organization $organization, int $currentCount): bool
|
||||||
|
{
|
||||||
|
$max = $this->maxBranches($organization);
|
||||||
|
|
||||||
|
return $max === null || $currentCount < $max;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAddQueue(Organization $organization, int $currentCount): bool
|
||||||
|
{
|
||||||
|
$max = $this->maxQueues($organization);
|
||||||
|
|
||||||
|
return $max === null || $currentCount < $max;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
|
||||||
|
class QmsPermissions
|
||||||
|
{
|
||||||
|
/** @var array<string, list<string>> */
|
||||||
|
protected array $roleAbilities = [
|
||||||
|
'super_admin' => ['*'],
|
||||||
|
'org_admin' => ['*'],
|
||||||
|
'branch_manager' => [
|
||||||
|
'dashboard.view', 'queues.view', 'queues.manage', 'tickets.view', 'tickets.manage',
|
||||||
|
'counters.view', 'counters.manage', 'console.use', 'displays.view', 'displays.manage',
|
||||||
|
'appointments.view', 'appointments.manage', 'workflows.view', 'workflows.manage',
|
||||||
|
'rules.view', 'rules.manage', 'reports.view', 'reports.export', 'feedback.view',
|
||||||
|
'admin.branches.view', 'admin.branches.manage', 'admin.departments.view', 'admin.departments.manage',
|
||||||
|
'admin.members.view', 'admin.members.manage', 'settings.view', 'settings.manage', 'audit.view', 'audit.export',
|
||||||
|
],
|
||||||
|
'receptionist' => [
|
||||||
|
'dashboard.view', 'queues.view', 'tickets.view', 'tickets.issue',
|
||||||
|
'appointments.view', 'appointments.manage',
|
||||||
|
],
|
||||||
|
'queue_operator' => [
|
||||||
|
'dashboard.view', 'queues.view', 'queues.manage', 'tickets.view', 'tickets.manage',
|
||||||
|
'console.use', 'displays.view',
|
||||||
|
],
|
||||||
|
'counter_staff' => [
|
||||||
|
'dashboard.view', 'queues.view', 'tickets.view', 'console.use',
|
||||||
|
],
|
||||||
|
'department_manager' => [
|
||||||
|
'dashboard.view', 'queues.view', 'queues.manage', 'tickets.view', 'tickets.manage',
|
||||||
|
'counters.view', 'console.use', 'reports.view',
|
||||||
|
],
|
||||||
|
'security' => [
|
||||||
|
'dashboard.view', 'tickets.view',
|
||||||
|
],
|
||||||
|
'supervisor' => [
|
||||||
|
'dashboard.view', 'queues.view', 'tickets.view', 'tickets.manage',
|
||||||
|
'counters.view', 'console.use', 'reports.view', 'reports.export',
|
||||||
|
'feedback.view', 'audit.view', 'audit.export',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function can(?Member $member, string $ability): bool
|
||||||
|
{
|
||||||
|
if ($member === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$abilities = $this->roleAbilities[$member->role] ?? [];
|
||||||
|
|
||||||
|
if (in_array('*', $abilities, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($ability, $abilities, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAdmin(?Member $member): bool
|
||||||
|
{
|
||||||
|
return $member !== null && in_array($member->role, ['super_admin', 'org_admin'], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\Counter;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketEvent;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class QueueEngine
|
||||||
|
{
|
||||||
|
public function issueTicket(
|
||||||
|
ServiceQueue $queue,
|
||||||
|
string $ownerRef,
|
||||||
|
array $attributes = [],
|
||||||
|
): Ticket {
|
||||||
|
return DB::transaction(function () use ($queue, $ownerRef, $attributes) {
|
||||||
|
$queue = ServiceQueue::query()->lockForUpdate()->findOrFail($queue->id);
|
||||||
|
|
||||||
|
if ($queue->is_paused || ! $queue->is_active) {
|
||||||
|
throw new \RuntimeException('Queue is not accepting tickets.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceQueueId = $queue->id;
|
||||||
|
[$queue, $attributes] = app(QueueRuleService::class)->apply($queue, $attributes);
|
||||||
|
app(WorkflowService::class)->attachOnIssue($queue, $attributes);
|
||||||
|
|
||||||
|
if ($queue->id !== $sourceQueueId) {
|
||||||
|
$queue = ServiceQueue::query()->lockForUpdate()->findOrFail($queue->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($queue->is_paused || ! $queue->is_active) {
|
||||||
|
throw new \RuntimeException('Queue is not accepting tickets.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($queue->max_capacity) {
|
||||||
|
$waiting = Ticket::query()
|
||||||
|
->where('service_queue_id', $queue->id)
|
||||||
|
->whereIn('status', ['waiting', 'called', 'serving', 'on_hold'])
|
||||||
|
->count();
|
||||||
|
if ($waiting >= $queue->max_capacity) {
|
||||||
|
throw new \RuntimeException('Queue has reached maximum capacity.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$queue->increment('ticket_sequence');
|
||||||
|
$queue->refresh();
|
||||||
|
|
||||||
|
$number = sprintf('%s%03d', $queue->prefix, $queue->ticket_sequence);
|
||||||
|
$waitingCount = Ticket::query()
|
||||||
|
->where('service_queue_id', $queue->id)
|
||||||
|
->where('status', 'waiting')
|
||||||
|
->count();
|
||||||
|
$estimatedWait = $waitingCount * (int) $queue->avg_service_seconds;
|
||||||
|
|
||||||
|
$ticket = Ticket::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $queue->organization_id,
|
||||||
|
'branch_id' => $queue->branch_id,
|
||||||
|
'service_queue_id' => $queue->id,
|
||||||
|
'ticket_number' => $number,
|
||||||
|
'status' => 'waiting',
|
||||||
|
'priority' => $attributes['priority'] ?? 'walk_in',
|
||||||
|
'position' => $waitingCount + 1,
|
||||||
|
'customer_name' => $attributes['customer_name'] ?? null,
|
||||||
|
'customer_phone' => $attributes['customer_phone'] ?? null,
|
||||||
|
'customer_email' => $attributes['customer_email'] ?? null,
|
||||||
|
'source' => $attributes['source'] ?? 'kiosk',
|
||||||
|
'barcode' => $attributes['barcode'] ?? Str::upper(Str::random(10)),
|
||||||
|
'estimated_wait_seconds' => $estimatedWait,
|
||||||
|
'issued_at' => now(),
|
||||||
|
'metadata' => $attributes['metadata'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->recordEvent($ticket, 'issued', $attributes['actor_ref'] ?? null);
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$ownerRef,
|
||||||
|
'ticket.issued',
|
||||||
|
$queue->organization_id,
|
||||||
|
$attributes['actor_ref'] ?? null,
|
||||||
|
Ticket::class,
|
||||||
|
$ticket->id,
|
||||||
|
['ticket_number' => $number, 'queue' => $queue->name],
|
||||||
|
);
|
||||||
|
|
||||||
|
$ticket = $ticket->fresh(['serviceQueue', 'branch']);
|
||||||
|
app(QueueNotificationService::class)->ticketIssued($ticket);
|
||||||
|
|
||||||
|
return $ticket;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callNext(ServiceQueue $queue, Counter $counter, ?string $actorRef = null): ?Ticket
|
||||||
|
{
|
||||||
|
$ticket = $this->nextWaitingTicket($queue);
|
||||||
|
if (! $ticket) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->callTicket($ticket, $counter, $actorRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callTicket(Ticket $ticket, Counter $counter, ?string $actorRef = null): Ticket
|
||||||
|
{
|
||||||
|
$ticket->update([
|
||||||
|
'status' => 'called',
|
||||||
|
'counter_id' => $counter->id,
|
||||||
|
'called_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$counter->update(['status' => 'busy']);
|
||||||
|
$this->recordEvent($ticket, 'called', $actorRef, $counter->id);
|
||||||
|
$this->audit($ticket, 'ticket.called', $actorRef, ['counter_id' => $counter->id]);
|
||||||
|
|
||||||
|
app(VoiceAnnouncementService::class)->announceCalled($ticket, $counter);
|
||||||
|
|
||||||
|
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
|
||||||
|
app(QueueNotificationService::class)->ticketCalled($ticket);
|
||||||
|
$this->notifyWaitingPositions($ticket->serviceQueue);
|
||||||
|
|
||||||
|
return $ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recall(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||||
|
{
|
||||||
|
$ticket->load(['serviceQueue', 'counter']);
|
||||||
|
$this->recordEvent($ticket, 'recalled', $actorRef, $ticket->counter_id);
|
||||||
|
$this->audit($ticket, 'ticket.recalled', $actorRef);
|
||||||
|
|
||||||
|
if ($ticket->counter) {
|
||||||
|
app(VoiceAnnouncementService::class)->announceCalled($ticket, $ticket->counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ticket->fresh(['serviceQueue', 'counter']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startServing(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||||
|
{
|
||||||
|
$ticket->update([
|
||||||
|
'status' => 'serving',
|
||||||
|
'serving_started_at' => now(),
|
||||||
|
]);
|
||||||
|
$this->recordEvent($ticket, 'serving', $actorRef, $ticket->counter_id);
|
||||||
|
app(ServiceSessionTracker::class)->start($ticket, $actorRef);
|
||||||
|
|
||||||
|
return $ticket->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hold(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||||
|
{
|
||||||
|
$ticket->update(['status' => 'on_hold']);
|
||||||
|
$this->recordEvent($ticket, 'held', $actorRef, $ticket->counter_id);
|
||||||
|
$this->audit($ticket, 'ticket.held', $actorRef);
|
||||||
|
|
||||||
|
return $ticket->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function skip(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||||
|
{
|
||||||
|
$ticket->update([
|
||||||
|
'status' => 'waiting',
|
||||||
|
'counter_id' => null,
|
||||||
|
'called_at' => null,
|
||||||
|
]);
|
||||||
|
$this->recordEvent($ticket, 'skipped', $actorRef);
|
||||||
|
$this->audit($ticket, 'ticket.skipped', $actorRef);
|
||||||
|
|
||||||
|
return $ticket->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function completeTicket(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||||
|
{
|
||||||
|
$ticket->update([
|
||||||
|
'status' => 'completed',
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($ticket->counter_id) {
|
||||||
|
Counter::where('id', $ticket->counter_id)->update(['status' => 'available']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->recordEvent($ticket, 'completed', $actorRef, $ticket->counter_id);
|
||||||
|
$this->audit($ticket, 'ticket.completed', $actorRef);
|
||||||
|
app(ServiceSessionTracker::class)->endForTicket($ticket);
|
||||||
|
|
||||||
|
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
|
||||||
|
app(QueueNotificationService::class)->ticketCompleted($ticket);
|
||||||
|
app(WorkflowService::class)->advanceAfterCompletion($ticket);
|
||||||
|
|
||||||
|
return $ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markNoShow(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||||
|
{
|
||||||
|
$ticket->update([
|
||||||
|
'status' => 'no_show',
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($ticket->counter_id) {
|
||||||
|
Counter::where('id', $ticket->counter_id)->update(['status' => 'available']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->recordEvent($ticket, 'no_show', $actorRef, $ticket->counter_id);
|
||||||
|
$this->audit($ticket, 'ticket.no_show', $actorRef);
|
||||||
|
|
||||||
|
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
|
||||||
|
app(QueueNotificationService::class)->ticketMissed($ticket);
|
||||||
|
|
||||||
|
return $ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||||
|
{
|
||||||
|
$ticket->update(['status' => 'cancelled', 'completed_at' => now()]);
|
||||||
|
$this->recordEvent($ticket, 'cancelled', $actorRef, $ticket->counter_id);
|
||||||
|
$this->audit($ticket, 'ticket.cancelled', $actorRef);
|
||||||
|
|
||||||
|
return $ticket->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transfer(
|
||||||
|
Ticket $ticket,
|
||||||
|
ServiceQueue $toQueue,
|
||||||
|
?Counter $counter = null,
|
||||||
|
?string $reason = null,
|
||||||
|
?string $actorRef = null,
|
||||||
|
): Ticket {
|
||||||
|
$fromQueueId = $ticket->service_queue_id;
|
||||||
|
|
||||||
|
$ticket->update([
|
||||||
|
'service_queue_id' => $toQueue->id,
|
||||||
|
'branch_id' => $toQueue->branch_id,
|
||||||
|
'status' => 'waiting',
|
||||||
|
'counter_id' => null,
|
||||||
|
'called_at' => null,
|
||||||
|
'serving_started_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('queue_ticket_transfers')->insert([
|
||||||
|
'owner_ref' => $ticket->owner_ref,
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'from_service_queue_id' => $fromQueueId,
|
||||||
|
'to_service_queue_id' => $toQueue->id,
|
||||||
|
'from_counter_id' => $counter?->id,
|
||||||
|
'reason' => $reason,
|
||||||
|
'transferred_by' => $actorRef,
|
||||||
|
'transferred_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->recordEvent($ticket, 'transferred', $actorRef, $counter?->id, [
|
||||||
|
'to_queue_id' => $toQueue->id,
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
|
$this->audit($ticket, 'ticket.transferred', $actorRef, ['to_queue' => $toQueue->name]);
|
||||||
|
|
||||||
|
return $ticket->fresh(['serviceQueue']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pauseQueue(ServiceQueue $queue, ?string $actorRef = null): ServiceQueue
|
||||||
|
{
|
||||||
|
$queue->update(['is_paused' => true]);
|
||||||
|
AuditLogger::record($queue->owner_ref, 'service_queue.paused', $queue->organization_id, $actorRef, ServiceQueue::class, $queue->id);
|
||||||
|
|
||||||
|
$org = Organization::find($queue->organization_id);
|
||||||
|
if ($org) {
|
||||||
|
app(StaffNotifier::class)->queueEvent(
|
||||||
|
$org,
|
||||||
|
'Queue paused',
|
||||||
|
"{$queue->name} is no longer accepting tickets.",
|
||||||
|
route('qms.queues.show', $queue),
|
||||||
|
'pause',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $queue->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resumeQueue(ServiceQueue $queue, ?string $actorRef = null): ServiceQueue
|
||||||
|
{
|
||||||
|
$queue->update(['is_paused' => false]);
|
||||||
|
AuditLogger::record($queue->owner_ref, 'service_queue.resumed', $queue->organization_id, $actorRef, ServiceQueue::class, $queue->id);
|
||||||
|
|
||||||
|
return $queue->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delayArrival(Ticket $ticket, int $minutes, ?string $actorRef = null): Ticket
|
||||||
|
{
|
||||||
|
$metadata = $ticket->metadata ?? [];
|
||||||
|
$metadata['delayed_until'] = now()->addMinutes($minutes)->toIso8601String();
|
||||||
|
$ticket->update(['metadata' => $metadata]);
|
||||||
|
$this->recordEvent($ticket, 'delayed', $actorRef, null, ['minutes' => $minutes]);
|
||||||
|
|
||||||
|
return $ticket->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Ticket>
|
||||||
|
*/
|
||||||
|
public function waitingTickets(ServiceQueue $queue, int $limit = 50): array
|
||||||
|
{
|
||||||
|
return Ticket::query()
|
||||||
|
->where('service_queue_id', $queue->id)
|
||||||
|
->where('status', 'waiting')
|
||||||
|
->orderByRaw($this->priorityOrderSql())
|
||||||
|
->orderBy('issued_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function nextWaitingTicket(ServiceQueue $queue): ?Ticket
|
||||||
|
{
|
||||||
|
return Ticket::query()
|
||||||
|
->where('service_queue_id', $queue->id)
|
||||||
|
->where('status', 'waiting')
|
||||||
|
->orderByRaw($this->priorityOrderSql())
|
||||||
|
->orderBy('issued_at')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function priorityOrderSql(): string
|
||||||
|
{
|
||||||
|
$priorityOrder = array_keys(config('qms.ticket_priorities'));
|
||||||
|
|
||||||
|
if (\Illuminate\Support\Facades\DB::connection()->getDriverName() === 'sqlite') {
|
||||||
|
$cases = collect($priorityOrder)
|
||||||
|
->map(fn ($p, $i) => "WHEN priority = '{$p}' THEN {$i}")
|
||||||
|
->implode(' ');
|
||||||
|
|
||||||
|
return "CASE {$cases} ELSE 99 END";
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'FIELD(priority, '.implode(',', array_map(fn ($p) => "'{$p}'", $priorityOrder)).')';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function notifyWaitingPositions(?ServiceQueue $queue): void
|
||||||
|
{
|
||||||
|
if (! $queue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notifications = app(QueueNotificationService::class);
|
||||||
|
foreach ($this->waitingTickets($queue, 20) as $position => $waitingTicket) {
|
||||||
|
$notifications->customersAhead($waitingTicket, $position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function audit(Ticket $ticket, string $action, ?string $actorRef, ?array $metadata = null): void
|
||||||
|
{
|
||||||
|
AuditLogger::record(
|
||||||
|
$ticket->owner_ref,
|
||||||
|
$action,
|
||||||
|
$ticket->organization_id,
|
||||||
|
$actorRef,
|
||||||
|
Ticket::class,
|
||||||
|
$ticket->id,
|
||||||
|
$metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $metadata
|
||||||
|
*/
|
||||||
|
protected function recordEvent(
|
||||||
|
Ticket $ticket,
|
||||||
|
string $event,
|
||||||
|
?string $actorRef = null,
|
||||||
|
?int $counterId = null,
|
||||||
|
?array $metadata = null,
|
||||||
|
): void {
|
||||||
|
TicketEvent::create([
|
||||||
|
'owner_ref' => $ticket->owner_ref,
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'event' => $event,
|
||||||
|
'actor_ref' => $actorRef,
|
||||||
|
'counter_id' => $counterId,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Services\Comms\EmailService;
|
||||||
|
use App\Services\Comms\SmsService;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class QueueNotificationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected SmsService $sms,
|
||||||
|
protected EmailService $email,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function ticketIssued(Ticket $ticket): void
|
||||||
|
{
|
||||||
|
$this->notify($ticket, 'issued', $this->message('issued', $ticket));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticketCalled(Ticket $ticket): void
|
||||||
|
{
|
||||||
|
$counter = $ticket->counter?->name ?? 'the counter';
|
||||||
|
$this->notify($ticket, 'called', $this->message('called', $ticket, [':counter' => $counter]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customersAhead(Ticket $ticket, int $ahead): void
|
||||||
|
{
|
||||||
|
if ($ahead > 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->notify($ticket, 'almost_ready', $this->message('almost_ready', $ticket, [':ahead' => (string) $ahead]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticketCompleted(Ticket $ticket): void
|
||||||
|
{
|
||||||
|
$feedbackUrl = route('qms.feedback.show', $ticket->qr_token);
|
||||||
|
$this->notify($ticket, 'completed', $this->message('completed', $ticket, [':feedback_url' => $feedbackUrl]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticketMissed(Ticket $ticket): void
|
||||||
|
{
|
||||||
|
$this->notify($ticket, 'missed', $this->message('missed', $ticket));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function appointmentReminder(\App\Models\QueueAppointment $appointment): void
|
||||||
|
{
|
||||||
|
$message = "Reminder: your appointment at {$appointment->scheduled_at->format('M j, H:i')} is coming up. Ref: {$appointment->reference}";
|
||||||
|
$this->sendToContact($appointment->customer_phone, $appointment->customer_email, $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function notify(Ticket $ticket, string $event, string $message): void
|
||||||
|
{
|
||||||
|
if (! data_get($ticket->serviceQueue?->settings, 'notifications_enabled', true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendToContact($ticket->customer_phone, $ticket->customer_email, $message);
|
||||||
|
|
||||||
|
Log::info('Queue notification sent', [
|
||||||
|
'event' => $event,
|
||||||
|
'ticket' => $ticket->ticket_number,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sendToContact(?string $phone, ?string $email, string $message): void
|
||||||
|
{
|
||||||
|
if ($phone) {
|
||||||
|
$this->sms->send($phone, $message);
|
||||||
|
}
|
||||||
|
if ($email) {
|
||||||
|
$this->email->send($email, 'Ladill Queue update', $message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $replace
|
||||||
|
*/
|
||||||
|
protected function message(string $key, Ticket $ticket, array $replace = []): string
|
||||||
|
{
|
||||||
|
$template = config("qms.notification_templates.{$key}", 'Queue update for ticket :ticket.');
|
||||||
|
$replace = array_merge([
|
||||||
|
':ticket' => $ticket->ticket_number,
|
||||||
|
':queue' => $ticket->serviceQueue?->name ?? 'Queue',
|
||||||
|
], $replace);
|
||||||
|
|
||||||
|
return str_replace(array_keys($replace), array_values($replace), $template);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\QueueRule;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
|
||||||
|
class QueueRuleService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Apply active rules and return possibly modified queue + attributes.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
* @return array{0: ServiceQueue, 1: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
public function apply(ServiceQueue $queue, array $attributes): array
|
||||||
|
{
|
||||||
|
$rules = QueueRule::owned($queue->owner_ref)
|
||||||
|
->where('service_queue_id', $queue->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($rules as $rule) {
|
||||||
|
match ($rule->rule_type) {
|
||||||
|
'priority_boost' => $this->applyPriorityBoost($rule, $attributes),
|
||||||
|
'overflow' => [$queue, $attributes] = $this->applyOverflow($queue, $rule, $attributes),
|
||||||
|
'routing' => [$queue, $attributes] = $this->applyRouting($queue, $rule, $attributes),
|
||||||
|
'capacity' => [$queue, $attributes] = $this->applyCapacity($queue, $rule, $attributes),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$queue, $attributes];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
protected function applyPriorityBoost(QueueRule $rule, array &$attributes): void
|
||||||
|
{
|
||||||
|
$boostTo = data_get($rule->actions, 'priority');
|
||||||
|
$when = data_get($rule->conditions, 'source');
|
||||||
|
|
||||||
|
if ($boostTo && (! $when || ($attributes['source'] ?? null) === $when)) {
|
||||||
|
$attributes['priority'] = $boostTo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
* @return array{0: ServiceQueue, 1: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
protected function applyOverflow(ServiceQueue $queue, QueueRule $rule, array $attributes): array
|
||||||
|
{
|
||||||
|
$threshold = (int) data_get($rule->conditions, 'waiting_threshold', 0);
|
||||||
|
$overflowQueueId = data_get($rule->actions, 'overflow_queue_id');
|
||||||
|
|
||||||
|
if (! $overflowQueueId || $threshold <= 0) {
|
||||||
|
return [$queue, $attributes];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->waitingCount($queue) >= $threshold) {
|
||||||
|
$overflow = $this->activeQueue($overflowQueueId);
|
||||||
|
if ($overflow) {
|
||||||
|
return [$overflow, $attributes];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$queue, $attributes];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
* @return array{0: ServiceQueue, 1: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
protected function applyRouting(ServiceQueue $queue, QueueRule $rule, array $attributes): array
|
||||||
|
{
|
||||||
|
$targetId = data_get($rule->actions, 'target_queue_id');
|
||||||
|
if (! $targetId) {
|
||||||
|
return [$queue, $attributes];
|
||||||
|
}
|
||||||
|
|
||||||
|
$whenSource = data_get($rule->conditions, 'source');
|
||||||
|
$whenPriority = data_get($rule->conditions, 'priority');
|
||||||
|
|
||||||
|
if ($whenSource && ($attributes['source'] ?? null) !== $whenSource) {
|
||||||
|
return [$queue, $attributes];
|
||||||
|
}
|
||||||
|
if ($whenPriority && ($attributes['priority'] ?? null) !== $whenPriority) {
|
||||||
|
return [$queue, $attributes];
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $this->activeQueue((int) $targetId);
|
||||||
|
|
||||||
|
return $target ? [$target, $attributes] : [$queue, $attributes];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
* @return array{0: ServiceQueue, 1: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
protected function applyCapacity(ServiceQueue $queue, QueueRule $rule, array $attributes): array
|
||||||
|
{
|
||||||
|
$maxWaiting = (int) data_get($rule->conditions, 'max_waiting', 0);
|
||||||
|
if ($maxWaiting <= 0) {
|
||||||
|
return [$queue, $attributes];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->waitingCount($queue) < $maxWaiting) {
|
||||||
|
return [$queue, $attributes];
|
||||||
|
}
|
||||||
|
|
||||||
|
$whenFull = data_get($rule->actions, 'when_full', 'reject');
|
||||||
|
if ($whenFull === 'reject') {
|
||||||
|
throw new \RuntimeException('Queue has reached the configured waiting limit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$overflowId = data_get($rule->actions, 'overflow_queue_id');
|
||||||
|
if ($overflowId) {
|
||||||
|
$overflow = $this->activeQueue((int) $overflowId);
|
||||||
|
if ($overflow) {
|
||||||
|
return [$overflow, $attributes];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException('Queue has reached the configured waiting limit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function waitingCount(ServiceQueue $queue): int
|
||||||
|
{
|
||||||
|
return Ticket::query()
|
||||||
|
->where('service_queue_id', $queue->id)
|
||||||
|
->where('status', 'waiting')
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function activeQueue(int $queueId): ?ServiceQueue
|
||||||
|
{
|
||||||
|
$target = ServiceQueue::find($queueId);
|
||||||
|
|
||||||
|
return ($target && $target->is_active && ! $target->is_paused) ? $target : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Counter;
|
||||||
|
use App\Models\CustomerFeedback;
|
||||||
|
use App\Models\QueueAppointment;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use App\Models\ServiceSession;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Support\DatabaseTime;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class ReportService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function ticketsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$base = Ticket::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('issued_at', [$from, $to]);
|
||||||
|
|
||||||
|
$waitDiff = DatabaseTime::diffSeconds('issued_at', 'called_at');
|
||||||
|
$serviceDiff = DatabaseTime::diffSeconds('serving_started_at', 'completed_at');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_issued' => (clone $base)->count(),
|
||||||
|
'completed' => (clone $base)->where('status', 'completed')->count(),
|
||||||
|
'no_show' => (clone $base)->where('status', 'no_show')->count(),
|
||||||
|
'cancelled' => (clone $base)->where('status', 'cancelled')->count(),
|
||||||
|
'avg_wait_seconds' => (int) round((float) (clone $base)
|
||||||
|
->where('status', 'completed')
|
||||||
|
->whereNotNull('called_at')
|
||||||
|
->selectRaw("AVG({$waitDiff}) as v")
|
||||||
|
->value('v')),
|
||||||
|
'avg_service_seconds' => (int) round((float) (clone $base)
|
||||||
|
->where('status', 'completed')
|
||||||
|
->whereNotNull('serving_started_at')
|
||||||
|
->whereNotNull('completed_at')
|
||||||
|
->selectRaw("AVG({$serviceDiff}) as v")
|
||||||
|
->value('v')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function waitTimesReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$hourExpr = DatabaseTime::hourOf('issued_at');
|
||||||
|
$waitDiff = DatabaseTime::diffSeconds('issued_at', 'called_at');
|
||||||
|
|
||||||
|
$rows = Ticket::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('issued_at', [$from, $to])
|
||||||
|
->where('status', 'completed')
|
||||||
|
->whereNotNull('called_at')
|
||||||
|
->selectRaw("{$hourExpr} as hour, AVG({$waitDiff}) as avg_wait")
|
||||||
|
->groupBy('hour')
|
||||||
|
->orderBy('hour')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$peak = $rows->sortByDesc('avg_wait')->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'hourly' => $rows->map(fn ($r) => ['hour' => (int) $r->hour, 'avg_wait_seconds' => (int) round((float) $r->avg_wait)])->values()->all(),
|
||||||
|
'peak_hour' => $peak ? (int) $peak->hour : null,
|
||||||
|
'peak_avg_wait_seconds' => $peak ? (int) round((float) $peak->avg_wait) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function countersReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$counters = Counter::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->withCount(['tickets as served_count' => function ($q) use ($from, $to) {
|
||||||
|
$q->where('status', 'completed')
|
||||||
|
->whereBetween('completed_at', [$from, $to]);
|
||||||
|
}])
|
||||||
|
->orderByDesc('served_count')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'counters' => $counters->map(fn ($c) => [
|
||||||
|
'name' => $c->name,
|
||||||
|
'served' => $c->served_count,
|
||||||
|
])->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function appointmentsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$base = QueueAppointment::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('scheduled_at', [$from, $to]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => (clone $base)->count(),
|
||||||
|
'checked_in' => (clone $base)->where('status', 'checked_in')->count(),
|
||||||
|
'completed' => (clone $base)->where('status', 'completed')->count(),
|
||||||
|
'cancelled' => (clone $base)->where('status', 'cancelled')->count(),
|
||||||
|
'no_show' => (clone $base)->where('status', 'no_show')->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function feedbackReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$base = CustomerFeedback::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->whereBetween('created_at', [$from, $to])
|
||||||
|
->when($branchId, function ($q) use ($branchId) {
|
||||||
|
$q->whereHas('ticket', fn ($t) => $t->where('branch_id', $branchId));
|
||||||
|
});
|
||||||
|
|
||||||
|
$count = (clone $base)->count();
|
||||||
|
$avgRating = (clone $base)->avg('rating');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'responses' => $count,
|
||||||
|
'avg_rating' => $count ? round((float) $avgRating, 1) : 0,
|
||||||
|
'avg_service_quality' => round((float) (clone $base)->avg('service_quality'), 1),
|
||||||
|
'avg_wait_experience' => round((float) (clone $base)->avg('wait_experience'), 1),
|
||||||
|
'avg_staff_professionalism' => round((float) (clone $base)->avg('staff_professionalism'), 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function serviceSessionsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$base = ServiceSession::owned($ownerRef)
|
||||||
|
->whereBetween('started_at', [$from, $to])
|
||||||
|
->whereHas('ticket', function ($q) use ($organizationId, $branchId) {
|
||||||
|
$q->where('organization_id', $organizationId);
|
||||||
|
if ($branchId) {
|
||||||
|
$q->where('branch_id', $branchId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$completed = (clone $base)->whereNotNull('ended_at');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sessions' => (clone $base)->count(),
|
||||||
|
'completed_sessions' => (clone $completed)->count(),
|
||||||
|
'avg_duration_seconds' => (int) round((float) (clone $completed)->avg('duration_seconds')),
|
||||||
|
'total_service_seconds' => (int) (clone $completed)->sum('duration_seconds'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user