update project
مخزن کامیت contained در
والد
b246b94754
کامیت
e1a86ead41
|
|
@ -0,0 +1,104 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- connection: sqlite
|
||||||
|
database: ':memory:'
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 0
|
||||||
|
username: root
|
||||||
|
password: ''
|
||||||
|
- connection: mysql
|
||||||
|
database: crm
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 3306
|
||||||
|
username: root
|
||||||
|
password: root
|
||||||
|
- connection: pgsql
|
||||||
|
database: crm
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 5432
|
||||||
|
username: postgres
|
||||||
|
password: postgres
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
env:
|
||||||
|
MYSQL_DATABASE: crm
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
ports:
|
||||||
|
- 3306:3306
|
||||||
|
options: >-
|
||||||
|
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=10
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: crm
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd="pg_isready -U postgres -d crm"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=10
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
env:
|
||||||
|
APP_ENV: testing
|
||||||
|
APP_KEY: base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||||
|
DB_CONNECTION: ${{ matrix.connection }}
|
||||||
|
DB_DATABASE: ${{ matrix.database }}
|
||||||
|
DB_HOST: ${{ matrix.host }}
|
||||||
|
DB_PORT: ${{ matrix.port }}
|
||||||
|
DB_USERNAME: ${{ matrix.username }}
|
||||||
|
DB_PASSWORD: ${{ matrix.password }}
|
||||||
|
CACHE_STORE: array
|
||||||
|
QUEUE_CONNECTION: sync
|
||||||
|
SESSION_DRIVER: array
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: mbstring, pdo_sqlite, pdo_mysql, pdo_pgsql
|
||||||
|
coverage: none
|
||||||
|
- run: composer install --no-interaction --prefer-dist --no-progress
|
||||||
|
- run: php artisan migrate:fresh --seed --force
|
||||||
|
- run: php artisan test
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run test:run
|
||||||
|
- run: npm run build
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
- run: npm run test:e2e
|
||||||
|
|
@ -20,10 +20,13 @@ node_modules/
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
|
artifacts/
|
||||||
backend/public/build/
|
backend/public/build/
|
||||||
backend/public/hot
|
backend/public/hot
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/dist-ssr/
|
frontend/dist-ssr/
|
||||||
|
frontend/test-results/
|
||||||
|
frontend/playwright-report/
|
||||||
|
|
||||||
# Laravel runtime data
|
# Laravel runtime data
|
||||||
backend/storage/app/*
|
backend/storage/app/*
|
||||||
|
|
|
||||||
92
README.md
92
README.md
|
|
@ -1 +1,93 @@
|
||||||
|
# CRM
|
||||||
|
|
||||||
|
سامانه CRM با بکاند Laravel 12 و فرانتاند React/Vite. مجوزها در بکاند منبع حقیقت هستند و رابط کاربری نیز از همان permissionهای کاربر برای نمایش مسیرها و عملیات استفاده میکند.
|
||||||
|
|
||||||
|
## اجرای محلی در ویندوز
|
||||||
|
|
||||||
|
پیشنیازها: PHP 8.2 یا جدیدتر، Composer، Node.js و npm.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
composer install
|
||||||
|
Copy-Item .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
php artisan migrate --seed
|
||||||
|
|
||||||
|
cd ..\frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
سپس از ریشه پروژه `run-project.bat` را اجرا کنید. بکاند روی `http://127.0.0.1:8887` و فرانتاند روی `http://127.0.0.1:8886` بالا میآیند. Vite درخواستهای `/api`، `/sanctum` و `/storage` را به بکاند proxy میکند.
|
||||||
|
|
||||||
|
اگر پروژه را بدون فایل bat اجرا میکنید، مقادیر `APP_URL`، `FRONTEND_URL`، `SANCTUM_STATEFUL_DOMAINS` و `CORS_ALLOWED_ORIGINS` را متناسب با میزبان و پورتهای خود تنظیم کنید.
|
||||||
|
|
||||||
|
## عملیات پس از استقرار
|
||||||
|
|
||||||
|
بعد از migrate، ماتریس مجوز نقشهای پیشفرض را همگام کنید:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
php artisan migrate --force
|
||||||
|
php artisan permissions:sync-defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
برای reminderهای Follow-up و Task باید scheduler لاراول فعال باشد. در سرور، `php artisan schedule:run` را هر دقیقه اجرا کنید؛ برای اجرای دائمی در محیط توسعه میتوان از `php artisan schedule:work` استفاده کرد. Task reminder به صف ارسال میشود، پس در production یک `php artisan queue:work` تحت process manager نیز اجرا کنید.
|
||||||
|
|
||||||
|
پس از استقرار P1، تبدیل idempotent یادداشتهای قدیمی تماس را هم اجرا کنید (migration نیز همین تبدیل را انجام میدهد و تکرار فرمان امن است):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
php artisan call-notes:backfill
|
||||||
|
```
|
||||||
|
|
||||||
|
مرکز کارها در مسیر `/tasks` قرار دارد و Task را میتوان به Lead، Contact، Company، Deal، Call یا Campaign متصل کرد. جزئیات schema، API، مجوزها و rollback در [راهنمای P1](docs/P1_TASKS_AND_CALL_NOTES_FA.md) آمده است.
|
||||||
|
|
||||||
|
فاز P2 برد چندپایپلاین فرصتها، workspace فروش، جستوجوی سراسری، نماهای ذخیرهشده، scoring/SLA، اتوماسیون محدود، فیلدهای سفارشی و گزارش عملیات را اضافه میکند. داشبورد نقشمحور در `/` صفحه پیشفرض باقی میماند. جزئیات API، مجوزها، scheduler و rollback در [راهنمای P2](docs/P2_PROFESSIONAL_CRM_FA.md) آمده است.
|
||||||
|
|
||||||
|
پایش دستی SLA (اجرای تکراری امن است):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
php artisan sla:monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
## کنترل کیفیت
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
php artisan test
|
||||||
|
composer audit
|
||||||
|
|
||||||
|
cd ..\frontend
|
||||||
|
npm run lint
|
||||||
|
npm run test:run
|
||||||
|
npm run build
|
||||||
|
npm run test:e2e
|
||||||
|
npm audit --audit-level=high
|
||||||
|
```
|
||||||
|
|
||||||
|
برای فایلهای PHP تغییرکرده نیز `vendor\bin\pint --test <files>` را اجرا کنید. CI تستهای بکاند را روی SQLite، MySQL و PostgreSQL و lint، unit test، build و E2E فرانتاند را اجرا میکند.
|
||||||
|
|
||||||
|
## انتشار امن
|
||||||
|
|
||||||
|
ابتدا همه تغییرات مورد انتشار را commit کنید و مطمئن شوید working tree تمیز است. سپس:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\package-release.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
اسکریپت فقط فایلهای tracked در commit فعلی را archive میکند، مسیرهای حساس/وابستگیها را رد میکند و در صورت مشاهده الگوی secret متوقف میشود. خروجی پیشفرض `artifacts/crm-release.zip` است. فایلهای `.env`، دیتابیس محلی، `vendor`، `node_modules`، log و build محلی وارد بسته نمیشوند.
|
||||||
|
|
||||||
|
قبل از انتشار واقعی، secretهای محیط مقصد را خارج از Git نگه دارید و اگر قبلاً جایی افشا شدهاند آنها را در سرویس مربوطه rotate کنید. سپس cacheهای production را با `php artisan optimize` بسازید.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
قبل از migrate از دیتابیس نسخه پشتیبان بگیرید. برای برگشت آخرین batch:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
php artisan migrate:rollback --step=1 --force
|
||||||
|
```
|
||||||
|
|
||||||
|
مهاجرت نرمالسازی Sales Script دادههای رابطهای قدیمی را به کلیدهای canonical در `campaigns` و `products` منتقل میکند؛ بنابراین rollback تولیدی باید همراه با backup و برنامه بازیابی داده انجام شود.
|
||||||
|
|
||||||
|
در rollback مهاجرت تاریخچه تماس، Noteهای تولیدشده با `source_key=legacy_call:*` حذف میشوند ولی ستون قدیمی `calls.notes` دستنخورده است. nullable شدن `notes.user_id` و رفتار `nullOnDelete` عمداً برگشت داده نمیشود تا حذف کاربر باعث نابودی تاریخچه نشود؛ برای rollback تولیدی P1 راهنمای بالا و backup الزامی است.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum TaskPriority: string
|
||||||
|
{
|
||||||
|
case Low = 'low';
|
||||||
|
case Normal = 'normal';
|
||||||
|
case High = 'high';
|
||||||
|
case Urgent = 'urgent';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum TaskStatus: string
|
||||||
|
{
|
||||||
|
case Open = 'open';
|
||||||
|
case InProgress = 'in_progress';
|
||||||
|
case Done = 'done';
|
||||||
|
case Cancelled = 'cancelled';
|
||||||
|
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [self::Open, self::InProgress], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum TaskVisibility: string
|
||||||
|
{
|
||||||
|
case Private = 'private';
|
||||||
|
case Team = 'team';
|
||||||
|
case Organization = 'organization';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class TaskVersionConflictException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct('این کار توسط کاربر دیگری تغییر کرده است. اطلاعات را تازهسازی و دوباره تلاش کنید.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,14 +12,15 @@ use App\Models\FollowUp;
|
||||||
use App\Models\Lead;
|
use App\Models\Lead;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
use App\Services\CallService;
|
use App\Services\CallService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use App\Services\FollowUpService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class AgentMobileController extends Controller
|
class AgentMobileController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private CallService $callService) {}
|
public function __construct(private CallService $callService, private FollowUpService $followUps) {}
|
||||||
|
|
||||||
public function bootstrap(Request $request): JsonResponse
|
public function bootstrap(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
|
@ -29,7 +30,7 @@ class AgentMobileController extends Controller
|
||||||
'call_results' => CallResult::where('is_active', true)
|
'call_results' => CallResult::where('is_active', true)
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->get(['name', 'slug', 'requires_follow_up', 'is_final'])
|
->get(['name', 'slug', 'requires_follow_up', 'is_final'])
|
||||||
->map(fn(CallResult $result) => [
|
->map(fn (CallResult $result) => [
|
||||||
'name' => $result->name,
|
'name' => $result->name,
|
||||||
'slug' => $result->slug,
|
'slug' => $result->slug,
|
||||||
'requires_follow_up' => $result->requires_follow_up,
|
'requires_follow_up' => $result->requires_follow_up,
|
||||||
|
|
@ -71,7 +72,7 @@ class AgentMobileController extends Controller
|
||||||
$this->applyMobileFilter($query, $request->query('filter'));
|
$this->applyMobileFilter($query, $request->query('filter'));
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $query->limit(30)->get()->map(fn(Lead $lead) => $this->leadCard($lead))->values(),
|
'data' => $query->limit(30)->get()->map(fn (Lead $lead) => $this->leadCard($lead))->values(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,12 +85,12 @@ class AgentMobileController extends Controller
|
||||||
$query->where(function ($q) {
|
$query->where(function ($q) {
|
||||||
$q->where('interest_level', 'hot')
|
$q->where('interest_level', 'hot')
|
||||||
->orWhere('last_call_result', 'علاقهمند بود')
|
->orWhere('last_call_result', 'علاقهمند بود')
|
||||||
->orWhereHas('pipelineStage', fn($stage) => $stage->where('name', 'like', '%علاقهمند%'));
|
->orWhereHas('pipelineStage', fn ($stage) => $stage->where('name', 'like', '%علاقهمند%'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->query('filter') === 'proposal') {
|
if ($request->query('filter') === 'proposal') {
|
||||||
$query->whereHas('pipelineStage', fn($stage) => $stage->where('name', 'like', '%پیشنهاد%'));
|
$query->whereHas('pipelineStage', fn ($stage) => $stage->where('name', 'like', '%پیشنهاد%'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->query('filter') === 'closed') {
|
if ($request->query('filter') === 'closed') {
|
||||||
|
|
@ -103,7 +104,7 @@ class AgentMobileController extends Controller
|
||||||
->orderBy('next_follow_up_at')
|
->orderBy('next_follow_up_at')
|
||||||
->limit(40)
|
->limit(40)
|
||||||
->get()
|
->get()
|
||||||
->map(fn(Lead $lead) => $this->leadCard($lead))
|
->map(fn (Lead $lead) => $this->leadCard($lead))
|
||||||
->values(),
|
->values(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -120,9 +121,9 @@ class AgentMobileController extends Controller
|
||||||
'contacts.phones:id,contact_id,type,status,call_count,successful_call_count,failed_call_count,last_called_at,last_call_result',
|
'contacts.phones:id,contact_id,type,status,call_count,successful_call_count,failed_call_count,last_called_at,last_call_result',
|
||||||
'contactRelations.fromContact:id,name,role',
|
'contactRelations.fromContact:id,name,role',
|
||||||
'contactRelations.toContact:id,name,role',
|
'contactRelations.toContact:id,name,role',
|
||||||
'calls' => fn($q) => $q->with('contact:id,name,role')->latest()->limit(12),
|
'calls' => fn ($q) => $q->with('contact:id,name,role')->latest()->limit(12),
|
||||||
'callLogs' => fn($q) => $q->with('contact:id,name,role')->latest('called_at')->limit(12),
|
'callLogs' => fn ($q) => $q->with('contact:id,name,role')->latest('called_at')->limit(12),
|
||||||
'followUps' => fn($q) => $q->latest('scheduled_at')->limit(8),
|
'followUps' => fn ($q) => $q->latest('scheduled_at')->limit(8),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$primary = $this->primaryContact($lead);
|
$primary = $this->primaryContact($lead);
|
||||||
|
|
@ -147,20 +148,20 @@ class AgentMobileController extends Controller
|
||||||
'tags' => $this->tags($lead),
|
'tags' => $this->tags($lead),
|
||||||
'final_result' => $lead->final_result,
|
'final_result' => $lead->final_result,
|
||||||
],
|
],
|
||||||
'calls' => $lead->calls->map(fn(Call $call) => [
|
'calls' => $lead->calls->map(fn (Call $call) => [
|
||||||
'id' => $call->id,
|
'id' => $call->id,
|
||||||
'contact_name' => $call->contact?->name,
|
'contact_name' => $call->contact?->name,
|
||||||
'result' => $call->result,
|
'result' => $call->result,
|
||||||
'notes' => $call->notes,
|
'notes' => $call->notes,
|
||||||
'created_at' => optional($call->created_at)->toIso8601String(),
|
'created_at' => optional($call->created_at)->toIso8601String(),
|
||||||
])->values(),
|
])->values(),
|
||||||
'contacts' => $lead->contacts->map(fn($contact) => $this->contactSummary($contact))->values(),
|
'contacts' => $lead->contacts->map(fn ($contact) => $this->contactSummary($contact))->values(),
|
||||||
'notes' => array_values(array_filter([
|
'notes' => array_values(array_filter([
|
||||||
$lead->notes ? ['id' => 'lead-notes', 'text' => $lead->notes, 'created_at' => optional($lead->updated_at)->toIso8601String()] : null,
|
$lead->notes ? ['id' => 'lead-notes', 'text' => $lead->notes, 'created_at' => optional($lead->updated_at)->toIso8601String()] : null,
|
||||||
$lead->customer_notes ? ['id' => 'customer-notes', 'text' => $lead->customer_notes, 'created_at' => optional($lead->updated_at)->toIso8601String()] : null,
|
$lead->customer_notes ? ['id' => 'customer-notes', 'text' => $lead->customer_notes, 'created_at' => optional($lead->updated_at)->toIso8601String()] : null,
|
||||||
])),
|
])),
|
||||||
'files' => [],
|
'files' => [],
|
||||||
'contact_routes' => $lead->contactRelations->map(fn($relation) => [
|
'contact_routes' => $lead->contactRelations->map(fn ($relation) => [
|
||||||
'id' => $relation->id,
|
'id' => $relation->id,
|
||||||
'from' => $relation->fromContact?->name,
|
'from' => $relation->fromContact?->name,
|
||||||
'to' => $relation->toContact?->name,
|
'to' => $relation->toContact?->name,
|
||||||
|
|
@ -184,7 +185,7 @@ class AgentMobileController extends Controller
|
||||||
->orderBy('scheduled_at')
|
->orderBy('scheduled_at')
|
||||||
->limit(80)
|
->limit(80)
|
||||||
->get()
|
->get()
|
||||||
->map(fn(FollowUp $followUp) => $this->followUpCard($followUp));
|
->map(fn (FollowUp $followUp) => $this->followUpCard($followUp));
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => [
|
'data' => [
|
||||||
|
|
@ -224,16 +225,16 @@ class AgentMobileController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$lead = Lead::findOrFail($validated['lead_id']);
|
$lead = Lead::findOrFail($validated['lead_id']);
|
||||||
if (!$this->canAccessLead($request, $lead)) {
|
if (! $this->canAccessLead($request, $lead)) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($validated['contact_phone_id'])) {
|
if (! empty($validated['contact_phone_id'])) {
|
||||||
$belongsToLead = ContactPhone::where('id', $validated['contact_phone_id'])
|
$belongsToLead = ContactPhone::where('id', $validated['contact_phone_id'])
|
||||||
->whereHas('contact', fn($query) => $query->where('lead_id', $lead->id))
|
->whereHas('contact', fn ($query) => $query->where('lead_id', $lead->id))
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if (!$belongsToLead) {
|
if (! $belongsToLead) {
|
||||||
return response()->json(['message' => 'شماره انتخابشده برای این لید معتبر نیست'], 422);
|
return response()->json(['message' => 'شماره انتخابشده برای این لید معتبر نیست'], 422);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -271,13 +272,13 @@ class AgentMobileController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$call = Call::with('lead')->findOrFail($validated['call_id']);
|
$call = Call::with('lead')->findOrFail($validated['call_id']);
|
||||||
if ($call->user_id !== $request->user()->id || !$call->lead || !$this->canAccessLead($request, $call->lead)) {
|
if ($call->user_id !== $request->user()->id || ! $call->lead || ! $this->canAccessLead($request, $call->lead)) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$followUpAt = $validated['next_follow_up_at'] ?? $validated['referral']['next_follow_up_at'] ?? null;
|
$followUpAt = $validated['next_follow_up_at'] ?? $validated['referral']['next_follow_up_at'] ?? null;
|
||||||
$callResult = CallResult::where('name', $validated['result'])->first();
|
$callResult = CallResult::where('name', $validated['result'])->first();
|
||||||
if ($callResult?->requires_follow_up && !$followUpAt) {
|
if ($callResult?->requires_follow_up && ! $followUpAt) {
|
||||||
return response()->json(['message' => 'برای این نتیجه تماس، زمان پیگیری الزامی است'], 422);
|
return response()->json(['message' => 'برای این نتیجه تماس، زمان پیگیری الزامی است'], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,7 +301,7 @@ class AgentMobileController extends Controller
|
||||||
'call_id' => $registeredCall->id,
|
'call_id' => $registeredCall->id,
|
||||||
'lead_id' => $registeredCall->lead_id,
|
'lead_id' => $registeredCall->lead_id,
|
||||||
'result' => $registeredCall->result,
|
'result' => $registeredCall->result,
|
||||||
'next_call' => optional($this->queueQuery($request->user()->id)->where('id', '!=', $registeredCall->lead_id)->first(), fn($lead) => $this->leadCard($lead)),
|
'next_call' => optional($this->queueQuery($request->user()->id)->where('id', '!=', $registeredCall->lead_id)->first(), fn ($lead) => $this->leadCard($lead)),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -315,25 +316,23 @@ class AgentMobileController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$lead = Lead::findOrFail($validated['lead_id']);
|
$lead = Lead::findOrFail($validated['lead_id']);
|
||||||
if (!$this->canAccessLead($request, $lead)) {
|
if (! $this->canAccessLead($request, $lead)) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($validated['call_id']) && !Call::where('id', $validated['call_id'])->where('user_id', $request->user()->id)->where('lead_id', $lead->id)->exists()) {
|
if (! empty($validated['call_id']) && ! Call::where('id', $validated['call_id'])->where('user_id', $request->user()->id)->where('lead_id', $lead->id)->exists()) {
|
||||||
return response()->json(['message' => 'تماس انتخابشده معتبر نیست'], 422);
|
return response()->json(['message' => 'تماس انتخابشده معتبر نیست'], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$followUp = FollowUp::create([
|
$followUp = $this->followUps->schedule(
|
||||||
'lead_id' => $lead->id,
|
$lead,
|
||||||
'user_id' => $request->user()->id,
|
$request->user()->id,
|
||||||
'call_id' => $validated['call_id'] ?? null,
|
$validated['scheduled_at'],
|
||||||
'scheduled_at' => $validated['scheduled_at'],
|
$request->user(),
|
||||||
'status' => 'pending',
|
$validated['notes'] ?? null,
|
||||||
'notes' => $validated['notes'] ?? null,
|
$validated['call_id'] ?? null,
|
||||||
]);
|
'agent_mobile',
|
||||||
$lead->update(['next_follow_up_at' => $validated['scheduled_at']]);
|
);
|
||||||
|
|
||||||
ActivityLogger::log('agent_mobile_follow_up_created', "Mobile follow-up created for lead {$lead->id}", $followUp);
|
|
||||||
|
|
||||||
return response()->json(['data' => $this->followUpCard($followUp->load('lead.contacts.phones'))], 201);
|
return response()->json(['data' => $this->followUpCard($followUp->load('lead.contacts.phones'))], 201);
|
||||||
}
|
}
|
||||||
|
|
@ -353,11 +352,11 @@ class AgentMobileController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$lead = Lead::findOrFail($validated['lead_id']);
|
$lead = Lead::findOrFail($validated['lead_id']);
|
||||||
if (!$this->canAccessLead($request, $lead)) {
|
if (! $this->canAccessLead($request, $lead)) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($validated['from_contact_id']) && !Contact::where('id', $validated['from_contact_id'])->where('lead_id', $lead->id)->exists()) {
|
if (! empty($validated['from_contact_id']) && ! Contact::where('id', $validated['from_contact_id'])->where('lead_id', $lead->id)->exists()) {
|
||||||
return response()->json(['message' => 'مخاطب معرف برای این لید معتبر نیست'], 422);
|
return response()->json(['message' => 'مخاطب معرف برای این لید معتبر نیست'], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,7 +439,7 @@ class AgentMobileController extends Controller
|
||||||
->where(function ($query) {
|
->where(function ($query) {
|
||||||
$query->whereNull('last_call_at')
|
$query->whereNull('last_call_at')
|
||||||
->orWhere('next_follow_up_at', '<=', now())
|
->orWhere('next_follow_up_at', '<=', now())
|
||||||
->orWhereHas('followUps', fn($followUp) => $followUp->where('status', 'pending')->where('scheduled_at', '<=', now()));
|
->orWhereHas('followUps', fn ($followUp) => $followUp->where('status', 'pending')->where('scheduled_at', '<=', now()));
|
||||||
})
|
})
|
||||||
->orderByDesc('priority')
|
->orderByDesc('priority')
|
||||||
->orderByRaw('next_follow_up_at IS NULL')
|
->orderByRaw('next_follow_up_at IS NULL')
|
||||||
|
|
@ -533,6 +532,7 @@ class AgentMobileController extends Controller
|
||||||
private function primaryContact(Lead $lead)
|
private function primaryContact(Lead $lead)
|
||||||
{
|
{
|
||||||
$contacts = $lead->relationLoaded('contacts') ? $lead->contacts : $lead->contacts()->with('phones')->get();
|
$contacts = $lead->relationLoaded('contacts') ? $lead->contacts : $lead->contacts()->with('phones')->get();
|
||||||
|
|
||||||
return $contacts->firstWhere('is_primary', true) ?? $contacts->first();
|
return $contacts->firstWhere('is_primary', true) ?? $contacts->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -543,13 +543,13 @@ class AgentMobileController extends Controller
|
||||||
|
|
||||||
private function tags(Lead $lead): array
|
private function tags(Lead $lead): array
|
||||||
{
|
{
|
||||||
if (!$lead->tags) {
|
if (! $lead->tags) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$decoded = json_decode($lead->tags, true);
|
$decoded = json_decode($lead->tags, true);
|
||||||
if (is_array($decoded)) {
|
if (is_array($decoded)) {
|
||||||
return array_values(array_filter($decoded, fn($tag) => is_string($tag) && $tag !== ''));
|
return array_values(array_filter($decoded, fn ($tag) => is_string($tag) && $tag !== ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_filter(array_map('trim', explode(',', $lead->tags))));
|
return array_values(array_filter(array_map('trim', explode(',', $lead->tags))));
|
||||||
|
|
@ -557,34 +557,64 @@ class AgentMobileController extends Controller
|
||||||
|
|
||||||
private function priorityLabel(int $priority): string
|
private function priorityLabel(int $priority): string
|
||||||
{
|
{
|
||||||
if ($priority >= 8) return 'خیلی بالا';
|
if ($priority >= 8) {
|
||||||
if ($priority >= 5) return 'بالا';
|
return 'خیلی بالا';
|
||||||
if ($priority >= 2) return 'متوسط';
|
}
|
||||||
|
if ($priority >= 5) {
|
||||||
|
return 'بالا';
|
||||||
|
}
|
||||||
|
if ($priority >= 2) {
|
||||||
|
return 'متوسط';
|
||||||
|
}
|
||||||
|
|
||||||
return 'عادی';
|
return 'عادی';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function nextAction(Lead $lead): string
|
private function nextAction(Lead $lead): string
|
||||||
{
|
{
|
||||||
if (!$lead->last_call_at) return 'تماس اول';
|
if (! $lead->last_call_at) {
|
||||||
if ($lead->next_follow_up_at && $lead->next_follow_up_at->isPast()) return 'پیگیری عقبافتاده';
|
return 'تماس اول';
|
||||||
if ($lead->next_follow_up_at && $lead->next_follow_up_at->isToday()) return 'پیگیری امروز';
|
}
|
||||||
|
if ($lead->next_follow_up_at && $lead->next_follow_up_at->isPast()) {
|
||||||
|
return 'پیگیری عقبافتاده';
|
||||||
|
}
|
||||||
|
if ($lead->next_follow_up_at && $lead->next_follow_up_at->isToday()) {
|
||||||
|
return 'پیگیری امروز';
|
||||||
|
}
|
||||||
|
|
||||||
return 'ادامه پیگیری';
|
return 'ادامه پیگیری';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function followUpStatus(Lead $lead): string
|
private function followUpStatus(Lead $lead): string
|
||||||
{
|
{
|
||||||
if (!$lead->next_follow_up_at) return 'بدون زمان پیگیری';
|
if (! $lead->next_follow_up_at) {
|
||||||
if ($lead->next_follow_up_at->isPast()) return 'عقبافتاده';
|
return 'بدون زمان پیگیری';
|
||||||
if ($lead->next_follow_up_at->isToday()) return 'امروز';
|
}
|
||||||
|
if ($lead->next_follow_up_at->isPast()) {
|
||||||
|
return 'عقبافتاده';
|
||||||
|
}
|
||||||
|
if ($lead->next_follow_up_at->isToday()) {
|
||||||
|
return 'امروز';
|
||||||
|
}
|
||||||
|
|
||||||
return 'زمانبندیشده';
|
return 'زمانبندیشده';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function followUpGroup(FollowUp $followUp): string
|
private function followUpGroup(FollowUp $followUp): string
|
||||||
{
|
{
|
||||||
if ($followUp->status === 'completed') return 'done';
|
if ($followUp->status === 'completed') {
|
||||||
if ($followUp->scheduled_at->isPast() && !$followUp->scheduled_at->isToday()) return 'overdue';
|
return 'done';
|
||||||
if ($followUp->scheduled_at->isToday()) return 'today';
|
}
|
||||||
if ($followUp->scheduled_at->isTomorrow()) return 'tomorrow';
|
if ($followUp->scheduled_at->isPast() && ! $followUp->scheduled_at->isToday()) {
|
||||||
|
return 'overdue';
|
||||||
|
}
|
||||||
|
if ($followUp->scheduled_at->isToday()) {
|
||||||
|
return 'today';
|
||||||
|
}
|
||||||
|
if ($followUp->scheduled_at->isTomorrow()) {
|
||||||
|
return 'tomorrow';
|
||||||
|
}
|
||||||
|
|
||||||
return 'week';
|
return 'week';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class AssignmentController extends Controller
|
||||||
]);
|
]);
|
||||||
$lead = Lead::findOrFail($validated['lead_id']);
|
$lead = Lead::findOrFail($validated['lead_id']);
|
||||||
$agent = User::findOrFail($validated['agent_id']);
|
$agent = User::findOrFail($validated['agent_id']);
|
||||||
if (!$this->canAssignToAgent($lead, $agent)) {
|
if (! $this->canAssignToAgent($lead, $agent)) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ class AssignmentController extends Controller
|
||||||
]);
|
]);
|
||||||
$agent = User::findOrFail($validated['agent_id']);
|
$agent = User::findOrFail($validated['agent_id']);
|
||||||
$leads = Lead::whereIn('id', $validated['lead_ids'])->get();
|
$leads = Lead::whereIn('id', $validated['lead_ids'])->get();
|
||||||
if ($leads->count() !== count(array_unique($validated['lead_ids'])) || $leads->contains(fn(Lead $lead) => !$this->canAssignToAgent($lead, $agent))) {
|
if ($leads->count() !== count(array_unique($validated['lead_ids'])) || $leads->contains(fn (Lead $lead) => ! $this->canAssignToAgent($lead, $agent))) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@ class AssignmentController extends Controller
|
||||||
$validated['agent_id'],
|
$validated['agent_id'],
|
||||||
auth()->id()
|
auth()->id()
|
||||||
);
|
);
|
||||||
ActivityLogger::log('lead_bulk_owner_changed', count($leads) . " leads assigned to user {$agent->id}");
|
ActivityLogger::log('lead_bulk_owner_changed', count($leads)." leads assigned to user {$agent->id}");
|
||||||
|
|
||||||
return response()->json(['assigned' => count($leads)]);
|
return response()->json(['assigned' => count($leads)]);
|
||||||
}
|
}
|
||||||
|
|
@ -74,8 +74,8 @@ class AssignmentController extends Controller
|
||||||
if (
|
if (
|
||||||
$agents->count() !== count(array_unique($validated['agent_ids']))
|
$agents->count() !== count(array_unique($validated['agent_ids']))
|
||||||
|| $leads->count() !== count(array_unique($validated['lead_ids']))
|
|| $leads->count() !== count(array_unique($validated['lead_ids']))
|
||||||
|| $leads->contains(fn(Lead $lead) => Gate::denies('assign', $lead))
|
|| $leads->contains(fn (Lead $lead) => Gate::denies('assign', $lead))
|
||||||
|| $agents->contains(fn(User $agent) => !$this->agentIsAssignable($agent))
|
|| $agents->contains(fn (User $agent) => ! $this->agentIsAssignable($agent))
|
||||||
) {
|
) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +85,7 @@ class AssignmentController extends Controller
|
||||||
$validated['agent_ids'],
|
$validated['agent_ids'],
|
||||||
auth()->id()
|
auth()->id()
|
||||||
);
|
);
|
||||||
ActivityLogger::log('lead_round_robin_owner_changed', count($leads) . ' leads assigned by round robin');
|
ActivityLogger::log('lead_round_robin_owner_changed', count($leads).' leads assigned by round robin');
|
||||||
|
|
||||||
return response()->json(['assigned' => count($leads)]);
|
return response()->json(['assigned' => count($leads)]);
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +98,7 @@ class AssignmentController extends Controller
|
||||||
]);
|
]);
|
||||||
$lead = Lead::findOrFail($validated['lead_id']);
|
$lead = Lead::findOrFail($validated['lead_id']);
|
||||||
$agent = User::findOrFail($validated['agent_id']);
|
$agent = User::findOrFail($validated['agent_id']);
|
||||||
if (!$this->canAssignToAgent($lead, $agent)) {
|
if (! $this->canAssignToAgent($lead, $agent)) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,12 +112,36 @@ class AssignmentController extends Controller
|
||||||
return response()->json($lead->load('assignedAgent'));
|
return response()->json($lead->load('assignedAgent'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function refer(Request $request, Lead $lead): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate(['user_id' => 'required|integer|exists:users,id']);
|
||||||
|
$actor = $request->user();
|
||||||
|
abort_unless(AccessControl::canAccessLead($actor, $lead), 403, 'به این لید دسترسی ندارید.');
|
||||||
|
if ($actor->hasRole('agent')) {
|
||||||
|
abort_unless($lead->assigned_to === $actor->id, 403, 'فقط لید تحت مسئولیت خود را میتوانید ارجاع دهید.');
|
||||||
|
} else {
|
||||||
|
Gate::authorize('assign', $lead);
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = User::whereKey($validated['user_id'])->where('is_active', true)->firstOrFail();
|
||||||
|
abort_unless($target->hasAnyRole(['agent', 'supervisor']), 422, 'مقصد ارجاع باید کارشناس یا مدیر فروش باشد.');
|
||||||
|
if (! $actor->hasRole('admin')) {
|
||||||
|
abort_unless((bool) array_intersect(AccessControl::teamIds($actor), AccessControl::teamIds($target)), 403, 'مقصد ارجاع خارج از تیم شما است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $this->assignmentService->assignToAgent($lead->id, $target->id, $actor->id);
|
||||||
|
ActivityLogger::log('lead_referred', "Lead {$lead->id} referred from {$actor->id} to {$target->id}", $updated);
|
||||||
|
|
||||||
|
return response()->json($updated->load('assignedAgent'));
|
||||||
|
}
|
||||||
|
|
||||||
public function returnToPool(int $leadId): JsonResponse
|
public function returnToPool(int $leadId): JsonResponse
|
||||||
{
|
{
|
||||||
$leadModel = Lead::findOrFail($leadId);
|
$leadModel = Lead::findOrFail($leadId);
|
||||||
Gate::authorize('assign', $leadModel);
|
Gate::authorize('assign', $leadModel);
|
||||||
|
|
||||||
$lead = $this->assignmentService->returnToPool($leadId);
|
$lead = $this->assignmentService->returnToPool($leadId);
|
||||||
|
|
||||||
return response()->json($lead);
|
return response()->json($lead);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,7 +154,7 @@ class AssignmentController extends Controller
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (!$agent->hasRole('agent') || !$agent->is_active) {
|
if (! $agent->hasRole('agent') || ! $agent->is_active) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,12 @@ namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Attachment;
|
use App\Models\Attachment;
|
||||||
use App\Models\Company;
|
|
||||||
use App\Models\Deal;
|
|
||||||
use App\Models\Lead;
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
|
use App\Support\EntityResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class AttachmentController extends Controller
|
class AttachmentController extends Controller
|
||||||
|
|
@ -18,7 +17,8 @@ class AttachmentController extends Controller
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate(['entity_type' => 'required|in:lead,company,deal', 'entity_id' => 'required|integer']);
|
$validated = $request->validate(['entity_type' => 'required|in:lead,company,deal', 'entity_id' => 'required|integer']);
|
||||||
$model = $this->resolve($validated['entity_type'], $validated['entity_id']);
|
$model = EntityResolver::authorize($validated['entity_type'], $validated['entity_id']);
|
||||||
|
|
||||||
return response()->json(['data' => $model->attachments()->with('uploader:id,name')->latest()->get()]);
|
return response()->json(['data' => $model->attachments()->with('uploader:id,name')->latest()->get()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,12 +27,12 @@ class AttachmentController extends Controller
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'entity_type' => 'required|in:lead,company,deal',
|
'entity_type' => 'required|in:lead,company,deal',
|
||||||
'entity_id' => 'required|integer',
|
'entity_id' => 'required|integer',
|
||||||
'file' => 'required|file|mimes:' . $this->allowedMimes() . '|max:' . ($this->maxFileMb() * 1024),
|
'file' => 'required|file|mimes:'.$this->allowedMimes().'|max:'.($this->maxFileMb() * 1024),
|
||||||
], [
|
], [
|
||||||
'file.mimes' => 'نوع فایل پیوست مجاز نیست.',
|
'file.mimes' => 'نوع فایل پیوست مجاز نیست.',
|
||||||
'file.max' => 'حجم فایل پیوست بیش از حد مجاز است.',
|
'file.max' => 'حجم فایل پیوست بیش از حد مجاز است.',
|
||||||
]);
|
]);
|
||||||
$model = $this->resolve($validated['entity_type'], $validated['entity_id']);
|
$model = EntityResolver::authorize($validated['entity_type'], $validated['entity_id'], 'update');
|
||||||
$file = $request->file('file');
|
$file = $request->file('file');
|
||||||
$path = $file->store('attachments');
|
$path = $file->store('attachments');
|
||||||
|
|
||||||
|
|
@ -45,31 +45,26 @@ class AttachmentController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ActivityLogger::log('attachment_uploaded', "File {$attachment->original_name} uploaded", $model);
|
ActivityLogger::log('attachment_uploaded', "File {$attachment->original_name} uploaded", $model);
|
||||||
|
|
||||||
return response()->json($attachment->load('uploader:id,name'), 201);
|
return response()->json($attachment->load('uploader:id,name'), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function download(Attachment $attachment)
|
public function download(Attachment $attachment)
|
||||||
{
|
{
|
||||||
|
Gate::authorize('view', $attachment);
|
||||||
ActivityLogger::log('attachment_downloaded', "File {$attachment->original_name} downloaded", $attachment->attachable);
|
ActivityLogger::log('attachment_downloaded', "File {$attachment->original_name} downloaded", $attachment->attachable);
|
||||||
|
|
||||||
return Storage::download($attachment->path, $attachment->original_name);
|
return Storage::download($attachment->path, $attachment->original_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Attachment $attachment): JsonResponse
|
public function destroy(Attachment $attachment): JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()?->hasRole('admin') || auth()->id() === $attachment->uploaded_by, 403, 'دسترسی غیرمجاز');
|
Gate::authorize('delete', $attachment);
|
||||||
Storage::delete($attachment->path);
|
Storage::delete($attachment->path);
|
||||||
$attachment->delete();
|
$attachment->delete();
|
||||||
ActivityLogger::log('attachment_deleted', "File {$attachment->id} deleted");
|
ActivityLogger::log('attachment_deleted', "File {$attachment->id} deleted");
|
||||||
return response()->json(['message' => 'فایل حذف شد']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolve(string $type, int $id): Lead|Company|Deal
|
return response()->json(['message' => 'فایل حذف شد']);
|
||||||
{
|
|
||||||
return match ($type) {
|
|
||||||
'lead' => Lead::findOrFail($id),
|
|
||||||
'company' => Company::findOrFail($id),
|
|
||||||
'deal' => Deal::findOrFail($id),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function allowedMimes(): string
|
private function allowedMimes(): string
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,13 @@ class AuthController extends Controller
|
||||||
? User::whereRaw('lower(email) = ?', [$login])->first()
|
? User::whereRaw('lower(email) = ?', [$login])->first()
|
||||||
: User::get()->first(fn (User $candidate) => $this->normalizeLoginIdentifier($candidate->phone) === $login);
|
: User::get()->first(fn (User $candidate) => $this->normalizeLoginIdentifier($candidate->phone) === $login);
|
||||||
|
|
||||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
if (! $user || ! Hash::check($request->password, $user->password)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'phone' => ['شماره موبایل یا رمز عبور اشتباه است'],
|
'phone' => ['شماره موبایل یا رمز عبور اشتباه است'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$user->is_active) {
|
if (! $user->is_active) {
|
||||||
return response()->json(['message' => 'حساب کاربری شما غیرفعال است'], 403);
|
return response()->json(['message' => 'حساب کاربری شما غیرفعال است'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ class AuthController extends Controller
|
||||||
])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
ActivityLogger::log('logout', "User logged out");
|
ActivityLogger::log('logout', 'User logged out');
|
||||||
|
|
||||||
Auth::guard('web')->logout();
|
Auth::guard('web')->logout();
|
||||||
if ($request->hasSession()) {
|
if ($request->hasSession()) {
|
||||||
|
|
@ -79,10 +79,20 @@ class AuthController extends Controller
|
||||||
|
|
||||||
public function me(Request $request): JsonResponse
|
public function me(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $request->user()->load('roles.permissions', 'teams');
|
$user = Auth::guard('web')->user();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return response()->json([
|
||||||
|
'authenticated' => false,
|
||||||
|
'data' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->load('roles.permissions', 'teams');
|
||||||
$user->permissions = $user->getAllPermissions()->pluck('name');
|
$user->permissions = $user->getAllPermissions()->pluck('name');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
'authenticated' => true,
|
||||||
'data' => $user,
|
'data' => $user,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -93,8 +103,8 @@ class AuthController extends Controller
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'email' => 'required|email|unique:users,email,' . $user->id,
|
'email' => 'required|email|unique:users,email,'.$user->id,
|
||||||
'phone' => 'nullable|string|max:20|unique:users,phone,' . $user->id,
|
'phone' => 'nullable|string|max:20|unique:users,phone,'.$user->id,
|
||||||
'password' => ['nullable', ...PasswordPolicy::rules()],
|
'password' => ['nullable', ...PasswordPolicy::rules()],
|
||||||
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048',
|
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048',
|
||||||
]);
|
]);
|
||||||
|
|
@ -104,7 +114,7 @@ class AuthController extends Controller
|
||||||
$validated['avatar'] = $path;
|
$validated['avatar'] = $path;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($validated['password'])) {
|
if (! empty($validated['password'])) {
|
||||||
$validated['password'] = Hash::make($validated['password']);
|
$validated['password'] = Hash::make($validated['password']);
|
||||||
} else {
|
} else {
|
||||||
unset($validated['password']);
|
unset($validated['password']);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\FollowUp;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Support\AccessControl;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class CalendarController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'from' => 'required|date',
|
||||||
|
'to' => 'required|date|after_or_equal:from',
|
||||||
|
'type' => 'nullable|string|in:task,follow_up',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from = Carbon::parse($validated['from'])->startOfDay();
|
||||||
|
$to = Carbon::parse($validated['to'])->endOfDay();
|
||||||
|
abort_if($from->diffInDays($to) > 370, 422, 'بازه تقویم نمیتواند بیشتر از یک سال باشد.');
|
||||||
|
|
||||||
|
$events = collect();
|
||||||
|
$type = $validated['type'] ?? null;
|
||||||
|
|
||||||
|
if ((! $type || $type === 'task') && Gate::allows('viewAny', Task::class)) {
|
||||||
|
$tasks = Task::query()
|
||||||
|
->with(['assignee:id,name', 'taskable'])
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->whereBetween('due_at', [$from, $to]);
|
||||||
|
AccessControl::scopeTasks($tasks, $request->user());
|
||||||
|
$events->push(...$tasks->get()->map(fn (Task $task) => [
|
||||||
|
'id' => "task-{$task->id}",
|
||||||
|
'entity_id' => $task->id,
|
||||||
|
'type' => 'task',
|
||||||
|
'title' => $task->subject,
|
||||||
|
'starts_at' => $task->due_at,
|
||||||
|
'status' => is_object($task->status) ? $task->status->value : $task->status,
|
||||||
|
'priority' => is_object($task->priority) ? $task->priority->value : $task->priority,
|
||||||
|
'assignee' => $task->assignee?->only(['id', 'name']),
|
||||||
|
'related' => $task->taskable ? [
|
||||||
|
'type' => class_basename($task->taskable_type),
|
||||||
|
'id' => $task->taskable_id,
|
||||||
|
'label' => $task->taskable->name ?? $task->taskable->title ?? $task->taskable->company ?? null,
|
||||||
|
] : null,
|
||||||
|
'url' => "/tasks?task={$task->id}",
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((! $type || $type === 'follow_up') && Gate::allows('viewAny', FollowUp::class)) {
|
||||||
|
$followUps = FollowUp::query()
|
||||||
|
->with(['lead:id,first_name,last_name,company', 'user:id,name'])
|
||||||
|
->whereBetween('scheduled_at', [$from, $to]);
|
||||||
|
AccessControl::scopeFollowUps($followUps, $request->user());
|
||||||
|
$events->push(...$followUps->get()->map(fn (FollowUp $followUp) => [
|
||||||
|
'id' => "follow-up-{$followUp->id}",
|
||||||
|
'entity_id' => $followUp->id,
|
||||||
|
'type' => 'follow_up',
|
||||||
|
'title' => $followUp->notes ?: 'پیگیری لید',
|
||||||
|
'starts_at' => $followUp->scheduled_at,
|
||||||
|
'status' => $followUp->status,
|
||||||
|
'priority' => null,
|
||||||
|
'assignee' => $followUp->user?->only(['id', 'name']),
|
||||||
|
'related' => $followUp->lead ? [
|
||||||
|
'type' => 'Lead',
|
||||||
|
'id' => $followUp->lead_id,
|
||||||
|
'label' => $followUp->lead->company ?: trim("{$followUp->lead->first_name} {$followUp->lead->last_name}"),
|
||||||
|
] : null,
|
||||||
|
'url' => "/follow-ups?follow_up={$followUp->id}",
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
abort_if($events->isEmpty() && ! Gate::allows('viewAny', Task::class) && ! Gate::allows('viewAny', FollowUp::class), 403);
|
||||||
|
|
||||||
|
return response()->json(['events' => $events->sortBy('starts_at')->values()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\CallResource;
|
||||||
|
use App\Http\Responses\ApiResponse;
|
||||||
use App\Models\Call;
|
use App\Models\Call;
|
||||||
use App\Models\CallResult;
|
use App\Models\CallResult;
|
||||||
use App\Models\Lead;
|
use App\Models\Lead;
|
||||||
|
|
@ -26,13 +28,19 @@ class CallController extends Controller
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
Gate::authorize('viewAny', Call::class);
|
Gate::authorize('viewAny', Call::class);
|
||||||
|
|
||||||
$query = Call::with('lead:id,first_name,last_name,phone', 'user:id,name', 'contact:id,name,role', 'contactPhone:id,phone,type,status');
|
$query = Call::with('lead:id,first_name,last_name,company,phone', 'user:id,name', 'contact:id,name,role', 'contactPhone:id,phone,type,status');
|
||||||
|
|
||||||
AccessControl::scopeCalls($query, $user);
|
AccessControl::scopeCalls($query, $user);
|
||||||
|
|
||||||
if ($request->lead_id) {
|
if ($request->lead_id) {
|
||||||
$query->where('lead_id', $request->lead_id);
|
$query->where('lead_id', $request->lead_id);
|
||||||
}
|
}
|
||||||
|
if ($request->user_id) {
|
||||||
|
$query->where('user_id', $request->user_id);
|
||||||
|
}
|
||||||
|
if ($request->direction) {
|
||||||
|
$query->where('direction', $request->direction);
|
||||||
|
}
|
||||||
if ($request->result) {
|
if ($request->result) {
|
||||||
$query->where('result', $request->result);
|
$query->where('result', $request->result);
|
||||||
}
|
}
|
||||||
|
|
@ -42,16 +50,30 @@ class CallController extends Controller
|
||||||
if ($request->date_to) {
|
if ($request->date_to) {
|
||||||
$query->whereDate('created_at', '<=', $request->date_to);
|
$query->whereDate('created_at', '<=', $request->date_to);
|
||||||
}
|
}
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = trim((string) $request->get('search'));
|
||||||
|
$query->where(function ($searchQuery) use ($search): void {
|
||||||
|
$searchQuery->where('provider_call_id', 'like', "%{$search}%")
|
||||||
|
->orWhereHas('lead', fn ($leadQuery) => $leadQuery
|
||||||
|
->where('first_name', 'like', "%{$search}%")
|
||||||
|
->orWhere('last_name', 'like', "%{$search}%")
|
||||||
|
->orWhere('company', 'like', "%{$search}%"))
|
||||||
|
->orWhereHas('contact', fn ($contactQuery) => $contactQuery->where('name', 'like', "%{$search}%"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15));
|
$paginator = $query->orderByDesc('created_at')->paginate(min((int) $request->get('per_page', 15), 100));
|
||||||
|
|
||||||
|
return ApiResponse::paginated($paginator, fn (Call $call) => $this->resource($call));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function results(): JsonResponse
|
public function results(): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json(
|
return ApiResponse::success(
|
||||||
CallResult::where('is_active', true)
|
CallResult::where('is_active', true)
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->get(['id', 'name', 'slug', 'color', 'requires_follow_up', 'is_positive', 'is_negative', 'is_final'])
|
->get(['id', 'name', 'slug', 'color', 'requires_follow_up', 'is_positive', 'is_negative', 'is_final'])
|
||||||
|
->toArray()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,7 +89,7 @@ class CallController extends Controller
|
||||||
|
|
||||||
$result = $this->callService->initiateCall($validated['lead_id'], auth()->id(), $validated['contact_phone_id'] ?? null);
|
$result = $this->callService->initiateCall($validated['lead_id'], auth()->id(), $validated['contact_phone_id'] ?? null);
|
||||||
|
|
||||||
return response()->json($result, 201);
|
return ApiResponse::success($result, 201, 'تماس آغاز شد.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Call $call): JsonResponse
|
public function show(Call $call): JsonResponse
|
||||||
|
|
@ -77,7 +99,7 @@ class CallController extends Controller
|
||||||
ActivityLogger::log('recording_viewed', "Recording viewed for call {$call->id}", $call);
|
ActivityLogger::log('recording_viewed', "Recording viewed for call {$call->id}", $call);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($call->load('lead', 'user', 'contact', 'contactPhone'));
|
return ApiResponse::success($this->resource($call->load('lead', 'user', 'contact', 'contactPhone')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function registerResult(Request $request): JsonResponse
|
public function registerResult(Request $request): JsonResponse
|
||||||
|
|
@ -113,6 +135,54 @@ class CallController extends Controller
|
||||||
$validated['referral'] ?? null
|
$validated['referral'] ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json($call);
|
return ApiResponse::success($this->resource($call->loadMissing('lead', 'user', 'contact', 'contactPhone')), message: 'نتیجه تماس ثبت شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function manualResult(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'lead_id' => 'required|exists:leads,id',
|
||||||
|
'contact_phone_id' => 'required|exists:contact_phones,id',
|
||||||
|
'result' => 'required|string|max:50',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'next_follow_up_at' => 'nullable|date',
|
||||||
|
'referral' => 'nullable|array',
|
||||||
|
'referral.name' => 'required_with:referral|string|max:255',
|
||||||
|
'referral.phone' => 'required_with:referral|string|max:30',
|
||||||
|
'referral.phone_type' => 'nullable|string|in:mobile,landline,extension',
|
||||||
|
'referral.role' => 'nullable|string|max:80',
|
||||||
|
'referral.relation_description' => 'nullable|string|max:255',
|
||||||
|
'referral.description' => 'nullable|string',
|
||||||
|
'referral.make_primary' => 'nullable|boolean',
|
||||||
|
'referral.next_follow_up_at' => 'nullable|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lead = Lead::findOrFail($validated['lead_id']);
|
||||||
|
Gate::authorize('create', [Call::class, $lead]);
|
||||||
|
$callResult = CallResult::where('name', $validated['result'])->first();
|
||||||
|
if ($callResult?->requires_follow_up && empty($validated['next_follow_up_at']) && empty($validated['referral']['next_follow_up_at'])) {
|
||||||
|
return response()->json(['message' => 'برای این نتیجه تماس، زمان پیگیری الزامی است'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$call = $this->callService->recordManualResult(
|
||||||
|
$lead->id,
|
||||||
|
(int) $request->user()->id,
|
||||||
|
(int) $validated['contact_phone_id'],
|
||||||
|
$validated['result'],
|
||||||
|
$validated['notes'] ?? null,
|
||||||
|
$validated['next_follow_up_at'] ?? null,
|
||||||
|
$validated['referral'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse::success(
|
||||||
|
$this->resource($call->loadMissing('lead', 'user', 'contact', 'contactPhone')),
|
||||||
|
201,
|
||||||
|
'تماس و نتیجه آن ثبت شد.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(Call $call): array
|
||||||
|
{
|
||||||
|
return (new CallResource($call))->resolve(request());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,30 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\CampaignResource;
|
||||||
|
use App\Http\Responses\ApiResponse;
|
||||||
use App\Models\Campaign;
|
use App\Models\Campaign;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
use App\Support\AccessControl;
|
use App\Support\AccessControl;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class CampaignController extends Controller
|
class CampaignController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = Campaign::withCount('leads')->with('assignedAgents:id,name', 'assignedSupervisors:id,name');
|
$query = Campaign::with('assignedAgents:id,name', 'assignedSupervisors:id,name', 'salesScript:id,title', 'product:id,name,base_price')
|
||||||
|
->withCount([
|
||||||
|
'leads',
|
||||||
|
'leads as contacted_leads_count' => fn ($leadQuery) => $leadQuery->whereNotNull('last_call_at'),
|
||||||
|
'leads as won_leads_count' => fn ($leadQuery) => $leadQuery->where('final_result', 'موفق'),
|
||||||
|
])
|
||||||
|
->withSum([
|
||||||
|
'leads as won_value_sum' => fn ($leadQuery) => $leadQuery->where('final_result', 'موفق'),
|
||||||
|
], 'deal_value');
|
||||||
Gate::authorize('viewAny', Campaign::class);
|
Gate::authorize('viewAny', Campaign::class);
|
||||||
AccessControl::scopeCampaigns($query, auth()->user());
|
AccessControl::scopeCampaigns($query, auth()->user());
|
||||||
|
|
||||||
|
|
@ -26,7 +38,9 @@ class CampaignController extends Controller
|
||||||
$query->where('status', $request->status);
|
$query->where('status', $request->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15));
|
$paginator = $query->orderByDesc('created_at')->paginate(min((int) $request->get('per_page', 15), 100));
|
||||||
|
|
||||||
|
return ApiResponse::paginated($paginator, fn (Campaign $campaign) => $this->resource($campaign));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
|
|
@ -37,40 +51,44 @@ class CampaignController extends Controller
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
'product_service' => 'nullable|string|max:255',
|
'product_service' => 'nullable|string|max:255',
|
||||||
|
'product_id' => 'nullable|exists:products,id',
|
||||||
|
'channel' => 'nullable|string|in:phone,sms,email,social,advertising,event,referral,other',
|
||||||
'start_date' => 'nullable|date',
|
'start_date' => 'nullable|date',
|
||||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||||
'target' => 'nullable|integer|min:0',
|
'target' => 'nullable|integer|min:0',
|
||||||
|
'budget' => 'nullable|numeric|min:0',
|
||||||
|
'actual_cost' => 'nullable|numeric|min:0',
|
||||||
'status' => 'nullable|string|in:draft,active,paused,completed,archived',
|
'status' => 'nullable|string|in:draft,active,paused,completed,archived',
|
||||||
|
'sales_script_id' => 'nullable|exists:sales_scripts,id',
|
||||||
'agent_ids' => 'nullable|array',
|
'agent_ids' => 'nullable|array',
|
||||||
'agent_ids.*' => 'exists:users,id',
|
'agent_ids.*' => 'exists:users,id',
|
||||||
'supervisor_ids' => 'nullable|array',
|
'supervisor_ids' => 'nullable|array',
|
||||||
'supervisor_ids.*' => 'exists:users,id',
|
'supervisor_ids.*' => 'exists:users,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$campaign = Campaign::create($validated);
|
$this->authorizeAssignments($validated);
|
||||||
|
$campaign = DB::transaction(function () use ($validated): Campaign {
|
||||||
|
$campaign = Campaign::create(collect($validated)->except(['agent_ids', 'supervisor_ids'])->all());
|
||||||
|
$this->syncAssignments($campaign, $validated['agent_ids'] ?? [], $validated['supervisor_ids'] ?? []);
|
||||||
|
|
||||||
if ($request->agent_ids) {
|
return $campaign;
|
||||||
$campaign->assignedAgents()->sync($request->agent_ids);
|
});
|
||||||
}
|
|
||||||
if ($request->supervisor_ids) {
|
|
||||||
$campaign->assignedSupervisors()->sync($request->supervisor_ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
ActivityLogger::log('campaign_created', "Campaign {$campaign->name} created", $campaign);
|
ActivityLogger::log('campaign_created', "Campaign {$campaign->name} created", $campaign);
|
||||||
|
|
||||||
return response()->json($campaign->load('assignedAgents', 'assignedSupervisors'), 201);
|
return ApiResponse::success($this->resource($campaign->load('assignedAgents', 'assignedSupervisors', 'salesScript', 'product')), 201, 'کمپین ایجاد شد.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Campaign $campaign): JsonResponse
|
public function show(Campaign $campaign): JsonResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('view', $campaign);
|
Gate::authorize('view', $campaign);
|
||||||
|
|
||||||
return response()->json(
|
return ApiResponse::success($this->resource(
|
||||||
$campaign->load([
|
$campaign->load([
|
||||||
'assignedAgents', 'assignedSupervisors', 'salesScript',
|
'assignedAgents', 'assignedSupervisors', 'salesScript', 'product',
|
||||||
'leads' => fn($q) => $q->with('leadStatus'),
|
'leads' => fn ($q) => $q->with('leadStatus'),
|
||||||
])
|
])
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, Campaign $campaign): JsonResponse
|
public function update(Request $request, Campaign $campaign): JsonResponse
|
||||||
|
|
@ -81,28 +99,34 @@ class CampaignController extends Controller
|
||||||
'name' => 'sometimes|string|max:255',
|
'name' => 'sometimes|string|max:255',
|
||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
'product_service' => 'nullable|string|max:255',
|
'product_service' => 'nullable|string|max:255',
|
||||||
|
'product_id' => 'nullable|exists:products,id',
|
||||||
|
'channel' => 'nullable|string|in:phone,sms,email,social,advertising,event,referral,other',
|
||||||
'start_date' => 'nullable|date',
|
'start_date' => 'nullable|date',
|
||||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||||
'target' => 'nullable|integer|min:0',
|
'target' => 'nullable|integer|min:0',
|
||||||
|
'budget' => 'nullable|numeric|min:0',
|
||||||
|
'actual_cost' => 'nullable|numeric|min:0',
|
||||||
'status' => 'nullable|string|in:draft,active,paused,completed,archived',
|
'status' => 'nullable|string|in:draft,active,paused,completed,archived',
|
||||||
|
'sales_script_id' => 'nullable|exists:sales_scripts,id',
|
||||||
'agent_ids' => 'nullable|array',
|
'agent_ids' => 'nullable|array',
|
||||||
'agent_ids.*' => 'exists:users,id',
|
'agent_ids.*' => 'exists:users,id',
|
||||||
'supervisor_ids' => 'nullable|array',
|
'supervisor_ids' => 'nullable|array',
|
||||||
'supervisor_ids.*' => 'exists:users,id',
|
'supervisor_ids.*' => 'exists:users,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$campaign->update($validated);
|
$this->authorizeAssignments($validated);
|
||||||
|
DB::transaction(function () use ($campaign, $validated, $request): void {
|
||||||
if ($request->has('agent_ids')) {
|
$campaign->update(collect($validated)->except(['agent_ids', 'supervisor_ids'])->all());
|
||||||
$campaign->assignedAgents()->sync($request->agent_ids);
|
if ($request->has('agent_ids') || $request->has('supervisor_ids')) {
|
||||||
}
|
$agentIds = $request->has('agent_ids') ? ($validated['agent_ids'] ?? []) : $campaign->assignedAgents()->pluck('users.id')->all();
|
||||||
if ($request->has('supervisor_ids')) {
|
$supervisorIds = $request->has('supervisor_ids') ? ($validated['supervisor_ids'] ?? []) : $campaign->assignedSupervisors()->pluck('users.id')->all();
|
||||||
$campaign->assignedSupervisors()->sync($request->supervisor_ids);
|
$this->syncAssignments($campaign, $agentIds, $supervisorIds);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ActivityLogger::log('campaign_updated', "Campaign {$campaign->name} updated", $campaign);
|
ActivityLogger::log('campaign_updated', "Campaign {$campaign->name} updated", $campaign);
|
||||||
|
|
||||||
return response()->json($campaign->load('assignedAgents', 'assignedSupervisors'));
|
return ApiResponse::success($this->resource($campaign->load('assignedAgents', 'assignedSupervisors', 'salesScript', 'product')), message: 'کمپین ویرایش شد.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Campaign $campaign): JsonResponse
|
public function destroy(Campaign $campaign): JsonResponse
|
||||||
|
|
@ -111,6 +135,38 @@ class CampaignController extends Controller
|
||||||
|
|
||||||
$campaign->delete();
|
$campaign->delete();
|
||||||
ActivityLogger::log('campaign_deleted', "Campaign {$campaign->name} deleted");
|
ActivityLogger::log('campaign_deleted', "Campaign {$campaign->name} deleted");
|
||||||
return response()->json(['message' => 'کمپین حذف شد']);
|
|
||||||
|
return ApiResponse::success(null, message: 'کمپین حذف شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeAssignments(array $validated): void
|
||||||
|
{
|
||||||
|
foreach (['agent_ids' => 'agent', 'supervisor_ids' => 'supervisor'] as $key => $role) {
|
||||||
|
foreach ($validated[$key] ?? [] as $userId) {
|
||||||
|
$user = User::findOrFail($userId);
|
||||||
|
abort_unless($user->hasRole($role), 422, 'نقش کاربر انتخابشده با نوع تخصیص سازگار نیست.');
|
||||||
|
abort_unless(AccessControl::canAssignUser(auth()->user(), $user->id), 403, 'کاربر انتخابشده خارج از محدوده تیم شما است.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncAssignments(Campaign $campaign, array $agentIds, array $supervisorIds): void
|
||||||
|
{
|
||||||
|
$pivot = [];
|
||||||
|
foreach ($agentIds as $id) {
|
||||||
|
$pivot[$id] = ['role' => 'agent'];
|
||||||
|
}
|
||||||
|
foreach ($supervisorIds as $id) {
|
||||||
|
$pivot[$id] = ['role' => 'supervisor'];
|
||||||
|
}
|
||||||
|
$campaign->assignedAgents()->newPivotQuery()->where('campaign_id', $campaign->id)->delete();
|
||||||
|
if ($pivot) {
|
||||||
|
$campaign->assignedAgents()->attach($pivot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(Campaign $campaign): array
|
||||||
|
{
|
||||||
|
return (new CampaignResource($campaign))->resolve(request());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use App\Services\DuplicateService;
|
||||||
use App\Support\AccessControl;
|
use App\Support\AccessControl;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class CompanyController extends Controller
|
class CompanyController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -16,23 +17,31 @@ class CompanyController extends Controller
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
Gate::authorize('viewAny', Company::class);
|
||||||
$query = Company::with('owner:id,name')->withCount(['contacts', 'leads', 'deals']);
|
$query = Company::with('owner:id,name')->withCount(['contacts', 'leads', 'deals']);
|
||||||
$this->scope($query);
|
AccessControl::scopeCompanies($query, auth()->user());
|
||||||
|
|
||||||
if ($request->search) {
|
if ($request->search) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(fn($q) => $q->where('name', 'like', "%{$search}%")->orWhere('website', 'like', "%{$search}%")->orWhere('city', 'like', "%{$search}%"));
|
$query->where(fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('website', 'like', "%{$search}%")->orWhere('city', 'like', "%{$search}%"));
|
||||||
|
}
|
||||||
|
if ($request->status) {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
|
if ($request->owner_id) {
|
||||||
|
$query->where('owner_id', $request->owner_id);
|
||||||
}
|
}
|
||||||
if ($request->status) $query->where('status', $request->status);
|
|
||||||
if ($request->owner_id) $query->where('owner_id', $request->owner_id);
|
|
||||||
|
|
||||||
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
|
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
Gate::authorize('create', Company::class);
|
||||||
$validated = $this->validated($request);
|
$validated = $this->validated($request);
|
||||||
$suggestions = $this->duplicates->companySuggestions($validated);
|
$validated['owner_id'] ??= auth()->id();
|
||||||
|
abort_unless(AccessControl::canAssignUser(auth()->user(), $validated['owner_id']), 403, 'مالک انتخابشده خارج از محدوده مجاز است.');
|
||||||
|
$suggestions = $this->duplicates->companySuggestions($validated, null, auth()->user());
|
||||||
if ($request->boolean('block_duplicates') && $suggestions) {
|
if ($request->boolean('block_duplicates') && $suggestions) {
|
||||||
return response()->json(['message' => 'شرکت مشابهی در سیستم وجود دارد.', 'duplicates' => $suggestions], 422);
|
return response()->json(['message' => 'شرکت مشابهی در سیستم وجود دارد.', 'duplicates' => $suggestions], 422);
|
||||||
}
|
}
|
||||||
|
|
@ -41,13 +50,17 @@ class CompanyController extends Controller
|
||||||
ActivityLogger::log('company_created', "Company {$company->name} created", $company);
|
ActivityLogger::log('company_created', "Company {$company->name} created", $company);
|
||||||
|
|
||||||
$payload = $company->load('owner:id,name')->toArray();
|
$payload = $company->load('owner:id,name')->toArray();
|
||||||
if ($suggestions) $payload['duplicate_suggestions'] = $suggestions;
|
if ($suggestions) {
|
||||||
|
$payload['duplicate_suggestions'] = $suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json($payload, 201);
|
return response()->json($payload, 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Company $company): JsonResponse
|
public function show(Company $company): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeAccess($company);
|
Gate::authorize('view', $company);
|
||||||
|
|
||||||
return response()->json($company->load([
|
return response()->json($company->load([
|
||||||
'owner:id,name', 'contacts.phones', 'leads.pipelineStage', 'deals.product', 'notes.user:id,name', 'attachments.uploader:id,name',
|
'owner:id,name', 'contacts.phones', 'leads.pipelineStage', 'deals.product', 'notes.user:id,name', 'attachments.uploader:id,name',
|
||||||
]));
|
]));
|
||||||
|
|
@ -55,34 +68,43 @@ class CompanyController extends Controller
|
||||||
|
|
||||||
public function update(Request $request, Company $company): JsonResponse
|
public function update(Request $request, Company $company): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeAccess($company);
|
Gate::authorize('update', $company);
|
||||||
$validated = $this->validated($request, true);
|
$validated = $this->validated($request, true);
|
||||||
|
if (array_key_exists('owner_id', $validated)) {
|
||||||
|
abort_unless(AccessControl::canAssignUser(auth()->user(), $validated['owner_id']), 403, 'مالک انتخابشده خارج از محدوده مجاز است.');
|
||||||
|
}
|
||||||
$company->update($this->prepare($validated));
|
$company->update($this->prepare($validated));
|
||||||
ActivityLogger::log('company_updated', "Company {$company->name} updated", $company);
|
ActivityLogger::log('company_updated', "Company {$company->name} updated", $company);
|
||||||
|
|
||||||
return response()->json($company->fresh('owner:id,name'));
|
return response()->json($company->fresh('owner:id,name'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Company $company): JsonResponse
|
public function destroy(Company $company): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeAccess($company, true);
|
Gate::authorize('delete', $company);
|
||||||
$company->delete();
|
$company->delete();
|
||||||
ActivityLogger::log('company_deleted', "Company {$company->id} deleted");
|
ActivityLogger::log('company_deleted', "Company {$company->id} deleted");
|
||||||
|
|
||||||
return response()->json(['message' => 'شرکت حذف شد']);
|
return response()->json(['message' => 'شرکت حذف شد']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function merge(Request $request, Company $company): JsonResponse
|
public function merge(Request $request, Company $company): JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()?->hasRole('admin') || auth()->user()?->can('merge_duplicates'), 403, 'شما مجوز ادغام رکوردهای تکراری را ندارید.');
|
Gate::authorize('update', $company);
|
||||||
$validated = $request->validate(['target_id' => 'required|exists:companies,id|different:' . $company->id]);
|
abort_unless(auth()->user()?->can('merge_duplicates'), 403, 'شما مجوز ادغام رکوردهای تکراری را ندارید.');
|
||||||
|
$validated = $request->validate(['target_id' => 'required|exists:companies,id|different:'.$company->id]);
|
||||||
$target = Company::findOrFail($validated['target_id']);
|
$target = Company::findOrFail($validated['target_id']);
|
||||||
|
Gate::authorize('update', $target);
|
||||||
|
|
||||||
return response()->json($this->duplicates->mergeCompanies($company, $target, auth()->id()));
|
return response()->json($this->duplicates->mergeCompanies($company, $target, auth()->id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validated(Request $request, bool $partial = false): array
|
private function validated(Request $request, bool $partial = false): array
|
||||||
{
|
{
|
||||||
$sometimes = $partial ? 'sometimes|' : '';
|
$sometimes = $partial ? 'sometimes|' : '';
|
||||||
|
|
||||||
return $request->validate([
|
return $request->validate([
|
||||||
'name' => $sometimes . 'required|string|max:255',
|
'name' => $sometimes.'required|string|max:255',
|
||||||
'company_type' => 'nullable|string|max:80',
|
'company_type' => 'nullable|string|max:80',
|
||||||
'industry' => 'nullable|string|max:120',
|
'industry' => 'nullable|string|max:120',
|
||||||
'city' => 'nullable|string|max:120',
|
'city' => 'nullable|string|max:120',
|
||||||
|
|
@ -96,26 +118,13 @@ class CompanyController extends Controller
|
||||||
|
|
||||||
private function prepare(array $data): array
|
private function prepare(array $data): array
|
||||||
{
|
{
|
||||||
if (array_key_exists('name', $data)) $data['normalized_name'] = DuplicateService::normalizeName($data['name']);
|
if (array_key_exists('name', $data)) {
|
||||||
if (array_key_exists('website', $data)) $data['normalized_website'] = DuplicateService::normalizeWebsite($data['website']);
|
$data['normalized_name'] = DuplicateService::normalizeName($data['name']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('website', $data)) {
|
||||||
|
$data['normalized_website'] = DuplicateService::normalizeWebsite($data['website']);
|
||||||
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function scope($query): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
if ($user?->hasRole('admin')) return;
|
|
||||||
if ($user?->hasRole('agent')) $query->where('owner_id', $user->id);
|
|
||||||
if ($user?->hasRole('supervisor')) {
|
|
||||||
$query->where(function ($q) use ($user) {
|
|
||||||
$q->where('owner_id', $user->id)->orWhereIn('owner_id', AccessControl::teamMemberIds($user));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizeAccess(Company $company, bool $manage = false): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
abort_unless($user && ($user->hasRole('admin') || (!$manage && $company->owner_id === $user->id) || (!$manage && $user->hasRole('supervisor') && in_array($company->owner_id, AccessControl::teamMemberIds($user), true))), 403, 'دسترسی غیرمجاز');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AutomationRule;
|
||||||
|
use App\Models\AutomationRun;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Models\CustomFieldDefinition;
|
||||||
|
use App\Models\CustomFieldValue;
|
||||||
|
use App\Models\Lead;
|
||||||
|
use App\Services\ActivityLogger;
|
||||||
|
use App\Services\AutomationEngine;
|
||||||
|
use App\Support\AccessControl;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ConfigurationController extends Controller
|
||||||
|
{
|
||||||
|
private const ENTITIES = ['lead' => Lead::class, 'company' => Company::class, 'contact' => Contact::class];
|
||||||
|
|
||||||
|
public function __construct(private AutomationEngine $engine) {}
|
||||||
|
|
||||||
|
public function automations(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('manage_automations') || $request->user()->can('view_automation_logs'), 403);
|
||||||
|
$query = AutomationRule::withCount('runs')->with('creator:id,name')
|
||||||
|
->where('trigger', '!=', 'deal_stage_changed')->latest();
|
||||||
|
if (! $request->user()->hasRole('admin')) {
|
||||||
|
$query->where(fn ($q) => $q->whereNull('team_id')->orWhereIn('team_id', AccessControl::teamIds($request->user())));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeAutomation(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('manage_automations'), 403);
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => 'required|string|max:120', 'trigger' => 'required|in:manual,lead_created,lead_scored,sla_breached',
|
||||||
|
'conditions' => 'nullable|array|max:20', 'actions' => 'required|array|min:1|max:10',
|
||||||
|
'conditions.*.field' => 'required|string|max:80', 'conditions.*.operator' => 'required|in:equals,not_equals,greater_than,less_than,contains',
|
||||||
|
'conditions.*.value' => 'nullable',
|
||||||
|
'actions.*.type' => 'required|in:create_task,notify,set_lead_priority', 'team_id' => 'nullable|exists:teams,id',
|
||||||
|
'actions.*.subject' => 'nullable|string|max:255', 'actions.*.assigned_to' => 'nullable|exists:users,id',
|
||||||
|
'actions.*.priority' => 'nullable|in:low,normal,high,urgent', 'actions.*.due_in_minutes' => 'nullable|integer|min:1|max:525600',
|
||||||
|
'actions.*.user_id' => 'nullable|exists:users,id', 'actions.*.title' => 'nullable|string|max:255',
|
||||||
|
'actions.*.message' => 'nullable|string|max:1000', 'actions.*.value' => 'nullable|integer|min:0|max:4',
|
||||||
|
'is_active' => 'boolean', 'max_runs_per_record' => 'integer|min:1|max:20',
|
||||||
|
]);
|
||||||
|
if (! $request->user()->hasRole('admin') && ! empty($data['team_id'])) {
|
||||||
|
abort_unless(in_array((int) $data['team_id'], AccessControl::teamIds($request->user()), true), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(AutomationRule::create($data + ['created_by' => $request->user()->id]), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runAutomation(Request $request, AutomationRule $automationRule): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('manage_automations'), 403);
|
||||||
|
$data = $request->validate(['entity_type' => ['required', Rule::in(array_keys(self::ENTITIES))], 'entity_id' => 'required|integer|min:1', 'event_key' => 'nullable|string|max:150']);
|
||||||
|
$subject = self::ENTITIES[$data['entity_type']]::findOrFail($data['entity_id']);
|
||||||
|
abort_unless(AccessControl::canAccessEntity($request->user(), $subject), 403);
|
||||||
|
$key = $data['event_key'] ?? "manual:{$automationRule->id}:{$data['entity_type']}:{$subject->id}:".Str::uuid();
|
||||||
|
|
||||||
|
return response()->json($this->engine->run($automationRule, $subject, $key), 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function automationRuns(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('view_automation_logs'), 403);
|
||||||
|
|
||||||
|
return response()->json(AutomationRun::with('rule:id,name')->latest()->paginate(min((int) $request->get('per_page', 20), 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customFields(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate(['entity_type' => ['required', Rule::in(array_keys(self::ENTITIES))]]);
|
||||||
|
$role = $request->user()->roles->first()?->name;
|
||||||
|
$fields = CustomFieldDefinition::where('entity_type', $data['entity_type'])->where('is_active', true)
|
||||||
|
->where(fn ($q) => $q->whereNull('visible_to_roles')->orWhereJsonContains('visible_to_roles', $role))->orderBy('sort_order')->get();
|
||||||
|
|
||||||
|
return response()->json($fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeCustomField(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('manage_custom_fields'), 403);
|
||||||
|
$data = $request->validate([
|
||||||
|
'entity_type' => ['required', Rule::in(array_keys(self::ENTITIES))], 'key' => ['required', 'alpha_dash', 'max:80'],
|
||||||
|
'label' => 'required|string|max:120', 'type' => 'required|in:text,textarea,number,date,datetime,boolean,select,multiselect',
|
||||||
|
'options' => 'nullable|array|max:100', 'validation' => 'nullable|array|max:20', 'visible_to_roles' => 'nullable|array|max:3',
|
||||||
|
'default_value' => 'nullable|string|max:1000', 'sort_order' => 'integer|min:0|max:1000', 'is_required' => 'boolean',
|
||||||
|
'is_active' => 'boolean', 'is_filterable' => 'boolean', 'is_searchable' => 'boolean',
|
||||||
|
]);
|
||||||
|
abort_if(CustomFieldDefinition::where('entity_type', $data['entity_type'])->where('key', $data['key'])->exists(), 422, 'کلید فیلد تکراری است.');
|
||||||
|
|
||||||
|
return response()->json(CustomFieldDefinition::create($data + ['created_by' => $request->user()->id]), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function values(Request $request, string $entityType, int $entityId): JsonResponse
|
||||||
|
{
|
||||||
|
$subject = $this->subject($request, $entityType, $entityId);
|
||||||
|
|
||||||
|
return response()->json(CustomFieldValue::with('definition')->where('fieldable_type', $subject::class)->where('fieldable_id', $subject->id)->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateValues(Request $request, string $entityType, int $entityId): JsonResponse
|
||||||
|
{
|
||||||
|
$subject = $this->subject($request, $entityType, $entityId);
|
||||||
|
$data = $request->validate(['values' => 'required|array|max:100']);
|
||||||
|
$definitions = CustomFieldDefinition::where('entity_type', $entityType)->where('is_active', true)->get()->keyBy('key');
|
||||||
|
foreach ($data['values'] as $key => $value) {
|
||||||
|
$definition = $definitions->get($key);
|
||||||
|
if (! $definition) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$column = match ($definition->type) {
|
||||||
|
'number' => 'value_number', 'date' => 'value_date', 'datetime' => 'value_datetime', 'boolean' => 'value_boolean', 'multiselect' => 'value_json', 'textarea' => 'value_text', default => 'value_string'
|
||||||
|
};
|
||||||
|
CustomFieldValue::updateOrCreate([
|
||||||
|
'custom_field_definition_id' => $definition->id, 'fieldable_type' => $subject::class, 'fieldable_id' => $subject->id,
|
||||||
|
], [$column => $value, 'updated_by' => $request->user()->id]);
|
||||||
|
}
|
||||||
|
ActivityLogger::log('custom_fields_updated', "Custom fields updated for {$entityType} {$entityId}", $subject);
|
||||||
|
|
||||||
|
return $this->values($request, $entityType, $entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subject(Request $request, string $type, int $id): Model
|
||||||
|
{
|
||||||
|
abort_unless(isset(self::ENTITIES[$type]), 404);
|
||||||
|
$subject = self::ENTITIES[$type]::findOrFail($id);
|
||||||
|
abort_unless(AccessControl::canAccessEntity($request->user(), $subject), 403);
|
||||||
|
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Company;
|
||||||
use App\Models\Contact;
|
use App\Models\Contact;
|
||||||
use App\Models\ContactPhone;
|
use App\Models\ContactPhone;
|
||||||
|
use App\Models\Deal;
|
||||||
use App\Models\Lead;
|
use App\Models\Lead;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
use App\Support\AccessControl;
|
use App\Support\AccessControl;
|
||||||
|
|
@ -12,20 +14,31 @@ use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class ContactController extends Controller
|
class ContactController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
Gate::authorize('viewAny', Contact::class);
|
||||||
$query = Contact::with('phones', 'lead:id,company,first_name,last_name', 'company:id,name', 'deal:id,title');
|
$query = Contact::with('phones', 'lead:id,company,first_name,last_name', 'company:id,name', 'deal:id,title');
|
||||||
|
AccessControl::scopeContacts($query, auth()->user());
|
||||||
|
|
||||||
if ($request->lead_id) $query->where('lead_id', $request->lead_id);
|
if ($request->lead_id) {
|
||||||
if ($request->company_id) $query->where('company_id', $request->company_id);
|
$query->where('lead_id', $request->lead_id);
|
||||||
if ($request->deal_id) $query->where('deal_id', $request->deal_id);
|
}
|
||||||
if ($request->status) $query->where('status', $request->status);
|
if ($request->company_id) {
|
||||||
|
$query->where('company_id', $request->company_id);
|
||||||
|
}
|
||||||
|
if ($request->deal_id) {
|
||||||
|
$query->where('deal_id', $request->deal_id);
|
||||||
|
}
|
||||||
|
if ($request->status) {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
if ($request->search) {
|
if ($request->search) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(fn($q) => $q->where('name', 'like', "%{$search}%")->orWhere('email', 'like', "%{$search}%"));
|
$query->where(fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('email', 'like', "%{$search}%"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
|
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
|
||||||
|
|
@ -33,9 +46,12 @@ class ContactController extends Controller
|
||||||
|
|
||||||
public function storeStandalone(Request $request): JsonResponse
|
public function storeStandalone(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
Gate::authorize('create', Contact::class);
|
||||||
$validated = $this->validateContact($request);
|
$validated = $this->validateContact($request);
|
||||||
$contact = DB::transaction(fn() => $this->createContact($validated));
|
$this->authorizeParents($validated, 'update');
|
||||||
|
$contact = DB::transaction(fn () => $this->createContact($validated));
|
||||||
ActivityLogger::log('contact_created', "Contact {$contact->name} created", $contact);
|
ActivityLogger::log('contact_created', "Contact {$contact->name} created", $contact);
|
||||||
|
|
||||||
return response()->json($contact->load('phones.lastCaller', 'company:id,name', 'lead:id,company'), 201);
|
return response()->json($contact->load('phones.lastCaller', 'company:id,name', 'lead:id,company'), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,9 +61,10 @@ class ContactController extends Controller
|
||||||
|
|
||||||
$validated = $this->validateContact($request);
|
$validated = $this->validateContact($request);
|
||||||
$validated['lead_id'] = $lead->id;
|
$validated['lead_id'] = $lead->id;
|
||||||
|
$this->authorizeParents($validated, 'update');
|
||||||
|
|
||||||
$contact = DB::transaction(function () use ($lead, $validated) {
|
$contact = DB::transaction(function () use ($lead, $validated) {
|
||||||
if (!empty($validated['is_primary'])) {
|
if (! empty($validated['is_primary'])) {
|
||||||
Contact::where('lead_id', $lead->id)->update(['is_primary' => false]);
|
Contact::where('lead_id', $lead->id)->update(['is_primary' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,16 +84,14 @@ class ContactController extends Controller
|
||||||
|
|
||||||
public function setPrimary(Request $request, Contact $contact): JsonResponse
|
public function setPrimary(Request $request, Contact $contact): JsonResponse
|
||||||
{
|
{
|
||||||
if ($contact->lead) {
|
Gate::authorize('update', $contact);
|
||||||
$this->authorizeLeadAccess($contact->lead);
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'reason' => 'nullable|string|max:255',
|
'reason' => 'nullable|string|max:255',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
DB::transaction(function () use ($contact, $validated) {
|
DB::transaction(function () use ($contact, $validated) {
|
||||||
Contact::where('lead_id', $contact->lead_id)->update(['is_primary' => false]);
|
$this->primaryScope($contact)->whereKeyNot($contact->id)->update(['is_primary' => false]);
|
||||||
$contact->update([
|
$contact->update([
|
||||||
'is_primary' => true,
|
'is_primary' => true,
|
||||||
'primary_reason' => $validated['reason'] ?? 'انتخاب دستی توسط کاربر',
|
'primary_reason' => $validated['reason'] ?? 'انتخاب دستی توسط کاربر',
|
||||||
|
|
@ -90,27 +105,54 @@ class ContactController extends Controller
|
||||||
|
|
||||||
public function update(Request $request, Contact $contact): JsonResponse
|
public function update(Request $request, Contact $contact): JsonResponse
|
||||||
{
|
{
|
||||||
|
Gate::authorize('update', $contact);
|
||||||
$validated = $this->validateContact($request, true);
|
$validated = $this->validateContact($request, true);
|
||||||
$contact->update($validated);
|
$this->authorizeParents($validated, 'update', $contact);
|
||||||
|
DB::transaction(function () use ($contact, $validated, $request): void {
|
||||||
|
$beforePhoneIds = $contact->phones()->withTrashed()->pluck('id')->all();
|
||||||
|
$contact->update(collect($validated)->except('phones')->all());
|
||||||
|
if ($contact->is_primary) {
|
||||||
|
$this->primaryScope($contact)->whereKeyNot($contact->id)->update(['is_primary' => false]);
|
||||||
|
}
|
||||||
if ($request->has('phones')) {
|
if ($request->has('phones')) {
|
||||||
$contact->phones()->delete();
|
$keptIds = [];
|
||||||
foreach ($validated['phones'] ?? [] as $phone) {
|
foreach ($validated['phones'] ?? [] as $phone) {
|
||||||
ContactPhone::create([
|
if (! empty($phone['id'])) {
|
||||||
|
$existing = $contact->phones()->withTrashed()->findOrFail($phone['id']);
|
||||||
|
$existing->restore();
|
||||||
|
$existing->update([
|
||||||
|
'phone' => $phone['phone'],
|
||||||
|
'type' => $phone['type'] ?? $existing->type,
|
||||||
|
'status' => $phone['status'] ?? 'active',
|
||||||
|
]);
|
||||||
|
$keptIds[] = $existing->id;
|
||||||
|
} else {
|
||||||
|
$keptIds[] = ContactPhone::create([
|
||||||
'contact_id' => $contact->id,
|
'contact_id' => $contact->id,
|
||||||
'phone' => $phone['phone'],
|
'phone' => $phone['phone'],
|
||||||
'type' => $phone['type'] ?? 'mobile',
|
'type' => $phone['type'] ?? 'mobile',
|
||||||
'status' => $phone['status'] ?? 'active',
|
'status' => $phone['status'] ?? 'active',
|
||||||
]);
|
])->id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$contact->phones()->whereNotIn('id', $keptIds)->get()->each(function (ContactPhone $phone): void {
|
||||||
|
$phone->update(['status' => 'inactive']);
|
||||||
|
$phone->delete();
|
||||||
|
});
|
||||||
|
ActivityLogger::log('contact_phones_updated', "Contact {$contact->id} phones updated", $contact, ['phone_ids' => $beforePhoneIds], ['phone_ids' => $keptIds]);
|
||||||
|
}
|
||||||
|
});
|
||||||
ActivityLogger::log('contact_updated', "Contact {$contact->name} updated", $contact);
|
ActivityLogger::log('contact_updated', "Contact {$contact->name} updated", $contact);
|
||||||
|
|
||||||
return response()->json($contact->fresh('phones.lastCaller'));
|
return response()->json($contact->fresh('phones.lastCaller'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Contact $contact): JsonResponse
|
public function destroy(Contact $contact): JsonResponse
|
||||||
{
|
{
|
||||||
|
Gate::authorize('delete', $contact);
|
||||||
$contact->delete();
|
$contact->delete();
|
||||||
ActivityLogger::log('contact_deleted', "Contact {$contact->id} deleted");
|
ActivityLogger::log('contact_deleted', "Contact {$contact->id} deleted");
|
||||||
|
|
||||||
return response()->json(['message' => 'مخاطب حذف شد']);
|
return response()->json(['message' => 'مخاطب حذف شد']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,14 +161,43 @@ class ContactController extends Controller
|
||||||
Gate::authorize('update', $lead);
|
Gate::authorize('update', $lead);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authorizeParents(array $validated, string $ability, ?Contact $contact = null): void
|
||||||
|
{
|
||||||
|
$parentIds = [];
|
||||||
|
foreach (['lead_id' => Lead::class, 'company_id' => Company::class, 'deal_id' => Deal::class] as $key => $model) {
|
||||||
|
$parentIds[$key] = array_key_exists($key, $validated)
|
||||||
|
? $validated[$key]
|
||||||
|
: $contact?->{$key};
|
||||||
|
|
||||||
|
if (! empty($parentIds[$key])) {
|
||||||
|
Gate::authorize($ability, $model::findOrFail($parentIds[$key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$lead = $parentIds['lead_id'] ? Lead::findOrFail($parentIds['lead_id']) : null;
|
||||||
|
$deal = $parentIds['deal_id'] ? Deal::findOrFail($parentIds['deal_id']) : null;
|
||||||
|
$companyId = $parentIds['company_id'] ? (int) $parentIds['company_id'] : null;
|
||||||
|
|
||||||
|
$incompatible = ($lead?->company_id && $companyId && (int) $lead->company_id !== $companyId)
|
||||||
|
|| ($deal?->company_id && $companyId && (int) $deal->company_id !== $companyId)
|
||||||
|
|| ($deal?->lead_id && $lead && (int) $deal->lead_id !== (int) $lead->id);
|
||||||
|
|
||||||
|
if ($incompatible) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'relationships' => 'شرکت، سرنخ و معامله انتخابشده با یکدیگر سازگار نیستند.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function validateContact(Request $request, bool $partial = false): array
|
private function validateContact(Request $request, bool $partial = false): array
|
||||||
{
|
{
|
||||||
$sometimes = $partial ? 'sometimes|' : '';
|
$sometimes = $partial ? 'sometimes|' : '';
|
||||||
|
|
||||||
return $request->validate([
|
return $request->validate([
|
||||||
'lead_id' => 'nullable|exists:leads,id',
|
'lead_id' => $sometimes.'nullable|exists:leads,id',
|
||||||
'company_id' => 'nullable|exists:companies,id',
|
'company_id' => $sometimes.'nullable|exists:companies,id',
|
||||||
'deal_id' => 'nullable|exists:deals,id',
|
'deal_id' => $sometimes.'nullable|exists:deals,id',
|
||||||
'name' => $sometimes . 'required|string|max:255',
|
'name' => $sometimes.'required|string|max:255',
|
||||||
'first_name' => 'nullable|string|max:120',
|
'first_name' => 'nullable|string|max:120',
|
||||||
'last_name' => 'nullable|string|max:120',
|
'last_name' => 'nullable|string|max:120',
|
||||||
'role' => 'nullable|string|max:80',
|
'role' => 'nullable|string|max:80',
|
||||||
|
|
@ -137,8 +208,9 @@ class ContactController extends Controller
|
||||||
'status' => 'nullable|string|max:30',
|
'status' => 'nullable|string|max:30',
|
||||||
'is_primary' => 'nullable|boolean',
|
'is_primary' => 'nullable|boolean',
|
||||||
'primary_reason' => 'nullable|string|max:255',
|
'primary_reason' => 'nullable|string|max:255',
|
||||||
'phones' => ($partial ? 'nullable' : 'required') . '|array|min:1',
|
'phones' => ($partial ? 'nullable' : 'required').'|array|min:1',
|
||||||
'phones.*.phone' => 'required|string|max:30',
|
'phones.*.phone' => 'required|string|max:30',
|
||||||
|
'phones.*.id' => 'nullable|integer|exists:contact_phones,id',
|
||||||
'phones.*.type' => 'nullable|string|in:mobile,landline,extension',
|
'phones.*.type' => 'nullable|string|in:mobile,landline,extension',
|
||||||
'phones.*.status' => 'nullable|string|max:30',
|
'phones.*.status' => 'nullable|string|max:30',
|
||||||
]);
|
]);
|
||||||
|
|
@ -160,10 +232,14 @@ class ContactController extends Controller
|
||||||
'description' => $validated['description'] ?? null,
|
'description' => $validated['description'] ?? null,
|
||||||
'status' => $validated['status'] ?? 'active',
|
'status' => $validated['status'] ?? 'active',
|
||||||
'is_primary' => (bool) ($validated['is_primary'] ?? false),
|
'is_primary' => (bool) ($validated['is_primary'] ?? false),
|
||||||
'primary_reason' => $validated['primary_reason'] ?? (!empty($validated['is_primary']) ? 'انتخاب دستی توسط کاربر' : null),
|
'primary_reason' => $validated['primary_reason'] ?? (! empty($validated['is_primary']) ? 'انتخاب دستی توسط کاربر' : null),
|
||||||
'created_by' => auth()->id(),
|
'created_by' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($contact->is_primary) {
|
||||||
|
$this->primaryScope($contact)->whereKeyNot($contact->id)->update(['is_primary' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($validated['phones'] as $phone) {
|
foreach ($validated['phones'] as $phone) {
|
||||||
ContactPhone::create([
|
ContactPhone::create([
|
||||||
'contact_id' => $contact->id,
|
'contact_id' => $contact->id,
|
||||||
|
|
@ -175,4 +251,20 @@ class ContactController extends Controller
|
||||||
|
|
||||||
return $contact;
|
return $contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function primaryScope(Contact $contact)
|
||||||
|
{
|
||||||
|
$query = Contact::query();
|
||||||
|
if ($contact->lead_id) {
|
||||||
|
return $query->where('lead_id', $contact->lead_id);
|
||||||
|
}
|
||||||
|
if ($contact->company_id) {
|
||||||
|
return $query->whereNull('lead_id')->where('company_id', $contact->company_id);
|
||||||
|
}
|
||||||
|
if ($contact->deal_id) {
|
||||||
|
return $query->whereNull('lead_id')->whereNull('company_id')->where('deal_id', $contact->deal_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->whereNull('lead_id')->whereNull('company_id')->whereNull('deal_id')->where('created_by', $contact->created_by);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\DashboardService;
|
use App\Services\DashboardService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -13,13 +15,14 @@ class DashboardController extends Controller
|
||||||
|
|
||||||
public function admin(): JsonResponse
|
public function admin(): JsonResponse
|
||||||
{
|
{
|
||||||
|
Gate::authorize('view-admin-dashboard');
|
||||||
$stats = $this->dashboardService->admin();
|
$stats = $this->dashboardService->admin();
|
||||||
|
|
||||||
// Agent ranking
|
// Agent ranking
|
||||||
$agents = \App\Models\User::role('agent')
|
$agents = User::role('agent')
|
||||||
->withCount(['assignedLeads', 'calls'])
|
->withCount(['assignedLeads', 'calls'])
|
||||||
->get()
|
->get()
|
||||||
->map(fn($agent) => [
|
->map(fn ($agent) => [
|
||||||
'id' => $agent->id,
|
'id' => $agent->id,
|
||||||
'name' => $agent->name,
|
'name' => $agent->name,
|
||||||
'leads_count' => $agent->assigned_leads_count,
|
'leads_count' => $agent->assigned_leads_count,
|
||||||
|
|
@ -35,12 +38,13 @@ class DashboardController extends Controller
|
||||||
|
|
||||||
public function supervisor(): JsonResponse
|
public function supervisor(): JsonResponse
|
||||||
{
|
{
|
||||||
|
Gate::authorize('view-supervisor-dashboard');
|
||||||
$teamId = auth()->user()->teams->first()?->id;
|
$teamId = auth()->user()->teams->first()?->id;
|
||||||
$stats = $this->dashboardService->supervisor($teamId);
|
$stats = $this->dashboardService->supervisor($teamId);
|
||||||
|
|
||||||
// Team members
|
// Team members
|
||||||
$team = \App\Models\Team::with('members')->find($teamId);
|
$team = Team::with('members')->find($teamId);
|
||||||
$stats['team_members'] = $team?->members->map(fn($member) => [
|
$stats['team_members'] = $team?->members->map(fn ($member) => [
|
||||||
'id' => $member->id,
|
'id' => $member->id,
|
||||||
'name' => $member->name,
|
'name' => $member->name,
|
||||||
'is_online' => $member->last_login_at && $member->last_login_at->gt(now()->subMinutes(15)),
|
'is_online' => $member->last_login_at && $member->last_login_at->gt(now()->subMinutes(15)),
|
||||||
|
|
@ -52,7 +56,9 @@ class DashboardController extends Controller
|
||||||
|
|
||||||
public function agent(): JsonResponse
|
public function agent(): JsonResponse
|
||||||
{
|
{
|
||||||
|
Gate::authorize('view-agent-dashboard');
|
||||||
$stats = $this->dashboardService->agent();
|
$stats = $this->dashboardService->agent();
|
||||||
|
|
||||||
return response()->json($stats);
|
return response()->json($stats);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,85 +3,114 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\Contact;
|
||||||
use App\Models\Deal;
|
use App\Models\Deal;
|
||||||
|
use App\Models\Lead;
|
||||||
|
use App\Models\Product;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
use App\Support\AccessControl;
|
use App\Support\AccessControl;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class DealController extends Controller
|
class DealController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = Deal::with('company:id,name', 'lead:id,company,first_name,last_name', 'contact:id,name', 'product:id,name', 'owner:id,name');
|
Gate::authorize('viewAny', Deal::class);
|
||||||
$this->scope($query);
|
$query = Deal::with('company:id,name', 'lead:id,company,first_name,last_name', 'contact:id,name', 'product:id,name', 'owner:id,name', 'pipeline:id,name', 'stage:id,name,color,probability');
|
||||||
foreach (['status', 'sales_stage', 'company_id', 'lead_id', 'owner_id', 'product_id'] as $filter) {
|
AccessControl::scopeDeals($query, auth()->user());
|
||||||
if ($request->filled($filter)) $query->where($filter, $request->get($filter));
|
foreach (['status', 'sales_stage', 'company_id', 'lead_id', 'owner_id', 'product_id', 'pipeline_id', 'deal_stage_id', 'forecast_category'] as $filter) {
|
||||||
|
if ($request->filled($filter)) {
|
||||||
|
$query->where($filter, $request->get($filter));
|
||||||
}
|
}
|
||||||
if ($request->search) $query->where('title', 'like', "%{$request->search}%");
|
}
|
||||||
|
if ($request->search) {
|
||||||
|
$query->where('title', 'like', "%{$request->search}%");
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
|
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$deal = Deal::create($this->validated($request) + ['created_by' => auth()->id()]);
|
Gate::authorize('create', Deal::class);
|
||||||
|
$validated = $this->validated($request);
|
||||||
|
$validated['owner_id'] ??= auth()->id();
|
||||||
|
$this->authorizeRelations($validated);
|
||||||
|
$deal = Deal::create($validated + ['created_by' => auth()->id()]);
|
||||||
ActivityLogger::log('deal_created', "Deal {$deal->title} created", $deal);
|
ActivityLogger::log('deal_created', "Deal {$deal->title} created", $deal);
|
||||||
|
|
||||||
return response()->json($deal->load('company', 'contact', 'product', 'owner'), 201);
|
return response()->json($deal->load('company', 'contact', 'product', 'owner'), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Deal $deal): JsonResponse
|
public function show(Deal $deal): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeAccess($deal);
|
Gate::authorize('view', $deal);
|
||||||
return response()->json($deal->load('company', 'lead', 'contact.phones', 'product.salesScript.sections', 'owner:id,name', 'timelineNotes.user:id,name', 'attachments.uploader:id,name'));
|
|
||||||
|
return response()->json($deal->load('company', 'lead', 'contact.phones', 'product.salesScript.sections', 'owner:id,name', 'pipeline', 'stage', 'stageHistory.fromStage', 'stageHistory.toStage', 'stageHistory.actor:id,name', 'customFieldValues.definition', 'tasks.assignee:id,name', 'notes.user:id,name', 'attachments.uploader:id,name'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, Deal $deal): JsonResponse
|
public function update(Request $request, Deal $deal): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeAccess($deal);
|
Gate::authorize('update', $deal);
|
||||||
$deal->update($this->validated($request, true));
|
$validated = $this->validated($request, true);
|
||||||
|
$this->authorizeRelations($validated);
|
||||||
|
$deal->update($validated);
|
||||||
ActivityLogger::log('deal_updated', "Deal {$deal->title} updated", $deal);
|
ActivityLogger::log('deal_updated', "Deal {$deal->title} updated", $deal);
|
||||||
|
|
||||||
return response()->json($deal->fresh('company', 'contact', 'product', 'owner'));
|
return response()->json($deal->fresh('company', 'contact', 'product', 'owner'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Deal $deal): JsonResponse
|
public function destroy(Deal $deal): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeAccess($deal, true);
|
Gate::authorize('delete', $deal);
|
||||||
$deal->delete();
|
$deal->delete();
|
||||||
ActivityLogger::log('deal_deleted', "Deal {$deal->id} deleted");
|
ActivityLogger::log('deal_deleted', "Deal {$deal->id} deleted");
|
||||||
|
|
||||||
return response()->json(['message' => 'فرصت فروش حذف شد']);
|
return response()->json(['message' => 'فرصت فروش حذف شد']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validated(Request $request, bool $partial = false): array
|
private function validated(Request $request, bool $partial = false): array
|
||||||
{
|
{
|
||||||
$sometimes = $partial ? 'sometimes|' : '';
|
$sometimes = $partial ? 'sometimes|' : '';
|
||||||
|
|
||||||
return $request->validate([
|
return $request->validate([
|
||||||
'title' => $sometimes . 'required|string|max:255',
|
'title' => $sometimes.'required|string|max:255',
|
||||||
'company_id' => 'nullable|exists:companies,id',
|
'company_id' => 'nullable|exists:companies,id',
|
||||||
'lead_id' => 'nullable|exists:leads,id',
|
'lead_id' => 'nullable|exists:leads,id',
|
||||||
'contact_id' => 'nullable|exists:contacts,id',
|
'contact_id' => 'nullable|exists:contacts,id',
|
||||||
'product_id' => 'nullable|exists:products,id',
|
'product_id' => 'nullable|exists:products,id',
|
||||||
|
'pipeline_id' => 'nullable|exists:pipelines,id',
|
||||||
|
'deal_stage_id' => 'nullable|exists:deal_stages,id',
|
||||||
'estimated_value' => 'nullable|numeric|min:0',
|
'estimated_value' => 'nullable|numeric|min:0',
|
||||||
|
'final_amount' => 'nullable|numeric|min:0',
|
||||||
'win_probability' => 'nullable|integer|min:0|max:100',
|
'win_probability' => 'nullable|integer|min:0|max:100',
|
||||||
'sales_stage' => 'nullable|string|max:80',
|
'sales_stage' => 'nullable|string|max:80',
|
||||||
'expected_close_date' => 'nullable|date',
|
'expected_close_date' => 'nullable|date',
|
||||||
'owner_id' => 'nullable|exists:users,id',
|
'owner_id' => 'nullable|exists:users,id',
|
||||||
'status' => 'nullable|string|max:40',
|
'status' => 'nullable|string|max:40',
|
||||||
'won_lost_reason' => 'nullable|string|max:255',
|
'won_lost_reason' => 'nullable|string|max:255',
|
||||||
|
'competitor' => 'nullable|string|max:255',
|
||||||
|
'forecast_category' => 'nullable|in:pipeline,best_case,commit,closed',
|
||||||
'notes' => 'nullable|string',
|
'notes' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function scope($query): void
|
private function authorizeRelations(array $validated): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
if ($user?->hasRole('admin')) return;
|
if (array_key_exists('owner_id', $validated)) {
|
||||||
if ($user?->hasRole('agent')) $query->where('owner_id', $user->id);
|
abort_unless(AccessControl::canAssignUser($user, $validated['owner_id']), 403, 'مالک انتخابشده خارج از محدوده مجاز است.');
|
||||||
if ($user?->hasRole('supervisor')) $query->whereIn('owner_id', array_merge([$user->id], AccessControl::teamMemberIds($user)));
|
}
|
||||||
|
foreach (['company_id' => Company::class, 'lead_id' => Lead::class, 'contact_id' => Contact::class] as $key => $model) {
|
||||||
|
if (! empty($validated[$key])) {
|
||||||
|
Gate::authorize('view', $model::findOrFail($validated[$key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! empty($validated['product_id'])) {
|
||||||
|
Gate::authorize('view', Product::findOrFail($validated['product_id']));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function authorizeAccess(Deal $deal, bool $manage = false): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
abort_unless($user && ($user->hasRole('admin') || (!$manage && $deal->owner_id === $user->id) || (!$manage && $user->hasRole('supervisor') && in_array($deal->owner_id, AccessControl::teamMemberIds($user), true))), 403, 'دسترسی غیرمجاز');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ class DuplicateController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$items = $validated['entity_type'] === 'company'
|
$items = $validated['entity_type'] === 'company'
|
||||||
? $this->duplicates->companySuggestions($validated, $validated['exclude_id'] ?? null)
|
? $this->duplicates->companySuggestions($validated, $validated['exclude_id'] ?? null, auth()->user())
|
||||||
: $this->duplicates->leadSuggestions($validated, $validated['exclude_id'] ?? null);
|
: $this->duplicates->leadSuggestions($validated, $validated['exclude_id'] ?? null, auth()->user());
|
||||||
|
|
||||||
return response()->json(['data' => $items]);
|
return response()->json(['data' => $items]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,184 +3,150 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\FollowUpResource;
|
||||||
|
use App\Http\Responses\ApiResponse;
|
||||||
use App\Models\FollowUp;
|
use App\Models\FollowUp;
|
||||||
use App\Models\Lead;
|
use App\Models\Lead;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
use App\Services\NotificationService;
|
use App\Services\FollowUpService;
|
||||||
use App\Support\AccessControl;
|
use App\Support\AccessControl;
|
||||||
use App\Support\WorkingHours;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class FollowUpController extends Controller
|
class FollowUpController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private FollowUpService $service) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
|
||||||
Gate::authorize('viewAny', FollowUp::class);
|
Gate::authorize('viewAny', FollowUp::class);
|
||||||
|
$query = FollowUp::with('lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name');
|
||||||
|
AccessControl::scopeFollowUps($query, $request->user());
|
||||||
|
$this->applyFilters($query, $request);
|
||||||
|
|
||||||
$query = FollowUp::with('lead:id,first_name,last_name,phone', 'user:id,name');
|
$direction = $request->get('sort', '-created_at') === 'created_at' ? 'asc' : 'desc';
|
||||||
|
$paginator = $query->orderBy('created_at', $direction)->paginate(min((int) $request->get('per_page', 15), 100));
|
||||||
|
|
||||||
AccessControl::scopeFollowUps($query, $user);
|
return ApiResponse::paginated($paginator, fn (FollowUp $followUp) => $this->resource($followUp));
|
||||||
|
|
||||||
if ($request->status) {
|
|
||||||
$query->where('status', $request->status);
|
|
||||||
}
|
|
||||||
if ($request->lead_id) {
|
|
||||||
$query->where('lead_id', $request->lead_id);
|
|
||||||
}
|
|
||||||
if ($request->date_from) {
|
|
||||||
$query->whereDate('scheduled_at', '>=', $request->date_from);
|
|
||||||
}
|
|
||||||
if ($request->date_to) {
|
|
||||||
$query->whereDate('scheduled_at', '<=', $request->date_to);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json($query->orderBy('scheduled_at')->paginate($request->per_page ?? 15));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'lead_id' => 'required|exists:leads,id',
|
'lead_id' => 'required|exists:leads,id',
|
||||||
|
'user_id' => 'nullable|exists:users,id',
|
||||||
|
'call_id' => 'nullable|exists:calls,id',
|
||||||
'scheduled_at' => 'required|date',
|
'scheduled_at' => 'required|date',
|
||||||
'notes' => 'nullable|string',
|
'notes' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$lead = Lead::findOrFail($validated['lead_id']);
|
$lead = Lead::findOrFail($validated['lead_id']);
|
||||||
Gate::authorize('create', [FollowUp::class, $lead]);
|
Gate::authorize('create', [FollowUp::class, $lead]);
|
||||||
if (!WorkingHours::followUpAllowed($validated['scheduled_at'])) {
|
$assigneeId = (int) ($validated['user_id'] ?? $request->user()->id);
|
||||||
return response()->json(['message' => 'زمان پیگیری خارج از روز یا ساعت کاری مجاز است.'], 422);
|
abort_unless(AccessControl::canAssignUser($request->user(), $assigneeId), 403, 'مسئول انتخابشده خارج از محدوده مجاز است.');
|
||||||
|
|
||||||
|
$followUp = $this->service->schedule(
|
||||||
|
$lead,
|
||||||
|
$assigneeId,
|
||||||
|
$validated['scheduled_at'],
|
||||||
|
$request->user(),
|
||||||
|
$validated['notes'] ?? null,
|
||||||
|
$validated['call_id'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($followUp), 201, 'پیگیری ایجاد شد.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$followUp = FollowUp::create([
|
public function show(FollowUp $followUp): JsonResponse
|
||||||
'lead_id' => $validated['lead_id'],
|
{
|
||||||
'user_id' => auth()->id(),
|
Gate::authorize('view', $followUp);
|
||||||
'scheduled_at' => $validated['scheduled_at'],
|
|
||||||
'notes' => $validated['notes'] ?? null,
|
|
||||||
'status' => 'pending',
|
|
||||||
]);
|
|
||||||
|
|
||||||
ActivityLogger::log('follow_up_created', "Follow-up for lead {$validated['lead_id']} scheduled", $followUp);
|
return ApiResponse::success($this->resource($followUp->load('lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name')));
|
||||||
NotificationService::notifyFollowUpReminder($followUp->user_id, $lead->full_name ?: ($lead->company ?? "لید {$lead->id}"));
|
|
||||||
|
|
||||||
return response()->json($followUp->load('lead'), 201);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, FollowUp $followUp): JsonResponse
|
public function update(Request $request, FollowUp $followUp): JsonResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('update', $followUp);
|
Gate::authorize('update', $followUp);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
'user_id' => 'sometimes|exists:users,id',
|
||||||
'scheduled_at' => 'sometimes|date',
|
'scheduled_at' => 'sometimes|date',
|
||||||
'notes' => 'nullable|string',
|
'notes' => 'nullable|string',
|
||||||
'status' => 'sometimes|string|in:pending,completed,cancelled',
|
'status' => 'sometimes|string|in:pending,completed,cancelled',
|
||||||
]);
|
]);
|
||||||
|
if (isset($validated['user_id'])) {
|
||||||
|
abort_unless(AccessControl::canAssignUser($request->user(), (int) $validated['user_id']), 403, 'مسئول انتخابشده خارج از محدوده مجاز است.');
|
||||||
|
}
|
||||||
|
$followUp = $this->service->update($followUp, $validated);
|
||||||
|
|
||||||
$followUp->update($validated);
|
return ApiResponse::success($this->resource($followUp), message: 'پیگیری ویرایش شد.');
|
||||||
|
|
||||||
ActivityLogger::log('follow_up_updated', "Follow-up {$followUp->id} updated", $followUp);
|
|
||||||
|
|
||||||
return response()->json($followUp->load('lead'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function markDone(FollowUp $followUp): JsonResponse
|
public function markDone(FollowUp $followUp): JsonResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('update', $followUp);
|
Gate::authorize('complete', $followUp);
|
||||||
|
$followUp->update(['status' => 'completed', 'completed_at' => now(), 'is_overdue' => false]);
|
||||||
$followUp->update([
|
|
||||||
'status' => 'completed',
|
|
||||||
'completed_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ActivityLogger::log('follow_up_completed', "Follow-up {$followUp->id} completed", $followUp);
|
ActivityLogger::log('follow_up_completed', "Follow-up {$followUp->id} completed", $followUp);
|
||||||
|
|
||||||
return response()->json($followUp);
|
return ApiResponse::success($this->resource($followUp->fresh(['lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name'])), message: 'پیگیری تکمیل شد.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function today(): JsonResponse
|
public function destroy(FollowUp $followUp): JsonResponse
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
Gate::authorize('delete', $followUp);
|
||||||
$query = FollowUp::with('lead:id,first_name,last_name,phone')
|
$followUp->delete();
|
||||||
|
ActivityLogger::log('follow_up_deleted', "Follow-up {$followUp->id} deleted", $followUp);
|
||||||
|
|
||||||
|
return ApiResponse::success(null, message: 'پیگیری حذف شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function today(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('viewAny', FollowUp::class);
|
||||||
|
$query = FollowUp::with('lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name')
|
||||||
->whereDate('scheduled_at', now()->toDateString())
|
->whereDate('scheduled_at', now()->toDateString())
|
||||||
->where('status', 'pending');
|
->where('status', 'pending');
|
||||||
AccessControl::scopeFollowUps($query, $user);
|
AccessControl::scopeFollowUps($query, $request->user());
|
||||||
|
$direction = $request->get('sort', '-created_at') === 'created_at' ? 'asc' : 'desc';
|
||||||
|
$paginator = $query->orderBy('created_at', $direction)->paginate(min((int) $request->get('per_page', 50), 100));
|
||||||
|
|
||||||
$followUps = $query->orderBy('scheduled_at')->get();
|
return ApiResponse::paginated($paginator, fn (FollowUp $followUp) => $this->resource($followUp));
|
||||||
foreach ($followUps as $followUp) {
|
|
||||||
NotificationService::sendOnce(
|
|
||||||
$followUp->user_id,
|
|
||||||
'پیگیری عقبافتاده',
|
|
||||||
'پیگیری لید ' . ($followUp->lead?->full_name ?: ($followUp->lead?->company ?? "لید {$followUp->lead_id}")) . ' عقب افتاده است',
|
|
||||||
'overdue_follow_up',
|
|
||||||
['follow_up_id' => $followUp->id, 'lead_id' => $followUp->lead_id]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($followUps);
|
public function overdue(Request $request): JsonResponse
|
||||||
}
|
|
||||||
|
|
||||||
public function overdue(): JsonResponse
|
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
Gate::authorize('viewAny', FollowUp::class);
|
||||||
$query = FollowUp::with('lead:id,first_name,last_name,phone')
|
$query = FollowUp::with('lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name')
|
||||||
->where('status', 'pending')
|
->where('status', 'pending')
|
||||||
->where(function ($q) {
|
->where(fn ($overdue) => $overdue->where('is_overdue', true)->orWhere('scheduled_at', '<', now()));
|
||||||
$q->where('is_overdue', true)
|
AccessControl::scopeFollowUps($query, $request->user());
|
||||||
->orWhere('scheduled_at', '<', now());
|
$direction = $request->get('sort', '-created_at') === 'created_at' ? 'asc' : 'desc';
|
||||||
});
|
$paginator = $query->orderBy('created_at', $direction)->paginate(min((int) $request->get('per_page', 50), 100));
|
||||||
AccessControl::scopeFollowUps($query, $user);
|
|
||||||
|
|
||||||
return response()->json($query->orderBy('scheduled_at')->get());
|
return ApiResponse::paginated($paginator, fn (FollowUp $followUp) => $this->resource($followUp));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canManageFollowUp(FollowUp $followUp): bool
|
private function applyFilters($query, Request $request): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', $request->get('status'));
|
||||||
if (!$user) {
|
}
|
||||||
return false;
|
if ($request->filled('lead_id')) {
|
||||||
|
$query->where('lead_id', $request->integer('lead_id'));
|
||||||
|
}
|
||||||
|
if ($request->filled('user_id')) {
|
||||||
|
$query->where('user_id', $request->integer('user_id'));
|
||||||
|
}
|
||||||
|
if ($request->filled('date_from')) {
|
||||||
|
$query->whereDate('scheduled_at', '>=', $request->get('date_from'));
|
||||||
|
}
|
||||||
|
if ($request->filled('date_to')) {
|
||||||
|
$query->whereDate('scheduled_at', '<=', $request->get('date_to'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->hasRole('admin')) {
|
private function resource(FollowUp $followUp): array
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user->hasRole('agent')) {
|
|
||||||
return $followUp->user_id === $user->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user->hasRole('supervisor')) {
|
|
||||||
$teamIds = $user->teams->pluck('id')->toArray();
|
|
||||||
$agentIds = \App\Models\Team::whereIn('id', $teamIds)->with('members')->get()->pluck('members.*.id')->flatten();
|
|
||||||
return $agentIds->contains($followUp->user_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canAccessLead(\App\Models\Lead $lead): bool
|
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
return (new FollowUpResource($followUp))->resolve(request());
|
||||||
|
|
||||||
if (!$user) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user->hasRole('admin')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user->hasRole('agent')) {
|
|
||||||
return $lead->assigned_to === $user->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user->hasRole('supervisor')) {
|
|
||||||
$teamIds = $user->teams->pluck('id')->toArray();
|
|
||||||
return in_array($lead->team_id, $teamIds, true) || $lead->assigned_to === $user->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@ namespace App\Http\Controllers\Api;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\ImportBatch;
|
use App\Models\ImportBatch;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Services\ImportService;
|
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
|
use App\Services\ImportService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||||
|
|
||||||
|
|
@ -25,7 +26,7 @@ class ImportController extends Controller
|
||||||
$allowedTypes = Setting::where('key', 'import_allowed_file_types')->value('value') ?: 'xlsx,xls,csv';
|
$allowedTypes = Setting::where('key', 'import_allowed_file_types')->value('value') ?: 'xlsx,xls,csv';
|
||||||
$maxMb = max(1, (int) (Setting::where('key', 'import_max_file_size_mb')->value('value') ?: 10));
|
$maxMb = max(1, (int) (Setting::where('key', 'import_max_file_size_mb')->value('value') ?: 10));
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'file' => 'required|file|mimes:' . $allowedTypes . '|max:' . ($maxMb * 1024),
|
'file' => 'required|file|mimes:'.$allowedTypes.'|max:'.($maxMb * 1024),
|
||||||
], [
|
], [
|
||||||
'file.mimes' => 'نوع فایل import مجاز نیست.',
|
'file.mimes' => 'نوع فایل import مجاز نیست.',
|
||||||
'file.max' => "حجم فایل import نباید بیشتر از {$maxMb} مگابایت باشد.",
|
'file.max' => "حجم فایل import نباید بیشتر از {$maxMb} مگابایت باشد.",
|
||||||
|
|
@ -46,7 +47,7 @@ class ImportController extends Controller
|
||||||
$preview = $this->importService->preview($fullPath);
|
$preview = $this->importService->preview($fullPath);
|
||||||
|
|
||||||
// Store rows for later processing
|
// Store rows for later processing
|
||||||
$rows = \Maatwebsite\Excel\Facades\Excel::toArray([], $fullPath)[0] ?? [];
|
$rows = Excel::toArray([], $fullPath)[0] ?? [];
|
||||||
$dataRows = array_slice($rows, 1);
|
$dataRows = array_slice($rows, 1);
|
||||||
|
|
||||||
foreach ($dataRows as $index => $row) {
|
foreach ($dataRows as $index => $row) {
|
||||||
|
|
@ -68,6 +69,7 @@ class ImportController extends Controller
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$batch->update(['status' => 'failed', 'errors' => $e->getMessage()]);
|
$batch->update(['status' => 'failed', 'errors' => $e->getMessage()]);
|
||||||
|
|
||||||
return response()->json(['message' => $e->getMessage()], 422);
|
return response()->json(['message' => $e->getMessage()], 422);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +129,7 @@ class ImportController extends Controller
|
||||||
|
|
||||||
$this->importService->rollback($batchId);
|
$this->importService->rollback($batchId);
|
||||||
ActivityLogger::log('lead_import_rolled_back', "Import batch {$batchId} rolled back", $batch);
|
ActivityLogger::log('lead_import_rolled_back', "Import batch {$batchId} rolled back", $batch);
|
||||||
|
|
||||||
return response()->json(['message' => 'بازگشت import انجام شد']);
|
return response()->json(['message' => 'بازگشت import انجام شد']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,7 +148,7 @@ class ImportController extends Controller
|
||||||
{
|
{
|
||||||
Gate::authorize('import', ImportBatch::class);
|
Gate::authorize('import', ImportBatch::class);
|
||||||
|
|
||||||
$spreadsheet = new Spreadsheet();
|
$spreadsheet = new Spreadsheet;
|
||||||
$sheet = $spreadsheet->getActiveSheet();
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
$sheet->fromArray([
|
$sheet->fromArray([
|
||||||
['company', 'phone', 'phone_secondary', 'first_name', 'last_name', 'email', 'city', 'province', 'source', 'product_interest', 'priority', 'lead_score', 'interest_level', 'notes', 'tags'],
|
['company', 'phone', 'phone_secondary', 'first_name', 'last_name', 'email', 'city', 'province', 'source', 'product_interest', 'priority', 'lead_score', 'interest_level', 'notes', 'tags'],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Lead;
|
||||||
|
use App\Models\SlaBreach;
|
||||||
|
use App\Models\SlaRule;
|
||||||
|
use App\Services\ActivityLogger;
|
||||||
|
use App\Services\LeadScoringService;
|
||||||
|
use App\Services\SlaMonitorService;
|
||||||
|
use App\Support\AccessControl;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class IntelligenceController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private LeadScoringService $scoring, private SlaMonitorService $slaMonitor) {}
|
||||||
|
|
||||||
|
public function scoreLead(Request $request, Lead $lead): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('score_leads') && AccessControl::canAccessLead($request->user(), $lead), 403);
|
||||||
|
|
||||||
|
return response()->json($this->scoring->score($lead));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkScore(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('score_leads'), 403);
|
||||||
|
$ids = $request->validate(['ids' => 'required|array|min:1|max:100', 'ids.*' => 'integer|exists:leads,id'])['ids'];
|
||||||
|
$query = Lead::whereKey($ids);
|
||||||
|
AccessControl::scopeLeads($query, $request->user());
|
||||||
|
$scored = $query->get()->map(fn (Lead $lead) => $this->scoring->score($lead));
|
||||||
|
|
||||||
|
return response()->json(['count' => $scored->count(), 'data' => $scored]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function slaRules(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('view_sla'), 403);
|
||||||
|
|
||||||
|
return response()->json(SlaRule::where('event', '!=', 'stale_deal')->latest()->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeSlaRule(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('manage_sla'), 403);
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => 'required|string|max:120', 'event' => 'required|in:first_contact,follow_up',
|
||||||
|
'warning_minutes' => 'required|integer|min:1|max:525600', 'breach_minutes' => 'required|integer|gt:warning_minutes|max:525600',
|
||||||
|
'scope' => 'nullable|array|max:10', 'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(SlaRule::create($data + ['created_by' => $request->user()->id]), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function breaches(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('view_sla'), 403);
|
||||||
|
$query = SlaBreach::with('rule:id,name,event', 'assignee:id,name', 'breachable');
|
||||||
|
if ($request->user()->hasRole('agent')) {
|
||||||
|
$query->where('assigned_to', $request->user()->id);
|
||||||
|
} elseif ($request->user()->hasRole('supervisor')) {
|
||||||
|
$query->whereIn('assigned_to', AccessControl::teamMemberIds($request->user()));
|
||||||
|
}
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', $request->string('status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 20), 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detect(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('manage_sla'), 403);
|
||||||
|
|
||||||
|
return response()->json(['created' => $this->slaMonitor->run()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve(Request $request, SlaBreach $slaBreach): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('manage_sla') || $slaBreach->assigned_to === $request->user()->id, 403);
|
||||||
|
$slaBreach->update(['status' => 'resolved', 'resolved_at' => now()]);
|
||||||
|
ActivityLogger::log('sla_breach_resolved', "SLA breach {$slaBreach->id} resolved", $slaBreach);
|
||||||
|
|
||||||
|
return response()->json($slaBreach->fresh(['rule', 'assignee:id,name']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,383 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\InvoiceTemplate;
|
||||||
|
use App\Models\Lead;
|
||||||
|
use App\Services\ActivityLogger;
|
||||||
|
use App\Services\InvoiceService;
|
||||||
|
use App\Services\InvoiceWordService;
|
||||||
|
use App\Support\AccessControl;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
|
||||||
|
class InvoiceController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private InvoiceService $service, private InvoiceWordService $wordService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('viewAny', Invoice::class);
|
||||||
|
$user = auth()->user();
|
||||||
|
$query = Invoice::with('lead:id,first_name,last_name,company,assigned_to,final_result', 'template:id,name', 'creator:id,name', 'approver:id,name');
|
||||||
|
if (! $user->hasRole('admin')) {
|
||||||
|
if ($user->hasRole('supervisor') || $user->can('approve_invoices')) {
|
||||||
|
$query->whereHas('lead', fn ($leads) => AccessControl::scopeLeads($leads, $user, false));
|
||||||
|
} else {
|
||||||
|
$query->where(fn ($scope) => $scope->where('created_by', $user->id)->orWhereHas('lead', fn ($leads) => $leads->where('assigned_to', $user->id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['status', 'lead_id', 'created_by'] as $filter) {
|
||||||
|
if ($request->filled($filter)) {
|
||||||
|
$query->where($filter, $request->get($filter));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = trim((string) $request->get('search'));
|
||||||
|
$query->where(fn ($scope) => $scope->where('number', 'like', "%{$search}%")
|
||||||
|
->orWhereHas('lead', fn ($leads) => $leads->where('company', 'like', "%{$search}%")->orWhere('phone', 'like', "%{$search}%")));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function summary(): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('viewAny', Invoice::class);
|
||||||
|
$user = auth()->user();
|
||||||
|
$query = Invoice::query();
|
||||||
|
if (! $user->hasRole('admin')) {
|
||||||
|
if ($user->hasRole('supervisor') || $user->can('approve_invoices')) {
|
||||||
|
$query->whereHas('lead', fn ($leads) => AccessControl::scopeLeads($leads, $user, false));
|
||||||
|
} else {
|
||||||
|
$query->where(fn ($scope) => $scope->where('created_by', $user->id)
|
||||||
|
->orWhereHas('lead', fn ($leads) => $leads->where('assigned_to', $user->id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = (clone $query)->selectRaw('status, COUNT(*) as aggregate')->groupBy('status')->pluck('aggregate', 'status');
|
||||||
|
$issued = (clone $query)->where('status', 'issued');
|
||||||
|
$issuedTotal = (float) (clone $issued)->sum('total');
|
||||||
|
$paidTotal = (float) (clone $issued)->sum('paid_amount');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'total' => (clone $query)->count(),
|
||||||
|
'counts' => [
|
||||||
|
'draft' => (int) ($counts['draft'] ?? 0),
|
||||||
|
'pending_approval' => (int) ($counts['pending_approval'] ?? 0),
|
||||||
|
'approved' => (int) ($counts['approved'] ?? 0),
|
||||||
|
'rejected' => (int) ($counts['rejected'] ?? 0),
|
||||||
|
'issued' => (int) ($counts['issued'] ?? 0),
|
||||||
|
'void' => (int) ($counts['void'] ?? 0),
|
||||||
|
],
|
||||||
|
'issued_total' => $issuedTotal,
|
||||||
|
'paid_total' => $paidTotal,
|
||||||
|
'outstanding_total' => max(0, $issuedTotal - $paidTotal),
|
||||||
|
'currency' => 'IRR',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Invoice $invoice): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('view', $invoice);
|
||||||
|
|
||||||
|
return response()->json($this->invoicePayload($invoice->load('lead', 'template', 'creator:id,name', 'approver:id,name')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function word(Invoice $invoice): BinaryFileResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('view', $invoice);
|
||||||
|
$document = $this->wordService->create($invoice);
|
||||||
|
|
||||||
|
return response()->download(
|
||||||
|
$document['path'],
|
||||||
|
$document['filename'],
|
||||||
|
['Content-Type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||||
|
)->deleteFileAfterSend(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fromLead(Request $request, Lead $lead): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('create', Invoice::class);
|
||||||
|
Gate::authorize('view', $lead);
|
||||||
|
if (auth()->user()->hasRole('agent')) {
|
||||||
|
abort_unless($lead->assigned_to === auth()->id(), 403, 'فقط مسئول این لید میتواند درخواست فاکتور ثبت کند.');
|
||||||
|
}
|
||||||
|
$payload = $request->validate($this->invoiceRules());
|
||||||
|
$invoice = $this->service->createFromLead($lead, auth()->user(), $payload);
|
||||||
|
|
||||||
|
return response()->json($this->invoicePayload($invoice), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Invoice $invoice): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('update', $invoice);
|
||||||
|
$invoice = $this->service->updateDraft($invoice, $request->validate($this->invoiceRules(true)), auth()->user());
|
||||||
|
|
||||||
|
return response()->json($this->invoicePayload($invoice));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function issue(Invoice $invoice): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('issue', $invoice);
|
||||||
|
|
||||||
|
return response()->json($this->invoicePayload($this->service->issue($invoice, auth()->user())));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(Invoice $invoice): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('approve', $invoice);
|
||||||
|
|
||||||
|
return response()->json($this->invoicePayload($this->service->approve($invoice, auth()->user())));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(Request $request, Invoice $invoice): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('approve', $invoice);
|
||||||
|
$validated = $request->validate(['reason' => 'required|string|max:2000']);
|
||||||
|
|
||||||
|
return response()->json($this->invoicePayload($this->service->reject($invoice, auth()->user(), $validated['reason'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function void(Invoice $invoice): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('void', $invoice);
|
||||||
|
|
||||||
|
return response()->json($this->invoicePayload($this->service->void($invoice, auth()->user())));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function templates(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('viewAny', Invoice::class);
|
||||||
|
$query = InvoiceTemplate::query();
|
||||||
|
if (! ($request->boolean('include_inactive') && Gate::allows('manageTemplates', Invoice::class))) {
|
||||||
|
$query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->orderByDesc('is_default')->orderBy('name')->get()->map(fn ($template) => $this->templatePayload($template)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeTemplate(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('manageTemplates', Invoice::class);
|
||||||
|
$data = $request->validate($this->templateRules());
|
||||||
|
if (! empty($data['is_default'])) {
|
||||||
|
InvoiceTemplate::query()->update(['is_default' => false]);
|
||||||
|
}
|
||||||
|
$template = InvoiceTemplate::create($data + ['created_by' => auth()->id(), 'layout' => $data['layout'] ?? $this->service->defaultLayout()]);
|
||||||
|
ActivityLogger::log('invoice_template_created', "Invoice template {$template->name} created", $template);
|
||||||
|
|
||||||
|
return response()->json($this->templatePayload($template), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateTemplate(Request $request, InvoiceTemplate $invoiceTemplate): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('manageTemplates', Invoice::class);
|
||||||
|
$data = $request->validate($this->templateRules(true));
|
||||||
|
if (! empty($data['is_default'])) {
|
||||||
|
InvoiceTemplate::where('id', '!=', $invoiceTemplate->id)->update(['is_default' => false]);
|
||||||
|
}
|
||||||
|
$invoiceTemplate->update($data);
|
||||||
|
ActivityLogger::log('invoice_template_updated', "Invoice template {$invoiceTemplate->name} updated", $invoiceTemplate);
|
||||||
|
|
||||||
|
return response()->json($this->templatePayload($invoiceTemplate->fresh()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyTemplate(InvoiceTemplate $invoiceTemplate): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('manageTemplates', Invoice::class);
|
||||||
|
abort_if($invoiceTemplate->invoices()->exists(), 422, 'این قالب در فاکتورهای ثبتشده استفاده شده است؛ بهجای حذف، آن را غیرفعال کنید.');
|
||||||
|
$paths = array_filter([$invoiceTemplate->background_path, $invoiceTemplate->source_path]);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($invoiceTemplate): void {
|
||||||
|
$wasDefault = $invoiceTemplate->is_default;
|
||||||
|
$name = $invoiceTemplate->name;
|
||||||
|
$invoiceTemplate->delete();
|
||||||
|
if ($wasDefault) {
|
||||||
|
InvoiceTemplate::where('is_active', true)->orderBy('id')->first()?->update(['is_default' => true]);
|
||||||
|
}
|
||||||
|
ActivityLogger::log('invoice_template_deleted', "Invoice template {$name} deleted");
|
||||||
|
});
|
||||||
|
if ($paths) {
|
||||||
|
Storage::disk('local')->delete(array_values(array_unique($paths)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['message' => 'قالب حذف شد.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadTemplateBackground(Request $request, InvoiceTemplate $invoiceTemplate): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('manageTemplates', Invoice::class);
|
||||||
|
$data = $request->validate([
|
||||||
|
'file' => 'required|image|mimes:png,jpg,jpeg|max:20480',
|
||||||
|
'source_file' => 'nullable|file|mimes:png,jpg,jpeg,pdf|max:20480',
|
||||||
|
'source_name' => 'nullable|string|max:255',
|
||||||
|
'source_mime' => 'nullable|in:image/png,image/jpeg,application/pdf',
|
||||||
|
]);
|
||||||
|
$file = $request->file('file');
|
||||||
|
$sourceFile = $request->file('source_file');
|
||||||
|
if ($invoiceTemplate->background_path) {
|
||||||
|
Storage::disk('local')->delete($invoiceTemplate->background_path);
|
||||||
|
}
|
||||||
|
if ($invoiceTemplate->source_path) {
|
||||||
|
Storage::disk('local')->delete($invoiceTemplate->source_path);
|
||||||
|
}
|
||||||
|
$path = $file->store('invoice-templates', 'local');
|
||||||
|
$sourcePath = $sourceFile?->store('invoice-template-sources', 'local');
|
||||||
|
$invoiceTemplate->update([
|
||||||
|
'background_path' => $path,
|
||||||
|
'background_name' => $file->getClientOriginalName(),
|
||||||
|
'background_mime' => $file->getMimeType(),
|
||||||
|
'source_path' => $sourcePath,
|
||||||
|
'source_name' => $data['source_name'] ?? $sourceFile?->getClientOriginalName() ?? $file->getClientOriginalName(),
|
||||||
|
'source_mime' => $data['source_mime'] ?? $sourceFile?->getMimeType() ?? $file->getMimeType(),
|
||||||
|
'base_type' => $invoiceTemplate->base_type === 'blank' ? 'full_template' : $invoiceTemplate->base_type,
|
||||||
|
'background_settings' => $invoiceTemplate->background_settings ?: ['fit' => 'contain', 'top' => 0, 'height' => 100],
|
||||||
|
]);
|
||||||
|
ActivityLogger::log('invoice_template_background_uploaded', "Background uploaded for invoice template {$invoiceTemplate->id}", $invoiceTemplate);
|
||||||
|
|
||||||
|
return response()->json($this->templatePayload($invoiceTemplate->fresh()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteTemplateBackground(InvoiceTemplate $invoiceTemplate): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('manageTemplates', Invoice::class);
|
||||||
|
if ($invoiceTemplate->background_path) {
|
||||||
|
Storage::disk('local')->delete($invoiceTemplate->background_path);
|
||||||
|
}
|
||||||
|
if ($invoiceTemplate->source_path) {
|
||||||
|
Storage::disk('local')->delete($invoiceTemplate->source_path);
|
||||||
|
}
|
||||||
|
$invoiceTemplate->update([
|
||||||
|
'background_path' => null,
|
||||||
|
'background_name' => null,
|
||||||
|
'background_mime' => null,
|
||||||
|
'source_path' => null,
|
||||||
|
'source_name' => null,
|
||||||
|
'source_mime' => null,
|
||||||
|
'base_type' => 'blank',
|
||||||
|
'background_settings' => null,
|
||||||
|
]);
|
||||||
|
ActivityLogger::log('invoice_template_background_deleted', "Background deleted for invoice template {$invoiceTemplate->id}", $invoiceTemplate);
|
||||||
|
|
||||||
|
return response()->json($this->templatePayload($invoiceTemplate->fresh()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function templateBackground(InvoiceTemplate $invoiceTemplate)
|
||||||
|
{
|
||||||
|
Gate::authorize('viewAny', Invoice::class);
|
||||||
|
abort_unless($invoiceTemplate->background_path && Storage::disk('local')->exists($invoiceTemplate->background_path), 404);
|
||||||
|
|
||||||
|
return response()->file(Storage::disk('local')->path($invoiceTemplate->background_path), [
|
||||||
|
'Content-Type' => $invoiceTemplate->background_mime ?: 'application/octet-stream',
|
||||||
|
'Cache-Control' => 'private, max-age=300',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fieldCatalog(): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('viewAny', Invoice::class);
|
||||||
|
|
||||||
|
return response()->json(InvoiceService::FIELD_CATALOG);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invoiceRules(bool $partial = false): array
|
||||||
|
{
|
||||||
|
$sometimes = $partial ? 'sometimes|' : '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'invoice_template_id' => 'nullable|exists:invoice_templates,id',
|
||||||
|
'currency' => $sometimes.'nullable|string|max:8',
|
||||||
|
'customer_snapshot' => 'nullable|array',
|
||||||
|
'customer_snapshot.name' => 'nullable|string|max:255',
|
||||||
|
'customer_snapshot.company' => 'nullable|string|max:255',
|
||||||
|
'customer_snapshot.phone' => 'nullable|string|max:40',
|
||||||
|
'customer_snapshot.email' => 'nullable|email|max:255',
|
||||||
|
'customer_snapshot.address' => 'nullable|string|max:1000',
|
||||||
|
'customer_snapshot.national_code' => 'nullable|string|max:40',
|
||||||
|
'customer_snapshot.economic_code' => 'nullable|string|max:40',
|
||||||
|
'customer_snapshot.postal_code' => 'nullable|string|max:40',
|
||||||
|
'seller_snapshot' => 'nullable|array',
|
||||||
|
'seller_snapshot.name' => 'nullable|string|max:255',
|
||||||
|
'seller_snapshot.company' => 'nullable|string|max:255',
|
||||||
|
'seller_snapshot.phone' => 'nullable|string|max:40',
|
||||||
|
'seller_snapshot.email' => 'nullable|email|max:255',
|
||||||
|
'seller_snapshot.address' => 'nullable|string|max:1000',
|
||||||
|
'seller_snapshot.national_id' => 'nullable|string|max:40',
|
||||||
|
'seller_snapshot.economic_code' => 'nullable|string|max:40',
|
||||||
|
'seller_snapshot.postal_code' => 'nullable|string|max:40',
|
||||||
|
'items' => 'nullable|array|min:1',
|
||||||
|
'items.*.description' => 'required_with:items|string|max:500',
|
||||||
|
'items.*.quantity' => 'required_with:items|numeric|min:0.01',
|
||||||
|
'items.*.unit_price' => 'required_with:items|numeric|min:0',
|
||||||
|
'items.*.unit' => 'nullable|string|max:40',
|
||||||
|
'discount' => 'nullable|numeric|min:0',
|
||||||
|
'tax' => 'nullable|numeric|min:0',
|
||||||
|
'paid_amount' => 'nullable|numeric|min:0',
|
||||||
|
'notes' => 'nullable|string|max:2000',
|
||||||
|
'payment_terms' => 'nullable|string|max:2000',
|
||||||
|
'due_date' => 'nullable|date',
|
||||||
|
'resolved_fields' => 'nullable|array',
|
||||||
|
'page_width_mm' => 'nullable|integer|min:100|max:500',
|
||||||
|
'page_height_mm' => 'nullable|integer|min:100|max:500',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function templateRules(bool $partial = false): array
|
||||||
|
{
|
||||||
|
$required = $partial ? 'sometimes|' : 'required|';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $required.'string|max:255',
|
||||||
|
'layout' => 'nullable|array',
|
||||||
|
'layout.*.id' => 'required_with:layout|string|max:80',
|
||||||
|
'layout.*.label' => 'required_with:layout|string|max:255',
|
||||||
|
'layout.*.source' => 'required_with:layout|string|max:120',
|
||||||
|
'layout.*.x' => 'required_with:layout|numeric|min:0|max:100',
|
||||||
|
'layout.*.y' => 'required_with:layout|numeric|min:0|max:100',
|
||||||
|
'layout.*.width' => 'required_with:layout|numeric|min:1|max:100',
|
||||||
|
'layout.*.font_size' => 'nullable|integer|min:8|max:48',
|
||||||
|
'layout.*.align' => 'nullable|in:right,center,left',
|
||||||
|
'layout.*.default' => 'nullable|string|max:1000',
|
||||||
|
'base_type' => 'nullable|in:blank,letterhead,full_template',
|
||||||
|
'background_settings' => 'nullable|array',
|
||||||
|
'background_settings.fit' => 'nullable|in:contain,cover,stretch',
|
||||||
|
'background_settings.top' => 'nullable|numeric|min:0|max:95',
|
||||||
|
'background_settings.height' => 'nullable|numeric|min:5|max:100',
|
||||||
|
'page_width_mm' => 'nullable|integer|min:100|max:500',
|
||||||
|
'page_height_mm' => 'nullable|integer|min:100|max:500',
|
||||||
|
'is_default' => 'nullable|boolean',
|
||||||
|
'is_active' => 'nullable|boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invoicePayload(Invoice $invoice): array
|
||||||
|
{
|
||||||
|
$array = $invoice->toArray();
|
||||||
|
$array['balance_due'] = max(0, (float) $invoice->total - (float) $invoice->paid_amount);
|
||||||
|
$array['capabilities'] = [
|
||||||
|
'update' => Gate::allows('update', $invoice),
|
||||||
|
'approve' => Gate::allows('approve', $invoice),
|
||||||
|
'issue' => Gate::allows('issue', $invoice),
|
||||||
|
'void' => Gate::allows('void', $invoice),
|
||||||
|
];
|
||||||
|
if ($invoice->template) {
|
||||||
|
$array['template'] = $this->templatePayload($invoice->template);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function templatePayload(InvoiceTemplate $template): array
|
||||||
|
{
|
||||||
|
return $template->toArray() + [
|
||||||
|
'background_url' => $template->background_path ? url("/api/invoice-templates/{$template->id}/background") : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,15 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Models\ContactPhone;
|
||||||
use App\Models\Lead;
|
use App\Models\Lead;
|
||||||
|
use App\Models\LeadAssignment;
|
||||||
use App\Models\PipelineStage;
|
use App\Models\PipelineStage;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
|
use App\Services\AutomationDispatcher;
|
||||||
use App\Services\LeadService;
|
use App\Services\LeadService;
|
||||||
use App\Support\AccessControl;
|
use App\Support\AccessControl;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
@ -17,7 +21,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
class LeadController extends Controller
|
class LeadController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private LeadService $leadService) {}
|
public function __construct(private LeadService $leadService, private AutomationDispatcher $automations) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
|
@ -76,7 +80,7 @@ class LeadController extends Controller
|
||||||
public function export(Request $request): StreamedResponse|JsonResponse
|
public function export(Request $request): StreamedResponse|JsonResponse
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
if (!$user?->hasRole('admin') && !$user?->can('export_leads')) {
|
if (! $user?->hasRole('admin') && ! $user?->can('export_leads')) {
|
||||||
return response()->json(['message' => 'شما مجوز خروجی گرفتن از لیدها را ندارید.'], 403);
|
return response()->json(['message' => 'شما مجوز خروجی گرفتن از لیدها را ندارید.'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,11 +172,11 @@ class LeadController extends Controller
|
||||||
|
|
||||||
if ($user?->hasRole('agent')) {
|
if ($user?->hasRole('agent')) {
|
||||||
$validated['assigned_to'] = $user->id;
|
$validated['assigned_to'] = $user->id;
|
||||||
} elseif ($user?->hasRole('supervisor') && !empty($validated['assigned_to'])) {
|
} elseif ($user?->hasRole('supervisor') && ! empty($validated['assigned_to'])) {
|
||||||
$assignee = User::find($validated['assigned_to']);
|
$assignee = User::find($validated['assigned_to']);
|
||||||
$teamIds = AccessControl::teamIds($user);
|
$teamIds = AccessControl::teamIds($user);
|
||||||
$assigneeTeamIds = $assignee?->teams()->pluck('teams.id')->all() ?? [];
|
$assigneeTeamIds = $assignee?->teams()->pluck('teams.id')->all() ?? [];
|
||||||
if (!array_intersect($teamIds, $assigneeTeamIds)) {
|
if (! array_intersect($teamIds, $assigneeTeamIds)) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -183,7 +187,7 @@ class LeadController extends Controller
|
||||||
?? PipelineStage::where('is_default', true)->value('id')
|
?? PipelineStage::where('is_default', true)->value('id')
|
||||||
?? PipelineStage::orderBy('sort_order')->value('id');
|
?? PipelineStage::orderBy('sort_order')->value('id');
|
||||||
$assignedTo = $validated['assigned_to'] ?? null;
|
$assignedTo = $validated['assigned_to'] ?? null;
|
||||||
if (!$assignedTo) {
|
if (! $assignedTo) {
|
||||||
$strategy = Setting::where('key', 'assignment_strategy')->value('value') ?? 'round_robin';
|
$strategy = Setting::where('key', 'assignment_strategy')->value('value') ?? 'round_robin';
|
||||||
if ($strategy !== 'manual' || auth()->user()?->hasRole('admin')) {
|
if ($strategy !== 'manual' || auth()->user()?->hasRole('admin')) {
|
||||||
$assignedTo = User::role('agent')
|
$assignedTo = User::role('agent')
|
||||||
|
|
@ -206,9 +210,9 @@ class LeadController extends Controller
|
||||||
|
|
||||||
$lead = Lead::create($validated);
|
$lead = Lead::create($validated);
|
||||||
|
|
||||||
$contact = \App\Models\Contact::create([
|
$contact = Contact::create([
|
||||||
'lead_id' => $lead->id,
|
'lead_id' => $lead->id,
|
||||||
'name' => trim(($lead->first_name ?? '') . ' ' . ($lead->last_name ?? '')) ?: ($lead->company ?? 'مخاطب اولیه'),
|
'name' => trim(($lead->first_name ?? '').' '.($lead->last_name ?? '')) ?: ($lead->company ?? 'مخاطب اولیه'),
|
||||||
'role' => 'رابط',
|
'role' => 'رابط',
|
||||||
'description' => 'مخاطب اولیه لید',
|
'description' => 'مخاطب اولیه لید',
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
|
|
@ -218,7 +222,7 @@ class LeadController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach (array_filter([$validated['phone'] ?? null, $validated['phone_secondary'] ?? null]) as $phone) {
|
foreach (array_filter([$validated['phone'] ?? null, $validated['phone_secondary'] ?? null]) as $phone) {
|
||||||
\App\Models\ContactPhone::create([
|
ContactPhone::create([
|
||||||
'contact_id' => $contact->id,
|
'contact_id' => $contact->id,
|
||||||
'phone' => $phone,
|
'phone' => $phone,
|
||||||
'type' => 'mobile',
|
'type' => 'mobile',
|
||||||
|
|
@ -226,8 +230,8 @@ class LeadController extends Controller
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($validated['assigned_to'])) {
|
if (! empty($validated['assigned_to'])) {
|
||||||
\App\Models\LeadAssignment::create([
|
LeadAssignment::create([
|
||||||
'lead_id' => $lead->id,
|
'lead_id' => $lead->id,
|
||||||
'user_id' => $validated['assigned_to'],
|
'user_id' => $validated['assigned_to'],
|
||||||
'assigned_by' => auth()->id(),
|
'assigned_by' => auth()->id(),
|
||||||
|
|
@ -236,6 +240,7 @@ class LeadController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
ActivityLogger::log('lead_created', "Lead {$lead->full_name} created", $lead);
|
ActivityLogger::log('lead_created', "Lead {$lead->full_name} created", $lead);
|
||||||
|
$this->automations->dispatch('lead_created', $lead, "lead-created:{$lead->id}", ['source' => $lead->source, 'priority' => $lead->priority]);
|
||||||
|
|
||||||
$payload = $lead->load(['leadStatus', 'pipelineStage', 'campaign'])->toArray();
|
$payload = $lead->load(['leadStatus', 'pipelineStage', 'campaign'])->toArray();
|
||||||
if ($duplicateLead && in_array($duplicatePolicy, ['warn', 'merge_suggestion'], true)) {
|
if ($duplicateLead && in_array($duplicatePolicy, ['warn', 'merge_suggestion'], true)) {
|
||||||
|
|
@ -262,8 +267,8 @@ class LeadController extends Controller
|
||||||
'callLogs.contact:id,name,role',
|
'callLogs.contact:id,name,role',
|
||||||
'callLogs.phone:id,phone,type,status',
|
'callLogs.phone:id,phone,type,status',
|
||||||
'callLogs.user:id,name',
|
'callLogs.user:id,name',
|
||||||
'calls' => fn($q) => $q->with('contact:id,name,role', 'contactPhone:id,phone,type,status')->latest(),
|
'calls' => fn ($q) => $q->with('contact:id,name,role', 'contactPhone:id,phone,type,status')->latest(),
|
||||||
'followUps' => fn($q) => $q->latest(),
|
'followUps' => fn ($q) => $q->latest(),
|
||||||
'notes.user:id,name',
|
'notes.user:id,name',
|
||||||
'attachments.uploader:id,name',
|
'attachments.uploader:id,name',
|
||||||
])
|
])
|
||||||
|
|
@ -325,7 +330,7 @@ class LeadController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$leads = Lead::whereIn('id', $validated['ids'])->get();
|
$leads = Lead::whereIn('id', $validated['ids'])->get();
|
||||||
if ($leads->count() !== count(array_unique($validated['ids'])) || $leads->contains(fn(Lead $lead) => Gate::denies('delete', $lead))) {
|
if ($leads->count() !== count(array_unique($validated['ids'])) || $leads->contains(fn (Lead $lead) => Gate::denies('delete', $lead))) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,21 +357,21 @@ class LeadController extends Controller
|
||||||
->orderBy('created_at', 'asc')
|
->orderBy('created_at', 'asc')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$lead) {
|
if (! $lead) {
|
||||||
$lead = Lead::where('assigned_to', $user->id)
|
$lead = Lead::where('assigned_to', $user->id)
|
||||||
->orderBy('priority', 'desc')
|
->orderBy('priority', 'desc')
|
||||||
->orderBy('last_call_at', 'asc')
|
->orderBy('last_call_at', 'asc')
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$lead) {
|
if (! $lead) {
|
||||||
return response()->json(['message' => 'لیدی برای تماس وجود ندارد'], 404);
|
return response()->json(['message' => 'لیدی برای تماس وجود ندارد'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
ActivityLogger::log('lead_viewed', "Lead {$lead->id} viewed as next lead", $lead);
|
ActivityLogger::log('lead_viewed', "Lead {$lead->id} viewed as next lead", $lead);
|
||||||
|
|
||||||
return response()->json(
|
return response()->json(
|
||||||
$lead->load(['leadStatus', 'pipelineStage', 'campaign', 'calls' => fn($q) => $q->latest()->limit(5)])
|
$lead->load(['leadStatus', 'pipelineStage', 'campaign', 'calls' => fn ($q) => $q->latest()->limit(5)])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,7 +379,7 @@ class LeadController extends Controller
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (!$user) {
|
if (! $user) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -388,6 +393,7 @@ class LeadController extends Controller
|
||||||
|
|
||||||
if ($user->hasRole('supervisor')) {
|
if ($user->hasRole('supervisor')) {
|
||||||
$teamIds = $user->teams->pluck('id')->toArray();
|
$teamIds = $user->teams->pluck('id')->toArray();
|
||||||
|
|
||||||
return in_array($lead->team_id, $teamIds, true) || $lead->assigned_to === $user->id;
|
return in_array($lead->team_id, $teamIds, true) || $lead->assigned_to === $user->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class LeadStatusController extends Controller
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'sometimes|string|max:255',
|
'name' => 'sometimes|string|max:255',
|
||||||
'slug' => 'nullable|string|max:80|unique:lead_statuses,slug,' . $leadStatus->id,
|
'slug' => 'nullable|string|max:80|unique:lead_statuses,slug,'.$leadStatus->id,
|
||||||
'color' => 'nullable|string|max:20',
|
'color' => 'nullable|string|max:20',
|
||||||
'icon' => 'nullable|string|max:255',
|
'icon' => 'nullable|string|max:255',
|
||||||
'sort_order' => 'nullable|integer',
|
'sort_order' => 'nullable|integer',
|
||||||
|
|
@ -61,6 +61,7 @@ class LeadStatusController extends Controller
|
||||||
{
|
{
|
||||||
$leadStatus->delete();
|
$leadStatus->delete();
|
||||||
ActivityLogger::log('lead_status_deleted', "Status {$leadStatus->name} deleted");
|
ActivityLogger::log('lead_status_deleted', "Status {$leadStatus->name} deleted");
|
||||||
|
|
||||||
return response()->json(['message' => 'وضعیت حذف شد']);
|
return response()->json(['message' => 'وضعیت حذف شد']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class LostReasonController extends Controller
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'sometimes|string|max:255',
|
'name' => 'sometimes|string|max:255',
|
||||||
'slug' => 'sometimes|string|max:80|unique:lost_reasons,slug,' . $lostReason->id,
|
'slug' => 'sometimes|string|max:80|unique:lost_reasons,slug,'.$lostReason->id,
|
||||||
'color' => 'nullable|string|max:20',
|
'color' => 'nullable|string|max:20',
|
||||||
'sort_order' => 'nullable|integer',
|
'sort_order' => 'nullable|integer',
|
||||||
'is_active' => 'nullable|boolean',
|
'is_active' => 'nullable|boolean',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
|
||||||
|
class MediaController extends Controller
|
||||||
|
{
|
||||||
|
public function avatar(string $filename): BinaryFileResponse
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
$filename === basename($filename)
|
||||||
|
&& preg_match('/^[A-Za-z0-9_-]+\.(?:jpe?g|png|webp)$/i', $filename),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
|
||||||
|
$path = "avatars/{$filename}";
|
||||||
|
abort_unless(Storage::disk('public')->exists($path), 404);
|
||||||
|
|
||||||
|
return response()->file(Storage::disk('public')->path($path), [
|
||||||
|
'Cache-Control' => 'public, max-age=86400, immutable',
|
||||||
|
'X-Content-Type-Options' => 'nosniff',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,48 +3,126 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Company;
|
use App\Http\Requests\StoreCallNoteRequest;
|
||||||
use App\Models\Deal;
|
use App\Http\Requests\UpdateNoteRequest;
|
||||||
use App\Models\Lead;
|
use App\Http\Resources\NoteResource;
|
||||||
|
use App\Http\Responses\ApiResponse;
|
||||||
|
use App\Models\Call;
|
||||||
use App\Models\Note;
|
use App\Models\Note;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
|
use App\Support\EntityResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class NoteController extends Controller
|
class NoteController extends Controller
|
||||||
{
|
{
|
||||||
|
public function callIndex(Call $call): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('view', $call);
|
||||||
|
$query = $call->notesHistory()->with('user:id,name');
|
||||||
|
if (! auth()->user()->hasRole('admin')) {
|
||||||
|
$query->where(fn ($visibility) => $visibility
|
||||||
|
->where('visibility', '<>', 'private')
|
||||||
|
->orWhere('user_id', auth()->id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
$notes = $query->orderByDesc('is_pinned')->latest()->get();
|
||||||
|
|
||||||
|
return ApiResponse::success($notes->map(fn (Note $note) => $this->resource($note))->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callStore(StoreCallNoteRequest $request, Call $call): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('create', [Note::class, $call]);
|
||||||
|
$note = DB::transaction(function () use ($request, $call): Note {
|
||||||
|
$note = $call->notesHistory()->create([
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'content' => $request->validated('content'),
|
||||||
|
'type' => $request->validated('type', 'general'),
|
||||||
|
'visibility' => $request->validated('visibility', 'team'),
|
||||||
|
]);
|
||||||
|
ActivityLogger::log('call_note_created', "Call note {$note->id} created", $note, null, $note->only(['type', 'visibility', 'is_pinned']));
|
||||||
|
|
||||||
|
return $note;
|
||||||
|
});
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($note->load('user:id,name')), 201, 'یادداشت تماس ثبت شد.');
|
||||||
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'entity_type' => 'required|in:lead,company,deal',
|
'entity_type' => 'required|in:lead,company,deal',
|
||||||
'entity_id' => 'required|integer',
|
'entity_id' => 'required|integer',
|
||||||
'content' => 'required|string',
|
'content' => 'required|string|max:5000',
|
||||||
]);
|
]);
|
||||||
|
$model = EntityResolver::authorize($validated['entity_type'], $validated['entity_id'], 'update');
|
||||||
$model = $this->resolve($validated['entity_type'], $validated['entity_id']);
|
$note = DB::transaction(function () use ($model, $validated): Note {
|
||||||
$note = $model->notes()->create([
|
$note = $model->notes()->create([
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'content' => $validated['content'],
|
'content' => $validated['content'],
|
||||||
|
'type' => 'general',
|
||||||
|
'visibility' => 'team',
|
||||||
]);
|
]);
|
||||||
|
ActivityLogger::log('note_created', 'یادداشت جدید ثبت شد', $note, null, $note->only(['type', 'visibility']));
|
||||||
|
|
||||||
ActivityLogger::log('note_created', 'یادداشت جدید ثبت شد', $model);
|
return $note;
|
||||||
return response()->json($note->load('user:id,name'), 201);
|
});
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($note->load('user:id,name')), 201, 'یادداشت ثبت شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateNoteRequest $request, Note $note): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('update', $note);
|
||||||
|
$updated = DB::transaction(function () use ($request, $note): Note {
|
||||||
|
$before = $note->only(['type', 'visibility', 'is_pinned']);
|
||||||
|
$note->fill($request->validated());
|
||||||
|
$note->edited_at = now();
|
||||||
|
$note->save();
|
||||||
|
ActivityLogger::log('call_note_edited', "Note {$note->id} edited", $note, $before, $note->only(['type', 'visibility', 'is_pinned']));
|
||||||
|
|
||||||
|
return $note;
|
||||||
|
});
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($updated->load('user:id,name')), message: 'یادداشت ویرایش شد.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Note $note): JsonResponse
|
public function destroy(Note $note): JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless(auth()->id() === $note->user_id || auth()->user()?->hasRole('admin'), 403, 'دسترسی غیرمجاز');
|
Gate::authorize('delete', $note);
|
||||||
|
DB::transaction(function () use ($note): void {
|
||||||
|
$before = $note->only(['type', 'visibility', 'is_pinned']);
|
||||||
$note->delete();
|
$note->delete();
|
||||||
ActivityLogger::log('note_deleted', "Note {$note->id} deleted");
|
ActivityLogger::log('call_note_deleted', "Note {$note->id} deleted", $note, $before, ['deleted' => true]);
|
||||||
return response()->json(['message' => 'یادداشت حذف شد']);
|
});
|
||||||
|
|
||||||
|
return ApiResponse::success(null, message: 'یادداشت حذف شد.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolve(string $type, int $id): Lead|Company|Deal
|
public function pin(Note $note): JsonResponse
|
||||||
{
|
{
|
||||||
return match ($type) {
|
return $this->setPinned($note, true);
|
||||||
'lead' => Lead::findOrFail($id),
|
}
|
||||||
'company' => Company::findOrFail($id),
|
|
||||||
'deal' => Deal::findOrFail($id),
|
public function unpin(Note $note): JsonResponse
|
||||||
};
|
{
|
||||||
|
return $this->setPinned($note, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setPinned(Note $note, bool $pinned): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('pin', $note);
|
||||||
|
$note->update(['is_pinned' => $pinned]);
|
||||||
|
ActivityLogger::log($pinned ? 'call_note_pinned' : 'call_note_unpinned', "Note {$note->id} pin changed", $note, ['is_pinned' => ! $pinned], ['is_pinned' => $pinned]);
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($note->load('user:id,name')), message: $pinned ? 'یادداشت سنجاق شد.' : 'سنجاق یادداشت برداشته شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(Note $note): array
|
||||||
|
{
|
||||||
|
return (new NoteResource($note))->resolve(request());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,47 +3,83 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\NotificationResource;
|
||||||
|
use App\Http\Responses\ApiResponse;
|
||||||
use App\Models\Notification;
|
use App\Models\Notification;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class NotificationController extends Controller
|
class NotificationController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$notifications = Notification::where('user_id', auth()->id())
|
$query = Notification::where('user_id', auth()->id());
|
||||||
->orderBy('created_at', 'desc')
|
if ($request->boolean('archived')) {
|
||||||
|
$query->whereNotNull('archived_at');
|
||||||
|
} else {
|
||||||
|
$query->whereNull('archived_at');
|
||||||
|
}
|
||||||
|
if ($request->filled('type')) {
|
||||||
|
$query->where('type', $request->string('type'));
|
||||||
|
}
|
||||||
|
if ($request->has('unread')) {
|
||||||
|
$query->where('is_read', $request->boolean('unread') ? false : true);
|
||||||
|
}
|
||||||
|
$paginator = $query
|
||||||
|
->orderByDesc('created_at')
|
||||||
->paginate(20);
|
->paginate(20);
|
||||||
|
|
||||||
return response()->json($notifications);
|
return ApiResponse::paginated($paginator, fn (Notification $notification) => $this->resource($notification));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function markRead(Notification $notification): JsonResponse
|
public function markRead(Notification $notification): JsonResponse
|
||||||
{
|
{
|
||||||
if ($notification->user_id !== auth()->id()) {
|
Gate::authorize('update', $notification);
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
$notification->update(['is_read' => true, 'read_at' => $notification->read_at ?? now()]);
|
||||||
}
|
|
||||||
|
|
||||||
$notification->update(['is_read' => true]);
|
return ApiResponse::success($this->resource($notification), message: 'اعلان خوانده شد.');
|
||||||
|
|
||||||
return response()->json($notification);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function markAllRead(): JsonResponse
|
public function markAllRead(): JsonResponse
|
||||||
{
|
{
|
||||||
Notification::where('user_id', auth()->id())
|
Notification::where('user_id', auth()->id())
|
||||||
->where('is_read', false)
|
->where('is_read', false)
|
||||||
->update(['is_read' => true]);
|
->whereNull('archived_at')
|
||||||
|
->update(['is_read' => true, 'read_at' => now()]);
|
||||||
|
|
||||||
return response()->json(['message' => 'همه اعلانها خوانده شد']);
|
return ApiResponse::success(null, message: 'همه اعلانها خوانده شد.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function unreadCount(): JsonResponse
|
public function unreadCount(): JsonResponse
|
||||||
{
|
{
|
||||||
$count = Notification::where('user_id', auth()->id())
|
$count = Notification::where('user_id', auth()->id())->where('is_read', false)->whereNull('archived_at')->count();
|
||||||
->where('is_read', false)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
return response()->json(['count' => $count]);
|
return ApiResponse::success($count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function archive(Notification $notification): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('update', $notification);
|
||||||
|
$notification->update([
|
||||||
|
'archived_at' => now(),
|
||||||
|
'is_read' => true,
|
||||||
|
'read_at' => $notification->read_at ?? now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($notification), message: 'اعلان بایگانی شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Notification $notification): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('delete', $notification);
|
||||||
|
$notification->delete();
|
||||||
|
|
||||||
|
return ApiResponse::success(null, message: 'اعلان حذف شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(Notification $notification): array
|
||||||
|
{
|
||||||
|
return (new NotificationResource($notification))->resolve(request());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Deal;
|
||||||
|
use App\Models\DealStage;
|
||||||
|
use App\Models\Pipeline;
|
||||||
|
use App\Services\DealPipelineService;
|
||||||
|
use App\Support\AccessControl;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PipelineController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private DealPipelineService $service) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('view_pipelines'), 403);
|
||||||
|
$query = Pipeline::with('stages')->where('is_active', true)->orderBy('sort_order');
|
||||||
|
if (! $request->user()->hasRole('admin')) {
|
||||||
|
$teamIds = AccessControl::teamIds($request->user());
|
||||||
|
$query->where(fn ($scope) => $scope->whereNull('team_id')->orWhereIn('team_id', $teamIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('manage_pipelines'), 403);
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => 'required|string|max:120', 'team_id' => 'nullable|exists:teams,id',
|
||||||
|
'is_default' => 'boolean', 'stages' => 'required|array|min:2|max:20',
|
||||||
|
'stages.*.name' => 'required|string|max:80', 'stages.*.color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||||
|
'stages.*.probability' => 'required|integer|min:0|max:100', 'stages.*.is_won' => 'boolean', 'stages.*.is_lost' => 'boolean',
|
||||||
|
]);
|
||||||
|
if (! $request->user()->hasRole('admin') && ! empty($data['team_id'])) {
|
||||||
|
abort_unless(in_array((int) $data['team_id'], AccessControl::teamIds($request->user()), true), 403);
|
||||||
|
}
|
||||||
|
$pipeline = DB::transaction(function () use ($data): Pipeline {
|
||||||
|
if ($data['is_default'] ?? false) {
|
||||||
|
Pipeline::query()->update(['is_default' => false]);
|
||||||
|
}
|
||||||
|
$pipeline = Pipeline::create([
|
||||||
|
'name' => $data['name'], 'slug' => Str::slug($data['name']).'-'.Str::lower(Str::random(5)),
|
||||||
|
'team_id' => $data['team_id'] ?? null, 'is_default' => $data['is_default'] ?? false, 'is_active' => true,
|
||||||
|
]);
|
||||||
|
foreach ($data['stages'] as $index => $stage) {
|
||||||
|
$pipeline->stages()->create($stage + ['slug' => Str::slug($stage['name']).'-'.$index, 'sort_order' => $index, 'is_active' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pipeline;
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json($pipeline->load('stages'), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function board(Request $request, Pipeline $pipeline): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('view_pipelines'), 403);
|
||||||
|
abort_unless($this->visible($pipeline, $request), 403);
|
||||||
|
|
||||||
|
return response()->json($this->service->board($pipeline, $request->user(), $request->only(['search', 'owner_id', 'forecast_category', 'status'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function move(Request $request, Deal $deal): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('move_deals'), 403);
|
||||||
|
abort_unless(AccessControl::canAccessDeal($request->user(), $deal), 403);
|
||||||
|
$data = $request->validate(['deal_stage_id' => 'required|exists:deal_stages,id', 'version' => 'required|integer|min:1', 'reason' => 'nullable|string|max:1000', 'final_amount' => 'nullable|numeric|min:0']);
|
||||||
|
$stage = DealStage::findOrFail($data['deal_stage_id']);
|
||||||
|
|
||||||
|
return response()->json($this->service->move($deal, $stage, $request->user(), $data['version'], $data['reason'] ?? null, isset($data['final_amount']) ? (float) $data['final_amount'] : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function visible(Pipeline $pipeline, Request $request): bool
|
||||||
|
{
|
||||||
|
return $request->user()->hasRole('admin') || $pipeline->team_id === null || in_array($pipeline->team_id, AccessControl::teamIds($request->user()), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,16 +3,19 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\FollowUp;
|
use App\Models\Lead;
|
||||||
|
use App\Models\LeadStatus;
|
||||||
use App\Models\PipelineStage;
|
use App\Models\PipelineStage;
|
||||||
use App\Services\NotificationService;
|
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
|
use App\Services\FollowUpService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class PipelineStageController extends Controller
|
class PipelineStageController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private FollowUpService $followUps) {}
|
||||||
|
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json(
|
return response()->json(
|
||||||
|
|
@ -48,7 +51,7 @@ class PipelineStageController extends Controller
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'sometimes|string|max:255',
|
'name' => 'sometimes|string|max:255',
|
||||||
'slug' => 'nullable|string|max:80|unique:pipeline_stages,slug,' . $pipelineStage->id,
|
'slug' => 'nullable|string|max:80|unique:pipeline_stages,slug,'.$pipelineStage->id,
|
||||||
'color' => 'nullable|string|max:20',
|
'color' => 'nullable|string|max:20',
|
||||||
'sort_order' => 'nullable|integer',
|
'sort_order' => 'nullable|integer',
|
||||||
'is_active' => 'nullable|boolean',
|
'is_active' => 'nullable|boolean',
|
||||||
|
|
@ -69,6 +72,7 @@ class PipelineStageController extends Controller
|
||||||
{
|
{
|
||||||
$pipelineStage->delete();
|
$pipelineStage->delete();
|
||||||
ActivityLogger::log('pipeline_stage_deleted', "Stage {$pipelineStage->name} deleted");
|
ActivityLogger::log('pipeline_stage_deleted', "Stage {$pipelineStage->name} deleted");
|
||||||
|
|
||||||
return response()->json(['message' => 'مرحله حذف شد']);
|
return response()->json(['message' => 'مرحله حذف شد']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,6 +81,7 @@ class PipelineStageController extends Controller
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'lead_id' => 'required|exists:leads,id',
|
'lead_id' => 'required|exists:leads,id',
|
||||||
'next_follow_up_at' => 'nullable|date',
|
'next_follow_up_at' => 'nullable|date',
|
||||||
|
'follow_up_notes' => 'nullable|string|max:2000',
|
||||||
'final_result' => 'nullable|in:موفق,ناموفق',
|
'final_result' => 'nullable|in:موفق,ناموفق',
|
||||||
'lost_reason' => 'nullable|string|max:255',
|
'lost_reason' => 'nullable|string|max:255',
|
||||||
'deal_value' => 'nullable|numeric|min:0',
|
'deal_value' => 'nullable|numeric|min:0',
|
||||||
|
|
@ -86,7 +91,7 @@ class PipelineStageController extends Controller
|
||||||
'customer_notes' => 'nullable|string',
|
'customer_notes' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$lead = \App\Models\Lead::findOrFail($validated['lead_id']);
|
$lead = Lead::findOrFail($validated['lead_id']);
|
||||||
Gate::authorize('changeStage', $lead);
|
Gate::authorize('changeStage', $lead);
|
||||||
|
|
||||||
$update = ['pipeline_stage_id' => $pipelineStage->id];
|
$update = ['pipeline_stage_id' => $pipelineStage->id];
|
||||||
|
|
@ -100,7 +105,7 @@ class PipelineStageController extends Controller
|
||||||
$request->validate(['final_result' => 'required|in:موفق,ناموفق']);
|
$request->validate(['final_result' => 'required|in:موفق,ناموفق']);
|
||||||
$update['final_result'] = $validated['final_result'];
|
$update['final_result'] = $validated['final_result'];
|
||||||
$statusName = $validated['final_result'];
|
$statusName = $validated['final_result'];
|
||||||
$update['lead_status_id'] = \App\Models\LeadStatus::where($statusName === 'موفق' ? 'is_won' : 'is_lost', true)->value('id');
|
$update['lead_status_id'] = LeadStatus::where($statusName === 'موفق' ? 'is_won' : 'is_lost', true)->value('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($pipelineStage->is_won || $pipelineStage->is_lost) && ($validated['final_result'] ?? null) === 'ناموفق') {
|
if (($pipelineStage->is_won || $pipelineStage->is_lost) && ($validated['final_result'] ?? null) === 'ناموفق') {
|
||||||
|
|
@ -125,17 +130,14 @@ class PipelineStageController extends Controller
|
||||||
$lead->update($update);
|
$lead->update($update);
|
||||||
|
|
||||||
if ($pipelineStage->requires_follow_up) {
|
if ($pipelineStage->requires_follow_up) {
|
||||||
FollowUp::create([
|
$this->followUps->schedule(
|
||||||
'lead_id' => $lead->id,
|
$lead,
|
||||||
'user_id' => $lead->assigned_to ?? auth()->id(),
|
$lead->assigned_to ?? auth()->id(),
|
||||||
'scheduled_at' => $validated['next_follow_up_at'],
|
$validated['next_follow_up_at'],
|
||||||
'notes' => 'ثبت شده از قیف فروش',
|
$request->user(),
|
||||||
'status' => 'pending',
|
$validated['follow_up_notes'] ?? null,
|
||||||
]);
|
source: 'pipeline',
|
||||||
|
);
|
||||||
if ($lead->assigned_to) {
|
|
||||||
NotificationService::notifyFollowUpReminder($lead->assigned_to, $lead->company ?? $lead->full_name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ActivityLogger::log('lead_stage_changed', "Lead {$lead->id} moved to stage {$pipelineStage->name}", $lead);
|
ActivityLogger::log('lead_stage_changed', "Lead {$lead->id} moved to stage {$pipelineStage->name}", $lead);
|
||||||
|
|
|
||||||
|
|
@ -7,51 +7,64 @@ use App\Models\Product;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class ProductController extends Controller
|
class ProductController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
Gate::authorize('viewAny', Product::class);
|
||||||
$query = Product::with('salesScript:id,title')->withCount('deals');
|
$query = Product::with('salesScript:id,title')->withCount('deals');
|
||||||
if ($request->search) $query->where('name', 'like', "%{$request->search}%");
|
if ($request->search) {
|
||||||
if ($request->filled('is_active')) $query->where('is_active', filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN));
|
$query->where('name', 'like', "%{$request->search}%");
|
||||||
|
}
|
||||||
|
if ($request->filled('is_active')) {
|
||||||
|
$query->where('is_active', filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN));
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
|
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeManage();
|
Gate::authorize('create', Product::class);
|
||||||
$product = Product::create($this->validated($request) + ['created_by' => auth()->id()]);
|
$product = Product::create($this->validated($request) + ['created_by' => auth()->id()]);
|
||||||
ActivityLogger::log('product_created', "Product {$product->name} created", $product);
|
ActivityLogger::log('product_created', "Product {$product->name} created", $product);
|
||||||
|
|
||||||
return response()->json($product->load('salesScript:id,title'), 201);
|
return response()->json($product->load('salesScript:id,title'), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Product $product): JsonResponse
|
public function show(Product $product): JsonResponse
|
||||||
{
|
{
|
||||||
|
Gate::authorize('view', $product);
|
||||||
|
|
||||||
return response()->json($product->load('salesScript.sections', 'deals:id,title,product_id,status'));
|
return response()->json($product->load('salesScript.sections', 'deals:id,title,product_id,status'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, Product $product): JsonResponse
|
public function update(Request $request, Product $product): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeManage();
|
Gate::authorize('update', $product);
|
||||||
$product->update($this->validated($request, true));
|
$product->update($this->validated($request, true));
|
||||||
ActivityLogger::log('product_updated', "Product {$product->name} updated", $product);
|
ActivityLogger::log('product_updated', "Product {$product->name} updated", $product);
|
||||||
|
|
||||||
return response()->json($product->fresh('salesScript:id,title'));
|
return response()->json($product->fresh('salesScript:id,title'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Product $product): JsonResponse
|
public function destroy(Product $product): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeManage();
|
Gate::authorize('delete', $product);
|
||||||
$product->delete();
|
$product->delete();
|
||||||
ActivityLogger::log('product_deleted', "Product {$product->id} deleted");
|
ActivityLogger::log('product_deleted', "Product {$product->id} deleted");
|
||||||
|
|
||||||
return response()->json(['message' => 'محصول/خدمت حذف شد']);
|
return response()->json(['message' => 'محصول/خدمت حذف شد']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validated(Request $request, bool $partial = false): array
|
private function validated(Request $request, bool $partial = false): array
|
||||||
{
|
{
|
||||||
$sometimes = $partial ? 'sometimes|' : '';
|
$sometimes = $partial ? 'sometimes|' : '';
|
||||||
|
|
||||||
return $request->validate([
|
return $request->validate([
|
||||||
'name' => $sometimes . 'required|string|max:255',
|
'name' => $sometimes.'required|string|max:255',
|
||||||
'category' => 'nullable|string|max:120',
|
'category' => 'nullable|string|max:120',
|
||||||
'base_price' => 'nullable|numeric|min:0',
|
'base_price' => 'nullable|numeric|min:0',
|
||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
|
|
@ -61,9 +74,4 @@ class ProductController extends Controller
|
||||||
'objection_handling' => 'nullable|array',
|
'objection_handling' => 'nullable|array',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function authorizeManage(): void
|
|
||||||
{
|
|
||||||
abort_unless(auth()->user()?->hasRole('admin') || auth()->user()?->hasRole('supervisor') || auth()->user()?->can('manage_products'), 403, 'شما مجوز مدیریت محصولات را ندارید.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,75 +3,187 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Call;
|
||||||
use App\Models\QualityReview;
|
use App\Models\QualityReview;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
|
use App\Support\AccessControl;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class QualityReviewController extends Controller
|
class QualityReviewController extends Controller
|
||||||
{
|
{
|
||||||
|
private const SCORE_FIELDS = [
|
||||||
|
'greeting_score',
|
||||||
|
'product_intro_score',
|
||||||
|
'needs_discovery_score',
|
||||||
|
'objection_handling_score',
|
||||||
|
'closing_score',
|
||||||
|
'crm_accuracy_score',
|
||||||
|
'follow_up_quality_score',
|
||||||
|
];
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
Gate::authorize('viewAny', QualityReview::class);
|
||||||
$query = QualityReview::with('call:id,lead_id,created_at', 'agent:id,name', 'reviewer:id,name');
|
|
||||||
|
|
||||||
if ($user->hasRole('agent')) {
|
if ($request->has('current_only')) {
|
||||||
$query->where('agent_id', $user->id);
|
$request->merge([
|
||||||
} elseif ($user->hasRole('supervisor')) {
|
'current_only' => filter_var(
|
||||||
$teamIds = $user->teams->pluck('id')->toArray();
|
$request->input('current_only'),
|
||||||
$agentIds = \App\Models\Team::whereIn('id', $teamIds)->with('members')->get()->pluck('members.*.id')->flatten();
|
FILTER_VALIDATE_BOOLEAN,
|
||||||
$query->whereIn('agent_id', $agentIds);
|
FILTER_NULL_ON_FAILURE,
|
||||||
|
),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15));
|
$filters = $request->validate([
|
||||||
|
'agent_id' => 'nullable|integer|exists:users,id',
|
||||||
|
'score_min' => 'nullable|integer|min:0|max:100',
|
||||||
|
'score_max' => 'nullable|integer|min:0|max:100|gte:score_min',
|
||||||
|
'status' => 'nullable|string|max:30',
|
||||||
|
'shared' => 'nullable|boolean',
|
||||||
|
'date_from' => 'nullable|date',
|
||||||
|
'date_to' => 'nullable|date|after_or_equal:date_from',
|
||||||
|
'result' => 'nullable|string|max:50',
|
||||||
|
'has_recording' => 'nullable|boolean',
|
||||||
|
'weak_section' => 'nullable|in:greeting_score,product_intro_score,needs_discovery_score,objection_handling_score,closing_score,crm_accuracy_score,follow_up_quality_score',
|
||||||
|
'weak_threshold' => 'nullable|integer|min:0|max:100',
|
||||||
|
'current_only' => 'nullable|boolean',
|
||||||
|
'page' => 'nullable|integer|min:1',
|
||||||
|
'per_page' => 'nullable|integer|min:1|max:100',
|
||||||
|
]);
|
||||||
|
$user = $request->user();
|
||||||
|
$query = QualityReview::with('call:id,lead_id,user_id,result,recording_url,created_at', 'agent:id,name', 'reviewer:id,name');
|
||||||
|
|
||||||
|
if ($user->hasRole('agent')) {
|
||||||
|
$query->where('agent_id', $user->id)->where('is_shared_with_agent', true);
|
||||||
|
} elseif ($user->hasRole('supervisor')) {
|
||||||
|
$query->whereHas('call', fn ($callQuery) => AccessControl::scopeCalls($callQuery, $user));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->boolean('current_only', true)) {
|
||||||
|
$query->where('is_current', true);
|
||||||
|
}
|
||||||
|
if (! empty($filters['agent_id'])) {
|
||||||
|
$query->where('agent_id', $filters['agent_id']);
|
||||||
|
}
|
||||||
|
if (isset($filters['score_min'])) {
|
||||||
|
$query->where('overall_score', '>=', $filters['score_min']);
|
||||||
|
}
|
||||||
|
if (isset($filters['score_max'])) {
|
||||||
|
$query->where('overall_score', '<=', $filters['score_max']);
|
||||||
|
}
|
||||||
|
if (! empty($filters['status'])) {
|
||||||
|
$query->where('status', $filters['status']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('shared', $filters)) {
|
||||||
|
$query->where('is_shared_with_agent', (bool) $filters['shared']);
|
||||||
|
}
|
||||||
|
if (! empty($filters['date_from'])) {
|
||||||
|
$query->whereDate('created_at', '>=', $filters['date_from']);
|
||||||
|
}
|
||||||
|
if (! empty($filters['date_to'])) {
|
||||||
|
$query->whereDate('created_at', '<=', $filters['date_to']);
|
||||||
|
}
|
||||||
|
if (! empty($filters['result'])) {
|
||||||
|
$query->whereHas('call', fn ($call) => $call->where('result', $filters['result']));
|
||||||
|
}
|
||||||
|
if (array_key_exists('has_recording', $filters)) {
|
||||||
|
$query->whereHas('call', fn ($call) => (bool) $filters['has_recording']
|
||||||
|
? $call->whereNotNull('recording_url')
|
||||||
|
: $call->whereNull('recording_url'));
|
||||||
|
}
|
||||||
|
if (! empty($filters['weak_section'])) {
|
||||||
|
$query->where($filters['weak_section'], '<', $filters['weak_threshold'] ?? 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate(array_merge([
|
||||||
'call_id' => 'required|exists:calls,id',
|
'call_id' => 'required|exists:calls,id',
|
||||||
'agent_id' => 'required|exists:users,id',
|
|
||||||
'greeting_score' => 'required|integer|min:0|max:100',
|
|
||||||
'product_intro_score' => 'required|integer|min:0|max:100',
|
|
||||||
'needs_discovery_score' => 'required|integer|min:0|max:100',
|
|
||||||
'objection_handling_score' => 'required|integer|min:0|max:100',
|
|
||||||
'closing_score' => 'required|integer|min:0|max:100',
|
|
||||||
'crm_accuracy_score' => 'required|integer|min:0|max:100',
|
|
||||||
'follow_up_quality_score' => 'required|integer|min:0|max:100',
|
|
||||||
'feedback' => 'nullable|string',
|
'feedback' => 'nullable|string',
|
||||||
'tag' => 'nullable|string|max:30',
|
'tag' => 'nullable|string|max:30',
|
||||||
|
'is_shared_with_agent' => 'nullable|boolean',
|
||||||
|
'strengths' => 'nullable|array|max:20',
|
||||||
|
'improvement_areas' => 'nullable|array|max:20',
|
||||||
|
], $this->scoreRules()));
|
||||||
|
|
||||||
|
$call = Call::findOrFail($validated['call_id']);
|
||||||
|
Gate::authorize('create', [QualityReview::class, $call]);
|
||||||
|
|
||||||
|
$review = DB::transaction(function () use ($validated, $call, $request): QualityReview {
|
||||||
|
$latestVersion = QualityReview::where('call_id', $call->id)->lockForUpdate()->max('version') ?? 0;
|
||||||
|
QualityReview::where('call_id', $call->id)->update(['is_current' => false]);
|
||||||
|
|
||||||
|
return QualityReview::create($validated + [
|
||||||
|
'reviewer_id' => $request->user()->id,
|
||||||
|
'agent_id' => $call->user_id,
|
||||||
|
'version' => $latestVersion + 1,
|
||||||
|
'is_current' => true,
|
||||||
|
'is_shared_with_agent' => $validated['is_shared_with_agent'] ?? false,
|
||||||
|
'overall_score' => $this->overallScore($validated),
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
$validated['reviewer_id'] = auth()->id();
|
ActivityLogger::log('quality_review_created', "Quality review for call {$call->id} created", $review);
|
||||||
$validated['overall_score'] = (int) round(collect([
|
|
||||||
$validated['greeting_score'], $validated['product_intro_score'],
|
|
||||||
$validated['needs_discovery_score'], $validated['objection_handling_score'],
|
|
||||||
$validated['closing_score'], $validated['crm_accuracy_score'],
|
|
||||||
$validated['follow_up_quality_score'],
|
|
||||||
])->avg());
|
|
||||||
|
|
||||||
$review = QualityReview::create($validated);
|
return response()->json($review->load('call:id,lead_id,user_id,created_at', 'agent:id,name', 'reviewer:id,name'), 201);
|
||||||
|
|
||||||
ActivityLogger::log('quality_review_created', "Quality review for agent {$validated['agent_id']} created", $review);
|
|
||||||
|
|
||||||
return response()->json($review->load('agent:id,name', 'reviewer:id,name'), 201);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(QualityReview $qualityReview): JsonResponse
|
public function show(QualityReview $qualityReview): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json($qualityReview->load('call.lead', 'agent', 'reviewer'));
|
Gate::authorize('view', $qualityReview);
|
||||||
|
|
||||||
|
return response()->json($qualityReview->load('call.lead', 'agent:id,name', 'reviewer:id,name'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, QualityReview $qualityReview): JsonResponse
|
public function update(Request $request, QualityReview $qualityReview): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
Gate::authorize('update', $qualityReview);
|
||||||
|
$validated = $request->validate(array_merge([
|
||||||
'feedback' => 'nullable|string',
|
'feedback' => 'nullable|string',
|
||||||
'tag' => 'nullable|string|max:30',
|
'tag' => 'nullable|string|max:30',
|
||||||
'is_shared_with_agent' => 'nullable|boolean',
|
'is_shared_with_agent' => 'nullable|boolean',
|
||||||
]);
|
'strengths' => 'nullable|array|max:20',
|
||||||
|
'improvement_areas' => 'nullable|array|max:20',
|
||||||
|
], $this->scoreRules(required: false)));
|
||||||
|
|
||||||
|
if (collect(self::SCORE_FIELDS)->contains(fn (string $field) => array_key_exists($field, $validated))) {
|
||||||
|
$validated['overall_score'] = $this->overallScore(array_merge($qualityReview->only(self::SCORE_FIELDS), $validated));
|
||||||
|
}
|
||||||
|
|
||||||
$qualityReview->update($validated);
|
$qualityReview->update($validated);
|
||||||
|
ActivityLogger::log('quality_review_updated', "Quality review {$qualityReview->id} updated", $qualityReview);
|
||||||
|
|
||||||
return response()->json($qualityReview);
|
return response()->json($qualityReview->fresh(['call:id,lead_id,user_id,created_at', 'agent:id,name', 'reviewer:id,name']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function acknowledge(Request $request, QualityReview $qualityReview): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('acknowledge_quality_reviews'), 403);
|
||||||
|
Gate::authorize('view', $qualityReview);
|
||||||
|
abort_unless($qualityReview->agent_id === $request->user()->id, 403, 'فقط کارشناس ارزیابیشده میتواند تأیید کند.');
|
||||||
|
$data = $request->validate(['agent_response' => 'nullable|string|max:2000']);
|
||||||
|
$qualityReview->update(['status' => 'acknowledged', 'acknowledged_at' => now(), 'agent_response' => $data['agent_response'] ?? null]);
|
||||||
|
ActivityLogger::log('quality_review_acknowledged', "Quality review {$qualityReview->id} acknowledged", $qualityReview);
|
||||||
|
|
||||||
|
return response()->json($qualityReview->fresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scoreRules(bool $required = true): array
|
||||||
|
{
|
||||||
|
return collect(self::SCORE_FIELDS)->mapWithKeys(
|
||||||
|
fn (string $field): array => [$field => ($required ? 'required' : 'sometimes').'|integer|min:0|max:100']
|
||||||
|
)->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function overallScore(array $data): int
|
||||||
|
{
|
||||||
|
return (int) round(collect(self::SCORE_FIELDS)->map(fn (string $field) => (int) $data[$field])->avg());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,21 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Campaign;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\Lead;
|
||||||
|
use App\Models\LeadStatus;
|
||||||
|
use App\Models\SlaBreach;
|
||||||
|
use App\Models\Task;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Campaign;
|
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
use App\Services\ReportService;
|
use App\Services\ReportService;
|
||||||
use App\Support\AccessControl;
|
use App\Support\AccessControl;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
|
@ -18,6 +25,75 @@ class ReportController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private ReportService $reportService) {}
|
public function __construct(private ReportService $reportService) {}
|
||||||
|
|
||||||
|
public function operations(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('view-report-data');
|
||||||
|
$filters = $request->validate(['date_from' => 'nullable|date', 'date_to' => 'nullable|date|after_or_equal:date_from']);
|
||||||
|
$leads = Lead::query();
|
||||||
|
AccessControl::scopeLeads($leads, $request->user());
|
||||||
|
$invoices = Invoice::query()->whereHas('lead', function ($query) use ($request) {
|
||||||
|
AccessControl::scopeLeads($query, $request->user());
|
||||||
|
});
|
||||||
|
$tasks = Task::query();
|
||||||
|
AccessControl::scopeTasks($tasks, $request->user());
|
||||||
|
if (! empty($filters['date_from'])) {
|
||||||
|
$leads->whereDate('leads.created_at', '>=', $filters['date_from']);
|
||||||
|
$invoices->whereDate('invoices.created_at', '>=', $filters['date_from']);
|
||||||
|
$tasks->whereDate('tasks.created_at', '>=', $filters['date_from']);
|
||||||
|
}
|
||||||
|
if (! empty($filters['date_to'])) {
|
||||||
|
$leads->whereDate('leads.created_at', '<=', $filters['date_to']);
|
||||||
|
$invoices->whereDate('invoices.created_at', '<=', $filters['date_to']);
|
||||||
|
$tasks->whereDate('tasks.created_at', '<=', $filters['date_to']);
|
||||||
|
}
|
||||||
|
$pipeline = LeadStatus::query()->where('is_active', true)->orderBy('sort_order')->get()
|
||||||
|
->map(fn (LeadStatus $status) => [
|
||||||
|
'stage' => $status->name,
|
||||||
|
'count' => (clone $leads)->where('lead_status_id', $status->id)->count(),
|
||||||
|
]);
|
||||||
|
$sla = SlaBreach::query();
|
||||||
|
if ($request->user()->hasRole('agent')) {
|
||||||
|
$sla->where('assigned_to', $request->user()->id);
|
||||||
|
} elseif ($request->user()->hasRole('supervisor')) {
|
||||||
|
$sla->whereIn('assigned_to', AccessControl::teamMemberIds($request->user()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'summary' => [
|
||||||
|
'open_leads' => (clone $leads)->whereNull('final_result')->count(),
|
||||||
|
'issued_invoices' => (clone $invoices)->where('status', 'issued')->count(),
|
||||||
|
'invoiced_total' => (float) (clone $invoices)->where('status', 'issued')->sum('total'),
|
||||||
|
'outstanding_total' => max(
|
||||||
|
0,
|
||||||
|
(float) (clone $invoices)->where('status', 'issued')->sum('total')
|
||||||
|
- (float) (clone $invoices)->where('status', 'issued')->sum('paid_amount'),
|
||||||
|
),
|
||||||
|
'sla_breaches' => (clone $sla)->where('status', 'breached')->count(),
|
||||||
|
'overdue_tasks' => (clone $tasks)->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())->count(),
|
||||||
|
],
|
||||||
|
'pipeline' => $pipeline,
|
||||||
|
'sla_by_status' => (clone $sla)->selectRaw('status, COUNT(*) as count')->groupBy('status')->get(),
|
||||||
|
'tasks_by_status' => (clone $tasks)->selectRaw('status, COUNT(*) as count')->groupBy('status')->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function kpi(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('view-report-data');
|
||||||
|
$validated = $this->validateScopedReport($request);
|
||||||
|
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
|
||||||
|
if ($agentIds === false) {
|
||||||
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($this->reportService->kpiDashboard(
|
||||||
|
$validated['date_from'] ?? null,
|
||||||
|
$validated['date_to'] ?? null,
|
||||||
|
$validated['agent_id'] ?? null,
|
||||||
|
$agentIds
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
public function agentPerformance(Request $request): JsonResponse
|
public function agentPerformance(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('view-report-data');
|
Gate::authorize('view-report-data');
|
||||||
|
|
@ -29,14 +105,14 @@ class ReportController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$allowedAgentIds = $this->allowedAgentIds();
|
$allowedAgentIds = $this->allowedAgentIds();
|
||||||
if (!empty($validated['agent_id']) && !in_array((int) $validated['agent_id'], $allowedAgentIds, true)) {
|
if (! empty($validated['agent_id']) && ! in_array((int) $validated['agent_id'], $allowedAgentIds, true)) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($validated['agent_id'])) {
|
if (empty($validated['agent_id'])) {
|
||||||
$agents = User::role('agent')->whereIn('id', $allowedAgentIds)->orderBy('name')->get();
|
$agents = User::role('agent')->whereIn('id', $allowedAgentIds)->orderBy('name')->get();
|
||||||
|
|
||||||
return response()->json($agents->map(fn(User $agent) => $this->reportService->agentPerformance(
|
return response()->json($agents->map(fn (User $agent) => $this->reportService->agentPerformance(
|
||||||
$agent->id,
|
$agent->id,
|
||||||
$validated['date_from'] ?? null,
|
$validated['date_from'] ?? null,
|
||||||
$validated['date_to'] ?? null
|
$validated['date_to'] ?? null
|
||||||
|
|
@ -55,6 +131,7 @@ class ReportController extends Controller
|
||||||
public function teamPerformance(Request $request): JsonResponse
|
public function teamPerformance(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('view-report-data');
|
Gate::authorize('view-report-data');
|
||||||
|
abort_if($request->user()->hasRole('agent'), 403, 'کارشناس فقط به گزارش عملکرد خود دسترسی دارد.');
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'team_id' => 'nullable|exists:teams,id',
|
'team_id' => 'nullable|exists:teams,id',
|
||||||
|
|
@ -63,14 +140,14 @@ class ReportController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$allowedTeamIds = $this->allowedTeamIds();
|
$allowedTeamIds = $this->allowedTeamIds();
|
||||||
if (!empty($validated['team_id']) && !in_array((int) $validated['team_id'], $allowedTeamIds, true)) {
|
if (! empty($validated['team_id']) && ! in_array((int) $validated['team_id'], $allowedTeamIds, true)) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($validated['team_id'])) {
|
if (empty($validated['team_id'])) {
|
||||||
$teams = Team::whereIn('id', $allowedTeamIds)->orderBy('name')->get();
|
$teams = Team::whereIn('id', $allowedTeamIds)->orderBy('name')->get();
|
||||||
|
|
||||||
return response()->json($teams->map(fn(Team $team) => $this->reportService->teamPerformance(
|
return response()->json($teams->map(fn (Team $team) => $this->reportService->teamPerformance(
|
||||||
$team->id,
|
$team->id,
|
||||||
$validated['date_from'] ?? null,
|
$validated['date_from'] ?? null,
|
||||||
$validated['date_to'] ?? null
|
$validated['date_to'] ?? null
|
||||||
|
|
@ -89,6 +166,7 @@ class ReportController extends Controller
|
||||||
public function campaignReport(Request $request, int $campaignId): JsonResponse
|
public function campaignReport(Request $request, int $campaignId): JsonResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('view-report-data');
|
Gate::authorize('view-report-data');
|
||||||
|
abort_if($request->user()->hasRole('agent'), 403, 'کارشناس فقط به گزارش عملکرد خود دسترسی دارد.');
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'date_from' => 'nullable|date',
|
'date_from' => 'nullable|date',
|
||||||
|
|
@ -185,7 +263,9 @@ class ReportController extends Controller
|
||||||
Gate::authorize('view-report-data');
|
Gate::authorize('view-report-data');
|
||||||
$validated = $this->validateScopedReport($request);
|
$validated = $this->validateScopedReport($request);
|
||||||
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
|
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
|
||||||
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
if ($agentIds === false) {
|
||||||
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json($this->reportService->lostReasonReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
|
return response()->json($this->reportService->lostReasonReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
|
||||||
}
|
}
|
||||||
|
|
@ -195,7 +275,9 @@ class ReportController extends Controller
|
||||||
Gate::authorize('view-report-data');
|
Gate::authorize('view-report-data');
|
||||||
$validated = $this->validateScopedReport($request);
|
$validated = $this->validateScopedReport($request);
|
||||||
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
|
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
|
||||||
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
if ($agentIds === false) {
|
||||||
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json($this->reportService->sourcePerformanceReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
|
return response()->json($this->reportService->sourcePerformanceReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
|
||||||
}
|
}
|
||||||
|
|
@ -205,7 +287,9 @@ class ReportController extends Controller
|
||||||
Gate::authorize('view-report-data');
|
Gate::authorize('view-report-data');
|
||||||
$validated = $this->validateScopedReport($request);
|
$validated = $this->validateScopedReport($request);
|
||||||
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
|
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
|
||||||
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
if ($agentIds === false) {
|
||||||
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json($this->reportService->duplicateLeadsReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
|
return response()->json($this->reportService->duplicateLeadsReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
|
||||||
}
|
}
|
||||||
|
|
@ -213,6 +297,7 @@ class ReportController extends Controller
|
||||||
public function importQuality(Request $request): JsonResponse
|
public function importQuality(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('view-report-data');
|
Gate::authorize('view-report-data');
|
||||||
|
abort_if($request->user()->hasRole('agent'), 403, 'کارشناس فقط به گزارش عملکرد خود دسترسی دارد.');
|
||||||
$validated = $request->validate(['date_from' => 'nullable|date', 'date_to' => 'nullable|date']);
|
$validated = $request->validate(['date_from' => 'nullable|date', 'date_to' => 'nullable|date']);
|
||||||
|
|
||||||
return response()->json($this->reportService->importQualityReport($validated['date_from'] ?? null, $validated['date_to'] ?? null));
|
return response()->json($this->reportService->importQualityReport($validated['date_from'] ?? null, $validated['date_to'] ?? null));
|
||||||
|
|
@ -223,7 +308,9 @@ class ReportController extends Controller
|
||||||
Gate::authorize('view-report-data');
|
Gate::authorize('view-report-data');
|
||||||
$validated = $this->validateScopedReport($request);
|
$validated = $this->validateScopedReport($request);
|
||||||
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
|
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
|
||||||
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
if ($agentIds === false) {
|
||||||
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json($this->reportService->callQualityReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
|
return response()->json($this->reportService->callQualityReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
|
||||||
}
|
}
|
||||||
|
|
@ -233,7 +320,9 @@ class ReportController extends Controller
|
||||||
Gate::authorize('view-report-data');
|
Gate::authorize('view-report-data');
|
||||||
$validated = $this->validateScopedReport($request);
|
$validated = $this->validateScopedReport($request);
|
||||||
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
|
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
|
||||||
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
if ($agentIds === false) {
|
||||||
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json($this->reportService->bestContactTimeReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
|
return response()->json($this->reportService->bestContactTimeReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
|
||||||
}
|
}
|
||||||
|
|
@ -243,18 +332,23 @@ class ReportController extends Controller
|
||||||
Gate::authorize('export-report-data');
|
Gate::authorize('export-report-data');
|
||||||
|
|
||||||
$type = $request->type ?? 'agent';
|
$type = $request->type ?? 'agent';
|
||||||
|
$agentExportTypes = ['kpi', 'agent', 'conversion', 'call', 'follow_up', 'lost_reason', 'source', 'duplicate', 'call_quality', 'best_contact_time'];
|
||||||
|
if ($request->user()->hasRole('agent') && ! in_array($type, $agentExportTypes, true)) {
|
||||||
|
return response()->json(['message' => 'کارشناس فقط میتواند گزارش عملکرد خود را خروجی بگیرد.'], 403);
|
||||||
|
}
|
||||||
$agentIds = $this->requestedAgentScope($request->integer('agent_id') ?: null);
|
$agentIds = $this->requestedAgentScope($request->integer('agent_id') ?: null);
|
||||||
if ($agentIds === false) {
|
if ($agentIds === false) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->filled('team_id') && !in_array($request->integer('team_id'), $this->allowedTeamIds(), true)) {
|
if ($request->filled('team_id') && ! in_array($request->integer('team_id'), $this->allowedTeamIds(), true)) {
|
||||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
ActivityLogger::log('report_exported', "Report export requested: {$type}");
|
ActivityLogger::log('report_exported', "Report export requested: {$type}");
|
||||||
|
|
||||||
$data = match ($type) {
|
$data = match ($type) {
|
||||||
|
'kpi' => $this->reportService->kpiDashboard($request->date_from, $request->date_to, $request->integer('agent_id') ?: null, $agentIds),
|
||||||
'agent' => $this->reportService->agentPerformance($request->integer('agent_id') ?: auth()->id(), $request->date_from, $request->date_to),
|
'agent' => $this->reportService->agentPerformance($request->integer('agent_id') ?: auth()->id(), $request->date_from, $request->date_to),
|
||||||
'team' => $this->reportService->teamPerformance($request->integer('team_id') ?: ($this->allowedTeamIds()[0] ?? 0), $request->date_from, $request->date_to),
|
'team' => $this->reportService->teamPerformance($request->integer('team_id') ?: ($this->allowedTeamIds()[0] ?? 0), $request->date_from, $request->date_to),
|
||||||
'campaign' => $request->integer('campaign_id') ? $this->reportService->campaignReport($request->integer('campaign_id'), $request->date_from, $request->date_to) : [],
|
'campaign' => $request->integer('campaign_id') ? $this->reportService->campaignReport($request->integer('campaign_id'), $request->date_from, $request->date_to) : [],
|
||||||
|
|
@ -271,7 +365,7 @@ class ReportController extends Controller
|
||||||
};
|
};
|
||||||
|
|
||||||
$rows = $this->flattenForCsv($data);
|
$rows = $this->flattenForCsv($data);
|
||||||
$filename = 'report-' . $type . '-' . now()->format('Ymd-His') . '.csv';
|
$filename = 'report-'.$type.'-'.now()->format('Ymd-His').'.csv';
|
||||||
|
|
||||||
return response()->streamDownload(function () use ($rows) {
|
return response()->streamDownload(function () use ($rows) {
|
||||||
$out = fopen('php://output', 'w');
|
$out = fopen('php://output', 'w');
|
||||||
|
|
@ -280,11 +374,12 @@ class ReportController extends Controller
|
||||||
fputcsv($out, ['message'], ',');
|
fputcsv($out, ['message'], ',');
|
||||||
fputcsv($out, ['دادهای برای خروجی وجود ندارد'], ',');
|
fputcsv($out, ['دادهای برای خروجی وجود ندارد'], ',');
|
||||||
fclose($out);
|
fclose($out);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fputcsv($out, array_keys($rows[0]), ',');
|
fputcsv($out, array_keys($rows[0]), ',');
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
fputcsv($out, array_map(fn($value) => is_scalar($value) || $value === null ? $value : json_encode($value, JSON_UNESCAPED_UNICODE), $row), ',');
|
fputcsv($out, array_map(fn ($value) => is_scalar($value) || $value === null ? $value : json_encode($value, JSON_UNESCAPED_UNICODE), $row), ',');
|
||||||
}
|
}
|
||||||
fclose($out);
|
fclose($out);
|
||||||
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
|
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||||
|
|
@ -336,9 +431,9 @@ class ReportController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<int>|false
|
* @return list<int>|false|null
|
||||||
*/
|
*/
|
||||||
private function requestedAgentScope(?int $agentId): array|false
|
private function requestedAgentScope(?int $agentId): array|false|null
|
||||||
{
|
{
|
||||||
$allowedAgentIds = $this->allowedAgentIds();
|
$allowedAgentIds = $this->allowedAgentIds();
|
||||||
|
|
||||||
|
|
@ -346,28 +441,30 @@ class ReportController extends Controller
|
||||||
return in_array($agentId, $allowedAgentIds, true) ? [$agentId] : false;
|
return in_array($agentId, $allowedAgentIds, true) ? [$agentId] : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return auth()->user()->hasRole('admin') ? [] : $allowedAgentIds;
|
return auth()->user()->hasRole('admin') ? null : $allowedAgentIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function flattenForCsv(mixed $data): array
|
private function flattenForCsv(mixed $data): array
|
||||||
{
|
{
|
||||||
if ($data instanceof \Illuminate\Support\Collection) {
|
if ($data instanceof Collection) {
|
||||||
$data = $data->toArray();
|
$data = $data->toArray();
|
||||||
}
|
}
|
||||||
if ($data instanceof \Illuminate\Database\Eloquent\Model) {
|
if ($data instanceof Model) {
|
||||||
$data = $data->toArray();
|
$data = $data->toArray();
|
||||||
}
|
}
|
||||||
if (is_array($data) && array_is_list($data)) {
|
if (is_array($data) && array_is_list($data)) {
|
||||||
return array_map(fn($row) => is_array($row) ? $row : ['value' => $row], $data);
|
return array_map(fn ($row) => is_array($row) ? $row : ['value' => $row], $data);
|
||||||
}
|
}
|
||||||
if (is_array($data)) {
|
if (is_array($data)) {
|
||||||
foreach (['agents', 'by_stage', 'by_result', 'items', 'by_reason', 'sources', 'phone_duplicates', 'batches', 'by_agent', 'by_hour'] as $key) {
|
foreach (['agents', 'by_stage', 'by_result', 'items', 'by_reason', 'sources', 'phone_duplicates', 'batches', 'by_agent', 'by_hour'] as $key) {
|
||||||
if (isset($data[$key]) && is_iterable($data[$key])) {
|
if (isset($data[$key]) && is_iterable($data[$key])) {
|
||||||
return collect($data[$key])->map(fn($row) => $row instanceof \Illuminate\Database\Eloquent\Model ? $row->toArray() : (array) $row)->values()->all();
|
return collect($data[$key])->map(fn ($row) => $row instanceof Model ? $row->toArray() : (array) $row)->values()->all();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$data];
|
return [$data];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,15 @@ use App\Http\Controllers\Controller;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Spatie\Permission\Models\Role;
|
|
||||||
use Spatie\Permission\Models\Permission;
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
class RoleController extends Controller
|
class RoleController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
$roles = Role::with('permissions')->orderBy('name')->get();
|
$roles = Role::with('permissions')->orderBy('name')->get();
|
||||||
|
|
||||||
return response()->json($roles);
|
return response()->json($roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,7 +45,7 @@ class RoleController extends Controller
|
||||||
public function update(Request $request, Role $role): JsonResponse
|
public function update(Request $request, Role $role): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'sometimes|string|unique:roles,name,' . $role->id,
|
'name' => 'sometimes|string|unique:roles,name,'.$role->id,
|
||||||
'permissions' => 'sometimes|array',
|
'permissions' => 'sometimes|array',
|
||||||
'permissions.*' => 'exists:permissions,name',
|
'permissions.*' => 'exists:permissions,name',
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -3,73 +3,111 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Campaign;
|
||||||
|
use App\Models\Product;
|
||||||
use App\Models\SalesScript;
|
use App\Models\SalesScript;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
|
use App\Services\NotificationService;
|
||||||
|
use App\Support\AccessControl;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class ScriptController extends Controller
|
class ScriptController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json(
|
Gate::authorize('viewAny', SalesScript::class);
|
||||||
SalesScript::with('sections', 'campaign:id,name')
|
$query = SalesScript::with('sections', 'campaign:id,name,sales_script_id', 'product:id,name,sales_script_id', 'assignees:id,name');
|
||||||
->orderBy('created_at', 'desc')
|
if (! $request->user()->can('manage_scripts')) {
|
||||||
->paginate(15)
|
$query->where('is_active', true)->whereHas('assignees', fn ($users) => $users->whereKey($request->user()->id));
|
||||||
);
|
}
|
||||||
|
foreach (['category', 'lead_source'] as $filter) {
|
||||||
|
if ($request->filled($filter)) {
|
||||||
|
$query->where($filter, $request->string($filter));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->string('search');
|
||||||
|
$query->where(fn ($scope) => $scope->where('title', 'like', "%{$search}%")->orWhere('description', 'like', "%{$search}%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
Gate::authorize('create', SalesScript::class);
|
||||||
'title' => 'required|string|max:255',
|
$validated = $request->validate($this->rules());
|
||||||
'description' => 'nullable|string',
|
$this->authorizeParents($validated);
|
||||||
'campaign_id' => 'nullable|exists:campaigns,id',
|
$this->authorizeAssignees($validated['assigned_user_ids'] ?? [], $request->user());
|
||||||
'product_id' => 'nullable|exists:products,id',
|
|
||||||
'version' => 'nullable|string|max:20',
|
|
||||||
'checklist' => 'nullable|array',
|
|
||||||
'objection_handling' => 'nullable|array',
|
|
||||||
'sections' => 'nullable|array',
|
|
||||||
'sections.*.title' => 'required|string|max:255',
|
|
||||||
'sections.*.content' => 'required|string',
|
|
||||||
'sections.*.sort_order' => 'nullable|integer',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$script = SalesScript::create([
|
$script = DB::transaction(function () use ($validated, $request): SalesScript {
|
||||||
'title' => $validated['title'],
|
$script = SalesScript::create(collect($validated)->except(['sections', 'campaign_id', 'product_id', 'assigned_user_ids'])->all());
|
||||||
'description' => $validated['description'] ?? null,
|
$this->syncSections($script, $validated['sections'] ?? []);
|
||||||
'campaign_id' => $validated['campaign_id'] ?? null,
|
$this->syncParents($script, $validated);
|
||||||
'product_id' => $validated['product_id'] ?? null,
|
$this->syncAssignees($script, $validated['assigned_user_ids'] ?? [], $request->user());
|
||||||
'version' => $validated['version'] ?? '1.0',
|
|
||||||
'is_active' => true,
|
|
||||||
'checklist' => $validated['checklist'] ?? null,
|
|
||||||
'objection_handling' => $validated['objection_handling'] ?? null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($request->sections) {
|
return $script;
|
||||||
foreach ($validated['sections'] as $i => $section) {
|
});
|
||||||
$script->sections()->create([
|
|
||||||
'title' => $section['title'],
|
|
||||||
'content' => $section['content'],
|
|
||||||
'sort_order' => $section['sort_order'] ?? $i,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ActivityLogger::log('script_created', "Script {$script->title} created", $script);
|
ActivityLogger::log('script_created', "Script {$script->title} created", $script);
|
||||||
|
|
||||||
return response()->json($script->load('sections'), 201);
|
return response()->json($script->load('sections', 'campaign:id,name,sales_script_id', 'product:id,name,sales_script_id', 'assignees:id,name'), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(SalesScript $script): JsonResponse
|
public function show(SalesScript $script): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json($script->load('sections', 'campaign'));
|
Gate::authorize('view', $script);
|
||||||
|
|
||||||
|
return response()->json($script->load('sections', 'campaign:id,name,sales_script_id', 'product:id,name,sales_script_id', 'assignees:id,name'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, SalesScript $script): JsonResponse
|
public function update(Request $request, SalesScript $script): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
Gate::authorize('update', $script);
|
||||||
'title' => 'sometimes|string|max:255',
|
$validated = $request->validate($this->rules(partial: true));
|
||||||
|
$this->authorizeParents($validated);
|
||||||
|
if ($request->has('assigned_user_ids')) {
|
||||||
|
$this->authorizeAssignees($validated['assigned_user_ids'] ?? [], $request->user());
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($script, $validated, $request): void {
|
||||||
|
$script->update(collect($validated)->except(['sections', 'campaign_id', 'product_id', 'assigned_user_ids'])->all());
|
||||||
|
if ($request->has('sections')) {
|
||||||
|
$this->syncSections($script, $validated['sections'] ?? []);
|
||||||
|
}
|
||||||
|
if ($request->has('campaign_id') || $request->has('product_id')) {
|
||||||
|
$this->syncParents($script, $validated, true);
|
||||||
|
}
|
||||||
|
if ($request->has('assigned_user_ids')) {
|
||||||
|
$this->syncAssignees($script, $validated['assigned_user_ids'] ?? [], $request->user());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ActivityLogger::log('script_updated', "Script {$script->title} updated", $script);
|
||||||
|
|
||||||
|
return response()->json($script->fresh(['sections', 'campaign:id,name,sales_script_id', 'product:id,name,sales_script_id', 'assignees:id,name']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(SalesScript $script): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('delete', $script);
|
||||||
|
$title = $script->title;
|
||||||
|
$script->delete();
|
||||||
|
ActivityLogger::log('script_deleted', "Script {$title} deleted");
|
||||||
|
|
||||||
|
return response()->json(['message' => 'اسکریپت حذف شد']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rules(bool $partial = false): array
|
||||||
|
{
|
||||||
|
$required = $partial ? 'sometimes' : 'required';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => "{$required}|string|max:255",
|
||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
'campaign_id' => 'nullable|exists:campaigns,id',
|
'campaign_id' => 'nullable|exists:campaigns,id',
|
||||||
'product_id' => 'nullable|exists:products,id',
|
'product_id' => 'nullable|exists:products,id',
|
||||||
|
|
@ -77,52 +115,83 @@ class ScriptController extends Controller
|
||||||
'is_active' => 'nullable|boolean',
|
'is_active' => 'nullable|boolean',
|
||||||
'checklist' => 'nullable|array',
|
'checklist' => 'nullable|array',
|
||||||
'objection_handling' => 'nullable|array',
|
'objection_handling' => 'nullable|array',
|
||||||
|
'category' => 'nullable|string|max:100',
|
||||||
|
'lead_source' => 'nullable|string|max:100',
|
||||||
|
'suggested_questions' => 'nullable|array|max:50',
|
||||||
|
'required_disclosures' => 'nullable|array|max:50',
|
||||||
|
'is_template' => 'nullable|boolean',
|
||||||
|
'assigned_user_ids' => 'nullable|array|max:200',
|
||||||
|
'assigned_user_ids.*' => 'integer|distinct|exists:users,id',
|
||||||
'sections' => 'nullable|array',
|
'sections' => 'nullable|array',
|
||||||
'sections.*.id' => 'nullable|exists:script_sections,id',
|
'sections.*.id' => 'nullable|integer|exists:script_sections,id',
|
||||||
'sections.*.title' => 'required|string|max:255',
|
'sections.*.title' => 'required|string|max:255',
|
||||||
'sections.*.content' => 'required|string',
|
'sections.*.content' => 'required|string',
|
||||||
'sections.*.sort_order' => 'nullable|integer',
|
'sections.*.sort_order' => 'nullable|integer|min:0|distinct',
|
||||||
]);
|
];
|
||||||
|
|
||||||
$script->update($validated);
|
|
||||||
|
|
||||||
if ($request->has('sections')) {
|
|
||||||
$existingIds = $script->sections->pluck('id')->toArray();
|
|
||||||
$updatedIds = [];
|
|
||||||
|
|
||||||
foreach ($validated['sections'] as $i => $section) {
|
|
||||||
if (isset($section['id']) && in_array($section['id'], $existingIds)) {
|
|
||||||
$script->sections()->where('id', $section['id'])->update([
|
|
||||||
'title' => $section['title'],
|
|
||||||
'content' => $section['content'],
|
|
||||||
'sort_order' => $section['sort_order'] ?? $i,
|
|
||||||
]);
|
|
||||||
$updatedIds[] = $section['id'];
|
|
||||||
} else {
|
|
||||||
$newSection = $script->sections()->create([
|
|
||||||
'title' => $section['title'],
|
|
||||||
'content' => $section['content'],
|
|
||||||
'sort_order' => $section['sort_order'] ?? $i,
|
|
||||||
]);
|
|
||||||
$updatedIds[] = $newSection->id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$toDelete = array_diff($existingIds, $updatedIds);
|
private function authorizeParents(array $validated): void
|
||||||
if (!empty($toDelete)) {
|
|
||||||
$script->sections()->whereIn('id', $toDelete)->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ActivityLogger::log('script_updated', "Script {$script->title} updated", $script);
|
|
||||||
|
|
||||||
return response()->json($script->load('sections'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(SalesScript $script): JsonResponse
|
|
||||||
{
|
{
|
||||||
$script->delete();
|
if (! empty($validated['campaign_id'])) {
|
||||||
ActivityLogger::log('script_deleted', "Script {$script->title} deleted");
|
Gate::authorize('update', Campaign::findOrFail($validated['campaign_id']));
|
||||||
return response()->json(['message' => 'اسکریپت حذف شد']);
|
}
|
||||||
|
if (! empty($validated['product_id'])) {
|
||||||
|
Gate::authorize('update', Product::findOrFail($validated['product_id']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncParents(SalesScript $script, array $validated, bool $partial = false): void
|
||||||
|
{
|
||||||
|
if (! $partial || array_key_exists('campaign_id', $validated)) {
|
||||||
|
Campaign::where('sales_script_id', $script->id)->update(['sales_script_id' => null]);
|
||||||
|
if (! empty($validated['campaign_id'])) {
|
||||||
|
Campaign::whereKey($validated['campaign_id'])->update(['sales_script_id' => $script->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! $partial || array_key_exists('product_id', $validated)) {
|
||||||
|
Product::where('sales_script_id', $script->id)->update(['sales_script_id' => null]);
|
||||||
|
if (! empty($validated['product_id'])) {
|
||||||
|
Product::whereKey($validated['product_id'])->update(['sales_script_id' => $script->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncSections(SalesScript $script, array $sections): void
|
||||||
|
{
|
||||||
|
$existingIds = $script->sections()->pluck('id')->all();
|
||||||
|
$keptIds = [];
|
||||||
|
DB::table('script_sections')->where('sales_script_id', $script->id)->update(['sort_order' => DB::raw('-id')]);
|
||||||
|
foreach (array_values($sections) as $index => $section) {
|
||||||
|
$values = ['title' => $section['title'], 'content' => $section['content'], 'sort_order' => $section['sort_order'] ?? $index];
|
||||||
|
if (! empty($section['id']) && in_array($section['id'], $existingIds, true)) {
|
||||||
|
$script->sections()->whereKey($section['id'])->update($values);
|
||||||
|
$keptIds[] = $section['id'];
|
||||||
|
} else {
|
||||||
|
$keptIds[] = $script->sections()->create($values)->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$script->sections()->whereNotIn('id', $keptIds)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeAssignees(array $ids, User $actor): void
|
||||||
|
{
|
||||||
|
$users = User::whereKey($ids)->where('is_active', true)->get();
|
||||||
|
abort_unless($users->count() === count(array_unique($ids)), 422, 'یک یا چند کارشناس انتخابشده معتبر نیستند.');
|
||||||
|
foreach ($users as $user) {
|
||||||
|
abort_unless($user->hasRole('agent'), 422, 'اسکریپت فقط به کارشناس فروش قابل تخصیص است.');
|
||||||
|
abort_unless($actor->hasRole('admin') || AccessControl::canAssignUser($actor, $user->id), 403, 'کارشناس انتخابشده خارج از تیم شما است.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncAssignees(SalesScript $script, array $ids, User $actor): void
|
||||||
|
{
|
||||||
|
$previous = $script->assignees()->pluck('users.id')->all();
|
||||||
|
$script->assignees()->syncWithPivotValues($ids, ['assigned_by' => $actor->id]);
|
||||||
|
foreach (array_diff($ids, $previous) as $userId) {
|
||||||
|
NotificationService::send((int) $userId, 'اسکریپت فروش جدید', "اسکریپت «{$script->title}» به شما اختصاص داده شد", 'script_assigned', [
|
||||||
|
'script_id' => $script->id,
|
||||||
|
'url' => '/sales-scripts',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,11 @@ class SettingController extends Controller
|
||||||
{
|
{
|
||||||
Gate::authorize('viewAny', Setting::class);
|
Gate::authorize('viewAny', Setting::class);
|
||||||
SettingsCatalog::ensureDefaults();
|
SettingsCatalog::ensureDefaults();
|
||||||
|
$runtimeKeys = collect(SettingsCatalog::definitions())
|
||||||
|
->where('is_runtime_enforced', true)
|
||||||
|
->pluck('key');
|
||||||
|
|
||||||
$settings = Setting::all()
|
$settings = Setting::whereIn('key', $runtimeKeys)->get()
|
||||||
->map(function (Setting $setting) {
|
->map(function (Setting $setting) {
|
||||||
$meta = SettingsCatalog::metaFor($setting->key) ?? [];
|
$meta = SettingsCatalog::metaFor($setting->key) ?? [];
|
||||||
if ($setting->is_secret) {
|
if ($setting->is_secret) {
|
||||||
|
|
@ -32,10 +35,11 @@ class SettingController extends Controller
|
||||||
'label' => $meta['label'] ?? $setting->key,
|
'label' => $meta['label'] ?? $setting->key,
|
||||||
'hint' => $meta['hint'] ?? $setting->description,
|
'hint' => $meta['hint'] ?? $setting->description,
|
||||||
'used_by' => $meta['used_by'] ?? [],
|
'used_by' => $meta['used_by'] ?? [],
|
||||||
'coming_soon' => $meta['coming_soon'] ?? !$setting->is_runtime_enforced,
|
'coming_soon' => $meta['coming_soon'] ?? ! $setting->is_runtime_enforced,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->groupBy('group');
|
->groupBy('group');
|
||||||
|
|
||||||
return response()->json($settings);
|
return response()->json($settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,9 +95,13 @@ class SettingController extends Controller
|
||||||
public function public(): JsonResponse
|
public function public(): JsonResponse
|
||||||
{
|
{
|
||||||
SettingsCatalog::ensureDefaults();
|
SettingsCatalog::ensureDefaults();
|
||||||
$settings = Setting::where('is_public', true)->get()
|
$publicRuntimeKeys = collect(SettingsCatalog::definitions())
|
||||||
->filter(fn(Setting $setting) => !$setting->is_secret)
|
->where('is_runtime_enforced', true)
|
||||||
->mapWithKeys(fn(Setting $setting) => [$setting->key => $setting->effectiveValue()]);
|
->where('is_public', true)
|
||||||
|
->pluck('key');
|
||||||
|
$settings = Setting::whereIn('key', $publicRuntimeKeys)->get()
|
||||||
|
->filter(fn (Setting $setting) => ! $setting->is_secret)
|
||||||
|
->mapWithKeys(fn (Setting $setting) => [$setting->key => $setting->effectiveValue()]);
|
||||||
|
|
||||||
return response()->json($settings);
|
return response()->json($settings);
|
||||||
}
|
}
|
||||||
|
|
@ -101,27 +109,34 @@ class SettingController extends Controller
|
||||||
public function testVoip(): JsonResponse
|
public function testVoip(): JsonResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('viewAny', Setting::class);
|
Gate::authorize('viewAny', Setting::class);
|
||||||
$provider = Setting::where('key', 'voip_provider')->value('value') ?: 'mock';
|
$provider = Setting::where('key', 'voip_provider')->value('value') ?: 'none';
|
||||||
$missing = [];
|
$missing = [];
|
||||||
if ($provider === 'ami') {
|
if ($provider === 'ami') {
|
||||||
foreach (['voip_ami_host', 'voip_ami_port', 'voip_ami_username', 'voip_ami_secret', 'voip_ami_channel_technology', 'voip_ami_context'] as $key) {
|
foreach (['voip_ami_host', 'voip_ami_port', 'voip_ami_username', 'voip_ami_secret', 'voip_ami_channel_technology', 'voip_ami_context'] as $key) {
|
||||||
if (!Setting::where('key', $key)->value('value')) $missing[] = $key;
|
if (! Setting::where('key', $key)->value('value')) {
|
||||||
|
$missing[] = $key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($provider === 'api') {
|
if ($provider === 'api') {
|
||||||
foreach (['voip_api_base_url', 'voip_api_token'] as $key) {
|
foreach (['voip_api_base_url', 'voip_api_token'] as $key) {
|
||||||
if (!Setting::where('key', $key)->value('value')) $missing[] = $key;
|
if (! Setting::where('key', $key)->value('value')) {
|
||||||
|
$missing[] = $key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($provider === 'socket') {
|
if ($provider === 'socket') {
|
||||||
foreach (['voip_socket_host', 'voip_socket_port'] as $key) {
|
foreach (['voip_socket_host', 'voip_socket_port'] as $key) {
|
||||||
if (!Setting::where('key', $key)->value('value')) $missing[] = $key;
|
if (! Setting::where('key', $key)->value('value')) {
|
||||||
|
$missing[] = $key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ActivityLogger::log('voip_connection_tested', "VoIP provider {$provider} tested");
|
ActivityLogger::log('voip_connection_tested', "VoIP provider {$provider} tested");
|
||||||
if ($missing === []) {
|
if ($missing === []) {
|
||||||
$result = $this->voipManager->testConnection();
|
$result = $this->voipManager->testConnection();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => (bool) ($result['ok'] ?? false),
|
'ok' => (bool) ($result['ok'] ?? false),
|
||||||
'provider' => $provider,
|
'provider' => $provider,
|
||||||
|
|
@ -131,13 +146,34 @@ class SettingController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => !$missing,
|
'ok' => ! $missing,
|
||||||
'provider' => $provider,
|
'provider' => $provider,
|
||||||
'message' => $missing ? 'تنظیمات اتصال کامل نیست.' : 'تنظیمات اتصال معتبر است.',
|
'message' => $missing ? 'تنظیمات اتصال کامل نیست.' : 'تنظیمات اتصال معتبر است.',
|
||||||
'missing' => $missing,
|
'missing' => $missing,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testVoipCall(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('update', Setting::class);
|
||||||
|
$validated = $request->validate([
|
||||||
|
'phone' => ['required', 'string', 'max:30', 'regex:/^[0-9+*#]+$/'],
|
||||||
|
'extension' => ['required', 'string', 'max:20', 'regex:/^[0-9*#]+$/'],
|
||||||
|
]);
|
||||||
|
$result = $this->voipManager->initiateCall($validated['phone'], $validated['extension']);
|
||||||
|
ActivityLogger::log('voip_test_call_requested', 'A real VoIP test call was requested', null, null, [
|
||||||
|
'success' => (bool) ($result['success'] ?? false),
|
||||||
|
'status' => $result['status'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => (bool) ($result['success'] ?? false),
|
||||||
|
'message' => $result['message'] ?? 'نتیجه تماس آزمایشی مشخص نیست.',
|
||||||
|
'provider_call_id' => $result['provider_call_id'] ?? null,
|
||||||
|
'status' => $result['status'] ?? null,
|
||||||
|
], ($result['success'] ?? false) ? 200 : 422);
|
||||||
|
}
|
||||||
|
|
||||||
private function validateSettingValue(array $payload, ?Setting $existing): void
|
private function validateSettingValue(array $payload, ?Setting $existing): void
|
||||||
{
|
{
|
||||||
$type = $payload['type'] ?? $existing?->type ?? 'string';
|
$type = $payload['type'] ?? $existing?->type ?? 'string';
|
||||||
|
|
@ -152,7 +188,7 @@ class SettingController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($existing?->allowed_values) {
|
if ($existing?->allowed_values) {
|
||||||
$rules[] = 'in:' . implode(',', $existing->allowed_values);
|
$rules[] = 'in:'.implode(',', $existing->allowed_values);
|
||||||
}
|
}
|
||||||
|
|
||||||
Validator::make(['value' => $value], ['value' => $rules])->validate();
|
Validator::make(['value' => $value], ['value' => $rules])->validate();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Enums\TaskStatus;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\AssignTaskRequest;
|
||||||
|
use App\Http\Requests\StoreTaskRequest;
|
||||||
|
use App\Http\Requests\TaskIndexRequest;
|
||||||
|
use App\Http\Requests\UpdateTaskRequest;
|
||||||
|
use App\Http\Resources\TaskResource;
|
||||||
|
use App\Http\Responses\ApiResponse;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Services\TaskService;
|
||||||
|
use App\Support\AccessControl;
|
||||||
|
use App\Support\EntityResolver;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class TaskController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private TaskService $service) {}
|
||||||
|
|
||||||
|
public function index(TaskIndexRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('viewAny', Task::class);
|
||||||
|
$validated = $request->validated();
|
||||||
|
$query = Task::with(['assignee:id,name,avatar', 'assigner:id,name', 'creator:id,name', 'taskable', 'parent:id,subject']);
|
||||||
|
AccessControl::scopeTasks($query, $request->user());
|
||||||
|
|
||||||
|
foreach (['status', 'priority', 'assigned_to', 'created_by'] as $filter) {
|
||||||
|
if (array_key_exists($filter, $validated)) {
|
||||||
|
$query->where($filter, $validated[$filter]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! empty($validated['due_from'])) {
|
||||||
|
$query->where('due_at', '>=', $validated['due_from']);
|
||||||
|
}
|
||||||
|
if (! empty($validated['due_to'])) {
|
||||||
|
$query->where('due_at', '<=', $validated['due_to']);
|
||||||
|
}
|
||||||
|
if ($request->boolean('overdue')) {
|
||||||
|
$query->active()->whereNotNull('due_at')->where('due_at', '<', now());
|
||||||
|
}
|
||||||
|
if (! empty($validated['taskable_type'])) {
|
||||||
|
$query->where('taskable_type', EntityResolver::classFor($validated['taskable_type']));
|
||||||
|
}
|
||||||
|
if (! empty($validated['taskable_id'])) {
|
||||||
|
$query->where('taskable_id', $validated['taskable_id']);
|
||||||
|
}
|
||||||
|
if (! empty($validated['search'])) {
|
||||||
|
$search = trim($validated['search']);
|
||||||
|
$query->where(fn ($searchQuery) => $searchQuery
|
||||||
|
->where('subject', 'like', "%{$search}%")
|
||||||
|
->orWhere('description', 'like', "%{$search}%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[$sortColumn, $sortDirection] = $this->sort($validated['sort'] ?? '-created_at');
|
||||||
|
$paginator = $query->orderBy($sortColumn, $sortDirection)->paginate((int) ($validated['per_page'] ?? 20));
|
||||||
|
|
||||||
|
return ApiResponse::paginated($paginator, fn (Task $task) => $this->resource($task));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreTaskRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('create', Task::class);
|
||||||
|
$task = $this->service->create($request->validated(), $request->user());
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($task), 201, 'کار ایجاد شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Task $task): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('view', $task);
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($task->load(['assignee:id,name,avatar', 'assigner:id,name', 'creator:id,name', 'taskable', 'parent:id,subject'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateTaskRequest $request, Task $task): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('update', $task);
|
||||||
|
$updated = $this->service->update($task, $request->validated(), $request->user());
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($updated), message: 'کار ویرایش شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Task $task): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('delete', $task);
|
||||||
|
$this->service->delete($task);
|
||||||
|
|
||||||
|
return ApiResponse::success(null, message: 'کار حذف شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assign(AssignTaskRequest $request, Task $task): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('assign', [$task, (int) $request->validated('assigned_to')]);
|
||||||
|
$updated = $this->service->assign($task, (int) $request->validated('assigned_to'), (int) $request->validated('version'), $request->user());
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($updated), message: 'مسئول کار تغییر کرد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(Request $request, Task $task): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate(['version' => 'required|integer|min:1']);
|
||||||
|
$updated = $this->service->transition($task, TaskStatus::InProgress, (int) $validated['version'], $request->user());
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($updated), message: 'کار شروع شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(Request $request, Task $task): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate(['version' => 'required|integer|min:1']);
|
||||||
|
$updated = $this->service->transition($task, TaskStatus::Done, (int) $validated['version'], $request->user());
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($updated), message: 'کار تکمیل شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reopen(Request $request, Task $task): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate(['version' => 'required|integer|min:1']);
|
||||||
|
$updated = $this->service->transition($task, TaskStatus::Open, (int) $validated['version'], $request->user());
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($updated), message: 'کار بازگشایی شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Request $request, Task $task): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate(['version' => 'required|integer|min:1']);
|
||||||
|
$updated = $this->service->transition($task, TaskStatus::Cancelled, (int) $validated['version'], $request->user());
|
||||||
|
|
||||||
|
return ApiResponse::success($this->resource($updated), message: 'کار لغو شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkAssign(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'task_ids' => 'required|array|min:1|max:100',
|
||||||
|
'task_ids.*' => 'required|integer|distinct',
|
||||||
|
'assigned_to' => 'required|integer|exists:users,id',
|
||||||
|
]);
|
||||||
|
$tasks = $this->service->bulkAssign($validated['task_ids'], (int) $validated['assigned_to'], $request->user());
|
||||||
|
|
||||||
|
return ApiResponse::success(array_map(fn (Task $task) => $this->resource($task), $tasks), message: 'کارها تخصیص داده شدند.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkComplete(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'task_ids' => 'required|array|min:1|max:100',
|
||||||
|
'task_ids.*' => 'required|integer|distinct',
|
||||||
|
]);
|
||||||
|
$tasks = $this->service->bulkComplete($validated['task_ids'], $request->user());
|
||||||
|
|
||||||
|
return ApiResponse::success(array_map(fn (Task $task) => $this->resource($task), $tasks), message: 'کارها تکمیل شدند.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(Task $task): array
|
||||||
|
{
|
||||||
|
return (new TaskResource($task))->resolve(request());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sort(string $sort): array
|
||||||
|
{
|
||||||
|
return str_starts_with($sort, '-') ? [substr($sort, 1), 'desc'] : [$sort, 'asc'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,13 +4,13 @@ namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\ActivityLog;
|
use App\Models\ActivityLog;
|
||||||
use App\Models\Call;
|
|
||||||
use App\Models\CallLog;
|
use App\Models\CallLog;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\Deal;
|
use App\Models\Deal;
|
||||||
use App\Models\FollowUp;
|
use App\Models\FollowUp;
|
||||||
use App\Models\Lead;
|
use App\Models\Lead;
|
||||||
use App\Models\Note;
|
use App\Models\Note;
|
||||||
|
use App\Support\EntityResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
|
@ -23,21 +23,23 @@ class TimelineController extends Controller
|
||||||
'entity_id' => 'required|integer',
|
'entity_id' => 'required|integer',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
EntityResolver::authorize($validated['entity_type'], $validated['entity_id']);
|
||||||
|
|
||||||
[$class, $id] = [$this->classFor($validated['entity_type']), $validated['entity_id']];
|
[$class, $id] = [$this->classFor($validated['entity_type']), $validated['entity_id']];
|
||||||
$items = collect();
|
$items = collect();
|
||||||
|
|
||||||
ActivityLog::with('user:id,name')->where('subject_type', $class)->where('subject_id', $id)->latest()->limit(100)->get()
|
ActivityLog::with('user:id,name')->where('subject_type', $class)->where('subject_id', $id)->latest()->limit(100)->get()
|
||||||
->each(fn($log) => $items->push($this->item($log->created_at, $this->label($log->action), $log->description, $log->user?->name, $log->action)));
|
->each(fn ($log) => $items->push($this->item($log->created_at, $this->label($log->action), $log->description, $log->user?->name, $log->action)));
|
||||||
|
|
||||||
Note::with('user:id,name')->where('notable_type', $class)->where('notable_id', $id)->latest()->limit(100)->get()
|
Note::with('user:id,name')->where('notable_type', $class)->where('notable_id', $id)->latest()->limit(100)->get()
|
||||||
->each(fn($note) => $items->push($this->item($note->created_at, 'یادداشت ثبت شد', $note->content, $note->user?->name, 'note_created')));
|
->each(fn ($note) => $items->push($this->item($note->created_at, 'یادداشت ثبت شد', $note->content, $note->user?->name, 'note_created')));
|
||||||
|
|
||||||
if ($class === Lead::class) {
|
if ($class === Lead::class) {
|
||||||
CallLog::with('user:id,name')->where('lead_id', $id)->latest('called_at')->limit(100)->get()
|
CallLog::with('user:id,name')->where('lead_id', $id)->latest('called_at')->limit(100)->get()
|
||||||
->each(fn($call) => $items->push($this->item($call->called_at, 'تماس ثبت شد', trim(($call->result ? "نتیجه: {$call->result}. " : '') . ($call->notes ?? '')), $call->user?->name, 'call')));
|
->each(fn ($call) => $items->push($this->item($call->called_at, 'تماس ثبت شد', trim(($call->result ? "نتیجه: {$call->result}. " : '').($call->notes ?? '')), $call->user?->name, 'call')));
|
||||||
|
|
||||||
FollowUp::with('user:id,name')->where('lead_id', $id)->latest('scheduled_at')->limit(100)->get()
|
FollowUp::with('user:id,name')->where('lead_id', $id)->latest('scheduled_at')->limit(100)->get()
|
||||||
->each(fn($followUp) => $items->push($this->item($followUp->created_at, 'پیگیری ایجاد شد', 'زمان پیگیری: ' . optional($followUp->scheduled_at)->toDateTimeString(), $followUp->user?->name, 'follow_up_created')));
|
->each(fn ($followUp) => $items->push($this->item($followUp->created_at, 'پیگیری ایجاد شد', 'زمان پیگیری: '.optional($followUp->scheduled_at)->toDateTimeString(), $followUp->user?->name, 'follow_up_created')));
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,80 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Responses\ApiResponse;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Team;
|
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
|
use App\Support\AccessControl;
|
||||||
|
use App\Support\EntityResolver;
|
||||||
use App\Support\PasswordPolicy;
|
use App\Support\PasswordPolicy;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
|
public function referralTargets(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$actor = $request->user();
|
||||||
|
$query = User::query()->where('is_active', true)->whereKeyNot($actor->id)
|
||||||
|
->whereHas('roles', fn ($roles) => $roles->whereIn('name', ['agent', 'supervisor']))
|
||||||
|
->with('roles:id,name', 'teams:id,name');
|
||||||
|
|
||||||
|
if (! $actor->hasRole('admin')) {
|
||||||
|
$teamIds = AccessControl::teamIds($actor);
|
||||||
|
$query->whereHas('teams', fn ($teams) => $teams->whereIn('teams.id', $teamIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->orderBy('name')->get()->map(fn (User $user) => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'role' => $user->roles->first()?->name,
|
||||||
|
'team' => $user->teams->first()?->name,
|
||||||
|
])->values());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignable(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'context' => 'required|in:task',
|
||||||
|
'entity_type' => 'nullable|required_with:entity_id|in:lead,contact,company,deal,call,campaign',
|
||||||
|
'entity_id' => 'nullable|required_with:entity_type|integer|min:1',
|
||||||
|
'search' => 'nullable|string|max:120',
|
||||||
|
]);
|
||||||
|
$actor = $request->user();
|
||||||
|
abort_unless($actor->can('create_tasks') || $actor->can('assign_tasks'), 403);
|
||||||
|
|
||||||
|
if (! empty($validated['entity_type'])) {
|
||||||
|
EntityResolver::authorize($validated['entity_type'], (int) $validated['entity_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = User::query()->where('is_active', true)->with(['roles:id,name', 'teams:id,name'])->withCount([
|
||||||
|
'assignedTasks as open_tasks_count' => fn ($tasks) => $tasks->active(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($actor->hasRole('supervisor')) {
|
||||||
|
$query->whereIn('id', array_values(array_unique(array_merge([$actor->id], AccessControl::teamMemberIds($actor)))));
|
||||||
|
} elseif (! $actor->hasRole('admin')) {
|
||||||
|
$query->whereKey($actor->id);
|
||||||
|
}
|
||||||
|
if (! empty($validated['search'])) {
|
||||||
|
$query->where('name', 'like', '%'.trim($validated['search']).'%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = $query->orderBy('name')->limit(20)->get()->map(fn (User $user) => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'avatar' => $user->avatar_url,
|
||||||
|
'role_label' => $user->roles->first()?->name,
|
||||||
|
'team_label' => $user->teams->first()?->name,
|
||||||
|
'is_active' => $user->is_active,
|
||||||
|
'open_tasks_count' => $user->open_tasks_count,
|
||||||
|
])->values()->all();
|
||||||
|
|
||||||
|
return ApiResponse::success($users);
|
||||||
|
}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('viewAny', User::class);
|
Gate::authorize('viewAny', User::class);
|
||||||
|
|
@ -34,7 +97,7 @@ class UserController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->team_id) {
|
if ($request->team_id) {
|
||||||
$query->whereHas('teams', fn($q) => $q->where('teams.id', $request->team_id));
|
$query->whereHas('teams', fn ($q) => $q->where('teams.id', $request->team_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->has('is_active')) {
|
if ($request->has('is_active')) {
|
||||||
|
|
@ -54,6 +117,17 @@ class UserController extends Controller
|
||||||
return response()->json([$user->load('roles', 'teams')]);
|
return response()->json([$user->load('roles', 'teams')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($user?->hasRole('supervisor')) {
|
||||||
|
return response()->json(
|
||||||
|
User::role('agent')
|
||||||
|
->whereIn('id', AccessControl::teamMemberIds($user))
|
||||||
|
->where('is_active', true)
|
||||||
|
->with('roles', 'teams')
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(
|
return response()->json(
|
||||||
User::role('agent')
|
User::role('agent')
|
||||||
->where('is_active', true)
|
->where('is_active', true)
|
||||||
|
|
@ -104,10 +178,10 @@ class UserController extends Controller
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'sometimes|string|max:255',
|
'name' => 'sometimes|string|max:255',
|
||||||
'email' => 'sometimes|email|unique:users,email,' . $user->id,
|
'email' => 'sometimes|email|unique:users,email,'.$user->id,
|
||||||
'password' => ['sometimes', ...PasswordPolicy::rules()],
|
'password' => ['sometimes', ...PasswordPolicy::rules()],
|
||||||
'phone' => 'nullable|string|max:20',
|
'phone' => 'nullable|string|max:20',
|
||||||
'voip_extension' => ['nullable', 'string', 'max:20', 'regex:/^[0-9*#]+$/', 'unique:users,voip_extension,' . $user->id],
|
'voip_extension' => ['nullable', 'string', 'max:20', 'regex:/^[0-9*#]+$/', 'unique:users,voip_extension,'.$user->id],
|
||||||
'is_active' => 'sometimes|boolean',
|
'is_active' => 'sometimes|boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -141,7 +215,7 @@ class UserController extends Controller
|
||||||
{
|
{
|
||||||
Gate::authorize('update', $user);
|
Gate::authorize('update', $user);
|
||||||
|
|
||||||
$user->update(['is_active' => !$user->is_active]);
|
$user->update(['is_active' => ! $user->is_active]);
|
||||||
|
|
||||||
ActivityLogger::log('user_toggled', "User {$user->email} active: {$user->is_active}");
|
ActivityLogger::log('user_toggled', "User {$user->email} active: {$user->is_active}");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\CallLog;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\ActivityLogger;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class VoipWebhookController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$secret = (string) Setting::where('key', 'voip_webhook_secret')->value('value');
|
||||||
|
abort_if($secret === '', 503, 'Webhook تلفن پیکربندی نشده است.');
|
||||||
|
$signature = (string) $request->header('X-VoIP-Signature');
|
||||||
|
$expected = hash_hmac('sha256', $request->getContent(), $secret);
|
||||||
|
abort_unless($signature !== '' && hash_equals($expected, $signature), 401, 'امضای Webhook معتبر نیست.');
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'provider_call_id' => 'required|string|max:255',
|
||||||
|
'status' => 'required|in:initiated,ringing,answered,completed,failed,busy,no_answer,cancelled',
|
||||||
|
'duration_seconds' => 'nullable|integer|min:0|max:86400',
|
||||||
|
'recording_url' => 'nullable|url|max:2048',
|
||||||
|
'result' => 'nullable|string|max:50',
|
||||||
|
'started_at' => 'nullable|date',
|
||||||
|
'ended_at' => 'nullable|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$call = Call::where('provider_call_id', $validated['provider_call_id'])->firstOrFail();
|
||||||
|
$terminal = in_array($validated['status'], ['completed', 'failed', 'busy', 'no_answer', 'cancelled'], true);
|
||||||
|
$call->update([
|
||||||
|
'provider_status' => $validated['status'],
|
||||||
|
'duration' => $validated['duration_seconds'] ?? $call->duration,
|
||||||
|
'recording_url' => $validated['recording_url'] ?? $call->recording_url,
|
||||||
|
'result' => $validated['result'] ?? $call->result,
|
||||||
|
'started_at' => $validated['started_at'] ?? $call->started_at,
|
||||||
|
'ended_at' => $validated['ended_at'] ?? ($terminal ? now() : $call->ended_at),
|
||||||
|
'provider_payload' => $request->all(),
|
||||||
|
]);
|
||||||
|
CallLog::where('call_id', $call->id)->update([
|
||||||
|
'recording_url' => $call->recording_url,
|
||||||
|
'result' => $call->result,
|
||||||
|
]);
|
||||||
|
ActivityLogger::log('voip_webhook_received', "VoIP status {$validated['status']} received for call {$call->id}", $call, null, ['provider_call_id' => $call->provider_call_id]);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'call_id' => $call->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Models\DashboardPreference;
|
||||||
|
use App\Models\Lead;
|
||||||
|
use App\Models\NotificationPreference;
|
||||||
|
use App\Models\SavedView;
|
||||||
|
use App\Support\AccessControl;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class WorkspaceController extends Controller
|
||||||
|
{
|
||||||
|
public function search(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('use_global_search'), 403);
|
||||||
|
$data = $request->validate(['q' => 'required|string|min:2|max:100']);
|
||||||
|
$q = $data['q'];
|
||||||
|
$user = $request->user();
|
||||||
|
$leads = Lead::query();
|
||||||
|
AccessControl::scopeLeads($leads, $user);
|
||||||
|
$companies = Company::query();
|
||||||
|
AccessControl::scopeCompanies($companies, $user);
|
||||||
|
$contacts = Contact::query();
|
||||||
|
AccessControl::scopeContacts($contacts, $user);
|
||||||
|
$calls = Call::with('lead:id,first_name,last_name,company');
|
||||||
|
AccessControl::scopeCalls($calls, $user);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'leads' => $leads->where(fn (Builder $x) => $x->where('first_name', 'like', "%{$q}%")->orWhere('last_name', 'like', "%{$q}%")->orWhere('company', 'like', "%{$q}%")->orWhere('phone', 'like', "%{$q}%"))->limit(5)->get(['id', 'first_name', 'last_name', 'company', 'lead_score', 'score_level']),
|
||||||
|
'companies' => $companies->where('name', 'like', "%{$q}%")->limit(5)->get(['id', 'name', 'industry']),
|
||||||
|
'contacts' => $contacts->where('name', 'like', "%{$q}%")->limit(5)->get(['id', 'name', 'email']),
|
||||||
|
'calls' => $calls->where('notes', 'like', "%{$q}%")->limit(5)->latest()->get(['id', 'lead_id', 'result', 'created_at']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function savedViews(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('manage_saved_views'), 403);
|
||||||
|
$entity = $request->validate(['entity_type' => 'nullable|string|in:lead,deal,company,contact,call,task'])['entity_type'] ?? null;
|
||||||
|
$teamIds = AccessControl::teamIds($request->user());
|
||||||
|
$query = SavedView::where(fn (Builder $q) => $q->where('user_id', $request->user()->id)->orWhere('visibility', 'public')->orWhere(fn (Builder $team) => $team->where('visibility', 'team')->whereIn('team_id', $teamIds)));
|
||||||
|
if ($entity) {
|
||||||
|
$query->where('entity_type', $entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->orderByDesc('is_default')->latest()->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeSavedView(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('manage_saved_views'), 403);
|
||||||
|
$data = $request->validate([
|
||||||
|
'entity_type' => 'required|in:lead,deal,company,contact,call,task', 'name' => 'required|string|max:100',
|
||||||
|
'visibility' => 'required|in:private,team,public', 'team_id' => 'nullable|exists:teams,id',
|
||||||
|
'filters' => 'required|array|max:30', 'columns' => 'nullable|array|max:30', 'sort' => 'nullable|array|max:5', 'is_default' => 'boolean',
|
||||||
|
]);
|
||||||
|
if ($data['visibility'] !== 'private') {
|
||||||
|
abort_unless($request->user()->can('share_team_views'), 403);
|
||||||
|
}
|
||||||
|
if (($data['visibility'] ?? '') === 'team') {
|
||||||
|
abort_unless(in_array((int) ($data['team_id'] ?? 0), AccessControl::teamIds($request->user()), true), 403);
|
||||||
|
}
|
||||||
|
if ($data['is_default'] ?? false) {
|
||||||
|
SavedView::where('user_id', $request->user()->id)->where('entity_type', $data['entity_type'])->update(['is_default' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(SavedView::create($data + ['user_id' => $request->user()->id]), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteSavedView(Request $request, SavedView $savedView): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($savedView->user_id === $request->user()->id || $request->user()->hasRole('admin'), 403);
|
||||||
|
$savedView->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'نمای ذخیرهشده حذف شد.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preferences(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'dashboard' => DashboardPreference::firstOrCreate(['user_id' => $request->user()->id]),
|
||||||
|
'notifications' => NotificationPreference::where('user_id', $request->user()->id)->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePreferences(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'widget_order' => 'nullable|array|max:30', 'hidden_widgets' => 'nullable|array|max:30', 'default_filters' => 'nullable|array|max:20',
|
||||||
|
'notifications' => 'nullable|array|max:30', 'notifications.*.notification_type' => 'required|string|max:60',
|
||||||
|
'notifications.*.in_app_enabled' => 'boolean', 'notifications.*.is_muted' => 'boolean',
|
||||||
|
]);
|
||||||
|
$dashboard = DashboardPreference::updateOrCreate(['user_id' => $request->user()->id], collect($data)->only(['widget_order', 'hidden_widgets', 'default_filters'])->all());
|
||||||
|
foreach ($data['notifications'] ?? [] as $preference) {
|
||||||
|
NotificationPreference::updateOrCreate(['user_id' => $request->user()->id, 'notification_type' => $preference['notification_type']], $preference);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['dashboard' => $dashboard, 'notifications' => NotificationPreference::where('user_id', $request->user()->id)->get()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\ActivityLogger;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use App\Services\ActivityLogger;
|
|
||||||
|
|
||||||
class LogActivity
|
class LogActivity
|
||||||
{
|
{
|
||||||
|
|
@ -14,7 +14,7 @@ class LogActivity
|
||||||
$response = $next($request);
|
$response = $next($request);
|
||||||
|
|
||||||
if ($request->method() !== 'GET' && auth()->check()) {
|
if ($request->method() !== 'GET' && auth()->check()) {
|
||||||
$action = $request->method() . ' ' . $request->path();
|
$action = $request->method().' '.$request->path();
|
||||||
$description = null;
|
$description = null;
|
||||||
|
|
||||||
if (str_contains($request->path(), 'login')) {
|
if (str_contains($request->path(), 'login')) {
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,14 @@ class MaskPhoneNumber
|
||||||
{
|
{
|
||||||
$response = $next($request);
|
$response = $next($request);
|
||||||
|
|
||||||
if (!$response instanceof JsonResponse) {
|
if (! $response instanceof JsonResponse) {
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$maskPhones = Setting::where('key', 'phone_mask_enabled')->value('value') !== 'false'
|
$maskPhones = Setting::where('key', 'phone_mask_enabled')->value('value') !== 'false'
|
||||||
&& (!$user || !$user->can('view_full_phone'));
|
&& (! $user || ! $user->can('view_full_phone'));
|
||||||
$maskRecordings = !$user || (!$user->hasRole('admin') && !$user->can('listen_recordings'));
|
$maskRecordings = ! $user || (! $user->hasRole('admin') && ! $user->can('listen_recordings'));
|
||||||
|
|
||||||
if ($maskPhones || $maskRecordings) {
|
if ($maskPhones || $maskRecordings) {
|
||||||
$data = $response->getData(true);
|
$data = $response->getData(true);
|
||||||
|
|
@ -52,7 +52,10 @@ class MaskPhoneNumber
|
||||||
if (Setting::where('key', 'phone_mask_level')->value('value') === 'full') {
|
if (Setting::where('key', 'phone_mask_level')->value('value') === 'full') {
|
||||||
return '********';
|
return '********';
|
||||||
}
|
}
|
||||||
if (strlen($phone) < 7) return $phone;
|
if (strlen($phone) < 7) {
|
||||||
return substr($phone, 0, 4) . ' *** **' . substr($phone, -2);
|
return $phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($phone, 0, 4).' *** **'.substr($phone, -2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,12 @@ class SecurityHeaders
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($headers as $name => $value) {
|
foreach ($headers as $name => $value) {
|
||||||
if (!$response->headers->has($name)) {
|
if (! $response->headers->has($name)) {
|
||||||
$response->headers->set($name, $value);
|
$response->headers->set($name, $value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->is('api/*') || $request->is('sanctum/*')) {
|
if (($request->is('api/*') || $request->is('sanctum/*')) && ! $request->is('api/media/avatars/*')) {
|
||||||
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
||||||
$response->headers->set('Pragma', 'no-cache');
|
$response->headers->set('Pragma', 'no-cache');
|
||||||
$response->headers->set('Expires', '0');
|
$response->headers->set('Expires', '0');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class AssignTaskRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'assigned_to' => 'required|integer|exists:users,id',
|
||||||
|
'version' => 'required|integer|min:1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreCallNoteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'content' => 'required|string|max:5000',
|
||||||
|
'type' => 'nullable|string|in:general,call_summary,objection,commitment,internal',
|
||||||
|
'visibility' => 'nullable|string|in:private,team,organization',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\TaskPriority;
|
||||||
|
use App\Enums\TaskVisibility;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class StoreTaskRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'subject' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string|max:5000',
|
||||||
|
'taskable_type' => 'nullable|required_with:taskable_id|in:lead,contact,company,deal,call,campaign',
|
||||||
|
'taskable_id' => 'nullable|required_with:taskable_type|integer|min:1',
|
||||||
|
'assigned_to' => 'nullable|integer|exists:users,id',
|
||||||
|
'priority' => ['nullable', Rule::enum(TaskPriority::class)],
|
||||||
|
'due_at' => 'nullable|date',
|
||||||
|
'reminder_at' => 'nullable|date',
|
||||||
|
'parent_task_id' => 'nullable|integer|exists:tasks,id',
|
||||||
|
'estimated_minutes' => 'nullable|integer|min:1|max:100000',
|
||||||
|
'visibility' => ['nullable', Rule::enum(TaskVisibility::class)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [function (Validator $validator): void {
|
||||||
|
if ($this->filled('reminder_at') && $this->filled('due_at') && $this->date('reminder_at')->gt($this->date('due_at'))) {
|
||||||
|
$validator->errors()->add('reminder_at', 'زمان یادآوری نمیتواند بعد از موعد کار باشد.');
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\TaskPriority;
|
||||||
|
use App\Enums\TaskStatus;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class TaskIndexRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => ['nullable', Rule::enum(TaskStatus::class)],
|
||||||
|
'priority' => ['nullable', Rule::enum(TaskPriority::class)],
|
||||||
|
'assigned_to' => 'nullable|integer|exists:users,id',
|
||||||
|
'created_by' => 'nullable|integer|exists:users,id',
|
||||||
|
'due_from' => 'nullable|date',
|
||||||
|
'due_to' => 'nullable|date|after_or_equal:due_from',
|
||||||
|
'overdue' => 'nullable|boolean',
|
||||||
|
'taskable_type' => 'nullable|string|in:lead,contact,company,deal,call,campaign',
|
||||||
|
'taskable_id' => 'nullable|integer|min:1',
|
||||||
|
'search' => 'nullable|string|max:120',
|
||||||
|
'sort' => 'nullable|string|in:created_at,-created_at,due_at,-due_at,priority,-priority',
|
||||||
|
'page' => 'nullable|integer|min:1',
|
||||||
|
'per_page' => 'nullable|integer|min:1|max:100',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateNoteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'content' => 'sometimes|required|string|max:5000',
|
||||||
|
'type' => 'sometimes|string|in:general,call_summary,objection,commitment,internal',
|
||||||
|
'visibility' => 'sometimes|string|in:private,team,organization',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\TaskPriority;
|
||||||
|
use App\Enums\TaskVisibility;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class UpdateTaskRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'subject' => 'sometimes|required|string|max:255',
|
||||||
|
'description' => 'sometimes|nullable|string|max:5000',
|
||||||
|
'priority' => ['sometimes', Rule::enum(TaskPriority::class)],
|
||||||
|
'due_at' => 'sometimes|nullable|date',
|
||||||
|
'reminder_at' => 'sometimes|nullable|date',
|
||||||
|
'estimated_minutes' => 'sometimes|nullable|integer|min:1|max:100000',
|
||||||
|
'visibility' => ['sometimes', Rule::enum(TaskVisibility::class)],
|
||||||
|
'version' => 'required|integer|min:1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [function (Validator $validator): void {
|
||||||
|
$dueAt = $this->input('due_at', $this->route('task')?->due_at);
|
||||||
|
$reminderAt = $this->input('reminder_at', $this->route('task')?->reminder_at);
|
||||||
|
if ($dueAt && $reminderAt && Carbon::parse($reminderAt)->gt(Carbon::parse($dueAt))) {
|
||||||
|
$validator->errors()->add('reminder_at', 'زمان یادآوری نمیتواند بعد از موعد کار باشد.');
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class CallResource extends JsonResource
|
||||||
|
{
|
||||||
|
public static $wrap = null;
|
||||||
|
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'lead_id' => $this->lead_id,
|
||||||
|
'lead' => $this->whenLoaded('lead', fn () => $this->lead?->only(['id', 'first_name', 'last_name', 'company', 'phone'])),
|
||||||
|
'contact_id' => $this->contact_id,
|
||||||
|
'contact' => $this->whenLoaded('contact', fn () => $this->contact?->only(['id', 'name', 'role'])),
|
||||||
|
'contact_phone_id' => $this->contact_phone_id,
|
||||||
|
'contact_phone' => $this->whenLoaded('contactPhone', fn () => $this->contactPhone?->only(['id', 'phone', 'type', 'status'])),
|
||||||
|
'user_id' => $this->user_id,
|
||||||
|
'agent' => $this->whenLoaded('user', fn () => $this->user?->only(['id', 'name'])),
|
||||||
|
'direction' => $this->direction,
|
||||||
|
'status' => $this->provider_status === 'completed' || $this->result ? 'completed' : 'pending',
|
||||||
|
'provider_status' => $this->provider_status,
|
||||||
|
'phone' => $this->phone,
|
||||||
|
'duration_seconds' => (int) $this->duration,
|
||||||
|
'result' => $this->result,
|
||||||
|
'notes' => $this->notes,
|
||||||
|
'provider_call_id' => $this->provider_call_id,
|
||||||
|
'recording_url' => $this->recording_url,
|
||||||
|
'is_manual' => (bool) $this->is_manual,
|
||||||
|
'started_at' => optional($this->started_at ?? $this->created_at)->toISOString(),
|
||||||
|
'ended_at' => optional($this->ended_at)->toISOString(),
|
||||||
|
'created_at' => optional($this->created_at)->toISOString(),
|
||||||
|
'updated_at' => optional($this->updated_at)->toISOString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class CampaignResource extends JsonResource
|
||||||
|
{
|
||||||
|
public static $wrap = null;
|
||||||
|
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description,
|
||||||
|
'product_service' => $this->product_service,
|
||||||
|
'product_id' => $this->product_id,
|
||||||
|
'product' => $this->whenLoaded('product', fn () => $this->product?->only(['id', 'name', 'base_price'])),
|
||||||
|
'channel' => $this->channel,
|
||||||
|
'start_date' => optional($this->start_date)->toDateString(),
|
||||||
|
'end_date' => optional($this->end_date)->toDateString(),
|
||||||
|
'target' => $this->target,
|
||||||
|
'budget' => $this->budget !== null ? (float) $this->budget : null,
|
||||||
|
'actual_cost' => $this->actual_cost !== null ? (float) $this->actual_cost : null,
|
||||||
|
'status' => $this->status,
|
||||||
|
'sales_script_id' => $this->sales_script_id,
|
||||||
|
'sales_script' => $this->whenLoaded('salesScript', fn () => $this->salesScript?->only(['id', 'title', 'version', 'is_active'])),
|
||||||
|
'assigned_agents' => $this->whenLoaded('assignedAgents', fn () => $this->assignedAgents->map->only(['id', 'name'])->values()),
|
||||||
|
'assigned_supervisors' => $this->whenLoaded('assignedSupervisors', fn () => $this->assignedSupervisors->map->only(['id', 'name'])->values()),
|
||||||
|
'leads' => $this->whenLoaded('leads'),
|
||||||
|
'leads_count' => $this->whenCounted('leads'),
|
||||||
|
'contacted_leads_count' => $this->when(isset($this->contacted_leads_count), (int) $this->contacted_leads_count),
|
||||||
|
'won_leads_count' => $this->when(isset($this->won_leads_count), (int) $this->won_leads_count),
|
||||||
|
'won_value' => $this->when(isset($this->won_value_sum), (float) ($this->won_value_sum ?? 0)),
|
||||||
|
'conversion_rate' => $this->when(isset($this->won_leads_count), $this->leads_count > 0 ? round(($this->won_leads_count / $this->leads_count) * 100, 1) : 0),
|
||||||
|
'target_progress' => $this->when(isset($this->won_leads_count), $this->target > 0 ? min(100, round(($this->won_leads_count / $this->target) * 100, 1)) : 0),
|
||||||
|
'cost_per_lead' => $this->when(isset($this->leads_count), $this->leads_count > 0 ? round(((float) ($this->actual_cost ?? 0)) / $this->leads_count, 2) : 0),
|
||||||
|
'roi' => $this->when(isset($this->won_value_sum), (float) ($this->actual_cost ?? 0) > 0 ? round((((float) ($this->won_value_sum ?? 0) - (float) $this->actual_cost) / (float) $this->actual_cost) * 100, 1) : null),
|
||||||
|
'created_at' => optional($this->created_at)->toISOString(),
|
||||||
|
'updated_at' => optional($this->updated_at)->toISOString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class FollowUpResource extends JsonResource
|
||||||
|
{
|
||||||
|
public static $wrap = null;
|
||||||
|
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$gate = Gate::forUser($request->user());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'lead_id' => $this->lead_id,
|
||||||
|
'lead' => $this->whenLoaded('lead', fn () => $this->lead?->only(['id', 'first_name', 'last_name', 'company', 'phone'])),
|
||||||
|
'user_id' => $this->user_id,
|
||||||
|
'assignee' => $this->whenLoaded('user', fn () => $this->user?->only(['id', 'name'])),
|
||||||
|
'created_by' => $this->created_by,
|
||||||
|
'creator' => $this->whenLoaded('creator', fn () => $this->creator?->only(['id', 'name'])),
|
||||||
|
'call_id' => $this->call_id,
|
||||||
|
'source' => $this->source,
|
||||||
|
'scheduled_at' => optional($this->scheduled_at)->toISOString(),
|
||||||
|
'completed_at' => optional($this->completed_at)->toISOString(),
|
||||||
|
'notes' => $this->notes,
|
||||||
|
'status' => $this->status,
|
||||||
|
'is_overdue' => (bool) $this->is_overdue || ($this->status === 'pending' && $this->scheduled_at?->isPast()),
|
||||||
|
'capabilities' => [
|
||||||
|
'update' => $gate->allows('update', $this->resource),
|
||||||
|
'complete' => $gate->allows('complete', $this->resource),
|
||||||
|
'delete' => $gate->allows('delete', $this->resource),
|
||||||
|
],
|
||||||
|
'created_at' => optional($this->created_at)->toISOString(),
|
||||||
|
'updated_at' => optional($this->updated_at)->toISOString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class NoteResource extends JsonResource
|
||||||
|
{
|
||||||
|
public static $wrap = null;
|
||||||
|
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$gate = Gate::forUser($request->user());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'content' => $this->content,
|
||||||
|
'type' => $this->type,
|
||||||
|
'visibility' => $this->visibility,
|
||||||
|
'is_pinned' => (bool) $this->is_pinned,
|
||||||
|
'author' => $this->whenLoaded('user', fn () => $this->user?->only(['id', 'name'])),
|
||||||
|
'edited_at' => $this->edited_at?->toISOString(),
|
||||||
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
|
'updated_at' => $this->updated_at?->toISOString(),
|
||||||
|
'capabilities' => [
|
||||||
|
'edit' => $gate->allows('update', $this->resource),
|
||||||
|
'delete' => $gate->allows('delete', $this->resource),
|
||||||
|
'pin' => $gate->allows('pin', $this->resource),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class NotificationResource extends JsonResource
|
||||||
|
{
|
||||||
|
public static $wrap = null;
|
||||||
|
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'title' => $this->title,
|
||||||
|
'message' => $this->message,
|
||||||
|
'type' => $this->type,
|
||||||
|
'data' => $this->data ?? [],
|
||||||
|
'is_read' => (bool) $this->is_read,
|
||||||
|
'read_at' => $this->read_at?->toISOString(),
|
||||||
|
'archived_at' => $this->archived_at?->toISOString(),
|
||||||
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Support\EntityResolver;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Http\Resources\MissingValue;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class TaskResource extends JsonResource
|
||||||
|
{
|
||||||
|
public static $wrap = null;
|
||||||
|
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$entity = $this->whenLoaded('taskable');
|
||||||
|
$taskable = $entity && ! $entity instanceof MissingValue
|
||||||
|
? [
|
||||||
|
'type' => EntityResolver::typeOf($entity),
|
||||||
|
'id' => $entity->getKey(),
|
||||||
|
'label' => $this->entityLabel($entity),
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$gate = Gate::forUser($request->user());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'subject' => $this->subject,
|
||||||
|
'description' => $this->description,
|
||||||
|
'taskable_type' => $taskable['type'] ?? null,
|
||||||
|
'taskable_id' => $taskable['id'] ?? $this->taskable_id,
|
||||||
|
'taskable' => $taskable,
|
||||||
|
'assigned_to' => $this->assigned_to,
|
||||||
|
'assignee' => $this->whenLoaded('assignee', fn () => $this->assignee?->only(['id', 'name', 'avatar', 'avatar_url'])),
|
||||||
|
'assigned_by' => $this->assigned_by,
|
||||||
|
'assigner' => $this->whenLoaded('assigner', fn () => $this->assigner?->only(['id', 'name'])),
|
||||||
|
'created_by' => $this->created_by,
|
||||||
|
'creator' => $this->whenLoaded('creator', fn () => $this->creator?->only(['id', 'name'])),
|
||||||
|
'priority' => $this->priority->value,
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'due_at' => $this->due_at?->toISOString(),
|
||||||
|
'started_at' => $this->started_at?->toISOString(),
|
||||||
|
'completed_at' => $this->completed_at?->toISOString(),
|
||||||
|
'reminder_at' => $this->reminder_at?->toISOString(),
|
||||||
|
'parent_task_id' => $this->parent_task_id,
|
||||||
|
'parent' => $this->whenLoaded('parent', fn () => $this->parent?->only(['id', 'subject'])),
|
||||||
|
'estimated_minutes' => $this->estimated_minutes,
|
||||||
|
'visibility' => $this->visibility->value,
|
||||||
|
'version' => $this->version,
|
||||||
|
'is_overdue' => $this->is_overdue,
|
||||||
|
'capabilities' => [
|
||||||
|
'update' => $gate->allows('update', $this->resource),
|
||||||
|
'assign' => $gate->allows('assign', [$this->resource, $this->assigned_to]),
|
||||||
|
'transition' => $gate->allows('transition', $this->resource),
|
||||||
|
'delete' => $gate->allows('delete', $this->resource),
|
||||||
|
],
|
||||||
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
|
'updated_at' => $this->updated_at?->toISOString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entityLabel(object $entity): string
|
||||||
|
{
|
||||||
|
return (string) ($entity->name
|
||||||
|
?? $entity->title
|
||||||
|
?? $entity->company
|
||||||
|
?? trim(($entity->first_name ?? '').' '.($entity->last_name ?? ''))
|
||||||
|
?: class_basename($entity).' #'.$entity->getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Responses;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class ApiResponse
|
||||||
|
{
|
||||||
|
public static function success(mixed $data, int $status = 200, ?string $message = null, array $meta = [], array $links = []): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'data' => $data,
|
||||||
|
'meta' => (object) $meta,
|
||||||
|
'links' => (object) $links,
|
||||||
|
'message' => $message,
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function paginated(LengthAwarePaginator $paginator, callable $transformer): JsonResponse
|
||||||
|
{
|
||||||
|
return self::success(
|
||||||
|
collect($paginator->items())->map($transformer)->values()->all(),
|
||||||
|
meta: [
|
||||||
|
'current_page' => $paginator->currentPage(),
|
||||||
|
'last_page' => $paginator->lastPage(),
|
||||||
|
'per_page' => $paginator->perPage(),
|
||||||
|
'total' => $paginator->total(),
|
||||||
|
'from' => $paginator->firstItem(),
|
||||||
|
'to' => $paginator->lastItem(),
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
'first' => $paginator->url(1),
|
||||||
|
'last' => $paginator->url($paginator->lastPage()),
|
||||||
|
'prev' => $paginator->previousPageUrl(),
|
||||||
|
'next' => $paginator->nextPageUrl(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function error(string $message, string $code, int $status, array $errors = [], ?string $traceId = null): JsonResponse
|
||||||
|
{
|
||||||
|
$traceId ??= (string) Str::uuid();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $message,
|
||||||
|
'code' => $code,
|
||||||
|
'errors' => (object) $errors,
|
||||||
|
'trace_id' => $traceId,
|
||||||
|
], $status)->header('X-Trace-ID', $traceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\TaskReminderService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class SendTaskReminders implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function handle(TaskReminderService $service): void
|
||||||
|
{
|
||||||
|
$service->sendDueNotifications();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,8 +10,14 @@ class ActivityLog extends Model
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id', 'action', 'description',
|
'user_id', 'action', 'description',
|
||||||
'subject_type', 'subject_id', 'ip_address', 'user_agent',
|
'subject_type', 'subject_id', 'ip_address', 'user_agent',
|
||||||
|
'before_data', 'after_data', 'request_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['before_data' => 'array', 'after_data' => 'array'];
|
||||||
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class AutomationRule extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = ['name', 'trigger', 'conditions', 'actions', 'team_id', 'is_active', 'max_runs_per_record', 'created_by'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['conditions' => 'array', 'actions' => 'array', 'is_active' => 'boolean', 'max_runs_per_record' => 'integer'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function team(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Team::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function creator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AutomationRun::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
class AutomationRun extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['automation_rule_id', 'subject_type', 'subject_id', 'status', 'attempt', 'event_key', 'input', 'output', 'error', 'started_at', 'completed_at'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['input' => 'array', 'output' => 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rule(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AutomationRule::class, 'automation_rule_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subject(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,17 +5,25 @@ namespace App\Models;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
|
||||||
class Call extends Model
|
class Call extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'lead_id', 'contact_id', 'contact_phone_id', 'user_id', 'direction', 'phone', 'duration',
|
'lead_id', 'contact_id', 'contact_phone_id', 'user_id', 'direction', 'phone', 'duration',
|
||||||
'result', 'notes', 'provider_call_id', 'recording_url', 'is_manual',
|
'result', 'notes', 'provider_call_id', 'recording_url', 'is_manual',
|
||||||
|
'provider_status', 'started_at', 'ended_at', 'provider_payload',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return ['is_manual' => 'boolean', 'duration' => 'integer'];
|
return [
|
||||||
|
'is_manual' => 'boolean',
|
||||||
|
'duration' => 'integer',
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'ended_at' => 'datetime',
|
||||||
|
'provider_payload' => 'array',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function lead(): BelongsTo
|
public function lead(): BelongsTo
|
||||||
|
|
@ -35,11 +43,21 @@ class Call extends Model
|
||||||
|
|
||||||
public function contactPhone(): BelongsTo
|
public function contactPhone(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(ContactPhone::class);
|
return $this->belongsTo(ContactPhone::class)->withTrashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function qualityReview(): HasOne
|
public function qualityReview(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(QualityReview::class);
|
return $this->hasOne(QualityReview::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function notesHistory(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Note::class, 'notable');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tasks(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Task::class, 'taskable');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
|
||||||
class Campaign extends Model
|
class Campaign extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -12,9 +14,13 @@ class Campaign extends Model
|
||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
'product_service',
|
'product_service',
|
||||||
|
'product_id',
|
||||||
|
'channel',
|
||||||
'start_date',
|
'start_date',
|
||||||
'end_date',
|
'end_date',
|
||||||
'target',
|
'target',
|
||||||
|
'budget',
|
||||||
|
'actual_cost',
|
||||||
'status',
|
'status',
|
||||||
'sales_script_id',
|
'sales_script_id',
|
||||||
];
|
];
|
||||||
|
|
@ -25,6 +31,8 @@ class Campaign extends Model
|
||||||
'start_date' => 'date',
|
'start_date' => 'date',
|
||||||
'end_date' => 'date',
|
'end_date' => 'date',
|
||||||
'target' => 'integer',
|
'target' => 'integer',
|
||||||
|
'budget' => 'decimal:2',
|
||||||
|
'actual_cost' => 'decimal:2',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,6 +40,7 @@ class Campaign extends Model
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(User::class)
|
return $this->belongsToMany(User::class)
|
||||||
->wherePivot('role', 'agent')
|
->wherePivot('role', 'agent')
|
||||||
|
->withPivotValue('role', 'agent')
|
||||||
->withPivot('role');
|
->withPivot('role');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,6 +48,7 @@ class Campaign extends Model
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(User::class)
|
return $this->belongsToMany(User::class)
|
||||||
->wherePivot('role', 'supervisor')
|
->wherePivot('role', 'supervisor')
|
||||||
|
->withPivotValue('role', 'supervisor')
|
||||||
->withPivot('role');
|
->withPivot('role');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,4 +56,19 @@ class Campaign extends Model
|
||||||
{
|
{
|
||||||
return $this->hasMany(Lead::class);
|
return $this->hasMany(Lead::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function salesScript(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(SalesScript::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Product::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tasks(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Task::class, 'taskable');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,4 +46,14 @@ class Company extends Model
|
||||||
{
|
{
|
||||||
return $this->morphMany(Attachment::class, 'attachable');
|
return $this->morphMany(Attachment::class, 'attachable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tasks(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Task::class, 'taskable');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customFieldValues(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(CustomFieldValue::class, 'fieldable');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Models;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
|
||||||
class Contact extends Model
|
class Contact extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -43,4 +44,14 @@ class Contact extends Model
|
||||||
{
|
{
|
||||||
return $this->hasMany(CallLog::class);
|
return $this->hasMany(CallLog::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tasks(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Task::class, 'taskable');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customFieldValues(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(CustomFieldValue::class, 'fieldable');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,12 @@ namespace App\Models;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class ContactPhone extends Model
|
class ContactPhone extends Model
|
||||||
{
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'contact_id', 'phone', 'type', 'status', 'call_count',
|
'contact_id', 'phone', 'type', 'status', 'call_count',
|
||||||
'successful_call_count', 'failed_call_count', 'last_called_at',
|
'successful_call_count', 'failed_call_count', 'last_called_at',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class CustomFieldDefinition extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = ['entity_type', 'key', 'label', 'type', 'options', 'validation', 'visible_to_roles', 'default_value', 'sort_order', 'is_required', 'is_active', 'is_filterable', 'is_searchable', 'created_by'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['options' => 'array', 'validation' => 'array', 'visible_to_roles' => 'array', 'is_required' => 'boolean', 'is_active' => 'boolean', 'is_filterable' => 'boolean', 'is_searchable' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function values(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CustomFieldValue::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
class CustomFieldValue extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['custom_field_definition_id', 'fieldable_type', 'fieldable_id', 'value_string', 'value_text', 'value_number', 'value_date', 'value_datetime', 'value_boolean', 'value_json', 'updated_by'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['value_number' => 'decimal:4', 'value_date' => 'date', 'value_datetime' => 'datetime', 'value_boolean' => 'boolean', 'value_json' => 'array'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function definition(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CustomFieldDefinition::class, 'custom_field_definition_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fieldable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class DashboardPreference extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['user_id', 'widget_order', 'hidden_widgets', 'default_filters'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['widget_order' => 'array', 'hidden_widgets' => 'array', 'default_filters' => 'array'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,17 +12,22 @@ class Deal extends Model
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'title', 'company_id', 'lead_id', 'contact_id', 'product_id',
|
'title', 'company_id', 'lead_id', 'contact_id', 'product_id', 'pipeline_id', 'deal_stage_id',
|
||||||
'estimated_value', 'win_probability', 'sales_stage', 'expected_close_date',
|
'estimated_value', 'final_amount', 'win_probability', 'sales_stage', 'expected_close_date',
|
||||||
'owner_id', 'status', 'won_lost_reason', 'notes', 'created_by',
|
'last_activity_at', 'closed_at', 'owner_id', 'status', 'won_lost_reason', 'competitor',
|
||||||
|
'forecast_category', 'version', 'notes', 'created_by',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'estimated_value' => 'decimal:2',
|
'estimated_value' => 'decimal:2',
|
||||||
|
'final_amount' => 'decimal:2',
|
||||||
'win_probability' => 'integer',
|
'win_probability' => 'integer',
|
||||||
'expected_close_date' => 'date',
|
'expected_close_date' => 'date',
|
||||||
|
'last_activity_at' => 'datetime',
|
||||||
|
'closed_at' => 'datetime',
|
||||||
|
'version' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +56,26 @@ class Deal extends Model
|
||||||
return $this->belongsTo(User::class, 'owner_id');
|
return $this->belongsTo(User::class, 'owner_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function pipeline(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Pipeline::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stage(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(DealStage::class, 'deal_stage_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stageHistory()
|
||||||
|
{
|
||||||
|
return $this->hasMany(DealStageHistory::class)->latest();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customFieldValues(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(CustomFieldValue::class, 'fieldable');
|
||||||
|
}
|
||||||
|
|
||||||
public function notes(): MorphMany
|
public function notes(): MorphMany
|
||||||
{
|
{
|
||||||
return $this->morphMany(Note::class, 'notable');
|
return $this->morphMany(Note::class, 'notable');
|
||||||
|
|
@ -60,4 +85,9 @@ class Deal extends Model
|
||||||
{
|
{
|
||||||
return $this->morphMany(Attachment::class, 'attachable');
|
return $this->morphMany(Attachment::class, 'attachable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tasks(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Task::class, 'taskable');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class DealStage extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['pipeline_id', 'name', 'slug', 'color', 'probability', 'sort_order', 'is_won', 'is_lost', 'is_active'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['probability' => 'integer', 'sort_order' => 'integer', 'is_won' => 'boolean', 'is_lost' => 'boolean', 'is_active' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pipeline(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Pipeline::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deals(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Deal::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class DealStageHistory extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['deal_id', 'from_stage_id', 'to_stage_id', 'changed_by', 'note'];
|
||||||
|
|
||||||
|
public function deal(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Deal::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fromStage(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(DealStage::class, 'from_stage_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toStage(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(DealStage::class, 'to_stage_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function actor(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'changed_by');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
class FollowUp extends Model
|
class FollowUp extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'lead_id', 'user_id', 'call_id', 'scheduled_at',
|
'lead_id', 'user_id', 'created_by', 'call_id', 'source', 'scheduled_at',
|
||||||
'completed_at', 'notes', 'status', 'is_overdue',
|
'completed_at', 'notes', 'status', 'is_overdue',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -31,6 +31,11 @@ class FollowUp extends Model
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function creator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
public function call(): BelongsTo
|
public function call(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Call::class);
|
return $this->belongsTo(Call::class);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Invoice extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'number', 'lead_id', 'invoice_template_id', 'created_by', 'approved_by',
|
||||||
|
'status', 'currency', 'customer_snapshot', 'seller_snapshot', 'lead_snapshot', 'items',
|
||||||
|
'resolved_fields', 'page_width_mm', 'page_height_mm', 'subtotal', 'discount', 'tax', 'total',
|
||||||
|
'paid_amount', 'payment_status', 'notes', 'payment_terms', 'due_date', 'issued_at', 'approved_at', 'rejected_at',
|
||||||
|
'rejection_reason', 'voided_at', 'version',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'customer_snapshot' => 'array',
|
||||||
|
'seller_snapshot' => 'array',
|
||||||
|
'lead_snapshot' => 'array',
|
||||||
|
'items' => 'array',
|
||||||
|
'resolved_fields' => 'array',
|
||||||
|
'page_width_mm' => 'integer',
|
||||||
|
'page_height_mm' => 'integer',
|
||||||
|
'subtotal' => 'decimal:2',
|
||||||
|
'discount' => 'decimal:2',
|
||||||
|
'tax' => 'decimal:2',
|
||||||
|
'total' => 'decimal:2',
|
||||||
|
'paid_amount' => 'decimal:2',
|
||||||
|
'due_date' => 'date',
|
||||||
|
'issued_at' => 'datetime',
|
||||||
|
'approved_at' => 'datetime',
|
||||||
|
'rejected_at' => 'datetime',
|
||||||
|
'voided_at' => 'datetime',
|
||||||
|
'version' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lead(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Lead::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function template(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(InvoiceTemplate::class, 'invoice_template_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function creator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approver(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'approved_by');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class InvoiceTemplate extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'name', 'background_path', 'background_name', 'background_mime', 'layout',
|
||||||
|
'source_path', 'source_name', 'source_mime', 'base_type', 'background_settings',
|
||||||
|
'page_width_mm', 'page_height_mm', 'is_default', 'is_active', 'created_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'layout' => 'array',
|
||||||
|
'background_settings' => 'array',
|
||||||
|
'is_default' => 'boolean',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'page_width_mm' => 'integer',
|
||||||
|
'page_height_mm' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function creator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invoices(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Invoice::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ class Lead extends Model
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'company_id', 'first_name', 'last_name', 'company', 'phone', 'phone_secondary',
|
'company_id', 'first_name', 'last_name', 'company', 'phone', 'phone_secondary',
|
||||||
'email', 'city', 'province', 'source', 'product_interest',
|
'email', 'city', 'province', 'source', 'product_interest',
|
||||||
'priority', 'lead_score', 'notes', 'tags',
|
'priority', 'lead_score', 'score_level', 'score_breakdown', 'scored_at', 'notes', 'tags',
|
||||||
'interest_level', 'call_attempts', 'lost_reason', 'final_result',
|
'interest_level', 'call_attempts', 'lost_reason', 'final_result',
|
||||||
'deal_value', 'sold_product', 'contract_date', 'payment_status', 'customer_notes',
|
'deal_value', 'sold_product', 'contract_date', 'payment_status', 'customer_notes',
|
||||||
'lead_status_id', 'pipeline_stage_id', 'campaign_id',
|
'lead_status_id', 'pipeline_stage_id', 'campaign_id',
|
||||||
|
|
@ -28,6 +28,8 @@ class Lead extends Model
|
||||||
return [
|
return [
|
||||||
'priority' => 'integer',
|
'priority' => 'integer',
|
||||||
'lead_score' => 'integer',
|
'lead_score' => 'integer',
|
||||||
|
'score_breakdown' => 'array',
|
||||||
|
'scored_at' => 'datetime',
|
||||||
'call_attempts' => 'integer',
|
'call_attempts' => 'integer',
|
||||||
'deal_value' => 'decimal:2',
|
'deal_value' => 'decimal:2',
|
||||||
'is_unassigned' => 'boolean',
|
'is_unassigned' => 'boolean',
|
||||||
|
|
@ -39,14 +41,17 @@ class Lead extends Model
|
||||||
|
|
||||||
public function getFullNameAttribute(): string
|
public function getFullNameAttribute(): string
|
||||||
{
|
{
|
||||||
return $this->first_name . ' ' . $this->last_name;
|
return $this->first_name.' '.$this->last_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMaskedPhoneAttribute(): string
|
public function getMaskedPhoneAttribute(): string
|
||||||
{
|
{
|
||||||
$phone = $this->phone;
|
$phone = $this->phone;
|
||||||
if (strlen($phone) < 7) return $phone;
|
if (strlen($phone) < 7) {
|
||||||
return substr($phone, 0, 4) . ' *** **' . substr($phone, -2);
|
return $phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($phone, 0, 4).' *** **'.substr($phone, -2);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function leadStatus(): BelongsTo
|
public function leadStatus(): BelongsTo
|
||||||
|
|
@ -124,16 +129,31 @@ class Lead extends Model
|
||||||
return $this->morphMany(Attachment::class, 'attachable');
|
return $this->morphMany(Attachment::class, 'attachable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tasks(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Task::class, 'taskable');
|
||||||
|
}
|
||||||
|
|
||||||
public function deals(): HasMany
|
public function deals(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Deal::class);
|
return $this->hasMany(Deal::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function customFieldValues(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(CustomFieldValue::class, 'fieldable');
|
||||||
|
}
|
||||||
|
|
||||||
public function leadAssignments(): HasMany
|
public function leadAssignments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(LeadAssignment::class);
|
return $this->hasMany(LeadAssignment::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function invoices(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Invoice::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeAssignedTo($query, $userId)
|
public function scopeAssignedTo($query, $userId)
|
||||||
{
|
{
|
||||||
return $query->where('assigned_to', $userId);
|
return $query->where('assigned_to', $userId);
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,21 @@ namespace App\Models;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Note extends Model
|
class Note extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['content', 'user_id'];
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = ['notable_type', 'notable_id', 'content', 'user_id', 'type', 'visibility', 'is_pinned', 'edited_at', 'source_key'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_pinned' => 'boolean',
|
||||||
|
'edited_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function notable(): MorphTo
|
public function notable(): MorphTo
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@ namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Notification extends Model
|
class Notification extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['user_id', 'title', 'message', 'type', 'data', 'is_read'];
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = ['user_id', 'title', 'message', 'type', 'data', 'is_read', 'read_at', 'archived_at', 'idempotency_key'];
|
||||||
|
|
||||||
protected $table = 'internal_notifications';
|
protected $table = 'internal_notifications';
|
||||||
|
|
||||||
|
|
@ -16,6 +19,8 @@ class Notification extends Model
|
||||||
return [
|
return [
|
||||||
'data' => 'array',
|
'data' => 'array',
|
||||||
'is_read' => 'boolean',
|
'is_read' => 'boolean',
|
||||||
|
'read_at' => 'datetime',
|
||||||
|
'archived_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class NotificationPreference extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['user_id', 'notification_type', 'in_app_enabled', 'is_muted'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['in_app_enabled' => 'boolean', 'is_muted' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Pipeline extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = ['name', 'slug', 'team_id', 'is_default', 'is_active', 'sort_order'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['is_default' => 'boolean', 'is_active' => 'boolean', 'sort_order' => 'integer'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function team(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Team::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stages(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(DealStage::class)->orderBy('sort_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deals(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Deal::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,4 +35,9 @@ class Product extends Model
|
||||||
{
|
{
|
||||||
return $this->hasMany(Deal::class);
|
return $this->hasMany(Deal::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function campaigns(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Campaign::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,24 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
class QualityReview extends Model
|
class QualityReview extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'call_id', 'reviewer_id', 'agent_id',
|
'call_id', 'reviewer_id', 'agent_id', 'version', 'is_current',
|
||||||
'greeting_score', 'product_intro_score', 'needs_discovery_score',
|
'greeting_score', 'product_intro_score', 'needs_discovery_score',
|
||||||
'objection_handling_score', 'closing_score', 'crm_accuracy_score',
|
'objection_handling_score', 'closing_score', 'crm_accuracy_score',
|
||||||
'follow_up_quality_score', 'overall_score',
|
'follow_up_quality_score', 'overall_score',
|
||||||
'feedback', 'tag', 'is_shared_with_agent',
|
'feedback', 'strengths', 'improvement_areas', 'tag', 'status', 'is_shared_with_agent',
|
||||||
|
'acknowledged_at', 'agent_response',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return ['is_shared_with_agent' => 'boolean'];
|
return [
|
||||||
|
'is_shared_with_agent' => 'boolean',
|
||||||
|
'is_current' => 'boolean',
|
||||||
|
'version' => 'integer',
|
||||||
|
'strengths' => 'array',
|
||||||
|
'improvement_areas' => 'array',
|
||||||
|
'acknowledged_at' => 'datetime',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function call(): BelongsTo
|
public function call(): BelongsTo
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
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\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
class SalesScript extends Model
|
class SalesScript extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['title', 'description', 'campaign_id', 'product_id', 'version', 'is_active', 'checklist', 'objection_handling'];
|
protected $fillable = ['title', 'description', 'version', 'is_active', 'category', 'lead_source', 'checklist', 'objection_handling', 'suggested_questions', 'required_disclosures', 'is_template'];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
|
|
@ -16,21 +17,31 @@ class SalesScript extends Model
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'checklist' => 'array',
|
'checklist' => 'array',
|
||||||
'objection_handling' => 'array',
|
'objection_handling' => 'array',
|
||||||
|
'suggested_questions' => 'array',
|
||||||
|
'required_disclosures' => 'array',
|
||||||
|
'is_template' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function campaign(): BelongsTo
|
public function campaign(): HasOne
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Campaign::class);
|
return $this->hasOne(Campaign::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function product(): BelongsTo
|
public function product(): HasOne
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Product::class);
|
return $this->hasOne(Product::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sections(): HasMany
|
public function sections(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ScriptSection::class);
|
return $this->hasMany(ScriptSection::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function assignees(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'sales_script_user')
|
||||||
|
->withPivot('assigned_by')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class SavedView extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = ['user_id', 'team_id', 'entity_type', 'name', 'visibility', 'filters', 'columns', 'sort', 'is_default'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['filters' => 'array', 'columns' => 'array', 'sort' => 'array', 'is_default' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function team(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Team::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
class SlaBreach extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['sla_rule_id', 'breachable_type', 'breachable_id', 'assigned_to', 'status', 'due_at', 'warned_at', 'breached_at', 'resolved_at', 'event_key', 'details'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['due_at' => 'datetime', 'warned_at' => 'datetime', 'breached_at' => 'datetime', 'resolved_at' => 'datetime', 'details' => 'array'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rule(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(SlaRule::class, 'sla_rule_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignee(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'assigned_to');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function breachable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class SlaRule extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['name', 'event', 'warning_minutes', 'breach_minutes', 'scope', 'is_active', 'created_by'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['warning_minutes' => 'integer', 'breach_minutes' => 'integer', 'scope' => 'array', 'is_active' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function creator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function breaches(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SlaBreach::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\TaskPriority;
|
||||||
|
use App\Enums\TaskStatus;
|
||||||
|
use App\Enums\TaskVisibility;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Task extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'subject', 'description', 'taskable_type', 'taskable_id', 'assigned_to', 'assigned_by',
|
||||||
|
'created_by', 'priority', 'status', 'due_at', 'started_at', 'completed_at', 'reminder_at',
|
||||||
|
'parent_task_id', 'estimated_minutes', 'visibility', 'version',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'priority' => TaskPriority::class,
|
||||||
|
'status' => TaskStatus::class,
|
||||||
|
'visibility' => TaskVisibility::class,
|
||||||
|
'due_at' => 'datetime',
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
'reminder_at' => 'datetime',
|
||||||
|
'version' => 'integer',
|
||||||
|
'estimated_minutes' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function taskable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignee(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'assigned_to');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assigner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'assigned_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function creator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parent(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(self::class, 'parent_task_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subtasks(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(self::class, 'parent_task_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereIn('status', [TaskStatus::Open->value, TaskStatus::InProgress->value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsOverdueAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->status->isActive() && $this->due_at?->isPast() === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,14 +10,13 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable, HasApiTokens, HasRoles, SoftDeletes;
|
use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name', 'email', 'password', 'phone', 'voip_extension', 'avatar', 'is_active',
|
'name', 'email', 'password', 'phone', 'voip_extension', 'avatar', 'is_active',
|
||||||
|
|
@ -66,30 +65,52 @@ class User extends Authenticatable
|
||||||
return $this->hasMany(FollowUp::class);
|
return $this->hasMany(FollowUp::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function assignedTasks(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Task::class, 'assigned_to');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function savedViews(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SavedView::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dashboardPreference()
|
||||||
|
{
|
||||||
|
return $this->hasOne(DashboardPreference::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notificationPreferences(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(NotificationPreference::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function leadAssignments(): HasMany
|
public function leadAssignments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(LeadAssignment::class, 'assigned_by');
|
return $this->hasMany(LeadAssignment::class, 'assigned_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createdInvoices(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Invoice::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approvedInvoices(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Invoice::class, 'approved_by');
|
||||||
|
}
|
||||||
|
|
||||||
protected function avatarUrl(): Attribute
|
protected function avatarUrl(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::get(function (): ?string {
|
return Attribute::get(function (): ?string {
|
||||||
if (!$this->avatar) {
|
if (! $this->avatar) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = parse_url($this->avatar, PHP_URL_PATH) ?: $this->avatar;
|
$path = parse_url($this->avatar, PHP_URL_PATH) ?: $this->avatar;
|
||||||
$path = ltrim($path, '/');
|
$filename = basename(str_replace('\\', '/', $path));
|
||||||
|
|
||||||
if (str_starts_with($path, 'storage/')) {
|
return '/api/media/avatars/'.rawurlencode($filename);
|
||||||
$path = substr($path, strlen('storage/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($path, 'public/')) {
|
|
||||||
$path = substr($path, strlen('public/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return url(Storage::url($path));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
final class TaskAssignedNotification
|
||||||
|
{
|
||||||
|
public static function send(Task $task, User $actor, bool $reassigned = false): void
|
||||||
|
{
|
||||||
|
if (! $task->assigned_to || $task->assigned_to === $actor->id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::firstOrCreate(
|
||||||
|
['idempotency_key' => "task:{$task->id}:assignment:{$task->version}:{$task->assigned_to}"],
|
||||||
|
[
|
||||||
|
'user_id' => $task->assigned_to,
|
||||||
|
'title' => $reassigned ? 'کار جدید به شما واگذار شد' : 'کار جدید برای شما ثبت شد',
|
||||||
|
'message' => $task->subject,
|
||||||
|
'type' => $reassigned ? 'task_reassigned' : 'task_assigned',
|
||||||
|
'data' => [
|
||||||
|
'task_id' => $task->id,
|
||||||
|
'url' => "/tasks?task={$task->id}",
|
||||||
|
'actor' => ['id' => $actor->id, 'name' => $actor->name],
|
||||||
|
],
|
||||||
|
'is_read' => false,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\Task;
|
||||||
|
|
||||||
|
final class TaskDueNotification
|
||||||
|
{
|
||||||
|
public static function send(Task $task, string $kind): void
|
||||||
|
{
|
||||||
|
if (! $task->assigned_to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$moment = $kind === 'overdue' ? $task->due_at : $task->reminder_at;
|
||||||
|
if (! $moment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::firstOrCreate(
|
||||||
|
['idempotency_key' => "task:{$task->id}:{$kind}:{$moment->getTimestamp()}"],
|
||||||
|
[
|
||||||
|
'user_id' => $task->assigned_to,
|
||||||
|
'title' => $kind === 'overdue' ? 'کار عقبافتاده' : 'یادآوری موعد کار',
|
||||||
|
'message' => $task->subject,
|
||||||
|
'type' => $kind === 'overdue' ? 'task_overdue' : 'task_due',
|
||||||
|
'data' => [
|
||||||
|
'task_id' => $task->id,
|
||||||
|
'url' => "/tasks?task={$task->id}",
|
||||||
|
'due_at' => $task->due_at?->toISOString(),
|
||||||
|
],
|
||||||
|
'is_read' => false,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Attachment;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\AccessControl;
|
||||||
|
|
||||||
|
class AttachmentPolicy
|
||||||
|
{
|
||||||
|
public function view(User $user, Attachment $attachment): bool
|
||||||
|
{
|
||||||
|
return AccessControl::canAccessEntity($user, $attachment->attachable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Attachment $attachment): bool
|
||||||
|
{
|
||||||
|
return $this->view($user, $attachment)
|
||||||
|
&& ($user->hasRole('admin') || $attachment->uploaded_by === $user->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,11 @@ use App\Support\AccessControl;
|
||||||
|
|
||||||
class CallPolicy
|
class CallPolicy
|
||||||
{
|
{
|
||||||
|
public function before(User $user): ?bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('admin') ? true : null;
|
||||||
|
}
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->hasRole('admin') || $user->can('view_all_calls') || $user->can('view_team_calls') || $user->can('view_own_calls');
|
return $user->hasRole('admin') || $user->can('view_all_calls') || $user->can('view_team_calls') || $user->can('view_own_calls');
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,14 @@ use App\Support\AccessControl;
|
||||||
|
|
||||||
class CampaignPolicy
|
class CampaignPolicy
|
||||||
{
|
{
|
||||||
|
public function before(User $user): ?bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('admin') ? true : null;
|
||||||
|
}
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->hasAnyRole(['admin', 'supervisor', 'agent']);
|
return $user->can('view_campaigns');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(User $user, Campaign $campaign): bool
|
public function view(User $user, Campaign $campaign): bool
|
||||||
|
|
@ -20,12 +25,12 @@ class CampaignPolicy
|
||||||
|
|
||||||
public function create(User $user): bool
|
public function create(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->hasRole('admin') || $user->can('manage_campaigns');
|
return $user->can('manage_campaigns');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(User $user, Campaign $campaign): bool
|
public function update(User $user, Campaign $campaign): bool
|
||||||
{
|
{
|
||||||
return AccessControl::canAccessCampaign($user, $campaign) && ($user->hasRole('admin') || $user->can('manage_campaigns'));
|
return AccessControl::canAccessCampaign($user, $campaign) && $user->can('manage_campaigns');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(User $user, Campaign $campaign): bool
|
public function delete(User $user, Campaign $campaign): bool
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\AccessControl;
|
||||||
|
|
||||||
|
class CompanyPolicy
|
||||||
|
{
|
||||||
|
public function before(User $user): ?bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('admin') ? true : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('view_leads');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Company $company): bool
|
||||||
|
{
|
||||||
|
return AccessControl::canAccessCompany($user, $company);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('create_leads');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Company $company): bool
|
||||||
|
{
|
||||||
|
return $user->can('edit_leads') && $this->view($user, $company);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Company $company): bool
|
||||||
|
{
|
||||||
|
return $user->can('delete_leads') && $this->view($user, $company);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\AccessControl;
|
||||||
|
|
||||||
|
class ContactPolicy
|
||||||
|
{
|
||||||
|
public function before(User $user): ?bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('admin') ? true : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('view_leads');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Contact $contact): bool
|
||||||
|
{
|
||||||
|
return AccessControl::canAccessContact($user, $contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('create_leads');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Contact $contact): bool
|
||||||
|
{
|
||||||
|
return $user->can('edit_leads') && $this->view($user, $contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Contact $contact): bool
|
||||||
|
{
|
||||||
|
return $user->can('delete_leads') && $this->view($user, $contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class DashboardPolicy
|
||||||
|
{
|
||||||
|
public function viewAdmin(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('admin') && $user->can('view_admin_dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewSupervisor(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('supervisor') && $user->can('view_supervisor_dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewAgent(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('agent') && $user->can('view_agent_dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Deal;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\AccessControl;
|
||||||
|
|
||||||
|
class DealPolicy
|
||||||
|
{
|
||||||
|
public function before(User $user): ?bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('admin') ? true : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('view_leads');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Deal $deal): bool
|
||||||
|
{
|
||||||
|
return AccessControl::canAccessDeal($user, $deal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('create_leads');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Deal $deal): bool
|
||||||
|
{
|
||||||
|
return $user->can('edit_leads') && $this->view($user, $deal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Deal $deal): bool
|
||||||
|
{
|
||||||
|
return $user->can('delete_leads') && $this->view($user, $deal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ class FollowUpPolicy
|
||||||
{
|
{
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->hasAnyRole(['admin', 'supervisor', 'agent']);
|
return $user->can('view_leads');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(User $user, FollowUp $followUp): bool
|
public function view(User $user, FollowUp $followUp): bool
|
||||||
|
|
@ -21,11 +21,25 @@ class FollowUpPolicy
|
||||||
|
|
||||||
public function create(User $user, Lead $lead): bool
|
public function create(User $user, Lead $lead): bool
|
||||||
{
|
{
|
||||||
return AccessControl::canAccessLead($user, $lead);
|
return $user->can('edit_leads') && AccessControl::canAccessLead($user, $lead);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(User $user, FollowUp $followUp): bool
|
public function update(User $user, FollowUp $followUp): bool
|
||||||
{
|
{
|
||||||
return AccessControl::canAccessFollowUp($user, $followUp);
|
return $followUp->created_by === $user->id
|
||||||
|
&& $user->can('edit_leads')
|
||||||
|
&& AccessControl::canAccessFollowUp($user, $followUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(User $user, FollowUp $followUp): bool
|
||||||
|
{
|
||||||
|
return $followUp->user_id === $user->id && AccessControl::canAccessFollowUp($user, $followUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, FollowUp $followUp): bool
|
||||||
|
{
|
||||||
|
return $followUp->created_by === $user->id
|
||||||
|
&& $user->can('edit_leads')
|
||||||
|
&& AccessControl::canAccessFollowUp($user, $followUp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
برخی از فایل ها نشان داده نشدند زیرا تعداد زیادی فایل در این تفاوت تغییر کرده اند نمایش بیشتر
بارگذاری…
مرجع در شماره جدید