diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..33701e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index e4c64e4..3c2ced0 100644 --- a/.gitignore +++ b/.gitignore @@ -20,10 +20,13 @@ node_modules/ vendor/ # Build output +artifacts/ backend/public/build/ backend/public/hot frontend/dist/ frontend/dist-ssr/ +frontend/test-results/ +frontend/playwright-report/ # Laravel runtime data backend/storage/app/* diff --git a/README.md b/README.md index 8b13789..5d03f36 100644 --- a/README.md +++ b/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 ` را اجرا کنید. 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 الزامی است. diff --git a/backend/app/Enums/TaskPriority.php b/backend/app/Enums/TaskPriority.php new file mode 100644 index 0000000..7ed9385 --- /dev/null +++ b/backend/app/Enums/TaskPriority.php @@ -0,0 +1,11 @@ + CallResult::where('is_active', true) ->orderBy('sort_order') ->get(['name', 'slug', 'requires_follow_up', 'is_final']) - ->map(fn(CallResult $result) => [ + ->map(fn (CallResult $result) => [ 'name' => $result->name, 'slug' => $result->slug, 'requires_follow_up' => $result->requires_follow_up, @@ -71,7 +72,7 @@ class AgentMobileController extends Controller $this->applyMobileFilter($query, $request->query('filter')); 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) { $q->where('interest_level', 'hot') ->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') { - $query->whereHas('pipelineStage', fn($stage) => $stage->where('name', 'like', '%پیشنهاد%')); + $query->whereHas('pipelineStage', fn ($stage) => $stage->where('name', 'like', '%پیشنهاد%')); } if ($request->query('filter') === 'closed') { @@ -103,7 +104,7 @@ class AgentMobileController extends Controller ->orderBy('next_follow_up_at') ->limit(40) ->get() - ->map(fn(Lead $lead) => $this->leadCard($lead)) + ->map(fn (Lead $lead) => $this->leadCard($lead)) ->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', 'contactRelations.fromContact:id,name,role', 'contactRelations.toContact:id,name,role', - '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), - 'followUps' => fn($q) => $q->latest('scheduled_at')->limit(8), + '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), + 'followUps' => fn ($q) => $q->latest('scheduled_at')->limit(8), ]); $primary = $this->primaryContact($lead); @@ -147,20 +148,20 @@ class AgentMobileController extends Controller 'tags' => $this->tags($lead), 'final_result' => $lead->final_result, ], - 'calls' => $lead->calls->map(fn(Call $call) => [ + 'calls' => $lead->calls->map(fn (Call $call) => [ 'id' => $call->id, 'contact_name' => $call->contact?->name, 'result' => $call->result, 'notes' => $call->notes, 'created_at' => optional($call->created_at)->toIso8601String(), ])->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([ $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, ])), 'files' => [], - 'contact_routes' => $lead->contactRelations->map(fn($relation) => [ + 'contact_routes' => $lead->contactRelations->map(fn ($relation) => [ 'id' => $relation->id, 'from' => $relation->fromContact?->name, 'to' => $relation->toContact?->name, @@ -174,17 +175,17 @@ class AgentMobileController extends Controller public function followUps(Request $request): JsonResponse { $items = FollowUp::with([ - 'lead:id,first_name,last_name,company,lead_status_id,pipeline_stage_id,last_call_result,next_follow_up_at,assigned_to', - 'lead.leadStatus:id,name,color', - 'lead.pipelineStage:id,name,color', - 'lead.contacts.phones:id,contact_id,type,status,call_count,last_call_result,last_called_at', - ]) + 'lead:id,first_name,last_name,company,lead_status_id,pipeline_stage_id,last_call_result,next_follow_up_at,assigned_to', + 'lead.leadStatus:id,name,color', + 'lead.pipelineStage:id,name,color', + 'lead.contacts.phones:id,contact_id,type,status,call_count,last_call_result,last_called_at', + ]) ->where('user_id', $request->user()->id) ->where('scheduled_at', '<=', now()->addWeek()) ->orderBy('scheduled_at') ->limit(80) ->get() - ->map(fn(FollowUp $followUp) => $this->followUpCard($followUp)); + ->map(fn (FollowUp $followUp) => $this->followUpCard($followUp)); return response()->json([ 'data' => [ @@ -224,16 +225,16 @@ class AgentMobileController extends Controller ]); $lead = Lead::findOrFail($validated['lead_id']); - if (!$this->canAccessLead($request, $lead)) { + if (! $this->canAccessLead($request, $lead)) { 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']) - ->whereHas('contact', fn($query) => $query->where('lead_id', $lead->id)) + ->whereHas('contact', fn ($query) => $query->where('lead_id', $lead->id)) ->exists(); - if (!$belongsToLead) { + if (! $belongsToLead) { return response()->json(['message' => 'شماره انتخاب‌شده برای این لید معتبر نیست'], 422); } } @@ -271,13 +272,13 @@ class AgentMobileController extends Controller ]); $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); } $followUpAt = $validated['next_follow_up_at'] ?? $validated['referral']['next_follow_up_at'] ?? null; $callResult = CallResult::where('name', $validated['result'])->first(); - if ($callResult?->requires_follow_up && !$followUpAt) { + if ($callResult?->requires_follow_up && ! $followUpAt) { return response()->json(['message' => 'برای این نتیجه تماس، زمان پیگیری الزامی است'], 422); } @@ -300,7 +301,7 @@ class AgentMobileController extends Controller 'call_id' => $registeredCall->id, 'lead_id' => $registeredCall->lead_id, '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']); - if (!$this->canAccessLead($request, $lead)) { + if (! $this->canAccessLead($request, $lead)) { 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); } - $followUp = FollowUp::create([ - 'lead_id' => $lead->id, - 'user_id' => $request->user()->id, - 'call_id' => $validated['call_id'] ?? null, - 'scheduled_at' => $validated['scheduled_at'], - 'status' => 'pending', - 'notes' => $validated['notes'] ?? null, - ]); - $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); + $followUp = $this->followUps->schedule( + $lead, + $request->user()->id, + $validated['scheduled_at'], + $request->user(), + $validated['notes'] ?? null, + $validated['call_id'] ?? null, + 'agent_mobile', + ); 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']); - if (!$this->canAccessLead($request, $lead)) { + if (! $this->canAccessLead($request, $lead)) { 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); } @@ -440,7 +439,7 @@ class AgentMobileController extends Controller ->where(function ($query) { $query->whereNull('last_call_at') ->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') ->orderByRaw('next_follow_up_at IS NULL') @@ -533,6 +532,7 @@ class AgentMobileController extends Controller private function primaryContact(Lead $lead) { $contacts = $lead->relationLoaded('contacts') ? $lead->contacts : $lead->contacts()->with('phones')->get(); + return $contacts->firstWhere('is_primary', true) ?? $contacts->first(); } @@ -543,13 +543,13 @@ class AgentMobileController extends Controller private function tags(Lead $lead): array { - if (!$lead->tags) { + if (! $lead->tags) { return []; } $decoded = json_decode($lead->tags, true); 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)))); @@ -557,34 +557,64 @@ class AgentMobileController extends Controller private function priorityLabel(int $priority): string { - if ($priority >= 8) return 'خیلی بالا'; - if ($priority >= 5) return 'بالا'; - if ($priority >= 2) return 'متوسط'; + if ($priority >= 8) { + return 'خیلی بالا'; + } + if ($priority >= 5) { + return 'بالا'; + } + if ($priority >= 2) { + return 'متوسط'; + } + return 'عادی'; } private function nextAction(Lead $lead): string { - if (!$lead->last_call_at) 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 'پیگیری امروز'; + if (! $lead->last_call_at) { + 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 'ادامه پیگیری'; } private function followUpStatus(Lead $lead): string { - if (!$lead->next_follow_up_at) return 'بدون زمان پیگیری'; - if ($lead->next_follow_up_at->isPast()) return 'عقب‌افتاده'; - if ($lead->next_follow_up_at->isToday()) return 'امروز'; + if (! $lead->next_follow_up_at) { + return 'بدون زمان پیگیری'; + } + if ($lead->next_follow_up_at->isPast()) { + return 'عقب‌افتاده'; + } + if ($lead->next_follow_up_at->isToday()) { + return 'امروز'; + } + return 'زمان‌بندی‌شده'; } private function followUpGroup(FollowUp $followUp): string { - if ($followUp->status === 'completed') return 'done'; - 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'; + if ($followUp->status === 'completed') { + return 'done'; + } + 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'; } diff --git a/backend/app/Http/Controllers/Api/AssignmentController.php b/backend/app/Http/Controllers/Api/AssignmentController.php index f0a525c..8d245ec 100644 --- a/backend/app/Http/Controllers/Api/AssignmentController.php +++ b/backend/app/Http/Controllers/Api/AssignmentController.php @@ -24,7 +24,7 @@ class AssignmentController extends Controller ]); $lead = Lead::findOrFail($validated['lead_id']); $agent = User::findOrFail($validated['agent_id']); - if (!$this->canAssignToAgent($lead, $agent)) { + if (! $this->canAssignToAgent($lead, $agent)) { return response()->json(['message' => 'دسترسی غیرمجاز'], 403); } @@ -47,7 +47,7 @@ class AssignmentController extends Controller ]); $agent = User::findOrFail($validated['agent_id']); $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); } @@ -56,7 +56,7 @@ class AssignmentController extends Controller $validated['agent_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)]); } @@ -74,8 +74,8 @@ class AssignmentController extends Controller if ( $agents->count() !== count(array_unique($validated['agent_ids'])) || $leads->count() !== count(array_unique($validated['lead_ids'])) - || $leads->contains(fn(Lead $lead) => Gate::denies('assign', $lead)) - || $agents->contains(fn(User $agent) => !$this->agentIsAssignable($agent)) + || $leads->contains(fn (Lead $lead) => Gate::denies('assign', $lead)) + || $agents->contains(fn (User $agent) => ! $this->agentIsAssignable($agent)) ) { return response()->json(['message' => 'دسترسی غیرمجاز'], 403); } @@ -85,7 +85,7 @@ class AssignmentController extends Controller $validated['agent_ids'], 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)]); } @@ -98,7 +98,7 @@ class AssignmentController extends Controller ]); $lead = Lead::findOrFail($validated['lead_id']); $agent = User::findOrFail($validated['agent_id']); - if (!$this->canAssignToAgent($lead, $agent)) { + if (! $this->canAssignToAgent($lead, $agent)) { return response()->json(['message' => 'دسترسی غیرمجاز'], 403); } @@ -112,12 +112,36 @@ class AssignmentController extends Controller 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 { $leadModel = Lead::findOrFail($leadId); Gate::authorize('assign', $leadModel); $lead = $this->assignmentService->returnToPool($leadId); + return response()->json($lead); } @@ -130,7 +154,7 @@ class AssignmentController extends Controller { $user = auth()->user(); - if (!$agent->hasRole('agent') || !$agent->is_active) { + if (! $agent->hasRole('agent') || ! $agent->is_active) { return false; } diff --git a/backend/app/Http/Controllers/Api/AttachmentController.php b/backend/app/Http/Controllers/Api/AttachmentController.php index 8b3c525..f2a4755 100644 --- a/backend/app/Http/Controllers/Api/AttachmentController.php +++ b/backend/app/Http/Controllers/Api/AttachmentController.php @@ -4,13 +4,12 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Attachment; -use App\Models\Company; -use App\Models\Deal; -use App\Models\Lead; use App\Models\Setting; use App\Services\ActivityLogger; +use App\Support\EntityResolver; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Storage; class AttachmentController extends Controller @@ -18,7 +17,8 @@ class AttachmentController extends Controller public function index(Request $request): JsonResponse { $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()]); } @@ -27,12 +27,12 @@ class AttachmentController extends Controller $validated = $request->validate([ 'entity_type' => 'required|in:lead,company,deal', '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.max' => 'حجم فایل پیوست بیش از حد مجاز است.', ]); - $model = $this->resolve($validated['entity_type'], $validated['entity_id']); + $model = EntityResolver::authorize($validated['entity_type'], $validated['entity_id'], 'update'); $file = $request->file('file'); $path = $file->store('attachments'); @@ -45,31 +45,26 @@ class AttachmentController extends Controller ]); ActivityLogger::log('attachment_uploaded', "File {$attachment->original_name} uploaded", $model); + return response()->json($attachment->load('uploader:id,name'), 201); } public function download(Attachment $attachment) { + Gate::authorize('view', $attachment); ActivityLogger::log('attachment_downloaded', "File {$attachment->original_name} downloaded", $attachment->attachable); + return Storage::download($attachment->path, $attachment->original_name); } 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); $attachment->delete(); ActivityLogger::log('attachment_deleted', "File {$attachment->id} deleted"); - return response()->json(['message' => 'فایل حذف شد']); - } - private function resolve(string $type, int $id): Lead|Company|Deal - { - return match ($type) { - 'lead' => Lead::findOrFail($id), - 'company' => Company::findOrFail($id), - 'deal' => Deal::findOrFail($id), - }; + return response()->json(['message' => 'فایل حذف شد']); } private function allowedMimes(): string diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php index 04383c0..9343590 100644 --- a/backend/app/Http/Controllers/Api/AuthController.php +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -26,13 +26,13 @@ class AuthController extends Controller ? User::whereRaw('lower(email) = ?', [$login])->first() : 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([ 'phone' => ['شماره موبایل یا رمز عبور اشتباه است'], ]); } - if (!$user->is_active) { + if (! $user->is_active) { return response()->json(['message' => 'حساب کاربری شما غیرفعال است'], 403); } @@ -66,7 +66,7 @@ class AuthController extends Controller ])->save(); } - ActivityLogger::log('logout', "User logged out"); + ActivityLogger::log('logout', 'User logged out'); Auth::guard('web')->logout(); if ($request->hasSession()) { @@ -79,10 +79,20 @@ class AuthController extends Controller 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'); return response()->json([ + 'authenticated' => true, 'data' => $user, ]); } @@ -93,8 +103,8 @@ class AuthController extends Controller $validated = $request->validate([ 'name' => 'required|string|max:255', - 'email' => 'required|email|unique:users,email,' . $user->id, - 'phone' => 'nullable|string|max:20|unique:users,phone,' . $user->id, + 'email' => 'required|email|unique:users,email,'.$user->id, + 'phone' => 'nullable|string|max:20|unique:users,phone,'.$user->id, 'password' => ['nullable', ...PasswordPolicy::rules()], 'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048', ]); @@ -104,7 +114,7 @@ class AuthController extends Controller $validated['avatar'] = $path; } - if (!empty($validated['password'])) { + if (! empty($validated['password'])) { $validated['password'] = Hash::make($validated['password']); } else { unset($validated['password']); diff --git a/backend/app/Http/Controllers/Api/CalendarController.php b/backend/app/Http/Controllers/Api/CalendarController.php new file mode 100644 index 0000000..f23cd18 --- /dev/null +++ b/backend/app/Http/Controllers/Api/CalendarController.php @@ -0,0 +1,82 @@ +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()]); + } +} diff --git a/backend/app/Http/Controllers/Api/CallController.php b/backend/app/Http/Controllers/Api/CallController.php index 25a1d94..ab6cc99 100644 --- a/backend/app/Http/Controllers/Api/CallController.php +++ b/backend/app/Http/Controllers/Api/CallController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Resources\CallResource; +use App\Http\Responses\ApiResponse; use App\Models\Call; use App\Models\CallResult; use App\Models\Lead; @@ -26,13 +28,19 @@ class CallController extends Controller $user = auth()->user(); 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); if ($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) { $query->where('result', $request->result); } @@ -42,16 +50,30 @@ class CallController extends Controller if ($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 { - return response()->json( + return ApiResponse::success( CallResult::where('is_active', true) ->orderBy('sort_order') ->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); - return response()->json($result, 201); + return ApiResponse::success($result, 201, 'تماس آغاز شد.'); } 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); } - 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 @@ -113,6 +135,54 @@ class CallController extends Controller $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()); } } diff --git a/backend/app/Http/Controllers/Api/CampaignController.php b/backend/app/Http/Controllers/Api/CampaignController.php index 7b089b6..4501160 100644 --- a/backend/app/Http/Controllers/Api/CampaignController.php +++ b/backend/app/Http/Controllers/Api/CampaignController.php @@ -3,18 +3,30 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Resources\CampaignResource; +use App\Http\Responses\ApiResponse; use App\Models\Campaign; +use App\Models\User; use App\Services\ActivityLogger; use App\Support\AccessControl; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; class CampaignController extends Controller { 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); AccessControl::scopeCampaigns($query, auth()->user()); @@ -26,7 +38,9 @@ class CampaignController extends Controller $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 @@ -37,40 +51,44 @@ class CampaignController extends Controller 'name' => 'required|string|max:255', 'description' => 'nullable|string', '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', 'end_date' => 'nullable|date|after_or_equal:start_date', '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', + 'sales_script_id' => 'nullable|exists:sales_scripts,id', 'agent_ids' => 'nullable|array', 'agent_ids.*' => 'exists:users,id', 'supervisor_ids' => 'nullable|array', '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) { - $campaign->assignedAgents()->sync($request->agent_ids); - } - if ($request->supervisor_ids) { - $campaign->assignedSupervisors()->sync($request->supervisor_ids); - } + return $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 { Gate::authorize('view', $campaign); - return response()->json( + return ApiResponse::success($this->resource( $campaign->load([ - 'assignedAgents', 'assignedSupervisors', 'salesScript', - 'leads' => fn($q) => $q->with('leadStatus'), + 'assignedAgents', 'assignedSupervisors', 'salesScript', 'product', + 'leads' => fn ($q) => $q->with('leadStatus'), ]) - ); + )); } public function update(Request $request, Campaign $campaign): JsonResponse @@ -81,28 +99,34 @@ class CampaignController extends Controller 'name' => 'sometimes|string|max:255', 'description' => 'nullable|string', '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', 'end_date' => 'nullable|date|after_or_equal:start_date', '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', + 'sales_script_id' => 'nullable|exists:sales_scripts,id', 'agent_ids' => 'nullable|array', 'agent_ids.*' => 'exists:users,id', 'supervisor_ids' => 'nullable|array', 'supervisor_ids.*' => 'exists:users,id', ]); - $campaign->update($validated); - - if ($request->has('agent_ids')) { - $campaign->assignedAgents()->sync($request->agent_ids); - } - if ($request->has('supervisor_ids')) { - $campaign->assignedSupervisors()->sync($request->supervisor_ids); - } + $this->authorizeAssignments($validated); + DB::transaction(function () use ($campaign, $validated, $request): void { + $campaign->update(collect($validated)->except(['agent_ids', 'supervisor_ids'])->all()); + if ($request->has('agent_ids') || $request->has('supervisor_ids')) { + $agentIds = $request->has('agent_ids') ? ($validated['agent_ids'] ?? []) : $campaign->assignedAgents()->pluck('users.id')->all(); + $supervisorIds = $request->has('supervisor_ids') ? ($validated['supervisor_ids'] ?? []) : $campaign->assignedSupervisors()->pluck('users.id')->all(); + $this->syncAssignments($campaign, $agentIds, $supervisorIds); + } + }); 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 @@ -111,6 +135,38 @@ class CampaignController extends Controller $campaign->delete(); 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()); } } diff --git a/backend/app/Http/Controllers/Api/CompanyController.php b/backend/app/Http/Controllers/Api/CompanyController.php index 86877d9..8a13b8b 100644 --- a/backend/app/Http/Controllers/Api/CompanyController.php +++ b/backend/app/Http/Controllers/Api/CompanyController.php @@ -9,6 +9,7 @@ use App\Services\DuplicateService; use App\Support\AccessControl; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; class CompanyController extends Controller { @@ -16,23 +17,31 @@ class CompanyController extends Controller public function index(Request $request): JsonResponse { + Gate::authorize('viewAny', Company::class); $query = Company::with('owner:id,name')->withCount(['contacts', 'leads', 'deals']); - $this->scope($query); + AccessControl::scopeCompanies($query, auth()->user()); if ($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))); } public function store(Request $request): JsonResponse { + Gate::authorize('create', Company::class); $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) { return response()->json(['message' => 'شرکت مشابهی در سیستم وجود دارد.', 'duplicates' => $suggestions], 422); } @@ -41,13 +50,17 @@ class CompanyController extends Controller ActivityLogger::log('company_created', "Company {$company->name} created", $company); $payload = $company->load('owner:id,name')->toArray(); - if ($suggestions) $payload['duplicate_suggestions'] = $suggestions; + if ($suggestions) { + $payload['duplicate_suggestions'] = $suggestions; + } + return response()->json($payload, 201); } public function show(Company $company): JsonResponse { - $this->authorizeAccess($company); + Gate::authorize('view', $company); + return response()->json($company->load([ '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 { - $this->authorizeAccess($company); + Gate::authorize('update', $company); $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)); ActivityLogger::log('company_updated', "Company {$company->name} updated", $company); + return response()->json($company->fresh('owner:id,name')); } public function destroy(Company $company): JsonResponse { - $this->authorizeAccess($company, true); + Gate::authorize('delete', $company); $company->delete(); ActivityLogger::log('company_deleted', "Company {$company->id} deleted"); + return response()->json(['message' => 'شرکت حذف شد']); } public function merge(Request $request, Company $company): JsonResponse { - abort_unless(auth()->user()?->hasRole('admin') || auth()->user()?->can('merge_duplicates'), 403, 'شما مجوز ادغام رکوردهای تکراری را ندارید.'); - $validated = $request->validate(['target_id' => 'required|exists:companies,id|different:' . $company->id]); + Gate::authorize('update', $company); + 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']); + Gate::authorize('update', $target); + return response()->json($this->duplicates->mergeCompanies($company, $target, auth()->id())); } private function validated(Request $request, bool $partial = false): array { $sometimes = $partial ? 'sometimes|' : ''; + return $request->validate([ - 'name' => $sometimes . 'required|string|max:255', + 'name' => $sometimes.'required|string|max:255', 'company_type' => 'nullable|string|max:80', 'industry' => 'nullable|string|max:120', 'city' => 'nullable|string|max:120', @@ -96,26 +118,13 @@ class CompanyController extends Controller private function prepare(array $data): array { - if (array_key_exists('name', $data)) $data['normalized_name'] = DuplicateService::normalizeName($data['name']); - if (array_key_exists('website', $data)) $data['normalized_website'] = DuplicateService::normalizeWebsite($data['website']); + if (array_key_exists('name', $data)) { + $data['normalized_name'] = DuplicateService::normalizeName($data['name']); + } + if (array_key_exists('website', $data)) { + $data['normalized_website'] = DuplicateService::normalizeWebsite($data['website']); + } + 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, 'دسترسی غیرمجاز'); - } } diff --git a/backend/app/Http/Controllers/Api/ConfigurationController.php b/backend/app/Http/Controllers/Api/ConfigurationController.php new file mode 100644 index 0000000..f362ed6 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ConfigurationController.php @@ -0,0 +1,142 @@ + 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; + } +} diff --git a/backend/app/Http/Controllers/Api/ContactController.php b/backend/app/Http/Controllers/Api/ContactController.php index ebb365f..5935e9e 100644 --- a/backend/app/Http/Controllers/Api/ContactController.php +++ b/backend/app/Http/Controllers/Api/ContactController.php @@ -3,8 +3,10 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\Company; use App\Models\Contact; use App\Models\ContactPhone; +use App\Models\Deal; use App\Models\Lead; use App\Services\ActivityLogger; use App\Support\AccessControl; @@ -12,20 +14,31 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; +use Illuminate\Validation\ValidationException; class ContactController extends Controller { 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'); + AccessControl::scopeContacts($query, auth()->user()); - if ($request->lead_id) $query->where('lead_id', $request->lead_id); - 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->lead_id) { + $query->where('lead_id', $request->lead_id); + } + 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) { $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))); @@ -33,9 +46,12 @@ class ContactController extends Controller public function storeStandalone(Request $request): JsonResponse { + Gate::authorize('create', Contact::class); $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); + 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['lead_id'] = $lead->id; + $this->authorizeParents($validated, 'update'); $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]); } @@ -67,16 +84,14 @@ class ContactController extends Controller public function setPrimary(Request $request, Contact $contact): JsonResponse { - if ($contact->lead) { - $this->authorizeLeadAccess($contact->lead); - } + Gate::authorize('update', $contact); $validated = $request->validate([ 'reason' => 'nullable|string|max:255', ]); 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([ 'is_primary' => true, 'primary_reason' => $validated['reason'] ?? 'انتخاب دستی توسط کاربر', @@ -90,27 +105,54 @@ class ContactController extends Controller public function update(Request $request, Contact $contact): JsonResponse { + Gate::authorize('update', $contact); $validated = $this->validateContact($request, true); - $contact->update($validated); - if ($request->has('phones')) { - $contact->phones()->delete(); - foreach ($validated['phones'] ?? [] as $phone) { - ContactPhone::create([ - 'contact_id' => $contact->id, - 'phone' => $phone['phone'], - 'type' => $phone['type'] ?? 'mobile', - 'status' => $phone['status'] ?? 'active', - ]); + $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')) { + $keptIds = []; + foreach ($validated['phones'] ?? [] as $phone) { + 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, + 'phone' => $phone['phone'], + 'type' => $phone['type'] ?? 'mobile', + '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); + return response()->json($contact->fresh('phones.lastCaller')); } public function destroy(Contact $contact): JsonResponse { + Gate::authorize('delete', $contact); $contact->delete(); ActivityLogger::log('contact_deleted', "Contact {$contact->id} deleted"); + return response()->json(['message' => 'مخاطب حذف شد']); } @@ -119,14 +161,43 @@ class ContactController extends Controller 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 { $sometimes = $partial ? 'sometimes|' : ''; + return $request->validate([ - 'lead_id' => 'nullable|exists:leads,id', - 'company_id' => 'nullable|exists:companies,id', - 'deal_id' => 'nullable|exists:deals,id', - 'name' => $sometimes . 'required|string|max:255', + 'lead_id' => $sometimes.'nullable|exists:leads,id', + 'company_id' => $sometimes.'nullable|exists:companies,id', + 'deal_id' => $sometimes.'nullable|exists:deals,id', + 'name' => $sometimes.'required|string|max:255', 'first_name' => 'nullable|string|max:120', 'last_name' => 'nullable|string|max:120', 'role' => 'nullable|string|max:80', @@ -137,8 +208,9 @@ class ContactController extends Controller 'status' => 'nullable|string|max:30', 'is_primary' => 'nullable|boolean', '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.*.id' => 'nullable|integer|exists:contact_phones,id', 'phones.*.type' => 'nullable|string|in:mobile,landline,extension', 'phones.*.status' => 'nullable|string|max:30', ]); @@ -160,10 +232,14 @@ class ContactController extends Controller 'description' => $validated['description'] ?? null, 'status' => $validated['status'] ?? 'active', '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(), ]); + if ($contact->is_primary) { + $this->primaryScope($contact)->whereKeyNot($contact->id)->update(['is_primary' => false]); + } + foreach ($validated['phones'] as $phone) { ContactPhone::create([ 'contact_id' => $contact->id, @@ -175,4 +251,20 @@ class ContactController extends Controller 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); + } } diff --git a/backend/app/Http/Controllers/Api/DashboardController.php b/backend/app/Http/Controllers/Api/DashboardController.php index 9f1ed37..803b034 100644 --- a/backend/app/Http/Controllers/Api/DashboardController.php +++ b/backend/app/Http/Controllers/Api/DashboardController.php @@ -3,9 +3,11 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\Team; +use App\Models\User; use App\Services\DashboardService; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; class DashboardController extends Controller { @@ -13,13 +15,14 @@ class DashboardController extends Controller public function admin(): JsonResponse { + Gate::authorize('view-admin-dashboard'); $stats = $this->dashboardService->admin(); // Agent ranking - $agents = \App\Models\User::role('agent') + $agents = User::role('agent') ->withCount(['assignedLeads', 'calls']) ->get() - ->map(fn($agent) => [ + ->map(fn ($agent) => [ 'id' => $agent->id, 'name' => $agent->name, 'leads_count' => $agent->assigned_leads_count, @@ -35,12 +38,13 @@ class DashboardController extends Controller public function supervisor(): JsonResponse { + Gate::authorize('view-supervisor-dashboard'); $teamId = auth()->user()->teams->first()?->id; $stats = $this->dashboardService->supervisor($teamId); // Team members - $team = \App\Models\Team::with('members')->find($teamId); - $stats['team_members'] = $team?->members->map(fn($member) => [ + $team = Team::with('members')->find($teamId); + $stats['team_members'] = $team?->members->map(fn ($member) => [ 'id' => $member->id, 'name' => $member->name, '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 { + Gate::authorize('view-agent-dashboard'); $stats = $this->dashboardService->agent(); + return response()->json($stats); } } diff --git a/backend/app/Http/Controllers/Api/DealController.php b/backend/app/Http/Controllers/Api/DealController.php index bf65ff2..17a12a5 100644 --- a/backend/app/Http/Controllers/Api/DealController.php +++ b/backend/app/Http/Controllers/Api/DealController.php @@ -3,85 +3,114 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\Company; +use App\Models\Contact; use App\Models\Deal; +use App\Models\Lead; +use App\Models\Product; use App\Services\ActivityLogger; use App\Support\AccessControl; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; class DealController extends Controller { 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'); - $this->scope($query); - foreach (['status', 'sales_stage', 'company_id', 'lead_id', 'owner_id', 'product_id'] as $filter) { - if ($request->filled($filter)) $query->where($filter, $request->get($filter)); + Gate::authorize('viewAny', Deal::class); + $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'); + AccessControl::scopeDeals($query, auth()->user()); + 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))); } 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); + return response()->json($deal->load('company', 'contact', 'product', 'owner'), 201); } public function show(Deal $deal): JsonResponse { - $this->authorizeAccess($deal); - return response()->json($deal->load('company', 'lead', 'contact.phones', 'product.salesScript.sections', 'owner:id,name', 'timelineNotes.user:id,name', 'attachments.uploader:id,name')); + Gate::authorize('view', $deal); + + 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 { - $this->authorizeAccess($deal); - $deal->update($this->validated($request, true)); + Gate::authorize('update', $deal); + $validated = $this->validated($request, true); + $this->authorizeRelations($validated); + $deal->update($validated); ActivityLogger::log('deal_updated', "Deal {$deal->title} updated", $deal); + return response()->json($deal->fresh('company', 'contact', 'product', 'owner')); } public function destroy(Deal $deal): JsonResponse { - $this->authorizeAccess($deal, true); + Gate::authorize('delete', $deal); $deal->delete(); ActivityLogger::log('deal_deleted', "Deal {$deal->id} deleted"); + return response()->json(['message' => 'فرصت فروش حذف شد']); } private function validated(Request $request, bool $partial = false): array { $sometimes = $partial ? 'sometimes|' : ''; + return $request->validate([ - 'title' => $sometimes . 'required|string|max:255', + 'title' => $sometimes.'required|string|max:255', 'company_id' => 'nullable|exists:companies,id', 'lead_id' => 'nullable|exists:leads,id', 'contact_id' => 'nullable|exists:contacts,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', + 'final_amount' => 'nullable|numeric|min:0', 'win_probability' => 'nullable|integer|min:0|max:100', 'sales_stage' => 'nullable|string|max:80', 'expected_close_date' => 'nullable|date', 'owner_id' => 'nullable|exists:users,id', 'status' => 'nullable|string|max:40', 'won_lost_reason' => 'nullable|string|max:255', + 'competitor' => 'nullable|string|max:255', + 'forecast_category' => 'nullable|in:pipeline,best_case,commit,closed', 'notes' => 'nullable|string', ]); } - private function scope($query): void + private function authorizeRelations(array $validated): void { $user = auth()->user(); - if ($user?->hasRole('admin')) return; - if ($user?->hasRole('agent')) $query->where('owner_id', $user->id); - if ($user?->hasRole('supervisor')) $query->whereIn('owner_id', array_merge([$user->id], AccessControl::teamMemberIds($user))); - } - - 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, 'دسترسی غیرمجاز'); + if (array_key_exists('owner_id', $validated)) { + abort_unless(AccessControl::canAssignUser($user, $validated['owner_id']), 403, 'مالک انتخاب‌شده خارج از محدوده مجاز است.'); + } + 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'])); + } } } diff --git a/backend/app/Http/Controllers/Api/DuplicateController.php b/backend/app/Http/Controllers/Api/DuplicateController.php index 7ab341e..20f14ad 100644 --- a/backend/app/Http/Controllers/Api/DuplicateController.php +++ b/backend/app/Http/Controllers/Api/DuplicateController.php @@ -24,8 +24,8 @@ class DuplicateController extends Controller ]); $items = $validated['entity_type'] === 'company' - ? $this->duplicates->companySuggestions($validated, $validated['exclude_id'] ?? null) - : $this->duplicates->leadSuggestions($validated, $validated['exclude_id'] ?? null); + ? $this->duplicates->companySuggestions($validated, $validated['exclude_id'] ?? null, auth()->user()) + : $this->duplicates->leadSuggestions($validated, $validated['exclude_id'] ?? null, auth()->user()); return response()->json(['data' => $items]); } diff --git a/backend/app/Http/Controllers/Api/FollowUpController.php b/backend/app/Http/Controllers/Api/FollowUpController.php index aa72f6d..1887a9a 100644 --- a/backend/app/Http/Controllers/Api/FollowUpController.php +++ b/backend/app/Http/Controllers/Api/FollowUpController.php @@ -3,184 +3,150 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Resources\FollowUpResource; +use App\Http\Responses\ApiResponse; use App\Models\FollowUp; use App\Models\Lead; use App\Services\ActivityLogger; -use App\Services\NotificationService; +use App\Services\FollowUpService; use App\Support\AccessControl; -use App\Support\WorkingHours; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; class FollowUpController extends Controller { + public function __construct(private FollowUpService $service) {} + public function index(Request $request): JsonResponse { - $user = auth()->user(); 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); - - 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)); + return ApiResponse::paginated($paginator, fn (FollowUp $followUp) => $this->resource($followUp)); } public function store(Request $request): JsonResponse { $validated = $request->validate([ 'lead_id' => 'required|exists:leads,id', + 'user_id' => 'nullable|exists:users,id', + 'call_id' => 'nullable|exists:calls,id', 'scheduled_at' => 'required|date', 'notes' => 'nullable|string', ]); $lead = Lead::findOrFail($validated['lead_id']); Gate::authorize('create', [FollowUp::class, $lead]); - if (!WorkingHours::followUpAllowed($validated['scheduled_at'])) { - return response()->json(['message' => 'زمان پیگیری خارج از روز یا ساعت کاری مجاز است.'], 422); - } + $assigneeId = (int) ($validated['user_id'] ?? $request->user()->id); + abort_unless(AccessControl::canAssignUser($request->user(), $assigneeId), 403, 'مسئول انتخاب‌شده خارج از محدوده مجاز است.'); - $followUp = FollowUp::create([ - 'lead_id' => $validated['lead_id'], - 'user_id' => auth()->id(), - 'scheduled_at' => $validated['scheduled_at'], - 'notes' => $validated['notes'] ?? null, - 'status' => 'pending', - ]); + $followUp = $this->service->schedule( + $lead, + $assigneeId, + $validated['scheduled_at'], + $request->user(), + $validated['notes'] ?? null, + $validated['call_id'] ?? null, + ); - ActivityLogger::log('follow_up_created', "Follow-up for lead {$validated['lead_id']} scheduled", $followUp); - NotificationService::notifyFollowUpReminder($followUp->user_id, $lead->full_name ?: ($lead->company ?? "لید {$lead->id}")); + return ApiResponse::success($this->resource($followUp), 201, 'پیگیری ایجاد شد.'); + } - return response()->json($followUp->load('lead'), 201); + public function show(FollowUp $followUp): JsonResponse + { + Gate::authorize('view', $followUp); + + return ApiResponse::success($this->resource($followUp->load('lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name'))); } public function update(Request $request, FollowUp $followUp): JsonResponse { Gate::authorize('update', $followUp); - $validated = $request->validate([ + 'user_id' => 'sometimes|exists:users,id', 'scheduled_at' => 'sometimes|date', 'notes' => 'nullable|string', '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); - - ActivityLogger::log('follow_up_updated', "Follow-up {$followUp->id} updated", $followUp); - - return response()->json($followUp->load('lead')); + return ApiResponse::success($this->resource($followUp), message: 'پیگیری ویرایش شد.'); } public function markDone(FollowUp $followUp): JsonResponse { - Gate::authorize('update', $followUp); - - $followUp->update([ - 'status' => 'completed', - 'completed_at' => now(), - ]); - + Gate::authorize('complete', $followUp); + $followUp->update(['status' => 'completed', 'completed_at' => now(), 'is_overdue' => false]); 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(); - $query = FollowUp::with('lead:id,first_name,last_name,phone') + Gate::authorize('delete', $followUp); + $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()) ->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(); - 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); + return ApiResponse::paginated($paginator, fn (FollowUp $followUp) => $this->resource($followUp)); } - public function overdue(): JsonResponse + public function overdue(Request $request): JsonResponse { - $user = auth()->user(); - $query = FollowUp::with('lead:id,first_name,last_name,phone') + Gate::authorize('viewAny', FollowUp::class); + $query = FollowUp::with('lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name') ->where('status', 'pending') - ->where(function ($q) { - $q->where('is_overdue', true) - ->orWhere('scheduled_at', '<', now()); - }); - AccessControl::scopeFollowUps($query, $user); + ->where(fn ($overdue) => $overdue->where('is_overdue', true)->orWhere('scheduled_at', '<', now())); + 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)); - 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 (!$user) { - return false; + if ($request->filled('status')) { + $query->where('status', $request->get('status')); } - - if ($user->hasRole('admin')) { - return true; + if ($request->filled('lead_id')) { + $query->where('lead_id', $request->integer('lead_id')); } - - if ($user->hasRole('agent')) { - return $followUp->user_id === $user->id; + if ($request->filled('user_id')) { + $query->where('user_id', $request->integer('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); + 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')); } - - return false; } - private function canAccessLead(\App\Models\Lead $lead): bool + private function resource(FollowUp $followUp): array { - $user = auth()->user(); - - 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; + return (new FollowUpResource($followUp))->resolve(request()); } } diff --git a/backend/app/Http/Controllers/Api/ImportController.php b/backend/app/Http/Controllers/Api/ImportController.php index 0728365..032c648 100644 --- a/backend/app/Http/Controllers/Api/ImportController.php +++ b/backend/app/Http/Controllers/Api/ImportController.php @@ -5,12 +5,13 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\ImportBatch; use App\Models\Setting; -use App\Services\ImportService; use App\Services\ActivityLogger; +use App\Services\ImportService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Storage; +use Maatwebsite\Excel\Facades\Excel; use PhpOffice\PhpSpreadsheet\Spreadsheet; 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'; $maxMb = max(1, (int) (Setting::where('key', 'import_max_file_size_mb')->value('value') ?: 10)); $request->validate([ - 'file' => 'required|file|mimes:' . $allowedTypes . '|max:' . ($maxMb * 1024), + 'file' => 'required|file|mimes:'.$allowedTypes.'|max:'.($maxMb * 1024), ], [ 'file.mimes' => 'نوع فایل import مجاز نیست.', 'file.max' => "حجم فایل import نباید بیشتر از {$maxMb} مگابایت باشد.", @@ -46,7 +47,7 @@ class ImportController extends Controller $preview = $this->importService->preview($fullPath); // Store rows for later processing - $rows = \Maatwebsite\Excel\Facades\Excel::toArray([], $fullPath)[0] ?? []; + $rows = Excel::toArray([], $fullPath)[0] ?? []; $dataRows = array_slice($rows, 1); foreach ($dataRows as $index => $row) { @@ -68,6 +69,7 @@ class ImportController extends Controller ]); } catch (\Exception $e) { $batch->update(['status' => 'failed', 'errors' => $e->getMessage()]); + return response()->json(['message' => $e->getMessage()], 422); } } @@ -127,6 +129,7 @@ class ImportController extends Controller $this->importService->rollback($batchId); ActivityLogger::log('lead_import_rolled_back', "Import batch {$batchId} rolled back", $batch); + return response()->json(['message' => 'بازگشت import انجام شد']); } @@ -145,7 +148,7 @@ class ImportController extends Controller { Gate::authorize('import', ImportBatch::class); - $spreadsheet = new Spreadsheet(); + $spreadsheet = new Spreadsheet; $sheet = $spreadsheet->getActiveSheet(); $sheet->fromArray([ ['company', 'phone', 'phone_secondary', 'first_name', 'last_name', 'email', 'city', 'province', 'source', 'product_interest', 'priority', 'lead_score', 'interest_level', 'notes', 'tags'], diff --git a/backend/app/Http/Controllers/Api/IntelligenceController.php b/backend/app/Http/Controllers/Api/IntelligenceController.php new file mode 100644 index 0000000..4d1c7c0 --- /dev/null +++ b/backend/app/Http/Controllers/Api/IntelligenceController.php @@ -0,0 +1,88 @@ +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'])); + } +} diff --git a/backend/app/Http/Controllers/Api/InvoiceController.php b/backend/app/Http/Controllers/Api/InvoiceController.php new file mode 100644 index 0000000..40f863c --- /dev/null +++ b/backend/app/Http/Controllers/Api/InvoiceController.php @@ -0,0 +1,383 @@ +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, + ]; + } +} diff --git a/backend/app/Http/Controllers/Api/LeadController.php b/backend/app/Http/Controllers/Api/LeadController.php index f61ef52..d6f9b9e 100644 --- a/backend/app/Http/Controllers/Api/LeadController.php +++ b/backend/app/Http/Controllers/Api/LeadController.php @@ -3,11 +3,15 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\Contact; +use App\Models\ContactPhone; use App\Models\Lead; +use App\Models\LeadAssignment; use App\Models\PipelineStage; use App\Models\Setting; use App\Models\User; use App\Services\ActivityLogger; +use App\Services\AutomationDispatcher; use App\Services\LeadService; use App\Support\AccessControl; use Illuminate\Http\JsonResponse; @@ -17,7 +21,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse; 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 { @@ -76,7 +80,7 @@ class LeadController extends Controller public function export(Request $request): StreamedResponse|JsonResponse { $user = auth()->user(); - if (!$user?->hasRole('admin') && !$user?->can('export_leads')) { + if (! $user?->hasRole('admin') && ! $user?->can('export_leads')) { return response()->json(['message' => 'شما مجوز خروجی گرفتن از لیدها را ندارید.'], 403); } @@ -168,11 +172,11 @@ class LeadController extends Controller if ($user?->hasRole('agent')) { $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']); $teamIds = AccessControl::teamIds($user); $assigneeTeamIds = $assignee?->teams()->pluck('teams.id')->all() ?? []; - if (!array_intersect($teamIds, $assigneeTeamIds)) { + if (! array_intersect($teamIds, $assigneeTeamIds)) { return response()->json(['message' => 'دسترسی غیرمجاز'], 403); } } @@ -183,7 +187,7 @@ class LeadController extends Controller ?? PipelineStage::where('is_default', true)->value('id') ?? PipelineStage::orderBy('sort_order')->value('id'); $assignedTo = $validated['assigned_to'] ?? null; - if (!$assignedTo) { + if (! $assignedTo) { $strategy = Setting::where('key', 'assignment_strategy')->value('value') ?? 'round_robin'; if ($strategy !== 'manual' || auth()->user()?->hasRole('admin')) { $assignedTo = User::role('agent') @@ -206,9 +210,9 @@ class LeadController extends Controller $lead = Lead::create($validated); - $contact = \App\Models\Contact::create([ + $contact = Contact::create([ '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' => 'رابط', 'description' => 'مخاطب اولیه لید', 'status' => 'active', @@ -218,7 +222,7 @@ class LeadController extends Controller ]); foreach (array_filter([$validated['phone'] ?? null, $validated['phone_secondary'] ?? null]) as $phone) { - \App\Models\ContactPhone::create([ + ContactPhone::create([ 'contact_id' => $contact->id, 'phone' => $phone, 'type' => 'mobile', @@ -226,8 +230,8 @@ class LeadController extends Controller ]); } - if (!empty($validated['assigned_to'])) { - \App\Models\LeadAssignment::create([ + if (! empty($validated['assigned_to'])) { + LeadAssignment::create([ 'lead_id' => $lead->id, 'user_id' => $validated['assigned_to'], 'assigned_by' => auth()->id(), @@ -236,6 +240,7 @@ class LeadController extends Controller } 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(); if ($duplicateLead && in_array($duplicatePolicy, ['warn', 'merge_suggestion'], true)) { @@ -262,8 +267,8 @@ class LeadController extends Controller 'callLogs.contact:id,name,role', 'callLogs.phone:id,phone,type,status', 'callLogs.user:id,name', - 'calls' => fn($q) => $q->with('contact:id,name,role', 'contactPhone:id,phone,type,status')->latest(), - 'followUps' => fn($q) => $q->latest(), + 'calls' => fn ($q) => $q->with('contact:id,name,role', 'contactPhone:id,phone,type,status')->latest(), + 'followUps' => fn ($q) => $q->latest(), 'notes.user:id,name', 'attachments.uploader:id,name', ]) @@ -325,7 +330,7 @@ class LeadController extends Controller ]); $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); } @@ -346,27 +351,27 @@ class LeadController extends Controller $lead = Lead::where('assigned_to', $user->id) ->where(function ($q) { $q->whereNull('last_call_at') - ->orWhere('next_follow_up_at', '<=', now()); + ->orWhere('next_follow_up_at', '<=', now()); }) ->orderBy('priority', 'desc') ->orderBy('created_at', 'asc') ->first(); - if (!$lead) { + if (! $lead) { $lead = Lead::where('assigned_to', $user->id) ->orderBy('priority', 'desc') ->orderBy('last_call_at', 'asc') ->first(); } - if (!$lead) { + if (! $lead) { return response()->json(['message' => 'لیدی برای تماس وجود ندارد'], 404); } ActivityLogger::log('lead_viewed', "Lead {$lead->id} viewed as next lead", $lead); 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(); - if (!$user) { + if (! $user) { return false; } @@ -388,6 +393,7 @@ class LeadController extends Controller if ($user->hasRole('supervisor')) { $teamIds = $user->teams->pluck('id')->toArray(); + return in_array($lead->team_id, $teamIds, true) || $lead->assigned_to === $user->id; } diff --git a/backend/app/Http/Controllers/Api/LeadStatusController.php b/backend/app/Http/Controllers/Api/LeadStatusController.php index 1cb85c3..fae5b25 100644 --- a/backend/app/Http/Controllers/Api/LeadStatusController.php +++ b/backend/app/Http/Controllers/Api/LeadStatusController.php @@ -40,7 +40,7 @@ class LeadStatusController extends Controller { $validated = $request->validate([ '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', 'icon' => 'nullable|string|max:255', 'sort_order' => 'nullable|integer', @@ -61,6 +61,7 @@ class LeadStatusController extends Controller { $leadStatus->delete(); ActivityLogger::log('lead_status_deleted', "Status {$leadStatus->name} deleted"); + return response()->json(['message' => 'وضعیت حذف شد']); } diff --git a/backend/app/Http/Controllers/Api/LostReasonController.php b/backend/app/Http/Controllers/Api/LostReasonController.php index 6db174c..8750d12 100644 --- a/backend/app/Http/Controllers/Api/LostReasonController.php +++ b/backend/app/Http/Controllers/Api/LostReasonController.php @@ -35,7 +35,7 @@ class LostReasonController extends Controller { $validated = $request->validate([ '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', 'sort_order' => 'nullable|integer', 'is_active' => 'nullable|boolean', diff --git a/backend/app/Http/Controllers/Api/MediaController.php b/backend/app/Http/Controllers/Api/MediaController.php new file mode 100644 index 0000000..9d6342e --- /dev/null +++ b/backend/app/Http/Controllers/Api/MediaController.php @@ -0,0 +1,27 @@ +exists($path), 404); + + return response()->file(Storage::disk('public')->path($path), [ + 'Cache-Control' => 'public, max-age=86400, immutable', + 'X-Content-Type-Options' => 'nosniff', + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/NoteController.php b/backend/app/Http/Controllers/Api/NoteController.php index f89ef17..11ae464 100644 --- a/backend/app/Http/Controllers/Api/NoteController.php +++ b/backend/app/Http/Controllers/Api/NoteController.php @@ -3,48 +3,126 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; -use App\Models\Company; -use App\Models\Deal; -use App\Models\Lead; +use App\Http\Requests\StoreCallNoteRequest; +use App\Http\Requests\UpdateNoteRequest; +use App\Http\Resources\NoteResource; +use App\Http\Responses\ApiResponse; +use App\Models\Call; use App\Models\Note; use App\Services\ActivityLogger; +use App\Support\EntityResolver; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; 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 { $validated = $request->validate([ 'entity_type' => 'required|in:lead,company,deal', 'entity_id' => 'required|integer', - 'content' => 'required|string', + 'content' => 'required|string|max:5000', ]); + $model = EntityResolver::authorize($validated['entity_type'], $validated['entity_id'], 'update'); + $note = DB::transaction(function () use ($model, $validated): Note { + $note = $model->notes()->create([ + 'user_id' => auth()->id(), + 'content' => $validated['content'], + 'type' => 'general', + 'visibility' => 'team', + ]); + ActivityLogger::log('note_created', 'یادداشت جدید ثبت شد', $note, null, $note->only(['type', 'visibility'])); - $model = $this->resolve($validated['entity_type'], $validated['entity_id']); - $note = $model->notes()->create([ - 'user_id' => auth()->id(), - 'content' => $validated['content'], - ]); + return $note; + }); - ActivityLogger::log('note_created', 'یادداشت جدید ثبت شد', $model); - 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 { - abort_unless(auth()->id() === $note->user_id || auth()->user()?->hasRole('admin'), 403, 'دسترسی غیرمجاز'); - $note->delete(); - ActivityLogger::log('note_deleted', "Note {$note->id} deleted"); - return response()->json(['message' => 'یادداشت حذف شد']); + Gate::authorize('delete', $note); + DB::transaction(function () use ($note): void { + $before = $note->only(['type', 'visibility', 'is_pinned']); + $note->delete(); + ActivityLogger::log('call_note_deleted', "Note {$note->id} deleted", $note, $before, ['deleted' => true]); + }); + + return ApiResponse::success(null, message: 'یادداشت حذف شد.'); } - private function resolve(string $type, int $id): Lead|Company|Deal + public function pin(Note $note): JsonResponse { - return match ($type) { - 'lead' => Lead::findOrFail($id), - 'company' => Company::findOrFail($id), - 'deal' => Deal::findOrFail($id), - }; + return $this->setPinned($note, true); + } + + 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()); } } diff --git a/backend/app/Http/Controllers/Api/NotificationController.php b/backend/app/Http/Controllers/Api/NotificationController.php index ce00618..503a928 100644 --- a/backend/app/Http/Controllers/Api/NotificationController.php +++ b/backend/app/Http/Controllers/Api/NotificationController.php @@ -3,47 +3,83 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Resources\NotificationResource; +use App\Http\Responses\ApiResponse; use App\Models\Notification; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; class NotificationController extends Controller { - public function index(): JsonResponse + public function index(Request $request): JsonResponse { - $notifications = Notification::where('user_id', auth()->id()) - ->orderBy('created_at', 'desc') + $query = Notification::where('user_id', auth()->id()); + 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); - return response()->json($notifications); + return ApiResponse::paginated($paginator, fn (Notification $notification) => $this->resource($notification)); } public function markRead(Notification $notification): JsonResponse { - if ($notification->user_id !== auth()->id()) { - return response()->json(['message' => 'دسترسی غیرمجاز'], 403); - } + Gate::authorize('update', $notification); + $notification->update(['is_read' => true, 'read_at' => $notification->read_at ?? now()]); - $notification->update(['is_read' => true]); - - return response()->json($notification); + return ApiResponse::success($this->resource($notification), message: 'اعلان خوانده شد.'); } public function markAllRead(): JsonResponse { Notification::where('user_id', auth()->id()) ->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 { - $count = Notification::where('user_id', auth()->id()) - ->where('is_read', false) - ->count(); + $count = Notification::where('user_id', auth()->id())->where('is_read', false)->whereNull('archived_at')->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()); } } diff --git a/backend/app/Http/Controllers/Api/PipelineController.php b/backend/app/Http/Controllers/Api/PipelineController.php new file mode 100644 index 0000000..7702567 --- /dev/null +++ b/backend/app/Http/Controllers/Api/PipelineController.php @@ -0,0 +1,84 @@ +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); + } +} diff --git a/backend/app/Http/Controllers/Api/PipelineStageController.php b/backend/app/Http/Controllers/Api/PipelineStageController.php index 30e93c8..0797cc2 100644 --- a/backend/app/Http/Controllers/Api/PipelineStageController.php +++ b/backend/app/Http/Controllers/Api/PipelineStageController.php @@ -3,16 +3,19 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; -use App\Models\FollowUp; +use App\Models\Lead; +use App\Models\LeadStatus; use App\Models\PipelineStage; -use App\Services\NotificationService; use App\Services\ActivityLogger; +use App\Services\FollowUpService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; class PipelineStageController extends Controller { + public function __construct(private FollowUpService $followUps) {} + public function index(): JsonResponse { return response()->json( @@ -48,7 +51,7 @@ class PipelineStageController extends Controller { $validated = $request->validate([ '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', 'sort_order' => 'nullable|integer', 'is_active' => 'nullable|boolean', @@ -69,6 +72,7 @@ class PipelineStageController extends Controller { $pipelineStage->delete(); ActivityLogger::log('pipeline_stage_deleted', "Stage {$pipelineStage->name} deleted"); + return response()->json(['message' => 'مرحله حذف شد']); } @@ -77,6 +81,7 @@ class PipelineStageController extends Controller $validated = $request->validate([ 'lead_id' => 'required|exists:leads,id', 'next_follow_up_at' => 'nullable|date', + 'follow_up_notes' => 'nullable|string|max:2000', 'final_result' => 'nullable|in:موفق,ناموفق', 'lost_reason' => 'nullable|string|max:255', 'deal_value' => 'nullable|numeric|min:0', @@ -86,7 +91,7 @@ class PipelineStageController extends Controller 'customer_notes' => 'nullable|string', ]); - $lead = \App\Models\Lead::findOrFail($validated['lead_id']); + $lead = Lead::findOrFail($validated['lead_id']); Gate::authorize('changeStage', $lead); $update = ['pipeline_stage_id' => $pipelineStage->id]; @@ -100,7 +105,7 @@ class PipelineStageController extends Controller $request->validate(['final_result' => 'required|in:موفق,ناموفق']); $update['final_result'] = $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) === 'ناموفق') { @@ -125,17 +130,14 @@ class PipelineStageController extends Controller $lead->update($update); if ($pipelineStage->requires_follow_up) { - FollowUp::create([ - 'lead_id' => $lead->id, - 'user_id' => $lead->assigned_to ?? auth()->id(), - 'scheduled_at' => $validated['next_follow_up_at'], - 'notes' => 'ثبت شده از قیف فروش', - 'status' => 'pending', - ]); - - if ($lead->assigned_to) { - NotificationService::notifyFollowUpReminder($lead->assigned_to, $lead->company ?? $lead->full_name); - } + $this->followUps->schedule( + $lead, + $lead->assigned_to ?? auth()->id(), + $validated['next_follow_up_at'], + $request->user(), + $validated['follow_up_notes'] ?? null, + source: 'pipeline', + ); } ActivityLogger::log('lead_stage_changed', "Lead {$lead->id} moved to stage {$pipelineStage->name}", $lead); diff --git a/backend/app/Http/Controllers/Api/ProductController.php b/backend/app/Http/Controllers/Api/ProductController.php index 5fc2f19..f22989a 100644 --- a/backend/app/Http/Controllers/Api/ProductController.php +++ b/backend/app/Http/Controllers/Api/ProductController.php @@ -7,51 +7,64 @@ use App\Models\Product; use App\Services\ActivityLogger; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; class ProductController extends Controller { public function index(Request $request): JsonResponse { + Gate::authorize('viewAny', Product::class); $query = Product::with('salesScript:id,title')->withCount('deals'); - if ($request->search) $query->where('name', 'like', "%{$request->search}%"); - if ($request->filled('is_active')) $query->where('is_active', filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN)); + if ($request->search) { + $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))); } public function store(Request $request): JsonResponse { - $this->authorizeManage(); + Gate::authorize('create', Product::class); $product = Product::create($this->validated($request) + ['created_by' => auth()->id()]); ActivityLogger::log('product_created', "Product {$product->name} created", $product); + return response()->json($product->load('salesScript:id,title'), 201); } public function show(Product $product): JsonResponse { + Gate::authorize('view', $product); + return response()->json($product->load('salesScript.sections', 'deals:id,title,product_id,status')); } public function update(Request $request, Product $product): JsonResponse { - $this->authorizeManage(); + Gate::authorize('update', $product); $product->update($this->validated($request, true)); ActivityLogger::log('product_updated', "Product {$product->name} updated", $product); + return response()->json($product->fresh('salesScript:id,title')); } public function destroy(Product $product): JsonResponse { - $this->authorizeManage(); + Gate::authorize('delete', $product); $product->delete(); ActivityLogger::log('product_deleted', "Product {$product->id} deleted"); + return response()->json(['message' => 'محصول/خدمت حذف شد']); } private function validated(Request $request, bool $partial = false): array { $sometimes = $partial ? 'sometimes|' : ''; + return $request->validate([ - 'name' => $sometimes . 'required|string|max:255', + 'name' => $sometimes.'required|string|max:255', 'category' => 'nullable|string|max:120', 'base_price' => 'nullable|numeric|min:0', 'description' => 'nullable|string', @@ -61,9 +74,4 @@ class ProductController extends Controller 'objection_handling' => 'nullable|array', ]); } - - private function authorizeManage(): void - { - abort_unless(auth()->user()?->hasRole('admin') || auth()->user()?->hasRole('supervisor') || auth()->user()?->can('manage_products'), 403, 'شما مجوز مدیریت محصولات را ندارید.'); - } } diff --git a/backend/app/Http/Controllers/Api/QualityReviewController.php b/backend/app/Http/Controllers/Api/QualityReviewController.php index 6a6086d..9a59e89 100644 --- a/backend/app/Http/Controllers/Api/QualityReviewController.php +++ b/backend/app/Http/Controllers/Api/QualityReviewController.php @@ -3,75 +3,187 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\Call; use App\Models\QualityReview; use App\Services\ActivityLogger; +use App\Support\AccessControl; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; 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 { - $user = auth()->user(); - $query = QualityReview::with('call:id,lead_id,created_at', 'agent:id,name', 'reviewer:id,name'); + Gate::authorize('viewAny', QualityReview::class); - if ($user->hasRole('agent')) { - $query->where('agent_id', $user->id); - } elseif ($user->hasRole('supervisor')) { - $teamIds = $user->teams->pluck('id')->toArray(); - $agentIds = \App\Models\Team::whereIn('id', $teamIds)->with('members')->get()->pluck('members.*.id')->flatten(); - $query->whereIn('agent_id', $agentIds); + if ($request->has('current_only')) { + $request->merge([ + 'current_only' => filter_var( + $request->input('current_only'), + FILTER_VALIDATE_BOOLEAN, + 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 { - $validated = $request->validate([ + $validated = $request->validate(array_merge([ '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', 'tag' => 'nullable|string|max:30', - ]); + 'is_shared_with_agent' => 'nullable|boolean', + 'strengths' => 'nullable|array|max:20', + 'improvement_areas' => 'nullable|array|max:20', + ], $this->scoreRules())); - $validated['reviewer_id'] = auth()->id(); - $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()); + $call = Call::findOrFail($validated['call_id']); + Gate::authorize('create', [QualityReview::class, $call]); - $review = QualityReview::create($validated); + $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]); - ActivityLogger::log('quality_review_created', "Quality review for agent {$validated['agent_id']} created", $review); + 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), + ]); + }); - return response()->json($review->load('agent:id,name', 'reviewer:id,name'), 201); + ActivityLogger::log('quality_review_created', "Quality review for call {$call->id} created", $review); + + return response()->json($review->load('call:id,lead_id,user_id,created_at', 'agent:id,name', 'reviewer:id,name'), 201); } 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 { - $validated = $request->validate([ + Gate::authorize('update', $qualityReview); + $validated = $request->validate(array_merge([ 'feedback' => 'nullable|string', 'tag' => 'nullable|string|max:30', '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); + 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()); } } diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php index 961a733..5c75944 100644 --- a/backend/app/Http/Controllers/Api/ReportController.php +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -3,14 +3,21 @@ namespace App\Http\Controllers\Api; 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\User; -use App\Models\Campaign; use App\Services\ActivityLogger; use App\Services\ReportService; use App\Support\AccessControl; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Gate; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -18,6 +25,75 @@ class ReportController extends Controller { 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 { Gate::authorize('view-report-data'); @@ -29,14 +105,14 @@ class ReportController extends Controller ]); $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); } if (empty($validated['agent_id'])) { $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, $validated['date_from'] ?? null, $validated['date_to'] ?? null @@ -55,6 +131,7 @@ class ReportController extends Controller public function teamPerformance(Request $request): JsonResponse { Gate::authorize('view-report-data'); + abort_if($request->user()->hasRole('agent'), 403, 'کارشناس فقط به گزارش عملکرد خود دسترسی دارد.'); $validated = $request->validate([ 'team_id' => 'nullable|exists:teams,id', @@ -63,14 +140,14 @@ class ReportController extends Controller ]); $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); } if (empty($validated['team_id'])) { $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, $validated['date_from'] ?? null, $validated['date_to'] ?? null @@ -89,6 +166,7 @@ class ReportController extends Controller public function campaignReport(Request $request, int $campaignId): JsonResponse { Gate::authorize('view-report-data'); + abort_if($request->user()->hasRole('agent'), 403, 'کارشناس فقط به گزارش عملکرد خود دسترسی دارد.'); $validated = $request->validate([ 'date_from' => 'nullable|date', @@ -185,7 +263,9 @@ class ReportController extends Controller Gate::authorize('view-report-data'); $validated = $this->validateScopedReport($request); $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)); } @@ -195,7 +275,9 @@ class ReportController extends Controller Gate::authorize('view-report-data'); $validated = $this->validateScopedReport($request); $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)); } @@ -205,7 +287,9 @@ class ReportController extends Controller Gate::authorize('view-report-data'); $validated = $this->validateScopedReport($request); $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)); } @@ -213,6 +297,7 @@ class ReportController extends Controller public function importQuality(Request $request): JsonResponse { Gate::authorize('view-report-data'); + abort_if($request->user()->hasRole('agent'), 403, 'کارشناس فقط به گزارش عملکرد خود دسترسی دارد.'); $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)); @@ -223,7 +308,9 @@ class ReportController extends Controller Gate::authorize('view-report-data'); $validated = $this->validateScopedReport($request); $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)); } @@ -233,7 +320,9 @@ class ReportController extends Controller Gate::authorize('view-report-data'); $validated = $this->validateScopedReport($request); $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)); } @@ -243,18 +332,23 @@ class ReportController extends Controller Gate::authorize('export-report-data'); $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); if ($agentIds === false) { 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); } ActivityLogger::log('report_exported', "Report export requested: {$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), '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) : [], @@ -271,7 +365,7 @@ class ReportController extends Controller }; $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) { $out = fopen('php://output', 'w'); @@ -280,11 +374,12 @@ class ReportController extends Controller fputcsv($out, ['message'], ','); fputcsv($out, ['داده‌ای برای خروجی وجود ندارد'], ','); fclose($out); + return; } fputcsv($out, array_keys($rows[0]), ','); 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); }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']); @@ -336,9 +431,9 @@ class ReportController extends Controller } /** - * @return list|false + * @return list|false|null */ - private function requestedAgentScope(?int $agentId): array|false + private function requestedAgentScope(?int $agentId): array|false|null { $allowedAgentIds = $this->allowedAgentIds(); @@ -346,28 +441,30 @@ class ReportController extends Controller 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 { - if ($data instanceof \Illuminate\Support\Collection) { + if ($data instanceof Collection) { $data = $data->toArray(); } - if ($data instanceof \Illuminate\Database\Eloquent\Model) { + if ($data instanceof Model) { $data = $data->toArray(); } 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)) { 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])) { - 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 []; } } diff --git a/backend/app/Http/Controllers/Api/RoleController.php b/backend/app/Http/Controllers/Api/RoleController.php index b5a7cf3..5392d58 100644 --- a/backend/app/Http/Controllers/Api/RoleController.php +++ b/backend/app/Http/Controllers/Api/RoleController.php @@ -6,14 +6,15 @@ use App\Http\Controllers\Controller; use App\Services\ActivityLogger; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Permission; +use Spatie\Permission\Models\Role; class RoleController extends Controller { public function index(): JsonResponse { $roles = Role::with('permissions')->orderBy('name')->get(); + return response()->json($roles); } @@ -44,7 +45,7 @@ class RoleController extends Controller public function update(Request $request, Role $role): JsonResponse { $validated = $request->validate([ - 'name' => 'sometimes|string|unique:roles,name,' . $role->id, + 'name' => 'sometimes|string|unique:roles,name,'.$role->id, 'permissions' => 'sometimes|array', 'permissions.*' => 'exists:permissions,name', ]); diff --git a/backend/app/Http/Controllers/Api/ScriptController.php b/backend/app/Http/Controllers/Api/ScriptController.php index dc56b8c..5228db2 100644 --- a/backend/app/Http/Controllers/Api/ScriptController.php +++ b/backend/app/Http/Controllers/Api/ScriptController.php @@ -3,73 +3,111 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\Campaign; +use App\Models\Product; use App\Models\SalesScript; +use App\Models\User; use App\Services\ActivityLogger; +use App\Services\NotificationService; +use App\Support\AccessControl; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; class ScriptController extends Controller { - public function index(): JsonResponse + public function index(Request $request): JsonResponse { - return response()->json( - SalesScript::with('sections', 'campaign:id,name') - ->orderBy('created_at', 'desc') - ->paginate(15) - ); + Gate::authorize('viewAny', SalesScript::class); + $query = SalesScript::with('sections', 'campaign:id,name,sales_script_id', 'product:id,name,sales_script_id', 'assignees:id,name'); + if (! $request->user()->can('manage_scripts')) { + $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 { - $validated = $request->validate([ - 'title' => 'required|string|max:255', - 'description' => 'nullable|string', - 'campaign_id' => 'nullable|exists:campaigns,id', - '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', - ]); + Gate::authorize('create', SalesScript::class); + $validated = $request->validate($this->rules()); + $this->authorizeParents($validated); + $this->authorizeAssignees($validated['assigned_user_ids'] ?? [], $request->user()); - $script = SalesScript::create([ - 'title' => $validated['title'], - 'description' => $validated['description'] ?? null, - 'campaign_id' => $validated['campaign_id'] ?? null, - 'product_id' => $validated['product_id'] ?? null, - 'version' => $validated['version'] ?? '1.0', - 'is_active' => true, - 'checklist' => $validated['checklist'] ?? null, - 'objection_handling' => $validated['objection_handling'] ?? null, - ]); + $script = DB::transaction(function () use ($validated, $request): SalesScript { + $script = SalesScript::create(collect($validated)->except(['sections', 'campaign_id', 'product_id', 'assigned_user_ids'])->all()); + $this->syncSections($script, $validated['sections'] ?? []); + $this->syncParents($script, $validated); + $this->syncAssignees($script, $validated['assigned_user_ids'] ?? [], $request->user()); - if ($request->sections) { - foreach ($validated['sections'] as $i => $section) { - $script->sections()->create([ - 'title' => $section['title'], - 'content' => $section['content'], - 'sort_order' => $section['sort_order'] ?? $i, - ]); - } - } + return $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 { - 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 { - $validated = $request->validate([ - 'title' => 'sometimes|string|max:255', + Gate::authorize('update', $script); + $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', 'campaign_id' => 'nullable|exists:campaigns,id', 'product_id' => 'nullable|exists:products,id', @@ -77,52 +115,83 @@ class ScriptController extends Controller 'is_active' => 'nullable|boolean', 'checklist' => '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.*.id' => 'nullable|exists:script_sections,id', + 'sections.*.id' => 'nullable|integer|exists:script_sections,id', 'sections.*.title' => 'required|string|max:255', 'sections.*.content' => 'required|string', - 'sections.*.sort_order' => 'nullable|integer', - ]); + 'sections.*.sort_order' => 'nullable|integer|min:0|distinct', + ]; + } - $script->update($validated); + private function authorizeParents(array $validated): void + { + if (! empty($validated['campaign_id'])) { + Gate::authorize('update', Campaign::findOrFail($validated['campaign_id'])); + } + if (! empty($validated['product_id'])) { + Gate::authorize('update', Product::findOrFail($validated['product_id'])); + } + } - 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); - if (!empty($toDelete)) { - $script->sections()->whereIn('id', $toDelete)->delete(); + 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]); } } - - ActivityLogger::log('script_updated', "Script {$script->title} updated", $script); - - return response()->json($script->load('sections')); } - public function destroy(SalesScript $script): JsonResponse + private function syncSections(SalesScript $script, array $sections): void { - $script->delete(); - ActivityLogger::log('script_deleted', "Script {$script->title} deleted"); - return response()->json(['message' => 'اسکریپت حذف شد']); + $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', + ]); + } } } diff --git a/backend/app/Http/Controllers/Api/SettingController.php b/backend/app/Http/Controllers/Api/SettingController.php index b7c04de..828b4bd 100644 --- a/backend/app/Http/Controllers/Api/SettingController.php +++ b/backend/app/Http/Controllers/Api/SettingController.php @@ -20,8 +20,11 @@ class SettingController extends Controller { Gate::authorize('viewAny', Setting::class); 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) { $meta = SettingsCatalog::metaFor($setting->key) ?? []; if ($setting->is_secret) { @@ -32,10 +35,11 @@ class SettingController extends Controller 'label' => $meta['label'] ?? $setting->key, 'hint' => $meta['hint'] ?? $setting->description, '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'); + return response()->json($settings); } @@ -91,9 +95,13 @@ class SettingController extends Controller public function public(): JsonResponse { SettingsCatalog::ensureDefaults(); - $settings = Setting::where('is_public', true)->get() - ->filter(fn(Setting $setting) => !$setting->is_secret) - ->mapWithKeys(fn(Setting $setting) => [$setting->key => $setting->effectiveValue()]); + $publicRuntimeKeys = collect(SettingsCatalog::definitions()) + ->where('is_runtime_enforced', true) + ->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); } @@ -101,27 +109,34 @@ class SettingController extends Controller public function testVoip(): JsonResponse { Gate::authorize('viewAny', Setting::class); - $provider = Setting::where('key', 'voip_provider')->value('value') ?: 'mock'; + $provider = Setting::where('key', 'voip_provider')->value('value') ?: 'none'; $missing = []; 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) { - if (!Setting::where('key', $key)->value('value')) $missing[] = $key; + if (! Setting::where('key', $key)->value('value')) { + $missing[] = $key; + } } } if ($provider === 'api') { 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') { 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"); if ($missing === []) { $result = $this->voipManager->testConnection(); + return response()->json([ 'ok' => (bool) ($result['ok'] ?? false), 'provider' => $provider, @@ -131,13 +146,34 @@ class SettingController extends Controller } return response()->json([ - 'ok' => !$missing, + 'ok' => ! $missing, 'provider' => $provider, 'message' => $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 { $type = $payload['type'] ?? $existing?->type ?? 'string'; @@ -152,7 +188,7 @@ class SettingController extends Controller } if ($existing?->allowed_values) { - $rules[] = 'in:' . implode(',', $existing->allowed_values); + $rules[] = 'in:'.implode(',', $existing->allowed_values); } Validator::make(['value' => $value], ['value' => $rules])->validate(); diff --git a/backend/app/Http/Controllers/Api/TaskController.php b/backend/app/Http/Controllers/Api/TaskController.php new file mode 100644 index 0000000..e432439 --- /dev/null +++ b/backend/app/Http/Controllers/Api/TaskController.php @@ -0,0 +1,168 @@ +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']; + } +} diff --git a/backend/app/Http/Controllers/Api/TimelineController.php b/backend/app/Http/Controllers/Api/TimelineController.php index 0811246..cf6c333 100644 --- a/backend/app/Http/Controllers/Api/TimelineController.php +++ b/backend/app/Http/Controllers/Api/TimelineController.php @@ -4,13 +4,13 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\ActivityLog; -use App\Models\Call; use App\Models\CallLog; use App\Models\Company; use App\Models\Deal; use App\Models\FollowUp; use App\Models\Lead; use App\Models\Note; +use App\Support\EntityResolver; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -23,21 +23,23 @@ class TimelineController extends Controller 'entity_id' => 'required|integer', ]); + EntityResolver::authorize($validated['entity_type'], $validated['entity_id']); + [$class, $id] = [$this->classFor($validated['entity_type']), $validated['entity_id']]; $items = collect(); 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() - ->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) { 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() - ->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([ diff --git a/backend/app/Http/Controllers/Api/UserController.php b/backend/app/Http/Controllers/Api/UserController.php index 57e8679..1bc01b2 100644 --- a/backend/app/Http/Controllers/Api/UserController.php +++ b/backend/app/Http/Controllers/Api/UserController.php @@ -3,17 +3,80 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Responses\ApiResponse; use App\Models\User; -use App\Models\Team; use App\Services\ActivityLogger; +use App\Support\AccessControl; +use App\Support\EntityResolver; use App\Support\PasswordPolicy; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Hash; 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 { Gate::authorize('viewAny', User::class); @@ -23,9 +86,9 @@ class UserController extends Controller if ($request->search) { $query->where(function ($q) use ($request) { $q->where('name', 'like', "%{$request->search}%") - ->orWhere('email', 'like', "%{$request->search}%") - ->orWhere('phone', 'like', "%{$request->search}%") - ->orWhere('voip_extension', 'like', "%{$request->search}%"); + ->orWhere('email', 'like', "%{$request->search}%") + ->orWhere('phone', 'like', "%{$request->search}%") + ->orWhere('voip_extension', 'like', "%{$request->search}%"); }); } @@ -34,7 +97,7 @@ class UserController extends Controller } 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')) { @@ -54,6 +117,17 @@ class UserController extends Controller 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( User::role('agent') ->where('is_active', true) @@ -104,10 +178,10 @@ class UserController extends Controller $validated = $request->validate([ '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()], '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', ]); @@ -141,7 +215,7 @@ class UserController extends Controller { 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}"); diff --git a/backend/app/Http/Controllers/Api/VoipWebhookController.php b/backend/app/Http/Controllers/Api/VoipWebhookController.php new file mode 100644 index 0000000..717f62e --- /dev/null +++ b/backend/app/Http/Controllers/Api/VoipWebhookController.php @@ -0,0 +1,52 @@ +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]); + } +} diff --git a/backend/app/Http/Controllers/Api/WorkspaceController.php b/backend/app/Http/Controllers/Api/WorkspaceController.php new file mode 100644 index 0000000..5b74439 --- /dev/null +++ b/backend/app/Http/Controllers/Api/WorkspaceController.php @@ -0,0 +1,107 @@ +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()]); + } +} diff --git a/backend/app/Http/Middleware/LogActivity.php b/backend/app/Http/Middleware/LogActivity.php index bf16a9c..88d7067 100644 --- a/backend/app/Http/Middleware/LogActivity.php +++ b/backend/app/Http/Middleware/LogActivity.php @@ -2,10 +2,10 @@ namespace App\Http\Middleware; +use App\Services\ActivityLogger; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; -use App\Services\ActivityLogger; class LogActivity { @@ -14,7 +14,7 @@ class LogActivity $response = $next($request); if ($request->method() !== 'GET' && auth()->check()) { - $action = $request->method() . ' ' . $request->path(); + $action = $request->method().' '.$request->path(); $description = null; if (str_contains($request->path(), 'login')) { diff --git a/backend/app/Http/Middleware/MaskPhoneNumber.php b/backend/app/Http/Middleware/MaskPhoneNumber.php index fabb661..8728de9 100644 --- a/backend/app/Http/Middleware/MaskPhoneNumber.php +++ b/backend/app/Http/Middleware/MaskPhoneNumber.php @@ -14,14 +14,14 @@ class MaskPhoneNumber { $response = $next($request); - if (!$response instanceof JsonResponse) { + if (! $response instanceof JsonResponse) { return $response; } $user = auth()->user(); $maskPhones = Setting::where('key', 'phone_mask_enabled')->value('value') !== 'false' - && (!$user || !$user->can('view_full_phone')); - $maskRecordings = !$user || (!$user->hasRole('admin') && !$user->can('listen_recordings')); + && (! $user || ! $user->can('view_full_phone')); + $maskRecordings = ! $user || (! $user->hasRole('admin') && ! $user->can('listen_recordings')); if ($maskPhones || $maskRecordings) { $data = $response->getData(true); @@ -52,7 +52,10 @@ class MaskPhoneNumber if (Setting::where('key', 'phone_mask_level')->value('value') === 'full') { return '********'; } - if (strlen($phone) < 7) return $phone; - return substr($phone, 0, 4) . ' *** **' . substr($phone, -2); + if (strlen($phone) < 7) { + return $phone; + } + + return substr($phone, 0, 4).' *** **'.substr($phone, -2); } } diff --git a/backend/app/Http/Middleware/SecurityHeaders.php b/backend/app/Http/Middleware/SecurityHeaders.php index 606a794..a8fe3a8 100644 --- a/backend/app/Http/Middleware/SecurityHeaders.php +++ b/backend/app/Http/Middleware/SecurityHeaders.php @@ -23,12 +23,12 @@ class SecurityHeaders ]; foreach ($headers as $name => $value) { - if (!$response->headers->has($name)) { + if (! $response->headers->has($name)) { $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('Pragma', 'no-cache'); $response->headers->set('Expires', '0'); diff --git a/backend/app/Http/Requests/AssignTaskRequest.php b/backend/app/Http/Requests/AssignTaskRequest.php new file mode 100644 index 0000000..b2d422e --- /dev/null +++ b/backend/app/Http/Requests/AssignTaskRequest.php @@ -0,0 +1,21 @@ + 'required|integer|exists:users,id', + 'version' => 'required|integer|min:1', + ]; + } +} diff --git a/backend/app/Http/Requests/StoreCallNoteRequest.php b/backend/app/Http/Requests/StoreCallNoteRequest.php new file mode 100644 index 0000000..002b117 --- /dev/null +++ b/backend/app/Http/Requests/StoreCallNoteRequest.php @@ -0,0 +1,22 @@ + 'required|string|max:5000', + 'type' => 'nullable|string|in:general,call_summary,objection,commitment,internal', + 'visibility' => 'nullable|string|in:private,team,organization', + ]; + } +} diff --git a/backend/app/Http/Requests/StoreTaskRequest.php b/backend/app/Http/Requests/StoreTaskRequest.php new file mode 100644 index 0000000..3096560 --- /dev/null +++ b/backend/app/Http/Requests/StoreTaskRequest.php @@ -0,0 +1,43 @@ + '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', 'زمان یادآوری نمی‌تواند بعد از موعد کار باشد.'); + } + }]; + } +} diff --git a/backend/app/Http/Requests/TaskIndexRequest.php b/backend/app/Http/Requests/TaskIndexRequest.php new file mode 100644 index 0000000..62ab165 --- /dev/null +++ b/backend/app/Http/Requests/TaskIndexRequest.php @@ -0,0 +1,35 @@ + ['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', + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateNoteRequest.php b/backend/app/Http/Requests/UpdateNoteRequest.php new file mode 100644 index 0000000..6349db9 --- /dev/null +++ b/backend/app/Http/Requests/UpdateNoteRequest.php @@ -0,0 +1,22 @@ + 'sometimes|required|string|max:5000', + 'type' => 'sometimes|string|in:general,call_summary,objection,commitment,internal', + 'visibility' => 'sometimes|string|in:private,team,organization', + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateTaskRequest.php b/backend/app/Http/Requests/UpdateTaskRequest.php new file mode 100644 index 0000000..c42093d --- /dev/null +++ b/backend/app/Http/Requests/UpdateTaskRequest.php @@ -0,0 +1,43 @@ + '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', 'زمان یادآوری نمی‌تواند بعد از موعد کار باشد.'); + } + }]; + } +} diff --git a/backend/app/Http/Resources/CallResource.php b/backend/app/Http/Resources/CallResource.php new file mode 100644 index 0000000..c5ede1d --- /dev/null +++ b/backend/app/Http/Resources/CallResource.php @@ -0,0 +1,40 @@ + $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(), + ]; + } +} diff --git a/backend/app/Http/Resources/CampaignResource.php b/backend/app/Http/Resources/CampaignResource.php new file mode 100644 index 0000000..8b81e6c --- /dev/null +++ b/backend/app/Http/Resources/CampaignResource.php @@ -0,0 +1,45 @@ + $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(), + ]; + } +} diff --git a/backend/app/Http/Resources/FollowUpResource.php b/backend/app/Http/Resources/FollowUpResource.php new file mode 100644 index 0000000..69bcc92 --- /dev/null +++ b/backend/app/Http/Resources/FollowUpResource.php @@ -0,0 +1,41 @@ +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(), + ]; + } +} diff --git a/backend/app/Http/Resources/NoteResource.php b/backend/app/Http/Resources/NoteResource.php new file mode 100644 index 0000000..955958c --- /dev/null +++ b/backend/app/Http/Resources/NoteResource.php @@ -0,0 +1,34 @@ +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), + ], + ]; + } +} diff --git a/backend/app/Http/Resources/NotificationResource.php b/backend/app/Http/Resources/NotificationResource.php new file mode 100644 index 0000000..d590cec --- /dev/null +++ b/backend/app/Http/Resources/NotificationResource.php @@ -0,0 +1,26 @@ + $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(), + ]; + } +} diff --git a/backend/app/Http/Resources/TaskResource.php b/backend/app/Http/Resources/TaskResource.php new file mode 100644 index 0000000..8fc9f4b --- /dev/null +++ b/backend/app/Http/Resources/TaskResource.php @@ -0,0 +1,72 @@ +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()); + } +} diff --git a/backend/app/Http/Responses/ApiResponse.php b/backend/app/Http/Responses/ApiResponse.php new file mode 100644 index 0000000..1f4ce90 --- /dev/null +++ b/backend/app/Http/Responses/ApiResponse.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/backend/app/Jobs/SendTaskReminders.php b/backend/app/Jobs/SendTaskReminders.php new file mode 100644 index 0000000..20a40e7 --- /dev/null +++ b/backend/app/Jobs/SendTaskReminders.php @@ -0,0 +1,17 @@ +sendDueNotifications(); + } +} diff --git a/backend/app/Models/ActivityLog.php b/backend/app/Models/ActivityLog.php index f9731a2..d7bf282 100644 --- a/backend/app/Models/ActivityLog.php +++ b/backend/app/Models/ActivityLog.php @@ -10,8 +10,14 @@ class ActivityLog extends Model protected $fillable = [ 'user_id', 'action', 'description', '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 { return $this->belongsTo(User::class); diff --git a/backend/app/Models/AutomationRule.php b/backend/app/Models/AutomationRule.php new file mode 100644 index 0000000..1ab416c --- /dev/null +++ b/backend/app/Models/AutomationRule.php @@ -0,0 +1,35 @@ + '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); + } +} diff --git a/backend/app/Models/AutomationRun.php b/backend/app/Models/AutomationRun.php new file mode 100644 index 0000000..0531f41 --- /dev/null +++ b/backend/app/Models/AutomationRun.php @@ -0,0 +1,27 @@ + '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(); + } +} diff --git a/backend/app/Models/Call.php b/backend/app/Models/Call.php index 9ccf87f..6d457ff 100644 --- a/backend/app/Models/Call.php +++ b/backend/app/Models/Call.php @@ -5,17 +5,25 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphMany; class Call extends Model { protected $fillable = [ 'lead_id', 'contact_id', 'contact_phone_id', 'user_id', 'direction', 'phone', 'duration', 'result', 'notes', 'provider_call_id', 'recording_url', 'is_manual', + 'provider_status', 'started_at', 'ended_at', 'provider_payload', ]; 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 @@ -35,11 +43,21 @@ class Call extends Model public function contactPhone(): BelongsTo { - return $this->belongsTo(ContactPhone::class); + return $this->belongsTo(ContactPhone::class)->withTrashed(); } public function qualityReview(): HasOne { 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'); + } } diff --git a/backend/app/Models/Campaign.php b/backend/app/Models/Campaign.php index c664114..b301489 100644 --- a/backend/app/Models/Campaign.php +++ b/backend/app/Models/Campaign.php @@ -3,8 +3,10 @@ namespace App\Models; 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\MorphMany; class Campaign extends Model { @@ -12,9 +14,13 @@ class Campaign extends Model 'name', 'description', 'product_service', + 'product_id', + 'channel', 'start_date', 'end_date', 'target', + 'budget', + 'actual_cost', 'status', 'sales_script_id', ]; @@ -25,6 +31,8 @@ class Campaign extends Model 'start_date' => 'date', 'end_date' => 'date', 'target' => 'integer', + 'budget' => 'decimal:2', + 'actual_cost' => 'decimal:2', ]; } @@ -32,6 +40,7 @@ class Campaign extends Model { return $this->belongsToMany(User::class) ->wherePivot('role', 'agent') + ->withPivotValue('role', 'agent') ->withPivot('role'); } @@ -39,6 +48,7 @@ class Campaign extends Model { return $this->belongsToMany(User::class) ->wherePivot('role', 'supervisor') + ->withPivotValue('role', 'supervisor') ->withPivot('role'); } @@ -46,4 +56,19 @@ class Campaign extends Model { 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'); + } } diff --git a/backend/app/Models/Company.php b/backend/app/Models/Company.php index f830082..7ce2f60 100644 --- a/backend/app/Models/Company.php +++ b/backend/app/Models/Company.php @@ -46,4 +46,14 @@ class Company extends Model { 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'); + } } diff --git a/backend/app/Models/Contact.php b/backend/app/Models/Contact.php index b592b7b..ee0de94 100644 --- a/backend/app/Models/Contact.php +++ b/backend/app/Models/Contact.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; class Contact extends Model { @@ -43,4 +44,14 @@ class Contact extends Model { 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'); + } } diff --git a/backend/app/Models/ContactPhone.php b/backend/app/Models/ContactPhone.php index ae2e867..ff96e4d 100644 --- a/backend/app/Models/ContactPhone.php +++ b/backend/app/Models/ContactPhone.php @@ -5,9 +5,12 @@ 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 ContactPhone extends Model { + use SoftDeletes; + protected $fillable = [ 'contact_id', 'phone', 'type', 'status', 'call_count', 'successful_call_count', 'failed_call_count', 'last_called_at', diff --git a/backend/app/Models/CustomFieldDefinition.php b/backend/app/Models/CustomFieldDefinition.php new file mode 100644 index 0000000..e7a6189 --- /dev/null +++ b/backend/app/Models/CustomFieldDefinition.php @@ -0,0 +1,24 @@ + '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); + } +} diff --git a/backend/app/Models/CustomFieldValue.php b/backend/app/Models/CustomFieldValue.php new file mode 100644 index 0000000..66b896e --- /dev/null +++ b/backend/app/Models/CustomFieldValue.php @@ -0,0 +1,27 @@ + '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(); + } +} diff --git a/backend/app/Models/DashboardPreference.php b/backend/app/Models/DashboardPreference.php new file mode 100644 index 0000000..fdcaad7 --- /dev/null +++ b/backend/app/Models/DashboardPreference.php @@ -0,0 +1,21 @@ + 'array', 'hidden_widgets' => 'array', 'default_filters' => 'array']; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/Deal.php b/backend/app/Models/Deal.php index b2a125d..4204321 100644 --- a/backend/app/Models/Deal.php +++ b/backend/app/Models/Deal.php @@ -12,17 +12,22 @@ class Deal extends Model use SoftDeletes; protected $fillable = [ - 'title', 'company_id', 'lead_id', 'contact_id', 'product_id', - 'estimated_value', 'win_probability', 'sales_stage', 'expected_close_date', - 'owner_id', 'status', 'won_lost_reason', 'notes', 'created_by', + 'title', 'company_id', 'lead_id', 'contact_id', 'product_id', 'pipeline_id', 'deal_stage_id', + 'estimated_value', 'final_amount', 'win_probability', 'sales_stage', 'expected_close_date', + 'last_activity_at', 'closed_at', 'owner_id', 'status', 'won_lost_reason', 'competitor', + 'forecast_category', 'version', 'notes', 'created_by', ]; protected function casts(): array { return [ 'estimated_value' => 'decimal:2', + 'final_amount' => 'decimal:2', 'win_probability' => 'integer', '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'); } + 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 { return $this->morphMany(Note::class, 'notable'); @@ -60,4 +85,9 @@ class Deal extends Model { return $this->morphMany(Attachment::class, 'attachable'); } + + public function tasks(): MorphMany + { + return $this->morphMany(Task::class, 'taskable'); + } } diff --git a/backend/app/Models/DealStage.php b/backend/app/Models/DealStage.php new file mode 100644 index 0000000..e9ca3c7 --- /dev/null +++ b/backend/app/Models/DealStage.php @@ -0,0 +1,27 @@ + '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); + } +} diff --git a/backend/app/Models/DealStageHistory.php b/backend/app/Models/DealStageHistory.php new file mode 100644 index 0000000..f305d2a --- /dev/null +++ b/backend/app/Models/DealStageHistory.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/backend/app/Models/FollowUp.php b/backend/app/Models/FollowUp.php index 0d4a492..aee730b 100644 --- a/backend/app/Models/FollowUp.php +++ b/backend/app/Models/FollowUp.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class FollowUp extends Model { 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', ]; @@ -31,6 +31,11 @@ class FollowUp extends Model return $this->belongsTo(User::class); } + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + public function call(): BelongsTo { return $this->belongsTo(Call::class); diff --git a/backend/app/Models/Invoice.php b/backend/app/Models/Invoice.php new file mode 100644 index 0000000..1f3372f --- /dev/null +++ b/backend/app/Models/Invoice.php @@ -0,0 +1,61 @@ + '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'); + } +} diff --git a/backend/app/Models/InvoiceTemplate.php b/backend/app/Models/InvoiceTemplate.php new file mode 100644 index 0000000..e4c0ced --- /dev/null +++ b/backend/app/Models/InvoiceTemplate.php @@ -0,0 +1,38 @@ + '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); + } +} diff --git a/backend/app/Models/Lead.php b/backend/app/Models/Lead.php index ac7376d..70015d3 100644 --- a/backend/app/Models/Lead.php +++ b/backend/app/Models/Lead.php @@ -15,7 +15,7 @@ class Lead extends Model protected $fillable = [ 'company_id', 'first_name', 'last_name', 'company', 'phone', 'phone_secondary', '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', 'deal_value', 'sold_product', 'contract_date', 'payment_status', 'customer_notes', 'lead_status_id', 'pipeline_stage_id', 'campaign_id', @@ -28,6 +28,8 @@ class Lead extends Model return [ 'priority' => 'integer', 'lead_score' => 'integer', + 'score_breakdown' => 'array', + 'scored_at' => 'datetime', 'call_attempts' => 'integer', 'deal_value' => 'decimal:2', 'is_unassigned' => 'boolean', @@ -39,14 +41,17 @@ class Lead extends Model public function getFullNameAttribute(): string { - return $this->first_name . ' ' . $this->last_name; + return $this->first_name.' '.$this->last_name; } public function getMaskedPhoneAttribute(): string { $phone = $this->phone; - if (strlen($phone) < 7) return $phone; - return substr($phone, 0, 4) . ' *** **' . substr($phone, -2); + if (strlen($phone) < 7) { + return $phone; + } + + return substr($phone, 0, 4).' *** **'.substr($phone, -2); } public function leadStatus(): BelongsTo @@ -124,16 +129,31 @@ class Lead extends Model return $this->morphMany(Attachment::class, 'attachable'); } + public function tasks(): MorphMany + { + return $this->morphMany(Task::class, 'taskable'); + } + public function deals(): HasMany { return $this->hasMany(Deal::class); } + public function customFieldValues(): MorphMany + { + return $this->morphMany(CustomFieldValue::class, 'fieldable'); + } + public function leadAssignments(): HasMany { return $this->hasMany(LeadAssignment::class); } + public function invoices(): HasMany + { + return $this->hasMany(Invoice::class); + } + public function scopeAssignedTo($query, $userId) { return $query->where('assigned_to', $userId); diff --git a/backend/app/Models/Note.php b/backend/app/Models/Note.php index 6fdf0a6..c862417 100644 --- a/backend/app/Models/Note.php +++ b/backend/app/Models/Note.php @@ -5,10 +5,21 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\SoftDeletes; 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 { diff --git a/backend/app/Models/Notification.php b/backend/app/Models/Notification.php index 124c815..78c486b 100644 --- a/backend/app/Models/Notification.php +++ b/backend/app/Models/Notification.php @@ -4,10 +4,13 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; 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'; @@ -16,6 +19,8 @@ class Notification extends Model return [ 'data' => 'array', 'is_read' => 'boolean', + 'read_at' => 'datetime', + 'archived_at' => 'datetime', ]; } diff --git a/backend/app/Models/NotificationPreference.php b/backend/app/Models/NotificationPreference.php new file mode 100644 index 0000000..29716c7 --- /dev/null +++ b/backend/app/Models/NotificationPreference.php @@ -0,0 +1,21 @@ + 'boolean', 'is_muted' => 'boolean']; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/Pipeline.php b/backend/app/Models/Pipeline.php new file mode 100644 index 0000000..1773c61 --- /dev/null +++ b/backend/app/Models/Pipeline.php @@ -0,0 +1,35 @@ + '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); + } +} diff --git a/backend/app/Models/Product.php b/backend/app/Models/Product.php index 90a9f76..7770290 100644 --- a/backend/app/Models/Product.php +++ b/backend/app/Models/Product.php @@ -35,4 +35,9 @@ class Product extends Model { return $this->hasMany(Deal::class); } + + public function campaigns(): HasMany + { + return $this->hasMany(Campaign::class); + } } diff --git a/backend/app/Models/QualityReview.php b/backend/app/Models/QualityReview.php index 1d6c646..f04dc6d 100644 --- a/backend/app/Models/QualityReview.php +++ b/backend/app/Models/QualityReview.php @@ -8,16 +8,24 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class QualityReview extends Model { 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', 'objection_handling_score', 'closing_score', 'crm_accuracy_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 { - 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 diff --git a/backend/app/Models/SalesScript.php b/backend/app/Models/SalesScript.php index 483c090..00e5db7 100644 --- a/backend/app/Models/SalesScript.php +++ b/backend/app/Models/SalesScript.php @@ -3,12 +3,13 @@ namespace App\Models; 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\HasOne; 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 { @@ -16,21 +17,31 @@ class SalesScript extends Model 'is_active' => 'boolean', 'checklist' => '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 { return $this->hasMany(ScriptSection::class); } + + public function assignees(): BelongsToMany + { + return $this->belongsToMany(User::class, 'sales_script_user') + ->withPivot('assigned_by') + ->withTimestamps(); + } } diff --git a/backend/app/Models/SavedView.php b/backend/app/Models/SavedView.php new file mode 100644 index 0000000..a2b95ad --- /dev/null +++ b/backend/app/Models/SavedView.php @@ -0,0 +1,29 @@ + '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); + } +} diff --git a/backend/app/Models/SlaBreach.php b/backend/app/Models/SlaBreach.php new file mode 100644 index 0000000..ce1765d --- /dev/null +++ b/backend/app/Models/SlaBreach.php @@ -0,0 +1,32 @@ + '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(); + } +} diff --git a/backend/app/Models/SlaRule.php b/backend/app/Models/SlaRule.php new file mode 100644 index 0000000..09b297a --- /dev/null +++ b/backend/app/Models/SlaRule.php @@ -0,0 +1,27 @@ + '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); + } +} diff --git a/backend/app/Models/Task.php b/backend/app/Models/Task.php new file mode 100644 index 0000000..54231ab --- /dev/null +++ b/backend/app/Models/Task.php @@ -0,0 +1,79 @@ + 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; + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 9c903f9..ff40224 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -10,14 +10,13 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Illuminate\Support\Facades\Storage; use Laravel\Sanctum\HasApiTokens; use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable { /** @use HasFactory */ - use HasFactory, Notifiable, HasApiTokens, HasRoles, SoftDeletes; + use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes; protected $fillable = [ 'name', 'email', 'password', 'phone', 'voip_extension', 'avatar', 'is_active', @@ -66,30 +65,52 @@ class User extends Authenticatable 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 { 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 { return Attribute::get(function (): ?string { - if (!$this->avatar) { + if (! $this->avatar) { return null; } $path = parse_url($this->avatar, PHP_URL_PATH) ?: $this->avatar; - $path = ltrim($path, '/'); + $filename = basename(str_replace('\\', '/', $path)); - if (str_starts_with($path, 'storage/')) { - $path = substr($path, strlen('storage/')); - } - - if (str_starts_with($path, 'public/')) { - $path = substr($path, strlen('public/')); - } - - return url(Storage::url($path)); + return '/api/media/avatars/'.rawurlencode($filename); }); } } diff --git a/backend/app/Notifications/TaskAssignedNotification.php b/backend/app/Notifications/TaskAssignedNotification.php new file mode 100644 index 0000000..05f9fdb --- /dev/null +++ b/backend/app/Notifications/TaskAssignedNotification.php @@ -0,0 +1,33 @@ +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, + ] + ); + } +} diff --git a/backend/app/Notifications/TaskDueNotification.php b/backend/app/Notifications/TaskDueNotification.php new file mode 100644 index 0000000..3a75cca --- /dev/null +++ b/backend/app/Notifications/TaskDueNotification.php @@ -0,0 +1,37 @@ +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, + ] + ); + } +} diff --git a/backend/app/Policies/AttachmentPolicy.php b/backend/app/Policies/AttachmentPolicy.php new file mode 100644 index 0000000..319222a --- /dev/null +++ b/backend/app/Policies/AttachmentPolicy.php @@ -0,0 +1,21 @@ +attachable); + } + + public function delete(User $user, Attachment $attachment): bool + { + return $this->view($user, $attachment) + && ($user->hasRole('admin') || $attachment->uploaded_by === $user->id); + } +} diff --git a/backend/app/Policies/CallPolicy.php b/backend/app/Policies/CallPolicy.php index 4767ca6..3c5d4be 100644 --- a/backend/app/Policies/CallPolicy.php +++ b/backend/app/Policies/CallPolicy.php @@ -9,6 +9,11 @@ use App\Support\AccessControl; class CallPolicy { + public function before(User $user): ?bool + { + return $user->hasRole('admin') ? true : null; + } + 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'); diff --git a/backend/app/Policies/CampaignPolicy.php b/backend/app/Policies/CampaignPolicy.php index 5f337f1..71eac31 100644 --- a/backend/app/Policies/CampaignPolicy.php +++ b/backend/app/Policies/CampaignPolicy.php @@ -8,9 +8,14 @@ use App\Support\AccessControl; class CampaignPolicy { + public function before(User $user): ?bool + { + return $user->hasRole('admin') ? true : null; + } + 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 @@ -20,12 +25,12 @@ class CampaignPolicy 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 { - 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 diff --git a/backend/app/Policies/CompanyPolicy.php b/backend/app/Policies/CompanyPolicy.php new file mode 100644 index 0000000..e4415e3 --- /dev/null +++ b/backend/app/Policies/CompanyPolicy.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/backend/app/Policies/ContactPolicy.php b/backend/app/Policies/ContactPolicy.php new file mode 100644 index 0000000..681e709 --- /dev/null +++ b/backend/app/Policies/ContactPolicy.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/backend/app/Policies/DashboardPolicy.php b/backend/app/Policies/DashboardPolicy.php new file mode 100644 index 0000000..035fd7a --- /dev/null +++ b/backend/app/Policies/DashboardPolicy.php @@ -0,0 +1,23 @@ +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'); + } +} diff --git a/backend/app/Policies/DealPolicy.php b/backend/app/Policies/DealPolicy.php new file mode 100644 index 0000000..ead33ae --- /dev/null +++ b/backend/app/Policies/DealPolicy.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/backend/app/Policies/FollowUpPolicy.php b/backend/app/Policies/FollowUpPolicy.php index dbcf565..c433dbf 100644 --- a/backend/app/Policies/FollowUpPolicy.php +++ b/backend/app/Policies/FollowUpPolicy.php @@ -11,7 +11,7 @@ class FollowUpPolicy { 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 @@ -21,11 +21,25 @@ class FollowUpPolicy 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 { - 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); } } diff --git a/backend/app/Policies/InvoicePolicy.php b/backend/app/Policies/InvoicePolicy.php new file mode 100644 index 0000000..5c375d2 --- /dev/null +++ b/backend/app/Policies/InvoicePolicy.php @@ -0,0 +1,65 @@ +can('view_invoices') || $user->hasAnyRole(['agent', 'supervisor']); + } + + public function view(User $user, Invoice $invoice): bool + { + if ($user->can('approve_invoices') || $user->hasRole('supervisor')) { + return AccessControl::canAccessLead($user, $invoice->lead); + } + + return $invoice->created_by === $user->id || $invoice->lead?->assigned_to === $user->id; + } + + public function create(User $user): bool + { + return $user->can('create_invoices') || $user->hasAnyRole(['agent', 'supervisor']); + } + + public function update(User $user, Invoice $invoice): bool + { + if ($invoice->created_by === $user->id && $invoice->status === 'rejected') { + return $this->view($user, $invoice); + } + + return ($user->can('approve_invoices') || $user->hasRole('supervisor')) + && AccessControl::canAccessLead($user, $invoice->lead) + && in_array($invoice->status, ['draft', 'pending_approval'], true); + } + + public function issue(User $user, Invoice $invoice): bool + { + return ($user->can('approve_invoices') || $user->hasRole('supervisor')) + && AccessControl::canAccessLead($user, $invoice->lead) + && in_array($invoice->status, ['pending_approval', 'approved'], true); + } + + public function approve(User $user, Invoice $invoice): bool + { + return ($user->can('approve_invoices') || $user->hasRole('supervisor')) + && AccessControl::canAccessLead($user, $invoice->lead) + && $invoice->status === 'pending_approval'; + } + + public function void(User $user, Invoice $invoice): bool + { + return ($user->can('approve_invoices') || $user->hasRole('supervisor')) + && $invoice->status === 'issued'; + } + + public function manageTemplates(User $user): bool + { + return $user->can('manage_invoice_templates') || $user->hasAnyRole(['admin', 'supervisor']); + } +} diff --git a/backend/app/Policies/NotePolicy.php b/backend/app/Policies/NotePolicy.php new file mode 100644 index 0000000..d4d964d --- /dev/null +++ b/backend/app/Policies/NotePolicy.php @@ -0,0 +1,43 @@ +notable)) { + return false; + } + + return $note->visibility !== 'private' || $note->user_id === $user->id || $user->hasRole('admin'); + } + + public function create(User $user, object $notable): bool + { + return $user->can('manage_call_notes') && AccessControl::canAccessEntity($user, $notable); + } + + public function update(User $user, Note $note): bool + { + if (! $user->can('manage_call_notes') || ! $this->view($user, $note)) { + return false; + } + + return $note->user_id === $user->id || $user->hasRole('supervisor') || $user->hasRole('admin'); + } + + public function delete(User $user, Note $note): bool + { + return $this->update($user, $note); + } + + public function pin(User $user, Note $note): bool + { + return $user->can('pin_call_notes') && $this->view($user, $note); + } +} diff --git a/backend/app/Policies/NotificationPolicy.php b/backend/app/Policies/NotificationPolicy.php new file mode 100644 index 0000000..9e662b7 --- /dev/null +++ b/backend/app/Policies/NotificationPolicy.php @@ -0,0 +1,24 @@ +user_id === $user->id; + } + + public function update(User $user, Notification $notification): bool + { + return $this->view($user, $notification); + } + + public function delete(User $user, Notification $notification): bool + { + return $this->view($user, $notification); + } +} diff --git a/backend/app/Policies/ProductPolicy.php b/backend/app/Policies/ProductPolicy.php new file mode 100644 index 0000000..eb1623a --- /dev/null +++ b/backend/app/Policies/ProductPolicy.php @@ -0,0 +1,39 @@ +hasRole('admin') ? true : null; + } + + public function viewAny(User $user): bool + { + return $user->can('view_products'); + } + + public function view(User $user, Product $product): bool + { + return $user->can('view_products'); + } + + public function create(User $user): bool + { + return $user->can('manage_products'); + } + + public function update(User $user, Product $product): bool + { + return $user->can('manage_products'); + } + + public function delete(User $user, Product $product): bool + { + return $user->can('manage_products'); + } +} diff --git a/backend/app/Policies/QualityReviewPolicy.php b/backend/app/Policies/QualityReviewPolicy.php new file mode 100644 index 0000000..e3dd8f3 --- /dev/null +++ b/backend/app/Policies/QualityReviewPolicy.php @@ -0,0 +1,46 @@ +hasRole('admin') ? true : null; + } + + public function viewAny(User $user): bool + { + return $user->can('view_quality_reviews'); + } + + public function view(User $user, QualityReview $review): bool + { + if ($user->hasRole('agent')) { + return $review->agent_id === $user->id && $review->is_shared_with_agent; + } + + return $user->can('view_quality_reviews') + && AccessControl::canAccessCall($user, $review->call); + } + + public function create(User $user, Call $call): bool + { + return $user->hasRole('supervisor') + && $user->can('create_quality_reviews') + && AccessControl::canAccessCall($user, $call); + } + + public function update(User $user, QualityReview $review): bool + { + return $user->hasRole('supervisor') + && $user->can('create_quality_reviews') + && $review->reviewer_id === $user->id + && AccessControl::canAccessCall($user, $review->call); + } +} diff --git a/backend/app/Policies/SalesScriptPolicy.php b/backend/app/Policies/SalesScriptPolicy.php new file mode 100644 index 0000000..e152bc0 --- /dev/null +++ b/backend/app/Policies/SalesScriptPolicy.php @@ -0,0 +1,40 @@ +hasRole('admin') ? true : null; + } + + public function viewAny(User $user): bool + { + return $user->can('view_scripts'); + } + + public function view(User $user, SalesScript $script): bool + { + return $user->can('view_scripts') + && ($user->can('manage_scripts') || ($script->is_active && $script->assignees()->whereKey($user->id)->exists())); + } + + public function create(User $user): bool + { + return $user->can('manage_scripts'); + } + + public function update(User $user, SalesScript $script): bool + { + return $user->can('manage_scripts'); + } + + public function delete(User $user, SalesScript $script): bool + { + return $user->can('manage_scripts'); + } +} diff --git a/backend/app/Policies/TaskPolicy.php b/backend/app/Policies/TaskPolicy.php new file mode 100644 index 0000000..6a825c7 --- /dev/null +++ b/backend/app/Policies/TaskPolicy.php @@ -0,0 +1,62 @@ +can('view_own_tasks') || $user->can('view_team_tasks') || $user->can('view_all_tasks'); + } + + public function view(User $user, Task $task): bool + { + return AccessControl::canAccessTask($user, $task); + } + + public function create(User $user): bool + { + return $user->can('create_tasks'); + } + + public function update(User $user, Task $task): bool + { + return $task->created_by === $user->id + && $this->view($user, $task) + && ($user->can('edit_own_tasks') || $user->can('edit_team_tasks')); + } + + public function assign(User $user, Task $task, ?int $newAssigneeId = null): bool + { + if (! $this->view($user, $task)) { + return false; + } + + $isReassign = $task->assigned_to !== null && $task->assigned_to !== $newAssigneeId; + + return $isReassign ? $user->can('reassign_tasks') : $user->can('assign_tasks'); + } + + public function transition(User $user, Task $task): bool + { + return $user->can('complete_tasks') + && ($task->assigned_to === $user->id || $task->created_by === $user->id) + && $this->view($user, $task); + } + + public function delete(User $user, Task $task): bool + { + return $task->created_by === $user->id + && $user->can('delete_tasks') + && $this->view($user, $task); + } + + public function bulkManage(User $user): bool + { + return $user->can('bulk_manage_tasks'); + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 85ee7ae..ef46277 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,19 +2,42 @@ namespace App\Providers; +use App\Models\Attachment; use App\Models\Call; use App\Models\Campaign; +use App\Models\Company; +use App\Models\Contact; +use App\Models\Deal; use App\Models\FollowUp; use App\Models\ImportBatch; +use App\Models\Invoice; use App\Models\Lead; +use App\Models\Note; +use App\Models\Notification; +use App\Models\Product; +use App\Models\QualityReview; +use App\Models\SalesScript; use App\Models\Setting; +use App\Models\Task; use App\Models\User; +use App\Policies\AttachmentPolicy; use App\Policies\CallPolicy; use App\Policies\CampaignPolicy; +use App\Policies\CompanyPolicy; +use App\Policies\ContactPolicy; +use App\Policies\DashboardPolicy; +use App\Policies\DealPolicy; use App\Policies\FollowUpPolicy; use App\Policies\ImportBatchPolicy; +use App\Policies\InvoicePolicy; use App\Policies\LeadPolicy; +use App\Policies\NotePolicy; +use App\Policies\NotificationPolicy; +use App\Policies\ProductPolicy; +use App\Policies\QualityReviewPolicy; +use App\Policies\SalesScriptPolicy; use App\Policies\SettingPolicy; +use App\Policies\TaskPolicy; use App\Policies\UserPolicy; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; @@ -38,12 +61,26 @@ class AppServiceProvider extends ServiceProvider Gate::policy(Call::class, CallPolicy::class); Gate::policy(FollowUp::class, FollowUpPolicy::class); Gate::policy(Campaign::class, CampaignPolicy::class); + Gate::policy(Company::class, CompanyPolicy::class); + Gate::policy(Contact::class, ContactPolicy::class); + Gate::policy(Deal::class, DealPolicy::class); + Gate::policy(Product::class, ProductPolicy::class); + Gate::policy(Note::class, NotePolicy::class); + Gate::policy(Notification::class, NotificationPolicy::class); + Gate::policy(Attachment::class, AttachmentPolicy::class); + Gate::policy(QualityReview::class, QualityReviewPolicy::class); + Gate::policy(SalesScript::class, SalesScriptPolicy::class); Gate::policy(User::class, UserPolicy::class); Gate::policy(Setting::class, SettingPolicy::class); Gate::policy(ImportBatch::class, ImportBatchPolicy::class); + Gate::policy(Task::class, TaskPolicy::class); + Gate::policy(Invoice::class, InvoicePolicy::class); - Gate::define('view-report-data', fn(User $user) => $user->hasRole('admin') || $user->can('view_reports')); - Gate::define('export-report-data', fn(User $user) => $user->hasRole('admin') || $user->can('export_reports')); - Gate::define('export-sensitive-data', fn(User $user) => $user->hasRole('admin') || $user->can('export_leads') || $user->can('export_reports')); + Gate::define('view-report-data', fn (User $user) => $user->hasRole('admin') || $user->can('view_reports')); + Gate::define('export-report-data', fn (User $user) => $user->hasRole('admin') || $user->can('export_reports')); + Gate::define('export-sensitive-data', fn (User $user) => $user->hasRole('admin') || $user->can('export_leads') || $user->can('export_reports')); + Gate::define('view-admin-dashboard', [DashboardPolicy::class, 'viewAdmin']); + Gate::define('view-supervisor-dashboard', [DashboardPolicy::class, 'viewSupervisor']); + Gate::define('view-agent-dashboard', [DashboardPolicy::class, 'viewAgent']); } } diff --git a/backend/app/Services/ActivityLogger.php b/backend/app/Services/ActivityLogger.php index e922835..3ee5a77 100644 --- a/backend/app/Services/ActivityLogger.php +++ b/backend/app/Services/ActivityLogger.php @@ -4,16 +4,25 @@ namespace App\Services; use App\Models\ActivityLog; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; class ActivityLogger { - public static function log(string $action, ?string $description = null, ?Model $subject = null): void - { + public static function log( + string $action, + ?string $description = null, + ?Model $subject = null, + ?array $before = null, + ?array $after = null, + ): void { try { ActivityLog::create([ 'user_id' => auth()->id(), 'action' => $action, 'description' => $description, + 'before_data' => self::redact($before), + 'after_data' => self::redact($after), + 'request_id' => request()->header('X-Request-ID') ?: (string) Str::uuid(), 'subject_type' => $subject ? get_class($subject) : null, 'subject_id' => $subject?->id, 'ip_address' => request()->ip(), @@ -23,4 +32,21 @@ class ActivityLogger // Silent fail for logging } } + + private static function redact(?array $data): ?array + { + if ($data === null) { + return null; + } + + foreach ($data as $key => $value) { + if (in_array((string) $key, ['password', 'token', 'content', 'notes', 'recording_url'], true)) { + $data[$key] = '[REDACTED]'; + } elseif (is_array($value)) { + $data[$key] = self::redact($value); + } + } + + return $data; + } } diff --git a/backend/app/Services/AssignmentService.php b/backend/app/Services/AssignmentService.php index edf2740..beefa5f 100644 --- a/backend/app/Services/AssignmentService.php +++ b/backend/app/Services/AssignmentService.php @@ -3,9 +3,6 @@ namespace App\Services; use App\Models\Lead; -use App\Models\User; -use App\Models\LeadAssignment; -use Illuminate\Support\Facades\DB; class AssignmentService { @@ -14,6 +11,7 @@ class AssignmentService public function assignToAgent(int $leadId, int $agentId, int $assignedById): Lead { $lead = Lead::findOrFail($leadId); + return $this->leadService->assignLead($lead, $agentId, $assignedById); } @@ -23,18 +21,21 @@ class AssignmentService foreach ($leadIds as $leadId) { $results[] = $this->assignToAgent($leadId, $agentId, $assignedById); } + return $results; } public function roundRobin(array $leadIds, array $agentIds, int $assignedById): array { $leads = Lead::whereIn('id', $leadIds)->get(); + return $this->leadService->roundRobinAssign($leads, $agentIds, $assignedById); } public function assignByCampaign(int $campaignId, array $agentIds, int $assignedById): array { $leads = Lead::where('campaign_id', $campaignId)->where('is_unassigned', true)->get(); + return $this->leadService->roundRobinAssign($leads, $agentIds, $assignedById); } diff --git a/backend/app/Services/AutomationDispatcher.php b/backend/app/Services/AutomationDispatcher.php new file mode 100644 index 0000000..3deee6a --- /dev/null +++ b/backend/app/Services/AutomationDispatcher.php @@ -0,0 +1,58 @@ +getAttribute('team_id'); + $rules = AutomationRule::where('trigger', $trigger)->where('is_active', true) + ->where(fn ($query) => $query->whereNull('team_id')->when($teamId, fn ($team) => $team->orWhere('team_id', $teamId))) + ->get(); + foreach ($rules as $rule) { + if (! $this->matches($rule->conditions ?? [], $subject, $context)) { + continue; + } + $runs = $rule->runs()->where('subject_type', $subject::class)->where('subject_id', $subject->getKey())->count(); + if ($runs >= $rule->max_runs_per_record) { + continue; + } + try { + $this->engine->run($rule, $subject, "automation:{$rule->id}:{$eventKey}", $context); + } catch (\Throwable) { + // The run log contains the failure; business lifecycle must remain available. + } + } + } + + private function matches(array $conditions, Model $subject, array $context): bool + { + foreach ($conditions as $condition) { + $field = $condition['field'] ?? null; + $operator = $condition['operator'] ?? 'equals'; + $expected = $condition['value'] ?? null; + if (! $field) { + return false; + } + $actual = $context[$field] ?? $subject->getAttribute($field); + $matches = match ($operator) { + 'not_equals' => $actual != $expected, + 'greater_than' => is_numeric($actual) && $actual > $expected, + 'less_than' => is_numeric($actual) && $actual < $expected, + 'contains' => is_string($actual) && str_contains($actual, (string) $expected), + default => $actual == $expected, + }; + if (! $matches) { + return false; + } + } + + return true; + } +} diff --git a/backend/app/Services/AutomationEngine.php b/backend/app/Services/AutomationEngine.php new file mode 100644 index 0000000..427a43d --- /dev/null +++ b/backend/app/Services/AutomationEngine.php @@ -0,0 +1,73 @@ +is_active) { + throw ValidationException::withMessages(['rule' => 'قانون غیرفعال است.']); + } + if ($existing = AutomationRun::where('event_key', $eventKey)->first()) { + return $existing; + } + if ($rule->runs()->where('subject_type', $subject::class)->where('subject_id', $subject->getKey())->count() >= $rule->max_runs_per_record) { + throw ValidationException::withMessages(['rule' => 'حداکثر دفعات اجرای این قانون برای رکورد تکمیل شده است.']); + } + + return DB::transaction(function () use ($rule, $subject, $eventKey, $input): AutomationRun { + $run = AutomationRun::create([ + 'automation_rule_id' => $rule->id, 'subject_type' => $subject::class, 'subject_id' => $subject->getKey(), + 'status' => 'running', 'event_key' => $eventKey, 'input' => $input, 'started_at' => now(), + ]); + $output = []; + try { + foreach ($rule->actions ?? [] as $action) { + $type = $action['type'] ?? ''; + if ($type === 'create_task') { + $assignee = $action['assigned_to'] ?? ($subject instanceof Lead ? $subject->assigned_to : auth()->id()); + $task = Task::create([ + 'subject' => $action['subject'] ?? "پیگیری خودکار {$rule->name}", + 'taskable_type' => $subject::class, 'taskable_id' => $subject->getKey(), + 'assigned_to' => $assignee, 'assigned_by' => auth()->id(), 'created_by' => auth()->id(), + 'priority' => $action['priority'] ?? 'normal', 'status' => 'open', + 'due_at' => now()->addMinutes((int) ($action['due_in_minutes'] ?? 1440)), 'visibility' => 'team', 'version' => 1, + ]); + $output[] = ['type' => $type, 'task_id' => $task->id]; + } elseif ($type === 'notify') { + $userId = $action['user_id'] ?? auth()->id(); + $notification = Notification::firstOrCreate(['idempotency_key' => "automation:{$eventKey}:{$userId}"], [ + 'user_id' => $userId, 'title' => $action['title'] ?? $rule->name, + 'message' => $action['message'] ?? 'یک قانون اتوماسیون اجرا شد.', 'type' => 'automation', + 'data' => ['rule_id' => $rule->id, 'subject_id' => $subject->getKey()], + ]); + $output[] = ['type' => $type, 'notification_id' => $notification->id]; + } elseif ($type === 'set_lead_priority' && $subject instanceof Lead) { + $subject->update(['priority' => max(0, min(4, (int) ($action['value'] ?? 1)))]); + $output[] = ['type' => $type, 'value' => $subject->priority]; + } else { + throw ValidationException::withMessages(['actions' => "عملیات {$type} پشتیبانی نمی‌شود."]); + } + } + $run->update(['status' => 'completed', 'output' => $output, 'completed_at' => now()]); + } catch (\Throwable $exception) { + $run->update(['status' => 'failed', 'error' => $exception->getMessage(), 'completed_at' => now()]); + throw $exception; + } + + ActivityLogger::log('automation_executed', "Automation {$rule->id} executed", $rule, null, ['run_id' => $run->id]); + + return $run->fresh(); + }); + } +} diff --git a/backend/app/Services/CallService.php b/backend/app/Services/CallService.php index ca8d70a..3b685de 100644 --- a/backend/app/Services/CallService.php +++ b/backend/app/Services/CallService.php @@ -9,27 +9,67 @@ use App\Models\Contact; use App\Models\ContactPhone; use App\Models\ContactRelation; use App\Models\Lead; +use App\Models\LeadAssignment; use App\Models\LeadStatus; -use App\Models\FollowUp; use App\Models\PipelineStage; use App\Models\Setting; use App\Models\User; -use App\Services\ActivityLogger; use App\Services\VoIP\VoIPManager; -use App\Support\WorkingHours; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; class CallService { - public function __construct(private VoIPManager $voipManager) {} + public function __construct(private VoIPManager $voipManager, private FollowUpService $followUps) {} + + public function recordManualResult( + int $leadId, + int $userId, + int $contactPhoneId, + string $result, + ?string $notes = null, + ?string $followUpAt = null, + ?array $referral = null + ): Call { + return DB::transaction(function () use ($leadId, $userId, $contactPhoneId, $result, $notes, $followUpAt, $referral): Call { + $lead = Lead::with('contacts.phones')->lockForUpdate()->findOrFail($leadId); + $phone = $this->resolvePhone($lead, $contactPhoneId); + $now = now(); + + $call = Call::create([ + 'lead_id' => $lead->id, + 'contact_id' => $phone->contact_id, + 'contact_phone_id' => $phone->id, + 'user_id' => $userId, + 'direction' => 'outbound', + 'phone' => $phone->phone, + 'result' => null, + 'provider_status' => 'completed', + 'started_at' => $now, + 'ended_at' => $now, + 'is_manual' => true, + ]); + + $phone->increment('call_count'); + $phone->update(['last_called_at' => $now, 'last_called_by' => $userId]); + $lead->increment('call_attempts'); + $lead->update(['last_call_at' => $now]); + + ActivityLogger::log('manual_call_recorded', "Manual call recorded for lead {$leadId}", $call); + + return $this->registerResult($call->id, $result, $notes, $followUpAt, $referral); + }); + } public function initiateCall(int $leadId, int $userId, ?int $contactPhoneId = null, bool $includePhone = true): array { $lead = Lead::with('contacts.phones')->findOrFail($leadId); $user = User::findOrFail($userId); $callerExtension = trim((string) ($user->voip_extension ?: '')); - $providerName = Setting::where('key', 'voip_provider')->value('value') ?: 'mock'; + $providerName = Setting::where('key', 'voip_provider')->value('value') ?: (app()->environment('testing') ? 'mock' : 'none'); + if (app()->environment('testing') && $providerName === 'none') { + $providerName = 'mock'; + } if ($providerName === 'ami' && $callerExtension === '') { throw ValidationException::withMessages([ @@ -37,7 +77,7 @@ class CallService ]); } - if (!$lead->assigned_to) { + if (! $lead->assigned_to) { $lead->update([ 'assigned_to' => $userId, 'assigned_by' => $userId, @@ -45,7 +85,7 @@ class CallService 'pipeline_stage_id' => PipelineStage::where('slug', 'waiting_call')->value('id') ?? $lead->pipeline_stage_id, ]); - \App\Models\LeadAssignment::create([ + LeadAssignment::create([ 'lead_id' => $lead->id, 'user_id' => $userId, 'assigned_by' => $userId, @@ -74,6 +114,9 @@ class CallService 'phone' => $phone->phone, 'result' => null, 'provider_call_id' => $providerCallId, + 'provider_status' => $providerResult['status'] ?? 'initiated', + 'started_at' => now(), + 'provider_payload' => $providerResult['raw'] ?? null, 'recording_url' => $recordingEnabled && $providerCallId ? $this->voipManager->getRecordingUrl($providerCallId) : null, 'is_manual' => true, ]); @@ -125,9 +168,25 @@ class CallService return DB::transaction(function () use ($call, $callId, $result, $notes, $followUpAt, $referral, $options) { $call->update([ 'result' => $result, - 'notes' => $notes, + 'provider_status' => 'completed', + 'ended_at' => $call->ended_at ?? now(), ]); + if ($notes) { + $note = $call->notesHistory()->firstOrCreate( + ['source_key' => "call_result:{$call->id}"], + [ + 'user_id' => $call->user_id, + 'content' => $notes, + 'type' => 'call_summary', + 'visibility' => 'team', + ] + ); + if ($note->wasRecentlyCreated) { + ActivityLogger::log('call_note_created_from_result', "Call note {$note->id} created from result", $note, null, $note->only(['type', 'visibility'])); + } + } + $lead = $call->lead; $lead->update([ 'last_call_result' => $result, @@ -188,22 +247,15 @@ class CallService } if ($followUpAt) { - if (!WorkingHours::followUpAllowed($followUpAt)) { - throw \Illuminate\Validation\ValidationException::withMessages([ - 'next_follow_up_at' => ['زمان پیگیری خارج از روز یا ساعت کاری مجاز است.'], - ]); - } - $lead->update(['next_follow_up_at' => $followUpAt]); - - FollowUp::create([ - 'lead_id' => $lead->id, - 'user_id' => $call->user_id, - 'call_id' => $call->id, - 'scheduled_at' => $followUpAt, - 'status' => 'pending', - 'notes' => $newContact ? "پیگیری مخاطب معرفی‌شده: {$newContact->name}" : $notes, - ]); - NotificationService::notifyFollowUpReminder($call->user_id, $lead->full_name ?: ($lead->company ?? "لید {$lead->id}")); + $this->followUps->schedule( + $lead, + $call->user_id, + $followUpAt, + User::findOrFail($call->user_id), + $newContact ? "پیگیری مخاطب معرفی‌شده: {$newContact->name}" : $notes, + $call->id, + 'call_result', + ); } CallLog::updateOrCreate( @@ -230,7 +282,7 @@ class CallService private function resolvePhone(Lead $lead, ?int $contactPhoneId): ContactPhone { if ($contactPhoneId) { - return ContactPhone::whereHas('contact', fn($query) => $query->where('lead_id', $lead->id)) + return ContactPhone::whereHas('contact', fn ($query) => $query->where('lead_id', $lead->id)) ->findOrFail($contactPhoneId); } @@ -306,5 +358,4 @@ class CallService return $contact; } - } diff --git a/backend/app/Services/DashboardService.php b/backend/app/Services/DashboardService.php index b738baa..0c6cd62 100644 --- a/backend/app/Services/DashboardService.php +++ b/backend/app/Services/DashboardService.php @@ -2,20 +2,20 @@ namespace App\Services; -use App\Models\Lead; -use App\Models\Call; -use App\Models\Campaign; -use App\Models\Deal; -use App\Models\FollowUp; use App\Models\ActivityLog; +use App\Models\Call; +use App\Models\CallResult; +use App\Models\Campaign; +use App\Models\FollowUp; use App\Models\ImportBatch; +use App\Models\Lead; use App\Models\LeadStatus; use App\Models\PipelineStage; -use App\Models\CallResult; use App\Models\QualityReview; use App\Models\Setting; -use App\Models\User; +use App\Models\Task; use App\Models\Team; +use App\Models\User; use Carbon\Carbon; class DashboardService @@ -24,6 +24,7 @@ class DashboardService { $today = Carbon::today(); $now = Carbon::now(); + $personalAgenda = $this->personalAgenda((int) auth()->id(), $today, $now); return [ 'total_leads' => Lead::count(), @@ -52,7 +53,7 @@ class DashboardService 'conversion_rate' => $this->calculateConversionRate(), 'follow_up_backlog' => FollowUp::where('status', 'pending')->whereDate('scheduled_at', '<=', $now)->count(), 'overdue_follow_ups' => FollowUp::where('status', 'pending') - ->where(fn($q) => $q->where('is_overdue', true)->orWhere('scheduled_at', '<', $now)) + ->where(fn ($q) => $q->where('is_overdue', true)->orWhere('scheduled_at', '<', $now)) ->count(), 'overdue_leads' => Lead::whereNotNull('next_follow_up_at')->where('next_follow_up_at', '<', $now)->whereNull('final_result')->count(), 'pipeline_data' => $this->pipelineSummary(), @@ -71,7 +72,6 @@ class DashboardService 'system_health' => [ 'pending_follow_ups' => FollowUp::where('status', 'pending')->count(), 'failed_imports_today' => ImportBatch::whereDate('created_at', $today)->where('failed_rows', '>', 0)->count(), - 'open_deals' => class_exists(Deal::class) ? Deal::where('status', 'open')->count() : 0, ], 'leads_by_status' => LeadStatus::withCount('leads') ->orderBy('sort_order') @@ -86,6 +86,15 @@ class DashboardService ->latest() ->limit(10) ->get(), + 'task_widgets' => [ + 'overdue' => Task::active()->whereNotNull('due_at')->where('due_at', '<', $now)->count(), + 'unassigned' => Task::active()->whereNull('assigned_to')->count(), + 'by_status' => Task::selectRaw('status, count(*) as total')->groupBy('status')->pluck('total', 'status'), + 'by_priority' => Task::selectRaw('priority, count(*) as total')->groupBy('priority')->pluck('total', 'priority'), + ], + 'my_task_widgets' => $personalAgenda['tasks'], + 'my_follow_up_widgets' => $personalAgenda['follow_ups'], + 'live_charts' => $this->liveCharts(), ]; } @@ -96,6 +105,7 @@ class DashboardService $today = Carbon::today(); $now = Carbon::now(); + $personalAgenda = $this->personalAgenda((int) auth()->id(), $today, $now); return [ 'total_leads' => Lead::whereIn('assigned_to', $agentIds)->count(), @@ -113,7 +123,7 @@ class DashboardService 'online_agents' => User::whereIn('id', $agentIds) ->where('last_login_at', '>=', now()->subMinutes(15))->count(), 'overdue_follow_ups' => FollowUp::whereIn('user_id', $agentIds) - ->where('status', 'pending')->where(fn($q) => $q->where('is_overdue', true)->orWhere('scheduled_at', '<', $now))->count(), + ->where('status', 'pending')->where(fn ($q) => $q->where('is_overdue', true)->orWhere('scheduled_at', '<', $now))->count(), 'leads_without_calls' => Lead::whereIn('assigned_to', $agentIds)->whereNull('last_call_at')->count(), 'team_stats' => $this->agentPerformance($agentIds), 'leads_stuck_by_stage' => $this->stuckLeadsByStage($agentIds), @@ -127,11 +137,24 @@ class DashboardService 'agents_behind_target' => $this->agentsBehindTarget($agentIds), 'hot_leads' => Lead::with('assignedAgent:id,name') ->whereIn('assigned_to', $agentIds) - ->where(fn($q) => $q->where('interest_level', 'hot')->orWhere('priority', '>=', 8)) + ->where(fn ($q) => $q->where('interest_level', 'hot')->orWhere('priority', '>=', 8)) ->latest() ->limit(10) ->get(['id', 'company', 'first_name', 'last_name', 'interest_level', 'priority', 'assigned_to', 'next_follow_up_at']), 'important_team_alerts' => $this->teamAlerts($agentIds), + 'task_widgets' => [ + 'overdue' => Task::active()->whereIn('assigned_to', $agentIds)->whereNotNull('due_at')->where('due_at', '<', $now)->count(), + 'unassigned' => Task::active()->whereNull('assigned_to')->whereIn('created_by', array_values(array_unique(array_merge($agentIds, [auth()->id()]))))->count(), + 'workload' => User::whereIn('id', $agentIds)->orderBy('name')->get(['id', 'name'])->map(fn (User $agent) => [ + 'user_id' => $agent->id, + 'name' => $agent->name, + 'open_tasks' => Task::active()->where('assigned_to', $agent->id)->count(), + 'overdue_tasks' => Task::active()->where('assigned_to', $agent->id)->whereNotNull('due_at')->where('due_at', '<', $now)->count(), + ])->values(), + ], + 'my_task_widgets' => $personalAgenda['tasks'], + 'my_follow_up_widgets' => $personalAgenda['follow_ups'], + 'live_charts' => $this->liveCharts($agentIds), ]; } @@ -139,15 +162,18 @@ class DashboardService { $userId = $userId ?? auth()->id(); $today = Carbon::today(); + $pendingFollowUps = FollowUp::where('user_id', $userId)->where('status', 'pending'); + $todayFollowUps = (clone $pendingFollowUps)->whereDate('scheduled_at', $today)->count(); + $overdueFollowUps = (clone $pendingFollowUps) + ->where(fn ($query) => $query->where('is_overdue', true)->orWhere('scheduled_at', '<', Carbon::now())) + ->count(); return [ 'my_leads' => Lead::where('assigned_to', $userId)->count(), 'my_calls_today' => Call::where('user_id', $userId)->whereDate('created_at', $today)->count(), - 'my_follow_ups_today' => FollowUp::where('user_id', $userId)->whereDate('scheduled_at', $today)->where('status', 'pending')->count(), - 'today_follow_ups' => FollowUp::where('user_id', $userId) - ->whereDate('scheduled_at', $today)->where('status', 'pending')->count(), - 'overdue_follow_ups' => FollowUp::where('user_id', $userId) - ->where('status', 'pending')->where(fn($q) => $q->where('is_overdue', true)->orWhere('scheduled_at', '<', Carbon::now()))->count(), + 'my_follow_ups_today' => $todayFollowUps, + 'today_follow_ups' => $todayFollowUps, + 'overdue_follow_ups' => $overdueFollowUps, 'calls_today' => Call::where('user_id', $userId)->whereDate('created_at', $today)->count(), 'answered_calls_today' => Call::where('user_id', $userId)->whereDate('created_at', $today) ->whereIn('result', $this->successfulResultNames())->count(), @@ -170,7 +196,7 @@ class DashboardService ->oldest('last_call_at') ->first(['id', 'company', 'first_name', 'last_name', 'priority', 'last_call_result', 'next_follow_up_at']), 'hot_leads' => Lead::where('assigned_to', $userId) - ->where(fn($q) => $q->where('interest_level', 'hot')->orWhere('priority', '>=', 8)) + ->where(fn ($q) => $q->where('interest_level', 'hot')->orWhere('priority', '>=', 8)) ->latest() ->limit(5) ->get(['id', 'company', 'first_name', 'last_name', 'interest_level', 'priority', 'next_follow_up_at']), @@ -181,15 +207,130 @@ class DashboardService ->orderBy('scheduled_at') ->limit(8) ->get(['id', 'lead_id', 'scheduled_at', 'notes', 'status']), + 'follow_up_widgets' => [ + 'today' => $todayFollowUps, + 'overdue' => $overdueFollowUps, + 'next' => (clone $pendingFollowUps) + ->with('lead:id,company,first_name,last_name') + ->orderBy('scheduled_at') + ->limit(8) + ->get(['id', 'lead_id', 'scheduled_at', 'notes', 'status', 'is_overdue']), + ], 'suggested_next_action' => $this->suggestedNextAction($userId), + 'task_widgets' => [ + 'today' => Task::active()->where('assigned_to', $userId)->whereDate('due_at', $today)->count(), + 'overdue' => Task::active()->where('assigned_to', $userId)->whereNotNull('due_at')->where('due_at', '<', Carbon::now())->count(), + 'next' => Task::active()->where('assigned_to', $userId)->whereNotNull('due_at') + ->orderBy('due_at')->limit(5)->get(['id', 'subject', 'priority', 'status', 'due_at', 'version']), + ], + 'live_charts' => $this->liveCharts([$userId]), ]; } + private function liveCharts(?array $agentIds = null): array + { + $leadScope = fn ($query) => is_array($agentIds) ? $query->whereIn('assigned_to', $agentIds) : $query; + $callScope = fn ($query) => is_array($agentIds) ? $query->whereIn('user_id', $agentIds) : $query; + $followUpScope = fn ($query) => is_array($agentIds) ? $query->whereIn('user_id', $agentIds) : $query; + + $leads = $leadScope(Lead::query()); + $leadCount = (clone $leads)->count(); + + $statusRows = LeadStatus::query()->where('is_active', true)->orderBy('sort_order')->get() + ->map(fn (LeadStatus $status) => [ + 'label' => $status->name, + 'value' => $leadScope(Lead::where('lead_status_id', $status->id))->count(), + ]) + ->filter(fn (array $row) => $row['value'] > 0) + ->sortByDesc('value') + ->values(); + $leadsWithoutStatus = max(0, $leadCount - $statusRows->sum('value')); + if ($leadsWithoutStatus > 0) { + $statusRows->push(['label' => 'بدون وضعیت', 'value' => $leadsWithoutStatus]); + $statusRows = $statusRows->sortByDesc('value')->values(); + } + if ($statusRows->count() > 5) { + $other = $statusRows->slice(4)->sum('value'); + $statusRows = $statusRows->take(4)->push(['label' => 'سایر وضعیت‌ها', 'value' => $other]); + } + + $todayCalls = $callScope(Call::whereDate('created_at', Carbon::today())); + $successfulCalls = (clone $todayCalls)->whereIn('result', $this->successfulResultNames())->count(); + $unansweredCalls = (clone $todayCalls)->whereIn('result', ['پاسخ نداد', 'اشغال بود', 'خاموش بود'])->count(); + $otherCalls = max(0, (clone $todayCalls)->count() - $successfulCalls - $unansweredCalls); + + $wonCount = (clone $leads)->where('final_result', 'موفق')->count(); + $todayFollowUps = $followUpScope(FollowUp::whereDate('scheduled_at', Carbon::today())); + $followUpCount = (clone $todayFollowUps)->count(); + $completedFollowUps = (clone $todayFollowUps)->where('status', 'completed')->count(); + $callCount = (clone $todayCalls)->count(); + + return [ + 'updated_at' => Carbon::now()->toISOString(), + 'lead_status' => $this->chartRows($statusRows->all()), + 'call_outcomes' => $this->chartRows([ + ['label' => 'موفق', 'value' => $successfulCalls], + ['label' => 'بدون پاسخ', 'value' => $unansweredCalls], + ['label' => 'سایر', 'value' => $otherCalls], + ]), + 'performance' => $this->chartRows([ + ['label' => 'تبدیل فروش', 'value' => $this->percent($wonCount, $leadCount)], + ['label' => 'موفقیت تماس', 'value' => $this->percent($successfulCalls, $callCount)], + ['label' => 'هدف تماس روزانه', 'value' => $this->percent($callCount, $this->intSetting('daily_call_target', 40))], + ['label' => 'تکمیل پیگیری', 'value' => $this->percent($completedFollowUps, $followUpCount)], + ['label' => 'کامل بودن داده', 'value' => $this->percent((clone $leads)->whereNotNull('email')->whereNotNull('phone')->count(), $leadCount)], + ]), + ]; + } + + private function personalAgenda(int $userId, Carbon $today, Carbon $now): array + { + $pendingFollowUps = FollowUp::where('user_id', $userId)->where('status', 'pending'); + + return [ + 'tasks' => [ + 'today' => Task::active()->where('assigned_to', $userId)->whereDate('due_at', $today)->count(), + 'overdue' => Task::active()->where('assigned_to', $userId)->whereNotNull('due_at')->where('due_at', '<', $now)->count(), + 'next' => Task::active()->where('assigned_to', $userId)->whereNotNull('due_at') + ->orderBy('due_at')->limit(8)->get(['id', 'subject', 'priority', 'status', 'due_at', 'version']), + ], + 'follow_ups' => [ + 'today' => (clone $pendingFollowUps)->whereDate('scheduled_at', $today)->count(), + 'overdue' => (clone $pendingFollowUps) + ->where(fn ($query) => $query->where('is_overdue', true)->orWhere('scheduled_at', '<', $now)) + ->count(), + 'next' => (clone $pendingFollowUps) + ->with('lead:id,company,first_name,last_name') + ->orderBy('scheduled_at') + ->limit(8) + ->get(['id', 'lead_id', 'scheduled_at', 'notes', 'status', 'is_overdue']), + ], + ]; + } + + private function chartRows(array $rows): array + { + $palette = ['#2563EB', '#0D9488', '#D97706', '#7C3AED', '#DC2626']; + + return collect($rows)->values()->map(fn (array $row, int $index) => [ + ...$row, + 'color' => $palette[$index % count($palette)], + ])->all(); + } + + private function percent(int $value, int $total): float + { + return $total > 0 ? min(100, round(($value / $total) * 100, 1)) : 0; + } + private function calculateConversionRate(): float { $total = Lead::count(); - if ($total === 0) return 0; + if ($total === 0) { + return 0; + } $won = Lead::where('final_result', 'موفق')->count(); + return round(($won / $total) * 100, 1); } @@ -203,6 +344,7 @@ class DashboardService ->map(function ($stages, string $name) { $ids = $stages->pluck('id'); $first = $stages->first(); + return [ 'stage' => $name, 'count' => Lead::whereIn('pipeline_stage_id', $ids)->count(), @@ -215,20 +357,26 @@ class DashboardService private function calculateTeamConversionRate(array $agentIds): float { $total = Lead::whereIn('assigned_to', $agentIds)->count(); - if ($total === 0) return 0; + if ($total === 0) { + return 0; + } $won = Lead::whereIn('assigned_to', $agentIds) ->where('final_result', 'موفق') ->count(); + return round(($won / $total) * 100, 1); } private function calculateAgentConversionRate(int $userId): float { $total = Lead::where('assigned_to', $userId)->count(); - if ($total === 0) return 0; + if ($total === 0) { + return 0; + } $won = Lead::where('assigned_to', $userId) ->where('final_result', 'موفق') ->count(); + return round(($won / $total) * 100, 1); } @@ -252,7 +400,10 @@ class DashboardService return collect(range(6, 0))->map(function (int $daysAgo) use ($agentIds) { $date = Carbon::today()->subDays($daysAgo); $query = Call::whereDate('created_at', $date); - if (is_array($agentIds)) $query->whereIn('user_id', $agentIds); + if (is_array($agentIds)) { + $query->whereIn('user_id', $agentIds); + } + return [ 'date' => $date->toDateString(), 'calls' => (clone $query)->count(), @@ -264,13 +415,16 @@ class DashboardService private function sourcePerformance(?array $agentIds = null): array { $query = Lead::query(); - if (is_array($agentIds)) $query->whereIn('assigned_to', $agentIds); + if (is_array($agentIds)) { + $query->whereIn('assigned_to', $agentIds); + } + return $query->selectRaw("COALESCE(source, 'نامشخص') as source, count(*) as total, sum(case when final_result = 'موفق' then 1 else 0 end) as won") ->groupBy('source') ->orderByDesc('total') ->limit(10) ->get() - ->map(fn($row) => [ + ->map(fn ($row) => [ 'source' => $row->source, 'total' => (int) $row->total, 'won' => (int) $row->won, @@ -284,7 +438,7 @@ class DashboardService ->latest() ->limit(8) ->get() - ->map(fn(Campaign $campaign) => [ + ->map(fn (Campaign $campaign) => [ 'id' => $campaign->id, 'name' => $campaign->name, 'target' => $campaign->target, @@ -297,6 +451,7 @@ class DashboardService { return Team::with('members:id,name')->get()->map(function (Team $team) { $agentIds = $team->members->pluck('id')->all(); + return [ 'id' => $team->id, 'name' => $team->name, @@ -310,10 +465,11 @@ class DashboardService private function agentPerformance(?array $agentIds = null, ?int $limit = null): array { - $users = User::role('agent')->when(is_array($agentIds), fn($q) => $q->whereIn('id', $agentIds))->orderBy('name')->get(); + $users = User::role('agent')->when(is_array($agentIds), fn ($q) => $q->whereIn('id', $agentIds))->orderBy('name')->get(); $rows = $users->map(function (User $agent) { $todayCalls = Call::where('user_id', $agent->id)->whereDate('created_at', Carbon::today())->count(); $successful = Call::where('user_id', $agent->id)->whereDate('created_at', Carbon::today())->whereIn('result', $this->successfulResultNames())->count(); + return [ 'agent_id' => $agent->id, 'agent_name' => $agent->name, @@ -330,8 +486,8 @@ class DashboardService private function lowPerformanceAlerts(): array { - return collect($this->agentPerformance())->filter(fn($row) => $row['call_target_percent'] < 50) - ->map(fn($row) => ['agent_id' => $row['agent_id'], 'agent_name' => $row['agent_name'], 'message' => 'کمتر از ۵۰٪ هدف تماس امروز انجام شده است.']) + return collect($this->agentPerformance())->filter(fn ($row) => $row['call_target_percent'] < 50) + ->map(fn ($row) => ['agent_id' => $row['agent_id'], 'agent_name' => $row['agent_name'], 'message' => 'کمتر از ۵۰٪ هدف تماس امروز انجام شده است.']) ->values() ->all(); } @@ -339,7 +495,8 @@ class DashboardService private function stuckLeadsByStage(array $agentIds): array { $threshold = Carbon::now()->subDays(7); - return PipelineStage::orderBy('sort_order')->get()->map(fn(PipelineStage $stage) => [ + + return PipelineStage::orderBy('sort_order')->get()->map(fn (PipelineStage $stage) => [ 'stage' => $stage->name, 'count' => Lead::whereIn('assigned_to', $agentIds)->where('pipeline_stage_id', $stage->id)->where('updated_at', '<=', $threshold)->whereNull('final_result')->count(), ])->values()->all(); @@ -348,6 +505,7 @@ class DashboardService private function qualitySummary(array $agentIds): array { $query = QualityReview::whereIn('agent_id', $agentIds); + return [ 'reviewed_calls' => (clone $query)->count(), 'average_score' => round((float) ((clone $query)->avg('overall_score') ?? 0), 1), @@ -358,7 +516,7 @@ class DashboardService private function agentsBehindTarget(array $agentIds): array { return collect($this->agentPerformance($agentIds)) - ->filter(fn($row) => $row['call_target_percent'] < 80) + ->filter(fn ($row) => $row['call_target_percent'] < 80) ->values() ->all(); } @@ -366,6 +524,7 @@ class DashboardService private function teamAlerts(array $agentIds): array { $now = Carbon::now(); + return [ ['label' => 'پیگیری عقب‌افتاده', 'count' => FollowUp::whereIn('user_id', $agentIds)->where('status', 'pending')->where('scheduled_at', '<', $now)->count()], ['label' => 'لید بدون تماس', 'count' => Lead::whereIn('assigned_to', $agentIds)->whereNull('last_call_at')->count()], @@ -388,6 +547,7 @@ class DashboardService 'mock' => true, default => false, }; + return [ 'provider' => $provider, 'configured' => $configured, @@ -398,7 +558,8 @@ class DashboardService private function funnelConversion(): array { $total = max(Lead::count(), 1); - return collect($this->pipelineSummary())->map(fn($row) => [ + + return collect($this->pipelineSummary())->map(fn ($row) => [ 'stage' => $row['stage'], 'count' => $row['count'], 'percentage' => round(($row['count'] / $total) * 100, 1), @@ -414,6 +575,7 @@ class DashboardService if (Lead::where('assigned_to', $userId)->whereNull('last_call_at')->exists()) { return 'با لیدهای بدون تماس شروع کنید.'; } + return 'تماس بعدی پیشنهادی را از صف تماس بردارید.'; } } diff --git a/backend/app/Services/DealPipelineService.php b/backend/app/Services/DealPipelineService.php new file mode 100644 index 0000000..18b8904 --- /dev/null +++ b/backend/app/Services/DealPipelineService.php @@ -0,0 +1,95 @@ +load(['stages' => fn ($query) => $query->where('is_active', true)]); + $query = Deal::with('owner:id,name', 'company:id,name', 'contact:id,name') + ->where('pipeline_id', $pipeline->id); + AccessControl::scopeDeals($query, $user); + + foreach (['owner_id', 'forecast_category', 'status'] as $filter) { + if (! empty($filters[$filter])) { + $query->where($filter, $filters[$filter]); + } + } + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(fn ($q) => $q->where('title', 'like', "%{$search}%") + ->orWhereHas('company', fn ($company) => $company->where('name', 'like', "%{$search}%"))); + } + + $deals = $query->orderByDesc('estimated_value')->get(); + $openDeals = $deals->filter(fn (Deal $deal) => ! in_array($deal->status, ['won', 'lost'], true)); + + return [ + 'pipeline' => $pipeline, + 'stages' => $pipeline->stages->map(fn (DealStage $stage) => [ + ...$stage->toArray(), + 'deals' => $deals->where('deal_stage_id', $stage->id)->values(), + 'total_value' => (float) $deals->where('deal_stage_id', $stage->id)->sum('estimated_value'), + ]), + 'summary' => [ + 'count' => $openDeals->count(), + 'total_value' => (float) $openDeals->sum('estimated_value'), + 'weighted_value' => round((float) $openDeals->sum(fn (Deal $deal) => ((float) $deal->estimated_value * $deal->win_probability) / 100), 2), + ], + ]; + } + + public function move(Deal $deal, DealStage $stage, User $user, int $version, ?string $reason = null, ?float $finalAmount = null): Deal + { + return DB::transaction(function () use ($deal, $stage, $user, $version, $reason, $finalAmount): Deal { + $locked = Deal::lockForUpdate()->findOrFail($deal->id); + if ($locked->version !== $version) { + throw ValidationException::withMessages(['version' => 'این فرصت هم‌زمان تغییر کرده است؛ برد را تازه‌سازی کنید.']); + } + if ($locked->pipeline_id !== $stage->pipeline_id) { + throw ValidationException::withMessages(['deal_stage_id' => 'مرحله باید متعلق به پایپ‌لاین فرصت باشد.']); + } + if (($stage->is_won || $stage->is_lost) && blank($reason)) { + throw ValidationException::withMessages(['reason' => 'برای بستن فرصت، دلیل الزامی است.']); + } + + $before = $locked->only(['deal_stage_id', 'status', 'version']); + $fromStage = $locked->deal_stage_id; + $status = $stage->is_won ? 'won' : ($stage->is_lost ? 'lost' : 'open'); + $locked->update([ + 'deal_stage_id' => $stage->id, + 'sales_stage' => $stage->slug, + 'win_probability' => $stage->probability, + 'status' => $status, + 'won_lost_reason' => ($stage->is_won || $stage->is_lost) ? $reason : null, + 'final_amount' => $stage->is_won ? ($finalAmount ?? $locked->estimated_value) : null, + 'closed_at' => ($stage->is_won || $stage->is_lost) ? now() : null, + 'last_activity_at' => now(), + 'version' => $locked->version + 1, + ]); + DealStageHistory::create([ + 'deal_id' => $locked->id, + 'from_stage_id' => $fromStage, + 'to_stage_id' => $stage->id, + 'changed_by' => $user->id, + 'note' => $reason, + ]); + ActivityLogger::log('deal_stage_changed', "Deal {$locked->id} moved to {$stage->name}", $locked, $before, $locked->fresh()->only(['deal_stage_id', 'status', 'version'])); + $this->automations->dispatch('deal_stage_changed', $locked, "deal-stage:{$locked->id}:{$locked->version}", ['from_stage_id' => $fromStage, 'to_stage_id' => $stage->id, 'status' => $status]); + + return $locked->fresh(['pipeline', 'stage', 'owner:id,name', 'company:id,name']); + }); + } +} diff --git a/backend/app/Services/DuplicateService.php b/backend/app/Services/DuplicateService.php index 3e3d6f1..5093c2f 100644 --- a/backend/app/Services/DuplicateService.php +++ b/backend/app/Services/DuplicateService.php @@ -2,10 +2,15 @@ namespace App\Services; +use App\Models\Attachment; use App\Models\Company; use App\Models\Contact; +use App\Models\Deal; use App\Models\Lead; use App\Models\MergeHistory; +use App\Models\Note; +use App\Models\User; +use App\Support\AccessControl; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -13,20 +18,32 @@ class DuplicateService { public static function normalizePhone(?string $phone): ?string { - if (!$phone) return null; - $digits = preg_replace('/\D+/', '', strtr($phone, ['۰'=>'0','۱'=>'1','۲'=>'2','۳'=>'3','۴'=>'4','۵'=>'5','۶'=>'6','۷'=>'7','۸'=>'8','۹'=>'9','٠'=>'0','١'=>'1','٢'=>'2','٣'=>'3','٤'=>'4','٥'=>'5','٦'=>'6','٧'=>'7','٨'=>'8','٩'=>'9'])); - if (!$digits) return null; - if (str_starts_with($digits, '0098')) $digits = '0' . substr($digits, 4); - if (str_starts_with($digits, '98')) $digits = '0' . substr($digits, 2); + if (! $phone) { + return null; + } + $digits = preg_replace('/\D+/', '', strtr($phone, ['۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4', '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9', '٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4', '٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9'])); + if (! $digits) { + return null; + } + if (str_starts_with($digits, '0098')) { + $digits = '0'.substr($digits, 4); + } + if (str_starts_with($digits, '98')) { + $digits = '0'.substr($digits, 2); + } + return $digits; } public static function normalizeWebsite(?string $website): ?string { - if (!$website) return null; + if (! $website) { + return null; + } $value = strtolower(trim($website)); $value = preg_replace('#^https?://#', '', $value); $value = preg_replace('#^www\.#', '', $value); + return rtrim($value, '/'); } @@ -35,7 +52,7 @@ class DuplicateService return $name ? Str::of($name)->lower()->squish()->toString() : null; } - public function companySuggestions(array $payload, ?int $excludeId = null): array + public function companySuggestions(array $payload, ?int $excludeId = null, ?User $user = null): array { $name = self::normalizeName($payload['name'] ?? null); $website = self::normalizeWebsite($payload['website'] ?? null); @@ -43,18 +60,29 @@ class DuplicateService $phone = self::normalizePhone($payload['phone'] ?? null); $query = Company::query()->with('owner:id,name')->limit(10); - if ($excludeId) $query->whereKeyNot($excludeId); + if ($user) { + AccessControl::scopeCompanies($query, $user); + } + if ($excludeId) { + $query->whereKeyNot($excludeId); + } $query->where(function ($q) use ($name, $website, $email, $phone) { - if ($name) $q->orWhere('normalized_name', $name); - if ($website) $q->orWhere('normalized_website', $website); - if ($email) $q->orWhereHas('contacts', fn($contact) => $contact->where('email', $email)); + if ($name) { + $q->orWhere('normalized_name', $name); + } + if ($website) { + $q->orWhere('normalized_website', $website); + } + if ($email) { + $q->orWhereHas('contacts', fn ($contact) => $contact->where('email', $email)); + } if ($phone) { - $q->orWhereHas('contacts.phones', fn($phoneQuery) => $phoneQuery->where('phone', 'like', "%{$phone}%")); + $q->orWhereHas('contacts.phones', fn ($phoneQuery) => $phoneQuery->where('phone', 'like', "%{$phone}%")); } }); - return $query->get()->map(fn(Company $company) => [ + return $query->get()->map(fn (Company $company) => [ 'id' => $company->id, 'type' => 'company', 'title' => $company->name, @@ -62,22 +90,33 @@ class DuplicateService ])->values()->all(); } - public function leadSuggestions(array $payload, ?int $excludeId = null): array + public function leadSuggestions(array $payload, ?int $excludeId = null, ?User $user = null): array { $phone = self::normalizePhone($payload['phone'] ?? null); $email = strtolower((string) ($payload['email'] ?? '')); $company = self::normalizeName($payload['company'] ?? null); $query = Lead::query()->limit(10); - if ($excludeId) $query->whereKeyNot($excludeId); + if ($user) { + AccessControl::scopeLeads($query, $user, false); + } + if ($excludeId) { + $query->whereKeyNot($excludeId); + } $query->where(function ($q) use ($phone, $email, $company) { - if ($phone) $q->orWhere('phone', 'like', "%{$phone}%")->orWhere('phone_secondary', 'like', "%{$phone}%"); - if ($email) $q->orWhere('email', $email); - if ($company) $q->orWhereRaw('LOWER(company) = ?', [$company]); + if ($phone) { + $q->orWhere('phone', 'like', "%{$phone}%")->orWhere('phone_secondary', 'like', "%{$phone}%"); + } + if ($email) { + $q->orWhere('email', $email); + } + if ($company) { + $q->orWhereRaw('LOWER(company) = ?', [$company]); + } }); - return $query->get()->map(fn(Lead $lead) => [ + return $query->get()->map(fn (Lead $lead) => [ 'id' => $lead->id, 'type' => 'lead', 'title' => $lead->company ?: $lead->full_name, @@ -90,9 +129,9 @@ class DuplicateService return DB::transaction(function () use ($source, $target, $userId) { Contact::where('company_id', $source->id)->update(['company_id' => $target->id]); Lead::where('company_id', $source->id)->update(['company_id' => $target->id]); - \App\Models\Deal::where('company_id', $source->id)->update(['company_id' => $target->id]); - \App\Models\Attachment::where('attachable_type', Company::class)->where('attachable_id', $source->id)->update(['attachable_id' => $target->id]); - \App\Models\Note::where('notable_type', Company::class)->where('notable_id', $source->id)->update(['notable_id' => $target->id]); + Deal::where('company_id', $source->id)->update(['company_id' => $target->id]); + Attachment::where('attachable_type', Company::class)->where('attachable_id', $source->id)->update(['attachable_id' => $target->id]); + Note::where('notable_type', Company::class)->where('notable_id', $source->id)->update(['notable_id' => $target->id]); MergeHistory::create([ 'entity_type' => 'company', @@ -104,6 +143,7 @@ class DuplicateService $source->delete(); ActivityLogger::log('company_merged', "Company {$source->id} merged into {$target->id}", $target); + return $target->fresh(['contacts.phones', 'leads', 'deals']); }); } diff --git a/backend/app/Services/FollowUpReminderService.php b/backend/app/Services/FollowUpReminderService.php new file mode 100644 index 0000000..526cc40 --- /dev/null +++ b/backend/app/Services/FollowUpReminderService.php @@ -0,0 +1,35 @@ +where('status', 'pending') + ->where('scheduled_at', '<=', now()) + ->orderBy('id') + ->chunkById(100, function ($followUps) use (&$count): void { + foreach ($followUps as $followUp) { + $followUp->update(['is_overdue' => $followUp->scheduled_at->isPast()]); + $leadName = $followUp->lead?->full_name ?: ($followUp->lead?->company ?? "لید {$followUp->lead_id}"); + $notification = NotificationService::sendOnce( + $followUp->user_id, + $followUp->is_overdue ? 'پیگیری عقب‌افتاده' : 'یادآوری پیگیری', + $followUp->is_overdue ? "پیگیری لید {$leadName} عقب افتاده است" : "موعد پیگیری لید {$leadName} رسیده است", + $followUp->is_overdue ? 'overdue_follow_up' : 'follow_up', + ['follow_up_id' => $followUp->id, 'lead_id' => $followUp->lead_id], + ); + if ($notification) { + $count++; + } + } + }); + + return $count; + } +} diff --git a/backend/app/Services/FollowUpService.php b/backend/app/Services/FollowUpService.php new file mode 100644 index 0000000..2be020b --- /dev/null +++ b/backend/app/Services/FollowUpService.php @@ -0,0 +1,83 @@ + ['زمان پیگیری خارج از روز یا ساعت کاری مجاز است.'], + ]); + } + + $followUp = DB::transaction(function () use ($lead, $assigneeId, $scheduledAt, $actor, $notes, $callId, $source): FollowUp { + $followUp = FollowUp::create([ + 'lead_id' => $lead->id, + 'user_id' => $assigneeId, + 'created_by' => $actor->id, + 'call_id' => $callId, + 'source' => $source, + 'scheduled_at' => $scheduledAt, + 'notes' => $notes, + 'status' => 'pending', + ]); + + $lead->update(['next_follow_up_at' => $scheduledAt]); + ActivityLogger::log('follow_up_created', "Follow-up {$followUp->id} scheduled from {$source}", $followUp); + + return $followUp; + }); + + NotificationService::notifyFollowUpAssigned( + $followUp, + $lead->full_name ?: ($lead->company ?? "لید {$lead->id}"), + $actor, + ); + + return $followUp->load($this->relations()); + } + + public function update(FollowUp $followUp, array $data): FollowUp + { + if (isset($data['scheduled_at']) && ! WorkingHours::followUpAllowed($data['scheduled_at'])) { + throw ValidationException::withMessages([ + 'scheduled_at' => ['زمان پیگیری خارج از روز یا ساعت کاری مجاز است.'], + ]); + } + + if (($data['status'] ?? null) === 'completed') { + $data['completed_at'] = now(); + $data['is_overdue'] = false; + } elseif (($data['status'] ?? null) === 'pending') { + $data['completed_at'] = null; + } + + $beforeAssignee = $followUp->user_id; + $followUp->update($data); + ActivityLogger::log('follow_up_updated', "Follow-up {$followUp->id} updated", $followUp); + + if (isset($data['user_id']) && (int) $data['user_id'] !== $beforeAssignee) { + NotificationService::notifyFollowUpAssigned( + $followUp->fresh(), + $followUp->lead?->full_name ?: ($followUp->lead?->company ?? "لید {$followUp->lead_id}"), + auth()->user(), + ); + } + + return $followUp->fresh($this->relations()); + } + + private function relations(): array + { + return ['lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name']; + } +} diff --git a/backend/app/Services/ImportService.php b/backend/app/Services/ImportService.php index 20c4478..be4125a 100644 --- a/backend/app/Services/ImportService.php +++ b/backend/app/Services/ImportService.php @@ -2,18 +2,16 @@ namespace App\Services; -use App\Models\ImportBatch; -use App\Models\ImportBatchRow; use App\Models\Contact; use App\Models\ContactPhone; +use App\Models\ImportBatch; use App\Models\Lead; +use App\Models\LeadAssignment; use App\Models\LeadStatus; use App\Models\PipelineStage; use App\Models\Setting; -use App\Services\ActivityLogger; -use App\Services\NotificationService; -use Maatwebsite\Excel\Facades\Excel; use Illuminate\Support\Facades\DB; +use Maatwebsite\Excel\Facades\Excel; class ImportService { @@ -47,20 +45,24 @@ class ImportService $agentCount = count($agentIds ?? []); foreach ($batch->rows as $row) { - if ($row->status !== 'pending') continue; + if ($row->status !== 'pending') { + continue; + } $data = $row->original_data; $leadData = $this->mapColumns($data, $columnMapping); - if (!$leadData['company']) { + if (! $leadData['company']) { $row->update(['status' => 'failed', 'error' => 'نام کسب‌وکار الزامی است']); $batch->increment('failed_rows'); + continue; } - if (!$leadData['phone']) { + if (! $leadData['phone']) { $row->update(['status' => 'failed', 'error' => 'شماره تلفن الزامی است']); $batch->increment('failed_rows'); + continue; } @@ -71,10 +73,12 @@ class ImportService } elseif ($duplicatePolicy === 'block') { $row->update(['status' => 'failed', 'lead_id' => $existing->id, 'error' => 'شماره تلفن تکراری است']); $batch->increment('failed_rows'); + continue; } else { $row->update(['status' => 'skipped', 'lead_id' => $existing->id, 'error' => 'شماره تلفن تکراری است؛ نیازمند بررسی یا ادغام']); $batch->increment('skipped_rows'); + continue; } } @@ -100,7 +104,7 @@ class ImportService $this->createPrimaryContact($lead, $assignedById); if ($leadData['assigned_to'] ?? null) { - \App\Models\LeadAssignment::create([ + LeadAssignment::create([ 'lead_id' => $lead->id, 'user_id' => $leadData['assigned_to'], 'assigned_by' => $assignedById, @@ -166,6 +170,7 @@ class ImportService $leadData[$field] = is_string($data[$columnIndex]) ? trim($data[$columnIndex]) : $data[$columnIndex]; } } + return $leadData; } @@ -173,7 +178,7 @@ class ImportService { $contact = Contact::create([ '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' => 'رابط', 'description' => 'مخاطب اولیه import', 'status' => 'active', diff --git a/backend/app/Services/InvoiceService.php b/backend/app/Services/InvoiceService.php new file mode 100644 index 0000000..b6cc138 --- /dev/null +++ b/backend/app/Services/InvoiceService.php @@ -0,0 +1,427 @@ + 'شماره فاکتور', + 'invoice.issue_date' => 'تاریخ صدور', + 'customer.name' => 'نام خریدار', + 'customer.company' => 'نام کسب‌وکار', + 'customer.phone' => 'تلفن خریدار', + 'customer.email' => 'ایمیل خریدار', + 'customer.address' => 'نشانی خریدار', + 'lead.product' => 'محصول یا خدمت', + 'lead.contract_date' => 'تاریخ قرارداد', + 'lead.payment_status' => 'وضعیت پرداخت', + 'invoice.items_summary' => 'خلاصه اقلام', + 'items.1.description' => 'ردیف ۱ — شرح', + 'items.1.quantity' => 'ردیف ۱ — تعداد', + 'items.1.unit_price' => 'ردیف ۱ — مبلغ واحد', + 'items.1.line_total' => 'ردیف ۱ — مبلغ کل', + 'items.2.description' => 'ردیف ۲ — شرح', + 'items.2.quantity' => 'ردیف ۲ — تعداد', + 'items.2.unit_price' => 'ردیف ۲ — مبلغ واحد', + 'items.2.line_total' => 'ردیف ۲ — مبلغ کل', + 'items.3.description' => 'ردیف ۳ — شرح', + 'items.3.quantity' => 'ردیف ۳ — تعداد', + 'items.3.unit_price' => 'ردیف ۳ — مبلغ واحد', + 'items.3.line_total' => 'ردیف ۳ — مبلغ کل', + 'items.4.description' => 'ردیف ۴ — شرح', + 'items.4.quantity' => 'ردیف ۴ — تعداد', + 'items.4.unit_price' => 'ردیف ۴ — مبلغ واحد', + 'items.4.line_total' => 'ردیف ۴ — مبلغ کل', + 'items.5.description' => 'ردیف ۵ — شرح', + 'items.5.quantity' => 'ردیف ۵ — تعداد', + 'items.5.unit_price' => 'ردیف ۵ — مبلغ واحد', + 'items.5.line_total' => 'ردیف ۵ — مبلغ کل', + 'items.6.description' => 'ردیف ۶ — شرح', + 'items.6.quantity' => 'ردیف ۶ — تعداد', + 'items.6.unit_price' => 'ردیف ۶ — مبلغ واحد', + 'items.6.line_total' => 'ردیف ۶ — مبلغ کل', + 'items.7.description' => 'ردیف ۷ — شرح', + 'items.7.quantity' => 'ردیف ۷ — تعداد', + 'items.7.unit_price' => 'ردیف ۷ — مبلغ واحد', + 'items.7.line_total' => 'ردیف ۷ — مبلغ کل', + 'invoice.subtotal' => 'جمع قبل از مالیات', + 'invoice.discount' => 'تخفیف', + 'invoice.tax' => 'مالیات', + 'invoice.total' => 'مبلغ قابل پرداخت', + 'invoice.paid_amount' => 'مبلغ پرداخت‌شده', + 'invoice.balance_due' => 'مانده قابل پرداخت', + 'invoice.currency' => 'واحد پول', + 'invoice.notes' => 'توضیحات فاکتور', + 'seller.name' => 'نام صادرکننده', + ]; + + public function createFromLead(Lead $lead, User $actor, array $payload = []): Invoice + { + abort_unless($lead->final_result === 'موفق', 422, 'فقط لید منجر به فروش قابل تبدیل به فاکتور است.'); + + $duplicate = Invoice::where('lead_id', $lead->id) + ->whereIn('status', ['draft', 'pending_approval', 'approved', 'issued']) + ->latest('id') + ->first(); + if ($duplicate) { + abort(409, 'برای این لید قبلاً فاکتور فعال ایجاد شده است.'); + } + + $template = ! empty($payload['invoice_template_id']) + ? InvoiceTemplate::where('is_active', true)->findOrFail($payload['invoice_template_id']) + : $this->defaultTemplate($actor); + $items = $this->normalizeItems($payload['items'] ?? [[ + 'description' => $lead->sold_product ?: $lead->product_interest ?: 'محصول / خدمت', + 'quantity' => 1, + 'unit_price' => (float) ($lead->deal_value ?? 0), + ]]); + $totals = $this->calculateTotals($items, (float) ($payload['discount'] ?? 0), (float) ($payload['tax'] ?? 0)); + $paidAmount = $this->normalizePaidAmount((float) ($payload['paid_amount'] ?? 0), $totals['total']); + $customer = array_merge($this->customerSnapshot($lead), $payload['customer_snapshot'] ?? []); + + $invoice = DB::transaction(function () use ($lead, $actor, $payload, $template, $items, $totals, $customer, $paidAmount): Invoice { + $invoice = Invoice::create([ + 'lead_id' => $lead->id, + 'invoice_template_id' => $template->id, + 'created_by' => $actor->id, + 'status' => 'pending_approval', + 'currency' => $payload['currency'] ?? 'IRR', + 'customer_snapshot' => $customer, + 'seller_snapshot' => $payload['seller_snapshot'] ?? ['name' => $actor->name], + 'lead_snapshot' => $this->leadSnapshot($lead), + 'items' => $items, + 'subtotal' => $totals['subtotal'], + 'discount' => $totals['discount'], + 'tax' => $totals['tax'], + 'total' => $totals['total'], + 'paid_amount' => $paidAmount, + 'payment_status' => $this->paymentStatus($paidAmount, $totals['total']), + 'notes' => $payload['notes'] ?? $lead->customer_notes, + 'payment_terms' => $payload['payment_terms'] ?? null, + 'due_date' => $payload['due_date'] ?? null, + 'page_width_mm' => $payload['page_width_mm'] ?? $template->page_width_mm ?? 210, + 'page_height_mm' => $payload['page_height_mm'] ?? $template->page_height_mm ?? 297, + ]); + $invoice->update(['number' => 'INV-'.now()->format('Y').'-'.str_pad((string) $invoice->id, 6, '0', STR_PAD_LEFT)]); + $resolved = $this->resolveTemplateFields($invoice->fresh(), $template, $actor); + if (! empty($payload['resolved_fields'])) { + $resolved = $this->applyLayoutOverrides($resolved, $payload['resolved_fields']); + } + $invoice->update(['resolved_fields' => $resolved]); + ActivityLogger::log('invoice_requested', "Invoice {$invoice->number} requested from lead {$lead->id}", $invoice); + + return $invoice->fresh($this->relations()); + }); + + User::permission('approve_invoices')->where('is_active', true)->whereKeyNot($actor->id)->each(function (User $approver) use ($invoice, $actor): void { + NotificationService::send($approver->id, 'درخواست تأیید فاکتور', "فاکتور {$invoice->number} توسط {$actor->name} برای تأیید ارسال شد", 'invoice_approval', [ + 'invoice_id' => $invoice->id, + 'url' => "/invoices?invoice={$invoice->id}", + ]); + }); + + return $invoice; + } + + public function updateDraft(Invoice $invoice, array $payload, User $actor): Invoice + { + $wasRejected = $invoice->status === 'rejected'; + $items = array_key_exists('items', $payload) ? $this->normalizeItems($payload['items']) : $invoice->items; + $discount = (float) ($payload['discount'] ?? $invoice->discount); + $tax = (float) ($payload['tax'] ?? $invoice->tax); + $totals = $this->calculateTotals($items, $discount, $tax); + $paidAmount = $this->normalizePaidAmount((float) ($payload['paid_amount'] ?? $invoice->paid_amount), $totals['total']); + $template = ! empty($payload['invoice_template_id']) + ? InvoiceTemplate::where('is_active', true)->findOrFail($payload['invoice_template_id']) + : $invoice->template; + + $invoice->fill([ + 'invoice_template_id' => $template?->id, + 'customer_snapshot' => $payload['customer_snapshot'] ?? $invoice->customer_snapshot, + 'seller_snapshot' => $payload['seller_snapshot'] ?? $invoice->seller_snapshot, + 'items' => $items, + 'subtotal' => $totals['subtotal'], + 'discount' => $totals['discount'], + 'tax' => $totals['tax'], + 'total' => $totals['total'], + 'paid_amount' => $paidAmount, + 'payment_status' => $this->paymentStatus($paidAmount, $totals['total']), + 'status' => $wasRejected ? 'pending_approval' : $invoice->status, + 'rejected_at' => $wasRejected ? null : $invoice->rejected_at, + 'rejection_reason' => $wasRejected ? null : $invoice->rejection_reason, + 'notes' => array_key_exists('notes', $payload) ? $payload['notes'] : $invoice->notes, + 'payment_terms' => array_key_exists('payment_terms', $payload) ? $payload['payment_terms'] : $invoice->payment_terms, + 'due_date' => array_key_exists('due_date', $payload) ? $payload['due_date'] : $invoice->due_date, + 'page_width_mm' => $payload['page_width_mm'] ?? $invoice->page_width_mm, + 'page_height_mm' => $payload['page_height_mm'] ?? $invoice->page_height_mm, + 'version' => $invoice->version + 1, + ])->save(); + $resolved = $this->resolveTemplateFields($invoice->fresh(), $template, $actor); + if (! empty($payload['resolved_fields'])) { + $resolved = $this->applyLayoutOverrides($resolved, $payload['resolved_fields']); + } + $invoice->update(['resolved_fields' => $resolved]); + ActivityLogger::log('invoice_reviewed', "Invoice {$invoice->number} reviewed", $invoice); + + if ($wasRejected) { + User::permission('approve_invoices')->where('is_active', true)->whereKeyNot($actor->id)->each(function (User $approver) use ($invoice, $actor): void { + NotificationService::send($approver->id, 'فاکتور اصلاح و دوباره ارسال شد', "فاکتور {$invoice->number} توسط {$actor->name} دوباره برای تأیید ارسال شد", 'invoice_approval', [ + 'invoice_id' => $invoice->id, + 'url' => "/invoices?invoice={$invoice->id}", + ]); + }); + } + + return $invoice->fresh($this->relations()); + } + + public function issue(Invoice $invoice, User $actor): Invoice + { + abort_unless(in_array($invoice->status, ['pending_approval', 'approved'], true), 422, 'فقط فاکتور تأییدشده قابل صدور است.'); + abort_if((float) $invoice->total < 0, 422, 'مبلغ نهایی فاکتور معتبر نیست.'); + + $invoice->update([ + 'status' => 'issued', + 'approved_by' => $actor->id, + 'approved_at' => $invoice->approved_at ?? now(), + 'issued_at' => now(), + 'version' => $invoice->version + 1, + ]); + $resolved = $this->resolveTemplateFields($invoice->fresh(), $invoice->template, $actor); + $invoice->update(['resolved_fields' => $this->applyLayoutOverrides($resolved, $invoice->resolved_fields ?? [])]); + ActivityLogger::log('invoice_issued', "Invoice {$invoice->number} issued", $invoice); + $this->notifyCreator($invoice, 'فاکتور صادر شد', "فاکتور {$invoice->number} تأیید و صادر شد"); + + return $invoice->fresh($this->relations()); + } + + public function approve(Invoice $invoice, User $actor): Invoice + { + abort_unless($invoice->status === 'pending_approval', 422, 'فقط فاکتور در انتظار بررسی قابل تأیید است.'); + $invoice->update([ + 'status' => 'approved', + 'approved_by' => $actor->id, + 'approved_at' => now(), + 'rejected_at' => null, + 'rejection_reason' => null, + 'version' => $invoice->version + 1, + ]); + ActivityLogger::log('invoice_approved', "Invoice {$invoice->number} approved", $invoice); + $this->notifyCreator($invoice, 'فاکتور تأیید شد', "فاکتور {$invoice->number} تأیید و آماده صدور است"); + + return $invoice->fresh($this->relations()); + } + + public function reject(Invoice $invoice, User $actor, string $reason): Invoice + { + abort_unless($invoice->status === 'pending_approval', 422, 'فقط فاکتور در انتظار بررسی قابل رد است.'); + $invoice->update([ + 'status' => 'rejected', + 'approved_by' => $actor->id, + 'rejected_at' => now(), + 'rejection_reason' => $reason, + 'version' => $invoice->version + 1, + ]); + ActivityLogger::log('invoice_rejected', "Invoice {$invoice->number} rejected", $invoice); + $this->notifyCreator($invoice, 'فاکتور نیازمند اصلاح است', "فاکتور {$invoice->number} رد شد: {$reason}"); + + return $invoice->fresh($this->relations()); + } + + public function void(Invoice $invoice, User $actor): Invoice + { + $invoice->update(['status' => 'void', 'voided_at' => now(), 'version' => $invoice->version + 1]); + ActivityLogger::log('invoice_voided', "Invoice {$invoice->number} voided", $invoice); + + return $invoice->fresh($this->relations()); + } + + public function defaultTemplate(User $actor): InvoiceTemplate + { + $template = InvoiceTemplate::where('is_active', true)->where('is_default', true)->first(); + if ($template) { + return $template; + } + + return InvoiceTemplate::create([ + 'name' => 'قالب استاندارد فاکتور', + 'is_default' => true, + 'is_active' => true, + 'created_by' => $actor->id, + 'layout' => $this->defaultLayout(), + ]); + } + + public function defaultLayout(): array + { + return [ + ['id' => 'number', 'label' => 'شماره فاکتور', 'source' => 'invoice.number', 'x' => 68, 'y' => 8, 'width' => 24, 'font_size' => 12, 'align' => 'right'], + ['id' => 'date', 'label' => 'تاریخ صدور', 'source' => 'invoice.issue_date', 'x' => 68, 'y' => 13, 'width' => 24, 'font_size' => 11, 'align' => 'right'], + ['id' => 'customer', 'label' => 'خریدار', 'source' => 'customer.name', 'x' => 8, 'y' => 24, 'width' => 40, 'font_size' => 12, 'align' => 'right'], + ['id' => 'company', 'label' => 'کسب‌وکار', 'source' => 'customer.company', 'x' => 52, 'y' => 24, 'width' => 40, 'font_size' => 12, 'align' => 'right'], + ['id' => 'phone', 'label' => 'تلفن', 'source' => 'customer.phone', 'x' => 8, 'y' => 30, 'width' => 30, 'font_size' => 11, 'align' => 'right'], + ['id' => 'items', 'label' => 'شرح اقلام', 'source' => 'invoice.items_summary', 'x' => 8, 'y' => 42, 'width' => 84, 'font_size' => 12, 'align' => 'right'], + ['id' => 'total', 'label' => 'مبلغ نهایی', 'source' => 'invoice.total', 'x' => 60, 'y' => 78, 'width' => 32, 'font_size' => 15, 'align' => 'right'], + ['id' => 'notes', 'label' => 'توضیحات', 'source' => 'invoice.notes', 'x' => 8, 'y' => 86, 'width' => 84, 'font_size' => 10, 'align' => 'right'], + ]; + } + + public function resolveTemplateFields(Invoice $invoice, ?InvoiceTemplate $template, User $actor): array + { + $sources = [ + 'invoice.number' => $invoice->number, + 'invoice.issue_date' => ($invoice->issued_at ?? now())->format('Y-m-d'), + 'customer.name' => Arr::get($invoice->customer_snapshot, 'name'), + 'customer.company' => Arr::get($invoice->customer_snapshot, 'company'), + 'customer.phone' => Arr::get($invoice->customer_snapshot, 'phone'), + 'customer.email' => Arr::get($invoice->customer_snapshot, 'email'), + 'customer.address' => Arr::get($invoice->customer_snapshot, 'address'), + 'lead.product' => Arr::get($invoice->lead_snapshot, 'sold_product'), + 'lead.contract_date' => Arr::get($invoice->lead_snapshot, 'contract_date'), + 'lead.payment_status' => Arr::get($invoice->lead_snapshot, 'payment_status'), + 'invoice.items_summary' => collect($invoice->items)->map(fn (array $item) => ($item['description'] ?? 'قلم').' × '.($item['quantity'] ?? 1))->implode(' | '), + 'invoice.subtotal' => number_format((float) $invoice->subtotal), + 'invoice.discount' => number_format((float) $invoice->discount), + 'invoice.tax' => number_format((float) $invoice->tax), + 'invoice.total' => number_format((float) $invoice->total), + 'invoice.paid_amount' => number_format((float) $invoice->paid_amount), + 'invoice.balance_due' => number_format(max(0, (float) $invoice->total - (float) $invoice->paid_amount)), + 'invoice.currency' => $invoice->currency, + 'invoice.notes' => $invoice->notes, + 'seller.name' => $actor->name, + ]; + foreach (array_slice($invoice->items ?? [], 0, 7) as $index => $item) { + $row = $index + 1; + $quantity = (float) ($item['quantity'] ?? 0); + $unitPrice = (float) ($item['unit_price'] ?? 0); + $sources["items.{$row}.description"] = (string) ($item['description'] ?? ''); + $sources["items.{$row}.quantity"] = rtrim(rtrim(number_format($quantity, 2, '.', ''), '0'), '.'); + $sources["items.{$row}.unit_price"] = number_format($unitPrice); + $sources["items.{$row}.line_total"] = number_format((float) ($item['line_total'] ?? ($quantity * $unitPrice))); + } + foreach ((array) Arr::get($invoice->lead_snapshot, 'custom_fields', []) as $key => $value) { + $sources['custom.'.$key] = is_scalar($value) ? (string) $value : json_encode($value, JSON_UNESCAPED_UNICODE); + } + + return collect($template?->layout ?: $this->defaultLayout())->mapWithKeys(function (array $field) use ($sources): array { + $id = (string) ($field['id'] ?? uniqid('field_', true)); + + return [$id => array_merge($field, ['value' => (string) ($sources[$field['source'] ?? ''] ?? ($field['default'] ?? ''))])]; + })->all(); + } + + private function customerSnapshot(Lead $lead): array + { + return [ + 'name' => trim($lead->first_name.' '.$lead->last_name), + 'company' => $lead->company, + 'phone' => $lead->phone, + 'email' => $lead->email, + 'address' => trim(implode('، ', array_filter([$lead->province, $lead->city]))), + 'national_code' => $lead->national_code, + ]; + } + + private function leadSnapshot(Lead $lead): array + { + $lead->loadMissing('customFieldValues.definition'); + + return [ + 'id' => $lead->id, + 'source' => $lead->source, + 'sold_product' => $lead->sold_product ?: $lead->product_interest, + 'deal_value' => $lead->deal_value, + 'contract_date' => optional($lead->contract_date)->format('Y-m-d'), + 'payment_status' => $lead->payment_status, + 'customer_notes' => $lead->customer_notes, + 'custom_fields' => $lead->customFieldValues->mapWithKeys(fn ($value) => [$value->definition?->key ?? (string) $value->custom_field_definition_id => $value->value])->all(), + ]; + } + + private function normalizeItems(array $items): array + { + abort_if($items === [], 422, 'فاکتور باید حداقل یک قلم داشته باشد.'); + + return collect($items)->map(function (array $item): array { + $description = trim((string) ($item['description'] ?? '')); + $quantity = max(0.01, (float) ($item['quantity'] ?? 1)); + $unitPrice = max(0, (float) ($item['unit_price'] ?? 0)); + abort_if($description === '', 422, 'شرح قلم فاکتور الزامی است.'); + + return [ + 'description' => $description, + 'unit' => trim((string) ($item['unit'] ?? 'عدد')) ?: 'عدد', + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'line_total' => round($quantity * $unitPrice, 2), + ]; + })->values()->all(); + } + + private function calculateTotals(array $items, float $discount, float $tax): array + { + $subtotal = round((float) collect($items)->sum('line_total'), 2); + $discount = max(0, min($subtotal, $discount)); + $tax = max(0, $tax); + + return compact('subtotal', 'discount', 'tax') + ['total' => round($subtotal - $discount + $tax, 2)]; + } + + private function normalizePaidAmount(float $paidAmount, float $total): float + { + abort_if($paidAmount < 0 || $paidAmount > $total, 422, 'مبلغ پرداخت‌شده باید بین صفر و مبلغ نهایی باشد.'); + + return round($paidAmount, 2); + } + + private function paymentStatus(float $paidAmount, float $total): string + { + if ($paidAmount <= 0) { + return 'unpaid'; + } + if ($paidAmount >= $total) { + return 'paid'; + } + + return 'partial'; + } + + private function notifyCreator(Invoice $invoice, string $title, string $message): void + { + if (! $invoice->created_by) { + return; + } + NotificationService::send($invoice->created_by, $title, $message, 'invoice', [ + 'invoice_id' => $invoice->id, + 'url' => "/invoices?invoice={$invoice->id}", + ]); + } + + private function relations(): array + { + return ['lead:id,first_name,last_name,company,assigned_to,final_result', 'template', 'creator:id,name', 'approver:id,name']; + } + + private function applyLayoutOverrides(array $resolved, array $overrides): array + { + foreach ($overrides as $id => $field) { + if (! isset($resolved[$id]) || ! is_array($field)) { + continue; + } + foreach (['x', 'y', 'width', 'font_size', 'align'] as $key) { + if (array_key_exists($key, $field)) { + $resolved[$id][$key] = $field[$key]; + } + } + } + + return $resolved; + } +} diff --git a/backend/app/Services/InvoiceWordService.php b/backend/app/Services/InvoiceWordService.php new file mode 100644 index 0000000..59c2de6 --- /dev/null +++ b/backend/app/Services/InvoiceWordService.php @@ -0,0 +1,157 @@ +loadMissing('creator:id,name'); + $phpWord = new PhpWord; + $phpWord->setDefaultFontName('B Nazanin'); + $phpWord->setDefaultFontSize(11); + + $section = $phpWord->addSection([ + 'pageSizeW' => Converter::cmToTwip(21), + 'pageSizeH' => Converter::cmToTwip(29.7), + 'marginTop' => Converter::cmToTwip(1.2), + 'marginRight' => Converter::cmToTwip(1.2), + 'marginBottom' => Converter::cmToTwip(1.2), + 'marginLeft' => Converter::cmToTwip(1.2), + ]); + + $rtl = ['alignment' => 'right', 'bidi' => true, 'spaceAfter' => 0]; + $center = ['alignment' => 'center', 'bidi' => true, 'spaceAfter' => 0]; + $font = ['name' => 'B Nazanin', 'size' => 11]; + $bold = $font + ['bold' => true]; + $heading = $font + ['bold' => true, 'size' => 17, 'color' => '172554']; + $headerCell = ['bgColor' => '172554', 'valign' => 'center']; + $borderCell = ['borderSize' => 4, 'borderColor' => '94A3B8', 'valign' => 'center']; + + $section->addText('فاکتور فروش', $heading, $center); + $section->addTextBreak(1); + + $meta = $section->addTable(['alignment' => JcTable::CENTER, 'width' => 100 * 50, 'unit' => 'pct', 'borderSize' => 4, 'borderColor' => '94A3B8']); + $meta->addRow(520); + $this->addLabelValue($meta, 'شماره فاکتور', $invoice->number ?: '—', $headerCell, $borderCell, $font, $bold); + $this->addLabelValue($meta, 'تاریخ صدور', $this->date($invoice->issued_at ?? $invoice->created_at), $headerCell, $borderCell, $font, $bold); + + $this->addSectionTitle($section, 'مشخصات فروشنده', $headerCell, $bold); + $this->addPartyTable($section, $invoice->seller_snapshot ?: ['name' => $invoice->creator?->name], $font, $bold, $borderCell); + $this->addSectionTitle($section, 'مشخصات خریدار', $headerCell, $bold); + $this->addPartyTable($section, $invoice->customer_snapshot ?: [], $font, $bold, $borderCell); + + $this->addSectionTitle($section, 'مشخصات کالا یا خدمات', $headerCell, $bold); + $items = $section->addTable(['alignment' => JcTable::CENTER, 'width' => 100 * 50, 'unit' => 'pct', 'borderSize' => 4, 'borderColor' => '94A3B8']); + $items->addRow(520); + foreach (['ردیف', 'شرح کالا یا خدمت', 'واحد', 'تعداد', 'مبلغ واحد', 'مبلغ کل'] as $index => $label) { + $widths = [700, 4300, 900, 900, 1600, 1800]; + $items->addCell($widths[$index], $headerCell)->addText($label, $bold + ['color' => 'FFFFFF'], $center); + } + foreach (array_values($invoice->items ?: []) as $index => $item) { + $items->addRow(500); + $values = [ + $index + 1, + $item['description'] ?? '—', + $item['unit'] ?? 'عدد', + $this->number($item['quantity'] ?? 0), + $this->money($item['unit_price'] ?? 0), + $this->money($item['line_total'] ?? 0), + ]; + foreach ($values as $column => $value) { + $widths = [700, 4300, 900, 900, 1600, 1800]; + $items->addCell($widths[$column], $borderCell)->addText((string) $value, $font, $column === 1 ? $rtl : $center); + } + } + + $section->addTextBreak(1); + $totals = $section->addTable(['alignment' => JcTable::END, 'width' => 55 * 50, 'unit' => 'pct', 'borderSize' => 4, 'borderColor' => '94A3B8']); + foreach ([ + 'جمع اقلام' => $invoice->subtotal, + 'تخفیف' => $invoice->discount, + 'مالیات و عوارض' => $invoice->tax, + 'مبلغ نهایی' => $invoice->total, + 'پرداخت‌شده' => $invoice->paid_amount, + 'مانده قابل پرداخت' => max(0, (float) $invoice->total - (float) $invoice->paid_amount), + ] as $label => $value) { + $totals->addRow(480); + $totals->addCell(2400, $headerCell)->addText($label, $bold + ['color' => 'FFFFFF'], $rtl); + $totals->addCell(2600, $borderCell)->addText($this->money($value).' ریال', $bold, $rtl); + } + + if ($invoice->payment_terms) { + $this->addSectionTitle($section, 'شرایط پرداخت', $headerCell, $bold); + $section->addText($invoice->payment_terms, $font, $rtl); + } + if ($invoice->notes) { + $this->addSectionTitle($section, 'توضیحات', $headerCell, $bold); + $section->addText($invoice->notes, $font, $rtl); + } + + $section->addTextBreak(2); + $signature = $section->addTable(['alignment' => JcTable::CENTER, 'width' => 100 * 50, 'unit' => 'pct']); + $signature->addRow(900); + $signature->addCell(5000)->addText('مهر و امضای فروشنده', $bold, $center); + $signature->addCell(5000)->addText('مهر و امضای خریدار', $bold, $center); + + Storage::disk('local')->makeDirectory('invoice-exports'); + $filename = "invoice-{$invoice->number}.docx"; + $path = Storage::disk('local')->path('invoice-exports/'.uniqid('invoice-', true).'.docx'); + $phpWord->save($path, 'Word2007'); + + return compact('path', 'filename'); + } + + private function addSectionTitle($section, string $title, array $cellStyle, array $font): void + { + $section->addTextBreak(1); + $table = $section->addTable(['width' => 100 * 50, 'unit' => 'pct']); + $table->addRow(440); + $table->addCell(10000, $cellStyle)->addText($title, $font + ['color' => 'FFFFFF'], ['alignment' => 'center', 'bidi' => true, 'spaceAfter' => 0]); + } + + private function addPartyTable($section, array $party, array $font, array $bold, array $cell): void + { + $table = $section->addTable(['alignment' => JcTable::CENTER, 'width' => 100 * 50, 'unit' => 'pct', 'borderSize' => 4, 'borderColor' => '94A3B8']); + $fields = [ + ['نام', $party['name'] ?? '—', 'نام/شرکت', $party['company'] ?? $party['name'] ?? '—'], + ['شناسه/کد ملی', $party['national_code'] ?? $party['national_id'] ?? '—', 'کد اقتصادی', $party['economic_code'] ?? '—'], + ['تلفن', $party['phone'] ?? '—', 'کد پستی', $party['postal_code'] ?? '—'], + ['نشانی', $party['address'] ?? '—', 'ایمیل', $party['email'] ?? '—'], + ]; + foreach ($fields as [$label1, $value1, $label2, $value2]) { + $table->addRow(480); + foreach ([[$label1, $value1], [$label2, $value2]] as [$label, $value]) { + $table->addCell(1500, $cell + ['bgColor' => 'E2E8F0'])->addText($label, $bold, ['alignment' => 'right', 'bidi' => true, 'spaceAfter' => 0]); + $table->addCell(3500, $cell)->addText((string) $value, $font, ['alignment' => 'right', 'bidi' => true, 'spaceAfter' => 0]); + } + } + } + + private function addLabelValue($table, string $label, string $value, array $headerCell, array $cell, array $font, array $bold): void + { + $table->addCell(1800, $headerCell)->addText($label, $bold + ['color' => 'FFFFFF'], ['alignment' => 'right', 'bidi' => true, 'spaceAfter' => 0]); + $table->addCell(3200, $cell)->addText($value, $font, ['alignment' => 'right', 'bidi' => true, 'spaceAfter' => 0]); + } + + private function money(mixed $value): string + { + return number_format((float) $value, 0, '.', ','); + } + + private function number(mixed $value): string + { + return rtrim(rtrim(number_format((float) $value, 2, '.', ''), '0'), '.'); + } + + private function date($value): string + { + return $value ? $value->format('Y/m/d') : '—'; + } +} diff --git a/backend/app/Services/LeadScoringService.php b/backend/app/Services/LeadScoringService.php new file mode 100644 index 0000000..1e330ba --- /dev/null +++ b/backend/app/Services/LeadScoringService.php @@ -0,0 +1,28 @@ + min(20, collect([$lead->email, $lead->company, $lead->city, $lead->product_interest])->filter()->count() * 5), + 'priority' => min(20, max(0, (int) $lead->priority) * 5), + 'engagement' => min(30, $lead->calls()->count() * 5 + $lead->followUps()->whereNotNull('completed_at')->count() * 5), + 'recency' => $lead->last_call_at?->gte(now()->subDays(7)) ? 15 : 0, + 'intent' => in_array($lead->interest_level, ['high', 'very_high'], true) ? 15 : ($lead->interest_level === 'medium' ? 8 : 0), + ]; + $score = min(100, array_sum($breakdown)); + $level = $score >= 70 ? 'hot' : ($score >= 40 ? 'warm' : 'cold'); + $lead->update(['lead_score' => $score, 'score_level' => $level, 'score_breakdown' => $breakdown, 'scored_at' => now()]); + ActivityLogger::log('lead_scored', "Lead {$lead->id} scored {$score}", $lead, null, ['score' => $score, 'level' => $level]); + $this->automations->dispatch('lead_scored', $lead, "lead-score:{$lead->id}:{$lead->scored_at?->timestamp}", ['score' => $score, 'score_level' => $level]); + + return $lead->fresh(); + } +} diff --git a/backend/app/Services/LeadService.php b/backend/app/Services/LeadService.php index 099b17c..a299383 100644 --- a/backend/app/Services/LeadService.php +++ b/backend/app/Services/LeadService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Lead; +use App\Models\LeadAssignment; use App\Models\PipelineStage; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; @@ -14,6 +15,7 @@ class LeadService $query = $this->getFilteredLeadQuery($filters); $perPage = min(max((int) ($filters['per_page'] ?? 15), 1), 100); + return $query->paginate($perPage); } @@ -30,14 +32,14 @@ class LeadService 'contacts.phones.lastCaller:id,name', ]); - if (!empty($filters['search'])) { + if (! empty($filters['search'])) { $search = $filters['search']; $query->where(function ($q) use ($search) { $q->where('first_name', 'like', "%{$search}%") - ->orWhere('last_name', 'like', "%{$search}%") - ->orWhere('phone', 'like', "%{$search}%") - ->orWhere('email', 'like', "%{$search}%") - ->orWhere('company', 'like', "%{$search}%"); + ->orWhere('last_name', 'like', "%{$search}%") + ->orWhere('phone', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%") + ->orWhere('company', 'like', "%{$search}%"); }); } @@ -49,12 +51,12 @@ class LeadService $query->where('pipeline_stage_id', $filters['pipeline_stage_id']); } - if (!empty($filters['include_unassigned_for_agent']) && isset($filters['assigned_to'])) { + if (! empty($filters['include_unassigned_for_agent']) && isset($filters['assigned_to'])) { $query->where(function ($q) use ($filters) { $q->where('assigned_to', $filters['assigned_to']) - ->orWhere(function ($inner) { - $inner->whereNull('assigned_to')->orWhere('is_unassigned', true); - }); + ->orWhere(function ($inner) { + $inner->whereNull('assigned_to')->orWhere('is_unassigned', true); + }); }); } elseif (isset($filters['assigned_to'])) { $query->where('assigned_to', $filters['assigned_to']); @@ -167,12 +169,12 @@ class LeadService ]; foreach ($aliases as $alias => $canonical) { - if (!isset($filters[$canonical]) && isset($filters[$alias])) { + if (! isset($filters[$canonical]) && isset($filters[$alias])) { $filters[$canonical] = $filters[$alias]; } } - return array_filter($filters, fn($value) => $value !== '' && $value !== null); + return array_filter($filters, fn ($value) => $value !== '' && $value !== null); } public function assignLead(Lead $lead, int $agentId, int $assignedById, string $method = 'manual'): Lead @@ -184,14 +186,19 @@ class LeadService 'pipeline_stage_id' => PipelineStage::where('slug', 'waiting_call')->value('id') ?? $lead->pipeline_stage_id, ]); - \App\Models\LeadAssignment::create([ + LeadAssignment::create([ 'lead_id' => $lead->id, 'user_id' => $agentId, 'assigned_by' => $assignedById, 'method' => $method, ]); - NotificationService::notifyAssignment($agentId, $lead->full_name ?: ($lead->company ?? "لید {$lead->id}")); + NotificationService::notifyAssignment( + $agentId, + $lead->full_name ?: ($lead->company ?? "لید {$lead->id}"), + $lead->id, + "lead:{$lead->id}:assignment:{$agentId}:{$lead->updated_at?->getTimestamp()}" + ); ActivityLogger::log('lead_assigned', "Lead {$lead->id} assigned to user {$agentId}", $lead); return $lead->fresh(); @@ -211,6 +218,7 @@ class LeadService $results[] = $this->assignLead($lead, $agentId, $assignedById, 'round_robin'); $index++; } + return $results; } } diff --git a/backend/app/Services/LegacyCallNoteBackfillService.php b/backend/app/Services/LegacyCallNoteBackfillService.php new file mode 100644 index 0000000..cf3291a --- /dev/null +++ b/backend/app/Services/LegacyCallNoteBackfillService.php @@ -0,0 +1,40 @@ +whereNotNull('notes') + ->where('notes', '<>', '') + ->orderBy('id') + ->chunkById(100, function ($calls) use (&$created): void { + foreach ($calls as $call) { + $note = Note::firstOrCreate( + ['source_key' => "legacy_call:{$call->id}"], + [ + 'notable_type' => Call::class, + 'notable_id' => $call->id, + 'user_id' => $call->user_id, + 'content' => $call->notes, + 'type' => 'call_summary', + 'visibility' => 'team', + 'is_pinned' => false, + 'created_at' => $call->updated_at ?? $call->created_at ?? now(), + 'updated_at' => $call->updated_at ?? now(), + ] + ); + $created += $note->wasRecentlyCreated ? 1 : 0; + } + }); + + return $created; + } +} diff --git a/backend/app/Services/NotificationService.php b/backend/app/Services/NotificationService.php index 2174e39..967a5ab 100644 --- a/backend/app/Services/NotificationService.php +++ b/backend/app/Services/NotificationService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\FollowUp; use App\Models\Notification as NotificationModel; use App\Models\Setting; use App\Models\User; @@ -10,8 +11,8 @@ class NotificationService { public static function send(int $userId, string $title, string $message, string $type = 'info', ?array $data = null): NotificationModel { - if (!self::enabled($type)) { - return new NotificationModel(); + if (! self::enabled($type)) { + return new NotificationModel; } return NotificationModel::create([ @@ -35,19 +36,58 @@ class NotificationService return $exists ? null : self::send($userId, $title, $message, $type, $data); } - public static function notifyAssignment(int $userId, string $leadName): void + public static function notifyAssignment(int $userId, string $leadName, ?int $leadId = null, ?string $eventKey = null): void { - self::send($userId, 'لید جدید', "لید {$leadName} به شما اختصاص داده شد", 'assignment'); + if (! self::enabled('assignment')) { + return; + } + + $attributes = [ + 'user_id' => $userId, + 'title' => 'لید جدید', + 'message' => "لید {$leadName} به شما اختصاص داده شد", + 'type' => 'assignment', + 'data' => $leadId ? ['lead_id' => $leadId, 'url' => "/leads/{$leadId}"] : null, + 'is_read' => false, + ]; + + if ($eventKey) { + NotificationModel::firstOrCreate(['idempotency_key' => $eventKey], $attributes); + } else { + NotificationModel::create($attributes); + } } - public static function notifyFollowUpReminder(int $userId, string $leadName): void + public static function notifyFollowUpReminder(int $userId, string $leadName, ?int $followUpId = null): void { - self::send($userId, 'یادآوری پیگیری', "موعد پیگیری لید {$leadName} رسیده است", 'follow_up'); + self::send($userId, 'یادآوری پیگیری', "موعد پیگیری لید {$leadName} رسیده است", 'follow_up', $followUpId ? [ + 'follow_up_id' => $followUpId, + 'url' => "/follow-ups?follow_up={$followUpId}", + ] : null); + } + + public static function notifyFollowUpAssigned(FollowUp $followUp, string $leadName, User $actor): void + { + if ($followUp->user_id === $actor->id) { + return; + } + + self::send( + $followUp->user_id, + 'پیگیری جدید به شما واگذار شد', + "پیگیری لید {$leadName} برای شما ثبت شد", + 'follow_up', + [ + 'follow_up_id' => $followUp->id, + 'url' => "/follow-ups?follow_up={$followUp->id}", + 'actor' => ['id' => $actor->id, 'name' => $actor->name], + ], + ); } public static function notifyFeedback(int $userId, string $agentName): void { - self::send($userId, 'بازخورد جدید', "بازخورد جدیدی برای شما ثبت شده است", 'feedback'); + self::send($userId, 'بازخورد جدید', 'بازخورد جدیدی برای شما ثبت شده است', 'feedback'); } public static function notifyReassignment(int $userId, string $leadName): void diff --git a/backend/app/Services/ReportService.php b/backend/app/Services/ReportService.php index a4b8274..e9b5b74 100644 --- a/backend/app/Services/ReportService.php +++ b/backend/app/Services/ReportService.php @@ -2,15 +2,18 @@ namespace App\Services; -use App\Models\Lead; use App\Models\Call; +use App\Models\CallResult; +use App\Models\Campaign; use App\Models\FollowUp; use App\Models\ImportBatch; +use App\Models\Lead; use App\Models\MergeHistory; +use App\Models\PipelineStage; use App\Models\QualityReview; -use App\Models\User; +use App\Models\Setting; use App\Models\Team; -use App\Models\CallResult; +use App\Models\User; use Carbon\Carbon; class ReportService @@ -85,7 +88,7 @@ class ReportService public function campaignReport(int $campaignId, ?string $dateFrom = null, ?string $dateTo = null): array { - $campaign = \App\Models\Campaign::with('assignedAgents')->findOrFail($campaignId); + $campaign = Campaign::with('assignedAgents')->findOrFail($campaignId); $leads = Lead::where('campaign_id', $campaignId); if ($dateFrom) { @@ -122,18 +125,26 @@ class ReportService public function conversionReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array { $leadsQuery = Lead::query(); - if ($dateFrom) $leadsQuery->whereDate('created_at', '>=', $dateFrom); - if ($dateTo) $leadsQuery->whereDate('created_at', '<=', $dateTo); - if ($agentId) $leadsQuery->where('assigned_to', $agentId); - elseif (is_array($agentIds) && $agentIds !== []) $leadsQuery->whereIn('assigned_to', $agentIds); - elseif (is_array($agentIds)) $leadsQuery->whereRaw('1 = 0'); + if ($dateFrom) { + $leadsQuery->whereDate('created_at', '>=', $dateFrom); + } + if ($dateTo) { + $leadsQuery->whereDate('created_at', '<=', $dateTo); + } + if ($agentId) { + $leadsQuery->where('assigned_to', $agentId); + } elseif (is_array($agentIds) && $agentIds !== []) { + $leadsQuery->whereIn('assigned_to', $agentIds); + } elseif (is_array($agentIds)) { + $leadsQuery->whereRaw('1 = 0'); + } $total = (clone $leadsQuery)->count(); $won = (clone $leadsQuery)->where('final_result', 'موفق')->count(); $lost = (clone $leadsQuery)->where('final_result', 'ناموفق')->count(); $today = Carbon::today(); $now = Carbon::now(); - $stages = \App\Models\PipelineStage::orderBy('sort_order')->get(); + $stages = PipelineStage::orderBy('sort_order')->get(); $byStage = []; foreach ($stages as $stage) { @@ -170,7 +181,7 @@ class ReportService ->orderByDesc('count') ->get(), 'proposal_sent_not_closed' => (clone $leadsQuery) - ->whereHas('pipelineStage', fn($q) => $q->where('slug', 'proposal_sent')) + ->whereHas('pipelineStage', fn ($q) => $q->where('slug', 'proposal_sent')) ->whereNull('final_result') ->count(), ]; @@ -179,14 +190,22 @@ class ReportService public function callReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array { $query = Call::query(); - if ($dateFrom) $query->whereDate('created_at', '>=', $dateFrom); - if ($dateTo) $query->whereDate('created_at', '<=', $dateTo); - if ($agentId) $query->where('user_id', $agentId); - elseif (is_array($agentIds) && $agentIds !== []) $query->whereIn('user_id', $agentIds); - elseif (is_array($agentIds)) $query->whereRaw('1 = 0'); + if ($dateFrom) { + $query->whereDate('created_at', '>=', $dateFrom); + } + if ($dateTo) { + $query->whereDate('created_at', '<=', $dateTo); + } + if ($agentId) { + $query->where('user_id', $agentId); + } elseif (is_array($agentIds) && $agentIds !== []) { + $query->whereIn('user_id', $agentIds); + } elseif (is_array($agentIds)) { + $query->whereRaw('1 = 0'); + } $total = $query->count(); - $results = \App\Models\CallResult::all(); + $results = CallResult::all(); $byResult = []; foreach ($results as $result) { @@ -209,11 +228,19 @@ class ReportService public function followUpReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array { $query = FollowUp::query(); - if ($dateFrom) $query->whereDate('scheduled_at', '>=', $dateFrom); - if ($dateTo) $query->whereDate('scheduled_at', '<=', $dateTo); - if ($agentId) $query->where('user_id', $agentId); - elseif (is_array($agentIds) && $agentIds !== []) $query->whereIn('user_id', $agentIds); - elseif (is_array($agentIds)) $query->whereRaw('1 = 0'); + if ($dateFrom) { + $query->whereDate('scheduled_at', '>=', $dateFrom); + } + if ($dateTo) { + $query->whereDate('scheduled_at', '<=', $dateTo); + } + if ($agentId) { + $query->where('user_id', $agentId); + } elseif (is_array($agentIds) && $agentIds !== []) { + $query->whereIn('user_id', $agentIds); + } elseif (is_array($agentIds)) { + $query->whereRaw('1 = 0'); + } return [ 'total' => $query->count(), @@ -221,7 +248,7 @@ class ReportService 'completed' => (clone $query)->where('status', 'completed')->count(), 'overdue' => (clone $query)->where('is_overdue', true)->count(), 'items' => (clone $query)->with(['user:id,name', 'lead:id,company,first_name,last_name']) - ->where(fn($q) => $q->where('is_overdue', true)->orWhere('scheduled_at', '<', Carbon::now())) + ->where(fn ($q) => $q->where('is_overdue', true)->orWhere('scheduled_at', '<', Carbon::now())) ->latest('scheduled_at') ->limit(50) ->get(), @@ -242,7 +269,7 @@ class ReportService ->groupBy('lost_reason') ->orderByDesc('count') ->get() - ->map(fn($row) => [ + ->map(fn ($row) => [ 'name' => $row->name, 'count' => (int) $row->count, 'percentage' => $total > 0 ? round(($row->count / $total) * 100, 1) : 0, @@ -260,7 +287,7 @@ class ReportService ->groupBy('source') ->orderByDesc('total') ->get() - ->map(fn($row) => [ + ->map(fn ($row) => [ 'source' => $row->source, 'total' => (int) $row->total, 'won' => (int) $row->won, @@ -309,8 +336,12 @@ class ReportService public function importQualityReport(?string $dateFrom = null, ?string $dateTo = null): array { $query = ImportBatch::with(['user:id,name', 'campaign:id,name']); - if ($dateFrom) $query->whereDate('created_at', '>=', $dateFrom); - if ($dateTo) $query->whereDate('created_at', '<=', $dateTo); + if ($dateFrom) { + $query->whereDate('created_at', '>=', $dateFrom); + } + if ($dateTo) { + $query->whereDate('created_at', '<=', $dateTo); + } $batches = $query->latest()->limit(50)->get(); $totalRows = max($batches->sum('total_rows'), 1); @@ -329,11 +360,19 @@ class ReportService public function callQualityReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array { $query = QualityReview::with(['agent:id,name', 'reviewer:id,name']); - if ($dateFrom) $query->whereDate('created_at', '>=', $dateFrom); - if ($dateTo) $query->whereDate('created_at', '<=', $dateTo); - if ($agentId) $query->where('agent_id', $agentId); - elseif (is_array($agentIds) && $agentIds !== []) $query->whereIn('agent_id', $agentIds); - elseif (is_array($agentIds)) $query->whereRaw('1 = 0'); + if ($dateFrom) { + $query->whereDate('created_at', '>=', $dateFrom); + } + if ($dateTo) { + $query->whereDate('created_at', '<=', $dateTo); + } + if ($agentId) { + $query->where('agent_id', $agentId); + } elseif (is_array($agentIds) && $agentIds !== []) { + $query->whereIn('agent_id', $agentIds); + } elseif (is_array($agentIds)) { + $query->whereRaw('1 = 0'); + } return [ 'reviewed_calls' => (clone $query)->count(), @@ -350,11 +389,19 @@ class ReportService public function bestContactTimeReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array { $query = Call::query(); - if ($dateFrom) $query->whereDate('created_at', '>=', $dateFrom); - if ($dateTo) $query->whereDate('created_at', '<=', $dateTo); - if ($agentId) $query->where('user_id', $agentId); - elseif (is_array($agentIds) && $agentIds !== []) $query->whereIn('user_id', $agentIds); - elseif (is_array($agentIds)) $query->whereRaw('1 = 0'); + if ($dateFrom) { + $query->whereDate('created_at', '>=', $dateFrom); + } + if ($dateTo) { + $query->whereDate('created_at', '<=', $dateTo); + } + if ($agentId) { + $query->where('user_id', $agentId); + } elseif (is_array($agentIds) && $agentIds !== []) { + $query->whereIn('user_id', $agentIds); + } elseif (is_array($agentIds)) { + $query->whereRaw('1 = 0'); + } $total = (clone $query)->count(); if ($total < 20) { @@ -370,10 +417,11 @@ class ReportService 'enough_data' => true, 'total_calls' => $total, 'by_hour' => (clone $query)->get(['created_at', 'result']) - ->groupBy(fn(Call $call) => $call->created_at->hour) + ->groupBy(fn (Call $call) => $call->created_at->hour) ->sortKeys() ->map(function ($calls, int $hour) { $successful = $calls->whereIn('result', $this->successfulResultNames())->count(); + return [ 'hour' => $hour, 'total' => $calls->count(), @@ -384,6 +432,166 @@ class ReportService ]; } + public function kpiDashboard(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array + { + $to = $dateTo ? Carbon::parse($dateTo)->endOfDay() : now()->endOfDay(); + $from = $dateFrom ? Carbon::parse($dateFrom)->startOfDay() : $to->copy()->subDays(29)->startOfDay(); + $periodDays = (int) $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1; + $previousTo = $from->copy()->subSecond(); + $previousFrom = $previousTo->copy()->subDays($periodDays - 1)->startOfDay(); + $current = $this->kpiMetrics($from, $to, $agentId, $agentIds); + $previous = $this->kpiMetrics($previousFrom, $previousTo, $agentId, $agentIds); + $workingDays = $this->workingDaysBetween($from, $to); + + $targets = [ + 'total_calls' => (int) (Setting::where('key', 'daily_call_target')->value('value') ?: 40) * $workingDays, + 'successful_calls' => (int) (Setting::where('key', 'successful_call_target')->value('value') ?: 15) * $workingDays, + 'completed_follow_ups' => (int) (Setting::where('key', 'follow_up_target')->value('value') ?: 20) * $workingDays, + 'conversion_rate' => (int) (Setting::where('key', 'conversion_target_percent')->value('value') ?: 20), + ]; + $definitions = [ + 'total_calls' => ['label' => 'کل تماس‌ها', 'unit' => 'تماس', 'link' => '/calls'], + 'successful_calls' => ['label' => 'تماس موفق', 'unit' => 'تماس', 'link' => '/calls?result=positive'], + 'completed_follow_ups' => ['label' => 'پیگیری انجام‌شده', 'unit' => 'پیگیری', 'link' => '/follow-ups?status=completed'], + 'conversion_rate' => ['label' => 'نرخ تبدیل', 'unit' => 'درصد', 'link' => '/leads?final_result=موفق'], + 'answer_rate' => ['label' => 'نرخ پاسخ', 'unit' => 'درصد', 'link' => '/calls'], + 'avg_call_duration' => ['label' => 'میانگین مدت تماس', 'unit' => 'ثانیه', 'link' => '/calls'], + 'won_sales' => ['label' => 'فروش موفق', 'unit' => 'فروش', 'link' => '/leads?final_result=موفق'], + 'won_value' => ['label' => 'ارزش فروش', 'unit' => 'مبلغ', 'link' => '/leads?final_result=موفق'], + 'overdue_follow_ups' => ['label' => 'پیگیری عقب‌افتاده', 'unit' => 'پیگیری', 'link' => '/follow-ups?status=overdue'], + ]; + $kpis = collect($definitions)->map(function (array $definition, string $key) use ($current, $previous, $targets): array { + $value = (float) ($current[$key] ?? 0); + $old = (float) ($previous[$key] ?? 0); + + return $definition + [ + 'key' => $key, + 'value' => $value, + 'target' => $targets[$key] ?? null, + 'target_percent' => isset($targets[$key]) && $targets[$key] > 0 ? round(($value / $targets[$key]) * 100, 1) : null, + 'change_percent' => $old > 0 ? round((($value - $old) / $old) * 100, 1) : ($value > 0 ? 100 : 0), + 'lower_is_better' => $key === 'overdue_follow_ups', + ]; + })->values()->all(); + + $calls = $this->scopeCallQuery(Call::whereBetween('created_at', [$from, $to]), $agentId, $agentIds)->get(['created_at', 'result']); + $followUps = $this->scopeFollowUpQuery(FollowUp::whereBetween('scheduled_at', [$from, $to]), $agentId, $agentIds)->get(['scheduled_at', 'status']); + $leads = $this->scopeLeadQuery(Lead::whereBetween('created_at', [$from, $to]), $agentId, $agentIds)->get(['created_at', 'final_result']); + $successNames = $this->successfulResultNames(); + $trend = collect(range(0, $periodDays - 1))->map(function (int $offset) use ($from, $calls, $followUps, $leads, $successNames): array { + $date = $from->copy()->addDays($offset)->toDateString(); + $dayCalls = $calls->filter(fn (Call $call) => $call->created_at->toDateString() === $date); + + return [ + 'date' => $date, + 'calls' => $dayCalls->count(), + 'successful_calls' => $dayCalls->whereIn('result', $successNames)->count(), + 'follow_ups' => $followUps->filter(fn (FollowUp $followUp) => $followUp->scheduled_at->toDateString() === $date && $followUp->status === 'completed')->count(), + 'won' => $leads->filter(fn (Lead $lead) => $lead->created_at->toDateString() === $date && $lead->final_result === 'موفق')->count(), + ]; + })->values()->all(); + + $leaderboardIds = $agentId ? [$agentId] : (is_array($agentIds) && $agentIds !== [] ? $agentIds : User::role('agent')->pluck('id')->all()); + $leaderboard = User::whereIn('id', $leaderboardIds)->orderBy('name')->get(['id', 'name'])->map(function (User $user) use ($from, $to): array { + $metric = $this->kpiMetrics($from, $to, $user->id, [$user->id]); + + return [ + 'agent_id' => $user->id, + 'agent_name' => $user->name, + 'calls' => $metric['total_calls'], + 'successful_calls' => $metric['successful_calls'], + 'conversion_rate' => $metric['conversion_rate'], + 'won_value' => $metric['won_value'], + ]; + })->sortByDesc(fn (array $row) => [$row['conversion_rate'], $row['successful_calls']])->values()->all(); + + return [ + 'range' => ['date_from' => $from->toDateString(), 'date_to' => $to->toDateString(), 'working_days' => $workingDays], + 'kpis' => $kpis, + 'trend' => $trend, + 'leaderboard' => $leaderboard, + 'updated_at' => now()->toISOString(), + ]; + } + + private function kpiMetrics(Carbon $from, Carbon $to, ?int $agentId, ?array $agentIds): array + { + $calls = $this->scopeCallQuery(Call::whereBetween('created_at', [$from, $to]), $agentId, $agentIds); + $leads = $this->scopeLeadQuery(Lead::whereBetween('created_at', [$from, $to]), $agentId, $agentIds); + $followUps = $this->scopeFollowUpQuery(FollowUp::whereBetween('scheduled_at', [$from, $to]), $agentId, $agentIds); + $totalCalls = (clone $calls)->count(); + $successfulCalls = (clone $calls)->whereIn('result', $this->successfulResultNames())->count(); + $answeredCalls = (clone $calls)->whereNotNull('result')->whereNotIn('result', ['پاسخ نداد', 'اشغال بود', 'خاموش بود'])->count(); + $totalLeads = (clone $leads)->count(); + $wonSales = (clone $leads)->where('final_result', 'موفق')->count(); + + return [ + 'total_calls' => $totalCalls, + 'successful_calls' => $successfulCalls, + 'completed_follow_ups' => (clone $followUps)->where('status', 'completed')->count(), + 'conversion_rate' => $totalLeads > 0 ? round(($wonSales / $totalLeads) * 100, 1) : 0, + 'answer_rate' => $totalCalls > 0 ? round(($answeredCalls / $totalCalls) * 100, 1) : 0, + 'avg_call_duration' => round((float) ((clone $calls)->avg('duration') ?? 0), 1), + 'won_sales' => $wonSales, + 'won_value' => round((float) (clone $leads)->where('final_result', 'موفق')->sum('deal_value'), 2), + 'overdue_follow_ups' => $this->scopeFollowUpQuery(FollowUp::where('scheduled_at', '<', now())->where('status', 'pending'), $agentId, $agentIds)->count(), + ]; + } + + private function workingDaysBetween(Carbon $from, Carbon $to): int + { + $codes = array_filter(explode(',', (string) (Setting::where('key', 'working_days')->value('value') ?: 'sat,sun,mon,tue,wed,thu'))); + $map = ['sun' => 0, 'mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6]; + $allowed = array_map(fn (string $code) => $map[$code] ?? -1, $codes); + $count = 0; + for ($date = $from->copy()->startOfDay(); $date->lte($to); $date->addDay()) { + if (in_array($date->dayOfWeek, $allowed, true)) { + $count++; + } + } + + return max($count, 1); + } + + private function scopeCallQuery($query, ?int $agentId, ?array $agentIds) + { + if ($agentId) { + $query->where('user_id', $agentId); + } elseif (is_array($agentIds) && $agentIds !== []) { + $query->whereIn('user_id', $agentIds); + } elseif (is_array($agentIds)) { + $query->whereRaw('1 = 0'); + } + + return $query; + } + + private function scopeLeadQuery($query, ?int $agentId, ?array $agentIds) + { + if ($agentId) { + $query->where('assigned_to', $agentId); + } elseif (is_array($agentIds) && $agentIds !== []) { + $query->whereIn('assigned_to', $agentIds); + } elseif (is_array($agentIds)) { + $query->whereRaw('1 = 0'); + } + + return $query; + } + + private function scopeFollowUpQuery($query, ?int $agentId, ?array $agentIds) + { + if ($agentId) { + $query->where('user_id', $agentId); + } elseif (is_array($agentIds) && $agentIds !== []) { + $query->whereIn('user_id', $agentIds); + } elseif (is_array($agentIds)) { + $query->whereRaw('1 = 0'); + } + + return $query; + } + private function filterFollowUpsByAgent($query, ?int $agentId, ?array $agentIds = null) { if ($agentId) { @@ -400,11 +608,19 @@ class ReportService private function scopedLeads(?string $dateFrom, ?string $dateTo, ?int $agentId, ?array $agentIds) { $query = Lead::query(); - if ($dateFrom) $query->whereDate('created_at', '>=', $dateFrom); - if ($dateTo) $query->whereDate('created_at', '<=', $dateTo); - if ($agentId) $query->where('assigned_to', $agentId); - elseif (is_array($agentIds) && $agentIds !== []) $query->whereIn('assigned_to', $agentIds); - elseif (is_array($agentIds)) $query->whereRaw('1 = 0'); + if ($dateFrom) { + $query->whereDate('created_at', '>=', $dateFrom); + } + if ($dateTo) { + $query->whereDate('created_at', '<=', $dateTo); + } + if ($agentId) { + $query->where('assigned_to', $agentId); + } elseif (is_array($agentIds) && $agentIds !== []) { + $query->whereIn('assigned_to', $agentIds); + } elseif (is_array($agentIds)) { + $query->whereRaw('1 = 0'); + } return $query; } diff --git a/backend/app/Services/SlaMonitorService.php b/backend/app/Services/SlaMonitorService.php new file mode 100644 index 0000000..16b4277 --- /dev/null +++ b/backend/app/Services/SlaMonitorService.php @@ -0,0 +1,78 @@ +where('event', '!=', 'stale_deal')->get() as $rule) { + foreach ($this->candidates($rule) as [$subject, $baseAt, $assignedTo]) { + $dueAt = $baseAt->copy()->addMinutes($rule->breach_minutes); + $status = $dueAt->isPast() ? 'breached' : 'warning'; + $eventKey = "sla:{$rule->id}:".strtolower(class_basename($subject)).":{$subject->getKey()}"; + $breach = SlaBreach::firstOrCreate(['event_key' => $eventKey], [ + 'sla_rule_id' => $rule->id, 'breachable_type' => $subject::class, 'breachable_id' => $subject->getKey(), + 'assigned_to' => $assignedTo, 'status' => $status, 'due_at' => $dueAt, + 'warned_at' => now(), 'breached_at' => $status === 'breached' ? now() : null, + 'details' => ['event' => $rule->event], + ]); + if (! $breach->wasRecentlyCreated) { + continue; + } + $created++; + $this->notify($breach, $rule->name); + $this->automations->dispatch('sla_breached', $subject, "sla:{$breach->id}", ['sla_breach_id' => $breach->id, 'status' => $status, 'rule_id' => $rule->id]); + } + } + + return $created; + } + + /** @return iterable */ + private function candidates(SlaRule $rule): iterable + { + if ($rule->event === 'first_contact') { + foreach (Lead::whereDoesntHave('calls')->where('created_at', '<=', now()->subMinutes($rule->warning_minutes))->limit(500)->get() as $lead) { + yield [$lead, $lead->created_at, $lead->assigned_to]; + } + } elseif ($rule->event === 'follow_up') { + foreach (FollowUp::where('status', 'pending')->where('scheduled_at', '<=', now()->subMinutes($rule->warning_minutes))->limit(500)->get() as $followUp) { + yield [$followUp, $followUp->scheduled_at, $followUp->user_id]; + } + } elseif ($rule->event === 'stale_deal') { + foreach (Deal::whereNotIn('status', ['won', 'lost'])->where(fn ($q) => $q->where('last_activity_at', '<=', now()->subMinutes($rule->warning_minutes))->orWhere(fn ($empty) => $empty->whereNull('last_activity_at')->where('created_at', '<=', now()->subMinutes($rule->warning_minutes))))->limit(500)->get() as $deal) { + yield [$deal, $deal->last_activity_at ?? $deal->created_at, $deal->owner_id]; + } + } + } + + private function notify(SlaBreach $breach, string $ruleName): void + { + if (! $breach->assigned_to) { + return; + } + $preference = NotificationPreference::where('user_id', $breach->assigned_to)->where('notification_type', 'sla')->first(); + if ($preference && (! $preference->in_app_enabled || $preference->is_muted)) { + return; + } + Notification::firstOrCreate(['idempotency_key' => "sla-breach:{$breach->id}"], [ + 'user_id' => $breach->assigned_to, 'title' => $breach->status === 'breached' ? 'نقض SLA' : 'هشدار SLA', + 'message' => "مهلت قانون «{$ruleName}» نیازمند اقدام است.", 'type' => 'sla', + 'data' => ['url' => '/operations', 'sla_breach_id' => $breach->id], + ]); + } +} diff --git a/backend/app/Services/TaskReminderService.php b/backend/app/Services/TaskReminderService.php new file mode 100644 index 0000000..57f9700 --- /dev/null +++ b/backend/app/Services/TaskReminderService.php @@ -0,0 +1,37 @@ +whereNotNull('assigned_to') + ->where(function ($query) use ($now): void { + $query->where(fn ($reminders) => $reminders->whereNotNull('reminder_at')->where('reminder_at', '<=', $now)) + ->orWhere(fn ($overdue) => $overdue->whereNotNull('due_at')->where('due_at', '<', $now)); + }) + ->orderBy('id') + ->chunkById(100, function ($tasks) use (&$sent, $now): void { + foreach ($tasks as $task) { + if ($task->reminder_at?->lte($now)) { + TaskDueNotification::send($task, 'reminder'); + $sent++; + } + if ($task->due_at?->lt($now)) { + TaskDueNotification::send($task, 'overdue'); + $sent++; + } + } + }); + + return $sent; + } +} diff --git a/backend/app/Services/TaskService.php b/backend/app/Services/TaskService.php new file mode 100644 index 0000000..35ba4d9 --- /dev/null +++ b/backend/app/Services/TaskService.php @@ -0,0 +1,245 @@ +id); + $this->assertAssignable($actor, $assigneeId, $assigneeId !== $actor->id); + + if (! empty($data['parent_task_id'])) { + $parent = Task::findOrFail($data['parent_task_id']); + Gate::forUser($actor)->authorize('view', $parent); + } + + $task = DB::transaction(function () use ($data, $actor, $entity, $assigneeId): Task { + $task = Task::create([ + ...Arr::only($data, ['subject', 'description', 'priority', 'due_at', 'reminder_at', 'parent_task_id', 'estimated_minutes', 'visibility']), + 'taskable_type' => $entity ? $entity::class : null, + 'taskable_id' => $entity?->getKey(), + 'assigned_to' => $assigneeId, + 'assigned_by' => $actor->id, + 'created_by' => $actor->id, + 'status' => TaskStatus::Open->value, + 'version' => 1, + ]); + + ActivityLogger::log('task_created', "Task {$task->id} created", $task, null, $this->auditState($task)); + + return $task; + }); + + try { + TaskAssignedNotification::send($task, $actor); + } catch (\Throwable) { + // Notification failure must not roll back task creation. + } + + return $this->load($task); + } + + public function update(Task $task, array $data, User $actor): Task + { + return DB::transaction(function () use ($task, $data): Task { + $locked = $this->locked($task, (int) $data['version']); + $before = $this->auditState($locked); + $locked->fill(Arr::only($data, ['subject', 'description', 'priority', 'due_at', 'reminder_at', 'estimated_minutes', 'visibility'])); + $locked->version++; + $locked->save(); + ActivityLogger::log('task_updated', "Task {$locked->id} updated", $locked, $before, $this->auditState($locked)); + + return $this->load($locked); + }); + } + + public function assign(Task $task, int $assigneeId, int $version, User $actor): Task + { + $this->assertAssignable($actor, $assigneeId, true); + $reassigned = $task->assigned_to !== null && $task->assigned_to !== $assigneeId; + + $updated = DB::transaction(function () use ($task, $assigneeId, $version, $actor, $reassigned): Task { + $locked = $this->locked($task, $version); + Gate::forUser($actor)->authorize('assign', [$locked, $assigneeId]); + $before = $this->auditState($locked); + $locked->update([ + 'assigned_to' => $assigneeId, + 'assigned_by' => $actor->id, + 'version' => $locked->version + 1, + ]); + ActivityLogger::log($reassigned ? 'task_reassigned' : 'task_assigned', "Task {$locked->id} assignment changed", $locked, $before, $this->auditState($locked)); + + return $this->load($locked); + }); + + try { + TaskAssignedNotification::send($updated, $actor, $reassigned); + } catch (\Throwable) { + // Notification failure must not roll back assignment. + } + + return $updated; + } + + public function transition(Task $task, TaskStatus $target, int $version, User $actor): Task + { + return DB::transaction(function () use ($task, $target, $version, $actor): Task { + $locked = $this->locked($task, $version); + Gate::forUser($actor)->authorize('transition', $locked); + $this->assertTransition($locked, $target); + $before = $this->auditState($locked); + + $attributes = ['status' => $target->value, 'version' => $locked->version + 1]; + if ($target === TaskStatus::InProgress) { + $attributes['started_at'] = $locked->started_at ?? now(); + $attributes['completed_at'] = null; + } elseif ($target === TaskStatus::Done) { + $attributes['completed_at'] = now(); + } elseif ($target === TaskStatus::Open) { + $attributes['completed_at'] = null; + } elseif ($target === TaskStatus::Cancelled) { + $attributes['completed_at'] = null; + } + + $locked->update($attributes); + ActivityLogger::log('task_'.$target->value, "Task {$locked->id} status changed", $locked, $before, $this->auditState($locked)); + + return $this->load($locked); + }); + } + + public function bulkAssign(array $ids, int $assigneeId, User $actor): array + { + Gate::forUser($actor)->authorize('bulkManage', Task::class); + $this->assertAssignable($actor, $assigneeId, true); + + $tasks = DB::transaction(function () use ($ids, $assigneeId, $actor) { + $tasks = $this->bulkTasks($ids, $actor, 'assign', $assigneeId); + foreach ($tasks as $task) { + $before = $this->auditState($task); + $task->update(['assigned_to' => $assigneeId, 'assigned_by' => $actor->id, 'version' => $task->version + 1]); + ActivityLogger::log('task_bulk_assigned', "Task {$task->id} bulk assigned", $task, $before, $this->auditState($task)); + } + + return $tasks; + }); + + foreach ($tasks as $task) { + try { + TaskAssignedNotification::send($task->fresh(), $actor, true); + } catch (\Throwable) { + // Assignment remains valid if notification delivery fails. + } + } + + return $tasks->map(fn (Task $task) => $this->load($task->fresh()))->all(); + } + + public function bulkComplete(array $ids, User $actor): array + { + Gate::forUser($actor)->authorize('bulkManage', Task::class); + $tasks = DB::transaction(function () use ($ids, $actor) { + $tasks = $this->bulkTasks($ids, $actor, 'transition'); + foreach ($tasks as $task) { + $before = $this->auditState($task); + $task->update(['status' => TaskStatus::Done->value, 'completed_at' => now(), 'version' => $task->version + 1]); + ActivityLogger::log('task_bulk_completed', "Task {$task->id} bulk completed", $task, $before, $this->auditState($task)); + } + + return $tasks; + }); + + return $tasks->map(fn (Task $task) => $this->load($task->fresh()))->all(); + } + + public function delete(Task $task): void + { + DB::transaction(function () use ($task): void { + $before = $this->auditState($task); + $task->delete(); + ActivityLogger::log('task_deleted', "Task {$task->id} deleted", $task, $before, ['deleted' => true]); + }); + } + + private function assertAssignable(User $actor, int $assigneeId, bool $requiresPermission): User + { + $assignee = User::whereKey($assigneeId)->where('is_active', true)->first(); + if (! $assignee) { + throw ValidationException::withMessages(['assigned_to' => ['کاربر انتخاب‌شده فعال نیست.']]); + } + if ($requiresPermission && ! $actor->can('assign_tasks') && ! $actor->can('reassign_tasks')) { + abort(403, 'مجوز تخصیص کار به دیگران را ندارید.'); + } + abort_unless(AccessControl::canAssignUser($actor, $assigneeId), 403, 'کاربر انتخاب‌شده خارج از محدوده تیم شما است.'); + + return $assignee; + } + + private function locked(Task $task, int $version): Task + { + $locked = Task::whereKey($task->id)->lockForUpdate()->firstOrFail(); + if ($locked->version !== $version) { + throw new TaskVersionConflictException; + } + + return $locked; + } + + private function assertTransition(Task $task, TaskStatus $target): void + { + $allowed = match ($target) { + TaskStatus::InProgress => [$task->status === TaskStatus::Open], + TaskStatus::Done => [in_array($task->status, [TaskStatus::Open, TaskStatus::InProgress], true)], + TaskStatus::Open => [in_array($task->status, [TaskStatus::Done, TaskStatus::Cancelled], true)], + TaskStatus::Cancelled => [in_array($task->status, [TaskStatus::Open, TaskStatus::InProgress], true)], + }; + + if (! $allowed[0]) { + throw ValidationException::withMessages(['status' => ['این تغییر وضعیت برای وضعیت فعلی کار مجاز نیست.']]); + } + } + + private function bulkTasks(array $ids, User $actor, string $ability, ?int $assigneeId = null) + { + $uniqueIds = collect($ids)->map(fn ($id) => (int) $id)->unique()->values(); + $tasks = Task::whereKey($uniqueIds)->lockForUpdate()->get(); + if ($tasks->count() !== $uniqueIds->count()) { + throw ValidationException::withMessages(['task_ids' => ['یک یا چند کار معتبر نیست.']]); + } + foreach ($tasks as $task) { + $arguments = $ability === 'assign' ? [$task, $assigneeId] : $task; + Gate::forUser($actor)->authorize($ability, $arguments); + } + + return $tasks; + } + + private function auditState(Task $task): array + { + return $task->only(['assigned_to', 'assigned_by', 'priority', 'status', 'due_at', 'reminder_at', 'visibility', 'version']); + } + + private function load(Task $task): Task + { + return $task->load(['assignee:id,name,avatar', 'assigner:id,name', 'creator:id,name', 'taskable', 'parent:id,subject']); + } +} diff --git a/backend/app/Services/VoIP/AmiProvider.php b/backend/app/Services/VoIP/AmiProvider.php index 002c0f7..47fa581 100644 --- a/backend/app/Services/VoIP/AmiProvider.php +++ b/backend/app/Services/VoIP/AmiProvider.php @@ -6,7 +6,7 @@ use App\Models\Setting; class AmiProvider implements VoIPProviderInterface { - public function initiateCall(string $phone, string $callerId = null): array + public function initiateCall(string $phone, ?string $callerId = null): array { $extension = trim((string) $callerId); if ($extension === '') { @@ -31,7 +31,7 @@ class AmiProvider implements VoIPProviderInterface } $connection = $this->connect($config, $error); - if (!$connection) { + if (! $connection) { return [ 'success' => false, 'provider_call_id' => null, @@ -41,8 +41,9 @@ class AmiProvider implements VoIPProviderInterface } $login = $this->login($connection, $config); - if (!$this->isSuccess($login)) { + if (! $this->isSuccess($login)) { fclose($connection); + return [ 'success' => false, 'provider_call_id' => null, @@ -52,7 +53,7 @@ class AmiProvider implements VoIPProviderInterface ]; } - $providerCallId = 'ami_' . uniqid(); + $providerCallId = 'ami_'.uniqid(); $channel = "{$config['technology']}/{$extension}"; $response = $this->sendAction($connection, [ 'Action' => 'Originate', @@ -71,6 +72,7 @@ class AmiProvider implements VoIPProviderInterface fclose($connection); $ok = $this->isSuccess($response); + return [ 'success' => $ok, 'provider_call_id' => $providerCallId, @@ -92,6 +94,7 @@ class AmiProvider implements VoIPProviderInterface public function getRecordingUrl(string $providerCallId): ?string { $template = Setting::where('key', 'voip_ami_recording_url')->value('value'); + return $template ? str_replace('{id}', $providerCallId, $template) : null; } @@ -108,7 +111,7 @@ class AmiProvider implements VoIPProviderInterface } $connection = $this->connect($config, $error); - if (!$connection) { + if (! $connection) { return [ 'ok' => false, 'message' => $error ?: 'اتصال به AMI برقرار نشد.', @@ -143,7 +146,7 @@ class AmiProvider implements VoIPProviderInterface private function missingConfig(array $config): array { - return array_values(array_filter(['host', 'port', 'username', 'secret', 'technology', 'context'], fn(string $key) => empty($config[$key]))); + return array_values(array_filter(['host', 'port', 'username', 'secret', 'technology', 'context'], fn (string $key) => empty($config[$key]))); } private function connect(array $config, ?string &$error): mixed @@ -156,8 +159,9 @@ class AmiProvider implements VoIPProviderInterface $config['timeout'] ); - if (!$connection) { + if (! $connection) { $error = $errorMessage ?: "خطای اتصال AMI ({$errorCode})"; + return false; } @@ -195,7 +199,7 @@ class AmiProvider implements VoIPProviderInterface private function readResponse(mixed $connection): array { $response = []; - while (!feof($connection)) { + while (! feof($connection)) { $line = fgets($connection); if ($line === false || trim($line) === '') { break; @@ -217,6 +221,7 @@ class AmiProvider implements VoIPProviderInterface private function callerId(string $extension): string { $template = Setting::where('key', 'voip_ami_caller_id_template')->value('value') ?: 'CRM <{extension}>'; + return str_replace('{extension}', $extension, $template); } } diff --git a/backend/app/Services/VoIP/ApiProvider.php b/backend/app/Services/VoIP/ApiProvider.php index b48c6bf..96b610a 100644 --- a/backend/app/Services/VoIP/ApiProvider.php +++ b/backend/app/Services/VoIP/ApiProvider.php @@ -4,16 +4,17 @@ namespace App\Services\VoIP; use App\Models\Setting; use Illuminate\Support\Facades\Http; +use Throwable; class ApiProvider implements VoIPProviderInterface { - public function initiateCall(string $phone, string $callerId = null): array + public function initiateCall(string $phone, ?string $callerId = null): array { $baseUrl = rtrim((string) Setting::where('key', 'voip_api_base_url')->value('value'), '/'); $token = Setting::where('key', 'voip_api_token')->value('value'); $callPath = Setting::where('key', 'voip_api_call_path')->value('value') ?: '/calls'; - if (!$baseUrl) { + if (! $baseUrl) { return [ 'success' => false, 'provider_call_id' => null, @@ -27,7 +28,7 @@ class ApiProvider implements VoIPProviderInterface $request = $request->withToken($token); } - $response = $request->post($baseUrl . '/' . ltrim($callPath, '/'), [ + $response = $request->post($baseUrl.'/'.ltrim($callPath, '/'), [ 'phone' => $phone, 'caller_id' => $callerId, ]); @@ -49,7 +50,7 @@ class ApiProvider implements VoIPProviderInterface $token = Setting::where('key', 'voip_api_token')->value('value'); $statusPath = Setting::where('key', 'voip_api_status_path')->value('value') ?: '/calls/{id}'; - if (!$baseUrl) { + if (! $baseUrl) { return ['status' => 'configuration_error']; } @@ -58,7 +59,7 @@ class ApiProvider implements VoIPProviderInterface $request = $request->withToken($token); } - $url = $baseUrl . '/' . ltrim(str_replace('{id}', $providerCallId, $statusPath), '/'); + $url = $baseUrl.'/'.ltrim(str_replace('{id}', $providerCallId, $statusPath), '/'); $response = $request->get($url); return $response->json() ?: [ @@ -69,6 +70,7 @@ class ApiProvider implements VoIPProviderInterface public function getRecordingUrl(string $providerCallId): ?string { $template = Setting::where('key', 'voip_api_recording_url')->value('value'); + return $template ? str_replace('{id}', $providerCallId, $template) : null; } @@ -76,15 +78,31 @@ class ApiProvider implements VoIPProviderInterface { $missing = []; foreach (['voip_api_base_url', 'voip_api_token'] as $key) { - if (!Setting::where('key', $key)->value('value')) { + if (! Setting::where('key', $key)->value('value')) { $missing[] = $key; } } - return [ - 'ok' => $missing === [], - 'message' => $missing ? 'تنظیمات اتصال API کامل نیست.' : 'تنظیمات اتصال API معتبر است.', - 'missing' => $missing, - ]; + if ($missing !== []) { + return ['ok' => false, 'message' => 'تنظیمات اتصال API کامل نیست.', 'missing' => $missing]; + } + + $baseUrl = rtrim((string) Setting::where('key', 'voip_api_base_url')->value('value'), '/'); + $token = Setting::where('key', 'voip_api_token')->value('value'); + $healthPath = Setting::where('key', 'voip_api_health_path')->value('value') ?: '/health'; + $started = microtime(true); + try { + $response = Http::acceptJson()->withToken($token)->timeout(10)->get($baseUrl.'/'.ltrim($healthPath, '/')); + $latency = (int) round((microtime(true) - $started) * 1000); + return [ + 'ok' => $response->successful(), + 'message' => $response->successful() ? "اتصال واقعی API موفق بود ({$latency} میلی‌ثانیه)." : "سرویس API پاسخ {$response->status()} داد.", + 'missing' => [], + 'latency_ms' => $latency, + 'http_status' => $response->status(), + ]; + } catch (Throwable $error) { + return ['ok' => false, 'message' => 'اتصال واقعی API برقرار نشد: '.$error->getMessage(), 'missing' => []]; + } } } diff --git a/backend/app/Services/VoIP/DisabledProvider.php b/backend/app/Services/VoIP/DisabledProvider.php new file mode 100644 index 0000000..bfcdcb8 --- /dev/null +++ b/backend/app/Services/VoIP/DisabledProvider.php @@ -0,0 +1,26 @@ + false, 'status' => 'not_configured', 'message' => 'مرکز تلفن واقعی پیکربندی نشده است. از تنظیمات، AMI، API یا Socket را فعال کنید.']; + } + + public function getCallStatus(string $providerCallId): array + { + return ['status' => 'not_configured', 'answered' => false, 'duration' => 0]; + } + + public function getRecordingUrl(string $providerCallId): ?string + { + return null; + } + + public function testConnection(): array + { + return ['ok' => false, 'message' => 'مرکز تلفن واقعی هنوز پیکربندی نشده است.', 'missing' => ['voip_provider']]; + } +} diff --git a/backend/app/Services/VoIP/MockProvider.php b/backend/app/Services/VoIP/MockProvider.php index 43c2e6e..e2cd61b 100644 --- a/backend/app/Services/VoIP/MockProvider.php +++ b/backend/app/Services/VoIP/MockProvider.php @@ -4,11 +4,11 @@ namespace App\Services\VoIP; class MockProvider implements VoIPProviderInterface { - public function initiateCall(string $phone, string $callerId = null): array + public function initiateCall(string $phone, ?string $callerId = null): array { return [ 'success' => true, - 'provider_call_id' => 'mock_' . uniqid(), + 'provider_call_id' => 'mock_'.uniqid(), 'message' => "تماس با {$phone} در حال انجام است", 'status' => 'initiated', ]; diff --git a/backend/app/Services/VoIP/SocketProvider.php b/backend/app/Services/VoIP/SocketProvider.php index b2a4a74..0897a2a 100644 --- a/backend/app/Services/VoIP/SocketProvider.php +++ b/backend/app/Services/VoIP/SocketProvider.php @@ -6,14 +6,14 @@ use App\Models\Setting; class SocketProvider implements VoIPProviderInterface { - public function initiateCall(string $phone, string $callerId = null): array + public function initiateCall(string $phone, ?string $callerId = null): array { $host = Setting::where('key', 'voip_socket_host')->value('value'); $port = (int) (Setting::where('key', 'voip_socket_port')->value('value') ?: 0); $token = Setting::where('key', 'voip_socket_token')->value('value'); $timeout = (float) (Setting::where('key', 'voip_socket_timeout')->value('value') ?: 5); - if (!$host || !$port) { + if (! $host || ! $port) { return [ 'success' => false, 'provider_call_id' => null, @@ -23,7 +23,7 @@ class SocketProvider implements VoIPProviderInterface } $connection = @stream_socket_client("tcp://{$host}:{$port}", $errorCode, $errorMessage, $timeout); - if (!$connection) { + if (! $connection) { return [ 'success' => false, 'provider_call_id' => null, @@ -34,7 +34,7 @@ class SocketProvider implements VoIPProviderInterface } stream_set_timeout($connection, (int) ceil($timeout)); - $providerCallId = 'socket_' . uniqid(); + $providerCallId = 'socket_'.uniqid(); $payload = [ 'type' => 'call.initiate', 'provider_call_id' => $providerCallId, @@ -43,14 +43,14 @@ class SocketProvider implements VoIPProviderInterface 'token' => $token, ]; - fwrite($connection, json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n"); + fwrite($connection, json_encode($payload, JSON_UNESCAPED_UNICODE)."\n"); $line = fgets($connection); fclose($connection); $response = $line ? json_decode($line, true) : []; return [ - 'success' => ($response['success'] ?? true) === true, + 'success' => ($response['success'] ?? false) === true, 'provider_call_id' => $response['provider_call_id'] ?? $providerCallId, 'message' => $response['message'] ?? 'درخواست تماس از راه سوکت ارسال شد', 'status' => $response['status'] ?? 'initiated', @@ -69,6 +69,7 @@ class SocketProvider implements VoIPProviderInterface public function getRecordingUrl(string $providerCallId): ?string { $template = Setting::where('key', 'voip_socket_recording_url')->value('value'); + return $template ? str_replace('{id}', $providerCallId, $template) : null; } @@ -76,15 +77,37 @@ class SocketProvider implements VoIPProviderInterface { $missing = []; foreach (['voip_socket_host', 'voip_socket_port'] as $key) { - if (!Setting::where('key', $key)->value('value')) { + if (! Setting::where('key', $key)->value('value')) { $missing[] = $key; } } + if ($missing !== []) { + return ['ok' => false, 'message' => 'تنظیمات اتصال سوکت کامل نیست.', 'missing' => $missing]; + } + + $host = Setting::where('key', 'voip_socket_host')->value('value'); + $port = (int) Setting::where('key', 'voip_socket_port')->value('value'); + $token = Setting::where('key', 'voip_socket_token')->value('value'); + $timeout = (float) (Setting::where('key', 'voip_socket_timeout')->value('value') ?: 5); + $started = microtime(true); + $connection = @stream_socket_client("tcp://{$host}:{$port}", $errorCode, $errorMessage, $timeout); + if (! $connection) { + return ['ok' => false, 'message' => $errorMessage ?: "اتصال سوکت برقرار نشد ({$errorCode})", 'missing' => []]; + } + stream_set_timeout($connection, (int) ceil($timeout)); + fwrite($connection, json_encode(['type' => 'health.check', 'token' => $token], JSON_UNESCAPED_UNICODE)."\n"); + $line = fgets($connection); + $meta = stream_get_meta_data($connection); + fclose($connection); + $response = $line ? json_decode($line, true) : null; + $ok = is_array($response) && (($response['success'] ?? false) === true || ($response['status'] ?? '') === 'ok'); + $latency = (int) round((microtime(true) - $started) * 1000); return [ - 'ok' => $missing === [], - 'message' => $missing ? 'تنظیمات اتصال سوکت کامل نیست.' : 'تنظیمات اتصال سوکت معتبر است.', - 'missing' => $missing, + 'ok' => $ok, + 'message' => $ok ? "اتصال و handshake سوکت موفق بود ({$latency} میلی‌ثانیه)." : ($meta['timed_out'] ? 'پاسخ health-check سوکت timeout شد.' : 'سوکت باز شد اما پاسخ health-check معتبر نبود.'), + 'missing' => [], + 'latency_ms' => $latency, ]; } } diff --git a/backend/app/Services/VoIP/VoIPManager.php b/backend/app/Services/VoIP/VoIPManager.php index 4c1154c..9b6b2b3 100644 --- a/backend/app/Services/VoIP/VoIPManager.php +++ b/backend/app/Services/VoIP/VoIPManager.php @@ -11,18 +11,23 @@ class VoIPManager public function provider(): VoIPProviderInterface { if ($this->provider === null) { - $providerName = Setting::where('key', 'voip_provider')->value('value') ?? 'mock'; + $providerName = Setting::where('key', 'voip_provider')->value('value') ?? (app()->environment('testing') ? 'mock' : 'none'); + if (app()->environment('testing') && $providerName === 'none') { + $providerName = 'mock'; + } $this->provider = match ($providerName) { - 'ami' => new AmiProvider(), - 'api' => new ApiProvider(), - 'socket' => new SocketProvider(), - default => new MockProvider(), + 'ami' => new AmiProvider, + 'api' => new ApiProvider, + 'socket' => new SocketProvider, + 'mock' => app()->environment('testing') ? new MockProvider : new DisabledProvider, + default => new DisabledProvider, }; } + return $this->provider; } - public function initiateCall(string $phone, string $callerId = null): array + public function initiateCall(string $phone, ?string $callerId = null): array { return $this->provider()->initiateCall($phone, $callerId); } diff --git a/backend/app/Services/VoIP/VoIPProviderInterface.php b/backend/app/Services/VoIP/VoIPProviderInterface.php index 1aa60e0..64b34ee 100644 --- a/backend/app/Services/VoIP/VoIPProviderInterface.php +++ b/backend/app/Services/VoIP/VoIPProviderInterface.php @@ -4,8 +4,11 @@ namespace App\Services\VoIP; interface VoIPProviderInterface { - public function initiateCall(string $phone, string $callerId = null): array; + public function initiateCall(string $phone, ?string $callerId = null): array; + public function getCallStatus(string $providerCallId): array; + public function getRecordingUrl(string $providerCallId): ?string; + public function testConnection(): array; } diff --git a/backend/app/Support/AccessControl.php b/backend/app/Support/AccessControl.php index afc837a..e46708c 100644 --- a/backend/app/Support/AccessControl.php +++ b/backend/app/Support/AccessControl.php @@ -2,16 +2,35 @@ namespace App\Support; -use App\Models\Campaign; use App\Models\Call; +use App\Models\Campaign; +use App\Models\Company; +use App\Models\Contact; +use App\Models\Deal; use App\Models\FollowUp; use App\Models\Lead; +use App\Models\Product; +use App\Models\Task; use App\Models\Team; use App\Models\User; use Illuminate\Database\Eloquent\Builder; class AccessControl { + public static function canAccessEntity(User $user, object $entity): bool + { + return match (true) { + $entity instanceof Lead => self::canAccessLead($user, $entity), + $entity instanceof Company => self::canAccessCompany($user, $entity), + $entity instanceof Deal => self::canAccessDeal($user, $entity), + $entity instanceof Contact => self::canAccessContact($user, $entity), + $entity instanceof Product => self::canAccessProduct($user), + $entity instanceof Call => self::canAccessCall($user, $entity), + $entity instanceof Campaign => self::canAccessCampaign($user, $entity), + default => false, + }; + } + public static function canAccessLead(User $user, Lead $lead, bool $allowUnassignedClaim = false): bool { if ($user->hasRole('admin')) { @@ -89,6 +108,125 @@ class AccessControl return false; } + public static function canAccessCompany(User $user, Company $company): bool + { + if ($user->hasRole('admin')) { + return true; + } + + if ($company->owner_id === $user->id) { + return true; + } + + if ($user->hasRole('agent')) { + return $company->leads()->where('assigned_to', $user->id)->exists(); + } + + if ($user->hasRole('supervisor')) { + return in_array($company->owner_id, self::teamMemberIds($user), true) + || $company->leads()->where(function (Builder $query) use ($user): void { + self::scopeLeads($query, $user, false); + })->exists(); + } + + return false; + } + + public static function canAccessDeal(User $user, Deal $deal): bool + { + if ($user->hasRole('admin') || $deal->owner_id === $user->id) { + return true; + } + + if ($deal->lead && self::canAccessLead($user, $deal->lead)) { + return true; + } + + return $user->hasRole('supervisor') + && in_array($deal->owner_id, self::teamMemberIds($user), true); + } + + public static function canAccessContact(User $user, Contact $contact): bool + { + if ($user->hasRole('admin') || $contact->created_by === $user->id) { + return true; + } + + return ($contact->lead && self::canAccessLead($user, $contact->lead)) + || ($contact->company && self::canAccessCompany($user, $contact->company)) + || ($contact->deal && self::canAccessDeal($user, $contact->deal)); + } + + public static function canAccessProduct(User $user): bool + { + return $user->hasRole('admin') || $user->can('view_products'); + } + + public static function canAssignUser(User $user, ?int $ownerId): bool + { + if ($ownerId === null || $user->hasRole('admin')) { + return true; + } + + if ($ownerId === $user->id) { + return true; + } + + return $user->hasRole('supervisor') + && in_array($ownerId, self::teamMemberIds($user), true); + } + + public static function canAccessTask(User $user, Task $task): bool + { + if ($user->can('view_all_tasks')) { + return true; + } + + if ($task->assigned_to === $user->id || $task->created_by === $user->id) { + return $user->can('view_own_tasks') || $user->can('view_team_tasks'); + } + + if (! $user->can('view_team_tasks') || $task->visibility->value === 'private') { + return false; + } + + $teamUserIds = self::teamMemberIds($user); + + return in_array($task->assigned_to, $teamUserIds, true) + || in_array($task->created_by, $teamUserIds, true); + } + + public static function scopeTasks(Builder $query, User $user): Builder + { + if ($user->can('view_all_tasks')) { + return $query; + } + + if ($user->can('view_team_tasks')) { + $teamUserIds = self::teamMemberIds($user); + + return $query->where(function (Builder $scope) use ($user, $teamUserIds): void { + $scope->where('assigned_to', $user->id) + ->orWhere('created_by', $user->id) + ->orWhere(function (Builder $teamScope) use ($teamUserIds): void { + $teamScope->where('visibility', '<>', 'private') + ->where(function (Builder $members) use ($teamUserIds): void { + $members->whereIn('assigned_to', $teamUserIds) + ->orWhereIn('created_by', $teamUserIds); + }); + }); + }); + } + + if ($user->can('view_own_tasks')) { + return $query->where(fn (Builder $scope) => $scope + ->where('assigned_to', $user->id) + ->orWhere('created_by', $user->id)); + } + + return $query->whereRaw('1 = 0'); + } + public static function scopeLeads(Builder $query, User $user, bool $allowUnassignedForSupervisor = true): Builder { if ($user->hasRole('admin')) { @@ -130,7 +268,7 @@ class AccessControl if ($user->hasRole('supervisor')) { return $query->where(function (Builder $q) use ($user): void { $q->whereIn('user_id', self::teamMemberIds($user)) - ->orWhereHas('lead', fn(Builder $leadQuery) => self::scopeLeads($leadQuery, $user)); + ->orWhereHas('lead', fn (Builder $leadQuery) => self::scopeLeads($leadQuery, $user)); }); } @@ -150,7 +288,7 @@ class AccessControl if ($user->hasRole('supervisor')) { return $query->where(function (Builder $q) use ($user): void { $q->whereIn('user_id', self::teamMemberIds($user)) - ->orWhereHas('lead', fn(Builder $leadQuery) => self::scopeLeads($leadQuery, $user)); + ->orWhereHas('lead', fn (Builder $leadQuery) => self::scopeLeads($leadQuery, $user)); }); } @@ -165,8 +303,8 @@ class AccessControl if ($user->hasRole('agent')) { return $query->where(function (Builder $q) use ($user): void { - $q->whereHas('assignedAgents', fn(Builder $agentQuery) => $agentQuery->whereKey($user->id)) - ->orWhereHas('leads', fn(Builder $leadQuery) => $leadQuery->where('assigned_to', $user->id)); + $q->whereHas('assignedAgents', fn (Builder $agentQuery) => $agentQuery->whereKey($user->id)) + ->orWhereHas('leads', fn (Builder $leadQuery) => $leadQuery->where('assigned_to', $user->id)); }); } @@ -174,14 +312,78 @@ class AccessControl $teamIds = self::teamIds($user); return $query->where(function (Builder $q) use ($user, $teamIds): void { - $q->whereHas('assignedSupervisors', fn(Builder $supervisorQuery) => $supervisorQuery->whereKey($user->id)) - ->orWhereHas('leads', fn(Builder $leadQuery) => $leadQuery->whereIn('team_id', $teamIds)); + $q->whereHas('assignedSupervisors', fn (Builder $supervisorQuery) => $supervisorQuery->whereKey($user->id)) + ->orWhereHas('leads', fn (Builder $leadQuery) => $leadQuery->whereIn('team_id', $teamIds)); }); } return $query->whereRaw('1 = 0'); } + public static function scopeCompanies(Builder $query, User $user): Builder + { + if ($user->hasRole('admin')) { + return $query; + } + + if ($user->hasRole('agent')) { + return $query->where(function (Builder $companyQuery) use ($user): void { + $companyQuery->where('owner_id', $user->id) + ->orWhereHas('leads', fn (Builder $leadQuery) => $leadQuery->where('assigned_to', $user->id)); + }); + } + + if ($user->hasRole('supervisor')) { + $ownerIds = array_values(array_unique(array_merge([$user->id], self::teamMemberIds($user)))); + + return $query->where(function (Builder $companyQuery) use ($user, $ownerIds): void { + $companyQuery->whereIn('owner_id', $ownerIds) + ->orWhereHas('leads', fn (Builder $leadQuery) => self::scopeLeads($leadQuery, $user, false)); + }); + } + + return $query->whereRaw('1 = 0'); + } + + public static function scopeDeals(Builder $query, User $user): Builder + { + if ($user->hasRole('admin')) { + return $query; + } + + if ($user->hasRole('agent')) { + return $query->where(function (Builder $dealQuery) use ($user): void { + $dealQuery->where('owner_id', $user->id) + ->orWhereHas('lead', fn (Builder $leadQuery) => $leadQuery->where('assigned_to', $user->id)); + }); + } + + if ($user->hasRole('supervisor')) { + $ownerIds = array_values(array_unique(array_merge([$user->id], self::teamMemberIds($user)))); + + return $query->where(function (Builder $dealQuery) use ($user, $ownerIds): void { + $dealQuery->whereIn('owner_id', $ownerIds) + ->orWhereHas('lead', fn (Builder $leadQuery) => self::scopeLeads($leadQuery, $user, false)); + }); + } + + return $query->whereRaw('1 = 0'); + } + + public static function scopeContacts(Builder $query, User $user): Builder + { + if ($user->hasRole('admin')) { + return $query; + } + + return $query->where(function (Builder $contactQuery) use ($user): void { + $contactQuery->where('created_by', $user->id) + ->orWhereHas('lead', fn (Builder $leadQuery) => self::scopeLeads($leadQuery, $user, false)) + ->orWhereHas('company', fn (Builder $companyQuery) => self::scopeCompanies($companyQuery, $user)) + ->orWhereHas('deal', fn (Builder $dealQuery) => self::scopeDeals($dealQuery, $user)); + }); + } + public static function teamIds(User $user): array { return $user->teams()->pluck('teams.id')->all(); @@ -191,7 +393,7 @@ class AccessControl { $teamIds = self::teamIds($user); - if (!$teamIds) { + if (! $teamIds) { return []; } diff --git a/backend/app/Support/EntityResolver.php b/backend/app/Support/EntityResolver.php new file mode 100644 index 0000000..645e40b --- /dev/null +++ b/backend/app/Support/EntityResolver.php @@ -0,0 +1,56 @@ + Lead::class, + 'company' => Company::class, + 'deal' => Deal::class, + 'contact' => Contact::class, + 'call' => Call::class, + 'campaign' => Campaign::class, + default => abort(422, 'نوع موجودیت پشتیبانی نمی‌شود.'), + }; + } + + public static function typeOf(Model $entity): string + { + return match (true) { + $entity instanceof Lead => 'lead', + $entity instanceof Company => 'company', + $entity instanceof Deal => 'deal', + $entity instanceof Contact => 'contact', + $entity instanceof Call => 'call', + $entity instanceof Campaign => 'campaign', + default => 'unknown', + }; + } + + public static function authorize(string $type, int $id, string $ability = 'view'): Model + { + $entity = self::resolve($type, $id); + Gate::authorize($ability, $entity); + + return $entity; + } +} diff --git a/backend/app/Support/PermissionCatalog.php b/backend/app/Support/PermissionCatalog.php new file mode 100644 index 0000000..325b33d --- /dev/null +++ b/backend/app/Support/PermissionCatalog.php @@ -0,0 +1,167 @@ + self::ALL, + 'supervisor' => [ + 'view_leads', + 'create_leads', + 'edit_leads', + 'assign_leads', + 'reassign_leads', + 'export_leads', + 'view_full_phone', + 'view_team_calls', + 'view_own_calls', + 'listen_recordings', + 'view_campaigns', + 'manage_campaigns', + 'view_products', + 'manage_products', + 'merge_duplicates', + 'view_reports', + 'export_reports', + 'view_scripts', + 'manage_scripts', + 'view_quality_reviews', + 'create_quality_reviews', + 'view_supervisor_dashboard', + 'view_own_tasks', + 'view_team_tasks', + 'create_tasks', + 'assign_tasks', + 'reassign_tasks', + 'edit_own_tasks', + 'edit_team_tasks', + 'delete_tasks', + 'complete_tasks', + 'bulk_manage_tasks', + 'manage_call_notes', + 'pin_call_notes', + 'view_pipelines', + 'manage_pipelines', + 'move_deals', + 'close_deals', + 'manage_saved_views', + 'share_team_views', + 'use_global_search', + 'score_leads', + 'view_sla', + 'manage_sla', + 'manage_automations', + 'view_automation_logs', + 'acknowledge_quality_reviews', + 'manage_dashboard_preferences', + 'manage_notification_preferences', + 'view_invoices', + 'create_invoices', + 'approve_invoices', + 'manage_invoice_templates', + ], + 'agent' => [ + 'view_leads', + 'create_leads', + 'edit_leads', + 'import_leads', + 'view_own_calls', + 'view_campaigns', + 'view_products', + 'view_reports', + 'view_scripts', + 'view_quality_reviews', + 'view_agent_dashboard', + 'view_own_tasks', + 'create_tasks', + 'edit_own_tasks', + 'complete_tasks', + 'manage_call_notes', + 'view_pipelines', + 'move_deals', + 'close_deals', + 'manage_saved_views', + 'use_global_search', + 'score_leads', + 'view_sla', + 'acknowledge_quality_reviews', + 'manage_dashboard_preferences', + 'manage_notification_preferences', + 'view_invoices', + 'create_invoices', + ], + ]; + + public static function forRole(string $role): array + { + return self::ROLE_DEFAULTS[$role] ?? []; + } +} diff --git a/backend/app/Support/SettingsCatalog.php b/backend/app/Support/SettingsCatalog.php index 8959916..57e9944 100644 --- a/backend/app/Support/SettingsCatalog.php +++ b/backend/app/Support/SettingsCatalog.php @@ -19,7 +19,7 @@ class SettingsCatalog self::d('datetime_format', 'general', 'string', 'jalali_datetime', 'قالب تاریخ و زمان', 'در رابط کاربری فارسی استفاده می‌شود.', false, true, false, ['jalali_datetime', 'gregorian_datetime'], [], true), self::d('jalali_calendar_enabled', 'general', 'boolean', 'true', 'تقویم جلالی', 'ورودی‌های تاریخ فعلی بر مبنای جلالی هستند.', true, true, false, null, ['PersianDateInput']), self::d('currency', 'general', 'string', 'IRR', 'واحد پول', 'برای فرصت‌های فروش و گزارش‌ها نگهداری می‌شود.', true, true, false, ['IRR', 'IRT', 'USD'], ['deals UI']), - self::d('working_days', 'general', 'json', 'sat,sun,mon,tue,wed', 'روزهای کاری', 'برای کنترل زمان‌بندی پیگیری استفاده می‌شود.', true, false, false, null, ['FollowUpController', 'CallService']), + self::d('working_days', 'general', 'json', 'sat,sun,mon,tue,wed,thu', 'روزهای کاری', 'روزهای کاری پیش‌فرض شنبه تا پنجشنبه است و برای کنترل زمان‌بندی پیگیری استفاده می‌شود.', true, false, false, null, ['FollowUpController', 'CallService']), self::d('working_hours_start', 'general', 'string', '09:00', 'شروع ساعت کاری', 'اگر محدودیت ساعت کاری فعال باشد، پیگیری خارج از این بازه رد می‌شود.', true, false, false, null, ['FollowUpController', 'CallService']), self::d('working_hours_end', 'general', 'string', '18:00', 'پایان ساعت کاری', 'اگر محدودیت ساعت کاری فعال باشد، پیگیری خارج از این بازه رد می‌شود.', true, false, false, null, ['FollowUpController', 'CallService']), self::d('custom_holidays', 'general', 'json', '', 'تعطیلات اختصاصی', 'برای جلوگیری از زمان‌بندی پیگیری در تعطیلات استفاده می‌شود.', true, false, false, null, ['FollowUpController']), @@ -43,7 +43,7 @@ class SettingsCatalog self::d('import_permission', 'users_roles', 'string', 'import_leads', 'مجوز ورود داده', 'از نقش‌ها مدیریت می‌شود و در ورود داده اعمال می‌شود.', true, false, false, null, ['ImportBatchPolicy']), self::d('delete_merge_permission', 'users_roles', 'string', 'delete_leads,merge_duplicates', 'مجوز حذف/ادغام', 'حذف لید و merge شرکت‌ها به مجوز وابسته است.', true, false, false, null, ['LeadPolicy', 'CompanyController@merge']), self::d('recording_visibility_permission', 'users_roles', 'string', 'listen_recordings', 'مجوز شنیدن ضبط', 'در نمایش recording_url اعمال می‌شود.', true, false, false, null, ['MaskPhoneNumber', 'CallPolicy']), - self::d('supervisor_scope_own_team', 'users_roles', 'boolean', 'true', 'محدودیت سرپرست به تیم خود', 'در AccessControl برای لید، تماس، پیگیری و گزارش اعمال می‌شود.', true, false, false, null, ['AccessControl']), + self::d('supervisor_scope_own_team', 'users_roles', 'boolean', 'true', 'محدودیت مدیر فروش به تیم خود', 'در AccessControl برای لید، تماس، پیگیری و گزارش اعمال می‌شود.', true, false, false, null, ['AccessControl']), self::d('force_password_change_supported', 'users_roles', 'boolean', 'false', 'اجبار تغییر رمز بعدی', 'ستون و جریان تغییر اجباری رمز هنوز اضافه نشده است.', false, false, false, null, [], true), self::d('duplicate_phone_policy', 'lead', 'string', 'warn', 'سیاست تکراری تلفن', 'در ایجاد و ورود لید اعمال می‌شود.', true, false, false, ['allow', 'warn', 'block', 'merge_suggestion'], ['LeadController', 'ImportService']), @@ -58,7 +58,7 @@ class SettingsCatalog self::d('lead_sources', 'lead', 'json', 'وب‌سایت,معرفی,نمایشگاه,کمپین', 'منابع لید', 'برای لیست‌های انتخابی آینده نگهداری می‌شود.', false, false, false, null, [], true), self::d('lead_tags', 'lead', 'json', '', 'برچسب‌ها', 'مدیریت ساختاری برچسب‌ها به‌زودی.', false, false, false, null, [], true), - self::d('voip_provider', 'voip', 'string', 'mock', 'ارائه‌دهنده', 'در مدیریت تلفن اینترنتی برای انتخاب ارائه‌دهنده استفاده می‌شود.', true, false, false, ['mock', 'ami', 'api', 'socket'], ['VoIPManager']), + self::d('voip_provider', 'voip', 'string', 'none', 'ارائه‌دهنده', 'برای تماس واقعی یکی از اتصال‌های AMI، API یا Socket را انتخاب و سپس تست کنید.', true, false, false, ['none', 'ami', 'api', 'socket'], ['VoIPManager']), self::d('voip_ami_host', 'voip', 'string', '', 'نشانی AMI الستیکس', 'میزبان Asterisk/Elastix AMI، مثلا 192.168.1.10.', true, false, false, null, ['AmiProvider']), self::d('voip_ami_port', 'voip', 'integer', '5038', 'پورت AMI', 'پورت پیش‌فرض AMI معمولا 5038 است.', true, false, false, null, ['AmiProvider']), self::d('voip_ami_username', 'voip', 'string', '', 'نام کاربری AMI', 'نام کاربری مرکزی AMI؛ برای هر کاربر CRM رمز جداگانه ذخیره نمی‌شود.', true, false, false, null, ['AmiProvider']), @@ -73,8 +73,10 @@ class SettingsCatalog self::d('voip_api_token', 'voip', 'string', '', 'Token', 'به صورت secret نگهداری و در API/Socket استفاده می‌شود.', true, false, true, null, ['ApiProvider']), self::d('voip_api_call_path', 'voip', 'string', '/calls', 'مسیر API تماس', 'در provider API استفاده می‌شود.', true, false, false, null, ['ApiProvider']), self::d('voip_api_status_path', 'voip', 'string', '/calls/{id}', 'Status endpoint', 'در provider API استفاده می‌شود.', true, false, false, null, ['ApiProvider']), + self::d('voip_api_health_path', 'voip', 'string', '/health', 'Health endpoint', 'برای تست واقعی اتصال احراز هویت‌شده به سرویس تماس استفاده می‌شود.', true, false, false, null, ['ApiProvider']), self::d('voip_api_recording_url', 'voip', 'string', '', 'Recording endpoint', 'برای ساخت لینک ضبط استفاده می‌شود.', true, false, false, null, ['ApiProvider']), - self::d('voip_webhook_path', 'voip', 'string', '/api/voip/webhook', 'Webhook endpoint', 'دریافت webhook به‌زودی.', false, false, false, null, [], true), + self::d('voip_webhook_path', 'voip', 'string', '/api/voip/webhook', 'Webhook endpoint', 'این مسیر رویدادهای امضاشده وضعیت، مدت و ضبط تماس را دریافت می‌کند.', true, false, false, null, ['VoipWebhookController']), + self::d('voip_webhook_secret', 'voip', 'string', '', 'رمز امضای Webhook', 'برای اعتبارسنجی HMAC رویدادهای وضعیت، مدت و ضبط تماس استفاده می‌شود.', true, false, true, null, ['VoipWebhookController']), self::d('voip_socket_timeout', 'voip', 'integer', '5', 'Timeout', 'در اتصال socket استفاده می‌شود.', true, false, false, null, ['SocketProvider']), self::d('call_recording_enabled', 'voip', 'boolean', 'false', 'ضبط تماس', 'در ساخت تماس و نمایش recording اعمال می‌شود.', true, false, false, null, ['CallService']), self::d('recording_retention_days', 'voip', 'integer', '90', 'نگهداری ضبط', 'پاکسازی خودکار ضبط‌ها به‌زودی.', false, false, false, null, [], true), @@ -84,7 +86,7 @@ class SettingsCatalog self::d('default_follow_up_hours', 'follow_up', 'integer', '24', 'زمان پیش‌فرض پیگیری', 'برای ایجاد پیگیری پیش‌فرض استفاده می‌شود.', true, false, false, null, ['CallService']), self::d('reminder_before_due_minutes', 'follow_up', 'integer', '30', 'یادآوری قبل از موعد', 'نوتیفیکیشن زمان‌بندی‌شده به‌زودی.', false, false, false, null, [], true), self::d('overdue_alert_hours', 'follow_up', 'integer', '2', 'هشدار تأخیر', 'در گزارش عقب‌افتادگی نگهداری می‌شود.', false, false, false, null, [], true), - self::d('escalate_overdue_to_supervisor', 'follow_up', 'boolean', 'false', 'ارجاع تأخیر به سرپرست', 'به‌زودی.', false, false, false, null, [], true), + self::d('escalate_overdue_to_supervisor', 'follow_up', 'boolean', 'false', 'ارجاع تأخیر به مدیر فروش', 'به‌زودی.', false, false, false, null, [], true), self::d('auto_follow_up_call_results', 'follow_up', 'json', 'بعداً تماس بگیرید,نیازمند پیگیری,معرفی شماره جدید', 'نتایج تماس نیازمند پیگیری', 'با requires_follow_up در call_results همگام می‌شود.', true, false, false, null, ['CallController']), self::d('prevent_follow_up_outside_working_hours', 'follow_up', 'boolean', 'false', 'جلوگیری خارج از ساعت کاری', 'در ساخت پیگیری دستی و بعد از تماس اعمال می‌شود.', true, false, false, null, ['FollowUpController', 'CallService']), @@ -106,7 +108,7 @@ class SettingsCatalog self::d('attachment_max_file_size_mb', 'import_export', 'integer', '10', 'حداکثر حجم فایل پیوست', 'در بارگذاری فایل‌های لید/شرکت/فرصت اعمال می‌شود.', true, false, false, null, ['AttachmentController']), self::d('attachment_allowed_file_types', 'import_export', 'json', 'pdf,jpg,jpeg,png,doc,docx,xls,xlsx,txt', 'نوع فایل پیوست', 'در بارگذاری فایل‌های پیوست اعمال می‌شود.', true, false, false, null, ['AttachmentController']), - self::d('daily_call_target', 'reports', 'integer', '40', 'هدف تماس روزانه', 'در داشبورد کارشناس و هشدارهای سرپرست/مدیریت استفاده می‌شود.', true, false, false, null, ['DashboardService']), + self::d('daily_call_target', 'reports', 'integer', '40', 'هدف تماس روزانه', 'در داشبورد کارشناس فروش و هشدارهای مدیر فروش/مدیریت استفاده می‌شود.', true, false, false, null, ['DashboardService']), self::d('successful_call_target', 'reports', 'integer', '15', 'هدف تماس موفق', 'در پیشرفت KPI کارشناس استفاده می‌شود.', true, false, false, null, ['DashboardService']), self::d('follow_up_target', 'reports', 'integer', '20', 'هدف پیگیری', 'در پیشرفت KPI کارشناس استفاده می‌شود.', true, false, false, null, ['DashboardService']), self::d('conversion_target_percent', 'reports', 'integer', '20', 'هدف تبدیل', 'برای مقایسه مدیریتی نرخ تبدیل نگهداری و در گزارش‌ها قابل استفاده است.', true, false, false, null, ['ReportService', 'DashboardService']), @@ -149,6 +151,7 @@ class SettingsCatalog return $definition; } } + return null; } diff --git a/backend/app/Support/WorkingHours.php b/backend/app/Support/WorkingHours.php index e3010ab..886281e 100644 --- a/backend/app/Support/WorkingHours.php +++ b/backend/app/Support/WorkingHours.php @@ -16,7 +16,7 @@ class WorkingHours $at = Carbon::parse($dateTime); $dayMap = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; $workingDays = array_filter(array_map('trim', explode(',', Setting::where('key', 'working_days')->value('value') ?: 'sat,sun,mon,tue,wed'))); - if (!in_array($dayMap[$at->dayOfWeek], $workingDays, true)) { + if (! in_array($dayMap[$at->dayOfWeek], $workingDays, true)) { return false; } diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index 2d79dbd..9f716f8 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -1,29 +1,36 @@ withRouting( - web: __DIR__ . '/../routes/web.php', - api: __DIR__ . '/../routes/api.php', - commands: __DIR__ . '/../routes/console.php', + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ - 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, - 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, + 'role' => RoleMiddleware::class, + 'permission' => PermissionMiddleware::class, 'mask_phone' => MaskPhoneNumber::class, ]); $middleware->api(prepend: [ - \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, + EnsureFrontendRequestsAreStateful::class, ]); $middleware->api(append: [ @@ -40,4 +47,19 @@ return Application::configure(basePath: dirname(__DIR__)) return response()->json(['message' => 'Unauthenticated.'], 401); } }); + $exceptions->render(function (AuthorizationException $e, Request $request) { + if ($request->is('api/*')) { + return ApiResponse::error($e->getMessage() ?: 'دسترسی غیرمجاز', 'FORBIDDEN', 403); + } + }); + $exceptions->render(function (ValidationException $e, Request $request) { + if ($request->is('api/*')) { + return ApiResponse::error($e->getMessage(), 'VALIDATION_FAILED', 422, $e->errors()); + } + }); + $exceptions->render(function (TaskVersionConflictException $e, Request $request) { + if ($request->is('api/*')) { + return ApiResponse::error($e->getMessage(), 'VERSION_CONFLICT', 409); + } + }); })->create(); diff --git a/backend/composer.json b/backend/composer.json index ebb049c..4000c3c 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -11,6 +11,7 @@ "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "maatwebsite/excel": "^3.1", + "phpoffice/phpword": "^1.4", "spatie/laravel-permission": "6.25" }, "require-dev": { diff --git a/backend/composer.lock b/backend/composer.lock index c67badb..ccc562d 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5d5dfa32a0a619e8070da09e0000f7a6", + "content-hash": "e572830bfebf9c536c7314f6d8cecbb4", "packages": [ { "name": "brick/math", @@ -857,22 +857,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.12.3", + "version": "7.15.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "9aa17bcdd777ee31df9fc83c337ca4ca2340def3" + "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/9aa17bcdd777ee31df9fc83c337ca4ca2340def3", - "reference": "9aa17bcdd777ee31df9fc83c337ca4ca2340def3", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", + "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.3", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -884,8 +884,8 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.5.1", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -965,7 +965,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.12.3" + "source": "https://github.com/guzzle/guzzle/tree/7.15.1" }, "funding": [ { @@ -981,20 +981,20 @@ "type": "tidelift" } ], - "time": "2026-06-23T15:29:02+00:00" + "time": "2026-07-18T11:23:11+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { @@ -1049,7 +1049,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -1065,20 +1065,20 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:23:43+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.12.3", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", "shasum": "" }, "require": { @@ -1168,7 +1168,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.3" + "source": "https://github.com/guzzle/psr7/tree/2.13.0" }, "funding": [ { @@ -1184,7 +1184,7 @@ "type": "tidelift" } ], - "time": "2026-06-23T15:21:08+00:00" + "time": "2026-07-16T22:23:49+00:00" }, { "name": "guzzlehttp/uri-template", @@ -3079,6 +3079,58 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "phpoffice/math", + "version": "0.3.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/Math.git", + "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/Math/zipball/fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a", + "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xml": "*", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.88 || ^1.0.0", + "phpunit/phpunit": "^7.0 || ^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\Math\\": "src/Math/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Progi1984", + "homepage": "https://lefevre.dev" + } + ], + "description": "Math - Manipulate Math Formula", + "homepage": "https://phpoffice.github.io/Math/", + "keywords": [ + "MathML", + "officemathml", + "php" + ], + "support": { + "issues": "https://github.com/PHPOffice/Math/issues", + "source": "https://github.com/PHPOffice/Math/tree/0.3.0" + }, + "time": "2025-05-29T08:31:49+00:00" + }, { "name": "phpoffice/phpspreadsheet", "version": "1.30.5", @@ -3187,6 +3239,114 @@ }, "time": "2026-05-31T05:13:11+00:00" }, + { + "name": "phpoffice/phpword", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PHPWord.git", + "reference": "6d75328229bc93790b37e93741adf70646cea958" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/6d75328229bc93790b37e93741adf70646cea958", + "reference": "6d75328229bc93790b37e93741adf70646cea958", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-gd": "*", + "ext-json": "*", + "ext-xml": "*", + "ext-zip": "*", + "php": "^7.1|^8.0", + "phpoffice/math": "^0.3" + }, + "require-dev": { + "dompdf/dompdf": "^2.0 || ^3.0", + "ext-libxml": "*", + "friendsofphp/php-cs-fixer": "^3.3", + "mpdf/mpdf": "^7.0 || ^8.0", + "phpmd/phpmd": "^2.13", + "phpstan/phpstan": "^0.12.88 || ^1.0.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": ">=7.0", + "symfony/process": "^4.4 || ^5.0", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Allows writing PDF", + "ext-xmlwriter": "Allows writing OOXML and ODF", + "ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpWord\\": "src/PhpWord" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-only" + ], + "authors": [ + { + "name": "Mark Baker" + }, + { + "name": "Gabriel Bull", + "email": "me@gabrielbull.com", + "homepage": "http://gabrielbull.com/" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net/blog/" + }, + { + "name": "Ivan Lanin", + "homepage": "http://ivan.lanin.org" + }, + { + "name": "Roman Syroeshko", + "homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/" + }, + { + "name": "Antoine de Troostembergh" + } + ], + "description": "PHPWord - A pure PHP library for reading and writing word processing documents (OOXML, ODF, RTF, HTML, PDF)", + "homepage": "https://phpoffice.github.io/PHPWord/", + "keywords": [ + "ISO IEC 29500", + "OOXML", + "Office Open XML", + "OpenDocument", + "OpenXML", + "PhpOffice", + "PhpWord", + "Rich Text Format", + "WordprocessingML", + "doc", + "docx", + "html", + "odf", + "odt", + "office", + "pdf", + "php", + "reader", + "rtf", + "template", + "template processor", + "word", + "writer" + ], + "support": { + "issues": "https://github.com/PHPOffice/PHPWord/issues", + "source": "https://github.com/PHPOffice/PHPWord/tree/1.4.0" + }, + "time": "2025-06-05T10:32:36+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -4282,16 +4442,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -4329,7 +4489,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -4349,7 +4509,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/error-handler", diff --git a/backend/database/migrations/2026_06_27_123132_create_campaigns_table.php b/backend/database/migrations/2026_06_27_123132_create_campaigns_table.php index 38f5dbe..adb7428 100644 --- a/backend/database/migrations/2026_06_27_123132_create_campaigns_table.php +++ b/backend/database/migrations/2026_06_27_123132_create_campaigns_table.php @@ -17,7 +17,8 @@ return new class extends Migration $table->date('end_date')->nullable(); $table->integer('target')->nullable(); $table->string('status', 20)->default('draft'); - $table->foreignId('sales_script_id')->nullable()->constrained('sales_scripts')->nullOnDelete(); + // The foreign key is added after sales_scripts exists by the normalization migration. + $table->unsignedBigInteger('sales_script_id')->nullable(); $table->timestamps(); }); diff --git a/backend/database/migrations/2026_06_27_200000_enhance_sales_funnel.php b/backend/database/migrations/2026_06_27_200000_enhance_sales_funnel.php index 70f4fb8..11f296a 100644 --- a/backend/database/migrations/2026_06_27_200000_enhance_sales_funnel.php +++ b/backend/database/migrations/2026_06_27_200000_enhance_sales_funnel.php @@ -10,28 +10,28 @@ return new class extends Migration public function up(): void { Schema::table('leads', function (Blueprint $table) { - if (!Schema::hasColumn('leads', 'interest_level')) { + if (! Schema::hasColumn('leads', 'interest_level')) { $table->string('interest_level', 10)->default('cold')->after('lead_score'); } - if (!Schema::hasColumn('leads', 'call_attempts')) { + if (! Schema::hasColumn('leads', 'call_attempts')) { $table->integer('call_attempts')->default(0)->after('last_call_result'); } - if (!Schema::hasColumn('leads', 'lost_reason')) { + if (! Schema::hasColumn('leads', 'lost_reason')) { $table->string('lost_reason')->nullable()->after('next_follow_up_at'); } - if (!Schema::hasColumn('leads', 'deal_value')) { + if (! Schema::hasColumn('leads', 'deal_value')) { $table->decimal('deal_value', 15, 2)->nullable()->after('lost_reason'); } - if (!Schema::hasColumn('leads', 'sold_product')) { + if (! Schema::hasColumn('leads', 'sold_product')) { $table->string('sold_product')->nullable()->after('deal_value'); } - if (!Schema::hasColumn('leads', 'contract_date')) { + if (! Schema::hasColumn('leads', 'contract_date')) { $table->date('contract_date')->nullable()->after('sold_product'); } - if (!Schema::hasColumn('leads', 'payment_status')) { + if (! Schema::hasColumn('leads', 'payment_status')) { $table->string('payment_status', 30)->nullable()->after('contract_date'); } - if (!Schema::hasColumn('leads', 'customer_notes')) { + if (! Schema::hasColumn('leads', 'customer_notes')) { $table->text('customer_notes')->nullable()->after('payment_status'); } }); diff --git a/backend/database/migrations/2026_06_27_202000_simplify_telephone_sales_funnel.php b/backend/database/migrations/2026_06_27_202000_simplify_telephone_sales_funnel.php index eaad264..665b9bc 100644 --- a/backend/database/migrations/2026_06_27_202000_simplify_telephone_sales_funnel.php +++ b/backend/database/migrations/2026_06_27_202000_simplify_telephone_sales_funnel.php @@ -36,7 +36,7 @@ return new class extends Migration public function up(): void { Schema::table('leads', function (Blueprint $table) { - if (!Schema::hasColumn('leads', 'final_result')) { + if (! Schema::hasColumn('leads', 'final_result')) { $table->string('final_result', 20)->nullable()->after('lost_reason'); } }); @@ -50,12 +50,12 @@ return new class extends Migration $stageIds = DB::table('pipeline_stages')->pluck('id', 'name'); foreach (['برنده شد', 'برنده / تبدیل شده', 'Won / Converted'] as $name) { - if (!empty($stageIds[$name])) { + if (! empty($stageIds[$name])) { DB::table('leads')->where('pipeline_stage_id', $stageIds[$name])->update(['final_result' => 'موفق']); } } foreach (['از دست رفت', 'از دست رفته / بسته شده', 'Lost / Closed'] as $name) { - if (!empty($stageIds[$name])) { + if (! empty($stageIds[$name])) { DB::table('leads')->where('pipeline_stage_id', $stageIds[$name])->update(['final_result' => 'ناموفق']); } } diff --git a/backend/database/migrations/2026_06_28_120000_add_presence_fields_to_users_table.php b/backend/database/migrations/2026_06_28_120000_add_presence_fields_to_users_table.php index 6bb54f6..66691a9 100644 --- a/backend/database/migrations/2026_06_28_120000_add_presence_fields_to_users_table.php +++ b/backend/database/migrations/2026_06_28_120000_add_presence_fields_to_users_table.php @@ -9,13 +9,13 @@ return new class extends Migration public function up(): void { Schema::table('users', function (Blueprint $table) { - if (!Schema::hasColumn('users', 'current_login_at')) { + if (! Schema::hasColumn('users', 'current_login_at')) { $table->timestamp('current_login_at')->nullable()->after('last_login_ip'); } - if (!Schema::hasColumn('users', 'last_logout_at')) { + if (! Schema::hasColumn('users', 'last_logout_at')) { $table->timestamp('last_logout_at')->nullable()->after('current_login_at'); } - if (!Schema::hasColumn('users', 'total_presence_seconds')) { + if (! Schema::hasColumn('users', 'total_presence_seconds')) { $table->unsignedBigInteger('total_presence_seconds')->default(0)->after('last_logout_at'); } }); diff --git a/backend/database/migrations/2026_06_28_150000_create_contact_route_tables.php b/backend/database/migrations/2026_06_28_150000_create_contact_route_tables.php index d11dc8c..57cfb19 100644 --- a/backend/database/migrations/2026_06_28_150000_create_contact_route_tables.php +++ b/backend/database/migrations/2026_06_28_150000_create_contact_route_tables.php @@ -79,7 +79,7 @@ return new class extends Migration foreach ($leads as $lead) { $contactId = DB::table('contacts')->insertGetId([ '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' => 'رابط', 'description' => 'ایجادشده از اطلاعات اولیه لید', 'status' => 'active', diff --git a/backend/database/migrations/2026_07_02_120000_structure_workflow_and_settings.php b/backend/database/migrations/2026_07_02_120000_structure_workflow_and_settings.php index 7af7774..cdf0da3 100644 --- a/backend/database/migrations/2026_07_02_120000_structure_workflow_and_settings.php +++ b/backend/database/migrations/2026_07_02_120000_structure_workflow_and_settings.php @@ -90,37 +90,75 @@ return new class extends Migration private function addWorkflowColumns(): void { Schema::table('pipeline_stages', function (Blueprint $table) { - if (!Schema::hasColumn('pipeline_stages', 'slug')) $table->string('slug')->nullable()->after('name'); - if (!Schema::hasColumn('pipeline_stages', 'is_won')) $table->boolean('is_won')->default(false)->after('is_default'); - if (!Schema::hasColumn('pipeline_stages', 'is_lost')) $table->boolean('is_lost')->default(false)->after('is_won'); - if (!Schema::hasColumn('pipeline_stages', 'requires_follow_up')) $table->boolean('requires_follow_up')->default(false)->after('is_lost'); + if (! Schema::hasColumn('pipeline_stages', 'slug')) { + $table->string('slug')->nullable()->after('name'); + } + if (! Schema::hasColumn('pipeline_stages', 'is_won')) { + $table->boolean('is_won')->default(false)->after('is_default'); + } + if (! Schema::hasColumn('pipeline_stages', 'is_lost')) { + $table->boolean('is_lost')->default(false)->after('is_won'); + } + if (! Schema::hasColumn('pipeline_stages', 'requires_follow_up')) { + $table->boolean('requires_follow_up')->default(false)->after('is_lost'); + } }); Schema::table('lead_statuses', function (Blueprint $table) { - if (!Schema::hasColumn('lead_statuses', 'slug')) $table->string('slug')->nullable()->after('name'); - if (!Schema::hasColumn('lead_statuses', 'is_won')) $table->boolean('is_won')->default(false)->after('is_default'); - if (!Schema::hasColumn('lead_statuses', 'is_lost')) $table->boolean('is_lost')->default(false)->after('is_won'); + if (! Schema::hasColumn('lead_statuses', 'slug')) { + $table->string('slug')->nullable()->after('name'); + } + if (! Schema::hasColumn('lead_statuses', 'is_won')) { + $table->boolean('is_won')->default(false)->after('is_default'); + } + if (! Schema::hasColumn('lead_statuses', 'is_lost')) { + $table->boolean('is_lost')->default(false)->after('is_won'); + } }); Schema::table('call_results', function (Blueprint $table) { - if (!Schema::hasColumn('call_results', 'slug')) $table->string('slug')->nullable()->after('name'); - if (!Schema::hasColumn('call_results', 'next_pipeline_stage_id')) $table->foreignId('next_pipeline_stage_id')->nullable()->after('is_final')->constrained('pipeline_stages')->nullOnDelete(); - if (!Schema::hasColumn('call_results', 'lead_status_id')) $table->foreignId('lead_status_id')->nullable()->after('next_pipeline_stage_id')->constrained('lead_statuses')->nullOnDelete(); - if (!Schema::hasColumn('call_results', 'phone_status')) $table->string('phone_status', 30)->nullable()->after('lead_status_id'); - if (!Schema::hasColumn('call_results', 'next_action')) $table->string('next_action')->nullable()->after('phone_status'); + if (! Schema::hasColumn('call_results', 'slug')) { + $table->string('slug')->nullable()->after('name'); + } + if (! Schema::hasColumn('call_results', 'next_pipeline_stage_id')) { + $table->foreignId('next_pipeline_stage_id')->nullable()->after('is_final')->constrained('pipeline_stages')->nullOnDelete(); + } + if (! Schema::hasColumn('call_results', 'lead_status_id')) { + $table->foreignId('lead_status_id')->nullable()->after('next_pipeline_stage_id')->constrained('lead_statuses')->nullOnDelete(); + } + if (! Schema::hasColumn('call_results', 'phone_status')) { + $table->string('phone_status', 30)->nullable()->after('lead_status_id'); + } + if (! Schema::hasColumn('call_results', 'next_action')) { + $table->string('next_action')->nullable()->after('phone_status'); + } }); } private function addSettingColumns(): void { Schema::table('settings', function (Blueprint $table) { - if (!Schema::hasColumn('settings', 'default_value')) $table->text('default_value')->nullable()->after('value'); - if (!Schema::hasColumn('settings', 'validation_rules')) $table->json('validation_rules')->nullable()->after('type'); - if (!Schema::hasColumn('settings', 'allowed_values')) $table->json('allowed_values')->nullable()->after('validation_rules'); - if (!Schema::hasColumn('settings', 'is_secret')) $table->boolean('is_secret')->default(false)->after('allowed_values'); - if (!Schema::hasColumn('settings', 'is_public')) $table->boolean('is_public')->default(false)->after('is_secret'); - if (!Schema::hasColumn('settings', 'is_runtime_enforced')) $table->boolean('is_runtime_enforced')->default(true)->after('is_public'); - if (!Schema::hasColumn('settings', 'description')) $table->text('description')->nullable()->after('is_runtime_enforced'); + if (! Schema::hasColumn('settings', 'default_value')) { + $table->text('default_value')->nullable()->after('value'); + } + if (! Schema::hasColumn('settings', 'validation_rules')) { + $table->json('validation_rules')->nullable()->after('type'); + } + if (! Schema::hasColumn('settings', 'allowed_values')) { + $table->json('allowed_values')->nullable()->after('validation_rules'); + } + if (! Schema::hasColumn('settings', 'is_secret')) { + $table->boolean('is_secret')->default(false)->after('allowed_values'); + } + if (! Schema::hasColumn('settings', 'is_public')) { + $table->boolean('is_public')->default(false)->after('is_secret'); + } + if (! Schema::hasColumn('settings', 'is_runtime_enforced')) { + $table->boolean('is_runtime_enforced')->default(true)->after('is_public'); + } + if (! Schema::hasColumn('settings', 'description')) { + $table->text('description')->nullable()->after('is_runtime_enforced'); + } }); } diff --git a/backend/database/migrations/2026_07_07_130000_phase5_core_sales_crm.php b/backend/database/migrations/2026_07_07_130000_phase5_core_sales_crm.php index 4c5b353..104c84c 100644 --- a/backend/database/migrations/2026_07_07_130000_phase5_core_sales_crm.php +++ b/backend/database/migrations/2026_07_07_130000_phase5_core_sales_crm.php @@ -99,7 +99,7 @@ return new class extends Migration }); Schema::table('leads', function (Blueprint $table) { - if (!Schema::hasColumn('leads', 'company_id')) { + if (! Schema::hasColumn('leads', 'company_id')) { $table->foreignId('company_id')->nullable()->after('id')->constrained('companies')->nullOnDelete(); } $table->index('company_id'); @@ -107,37 +107,37 @@ return new class extends Migration Schema::table('contacts', function (Blueprint $table) { $table->foreignId('lead_id')->nullable()->change(); - if (!Schema::hasColumn('contacts', 'company_id')) { + if (! Schema::hasColumn('contacts', 'company_id')) { $table->foreignId('company_id')->nullable()->after('lead_id')->constrained('companies')->cascadeOnDelete(); } - if (!Schema::hasColumn('contacts', 'deal_id')) { + if (! Schema::hasColumn('contacts', 'deal_id')) { $table->foreignId('deal_id')->nullable()->after('company_id')->constrained('deals')->nullOnDelete(); } - if (!Schema::hasColumn('contacts', 'first_name')) { + if (! Schema::hasColumn('contacts', 'first_name')) { $table->string('first_name')->nullable()->after('name'); } - if (!Schema::hasColumn('contacts', 'last_name')) { + if (! Schema::hasColumn('contacts', 'last_name')) { $table->string('last_name')->nullable()->after('first_name'); } - if (!Schema::hasColumn('contacts', 'job_title')) { + if (! Schema::hasColumn('contacts', 'job_title')) { $table->string('job_title')->nullable()->after('role'); } - if (!Schema::hasColumn('contacts', 'email')) { + if (! Schema::hasColumn('contacts', 'email')) { $table->string('email')->nullable()->after('job_title'); } - if (!Schema::hasColumn('contacts', 'preferred_channel')) { + if (! Schema::hasColumn('contacts', 'preferred_channel')) { $table->string('preferred_channel', 40)->nullable()->after('email'); } }); Schema::table('sales_scripts', function (Blueprint $table) { - if (!Schema::hasColumn('sales_scripts', 'product_id')) { + if (! Schema::hasColumn('sales_scripts', 'product_id')) { $table->foreignId('product_id')->nullable()->after('campaign_id')->constrained('products')->nullOnDelete(); } - if (!Schema::hasColumn('sales_scripts', 'checklist')) { + if (! Schema::hasColumn('sales_scripts', 'checklist')) { $table->json('checklist')->nullable(); } - if (!Schema::hasColumn('sales_scripts', 'objection_handling')) { + if (! Schema::hasColumn('sales_scripts', 'objection_handling')) { $table->json('objection_handling')->nullable(); } }); @@ -146,22 +146,36 @@ return new class extends Migration public function down(): void { Schema::table('sales_scripts', function (Blueprint $table) { - if (Schema::hasColumn('sales_scripts', 'product_id')) $table->dropConstrainedForeignId('product_id'); - if (Schema::hasColumn('sales_scripts', 'checklist')) $table->dropColumn('checklist'); - if (Schema::hasColumn('sales_scripts', 'objection_handling')) $table->dropColumn('objection_handling'); + if (Schema::hasColumn('sales_scripts', 'product_id')) { + $table->dropConstrainedForeignId('product_id'); + } + if (Schema::hasColumn('sales_scripts', 'checklist')) { + $table->dropColumn('checklist'); + } + if (Schema::hasColumn('sales_scripts', 'objection_handling')) { + $table->dropColumn('objection_handling'); + } }); Schema::table('contacts', function (Blueprint $table) { foreach (['preferred_channel', 'email', 'job_title', 'last_name', 'first_name'] as $column) { - if (Schema::hasColumn('contacts', $column)) $table->dropColumn($column); + if (Schema::hasColumn('contacts', $column)) { + $table->dropColumn($column); + } + } + if (Schema::hasColumn('contacts', 'deal_id')) { + $table->dropConstrainedForeignId('deal_id'); + } + if (Schema::hasColumn('contacts', 'company_id')) { + $table->dropConstrainedForeignId('company_id'); } - if (Schema::hasColumn('contacts', 'deal_id')) $table->dropConstrainedForeignId('deal_id'); - if (Schema::hasColumn('contacts', 'company_id')) $table->dropConstrainedForeignId('company_id'); $table->foreignId('lead_id')->nullable(false)->change(); }); Schema::table('leads', function (Blueprint $table) { - if (Schema::hasColumn('leads', 'company_id')) $table->dropConstrainedForeignId('company_id'); + if (Schema::hasColumn('leads', 'company_id')) { + $table->dropConstrainedForeignId('company_id'); + } }); Schema::dropIfExists('merge_histories'); diff --git a/backend/database/migrations/2026_07_07_160000_add_voip_extension_to_users_table.php b/backend/database/migrations/2026_07_07_160000_add_voip_extension_to_users_table.php index 42119f8..1329c2a 100644 --- a/backend/database/migrations/2026_07_07_160000_add_voip_extension_to_users_table.php +++ b/backend/database/migrations/2026_07_07_160000_add_voip_extension_to_users_table.php @@ -9,7 +9,7 @@ return new class extends Migration public function up(): void { Schema::table('users', function (Blueprint $table) { - if (!Schema::hasColumn('users', 'voip_extension')) { + if (! Schema::hasColumn('users', 'voip_extension')) { $table->string('voip_extension', 20)->nullable()->unique()->after('phone'); } }); diff --git a/backend/database/migrations/2026_07_15_000000_normalize_sales_script_relationships.php b/backend/database/migrations/2026_07_15_000000_normalize_sales_script_relationships.php new file mode 100644 index 0000000..5c1dc78 --- /dev/null +++ b/backend/database/migrations/2026_07_15_000000_normalize_sales_script_relationships.php @@ -0,0 +1,83 @@ +whereNotNull('campaign_id') + ->orderBy('id') + ->eachById(function (object $script): void { + DB::table('campaigns') + ->where('id', $script->campaign_id) + ->whereNull('sales_script_id') + ->update(['sales_script_id' => $script->id]); + }); + } + + if (Schema::hasColumn('sales_scripts', 'product_id')) { + DB::table('sales_scripts') + ->whereNotNull('product_id') + ->orderBy('id') + ->eachById(function (object $script): void { + DB::table('products') + ->where('id', $script->product_id) + ->whereNull('sales_script_id') + ->update(['sales_script_id' => $script->id]); + }); + } + + Schema::table('sales_scripts', function (Blueprint $table): void { + if (Schema::hasColumn('sales_scripts', 'campaign_id')) { + $table->dropConstrainedForeignId('campaign_id'); + } + if (Schema::hasColumn('sales_scripts', 'product_id')) { + $table->dropConstrainedForeignId('product_id'); + } + }); + + if (! $this->hasForeignKey('campaigns', 'sales_script_id')) { + Schema::table('campaigns', function (Blueprint $table): void { + $table->foreign('sales_script_id')->references('id')->on('sales_scripts')->nullOnDelete(); + }); + } + + if (! $this->hasForeignKey('products', 'sales_script_id')) { + Schema::table('products', function (Blueprint $table): void { + $table->foreign('sales_script_id')->references('id')->on('sales_scripts')->nullOnDelete(); + }); + } + } + + public function down(): void + { + Schema::table('sales_scripts', function (Blueprint $table): void { + if (! Schema::hasColumn('sales_scripts', 'campaign_id')) { + $table->foreignId('campaign_id')->nullable()->constrained('campaigns')->nullOnDelete(); + } + if (! Schema::hasColumn('sales_scripts', 'product_id')) { + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + } + }); + + DB::table('campaigns')->whereNotNull('sales_script_id')->orderBy('id')->eachById(function (object $campaign): void { + DB::table('sales_scripts')->where('id', $campaign->sales_script_id)->whereNull('campaign_id')->update(['campaign_id' => $campaign->id]); + }); + DB::table('products')->whereNotNull('sales_script_id')->orderBy('id')->eachById(function (object $product): void { + DB::table('sales_scripts')->where('id', $product->sales_script_id)->whereNull('product_id')->update(['product_id' => $product->id]); + }); + } + + private function hasForeignKey(string $table, string $column): bool + { + return collect(Schema::getForeignKeys($table))->contains( + fn (array $foreign): bool => in_array($column, $foreign['columns'] ?? [], true) + ); + } +}; diff --git a/backend/database/migrations/2026_07_15_010000_add_quality_and_script_integrity.php b/backend/database/migrations/2026_07_15_010000_add_quality_and_script_integrity.php new file mode 100644 index 0000000..4b4231e --- /dev/null +++ b/backend/database/migrations/2026_07_15_010000_add_quality_and_script_integrity.php @@ -0,0 +1,53 @@ +unsignedInteger('version')->default(1)->after('agent_id'); + $table->boolean('is_current')->default(true)->after('version'); + }); + + DB::table('quality_reviews')->orderBy('call_id')->orderBy('id')->get()->groupBy('call_id')->each(function ($reviews): void { + $lastId = $reviews->last()->id; + foreach ($reviews->values() as $index => $review) { + DB::table('quality_reviews')->where('id', $review->id)->update([ + 'version' => $index + 1, + 'is_current' => $review->id === $lastId, + ]); + } + }); + + DB::table('script_sections')->orderBy('sales_script_id')->orderBy('sort_order')->orderBy('id')->get()->groupBy('sales_script_id')->each(function ($sections): void { + foreach ($sections->values() as $index => $section) { + DB::table('script_sections')->where('id', $section->id)->update(['sort_order' => $index]); + } + }); + + Schema::table('quality_reviews', function (Blueprint $table): void { + $table->unique(['call_id', 'version']); + $table->index(['agent_id', 'is_current', 'is_shared_with_agent'], 'quality_reviews_agent_visibility_index'); + }); + Schema::table('script_sections', function (Blueprint $table): void { + $table->unique(['sales_script_id', 'sort_order']); + }); + } + + public function down(): void + { + Schema::table('script_sections', function (Blueprint $table): void { + $table->dropUnique(['sales_script_id', 'sort_order']); + }); + Schema::table('quality_reviews', function (Blueprint $table): void { + $table->dropUnique(['call_id', 'version']); + $table->dropIndex('quality_reviews_agent_visibility_index'); + $table->dropColumn(['version', 'is_current']); + }); + } +}; diff --git a/backend/database/migrations/2026_07_15_020000_create_tasks_table.php b/backend/database/migrations/2026_07_15_020000_create_tasks_table.php new file mode 100644 index 0000000..48339a4 --- /dev/null +++ b/backend/database/migrations/2026_07_15_020000_create_tasks_table.php @@ -0,0 +1,43 @@ +id(); + $table->string('subject'); + $table->text('description')->nullable(); + $table->nullableMorphs('taskable'); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('assigned_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('priority', 20)->default('normal'); + $table->string('status', 20)->default('open'); + $table->dateTime('due_at')->nullable(); + $table->dateTime('started_at')->nullable(); + $table->dateTime('completed_at')->nullable(); + $table->dateTime('reminder_at')->nullable(); + $table->foreignId('parent_task_id')->nullable()->constrained('tasks')->nullOnDelete(); + $table->unsignedInteger('estimated_minutes')->nullable(); + $table->string('visibility', 20)->default('private'); + $table->unsignedInteger('version')->default(1); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['assigned_to', 'status', 'due_at']); + $table->index('created_by'); + $table->index('parent_task_id'); + $table->index(['reminder_at', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tasks'); + } +}; diff --git a/backend/database/migrations/2026_07_15_021000_enhance_notes_and_backfill_call_notes.php b/backend/database/migrations/2026_07_15_021000_enhance_notes_and_backfill_call_notes.php new file mode 100644 index 0000000..b531112 --- /dev/null +++ b/backend/database/migrations/2026_07_15_021000_enhance_notes_and_backfill_call_notes.php @@ -0,0 +1,64 @@ +dropForeign(['user_id']); + }); + + Schema::table('notes', function (Blueprint $table) { + $table->unsignedBigInteger('user_id')->nullable()->change(); + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + $table->string('type', 30)->default('general')->after('content'); + $table->string('visibility', 20)->default('team')->after('type'); + $table->boolean('is_pinned')->default(false)->after('visibility'); + $table->dateTime('edited_at')->nullable()->after('is_pinned'); + $table->string('source_key')->nullable()->unique()->after('edited_at'); + $table->softDeletes(); + $table->index(['notable_type', 'notable_id', 'is_pinned']); + }); + + DB::table('calls') + ->whereNotNull('notes') + ->where('notes', '<>', '') + ->orderBy('id') + ->chunkById(100, function ($calls): void { + foreach ($calls as $call) { + DB::table('notes')->updateOrInsert( + ['source_key' => "legacy_call:{$call->id}"], + [ + 'notable_type' => Call::class, + 'notable_id' => $call->id, + 'user_id' => $call->user_id, + 'content' => $call->notes, + 'type' => 'call_summary', + 'visibility' => 'team', + 'is_pinned' => false, + 'created_at' => $call->updated_at ?? $call->created_at ?? now(), + 'updated_at' => $call->updated_at ?? now(), + ] + ); + } + }); + } + + public function down(): void + { + DB::table('notes')->where('source_key', 'like', 'legacy_call:%')->delete(); + + Schema::table('notes', function (Blueprint $table) { + $table->dropIndex(['notable_type', 'notable_id', 'is_pinned']); + $table->dropUnique(['source_key']); + $table->dropSoftDeletes(); + $table->dropColumn(['type', 'visibility', 'is_pinned', 'edited_at', 'source_key']); + }); + } +}; diff --git a/backend/database/migrations/2026_07_15_022000_extend_notifications_audit_and_contact_phones.php b/backend/database/migrations/2026_07_15_022000_extend_notifications_audit_and_contact_phones.php new file mode 100644 index 0000000..a5c0fea --- /dev/null +++ b/backend/database/migrations/2026_07_15_022000_extend_notifications_audit_and_contact_phones.php @@ -0,0 +1,46 @@ +dateTime('read_at')->nullable()->after('is_read'); + $table->string('idempotency_key')->nullable()->unique()->after('read_at'); + }); + + DB::table('internal_notifications')->where('is_read', true)->update(['read_at' => DB::raw('updated_at')]); + + Schema::table('activity_logs', function (Blueprint $table) { + $table->json('before_data')->nullable()->after('description'); + $table->json('after_data')->nullable()->after('before_data'); + $table->uuid('request_id')->nullable()->index()->after('after_data'); + }); + + Schema::table('contact_phones', function (Blueprint $table) { + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::table('contact_phones', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + + Schema::table('activity_logs', function (Blueprint $table) { + $table->dropIndex(['request_id']); + $table->dropColumn(['before_data', 'after_data', 'request_id']); + }); + + Schema::table('internal_notifications', function (Blueprint $table) { + $table->dropUnique(['idempotency_key']); + $table->dropColumn(['read_at', 'idempotency_key']); + }); + } +}; diff --git a/backend/database/migrations/2026_07_15_030000_create_deal_pipeline_foundation.php b/backend/database/migrations/2026_07_15_030000_create_deal_pipeline_foundation.php new file mode 100644 index 0000000..a826233 --- /dev/null +++ b/backend/database/migrations/2026_07_15_030000_create_deal_pipeline_foundation.php @@ -0,0 +1,114 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->foreignId('team_id')->nullable()->constrained('teams')->nullOnDelete(); + $table->boolean('is_default')->default(false); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + $table->softDeletes(); + $table->index(['team_id', 'is_active']); + }); + + Schema::create('deal_stages', function (Blueprint $table) { + $table->id(); + $table->foreignId('pipeline_id')->constrained('pipelines')->cascadeOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->string('color', 20)->default('#64748B'); + $table->unsignedTinyInteger('probability')->default(0); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('is_won')->default(false); + $table->boolean('is_lost')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->unique(['pipeline_id', 'slug']); + $table->index(['pipeline_id', 'sort_order', 'is_active']); + }); + + Schema::table('deals', function (Blueprint $table) { + $table->foreignId('pipeline_id')->nullable()->after('product_id')->constrained('pipelines')->nullOnDelete(); + $table->foreignId('deal_stage_id')->nullable()->after('pipeline_id')->constrained('deal_stages')->nullOnDelete(); + $table->decimal('final_amount', 15, 2)->nullable()->after('estimated_value'); + $table->string('competitor')->nullable()->after('won_lost_reason'); + $table->string('forecast_category', 30)->default('pipeline')->after('competitor'); + $table->dateTime('last_activity_at')->nullable()->after('expected_close_date'); + $table->dateTime('closed_at')->nullable()->after('last_activity_at'); + $table->unsignedInteger('version')->default(1); + $table->index(['pipeline_id', 'deal_stage_id', 'status']); + $table->index(['expected_close_date', 'status']); + $table->index(['last_activity_at', 'status']); + }); + + Schema::create('deal_stage_histories', function (Blueprint $table) { + $table->id(); + $table->foreignId('deal_id')->constrained('deals')->cascadeOnDelete(); + $table->foreignId('from_stage_id')->nullable()->constrained('deal_stages')->nullOnDelete(); + $table->foreignId('to_stage_id')->nullable()->constrained('deal_stages')->nullOnDelete(); + $table->foreignId('changed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('note')->nullable(); + $table->timestamps(); + $table->index(['deal_id', 'created_at']); + }); + + $pipelineId = DB::table('pipelines')->insertGetId([ + 'name' => 'فروش اصلی', 'slug' => 'default-sales', 'is_default' => true, + 'is_active' => true, 'sort_order' => 0, 'created_at' => now(), 'updated_at' => now(), + ]); + $stages = [ + ['new', 'جدید', '#2563EB', 10, 0, false, false], + ['qualified', 'واجد شرایط', '#0891B2', 30, 1, false, false], + ['proposal', 'پیشنهاد', '#7C3AED', 55, 2, false, false], + ['negotiation', 'مذاکره', '#D97706', 75, 3, false, false], + ['won', 'موفق', '#059669', 100, 4, true, false], + ['lost', 'ناموفق', '#DC2626', 0, 5, false, true], + ]; + $stageIds = []; + foreach ($stages as [$slug, $name, $color, $probability, $sort, $won, $lost]) { + $stageIds[$slug] = DB::table('deal_stages')->insertGetId([ + 'pipeline_id' => $pipelineId, 'slug' => $slug, 'name' => $name, 'color' => $color, + 'probability' => $probability, 'sort_order' => $sort, 'is_won' => $won, + 'is_lost' => $lost, 'is_active' => true, 'created_at' => now(), 'updated_at' => now(), + ]); + } + + DB::table('deals')->orderBy('id')->chunkById(100, function ($deals) use ($pipelineId, $stageIds): void { + foreach ($deals as $deal) { + $stage = match ($deal->status) { + 'won' => 'won', 'lost' => 'lost', default => array_key_exists($deal->sales_stage, $stageIds) ? $deal->sales_stage : 'new', + }; + DB::table('deals')->where('id', $deal->id)->update([ + 'pipeline_id' => $pipelineId, + 'deal_stage_id' => $stageIds[$stage], + ]); + } + }); + } + + public function down(): void + { + Schema::dropIfExists('deal_stage_histories'); + Schema::table('deals', function (Blueprint $table) { + $table->dropIndex(['last_activity_at', 'status']); + $table->dropIndex(['expected_close_date', 'status']); + $table->dropIndex(['pipeline_id', 'deal_stage_id', 'status']); + $table->dropConstrainedForeignId('deal_stage_id'); + $table->dropConstrainedForeignId('pipeline_id'); + $table->dropColumn(['final_amount', 'competitor', 'forecast_category', 'last_activity_at', 'closed_at', 'version']); + }); + Schema::dropIfExists('deal_stages'); + Schema::dropIfExists('pipelines'); + } +}; diff --git a/backend/database/migrations/2026_07_15_031000_create_saved_views_and_preferences.php b/backend/database/migrations/2026_07_15_031000_create_saved_views_and_preferences.php new file mode 100644 index 0000000..fafacab --- /dev/null +++ b/backend/database/migrations/2026_07_15_031000_create_saved_views_and_preferences.php @@ -0,0 +1,54 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnDelete(); + $table->foreignId('team_id')->nullable()->constrained('teams')->cascadeOnDelete(); + $table->string('entity_type', 30); + $table->string('name'); + $table->string('visibility', 20)->default('private'); + $table->json('filters'); + $table->json('columns')->nullable(); + $table->json('sort')->nullable(); + $table->boolean('is_default')->default(false); + $table->timestamps(); + $table->softDeletes(); + $table->index(['entity_type', 'visibility']); + $table->index(['user_id', 'entity_type', 'is_default']); + }); + + Schema::create('dashboard_preferences', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete(); + $table->json('widget_order')->nullable(); + $table->json('hidden_widgets')->nullable(); + $table->json('default_filters')->nullable(); + $table->timestamps(); + }); + + Schema::create('notification_preferences', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('notification_type', 60); + $table->boolean('in_app_enabled')->default(true); + $table->boolean('is_muted')->default(false); + $table->timestamps(); + $table->unique(['user_id', 'notification_type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('notification_preferences'); + Schema::dropIfExists('dashboard_preferences'); + Schema::dropIfExists('saved_views'); + } +}; diff --git a/backend/database/migrations/2026_07_15_032000_create_lead_scoring_and_sla.php b/backend/database/migrations/2026_07_15_032000_create_lead_scoring_and_sla.php new file mode 100644 index 0000000..b619110 --- /dev/null +++ b/backend/database/migrations/2026_07_15_032000_create_lead_scoring_and_sla.php @@ -0,0 +1,58 @@ +string('score_level', 20)->default('cold')->after('lead_score'); + $table->json('score_breakdown')->nullable()->after('score_level'); + $table->dateTime('scored_at')->nullable()->after('score_breakdown'); + $table->index(['score_level', 'lead_score']); + }); + + Schema::create('sla_rules', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('event', 50); + $table->unsignedInteger('warning_minutes'); + $table->unsignedInteger('breach_minutes'); + $table->json('scope')->nullable(); + $table->boolean('is_active')->default(true); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->index(['event', 'is_active']); + }); + + Schema::create('sla_breaches', function (Blueprint $table) { + $table->id(); + $table->foreignId('sla_rule_id')->constrained('sla_rules')->cascadeOnDelete(); + $table->morphs('breachable'); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->string('status', 20)->default('warning'); + $table->dateTime('due_at'); + $table->dateTime('warned_at')->nullable(); + $table->dateTime('breached_at')->nullable(); + $table->dateTime('resolved_at')->nullable(); + $table->string('event_key')->unique(); + $table->json('details')->nullable(); + $table->timestamps(); + $table->index(['status', 'due_at']); + $table->index(['assigned_to', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('sla_breaches'); + Schema::dropIfExists('sla_rules'); + Schema::table('leads', function (Blueprint $table) { + $table->dropIndex(['score_level', 'lead_score']); + $table->dropColumn(['score_level', 'score_breakdown', 'scored_at']); + }); + } +}; diff --git a/backend/database/migrations/2026_07_15_033000_create_automation_and_custom_fields.php b/backend/database/migrations/2026_07_15_033000_create_automation_and_custom_fields.php new file mode 100644 index 0000000..f789ad2 --- /dev/null +++ b/backend/database/migrations/2026_07_15_033000_create_automation_and_custom_fields.php @@ -0,0 +1,92 @@ +id(); + $table->string('name'); + $table->string('trigger', 50); + $table->json('conditions')->nullable(); + $table->json('actions'); + $table->foreignId('team_id')->nullable()->constrained('teams')->nullOnDelete(); + $table->boolean('is_active')->default(true); + $table->unsignedSmallInteger('max_runs_per_record')->default(1); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + $table->index(['trigger', 'is_active']); + $table->index(['team_id', 'is_active']); + }); + + Schema::create('automation_runs', function (Blueprint $table) { + $table->id(); + $table->foreignId('automation_rule_id')->constrained('automation_rules')->cascadeOnDelete(); + $table->nullableMorphs('subject'); + $table->string('status', 20)->default('running'); + $table->unsignedSmallInteger('attempt')->default(1); + $table->string('event_key')->unique(); + $table->json('input')->nullable(); + $table->json('output')->nullable(); + $table->text('error')->nullable(); + $table->dateTime('started_at'); + $table->dateTime('completed_at')->nullable(); + $table->timestamps(); + $table->index(['status', 'created_at']); + }); + + Schema::create('custom_field_definitions', function (Blueprint $table) { + $table->id(); + $table->string('entity_type', 30); + $table->string('key', 80); + $table->string('label'); + $table->string('type', 30); + $table->json('options')->nullable(); + $table->json('validation')->nullable(); + $table->json('visible_to_roles')->nullable(); + $table->text('default_value')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('is_required')->default(false); + $table->boolean('is_active')->default(true); + $table->boolean('is_filterable')->default(false); + $table->boolean('is_searchable')->default(false); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['entity_type', 'key']); + $table->index(['entity_type', 'is_active', 'sort_order']); + }); + + Schema::create('custom_field_values', function (Blueprint $table) { + $table->id(); + $table->foreignId('custom_field_definition_id')->constrained('custom_field_definitions')->cascadeOnDelete(); + $table->morphs('fieldable'); + $table->string('value_string')->nullable(); + $table->text('value_text')->nullable(); + $table->decimal('value_number', 20, 4)->nullable(); + $table->date('value_date')->nullable(); + $table->dateTime('value_datetime')->nullable(); + $table->boolean('value_boolean')->nullable(); + $table->json('value_json')->nullable(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->unique(['custom_field_definition_id', 'fieldable_type', 'fieldable_id'], 'custom_field_value_unique'); + $table->index(['custom_field_definition_id', 'value_string']); + $table->index(['custom_field_definition_id', 'value_number']); + $table->index(['custom_field_definition_id', 'value_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('custom_field_values'); + Schema::dropIfExists('custom_field_definitions'); + Schema::dropIfExists('automation_runs'); + Schema::dropIfExists('automation_rules'); + } +}; diff --git a/backend/database/migrations/2026_07_15_034000_enhance_quality_scripts_for_p2.php b/backend/database/migrations/2026_07_15_034000_enhance_quality_scripts_for_p2.php new file mode 100644 index 0000000..a9ff135 --- /dev/null +++ b/backend/database/migrations/2026_07_15_034000_enhance_quality_scripts_for_p2.php @@ -0,0 +1,43 @@ +json('strengths')->nullable(); + $table->json('improvement_areas')->nullable(); + $table->string('status', 30)->default('completed'); + $table->dateTime('acknowledged_at')->nullable(); + $table->text('agent_response')->nullable(); + $table->index(['agent_id', 'status', 'is_current']); + }); + + Schema::table('sales_scripts', function (Blueprint $table) { + $table->string('category')->nullable(); + $table->string('lead_source')->nullable(); + $table->json('suggested_questions')->nullable(); + $table->json('required_disclosures')->nullable(); + $table->boolean('is_template')->default(false); + $table->index(['category', 'is_active']); + $table->index(['lead_source', 'is_active']); + }); + } + + public function down(): void + { + Schema::table('sales_scripts', function (Blueprint $table) { + $table->dropIndex(['lead_source', 'is_active']); + $table->dropIndex(['category', 'is_active']); + $table->dropColumn(['category', 'lead_source', 'suggested_questions', 'required_disclosures', 'is_template']); + }); + Schema::table('quality_reviews', function (Blueprint $table) { + $table->dropIndex(['agent_id', 'status', 'is_current']); + $table->dropColumn(['strengths', 'improvement_areas', 'status', 'acknowledged_at', 'agent_response']); + }); + } +}; diff --git a/backend/database/migrations/2026_07_15_035000_disable_mock_voip_outside_tests.php b/backend/database/migrations/2026_07_15_035000_disable_mock_voip_outside_tests.php new file mode 100644 index 0000000..dcaacfe --- /dev/null +++ b/backend/database/migrations/2026_07_15_035000_disable_mock_voip_outside_tests.php @@ -0,0 +1,27 @@ +where('key', 'voip_provider')->where('value', 'mock')->update([ + 'value' => 'none', + 'default_value' => 'none', + 'allowed_values' => json_encode(['none', 'ami', 'api', 'socket']), + 'updated_at' => now(), + ]); + } + + public function down(): void + { + DB::table('settings')->where('key', 'voip_provider')->where('value', 'none')->update([ + 'value' => 'mock', + 'default_value' => 'mock', + 'allowed_values' => json_encode(['mock', 'ami', 'api', 'socket']), + 'updated_at' => now(), + ]); + } +}; diff --git a/backend/database/migrations/2026_07_15_040000_create_invoice_workflow.php b/backend/database/migrations/2026_07_15_040000_create_invoice_workflow.php new file mode 100644 index 0000000..77790e1 --- /dev/null +++ b/backend/database/migrations/2026_07_15_040000_create_invoice_workflow.php @@ -0,0 +1,98 @@ +id(); + $table->string('name'); + $table->string('background_path')->nullable(); + $table->string('background_name')->nullable(); + $table->string('background_mime')->nullable(); + $table->json('layout')->nullable(); + $table->unsignedSmallInteger('page_width_mm')->default(210); + $table->unsignedSmallInteger('page_height_mm')->default(297); + $table->boolean('is_default')->default(false)->index(); + $table->boolean('is_active')->default(true)->index(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + + Schema::create('invoices', function (Blueprint $table): void { + $table->id(); + $table->string('number')->nullable()->unique(); + $table->foreignId('lead_id')->constrained('leads')->restrictOnDelete(); + $table->foreignId('invoice_template_id')->nullable()->constrained('invoice_templates')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('status', 32)->default('pending_approval')->index(); + $table->string('currency', 8)->default('IRR'); + $table->json('customer_snapshot'); + $table->json('lead_snapshot'); + $table->json('items'); + $table->json('resolved_fields')->nullable(); + $table->decimal('subtotal', 18, 2)->default(0); + $table->decimal('discount', 18, 2)->default(0); + $table->decimal('tax', 18, 2)->default(0); + $table->decimal('total', 18, 2)->default(0); + $table->text('notes')->nullable(); + $table->timestamp('issued_at')->nullable(); + $table->timestamp('voided_at')->nullable(); + $table->unsignedInteger('version')->default(1); + $table->timestamps(); + $table->index(['lead_id', 'status']); + }); + + $permissions = [ + 'view_invoices', + 'create_invoices', + 'approve_invoices', + 'manage_invoice_templates', + ]; + foreach ($permissions as $name) { + DB::table('permissions')->insertOrIgnore([ + 'name' => $name, + 'guard_name' => 'web', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $rolePermissions = [ + 'admin' => $permissions, + 'supervisor' => $permissions, + 'agent' => ['view_invoices', 'create_invoices'], + ]; + foreach ($rolePermissions as $roleName => $names) { + $roleId = DB::table('roles')->where('name', $roleName)->where('guard_name', 'web')->value('id'); + if (! $roleId) { + continue; + } + foreach ($names as $name) { + $permissionId = DB::table('permissions')->where('name', $name)->where('guard_name', 'web')->value('id'); + if ($permissionId) { + DB::table('role_has_permissions')->insertOrIgnore(['permission_id' => $permissionId, 'role_id' => $roleId]); + } + } + } + } + + public function down(): void + { + Schema::dropIfExists('invoices'); + Schema::dropIfExists('invoice_templates'); + + $permissionIds = DB::table('permissions')->whereIn('name', [ + 'view_invoices', 'create_invoices', 'approve_invoices', 'manage_invoice_templates', + ])->pluck('id'); + DB::table('role_has_permissions')->whereIn('permission_id', $permissionIds)->delete(); + DB::table('model_has_permissions')->whereIn('permission_id', $permissionIds)->delete(); + DB::table('permissions')->whereIn('id', $permissionIds)->delete(); + } +}; diff --git a/backend/database/migrations/2026_07_15_040500_set_default_workweek_to_saturday_thursday.php b/backend/database/migrations/2026_07_15_040500_set_default_workweek_to_saturday_thursday.php new file mode 100644 index 0000000..6bd2653 --- /dev/null +++ b/backend/database/migrations/2026_07_15_040500_set_default_workweek_to_saturday_thursday.php @@ -0,0 +1,23 @@ +where('key', 'working_days')->update(['default_value' => 'sat,sun,mon,tue,wed,thu']); + DB::table('settings')->where('key', 'working_days')->where(function ($query): void { + $query->where('value', 'sat,sun,mon,tue,wed')->orWhereNull('value')->orWhere('value', ''); + })->update(['value' => 'sat,sun,mon,tue,wed,thu']); + } + + public function down(): void + { + DB::table('settings')->where('key', 'working_days')->update(['default_value' => 'sat,sun,mon,tue,wed']); + DB::table('settings')->where('key', 'working_days')->where('value', 'sat,sun,mon,tue,wed,thu')->update([ + 'value' => 'sat,sun,mon,tue,wed', + ]); + } +}; diff --git a/backend/database/migrations/2026_07_15_042000_add_provider_lifecycle_to_calls.php b/backend/database/migrations/2026_07_15_042000_add_provider_lifecycle_to_calls.php new file mode 100644 index 0000000..37905fc --- /dev/null +++ b/backend/database/migrations/2026_07_15_042000_add_provider_lifecycle_to_calls.php @@ -0,0 +1,26 @@ +string('provider_status', 40)->default('pending')->after('provider_call_id')->index(); + $table->dateTime('started_at')->nullable()->after('provider_status'); + $table->dateTime('ended_at')->nullable()->after('started_at'); + $table->json('provider_payload')->nullable()->after('ended_at'); + }); + } + + public function down(): void + { + Schema::table('calls', function (Blueprint $table): void { + $table->dropIndex(['provider_status']); + $table->dropColumn(['provider_status', 'started_at', 'ended_at', 'provider_payload']); + }); + } +}; diff --git a/backend/database/migrations/2026_07_16_000000_add_page_dimensions_to_invoices.php b/backend/database/migrations/2026_07_16_000000_add_page_dimensions_to_invoices.php new file mode 100644 index 0000000..a642f58 --- /dev/null +++ b/backend/database/migrations/2026_07_16_000000_add_page_dimensions_to_invoices.php @@ -0,0 +1,23 @@ +unsignedSmallInteger('page_width_mm')->default(210)->after('resolved_fields'); + $table->unsignedSmallInteger('page_height_mm')->default(297)->after('page_width_mm'); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table): void { + $table->dropColumn(['page_width_mm', 'page_height_mm']); + }); + } +}; diff --git a/backend/database/migrations/2026_07_16_010000_add_source_settings_to_invoice_templates.php b/backend/database/migrations/2026_07_16_010000_add_source_settings_to_invoice_templates.php new file mode 100644 index 0000000..bf54aa9 --- /dev/null +++ b/backend/database/migrations/2026_07_16_010000_add_source_settings_to_invoice_templates.php @@ -0,0 +1,36 @@ +string('source_path')->nullable()->after('background_mime'); + $table->string('source_name')->nullable()->after('source_path'); + $table->string('source_mime', 100)->nullable()->after('source_name'); + $table->string('base_type', 32)->default('blank')->after('source_mime'); + $table->json('background_settings')->nullable()->after('base_type'); + }); + + DB::table('invoice_templates')->whereNotNull('background_path')->update([ + 'base_type' => 'full_template', + 'background_settings' => json_encode(['fit' => 'stretch', 'top' => 0, 'height' => 100]), + ]); + DB::table('invoice_templates')->whereNotNull('background_path')->update([ + 'source_name' => DB::raw('background_name'), + 'source_mime' => DB::raw('background_mime'), + ]); + } + + public function down(): void + { + Schema::table('invoice_templates', function (Blueprint $table): void { + $table->dropColumn(['source_path', 'source_name', 'source_mime', 'base_type', 'background_settings']); + }); + } +}; diff --git a/backend/database/migrations/2026_07_22_000000_complete_workflow_interactions.php b/backend/database/migrations/2026_07_22_000000_complete_workflow_interactions.php new file mode 100644 index 0000000..0b15b5c --- /dev/null +++ b/backend/database/migrations/2026_07_22_000000_complete_workflow_interactions.php @@ -0,0 +1,61 @@ +foreignId('created_by')->nullable()->after('user_id')->constrained('users')->nullOnDelete(); + $table->string('source', 32)->default('manual')->after('call_id'); + $table->index(['created_by', 'created_at']); + }); + DB::table('follow_ups')->whereNull('created_by')->update(['created_by' => DB::raw('user_id')]); + + Schema::table('internal_notifications', function (Blueprint $table): void { + $table->timestamp('archived_at')->nullable()->after('read_at')->index(); + $table->softDeletes(); + }); + + Schema::create('sales_script_user', function (Blueprint $table): void { + $table->id(); + $table->foreignId('sales_script_id')->constrained('sales_scripts')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('assigned_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->unique(['sales_script_id', 'user_id']); + $table->index(['user_id', 'created_at']); + }); + + Schema::table('invoices', function (Blueprint $table): void { + $table->decimal('paid_amount', 18, 2)->default(0)->after('total'); + $table->string('payment_status', 24)->default('unpaid')->after('paid_amount')->index(); + $table->timestamp('approved_at')->nullable()->after('issued_at'); + $table->timestamp('rejected_at')->nullable()->after('approved_at'); + $table->text('rejection_reason')->nullable()->after('rejected_at'); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table): void { + $table->dropIndex(['payment_status']); + $table->dropColumn(['paid_amount', 'payment_status', 'approved_at', 'rejected_at', 'rejection_reason']); + }); + Schema::dropIfExists('sales_script_user'); + Schema::table('internal_notifications', function (Blueprint $table): void { + $table->dropIndex(['archived_at']); + $table->dropSoftDeletes(); + $table->dropColumn('archived_at'); + }); + Schema::table('follow_ups', function (Blueprint $table): void { + $table->dropIndex(['created_by', 'created_at']); + $table->dropConstrainedForeignId('created_by'); + $table->dropColumn('source'); + }); + } +}; diff --git a/backend/database/migrations/2026_07_23_010000_enhance_campaign_planning.php b/backend/database/migrations/2026_07_23_010000_enhance_campaign_planning.php new file mode 100644 index 0000000..498cb44 --- /dev/null +++ b/backend/database/migrations/2026_07_23_010000_enhance_campaign_planning.php @@ -0,0 +1,27 @@ +foreignId('product_id')->nullable()->after('product_service')->constrained('products')->nullOnDelete(); + $table->string('channel', 50)->nullable()->after('product_id')->index(); + $table->decimal('budget', 15, 2)->nullable()->after('target'); + $table->decimal('actual_cost', 15, 2)->nullable()->after('budget'); + }); + } + + public function down(): void + { + Schema::table('campaigns', function (Blueprint $table) { + $table->dropConstrainedForeignId('product_id'); + $table->dropIndex(['channel']); + $table->dropColumn(['channel', 'budget', 'actual_cost']); + }); + } +}; diff --git a/backend/database/migrations/2026_07_23_020000_add_word_fields_to_invoices.php b/backend/database/migrations/2026_07_23_020000_add_word_fields_to_invoices.php new file mode 100644 index 0000000..7a22070 --- /dev/null +++ b/backend/database/migrations/2026_07_23_020000_add_word_fields_to_invoices.php @@ -0,0 +1,24 @@ +json('seller_snapshot')->nullable()->after('customer_snapshot'); + $table->text('payment_terms')->nullable()->after('notes'); + $table->date('due_date')->nullable()->after('payment_terms'); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table): void { + $table->dropColumn(['seller_snapshot', 'payment_terms', 'due_date']); + }); + } +}; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index 8ed2723..c5f03c5 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -9,14 +9,12 @@ use App\Models\PipelineStage; use App\Models\Setting; use App\Models\User; use Illuminate\Database\Seeder; -use Spatie\Permission\Models\Permission; -use Spatie\Permission\Models\Role; class DatabaseSeeder extends Seeder { public function run(): void { - $this->seedRoles(); + $this->call(RolePermissionSeeder::class); $this->seedLeadStatuses(); $this->seedPipelineStages(); $this->seedCallResults(); @@ -24,30 +22,6 @@ class DatabaseSeeder extends Seeder $this->seedDemoUsers(); } - private function seedRoles(): void - { - $admin = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']); - Role::firstOrCreate(['name' => 'supervisor', 'guard_name' => 'web']); - Role::firstOrCreate(['name' => 'agent', 'guard_name' => 'web']); - - $permissions = [ - 'view_leads', 'create_leads', 'edit_leads', 'delete_leads', - 'import_leads', 'export_leads', 'view_full_phone', - 'view_reports', 'export_reports', - 'manage_users', 'manage_roles', 'manage_campaigns', 'manage_settings', - 'view_activity_logs', 'manage_scripts', - 'view_quality_reviews', 'create_quality_reviews', 'listen_recordings', - 'view_all_calls', 'view_team_calls', 'view_own_calls', - 'assign_leads', 'reassign_leads', - ]; - - foreach ($permissions as $p) { - Permission::firstOrCreate(['name' => $p, 'guard_name' => 'web']); - } - - $admin->syncPermissions(Permission::all()); - } - private function seedLeadStatuses(): void { $statuses = [ @@ -183,31 +157,31 @@ class DatabaseSeeder extends Seeder private function seedDemoUsers(): void { - $admin = User::create([ + $admin = User::updateOrCreate(['email' => 'admin@crm.com'], [ 'name' => 'مدیر سیستم', 'email' => 'admin@crm.com', 'password' => bcrypt('password'), 'phone' => '09121111111', 'is_active' => true, ]); - $admin->assignRole('admin'); + $admin->syncRoles(['admin']); - $supervisor = User::create([ + $supervisor = User::updateOrCreate(['email' => 'supervisor@crm.com'], [ 'name' => 'سرپرست فروش', 'email' => 'supervisor@crm.com', 'password' => bcrypt('password'), 'phone' => '09122222222', 'is_active' => true, ]); - $supervisor->assignRole('supervisor'); + $supervisor->syncRoles(['supervisor']); - $agent = User::create([ + $agent = User::updateOrCreate(['email' => 'agent@crm.com'], [ 'name' => 'کارشناس فروش', 'email' => 'agent@crm.com', 'password' => bcrypt('password'), 'phone' => '09123333333', 'is_active' => true, ]); - $agent->assignRole('agent'); + $agent->syncRoles(['agent']); } } diff --git a/backend/database/seeders/RolePermissionSeeder.php b/backend/database/seeders/RolePermissionSeeder.php new file mode 100644 index 0000000..f06ae53 --- /dev/null +++ b/backend/database/seeders/RolePermissionSeeder.php @@ -0,0 +1,35 @@ +forgetCachedPermissions(); + + foreach (PermissionCatalog::ALL as $permission) { + Permission::firstOrCreate([ + 'name' => $permission, + 'guard_name' => 'web', + ]); + } + + foreach (PermissionCatalog::ROLE_DEFAULTS as $roleName => $permissions) { + $role = Role::firstOrCreate([ + 'name' => $roleName, + 'guard_name' => 'web', + ]); + + $role->syncPermissions($permissions); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index b9aff31..62e56d4 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -5,20 +5,26 @@ use App\Http\Controllers\Api\AgentMobileController; use App\Http\Controllers\Api\AssignmentController; use App\Http\Controllers\Api\AttachmentController; use App\Http\Controllers\Api\AuthController; +use App\Http\Controllers\Api\CalendarController; use App\Http\Controllers\Api\CallController; use App\Http\Controllers\Api\CampaignController; use App\Http\Controllers\Api\CompanyController; +use App\Http\Controllers\Api\ConfigurationController; use App\Http\Controllers\Api\ContactController; use App\Http\Controllers\Api\DashboardController; use App\Http\Controllers\Api\DealController; use App\Http\Controllers\Api\DuplicateController; use App\Http\Controllers\Api\FollowUpController; use App\Http\Controllers\Api\ImportController; +use App\Http\Controllers\Api\IntelligenceController; +use App\Http\Controllers\Api\InvoiceController; use App\Http\Controllers\Api\LeadController; use App\Http\Controllers\Api\LeadStatusController; use App\Http\Controllers\Api\LostReasonController; +use App\Http\Controllers\Api\MediaController; use App\Http\Controllers\Api\NoteController; use App\Http\Controllers\Api\NotificationController; +use App\Http\Controllers\Api\PipelineController; use App\Http\Controllers\Api\PipelineStageController; use App\Http\Controllers\Api\ProductController; use App\Http\Controllers\Api\QualityReviewController; @@ -26,8 +32,11 @@ use App\Http\Controllers\Api\ReportController; use App\Http\Controllers\Api\RoleController; use App\Http\Controllers\Api\ScriptController; use App\Http\Controllers\Api\SettingController; +use App\Http\Controllers\Api\TaskController; use App\Http\Controllers\Api\TimelineController; use App\Http\Controllers\Api\UserController; +use App\Http\Controllers\Api\VoipWebhookController; +use App\Http\Controllers\Api\WorkspaceController; use Illuminate\Support\Facades\Route; /* @@ -38,12 +47,15 @@ use Illuminate\Support\Facades\Route; // Public Route::middleware(['web', 'throttle:5,1'])->post('auth/login', [AuthController::class, 'login']); +Route::middleware(['web', 'throttle:60,1'])->get('auth/me', [AuthController::class, 'me']); Route::middleware(['web'])->get('settings/public', [SettingController::class, 'public']); +Route::middleware('throttle:120,1')->get('media/avatars/{filename}', [MediaController::class, 'avatar']) + ->where('filename', '[A-Za-z0-9_-]+\.(?:jpe?g|png|webp)'); +Route::middleware('throttle:120,1')->post('voip/webhook', VoipWebhookController::class); // Authenticated Route::middleware(['web', 'auth:sanctum'])->group(function () { Route::post('auth/logout', [AuthController::class, 'logout']); - Route::get('auth/me', [AuthController::class, 'me']); Route::post('auth/profile', [AuthController::class, 'updateProfile']); Route::middleware('role:agent')->prefix('agent/mobile')->group(function () { @@ -63,6 +75,8 @@ Route::middleware(['web', 'auth:sanctum'])->group(function () { Route::get('lead-statuses', [LeadStatusController::class, 'index']); Route::get('pipeline-stages', [PipelineStageController::class, 'index']); Route::get('agents', [UserController::class, 'agents']); + Route::get('users/assignable', [UserController::class, 'assignable']); + Route::get('users/referral-targets', [UserController::class, 'referralTargets']); Route::get('import/template', [ImportController::class, 'template'])->middleware('throttle:20,1'); Route::middleware(['role:admin|agent', 'throttle:10,1'])->group(function () { Route::post('import/upload', [ImportController::class, 'upload']); @@ -94,6 +108,7 @@ Route::middleware(['web', 'auth:sanctum'])->group(function () { Route::get('settings', [SettingController::class, 'index']); Route::put('settings', [SettingController::class, 'update']); Route::post('settings/voip/test', [SettingController::class, 'testVoip']); + Route::post('settings/voip/test-call', [SettingController::class, 'testVoipCall']); }); @@ -105,18 +120,69 @@ Route::middleware(['web', 'auth:sanctum'])->group(function () { Route::post('leads/{lead}/contacts', [ContactController::class, 'store']); Route::patch('contacts/{contact}/primary', [ContactController::class, 'setPrimary']); Route::apiResource('leads', LeadController::class); + Route::post('leads/{lead}/refer', [AssignmentController::class, 'refer']); + + // Invoices + Route::get('invoices', [InvoiceController::class, 'index']); + Route::get('invoices-summary', [InvoiceController::class, 'summary']); + Route::get('invoices/{invoice}', [InvoiceController::class, 'show']); + Route::get('invoices/{invoice}/word', [InvoiceController::class, 'word']); + Route::post('leads/{lead}/invoice', [InvoiceController::class, 'fromLead']); + Route::put('invoices/{invoice}', [InvoiceController::class, 'update']); + Route::post('invoices/{invoice}/issue', [InvoiceController::class, 'issue']); + Route::post('invoices/{invoice}/approve', [InvoiceController::class, 'approve']); + Route::post('invoices/{invoice}/reject', [InvoiceController::class, 'reject']); + Route::post('invoices/{invoice}/void', [InvoiceController::class, 'void']); + Route::get('invoice-templates', [InvoiceController::class, 'templates']); + Route::post('invoice-templates', [InvoiceController::class, 'storeTemplate']); + Route::put('invoice-templates/{invoiceTemplate}', [InvoiceController::class, 'updateTemplate']); + Route::delete('invoice-templates/{invoiceTemplate}', [InvoiceController::class, 'destroyTemplate']); + Route::post('invoice-templates/{invoiceTemplate}/background', [InvoiceController::class, 'uploadTemplateBackground']); + Route::delete('invoice-templates/{invoiceTemplate}/background', [InvoiceController::class, 'deleteTemplateBackground']); + Route::get('invoice-templates/{invoiceTemplate}/background', [InvoiceController::class, 'templateBackground']); + Route::get('invoice-template-fields', [InvoiceController::class, 'fieldCatalog']); // Core CRM Route::apiResource('companies', CompanyController::class); Route::post('companies/{company}/merge', [CompanyController::class, 'merge']); Route::apiResource('deals', DealController::class); + Route::get('pipelines', [PipelineController::class, 'index']); + Route::post('pipelines', [PipelineController::class, 'store']); + Route::get('pipelines/{pipeline}/board', [PipelineController::class, 'board']); + Route::patch('deals/{deal}/stage', [PipelineController::class, 'move']); + Route::get('global-search', [WorkspaceController::class, 'search'])->middleware('throttle:60,1'); + Route::get('saved-views', [WorkspaceController::class, 'savedViews']); + Route::post('saved-views', [WorkspaceController::class, 'storeSavedView']); + Route::delete('saved-views/{savedView}', [WorkspaceController::class, 'deleteSavedView']); + Route::get('workspace-preferences', [WorkspaceController::class, 'preferences']); + Route::put('workspace-preferences', [WorkspaceController::class, 'updatePreferences']); + Route::post('leads/bulk-score', [IntelligenceController::class, 'bulkScore']); + Route::post('leads/{lead}/score', [IntelligenceController::class, 'scoreLead']); + Route::get('sla-rules', [IntelligenceController::class, 'slaRules']); + Route::post('sla-rules', [IntelligenceController::class, 'storeSlaRule']); + Route::get('sla-breaches', [IntelligenceController::class, 'breaches']); + Route::post('sla/detect', [IntelligenceController::class, 'detect']); + Route::patch('sla-breaches/{slaBreach}/resolve', [IntelligenceController::class, 'resolve']); + Route::get('automations', [ConfigurationController::class, 'automations']); + Route::post('automations', [ConfigurationController::class, 'storeAutomation']); + Route::post('automations/{automationRule}/run', [ConfigurationController::class, 'runAutomation']); + Route::get('automation-runs', [ConfigurationController::class, 'automationRuns']); + Route::get('custom-fields', [ConfigurationController::class, 'customFields']); + Route::post('custom-fields', [ConfigurationController::class, 'storeCustomField']); + Route::get('custom-field-values/{entityType}/{entityId}', [ConfigurationController::class, 'values']); + Route::put('custom-field-values/{entityType}/{entityId}', [ConfigurationController::class, 'updateValues']); Route::apiResource('products', ProductController::class); Route::get('contacts', [ContactController::class, 'index']); Route::post('contacts', [ContactController::class, 'storeStandalone']); Route::put('contacts/{contact}', [ContactController::class, 'update']); Route::delete('contacts/{contact}', [ContactController::class, 'destroy']); Route::post('notes', [NoteController::class, 'store']); + Route::get('calls/{call}/notes', [NoteController::class, 'callIndex']); + Route::post('calls/{call}/notes', [NoteController::class, 'callStore']); + Route::patch('notes/{note}', [NoteController::class, 'update']); Route::delete('notes/{note}', [NoteController::class, 'destroy']); + Route::post('notes/{note}/pin', [NoteController::class, 'pin']); + Route::post('notes/{note}/unpin', [NoteController::class, 'unpin']); Route::get('timeline', [TimelineController::class, 'index']); Route::get('attachments', [AttachmentController::class, 'index']); Route::post('attachments', [AttachmentController::class, 'store']); @@ -140,25 +206,38 @@ Route::middleware(['web', 'auth:sanctum'])->group(function () { Route::get('call-results', [CallController::class, 'results']); Route::get('calls', [CallController::class, 'index']); Route::post('calls', [CallController::class, 'store']); + Route::post('calls/manual-result', [CallController::class, 'manualResult']); Route::get('calls/{call}', [CallController::class, 'show']); Route::post('calls/register-result', [CallController::class, 'registerResult']); // Follow-ups - Route::apiResource('follow-ups', FollowUpController::class)->only(['index', 'store', 'update']); - Route::patch('follow-ups/{followUp}/mark-done', [FollowUpController::class, 'markDone']); Route::get('follow-ups/today', [FollowUpController::class, 'today']); Route::get('follow-ups/overdue', [FollowUpController::class, 'overdue']); + Route::patch('follow-ups/{followUp}/mark-done', [FollowUpController::class, 'markDone']); + Route::apiResource('follow-ups', FollowUpController::class)->only(['index', 'store', 'show', 'update', 'destroy']); + + // Tasks + Route::post('tasks/bulk-assign', [TaskController::class, 'bulkAssign']); + Route::post('tasks/bulk-complete', [TaskController::class, 'bulkComplete']); + Route::post('tasks/{task}/assign', [TaskController::class, 'assign']); + Route::post('tasks/{task}/start', [TaskController::class, 'start']); + Route::post('tasks/{task}/complete', [TaskController::class, 'complete']); + Route::post('tasks/{task}/reopen', [TaskController::class, 'reopen']); + Route::post('tasks/{task}/cancel', [TaskController::class, 'cancel']); + Route::apiResource('tasks', TaskController::class); + Route::get('calendar/events', [CalendarController::class, 'index']); // Pipeline Route::put('pipeline/{pipelineStage}/lead/{lead}', [PipelineStageController::class, 'updateLeadStage']); // Dashboards - Route::get('dashboard/admin', [DashboardController::class, 'admin']); - Route::get('dashboard/supervisor', [DashboardController::class, 'supervisor']); - Route::get('dashboard/agent', [DashboardController::class, 'agent']); + Route::get('dashboard/admin', [DashboardController::class, 'admin'])->middleware('permission:view_admin_dashboard'); + Route::get('dashboard/supervisor', [DashboardController::class, 'supervisor'])->middleware('permission:view_supervisor_dashboard'); + Route::get('dashboard/agent', [DashboardController::class, 'agent'])->middleware('permission:view_agent_dashboard'); // Reports Route::middleware('throttle:30,1')->group(function () { + Route::get('reports/kpi', [ReportController::class, 'kpi']); Route::get('reports/agent-performance', [ReportController::class, 'agentPerformance']); Route::get('reports/team-performance', [ReportController::class, 'teamPerformance']); Route::get('reports/campaign/{campaign}', [ReportController::class, 'campaignReport']); @@ -171,6 +250,7 @@ Route::middleware(['web', 'auth:sanctum'])->group(function () { Route::get('reports/import-quality', [ReportController::class, 'importQuality']); Route::get('reports/call-quality', [ReportController::class, 'callQuality']); Route::get('reports/best-contact-time', [ReportController::class, 'bestContactTime']); + Route::get('reports/operations', [ReportController::class, 'operations']); Route::get('reports/export/excel', [ReportController::class, 'exportExcel'])->middleware('throttle:5,1'); }); @@ -179,10 +259,13 @@ Route::middleware(['web', 'auth:sanctum'])->group(function () { // Quality Reviews Route::apiResource('quality-reviews', QualityReviewController::class)->only(['index', 'store', 'show', 'update']); + Route::post('quality-reviews/{qualityReview}/acknowledge', [QualityReviewController::class, 'acknowledge']); // Notifications Route::get('notifications', [NotificationController::class, 'index']); - Route::patch('notifications/{notification}/read', [NotificationController::class, 'markRead']); Route::patch('notifications/read-all', [NotificationController::class, 'markAllRead']); + Route::patch('notifications/{notification}/read', [NotificationController::class, 'markRead']); + Route::patch('notifications/{notification}/archive', [NotificationController::class, 'archive']); + Route::delete('notifications/{notification}', [NotificationController::class, 'destroy']); Route::get('notifications/unread-count', [NotificationController::class, 'unreadCount']); }); diff --git a/backend/routes/console.php b/backend/routes/console.php index 3c9adf1..d979bf1 100644 --- a/backend/routes/console.php +++ b/backend/routes/console.php @@ -1,8 +1,43 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Artisan::command('permissions:sync-defaults', function (RolePermissionSeeder $seeder) { + $seeder->run(); + $this->info('Default CRM roles and permissions were synchronized.'); +})->purpose('Create missing permissions and synchronize the default role matrix'); + +Artisan::command('call-notes:backfill', function (LegacyCallNoteBackfillService $service) { + $created = $service->run(); + $this->info("{$created} legacy call notes were backfilled."); +})->purpose('Idempotently backfill calls.notes into polymorphic note history'); + +Artisan::command('sla:monitor', function (SlaMonitorService $service) { + $this->info($service->run().' new SLA warnings or breaches created.'); +})->purpose('Idempotently detect and notify CRM SLA warnings and breaches'); + +Schedule::call(fn () => app(FollowUpReminderService::class)->sendDueReminders()) + ->name('follow-up-reminders') + ->everyFifteenMinutes() + ->withoutOverlapping(); + +Schedule::call(fn () => app(TaskReminderService::class)->sendDueNotifications()) + ->name('task-reminders') + ->everyMinute() + ->withoutOverlapping(); + +Schedule::call(fn () => app(SlaMonitorService::class)->run()) + ->name('sla-monitor') + ->everyFifteenMinutes() + ->withoutOverlapping(); diff --git a/backend/storage/backups/database-before-crm-ux-20260715-195530.sqlite b/backend/storage/backups/database-before-crm-ux-20260715-195530.sqlite new file mode 100644 index 0000000..5e2705f Binary files /dev/null and b/backend/storage/backups/database-before-crm-ux-20260715-195530.sqlite differ diff --git a/backend/storage/backups/database-before-p1-p2-20260715-185234.sqlite b/backend/storage/backups/database-before-p1-p2-20260715-185234.sqlite new file mode 100644 index 0000000..7608c0b Binary files /dev/null and b/backend/storage/backups/database-before-p1-p2-20260715-185234.sqlite differ diff --git a/backend/tests/Feature/CalendarAccessTest.php b/backend/tests/Feature/CalendarAccessTest.php new file mode 100644 index 0000000..84284fd --- /dev/null +++ b/backend/tests/Feature/CalendarAccessTest.php @@ -0,0 +1,66 @@ +seed(RolePermissionSeeder::class); + $agent = User::factory()->create(['is_active' => true]); + $agent->assignRole('agent'); + $other = User::factory()->create(['is_active' => true]); + $other->assignRole('agent'); + + $ownLead = $this->lead($agent, 'مشتری خودی', '09120000001'); + $otherLead = $this->lead($other, 'مشتری دیگر', '09120000002'); + $this->task($agent, 'کار خودم'); + $this->task($other, 'کار کارشناس دیگر'); + FollowUp::create(['lead_id' => $ownLead->id, 'user_id' => $agent->id, 'scheduled_at' => now()->addHour(), 'status' => 'pending', 'notes' => 'پیگیری خودم']); + FollowUp::create(['lead_id' => $otherLead->id, 'user_id' => $other->id, 'scheduled_at' => now()->addHour(), 'status' => 'pending', 'notes' => 'پیگیری دیگر']); + + $from = now()->subDay()->toDateString(); + $to = now()->addDay()->toDateString(); + $this->actingAs($agent)->getJson("/api/calendar/events?from={$from}&to={$to}") + ->assertOk() + ->assertJsonCount(2, 'events') + ->assertJsonFragment(['title' => 'کار خودم']) + ->assertJsonFragment(['title' => 'پیگیری خودم']) + ->assertJsonMissing(['title' => 'کار کارشناس دیگر']) + ->assertJsonMissing(['title' => 'پیگیری دیگر']); + + $this->actingAs($agent)->getJson("/api/calendar/events?from={$from}&to={$to}&type=task") + ->assertOk() + ->assertJsonCount(1, 'events') + ->assertJsonPath('events.0.type', 'task'); + } + + private function lead(User $agent, string $company, string $phone): Lead + { + return Lead::create(['first_name' => 'تست', 'last_name' => 'تقویم', 'company' => $company, 'phone' => $phone, 'assigned_to' => $agent->id, 'is_unassigned' => false]); + } + + private function task(User $agent, string $subject): Task + { + return Task::create([ + 'subject' => $subject, + 'assigned_to' => $agent->id, + 'assigned_by' => $agent->id, + 'created_by' => $agent->id, + 'priority' => 'normal', + 'status' => 'open', + 'visibility' => 'private', + 'due_at' => now()->addHour(), + ]); + } +} diff --git a/backend/tests/Feature/CallContractTest.php b/backend/tests/Feature/CallContractTest.php new file mode 100644 index 0000000..034da8e --- /dev/null +++ b/backend/tests/Feature/CallContractTest.php @@ -0,0 +1,122 @@ +seed(RolePermissionSeeder::class); + } + + public function test_call_list_uses_the_canonical_envelope_and_fields(): void + { + $admin = $this->admin(); + $lead = $this->lead('Needle Customer'); + $call = Call::create([ + 'lead_id' => $lead->id, + 'user_id' => $admin->id, + 'direction' => 'outbound', + 'phone' => '02112345678', + 'duration' => 42, + 'result' => 'پاسخ داده شد', + 'provider_call_id' => 'provider-needle', + ]); + + $response = $this->actingAs($admin)->getJson('/api/calls?search=Needle'); + + $response->assertOk() + ->assertJsonPath('data.0.id', $call->id) + ->assertJsonPath('data.0.user_id', $admin->id) + ->assertJsonPath('data.0.agent.name', $admin->name) + ->assertJsonPath('data.0.direction', 'outbound') + ->assertJsonPath('data.0.status', 'completed') + ->assertJsonPath('data.0.duration_seconds', 42) + ->assertJsonPath('meta.total', 1) + ->assertJsonMissingPath('data.0.caller_id') + ->assertJsonMissingPath('data.0.call_type') + ->assertJsonMissingPath('data.0.call_status'); + } + + public function test_call_search_filters_instead_of_being_a_frontend_only_control(): void + { + $admin = $this->admin(); + $matchingLead = $this->lead('Matching Company'); + $otherLead = $this->lead('Other Company'); + + foreach ([$matchingLead, $otherLead] as $index => $lead) { + Call::create([ + 'lead_id' => $lead->id, + 'user_id' => $admin->id, + 'direction' => 'outbound', + 'phone' => "0210000000{$index}", + ]); + } + + $this->actingAs($admin)->getJson('/api/calls?search=Matching') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.lead.company', 'Matching Company'); + } + + public function test_manual_call_result_can_be_recorded_without_a_voip_provider(): void + { + $admin = $this->admin(); + $lead = $this->lead('Manual Result Company'); + + $response = $this->actingAs($admin)->postJson('/api/calls/manual-result', [ + 'lead_id' => $lead->id, + 'contact_phone_id' => $lead->contacts()->firstOrCreate([ + 'name' => 'Ali Karimi', + ], [ + 'status' => 'active', + 'is_primary' => true, + 'created_by' => $admin->id, + ])->phones()->create([ + 'phone' => $lead->phone, + 'type' => 'mobile', + 'status' => 'active', + ])->id, + 'result' => 'شماره اشتباه', + 'notes' => 'ثبت دستی پس از تماس', + ]); + + $response->assertCreated() + ->assertJsonPath('data.result', 'شماره اشتباه') + ->assertJsonPath('data.is_manual', true); + $this->assertDatabaseHas('calls', [ + 'lead_id' => $lead->id, + 'user_id' => $admin->id, + 'result' => 'شماره اشتباه', + 'provider_status' => 'completed', + ]); + } + + private function admin(): User + { + $user = User::factory()->create(['is_active' => true]); + $user->assignRole('admin'); + + return $user; + } + + private function lead(string $company): Lead + { + return Lead::create([ + 'first_name' => 'Ali', + 'last_name' => 'Karimi', + 'company' => $company, + 'phone' => fake()->unique()->numerify('021########'), + ]); + } +} diff --git a/backend/tests/Feature/CallNoteNotificationIntegrityTest.php b/backend/tests/Feature/CallNoteNotificationIntegrityTest.php new file mode 100644 index 0000000..dc7758d --- /dev/null +++ b/backend/tests/Feature/CallNoteNotificationIntegrityTest.php @@ -0,0 +1,146 @@ +seed(RolePermissionSeeder::class); + } + + public function test_call_supports_multiple_scoped_notes_with_edit_delete_and_pin_permissions(): void + { + [$supervisor, $agent] = $this->teamUsers(); + $call = $this->makeCall($agent); + + $first = $this->actingAs($agent)->postJson("/api/calls/{$call->id}/notes", [ + 'content' => 'First note', 'type' => 'objection', 'visibility' => 'team', + ])->assertCreated(); + $second = $this->actingAs($agent)->postJson("/api/calls/{$call->id}/notes", [ + 'content' => 'Private note', 'type' => 'internal', 'visibility' => 'private', + ])->assertCreated(); + + $this->actingAs($agent)->getJson("/api/calls/{$call->id}/notes")->assertOk()->assertJsonCount(2, 'data'); + $this->actingAs($agent)->postJson('/api/notes/'.$first->json('data.id').'/pin')->assertForbidden(); + $this->actingAs($supervisor)->postJson('/api/notes/'.$first->json('data.id').'/pin')->assertOk()->assertJsonPath('data.is_pinned', true); + $this->actingAs($supervisor)->getJson("/api/calls/{$call->id}/notes")->assertOk()->assertJsonCount(1, 'data'); + + $this->actingAs($agent)->patchJson('/api/notes/'.$second->json('data.id'), ['content' => 'Edited private note']) + ->assertOk()->assertJsonPath('data.content', 'Edited private note'); + $this->assertDatabaseHas('notes', ['id' => $second->json('data.id'), 'content' => 'Edited private note']); + $this->assertDatabaseHas('activity_logs', ['action' => 'call_note_edited', 'subject_id' => $second->json('data.id')]); + } + + public function test_legacy_backfill_is_idempotent_and_task_reminders_do_not_repeat_or_notify_done_tasks(): void + { + $agent = $this->user('agent'); + $call = $this->makeCall($agent, 'Legacy summary'); + $backfill = app(LegacyCallNoteBackfillService::class); + + $this->assertSame(1, $backfill->run()); + $this->assertSame(0, $backfill->run()); + $this->assertSame(1, Note::where('source_key', "legacy_call:{$call->id}")->count()); + + $due = Task::create([ + 'subject' => 'Due task', 'assigned_to' => $agent->id, 'created_by' => $agent->id, + 'priority' => 'normal', 'status' => 'open', 'visibility' => 'private', + 'due_at' => now()->subHour(), 'reminder_at' => now()->subHours(2), + ]); + Task::create([ + 'subject' => 'Done task', 'assigned_to' => $agent->id, 'created_by' => $agent->id, + 'priority' => 'normal', 'status' => 'done', 'visibility' => 'private', + 'due_at' => now()->subHour(), 'reminder_at' => now()->subHours(2), 'completed_at' => now(), + ]); + + $service = app(TaskReminderService::class); + $service->sendDueNotifications(); + $service->sendDueNotifications(); + $this->assertDatabaseCount('internal_notifications', 2); + $this->assertDatabaseHas('internal_notifications', ['user_id' => $agent->id, 'type' => 'task_due']); + $this->assertDatabaseHas('internal_notifications', ['user_id' => $agent->id, 'type' => 'task_overdue']); + + $notificationId = Notification::where('user_id', $agent->id)->value('id'); + $this->actingAs($agent)->patchJson("/api/notifications/{$notificationId}/read") + ->assertOk()->assertJsonPath('data.is_read', true); + $this->assertNotNull(Notification::find($notificationId)->read_at); + $this->assertTrue($due->fresh()->is_overdue); + } + + public function test_contact_phone_update_preserves_historical_call_relation(): void + { + $admin = $this->user('admin'); + $call = $this->makeCall($admin); + $phoneId = $call->contact_phone_id; + $contact = $call->contact; + + $this->actingAs($admin)->putJson("/api/contacts/{$contact->id}", [ + 'name' => $contact->name, + 'phones' => [[ + 'id' => $phoneId, + 'phone' => '09129999999', + 'type' => 'mobile', + 'status' => 'active', + ]], + ])->assertOk(); + + $this->assertSame($phoneId, $call->fresh()->contact_phone_id); + $this->assertSame('09129999999', $call->fresh()->contactPhone->phone); + } + + private function makeCall(User $user, ?string $legacyNotes = null): Call + { + $lead = Lead::create([ + 'first_name' => 'Ali', 'last_name' => 'Karimi', 'company' => 'Acme', + 'phone' => fake()->unique()->numerify('021########'), 'assigned_to' => $user->id, + ]); + $contact = Contact::create([ + 'lead_id' => $lead->id, 'name' => 'Contact', 'status' => 'active', + 'is_primary' => true, 'created_by' => $user->id, + ]); + $phone = ContactPhone::create([ + 'contact_id' => $contact->id, 'phone' => '09121111111', 'type' => 'mobile', 'status' => 'active', + ]); + + return Call::create([ + 'lead_id' => $lead->id, 'contact_id' => $contact->id, 'contact_phone_id' => $phone->id, + 'user_id' => $user->id, 'direction' => 'outbound', 'phone' => $phone->phone, 'notes' => $legacyNotes, + ]); + } + + private function teamUsers(): array + { + $supervisor = $this->user('supervisor'); + $agent = $this->user('agent'); + $team = Team::create(['name' => 'Sales', 'supervisor_id' => $supervisor->id, 'is_active' => true]); + $team->members()->attach([$supervisor->id, $agent->id]); + + return [$supervisor, $agent]; + } + + private function user(string $role): User + { + $user = User::factory()->create(['is_active' => true]); + $user->assignRole($role); + + return $user; + } +} diff --git a/backend/tests/Feature/CampaignMetricsTest.php b/backend/tests/Feature/CampaignMetricsTest.php new file mode 100644 index 0000000..f020bb1 --- /dev/null +++ b/backend/tests/Feature/CampaignMetricsTest.php @@ -0,0 +1,50 @@ +seed(RolePermissionSeeder::class); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $product = Product::create(['name' => 'اشتراک سالانه', 'base_price' => 500000, 'is_active' => true]); + $campaign = Campaign::create([ + 'name' => 'فروش تابستان', + 'target' => 10, + 'status' => 'active', + 'product_id' => $product->id, + 'channel' => 'phone', + 'budget' => 150000, + 'actual_cost' => 100000, + ]); + + Lead::create(['campaign_id' => $campaign->id, 'company' => 'اول', 'first_name' => '', 'last_name' => '', 'phone' => '02111111111', 'last_call_at' => now(), 'final_result' => 'موفق', 'deal_value' => 500000]); + Lead::create(['campaign_id' => $campaign->id, 'company' => 'دوم', 'first_name' => '', 'last_name' => '', 'phone' => '02122222222', 'last_call_at' => now()]); + Lead::create(['campaign_id' => $campaign->id, 'company' => 'سوم', 'first_name' => '', 'last_name' => '', 'phone' => '02133333333']); + + $this->actingAs($admin)->getJson('/api/campaigns') + ->assertOk() + ->assertJsonPath('data.0.leads_count', 3) + ->assertJsonPath('data.0.contacted_leads_count', 2) + ->assertJsonPath('data.0.won_leads_count', 1) + ->assertJsonPath('data.0.won_value', 500000) + ->assertJsonPath('data.0.conversion_rate', 33.3) + ->assertJsonPath('data.0.target_progress', 10) + ->assertJsonPath('data.0.product.id', $product->id) + ->assertJsonPath('data.0.channel', 'phone') + ->assertJsonPath('data.0.cost_per_lead', 33333.33) + ->assertJsonPath('data.0.roi', 400); + } +} diff --git a/backend/tests/Feature/CoreCrmAuthorizationTest.php b/backend/tests/Feature/CoreCrmAuthorizationTest.php new file mode 100644 index 0000000..2c096d5 --- /dev/null +++ b/backend/tests/Feature/CoreCrmAuthorizationTest.php @@ -0,0 +1,118 @@ +seed(RolePermissionSeeder::class); + } + + public function test_agent_cannot_access_core_records_owned_by_another_agent(): void + { + $agent = $this->user('agent'); + $otherAgent = $this->user('agent'); + $lead = $this->lead($otherAgent); + $company = Company::create(['name' => 'Private Company', 'owner_id' => $otherAgent->id, 'created_by' => $otherAgent->id]); + $deal = Deal::create(['title' => 'Private Deal', 'owner_id' => $otherAgent->id, 'lead_id' => $lead->id, 'created_by' => $otherAgent->id]); + $contact = Contact::create(['name' => 'Private Contact', 'lead_id' => $lead->id, 'company_id' => $company->id, 'created_by' => $otherAgent->id]); + ContactPhone::create(['contact_id' => $contact->id, 'phone' => '09120000002', 'type' => 'mobile', 'status' => 'active']); + $attachment = Attachment::create([ + 'attachable_type' => Company::class, + 'attachable_id' => $company->id, + 'uploaded_by' => $otherAgent->id, + 'original_name' => 'private.pdf', + 'path' => 'attachments/private.pdf', + 'mime_type' => 'application/pdf', + 'size' => 10, + ]); + + $this->actingAs($agent)->getJson("/api/companies/{$company->id}")->assertForbidden(); + $this->actingAs($agent)->getJson("/api/deals/{$deal->id}")->assertForbidden(); + $this->actingAs($agent)->putJson("/api/contacts/{$contact->id}", ['name' => 'Tampered'])->assertForbidden(); + $this->actingAs($agent)->postJson('/api/notes', [ + 'entity_type' => 'lead', + 'entity_id' => $lead->id, + 'content' => 'Unauthorized note', + ])->assertForbidden(); + $this->actingAs($agent)->getJson("/api/timeline?entity_type=lead&entity_id={$lead->id}")->assertForbidden(); + $this->actingAs($agent)->getJson("/api/attachments?entity_type=company&entity_id={$company->id}")->assertForbidden(); + $this->actingAs($agent)->getJson("/api/attachments/{$attachment->id}/download")->assertForbidden(); + + $contacts = $this->actingAs($agent)->getJson('/api/contacts')->assertOk(); + $this->assertNotContains($contact->id, collect($contacts->json('data'))->pluck('id')->all()); + } + + public function test_duplicate_check_does_not_disclose_out_of_scope_lead(): void + { + $agent = $this->user('agent'); + $otherAgent = $this->user('agent'); + $lead = $this->lead($otherAgent, ['phone' => '09125556677', 'company' => 'Hidden Corp']); + + $response = $this->actingAs($agent)->postJson('/api/duplicates/check', [ + 'entity_type' => 'lead', + 'phone' => '09125556677', + ])->assertOk(); + + $this->assertNotContains($lead->id, collect($response->json('data'))->pluck('id')->all()); + } + + public function test_supervisor_can_access_team_records_but_not_other_teams(): void + { + $supervisor = $this->user('supervisor'); + $teamAgent = $this->user('agent'); + $otherAgent = $this->user('agent'); + $team = Team::create(['name' => 'Own Team', 'supervisor_id' => $supervisor->id, 'is_active' => true]); + $otherTeam = Team::create(['name' => 'Other Team', 'is_active' => true]); + $supervisor->teams()->attach($team); + $teamAgent->teams()->attach($team); + $otherAgent->teams()->attach($otherTeam); + + $teamLead = $this->lead($teamAgent, ['team_id' => $team->id]); + $otherLead = $this->lead($otherAgent, ['team_id' => $otherTeam->id]); + + $this->actingAs($supervisor)->getJson("/api/leads/{$teamLead->id}")->assertOk(); + $this->actingAs($supervisor)->getJson("/api/leads/{$otherLead->id}")->assertForbidden(); + $this->actingAs($supervisor)->postJson('/api/notes', [ + 'entity_type' => 'lead', + 'entity_id' => $otherLead->id, + 'content' => 'Unauthorized supervisor note', + ])->assertForbidden(); + } + + private function user(string $role): User + { + $user = User::factory()->create(['is_active' => true]); + $user->assignRole($role); + + return $user; + } + + private function lead(User $agent, array $attributes = []): Lead + { + return Lead::create(array_merge([ + 'company' => 'Scoped Company', + 'first_name' => 'Ali', + 'last_name' => 'Karimi', + 'phone' => fake()->unique()->numerify('021########'), + 'assigned_to' => $agent->id, + 'is_unassigned' => false, + ], $attributes)); + } +} diff --git a/backend/tests/Feature/CoreCrmIntegrityTest.php b/backend/tests/Feature/CoreCrmIntegrityTest.php new file mode 100644 index 0000000..4d3a18e --- /dev/null +++ b/backend/tests/Feature/CoreCrmIntegrityTest.php @@ -0,0 +1,115 @@ +seed(RolePermissionSeeder::class); + } + + public function test_campaign_assignments_store_the_correct_pivot_role_and_load_script(): void + { + $admin = $this->user('admin'); + $agent = $this->user('agent'); + $supervisor = $this->user('supervisor'); + $script = SalesScript::create(['title' => 'Default script']); + + $campaignId = $this->actingAs($admin)->postJson('/api/campaigns', [ + 'name' => 'Summer Campaign', + 'status' => 'active', + 'sales_script_id' => $script->id, + 'agent_ids' => [$agent->id], + 'supervisor_ids' => [$supervisor->id], + ])->assertCreated()->json('data.id'); + + $this->assertDatabaseHas('campaign_user', ['campaign_id' => $campaignId, 'user_id' => $agent->id, 'role' => 'agent']); + $this->assertDatabaseHas('campaign_user', ['campaign_id' => $campaignId, 'user_id' => $supervisor->id, 'role' => 'supervisor']); + $this->actingAs($admin)->getJson("/api/campaigns/{$campaignId}") + ->assertOk() + ->assertJsonPath('data.sales_script.id', $script->id); + } + + public function test_deal_show_loads_notes_without_missing_relation_error(): void + { + $admin = $this->user('admin'); + $deal = Deal::create(['title' => 'Safe Deal', 'owner_id' => $admin->id, 'created_by' => $admin->id]); + $deal->notes()->create([ + 'user_id' => $admin->id, + 'content' => 'Deal note', + ]); + + $this->actingAs($admin)->getJson("/api/deals/{$deal->id}") + ->assertOk() + ->assertJsonPath('notes.0.content', 'Deal note'); + } + + public function test_primary_contact_change_is_limited_to_the_same_parent_scope(): void + { + $admin = $this->user('admin'); + $otherAdmin = $this->user('admin'); + $first = Contact::create(['name' => 'First', 'created_by' => $admin->id, 'is_primary' => true]); + $second = Contact::create(['name' => 'Second', 'created_by' => $admin->id, 'is_primary' => false]); + $unrelated = Contact::create(['name' => 'Unrelated', 'created_by' => $otherAdmin->id, 'is_primary' => true]); + + $this->actingAs($admin)->patchJson("/api/contacts/{$second->id}/primary", ['reason' => 'Main contact'])->assertOk(); + + $this->assertFalse($first->fresh()->is_primary); + $this->assertTrue($second->fresh()->is_primary); + $this->assertTrue($unrelated->fresh()->is_primary); + } + + public function test_sales_script_relationship_has_one_canonical_foreign_key_direction(): void + { + $this->assertFalse(Schema::hasColumn('sales_scripts', 'campaign_id')); + $this->assertFalse(Schema::hasColumn('sales_scripts', 'product_id')); + $this->assertTrue(Schema::hasColumn('campaigns', 'sales_script_id')); + $this->assertTrue(Schema::hasColumn('products', 'sales_script_id')); + } + + public function test_contact_rejects_incompatible_parent_relationships(): void + { + $admin = $this->user('admin'); + $firstCompany = Company::create(['name' => 'First company', 'created_by' => $admin->id]); + $secondCompany = Company::create(['name' => 'Second company', 'created_by' => $admin->id]); + $lead = Lead::create([ + 'company_id' => $firstCompany->id, + 'first_name' => 'Ali', + 'last_name' => 'Ahmadi', + 'phone' => '09121111111', + ]); + + $this->actingAs($admin)->postJson('/api/contacts', [ + 'name' => 'Invalid contact', + 'lead_id' => $lead->id, + 'company_id' => $secondCompany->id, + 'phones' => [['phone' => '09120000000', 'type' => 'mobile']], + ])->assertUnprocessable() + ->assertJsonValidationErrors('relationships'); + + $this->assertDatabaseMissing('contacts', ['name' => 'Invalid contact']); + } + + private function user(string $role): User + { + $user = User::factory()->create(['is_active' => true]); + $user->assignRole($role); + + return $user; + } +} diff --git a/backend/tests/Feature/CoreCrmTest.php b/backend/tests/Feature/CoreCrmTest.php index f8d5ec9..94db694 100644 --- a/backend/tests/Feature/CoreCrmTest.php +++ b/backend/tests/Feature/CoreCrmTest.php @@ -3,9 +3,7 @@ namespace Tests\Feature; use App\Models\Company; -use App\Models\Deal; use App\Models\Lead; -use App\Models\Product; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; @@ -22,6 +20,7 @@ class CoreCrmTest extends TestCase $role = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']); $user = User::factory()->create(); $user->assignRole($role); + return $user; } diff --git a/backend/tests/Feature/FollowUpContractTest.php b/backend/tests/Feature/FollowUpContractTest.php new file mode 100644 index 0000000..fa7c5d0 --- /dev/null +++ b/backend/tests/Feature/FollowUpContractTest.php @@ -0,0 +1,89 @@ +seed(RolePermissionSeeder::class); + } + + public function test_follow_up_endpoints_share_one_canonical_enveloped_contract(): void + { + $agent = $this->agent(); + $lead = $this->lead($agent); + + $created = $this->actingAs($agent)->postJson('/api/follow-ups', [ + 'lead_id' => $lead->id, + 'scheduled_at' => now()->addHour()->toISOString(), + 'notes' => 'Call customer after lunch', + ])->assertCreated() + ->assertJsonPath('data.lead_id', $lead->id) + ->assertJsonPath('data.user_id', $agent->id) + ->assertJsonPath('data.status', 'pending') + ->assertJsonStructure(['data' => ['id', 'lead', 'assignee', 'scheduled_at', 'completed_at', 'notes', 'status', 'is_overdue'], 'meta', 'links', 'message']); + + $id = $created->json('data.id'); + $this->actingAs($agent)->getJson('/api/follow-ups') + ->assertOk() + ->assertJsonPath('data.0.id', $id) + ->assertJsonStructure(['data', 'meta' => ['current_page', 'last_page', 'per_page', 'total', 'from', 'to'], 'links']); + + $this->actingAs($agent)->patchJson("/api/follow-ups/{$id}/mark-done") + ->assertOk() + ->assertJsonPath('data.status', 'completed') + ->assertJsonPath('data.is_overdue', false); + } + + public function test_today_get_has_no_side_effect_and_scheduler_sends_due_notification_once(): void + { + $agent = $this->agent(); + $lead = $this->lead($agent); + $followUp = FollowUp::create([ + 'lead_id' => $lead->id, + 'user_id' => $agent->id, + 'scheduled_at' => now()->subMinute(), + 'status' => 'pending', + ]); + + $this->actingAs($agent)->getJson('/api/follow-ups/today')->assertOk()->assertJsonPath('data.0.id', $followUp->id); + $this->assertDatabaseCount('internal_notifications', 0); + + $service = app(FollowUpReminderService::class); + $this->assertSame(1, $service->sendDueReminders()); + $this->assertSame(0, $service->sendDueReminders()); + $this->assertDatabaseHas('internal_notifications', ['user_id' => $agent->id, 'type' => 'overdue_follow_up']); + } + + private function agent(): User + { + $agent = User::factory()->create(['is_active' => true]); + $agent->assignRole('agent'); + + return $agent; + } + + private function lead(User $agent): Lead + { + return Lead::create([ + 'company' => 'Follow-up Co', + 'first_name' => 'Mina', + 'last_name' => 'Rahimi', + 'phone' => fake()->unique()->numerify('021########'), + 'assigned_to' => $agent->id, + 'is_unassigned' => false, + ]); + } +} diff --git a/backend/tests/Feature/InvoiceWorkflowTest.php b/backend/tests/Feature/InvoiceWorkflowTest.php new file mode 100644 index 0000000..b3fc7ff --- /dev/null +++ b/backend/tests/Feature/InvoiceWorkflowTest.php @@ -0,0 +1,311 @@ +seed(RolePermissionSeeder::class); + $agent = User::factory()->create(['is_active' => true]); + $agent->assignRole('agent'); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $lead = Lead::create([ + 'company' => 'فروشگاه نمونه', + 'first_name' => 'علی', + 'last_name' => 'احمدی', + 'phone' => '09120000000', + 'assigned_to' => $agent->id, + 'is_unassigned' => false, + 'final_result' => 'موفق', + 'sold_product' => 'اشتراک سالانه', + 'deal_value' => 12500000, + ]); + + $this->actingAs($agent)->getJson('/api/invoice-template-fields') + ->assertOk() + ->assertJsonFragment([ + 'customer.name' => 'نام خریدار', + 'items.1.description' => 'ردیف ۱ — شرح', + 'items.7.line_total' => 'ردیف ۷ — مبلغ کل', + ]); + + $invoiceId = $this->actingAs($agent)->postJson("/api/leads/{$lead->id}/invoice", [ + 'page_width_mm' => 297, + 'page_height_mm' => 210, + 'resolved_fields' => ['number' => ['x' => 12, 'y' => 9, 'width' => 28]], + ]) + ->assertCreated() + ->assertJsonPath('status', 'pending_approval') + ->assertJsonPath('customer_snapshot.company', 'فروشگاه نمونه') + ->assertJsonPath('page_width_mm', 297) + ->assertJsonPath('page_height_mm', 210) + ->assertJsonPath('resolved_fields.number.x', 12) + ->json('id'); + + $this->assertDatabaseHas('invoices', ['id' => $invoiceId, 'lead_id' => $lead->id, 'created_by' => $agent->id]); + $lead->update(['company' => 'نام ویرایش‌شده']); + $this->actingAs($admin)->postJson("/api/invoices/{$invoiceId}/issue") + ->assertOk() + ->assertJsonPath('status', 'issued') + ->assertJsonPath('customer_snapshot.company', 'فروشگاه نمونه') + ->assertJsonPath('page_width_mm', 297) + ->assertJsonPath('resolved_fields.number.x', 12); + $this->assertNotNull(Invoice::findOrFail($invoiceId)->issued_at); + } + + public function test_non_won_lead_cannot_create_invoice(): void + { + $this->seed(RolePermissionSeeder::class); + $agent = User::factory()->create(['is_active' => true]); + $agent->assignRole('agent'); + $lead = Lead::create([ + 'company' => 'لید باز', 'first_name' => 'تست', 'last_name' => 'باز', 'phone' => '09121111111', + 'assigned_to' => $agent->id, 'is_unassigned' => false, + ]); + + $this->actingAs($agent)->postJson("/api/leads/{$lead->id}/invoice") + ->assertUnprocessable() + ->assertJsonFragment(['message' => 'فقط لید منجر به فروش قابل تبدیل به فاکتور است.']); + } + + public function test_invoice_center_summary_uses_scoped_live_financial_data(): void + { + $this->seed(RolePermissionSeeder::class); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $lead = Lead::create(['company' => 'خریدار خلاصه', 'first_name' => 'تست', 'last_name' => 'مالی', 'phone' => '09124445555']); + $base = [ + 'lead_id' => $lead->id, + 'created_by' => $admin->id, + 'currency' => 'IRR', + 'customer_snapshot' => [], + 'lead_snapshot' => [], + 'items' => [], + 'resolved_fields' => [], + 'subtotal' => 0, + 'discount' => 0, + 'tax' => 0, + ]; + Invoice::create($base + ['number' => 'INV-SUM-1', 'status' => 'issued', 'total' => 100000, 'paid_amount' => 40000, 'payment_status' => 'partial']); + Invoice::create($base + ['number' => 'INV-SUM-2', 'status' => 'pending_approval', 'total' => 50000, 'paid_amount' => 0, 'payment_status' => 'unpaid']); + + $this->actingAs($admin)->getJson('/api/invoices-summary') + ->assertOk() + ->assertJsonPath('total', 2) + ->assertJsonPath('counts.issued', 1) + ->assertJsonPath('counts.pending_approval', 1) + ->assertJsonPath('issued_total', 100000) + ->assertJsonPath('paid_total', 40000) + ->assertJsonPath('outstanding_total', 60000); + } + + public function test_authorized_user_can_download_a_real_a4_word_invoice(): void + { + $this->seed(RolePermissionSeeder::class); + $admin = User::factory()->create(['is_active' => true, 'name' => 'فروشنده نمونه']); + $admin->assignRole('admin'); + $lead = Lead::create([ + 'company' => 'خریدار Word', + 'first_name' => 'علی', + 'last_name' => 'آزمایشی', + 'phone' => '09127778888', + 'final_result' => 'موفق', + ]); + + $invoiceId = $this->actingAs($admin)->postJson("/api/leads/{$lead->id}/invoice", [ + 'seller_snapshot' => ['name' => 'شرکت فروشنده', 'economic_code' => '123456'], + 'items' => [['description' => 'خدمت مشاوره', 'unit' => 'ساعت', 'quantity' => 2, 'unit_price' => 500000]], + 'payment_terms' => 'پرداخت طی هفت روز', + ])->assertCreated()->json('id'); + + $this->actingAs($admin) + ->get("/api/invoices/{$invoiceId}/word") + ->assertOk() + ->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') + ->assertDownload(); + } + + public function test_structured_invoice_item_rows_are_resolved_into_template_positions(): void + { + $this->seed(RolePermissionSeeder::class); + $agent = User::factory()->create(['is_active' => true]); + $agent->assignRole('agent'); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $lead = Lead::create([ + 'company' => 'خریدار ردیفی', + 'first_name' => 'تست', + 'last_name' => 'اقلام', + 'phone' => '09125556666', + 'assigned_to' => $agent->id, + 'is_unassigned' => false, + 'final_result' => 'موفق', + ]); + $template = InvoiceTemplate::create([ + 'name' => 'قالب ردیفی', + 'is_default' => true, + 'is_active' => true, + 'created_by' => $admin->id, + 'layout' => [ + ['id' => 'row_1_description', 'label' => 'شرح ردیف اول', 'source' => 'items.1.description', 'x' => 9, 'y' => 51, 'width' => 22], + ['id' => 'row_2_total', 'label' => 'جمع ردیف دوم', 'source' => 'items.2.line_total', 'x' => 51, 'y' => 55, 'width' => 9], + ], + ]); + + $this->actingAs($agent)->postJson("/api/leads/{$lead->id}/invoice", [ + 'invoice_template_id' => $template->id, + 'items' => [ + ['description' => 'محصول اول', 'quantity' => 1, 'unit_price' => 1000], + ['description' => 'محصول دوم', 'quantity' => 2, 'unit_price' => 2500], + ], + ]) + ->assertCreated() + ->assertJsonPath('resolved_fields.row_1_description.value', 'محصول اول') + ->assertJsonPath('resolved_fields.row_2_total.value', '5,000'); + } + + public function test_admin_can_create_a5_template_and_remove_its_background(): void + { + Storage::fake('local'); + $this->seed(RolePermissionSeeder::class); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + $templateId = $this->actingAs($admin)->postJson('/api/invoice-templates', [ + 'name' => 'قالب A5 افقی', + 'page_width_mm' => 210, + 'page_height_mm' => 148, + 'base_type' => 'letterhead', + 'background_settings' => ['fit' => 'contain', 'top' => 0, 'height' => 30], + 'layout' => [[ + 'id' => 'invoice_number', + 'label' => 'شماره فاکتور', + 'source' => 'invoice.number', + 'x' => 68, + 'y' => 7, + 'width' => 25, + 'font_size' => 12, + 'align' => 'right', + ]], + 'is_active' => true, + ]) + ->assertCreated() + ->assertJsonPath('page_width_mm', 210) + ->assertJsonPath('page_height_mm', 148) + ->assertJsonPath('base_type', 'letterhead') + ->assertJsonPath('background_settings.height', 30) + ->json('id'); + + $this->actingAs($admin)->post("/api/invoice-templates/{$templateId}/background", [ + 'file' => UploadedFile::fake()->createWithContent('rendered-page.png', base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=')), + 'source_file' => UploadedFile::fake()->createWithContent('letterhead.pdf', "%PDF-1.4\n%%EOF"), + 'source_name' => 'letterhead.pdf', + 'source_mime' => 'application/pdf', + ], ['Accept' => 'application/json']) + ->assertOk() + ->assertJsonPath('source_name', 'letterhead.pdf') + ->assertJsonPath('source_mime', 'application/pdf'); + $storedPath = InvoiceTemplate::findOrFail($templateId)->background_path; + $sourcePath = InvoiceTemplate::findOrFail($templateId)->source_path; + Storage::disk('local')->assertExists($storedPath); + Storage::disk('local')->assertExists($sourcePath); + + $this->actingAs($admin)->deleteJson("/api/invoice-templates/{$templateId}/background") + ->assertOk() + ->assertJsonPath('background_path', null) + ->assertJsonPath('background_url', null); + Storage::disk('local')->assertMissing($storedPath); + Storage::disk('local')->assertMissing($sourcePath); + } + + public function test_template_deletion_cleans_files_promotes_default_and_preserves_used_templates(): void + { + Storage::fake('local'); + $this->seed(RolePermissionSeeder::class); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + Storage::disk('local')->put('invoice-templates/default.png', 'image'); + $default = InvoiceTemplate::create([ + 'name' => 'قالب پیش‌فرض', 'layout' => [], 'is_default' => true, 'is_active' => true, + 'background_path' => 'invoice-templates/default.png', 'background_name' => 'default.png', + 'background_mime' => 'image/png', 'created_by' => $admin->id, + ]); + $replacement = InvoiceTemplate::create([ + 'name' => 'قالب جایگزین', 'layout' => [], 'is_default' => false, 'is_active' => true, 'created_by' => $admin->id, + ]); + + $this->actingAs($admin)->deleteJson("/api/invoice-templates/{$default->id}") + ->assertOk() + ->assertJsonPath('message', 'قالب حذف شد.'); + $this->assertDatabaseMissing('invoice_templates', ['id' => $default->id]); + $this->assertTrue($replacement->fresh()->is_default); + Storage::disk('local')->assertMissing('invoice-templates/default.png'); + + $used = InvoiceTemplate::create([ + 'name' => 'قالب استفاده‌شده', 'layout' => [], 'is_default' => false, 'is_active' => true, 'created_by' => $admin->id, + ]); + $lead = Lead::create(['company' => 'نمونه', 'first_name' => 'کاربر', 'last_name' => 'آزمایشی', 'phone' => '09123334444']); + Invoice::create([ + 'lead_id' => $lead->id, 'invoice_template_id' => $used->id, 'created_by' => $admin->id, + 'status' => 'issued', 'currency' => 'IRR', 'customer_snapshot' => [], 'lead_snapshot' => [], + 'items' => [], 'resolved_fields' => [], 'subtotal' => 0, 'discount' => 0, 'tax' => 0, 'total' => 0, + ]); + + $this->actingAs($admin)->deleteJson("/api/invoice-templates/{$used->id}") + ->assertUnprocessable() + ->assertJsonFragment(['message' => 'این قالب در فاکتورهای ثبت‌شده استفاده شده است؛ به‌جای حذف، آن را غیرفعال کنید.']); + $this->assertDatabaseHas('invoice_templates', ['id' => $used->id]); + } + + public function test_signed_voip_webhook_updates_real_call_lifecycle(): void + { + $secret = 'test-webhook-secret'; + Setting::create(['key' => 'voip_webhook_secret', 'value' => $secret, 'group' => 'voip', 'type' => 'string']); + $user = User::factory()->create(); + $lead = Lead::create(['company' => 'Acme', 'first_name' => 'A', 'last_name' => 'B', 'phone' => '09122222222']); + $call = Call::create([ + 'lead_id' => $lead->id, + 'user_id' => $user->id, + 'direction' => 'outbound', + 'phone' => $lead->phone, + 'provider_call_id' => 'provider-123', + 'provider_status' => 'ringing', + 'is_manual' => false, + ]); + $payload = json_encode([ + 'provider_call_id' => 'provider-123', + 'status' => 'completed', + 'duration_seconds' => 93, + 'recording_url' => 'https://pbx.example.test/recordings/123.mp3', + ], JSON_UNESCAPED_SLASHES); + $signature = hash_hmac('sha256', $payload, $secret); + + $response = $this->call('POST', '/api/voip/webhook', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_VOIP_SIGNATURE' => $signature, + ], $payload); + $response->assertOk()->assertJsonPath('ok', true); + $call->refresh(); + $this->assertSame('completed', $call->provider_status); + $this->assertSame(93, $call->duration); + $this->assertNotNull($call->ended_at); + $this->assertSame('https://pbx.example.test/recordings/123.mp3', $call->recording_url); + } +} diff --git a/backend/tests/Feature/LeadActionsTest.php b/backend/tests/Feature/LeadActionsTest.php index 9723378..f9fac4c 100644 --- a/backend/tests/Feature/LeadActionsTest.php +++ b/backend/tests/Feature/LeadActionsTest.php @@ -2,9 +2,9 @@ namespace Tests\Feature; -use App\Models\ImportBatch; use App\Models\Contact; use App\Models\ContactPhone; +use App\Models\ImportBatch; use App\Models\Lead; use App\Models\LeadStatus; use App\Models\PipelineStage; @@ -165,7 +165,7 @@ class LeadActionsTest extends TestCase 'contact_phone_id' => $phone->id, ]) ->assertCreated() - ->json('call_id'); + ->json('data.call_id'); $followUpAt = now()->addDay()->toISOString(); @@ -186,7 +186,7 @@ class LeadActionsTest extends TestCase ], ]) ->assertOk() - ->assertJsonPath('result', 'معرفی شماره یا شخص جدید'); + ->assertJsonPath('data.result', 'معرفی شماره یا شخص جدید'); $this->assertDatabaseHas('contacts', [ 'lead_id' => $lead->id, diff --git a/backend/tests/Feature/ProfessionalCrmP2Test.php b/backend/tests/Feature/ProfessionalCrmP2Test.php new file mode 100644 index 0000000..098b7d3 --- /dev/null +++ b/backend/tests/Feature/ProfessionalCrmP2Test.php @@ -0,0 +1,155 @@ +seed(RolePermissionSeeder::class); + } + + public function test_pipeline_board_is_scoped_and_stage_moves_are_transactional_versioned_and_audited(): void + { + [$supervisor, $agent] = $this->teamUsers('Sales'); + [, $otherAgent] = $this->teamUsers('Other'); + $pipeline = Pipeline::where('is_default', true)->firstOrFail(); + $new = DealStage::where('pipeline_id', $pipeline->id)->where('slug', 'new')->firstOrFail(); + $won = DealStage::where('pipeline_id', $pipeline->id)->where('is_won', true)->firstOrFail(); + $deal = $this->deal($agent, $pipeline, $new, 'Scoped opportunity'); + $this->deal($otherAgent, $pipeline, $new, 'Foreign opportunity'); + + $this->actingAs($supervisor)->getJson("/api/pipelines/{$pipeline->id}/board") + ->assertOk()->assertJsonPath('summary.count', 1)->assertJsonFragment(['title' => 'Scoped opportunity'])->assertJsonMissing(['title' => 'Foreign opportunity']); + + $this->actingAs($agent)->patchJson("/api/deals/{$deal->id}/stage", ['deal_stage_id' => $won->id, 'version' => 1]) + ->assertUnprocessable()->assertJsonValidationErrors('reason'); + $this->actingAs($agent)->patchJson("/api/deals/{$deal->id}/stage", ['deal_stage_id' => $won->id, 'version' => 1, 'reason' => 'نیاز مشتری رفع شد', 'final_amount' => 950000]) + ->assertOk()->assertJsonPath('status', 'won')->assertJsonPath('version', 2); + $this->actingAs($agent)->patchJson("/api/deals/{$deal->id}/stage", ['deal_stage_id' => $new->id, 'version' => 1]) + ->assertUnprocessable()->assertJsonValidationErrors('version'); + + $this->assertDatabaseHas('deal_stage_histories', ['deal_id' => $deal->id, 'from_stage_id' => $new->id, 'to_stage_id' => $won->id, 'changed_by' => $agent->id]); + $this->assertDatabaseHas('activity_logs', ['action' => 'deal_stage_changed', 'subject_id' => $deal->id]); + $this->actingAs($supervisor)->getJson('/api/reports/operations') + ->assertOk() + ->assertJsonMissingPath('summary.won_value') + ->assertJsonStructure(['summary' => ['open_leads', 'issued_invoices', 'invoiced_total', 'outstanding_total']]); + } + + public function test_global_search_saved_views_and_preferences_are_scoped_to_user_and_team(): void + { + [$supervisor, $agent, $team] = $this->teamUsers('Search', true); + [, $otherAgent] = $this->teamUsers('Other'); + Lead::create(['first_name' => 'سارا', 'last_name' => 'فروش', 'company' => 'شرکت آلفا', 'phone' => '02111111111', 'assigned_to' => $agent->id, 'team_id' => $team->id]); + Lead::create(['first_name' => 'سارا', 'last_name' => 'محرمانه', 'company' => 'شرکت دیگر', 'phone' => '02122222222', 'assigned_to' => $otherAgent->id]); + + $this->actingAs($supervisor)->getJson('/api/global-search?q=سارا')->assertOk()->assertJsonCount(1, 'leads')->assertJsonPath('leads.0.last_name', 'فروش'); + $created = $this->actingAs($supervisor)->postJson('/api/saved-views', [ + 'entity_type' => 'deal', 'name' => 'فرصت‌های داغ تیم', 'visibility' => 'team', 'team_id' => $team->id, + 'filters' => ['forecast_category' => 'commit'], 'is_default' => true, + ])->assertCreated(); + $this->actingAs($agent)->getJson('/api/saved-views?entity_type=deal')->assertOk()->assertJsonFragment(['id' => $created->json('id'), 'name' => 'فرصت‌های داغ تیم']); + $this->actingAs($agent)->putJson('/api/workspace-preferences', [ + 'hidden_widgets' => ['team_performance'], 'widget_order' => ['task_summary'], + 'notifications' => [['notification_type' => 'sla', 'in_app_enabled' => true, 'is_muted' => false]], + ])->assertOk()->assertJsonPath('dashboard.hidden_widgets.0', 'team_performance'); + } + + public function test_scoring_sla_detection_and_automation_are_deterministic_and_idempotent(): void + { + $admin = $this->user('admin'); + $agent = $this->user('agent'); + $lead = Lead::create([ + 'first_name' => 'مینا', 'last_name' => 'گرم', 'company' => 'Intent Co', 'phone' => '02133333333', + 'email' => 'mina@example.test', 'city' => 'تهران', 'product_interest' => 'CRM', 'priority' => 4, + 'interest_level' => 'high', 'assigned_to' => $agent->id, + ]); + Lead::whereKey($lead->id)->update(['created_at' => now()->subHours(3)]); + $lead->refresh(); + + $this->actingAs($agent)->postJson("/api/leads/{$lead->id}/score")->assertOk()->assertJsonPath('score_level', 'warm'); + $rule = $this->actingAs($admin)->postJson('/api/sla-rules', [ + 'name' => 'اولین تماس دو ساعته', 'event' => 'first_contact', 'warning_minutes' => 60, 'breach_minutes' => 120, 'is_active' => true, + ])->assertCreated(); + $this->actingAs($admin)->postJson('/api/sla/detect')->assertOk()->assertJsonPath('created', 1); + $this->actingAs($admin)->postJson('/api/sla/detect')->assertOk()->assertJsonPath('created', 0); + $this->assertDatabaseHas('sla_breaches', ['sla_rule_id' => $rule->json('id'), 'breachable_id' => $lead->id, 'status' => 'breached']); + + $automation = $this->actingAs($admin)->postJson('/api/automations', [ + 'name' => 'پیگیری خودکار لید', 'trigger' => 'manual', 'actions' => [['type' => 'create_task']], 'is_active' => true, + ])->assertCreated(); + $payload = ['entity_type' => 'lead', 'entity_id' => $lead->id, 'event_key' => 'test-lead-followup']; + $this->actingAs($admin)->postJson("/api/automations/{$automation->json('id')}/run", $payload)->assertStatus(202)->assertJsonPath('status', 'completed'); + $this->actingAs($admin)->postJson("/api/automations/{$automation->json('id')}/run", $payload)->assertStatus(202); + $this->assertDatabaseCount('automation_runs', 1); + $this->assertDatabaseCount('tasks', 1); + } + + public function test_custom_fields_are_typed_and_quality_review_can_only_be_acknowledged_by_its_agent(): void + { + $admin = $this->user('admin'); + $agent = $this->user('agent'); + $other = $this->user('agent'); + $lead = Lead::create(['first_name' => 'علی', 'last_name' => 'کیفی', 'company' => 'QA', 'phone' => '02144444444', 'assigned_to' => $agent->id]); + $field = $this->actingAs($admin)->postJson('/api/custom-fields', [ + 'entity_type' => 'lead', 'key' => 'annual_budget', 'label' => 'بودجه سالانه', 'type' => 'number', + 'is_active' => true, 'is_filterable' => true, 'is_searchable' => false, 'is_required' => false, + ])->assertCreated(); + $this->actingAs($agent)->putJson("/api/custom-field-values/lead/{$lead->id}", ['values' => ['annual_budget' => 1250000]]) + ->assertOk()->assertJsonPath('0.custom_field_definition_id', $field->json('id'))->assertJsonPath('0.value_number', '1250000.0000'); + + $call = Call::create(['lead_id' => $lead->id, 'user_id' => $agent->id, 'phone' => $lead->phone, 'result' => 'answered']); + $review = QualityReview::create([ + 'call_id' => $call->id, 'reviewer_id' => $admin->id, 'agent_id' => $agent->id, 'version' => 1, 'is_current' => true, + 'overall_score' => 80, 'is_shared_with_agent' => true, + ]); + $this->actingAs($other)->postJson("/api/quality-reviews/{$review->id}/acknowledge")->assertForbidden(); + $this->actingAs($agent)->postJson("/api/quality-reviews/{$review->id}/acknowledge", ['agent_response' => 'دریافت شد']) + ->assertOk()->assertJsonPath('status', 'acknowledged'); + $this->assertNotNull($review->fresh()->acknowledged_at); + } + + private function deal(User $owner, Pipeline $pipeline, DealStage $stage, string $title): Deal + { + return Deal::create([ + 'title' => $title, 'owner_id' => $owner->id, 'created_by' => $owner->id, + 'pipeline_id' => $pipeline->id, 'deal_stage_id' => $stage->id, + 'estimated_value' => 1000000, 'win_probability' => $stage->probability, + 'sales_stage' => $stage->slug, 'status' => 'open', 'version' => 1, + ]); + } + + private function teamUsers(string $name, bool $returnTeam = false): array + { + $supervisor = $this->user('supervisor'); + $agent = $this->user('agent'); + $team = Team::create(['name' => $name, 'supervisor_id' => $supervisor->id, 'is_active' => true]); + $team->members()->attach([$supervisor->id, $agent->id]); + + return $returnTeam ? [$supervisor, $agent, $team] : [$supervisor, $agent]; + } + + private function user(string $role): User + { + $user = User::factory()->create(['is_active' => true]); + $user->assignRole($role); + + return $user; + } +} diff --git a/backend/tests/Feature/QualityScriptAuthorizationTest.php b/backend/tests/Feature/QualityScriptAuthorizationTest.php new file mode 100644 index 0000000..21b9748 --- /dev/null +++ b/backend/tests/Feature/QualityScriptAuthorizationTest.php @@ -0,0 +1,139 @@ +seed(RolePermissionSeeder::class); + } + + public function test_quality_review_derives_agent_from_call_and_versions_reviews(): void + { + [$supervisor, $agent] = $this->team(); + $otherAgent = $this->user('agent'); + $call = $this->makeCall($agent); + + $first = $this->actingAs($supervisor)->postJson('/api/quality-reviews', $this->reviewPayload($call, [ + 'agent_id' => $otherAgent->id, + 'is_shared_with_agent' => false, + ]))->assertCreated(); + + $this->assertSame($agent->id, $first->json('agent_id')); + $this->assertSame(1, $first->json('version')); + + $second = $this->actingAs($supervisor)->postJson('/api/quality-reviews', $this->reviewPayload($call, [ + 'is_shared_with_agent' => true, + ]))->assertCreated(); + + $this->assertSame(2, $second->json('version')); + $this->assertFalse(QualityReview::findOrFail($first->json('id'))->is_current); + $this->assertTrue(QualityReview::findOrFail($second->json('id'))->is_current); + } + + public function test_agent_only_sees_shared_own_review_and_cannot_create_one(): void + { + [$supervisor, $agent] = $this->team(); + $call = $this->makeCall($agent); + $review = $this->actingAs($supervisor)->postJson('/api/quality-reviews', $this->reviewPayload($call, [ + 'is_shared_with_agent' => false, + ]))->assertCreated()->json(); + + $this->actingAs($agent)->getJson("/api/quality-reviews/{$review['id']}")->assertForbidden(); + $this->actingAs($agent)->postJson('/api/quality-reviews', $this->reviewPayload($call))->assertForbidden(); + + $this->actingAs($supervisor)->putJson("/api/quality-reviews/{$review['id']}", ['is_shared_with_agent' => true])->assertOk(); + $this->actingAs($agent)->getJson("/api/quality-reviews/{$review['id']}")->assertOk(); + } + + public function test_sales_scripts_are_transactional_and_agents_only_see_active_scripts(): void + { + [$supervisor, $agent] = $this->team(); + + $scriptId = $this->actingAs($supervisor)->postJson('/api/scripts', [ + 'title' => 'Outbound sales', + 'description' => 'Approved script', + 'is_active' => true, + 'assigned_user_ids' => [$agent->id], + 'sections' => [ + ['title' => 'Greeting', 'content' => 'Hello', 'sort_order' => 0], + ['title' => 'Discovery', 'content' => 'Ask questions', 'sort_order' => 1], + ], + ])->assertCreated()->json('id'); + + $this->assertDatabaseHas('script_sections', ['sales_script_id' => $scriptId, 'sort_order' => 0]); + $this->actingAs($agent)->getJson("/api/scripts/{$scriptId}")->assertOk(); + $this->actingAs($agent)->postJson('/api/scripts', ['title' => 'Unauthorized'])->assertForbidden(); + + SalesScript::whereKey($scriptId)->update(['is_active' => false]); + $this->actingAs($agent)->getJson("/api/scripts/{$scriptId}")->assertForbidden(); + + $this->actingAs($supervisor)->postJson('/api/scripts', [ + 'title' => 'Invalid script', + 'sections' => [['title' => 'Missing content']], + ])->assertUnprocessable(); + $this->assertDatabaseMissing('sales_scripts', ['title' => 'Invalid script']); + } + + private function reviewPayload(Call $call, array $overrides = []): array + { + return array_merge([ + 'call_id' => $call->id, + 'greeting_score' => 80, + 'product_intro_score' => 75, + 'needs_discovery_score' => 70, + 'objection_handling_score' => 65, + 'closing_score' => 60, + 'crm_accuracy_score' => 90, + 'follow_up_quality_score' => 85, + 'feedback' => 'Useful feedback', + ], $overrides); + } + + private function team(): array + { + $supervisor = $this->user('supervisor'); + $agent = $this->user('agent'); + $team = Team::create(['name' => 'Sales Team', 'supervisor_id' => $supervisor->id, 'is_active' => true]); + $supervisor->teams()->attach($team); + $agent->teams()->attach($team); + + return [$supervisor, $agent]; + } + + private function makeCall(User $agent): Call + { + $lead = Lead::create([ + 'company' => 'Call Company', + 'first_name' => 'Sara', + 'last_name' => 'Ahmadi', + 'phone' => fake()->unique()->numerify('021########'), + 'assigned_to' => $agent->id, + 'is_unassigned' => false, + ]); + + return Call::create(['lead_id' => $lead->id, 'user_id' => $agent->id, 'direction' => 'outbound', 'phone' => $lead->phone]); + } + + private function user(string $role): User + { + $user = User::factory()->create(['is_active' => true]); + $user->assignRole($role); + + return $user; + } +} diff --git a/backend/tests/Feature/RegressionFixesTest.php b/backend/tests/Feature/RegressionFixesTest.php new file mode 100644 index 0000000..a19dc63 --- /dev/null +++ b/backend/tests/Feature/RegressionFixesTest.php @@ -0,0 +1,54 @@ +seed(RolePermissionSeeder::class); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + $this->actingAs($admin) + ->getJson('/api/quality-reviews?current_only=true&page=1&per_page=15') + ->assertOk() + ->assertJsonPath('current_page', 1); + } + + public function test_uploaded_avatar_is_served_from_public_media_endpoint(): void + { + Storage::fake('public'); + Storage::disk('public')->put('avatars/profile-test.jpg', 'avatar-content'); + + $response = $this->get('/api/media/avatars/profile-test.jpg') + ->assertOk(); + + $this->assertStringContainsString('public', (string) $response->headers->get('cache-control')); + $this->assertStringContainsString('max-age=86400', (string) $response->headers->get('cache-control')); + } + + public function test_session_probe_is_successful_for_guests_and_authenticated_users(): void + { + $this->getJson('/api/auth/me') + ->assertOk() + ->assertJsonPath('authenticated', false) + ->assertJsonPath('data', null); + + $user = User::factory()->create(['is_active' => true]); + + $this->actingAs($user) + ->getJson('/api/auth/me') + ->assertOk() + ->assertJsonPath('authenticated', true) + ->assertJsonPath('data.id', $user->id); + } +} diff --git a/backend/tests/Feature/ReportKpiTest.php b/backend/tests/Feature/ReportKpiTest.php new file mode 100644 index 0000000..6da1ba5 --- /dev/null +++ b/backend/tests/Feature/ReportKpiTest.php @@ -0,0 +1,86 @@ +seed(RolePermissionSeeder::class); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $agent = User::factory()->create(['is_active' => true, 'name' => 'کارشناس تست']); + $agent->assignRole('agent'); + CallResult::create([ + 'name' => 'فروش موفق', + 'slug' => 'successful-sale', + 'is_positive' => true, + 'is_final' => true, + 'is_active' => true, + ]); + $lead = Lead::create([ + 'company' => 'مشتری KPI', + 'first_name' => 'مینا', + 'last_name' => 'احمدی', + 'phone' => '09123333333', + 'assigned_to' => $agent->id, + 'is_unassigned' => false, + 'final_result' => 'موفق', + 'deal_value' => 5000000, + ]); + Call::create([ + 'lead_id' => $lead->id, + 'user_id' => $agent->id, + 'direction' => 'outbound', + 'phone' => $lead->phone, + 'result' => 'فروش موفق', + 'duration' => 120, + 'is_manual' => true, + ]); + + $today = now()->toDateString(); + $this->actingAs($admin)->getJson("/api/reports/kpi?date_from={$today}&date_to={$today}") + ->assertOk() + ->assertJsonPath('range.working_days', 1) + ->assertJsonCount(9, 'kpis') + ->assertJsonPath('kpis.0.key', 'total_calls') + ->assertJsonPath('kpis.0.value', 1) + ->assertJsonPath('trend.0.calls', 1) + ->assertJsonPath('trend.0.won', 1) + ->assertJsonPath('leaderboard.0.agent_name', 'کارشناس تست') + ->assertJsonPath('leaderboard.0.won_value', 5000000); + } + + public function test_agent_report_scope_cannot_be_changed_to_another_agent_or_aggregate_exports(): void + { + $this->seed(RolePermissionSeeder::class); + $agent = User::factory()->create(['is_active' => true]); + $agent->assignRole('agent'); + $otherAgent = User::factory()->create(['is_active' => true]); + $otherAgent->assignRole('agent'); + + $this->actingAs($agent)->getJson("/api/reports/agent-performance?agent_id={$otherAgent->id}") + ->assertForbidden(); + $this->actingAs($agent)->getJson('/api/reports/team-performance') + ->assertForbidden(); + $this->actingAs($agent)->getJson('/api/reports/import-quality') + ->assertForbidden(); + $this->actingAs($agent)->get('/api/reports/export/excel?type=team') + ->assertForbidden(); + + $this->actingAs($agent)->getJson('/api/reports/agent-performance') + ->assertOk() + ->assertJsonCount(1) + ->assertJsonPath('0.agent.id', $agent->id); + } +} diff --git a/backend/tests/Feature/RolePermissionDashboardTest.php b/backend/tests/Feature/RolePermissionDashboardTest.php new file mode 100644 index 0000000..8abe3f6 --- /dev/null +++ b/backend/tests/Feature/RolePermissionDashboardTest.php @@ -0,0 +1,78 @@ +seed(RolePermissionSeeder::class); + $this->seed(RolePermissionSeeder::class); + + foreach (PermissionCatalog::ROLE_DEFAULTS as $role => $permissions) { + $user = User::factory()->create(['is_active' => true]); + $user->assignRole($role); + + foreach ($permissions as $permission) { + $this->assertTrue($user->can($permission), "{$role} is missing {$permission}"); + } + } + } + + public function test_dashboard_endpoints_enforce_role_and_permission_matrix(): void + { + $this->seed(RolePermissionSeeder::class); + + $admin = User::factory()->create(['is_active' => true]); + $supervisor = User::factory()->create(['is_active' => true]); + $agent = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $supervisor->assignRole('supervisor'); + $agent->assignRole('agent'); + $lead = Lead::create(['company' => 'مشتری پیگیری', 'first_name' => 'علی', 'last_name' => 'فردا', 'phone' => '09120000001', 'assigned_to' => $agent->id]); + $futureFollowUp = FollowUp::create([ + 'lead_id' => $lead->id, + 'user_id' => $agent->id, + 'created_by' => $supervisor->id, + 'scheduled_at' => now()->addDays(3), + 'status' => 'pending', + 'is_overdue' => false, + 'notes' => 'پیگیری آینده', + ]); + + $this->actingAs($admin)->getJson('/api/dashboard/admin') + ->assertOk() + ->assertJsonStructure([ + 'live_charts' => [ + 'updated_at', + 'lead_status', + 'call_outcomes' => [['label', 'value', 'color']], + 'performance' => [['label', 'value', 'color']], + ], + ]) + ->assertJsonCount(3, 'live_charts.call_outcomes') + ->assertJsonCount(5, 'live_charts.performance'); + $this->actingAs($admin)->getJson('/api/dashboard/supervisor')->assertForbidden(); + $this->actingAs($supervisor)->getJson('/api/dashboard/supervisor') + ->assertOk() + ->assertJsonPath('live_charts.performance.0.label', 'تبدیل فروش'); + $this->actingAs($supervisor)->getJson('/api/dashboard/admin')->assertForbidden(); + $this->actingAs($agent)->getJson('/api/dashboard/agent') + ->assertOk() + ->assertJsonPath('live_charts.call_outcomes.0.label', 'موفق') + ->assertJsonPath('follow_up_widgets.next.0.id', $futureFollowUp->id) + ->assertJsonPath('follow_up_widgets.next.0.lead.company', 'مشتری پیگیری'); + $this->actingAs($agent)->getJson('/api/dashboard/admin')->assertForbidden(); + $this->actingAs($agent)->getJson('/api/dashboard/supervisor')->assertForbidden(); + } +} diff --git a/backend/tests/Feature/SecurityAuthorizationTest.php b/backend/tests/Feature/SecurityAuthorizationTest.php index eb1c963..75b7279 100644 --- a/backend/tests/Feature/SecurityAuthorizationTest.php +++ b/backend/tests/Feature/SecurityAuthorizationTest.php @@ -2,15 +2,15 @@ namespace Tests\Feature; +use App\Models\Call; use App\Models\FollowUp; use App\Models\Lead; -use App\Models\Call; use App\Models\Setting; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Permission; +use Spatie\Permission\Models\Role; use Tests\TestCase; class SecurityAuthorizationTest extends TestCase @@ -171,7 +171,7 @@ class SecurityAuthorizationTest extends TestCase ->assertOk(); $this->assertStringNotContainsString('09121234567', $response->getContent()); - $response->assertJsonPath('recording_url', null); + $response->assertJsonPath('data.recording_url', null); } public function test_supervisor_cannot_access_other_team_lead_or_report(): void diff --git a/backend/tests/Feature/SettingsBehaviorTest.php b/backend/tests/Feature/SettingsBehaviorTest.php new file mode 100644 index 0000000..b2dd283 --- /dev/null +++ b/backend/tests/Feature/SettingsBehaviorTest.php @@ -0,0 +1,68 @@ +seed(RolePermissionSeeder::class); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + $response = $this->actingAs($admin)->getJson('/api/settings')->assertOk(); + $items = collect($response->json())->flatten(1); + + $this->assertTrue($items->contains('key', 'company_name')); + $this->assertFalse($items->contains('key', 'two_factor_enabled')); + $this->assertFalse($items->contains(fn (array $setting) => ! $setting['is_runtime_enforced'])); + $this->assertCount(collect(SettingsCatalog::definitions())->where('is_runtime_enforced', true)->count(), $items); + $this->assertFalse($items->firstWhere('key', 'voip_provider')['allowed_values'] === ['mock', 'ami', 'api', 'socket']); + } + + public function test_saved_security_settings_change_runtime_password_validation(): void + { + $this->seed(RolePermissionSeeder::class); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + $this->actingAs($admin)->putJson('/api/settings', ['settings' => [ + ['key' => 'password_min_length', 'value' => '12', 'group' => 'security', 'type' => 'integer'], + ['key' => 'password_require_numbers', 'value' => 'true', 'group' => 'security', 'type' => 'boolean'], + ]])->assertOk(); + + $this->assertDatabaseHas('settings', ['key' => 'password_min_length', 'value' => '12']); + $this->assertTrue(Validator::make(['password' => 'longpassword'], ['password' => PasswordPolicy::rules()])->fails()); + $this->assertFalse(Validator::make(['password' => 'longpassword1'], ['password' => PasswordPolicy::rules()])->fails()); + } + + public function test_production_voip_does_not_report_a_fake_success_when_unconfigured(): void + { + Setting::updateOrCreate(['key' => 'voip_provider'], ['value' => 'none', 'group' => 'voip', 'type' => 'string']); + + $result = (new DisabledProvider)->testConnection(); + + $this->assertFalse($result['ok']); + $this->assertStringContainsString('پیکربندی نشده', $result['message']); + } + + public function test_every_exposed_setting_declares_its_runtime_consumer(): void + { + $definitions = collect(SettingsCatalog::definitions())->where('is_runtime_enforced', true); + + $this->assertNotEmpty($definitions); + $this->assertEmpty($definitions->filter(fn (array $setting) => empty($setting['used_by']))->all()); + } +} diff --git a/backend/tests/Feature/TaskAuthorizationLifecycleTest.php b/backend/tests/Feature/TaskAuthorizationLifecycleTest.php new file mode 100644 index 0000000..8199c23 --- /dev/null +++ b/backend/tests/Feature/TaskAuthorizationLifecycleTest.php @@ -0,0 +1,137 @@ +seed(RolePermissionSeeder::class); + } + + public function test_task_lists_and_direct_access_are_scoped_by_role_and_team(): void + { + $admin = $this->user('admin'); + [$supervisor, $agent] = $this->teamUsers('Own'); + [$otherSupervisor, $otherAgent] = $this->teamUsers('Other'); + + $own = $this->task($supervisor, $agent, 'Own team task', 'team'); + $other = $this->task($otherSupervisor, $otherAgent, 'Other team task', 'team'); + + $this->actingAs($admin)->getJson('/api/tasks')->assertOk()->assertJsonPath('meta.total', 2); + $this->actingAs($supervisor)->getJson('/api/tasks')->assertOk()->assertJsonPath('meta.total', 1)->assertJsonPath('data.0.id', $own->id); + $this->actingAs($agent)->getJson('/api/tasks')->assertOk()->assertJsonPath('meta.total', 1)->assertJsonPath('data.0.id', $own->id); + $this->actingAs($agent)->getJson("/api/tasks/{$other->id}")->assertForbidden(); + } + + public function test_cross_team_inactive_assignee_and_unauthorized_entity_are_rejected(): void + { + [$supervisor, $agent] = $this->teamUsers('Own'); + [, $otherAgent] = $this->teamUsers('Other'); + $task = $this->task($supervisor, $agent, 'Scoped task', 'team'); + + $this->actingAs($supervisor)->postJson("/api/tasks/{$task->id}/assign", [ + 'assigned_to' => $otherAgent->id, + 'version' => 1, + ])->assertForbidden(); + + $inactive = $this->user('agent', ['is_active' => false]); + $this->actingAs($supervisor)->postJson("/api/tasks/{$task->id}/assign", [ + 'assigned_to' => $inactive->id, + 'version' => 1, + ])->assertUnprocessable()->assertJsonPath('code', 'VALIDATION_FAILED'); + + $foreignLead = Lead::create([ + 'first_name' => 'Other', 'last_name' => 'Lead', 'company' => 'Foreign', + 'phone' => '02112345678', 'assigned_to' => $otherAgent->id, + ]); + $this->actingAs($agent)->postJson('/api/tasks', [ + 'subject' => 'Unauthorized relation', + 'taskable_type' => 'lead', + 'taskable_id' => $foreignLead->id, + ])->assertForbidden(); + } + + public function test_task_lifecycle_optimistic_lock_and_transactional_bulk_actions(): void + { + [$supervisor, $agent] = $this->teamUsers('Lifecycle'); + + $created = $this->actingAs($supervisor)->postJson('/api/tasks', [ + 'subject' => 'Call customer', + 'assigned_to' => $agent->id, + 'priority' => 'high', + 'visibility' => 'team', + 'due_at' => now()->addDay()->toISOString(), + ])->assertCreated()->assertJsonPath('data.status', 'open'); + $taskId = $created->json('data.id'); + + $started = $this->actingAs($agent)->postJson("/api/tasks/{$taskId}/start", ['version' => 1]) + ->assertOk()->assertJsonPath('data.status', 'in_progress'); + $this->actingAs($agent)->patchJson("/api/tasks/{$taskId}", [ + 'subject' => 'Stale edit', + 'version' => 1, + ])->assertForbidden(); + $this->actingAs($supervisor)->patchJson("/api/tasks/{$taskId}", [ + 'subject' => 'Stale creator edit', + 'version' => 1, + ])->assertStatus(409)->assertJsonPath('code', 'VERSION_CONFLICT'); + + $completed = $this->actingAs($agent)->postJson("/api/tasks/{$taskId}/complete", ['version' => $started->json('data.version')]) + ->assertOk()->assertJsonPath('data.status', 'done'); + $this->assertNotNull(Task::find($taskId)->completed_at); + + $reopened = $this->actingAs($agent)->postJson("/api/tasks/{$taskId}/reopen", ['version' => $completed->json('data.version')]) + ->assertOk()->assertJsonPath('data.status', 'open'); + $this->assertNull(Task::find($taskId)->completed_at); + + $second = $this->task($supervisor, $agent, 'Second', 'team'); + $this->actingAs($supervisor)->postJson('/api/tasks/bulk-complete', [ + 'task_ids' => [$taskId, $second->id], + ])->assertOk()->assertJsonCount(2, 'data'); + $this->assertSame(2, Task::whereIn('id', [$taskId, $second->id])->where('status', 'done')->count()); + $this->assertDatabaseHas('activity_logs', ['action' => 'task_bulk_completed', 'subject_id' => $second->id]); + $this->assertDatabaseHas('internal_notifications', ['user_id' => $agent->id, 'type' => 'task_assigned']); + } + + private function task(User $creator, User $assignee, string $subject, string $visibility): Task + { + return Task::create([ + 'subject' => $subject, + 'assigned_to' => $assignee->id, + 'assigned_by' => $creator->id, + 'created_by' => $creator->id, + 'priority' => 'normal', + 'status' => 'open', + 'visibility' => $visibility, + ]); + } + + private function teamUsers(string $name): array + { + $supervisor = $this->user('supervisor'); + $agent = $this->user('agent'); + $team = Team::create(['name' => $name, 'supervisor_id' => $supervisor->id, 'is_active' => true]); + $team->members()->attach([$supervisor->id, $agent->id]); + + return [$supervisor, $agent]; + } + + private function user(string $role, array $attributes = []): User + { + $user = User::factory()->create(array_merge(['is_active' => true], $attributes)); + $user->assignRole($role); + + return $user; + } +} diff --git a/backend/tests/Feature/UserManagementTest.php b/backend/tests/Feature/UserManagementTest.php index 4e8bf52..79193fb 100644 --- a/backend/tests/Feature/UserManagementTest.php +++ b/backend/tests/Feature/UserManagementTest.php @@ -2,10 +2,13 @@ namespace Tests\Feature; -use App\Models\User; -use Illuminate\Foundation\Testing\RefreshDatabase; use App\Models\Lead; use App\Models\PipelineStage; +use App\Models\User; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; +use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; use Tests\TestCase; @@ -13,6 +16,27 @@ class UserManagementTest extends TestCase { use RefreshDatabase; + public function test_profile_avatar_returns_same_origin_safe_media_url(): void + { + Storage::fake('public'); + $user = User::factory()->create(['is_active' => true]); + + $response = $this->actingAs($user)->post('/api/auth/profile', [ + 'name' => $user->name, + 'email' => $user->email, + 'phone' => $user->phone, + 'avatar' => UploadedFile::fake()->createWithContent( + 'avatar.png', + base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=') + ), + ], ['Accept' => 'application/json']); + + $response->assertOk(); + $avatar = $response->json('data.avatar'); + $response->assertJsonPath('data.avatar_url', '/api/media/avatars/'.basename($avatar)); + Storage::disk('public')->assertExists($avatar); + } + public function test_admin_can_update_user_and_remain_authenticated(): void { $adminRole = Role::create(['name' => 'admin', 'guard_name' => 'web']); @@ -49,7 +73,6 @@ class UserManagementTest extends TestCase Role::create(['name' => 'agent', 'guard_name' => 'web']); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole($adminRole); - $response = $this->actingAs($admin) ->postJson('/api/users', [ 'name' => 'کارشناس فروش', @@ -85,7 +108,7 @@ class UserManagementTest extends TestCase $this->actingAs($user) ->getJson('/api/pipeline-stages') ->assertOk() - ->assertJsonPath('0.name', 'لید جدید'); + ->assertJsonPath('0.name', 'لید جدید'); } } @@ -111,6 +134,10 @@ class UserManagementTest extends TestCase Role::create(['name' => 'agent', 'guard_name' => 'web']); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole($adminRole); + $adminRole->givePermissionTo(Permission::create([ + 'name' => 'view_admin_dashboard', + 'guard_name' => 'web', + ])); $firstStage = PipelineStage::create([ 'name' => 'لید جدید', diff --git a/backend/tests/Feature/WorkflowInteractionTest.php b/backend/tests/Feature/WorkflowInteractionTest.php new file mode 100644 index 0000000..9136a8f --- /dev/null +++ b/backend/tests/Feature/WorkflowInteractionTest.php @@ -0,0 +1,102 @@ +seed(RolePermissionSeeder::class); + } + + public function test_follow_up_assignment_is_visible_notified_and_only_creator_can_edit(): void + { + [$supervisor, $agent] = $this->team(); + $lead = $this->lead($agent); + + $created = $this->actingAs($supervisor)->postJson('/api/follow-ups', [ + 'lead_id' => $lead->id, + 'user_id' => $agent->id, + 'scheduled_at' => now()->addDay()->setTime(10, 0)->toISOString(), + 'notes' => 'تماس درباره پیشنهاد', + ])->assertCreated()->assertJsonPath('data.created_by', $supervisor->id); + $followUpId = $created->json('data.id'); + + $this->assertDatabaseHas('internal_notifications', ['user_id' => $agent->id, 'type' => 'follow_up']); + $this->actingAs($agent)->getJson('/api/follow-ups')->assertOk()->assertJsonFragment(['id' => $followUpId]); + $this->actingAs($agent)->patchJson("/api/follow-ups/{$followUpId}", ['notes' => 'ویرایش غیرمجاز'])->assertForbidden(); + $this->actingAs($supervisor)->patchJson("/api/follow-ups/{$followUpId}", ['notes' => 'ویرایش سازنده'])->assertOk(); + $this->actingAs($agent)->patchJson("/api/follow-ups/{$followUpId}/mark-done")->assertOk()->assertJsonPath('data.status', 'completed'); + } + + public function test_invoice_has_paid_balance_and_separate_approval_then_issue(): void + { + [$supervisor, $agent] = $this->team(); + $lead = $this->lead($agent, ['final_result' => 'موفق', 'deal_value' => 1_000_000, 'sold_product' => 'اشتراک']); + + $invoiceId = $this->actingAs($agent)->postJson("/api/leads/{$lead->id}/invoice", [ + 'paid_amount' => 250_000, + ])->assertCreated()->assertJsonPath('payment_status', 'partial')->assertJsonPath('balance_due', 750000)->json('id'); + + $this->actingAs($supervisor)->postJson("/api/invoices/{$invoiceId}/reject", ['reason' => 'اصلاح مبلغ پرداختی']) + ->assertOk()->assertJsonPath('status', 'rejected'); + $this->actingAs($agent)->putJson("/api/invoices/{$invoiceId}", ['paid_amount' => 300_000]) + ->assertOk()->assertJsonPath('status', 'pending_approval')->assertJsonPath('balance_due', 700000); + $this->actingAs($supervisor)->postJson("/api/invoices/{$invoiceId}/approve") + ->assertOk()->assertJsonPath('status', 'approved'); + $this->actingAs($supervisor)->postJson("/api/invoices/{$invoiceId}/issue") + ->assertOk()->assertJsonPath('status', 'issued'); + } + + public function test_agent_can_refer_own_lead_to_team_agent_and_recipient_is_notified(): void + { + [$supervisor, $agent, $secondAgent] = $this->team(true); + $lead = $this->lead($agent); + + $this->actingAs($agent)->postJson("/api/leads/{$lead->id}/refer", ['user_id' => $secondAgent->id]) + ->assertOk()->assertJsonPath('assigned_to', $secondAgent->id); + $this->assertDatabaseHas('internal_notifications', ['user_id' => $secondAgent->id, 'type' => 'assignment']); + $managerLead = $this->lead($agent); + $this->actingAs($agent)->postJson("/api/leads/{$managerLead->id}/refer", ['user_id' => $supervisor->id]) + ->assertOk()->assertJsonPath('assigned_to', $supervisor->id); + $this->assertDatabaseHas('internal_notifications', ['user_id' => $supervisor->id, 'type' => 'assignment']); + } + + private function team(bool $withSecondAgent = false): array + { + $supervisor = User::factory()->create(['is_active' => true]); + $supervisor->assignRole('supervisor'); + $agent = User::factory()->create(['is_active' => true]); + $agent->assignRole('agent'); + $team = Team::create(['name' => fake()->unique()->company(), 'supervisor_id' => $supervisor->id, 'is_active' => true]); + $supervisor->teams()->attach($team); + $agent->teams()->attach($team); + if (! $withSecondAgent) { + return [$supervisor, $agent]; + } + $second = User::factory()->create(['is_active' => true]); + $second->assignRole('agent'); + $second->teams()->attach($team); + + return [$supervisor, $agent, $second]; + } + + private function lead(User $agent, array $overrides = []): Lead + { + return Lead::create(array_merge([ + 'company' => 'شرکت نمونه', 'first_name' => 'علی', 'last_name' => 'آزمایشی', + 'phone' => fake()->unique()->numerify('0912#######'), 'assigned_to' => $agent->id, 'is_unassigned' => false, + 'team_id' => $agent->teams()->value('teams.id'), + ], $overrides)); + } +} diff --git a/docs/CRM_CHANGE_CHECKLIST_FA.md b/docs/CRM_CHANGE_CHECKLIST_FA.md new file mode 100644 index 0000000..cc2e8be --- /dev/null +++ b/docs/CRM_CHANGE_CHECKLIST_FA.md @@ -0,0 +1,77 @@ +# چک‌لیست تغییرات CRM + +آخرین به‌روزرسانی: ۱۴۰۵/۰۵/۰۱ + +## قواعد اجرا + +- هر مورد فقط پس از تکمیل کد، تست مرتبط و بازبینی رفتاری تیک می‌خورد. +- تغییرات موجود کاربر در working tree حفظ می‌شوند. +- قالب نمونه فاکتور، A4 افقی با ابعاد دقیق ۲۹۷×۲۱۰ میلی‌متر در نظر گرفته می‌شود. +- برای کمپین، قیف مستقل ساخته نمی‌شود. + +## چک‌لیست اصلی + +- [x] ۱. رفع خطای ثبت نتیجه تماس در پروفایل لید + - [x] جداسازی ثبت تماس دستی از شروع تماس VoIP + - [x] نمایش پیام واقعی خطای API + - [x] تست ثبت نتیجه، پیگیری و مخاطب معرفی‌شده +- [x] ۲. رفع نمایش‌ندادن تصویر پروفایل پس از آپلود + - [x] URL هم‌مبدأ برای تصویر + - [x] cache-busting و پیش‌نمایش + - [x] تست آپلود و نمایش +- [x] ۳. افزایش محسوس سرعت کل سامانه + - [x] ثبت baseline + - [x] حذف درخواست‌های تکراری و cache داده + - [x] بهینه‌سازی query و indexهای لازم + - [x] کنترل bundle و PDF worker +- [x] ۴. اولویت‌دادن مخاطب اصلی و شماره پیش‌فرض در قیف +- [x] ۵. رنگی‌کردن فقط عدد روز جاری با رنگ اصلی سامانه +- [x] ۶. اصلاح منطق موعد و یادآوری و اعلان به کاربر assign‌شده +- [x] ۷. نمایش کارها و پیگیری‌های کاربر در داشبورد +- [x] ۸. ارسال اعلان همراه لینک هنگام ارجاع یا تخصیص لید +- [x] ۹. حذف تخصیص برای کارشناس فروش و حفظ امکان ارجاع +- [x] ۱۰. اصلاح نمایش رویدادها در popup تقویم +- [x] ۱۱. نمایش popup خلاصه پیش از رفتن به مسیر رویداد +- [x] ۱۲. اصلاح منبع فرصت باز، ارزش کل و ارزش وزنی و به‌روزرسانی زنده +- [x] ۱۳. حذف فرصت‌های مالی از داخل قیف لید و نگهداری صفحه مستقل فرصت‌های فروش +- [x] ۱۴. یکپارچه‌سازی وابستگی‌های قیف، لید، داشبورد و گزارش +- [x] ۱۵. روشن‌سازی و اصلاح ارتباط شرکت‌ها و محصولات +- [x] ۱۶. افزودن فیلتر کارشناس فروش به صفحه تماس‌ها +- [x] ۱۷. اصلاح ترتیب فیلتر و toggle مرتب‌سازی پیگیری‌ها +- [x] ۱۸. تکمیل قالب فاکتور سفارشی و خروجی دقیق A4 + - [x] تشخیص A4 افقی نمونه + - [x] جای‌گذاری پیشنهادی فیلدها برای قالب نمونه + - [x] بزرگ‌کردن متناسب فایل‌های کوچک‌تر روی صفحه A4 + - [x] پیش‌نمایش و چاپ دقیق +- [x] ۱۹. تکمیل کمپین‌ها بدون ایجاد قیف کمپین + - [x] اتصال واقعی محصول + - [x] کانال، بودجه، هزینه و سنجه‌های عملکرد + - [x] صفحه جزئیات/خلاصه بدون برد قیفی +- [x] ۲۰. افزودن فیلترهای منطقی ارزیابی کیفیت +- [x] ۲۱. اتصال دقیق و زنده گزارش‌ها به منبع واحد داده +- [x] ۲۲. یکسان‌سازی عناوین مدیر فروش، کارشناس فروش و ادمین +- [x] ۲۳. ثابت‌کردن منوی تنظیمات بدون کوچک‌کردن فضای کار +- [x] ۲۴. جایگزینی loading محتوایی با Skeleton در کل سامانه + +## کنترل کیفیت نهایی + +- [x] تست‌های backend — ۸۱ تست و ۶۳۵ assertion +- [x] lint فرانت‌اند +- [x] تست‌های unit فرانت‌اند — ۲۲ تست +- [x] build production +- [x] تست‌های E2E — ۱۵ سناریوی مرورگر +- [x] تست چاپ A4 — ابعاد دقیق ۲۹۷×۲۱۰ میلی‌متر +- [x] ثبت نتیجه کارایی قبل و بعد در `docs/PERFORMANCE_AUDIT_FA.md` + +## بازبینی دوم بر اساس تصاویر تقویم و گزارش + +- [x] جلوگیری از بریده‌شدن رویداد داخل خانه تقویم +- [x] نمایش یک رویداد خوانا و شمارنده موارد بیشتر +- [x] جلوگیری از فشرده‌شدن رتبه کارشناسان کنار نمودار +- [x] محدودکردن اسکرول افقی به خود نمودار +- [x] ساخت بخش «برنامه من» با تب مستقل پیگیری‌ها و کارها +- [x] نمایش پیگیری‌های آینده، امروز و عقب‌افتاده در داشبورد +- [x] افزودن «فرصت‌های فروش» به منوی اصلی +- [x] بازطراحی مرکز فاکتورها و آمار مالی زنده +- [x] بازطراحی استودیوی نگاشت قالب A4 +- [x] اتصال ساختاریافته هفت ردیف کالا به قالب نمونه diff --git a/docs/P1_TASKS_AND_CALL_NOTES_FA.md b/docs/P1_TASKS_AND_CALL_NOTES_FA.md new file mode 100644 index 0000000..bd9409d --- /dev/null +++ b/docs/P1_TASKS_AND_CALL_NOTES_FA.md @@ -0,0 +1,85 @@ +# راهنمای فنی P1: Task و تاریخچه یادداشت تماس + +## دامنه پیاده‌سازی + +P1 یک Task عمومی و polymorphic برای `lead`، `contact`، `company`، `deal`، `call` و `campaign` اضافه می‌کند. نام کلاس PHP هیچ‌وقت از ورودی API پذیرفته نمی‌شود و alias امن در سرور resolve می‌شود. این فاز همچنین یادداشت چندتایی تماس، reminderهای idempotent، notification دارای `read_at`، audit قبل/بعد و حفظ تاریخی شماره تماس را پوشش می‌دهد. + +## مدل داده + +- `tasks`: موضوع، توضیحات، `taskable_type/id`، مسئول/تخصیص‌دهنده/سازنده، اولویت، وضعیت، موعد، شروع/تکمیل/یادآوری، parent، تخمین، visibility، version و soft delete. +- `notes`: تاریخچه polymorphic با type، visibility، pin، زمان ویرایش، `source_key` یکتا و soft delete. `user_id` برای حفظ تاریخچه پس از حذف کاربر nullable و `nullOnDelete` است. +- `internal_notifications`: زمان خواندن و کلید idempotency یکتا. +- `activity_logs`: snapshotهای redacted قبل/بعد و request ID. +- `contact_phones`: soft delete؛ تماس‌های تاریخی شماره حذف‌شده را با `withTrashed` بازیابی می‌کنند. + +وضعیت‌های Task عبارت‌اند از `open`، `in_progress`، `done` و `cancelled`. مسیرهای مجاز lifecycle در سرویس دامنه کنترل می‌شوند و تمام mutationها `version` را افزایش می‌دهند. ویرایش با version قدیمی پاسخ استاندارد `409 VERSION_CONFLICT` می‌دهد. + +## API اصلی + +- `GET/POST /api/tasks` و `GET/PATCH/DELETE /api/tasks/{id}` +- `POST /api/tasks/{id}/assign|start|complete|reopen|cancel` +- `POST /api/tasks/bulk-assign` و `POST /api/tasks/bulk-complete` (اتمیک) +- `GET /api/users/assignable?context=task&search=...` (فقط کاربران active و مجاز؛ حداکثر ۲۰ نتیجه) +- `GET/POST /api/calls/{call}/notes` +- `PATCH/DELETE /api/notes/{note}` و `POST /api/notes/{note}/pin|unpin` +- `GET /api/notifications`، `PATCH /api/notifications/read-all` و `PATCH /api/notifications/{id}/read` + +فهرست Task فیلترهای status، priority، assignee، creator، بازه موعد، overdue، entity، search، sort و pagination را می‌پذیرد. پاسخ‌های جدید envelope استاندارد `data/meta/links/message` دارند. + +## مجوز و scope + +مجوزهای مستقل view own/team/all، create، assign/reassign، edit own/team، delete، complete، bulk، مدیریت note و pin تعریف شده‌اند. Policy و scope سرور منبع حقیقت‌اند: + +- Admin تمام Taskهای سازمان و کاربران فعال را می‌بیند. +- Supervisor فقط Task و کاربران تیم خودش (به‌علاوه خودش) را مدیریت می‌کند. +- Agent فقط Taskهای خود را می‌بیند و نمی‌تواند Task را به کاربر دیگر تخصیص دهد. +- visibility خصوصی فقط برای مشارکت‌کننده مجاز است و IDOR با `403` بسته می‌شود. + +## مهاجرت و backfill + +قبل از استقرار backup بگیرید، سپس: + +```powershell +cd backend +php artisan migrate --force +php artisan permissions:sync-defaults +php artisan call-notes:backfill +``` + +migration و فرمان backfill از `source_key=legacy_call:{id}` استفاده می‌کنند؛ اجرای تکراری Note دوم نمی‌سازد. `calls.notes` حذف یا بازنویسی نمی‌شود و فقط به‌عنوان legacy read-only باقی می‌ماند. Noteهای نتیجه تماس جدید مستقیماً به تاریخچه افزوده می‌شوند. + +## Scheduler و صف + +Scheduler هر ۱۵ دقیقه reminderهای Task و Follow-up را بررسی می‌کند. Taskهای done/cancelled اعلان نمی‌گیرند و idempotency key از تکرار reminder/overdue جلوگیری می‌کند. + +```powershell +php artisan schedule:work +php artisan queue:work +``` + +در production این دو process را با Supervisor/systemd یا سرویس مشابه پایدار کنید. + +## Rollback + +برای بازگشت سه migration P1 در آخرین batch: + +```powershell +php artisan migrate:rollback --step=3 --force +``` + +Rollback جدول Task و ستون‌های افزوده را حذف می‌کند و Noteهای backfillشده با کلید legacy پاک می‌شوند؛ متن اصلی در `calls.notes` باقی است. تغییر `notes.user_id` به nullable/`nullOnDelete` عمداً به cascade قدیمی برنمی‌گردد تا تاریخچه با حذف کاربر از بین نرود. در production rollback را فقط همراه backup و بررسی batch اجرا کنید. + +## کنترل کیفیت + +```powershell +cd backend +php artisan test + +cd ..\frontend +npm run lint +npm run test:run +npm run build +npm run test:e2e +``` + +تست‌های Feature، ماتریس نقش و IDOR، lifecycle و conflict، bulk transaction، note و visibility، backfill، notification/reminder و شماره تاریخی را پوشش می‌دهند. E2E مسیر ایجاد و تخصیص Task توسط Supervisor را در مرورگر پوشش می‌دهد. diff --git a/docs/P2_PROFESSIONAL_CRM_FA.md b/docs/P2_PROFESSIONAL_CRM_FA.md new file mode 100644 index 0000000..dcfbce9 --- /dev/null +++ b/docs/P2_PROFESSIONAL_CRM_FA.md @@ -0,0 +1,100 @@ +# راهنمای فاز P2 — CRM حرفه‌ای + +این فاز قابلیت‌های حرفه‌ای فروش را روی پایه امن P0/P1 اضافه می‌کند. داشبوردهای نقش‌محور حذف یا جایگزین نشده‌اند و مسیر `/` همچنان پس از ورود، داشبورد مجاز کاربر را نمایش می‌دهد. + +## قابلیت‌های تحویل‌شده + +- داشبورد: حفظ داشبوردهای Admin/Supervisor/Agent، refresh دستی و دوره‌ای، loading و error/retry واقعی، و ترجیحات شخصی ویجت‌ها. +- فرصت فروش: چند پایپ‌لاین و چند مرحله، برد کانبان، مجموع ارزش مرحله، ارزش وزنی، workspace جزئیات، تاریخچه مرحله، بستن موفق/ناموفق با دلیل و مبلغ نهایی، و optimistic locking با `version`. +- بهره‌وری: جست‌وجوی سراسری scopeشده برای Lead/Deal/Company/Contact/Call، نماهای ذخیره‌شده private/team/public، و ترجیحات اعلان و داشبورد. +- هوشمندی: امتیازدهی deterministic لید با breakdown و سطح cold/warm/hot، SLA برای اولین تماس، پیگیری و فرصت راکد، تشخیص idempotent و اعلان قابل mute. +- پیکربندی: اتوماسیون محدود و قابل audit برای ایجاد Task، اعلان و تغییر اولویت لید؛ triggerهای واقعی `lead_created`، `lead_scored`، `deal_stage_changed` و `sla_breached`؛ سقف اجرا و event key ضدتکرار. +- فیلد سفارشی: تعریف فیلد برای Lead/Deal/Company/Contact با نوع‌های متنی، عددی، تاریخ، boolean و انتخابی؛ ذخیره typed و indexپذیر و کنترل visibility نقش. +- کیفیت و اسکریپت: strengths/improvement areas، acknowledgement کارشناس و پاسخ او، metadata اسکریپت شامل category/source/questions/disclosures/template و جست‌وجوی سروری. +- گزارش و اعلان: گزارش عملیات فروش شامل pipeline/forecast/won/SLA/task، فیلتر اعلان خوانده‌نشده و لینک ترجیحات. + +## مسیرهای UI + +- `/` داشبورد نقش‌محور و صفحه پیش‌فرض +- `/deals` برد فرصت‌ها +- `/deals/{id}` workspace فرصت و تاریخچه مرحله +- `/operations` مرکز SLA، اتوماسیون، فیلد سفارشی و ترجیحات +- `/reports` تب «عملیات فروش و SLA» +- `/sales-scripts` اسکریپت‌های توسعه‌یافته +- `/quality-reviews` ارزیابی و acknowledgement + +جست‌وجوی سراسری در Header با `Ctrl+K` در دسترس است. + +## APIهای اصلی + +| حوزه | Endpointهای اصلی | +|---|---| +| Pipeline | `GET /api/pipelines`, `POST /api/pipelines`, `GET /api/pipelines/{id}/board`, `PATCH /api/deals/{id}/stage` | +| Search/View | `GET /api/global-search`, `GET/POST/DELETE /api/saved-views` | +| Preferences | `GET/PUT /api/workspace-preferences` | +| Scoring/SLA | `POST /api/leads/{id}/score`, `POST /api/leads/bulk-score`, `GET/POST /api/sla-rules`, `POST /api/sla/detect`, `PATCH /api/sla-breaches/{id}/resolve` | +| Automation | `GET/POST /api/automations`, `POST /api/automations/{id}/run`, `GET /api/automation-runs` | +| Custom fields | `GET/POST /api/custom-fields`, `GET/PUT /api/custom-field-values/{type}/{id}` | +| QA | `POST /api/quality-reviews/{id}/acknowledge` | +| Reports | `GET /api/reports/operations` | + +تمام endpointهای رکوردی مجوز و scope مالک/تیم را در backend اعمال می‌کنند. مخفی‌کردن کنترل در UI جایگزین authorization سرور نیست. + +## مجوزهای جدید + +`view_pipelines`, `manage_pipelines`, `move_deals`, `close_deals`, `manage_saved_views`, `share_team_views`, `use_global_search`, `score_leads`, `view_sla`, `manage_sla`, `manage_automations`, `view_automation_logs`, `manage_custom_fields`, `acknowledge_quality_reviews`, `manage_dashboard_preferences`, `manage_notification_preferences`. + +بعد از deploy این فرمان را اجرا کنید تا permissionهای جدید ساخته و نقش‌های پیش‌فرض همگام شوند: + +```powershell +php artisan permissions:sync-defaults +``` + +## Scheduler و عملیات + +پایش SLA هر ۱۵ دقیقه در scheduler ثبت شده و از `withoutOverlapping` و event key یکتا استفاده می‌کند. اجرای دستی و idempotent: + +```powershell +php artisan sla:monitor +``` + +در production اجرای `php artisan schedule:run` در هر دقیقه الزامی است. اعلان SLA ترجیح `notification_type=sla` را رعایت می‌کند. + +## مهاجرت و rollback + +پنج migration این فاز با prefixهای `030000` تا `034000` به‌ترتیب pipeline، preferences، scoring/SLA، automation/custom fields و QA/scripts را ایجاد می‌کنند. + +پیش از migration از دیتابیس backup بگیرید: + +```powershell +php artisan migrate --force +php artisan permissions:sync-defaults +``` + +برای rollback کامل P2 در محیط کنترل‌شده، آخرین پنج migration را برگردانید: + +```powershell +php artisan migrate:rollback --step=5 --force +``` + +Rollback جدول‌ها و ستون‌های P2 را حذف می‌کند؛ در production بازیابی داده‌های pipeline/history/custom fields باید از backup انجام شود. ابتدا روی staging تمرین شود. + +## کنترل کیفیت + +```powershell +cd backend +php artisan test +vendor\bin\pint --test + +cd ..\frontend +npm run lint +npm run test:run +npm run build +npm run test:e2e +``` + +تست `ProfessionalCrmP2Test` scope تیم، بستن فرصت و version conflict، audit، search/view/preferences، scoring/SLA، idempotency اتوماسیون، typed custom field و acknowledgement QA را پوشش می‌دهد. تست کامپوننت rollback کانبان و خطای جست‌وجو و Playwright مسیر جست‌وجو تا جابه‌جایی فرصت را پوشش می‌دهند. + +## محدودیت‌های عمدی + +Automation یک DSL محدود است و PHP/SQL دلخواه اجرا نمی‌کند. actionهای مجاز فقط `create_task`، `notify` و `set_lead_priority` هستند. برای افزودن action جدید، validation، authorization، audit و تست idempotency هم‌زمان توسعه داده شوند. diff --git a/docs/PERFORMANCE_AUDIT_FA.md b/docs/PERFORMANCE_AUDIT_FA.md new file mode 100644 index 0000000..c84f54e --- /dev/null +++ b/docs/PERFORMANCE_AUDIT_FA.md @@ -0,0 +1,36 @@ +# ممیزی کارایی CRM + +تاریخ: ۱۴۰۵/۰۵/۰۱ + +## خط مبنا + +- بارگذاری مسیرها از قبل به‌صورت code-split بود، اما loading محتوایی با spinner یا متن ساده نمایش داده می‌شد. +- داده‌های مرجع پرتکرار مانند کارشناسان، نتایج تماس و پایپ‌لاین در چند صفحه و حتی هم‌زمان دوباره درخواست می‌شدند. +- قیف، KPI، داشبورد و گزارش‌ها پس از mutation محلی از یک کانال مشترک invalidation استفاده نمی‌کردند. +- PDF.js به chunk مستقل محدود بود؛ این رفتار باید حفظ می‌شد تا مسیرهای عادی هزینه PDF را نپردازند. +- indexهای ترکیبی اصلی برای وظایف، پیگیری‌ها، تماس‌ها، اعلان‌ها و معاملات در migrationهای موجود حاضر بودند؛ index تکراری اضافه نشد. + +## اصلاحات انجام‌شده + +- cache حافظه‌ای ۶۰ ثانیه‌ای همراه با deduplication درخواست‌های هم‌زمان برای داده‌های مرجع اضافه شد. +- هر mutation موفق، cache را invalid می‌کند و رویداد مشترک `crm:data-changed` را در همان tab و tabهای دیگر منتشر می‌کند. +- قیف لید، برد فرصت، داشبوردها و گزارش‌ها با رویداد تغییر داده فوراً refresh می‌شوند؛ polling ده تا پانزده ثانیه‌ای نیز برای تغییرات کاربران دیگر باقی مانده است. +- KPI فرصت‌ها از همان `DealPipelineService` خوانده می‌شود و بعد از انتقال کارت به‌صورت optimistic و سپس با داده سرور اصلاح می‌شود. +- همه loadingهای محتوایی اصلی با Skeleton هم‌اندازه جایگزین شدند؛ spinner فقط برای اکشن‌های کوتاه باقی می‌ماند. +- فیلترها و داده‌های وابسته به‌صورت موازی دریافت می‌شوند و route-level lazy loading حفظ شد. + +## کنترل بسته تولید + +آخرین Build موفق: + +- ورودی اصلی: حدود ۲۴۰ کیلوبایت خام و ۷۷ کیلوبایت gzip +- PDF.js: chunk مستقل حدود ۴۲۵ کیلوبایت خام و ۱۲۷ کیلوبایت gzip +- PDF worker: فایل مستقل و فقط در جریان قالب فاکتور +- ۱۸۴ ماژول با Build تولید بدون خطای TypeScript + +## نتیجه قابل سنجش + +- چند مصرف‌کننده هم‌زمان یک داده مرجع: یک درخواست شبکه به‌جای چند درخواست +- مراجعه به صفحات دارای داده مرجع در بازه cache: صفر درخواست تکراری +- mutation در همان مرورگر: refresh وابستگی‌ها بلافاصله، بدون انتظار برای polling بعدی +- تغییر از کاربر/مرورگر دیگر: حداکثر ۱۰ ثانیه در گزارش و ۱۵ ثانیه در داشبورد diff --git a/frontend/e2e/dark-mode-lead.spec.ts b/frontend/e2e/dark-mode-lead.spec.ts new file mode 100644 index 0000000..bf30719 --- /dev/null +++ b/frontend/e2e/dark-mode-lead.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from './fixtures' + +test('dark mode keeps lead workspace, dropdowns and modal surfaces readable', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('theme', 'dark')) + await page.setViewportSize({ width: 1440, height: 900 }) + + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()) + if (!url.pathname.startsWith('/api/')) return route.continue() + if (url.pathname === '/api/auth/me') return route.fulfill({ json: { data: { + id: 1, name: 'مدیر تست', email: 'admin@example.test', roles: ['admin'], + permissions: ['view_leads', 'create_calls', 'assign_leads'], is_active: true, + } } }) + if (url.pathname === '/api/leads/1') return route.fulfill({ json: { + id: 1, company: 'شرکت تست دارک', first_name: 'مینا', last_name: 'احمدی', phone: '09120000000', + notes: [], contacts: [], contactRelations: [], callLogs: [], call_attempts: 2, + assignee: { id: 2, name: 'کارشناس تست' }, + } }) + if (url.pathname === '/api/global-search') return route.fulfill({ json: { leads: [{ id: 1, company: 'شرکت تست دارک' }], deals: [], companies: [], contacts: [], calls: [] } }) + if (url.pathname === '/api/calls' || url.pathname === '/api/follow-ups') return route.fulfill({ json: { data: [], meta: { current_page: 1, last_page: 1, per_page: 50, total: 0 } } }) + if (url.pathname === '/api/call-results') return route.fulfill({ json: [{ id: 1, name: 'پاسخ نداد', color: '#64748b', is_positive: false, is_negative: true, is_final: false, requires_follow_up: false }] }) + if (url.pathname === '/api/timeline' || url.pathname === '/api/attachments') return route.fulfill({ json: { data: [] } }) + if (url.pathname === '/api/users/referral-targets') return route.fulfill({ json: [] }) + if (url.pathname === '/api/users') return route.fulfill({ json: { data: [] } }) + if (url.pathname === '/api/notifications/unread-count') return route.fulfill({ json: { data: 0 } }) + if (url.pathname === '/api/settings/public') return route.fulfill({ json: { data: {} } }) + return route.fulfill({ json: { data: [] } }) + }) + + await page.goto('/leads/1') + await expect(page.getByRole('heading', { name: 'شرکت تست دارک' })).toBeVisible() + await expect(page.locator('html')).toHaveClass(/dark/) + + await page.getByRole('button', { name: /مدیر تست/ }).click() + await expect(page.getByRole('dialog', { name: 'پروفایل من' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'حساب کاربری' })).toHaveAttribute('aria-selected', 'true') + await page.getByRole('button', { name: 'بستن' }).click() + + await page.getByRole('textbox', { name: 'جست‌وجوی سراسری' }).fill('شرکت') + const searchResult = page.getByRole('dialog', { name: 'نتایج جست‌وجو' }).getByRole('button').first() + await expect(searchResult).toBeVisible() + await searchResult.hover() + expect(await searchResult.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe('rgb(255, 255, 255)') + + await page.keyboard.press('Escape') + await page.getByRole('button', { name: 'ثبت تماس' }).click() + const dialog = page.getByRole('dialog', { name: 'ثبت تماس جدید' }) + await expect(dialog).toBeVisible() + expect(await dialog.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toContain('rgba') + await expect(dialog.getByRole('combobox', { name: 'شماره تماس' })).toHaveCSS('color', 'rgb(248, 250, 252)') + + await page.getByRole('button', { name: 'بستن' }).click() + await page.setViewportSize({ width: 375, height: 812 }) + await page.getByRole('tab', { name: /مخاطبین/ }).click() + await expect(page.getByRole('heading', { name: 'مخاطبین مرتبط با لید' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) +}) diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts new file mode 100644 index 0000000..05e9ddc --- /dev/null +++ b/frontend/e2e/fixtures.ts @@ -0,0 +1,42 @@ +import { + expect, + test as base, + type Request, + type Response, +} from '@playwright/test' + +type AutomaticFixtures = { + enforceApiHealth: void +} + +export const test = base.extend({ + enforceApiHealth: [async ({ page }, use) => { + const failures: string[] = [] + + const recordFailedRequest = (request: Request) => { + if (isApiUrl(request.url())) { + failures.push(`${request.method()} ${request.url()} failed: ${request.failure()?.errorText ?? 'unknown error'}`) + } + } + const recordServerError = (response: Response) => { + if (isApiUrl(response.url()) && response.status() >= 500) { + failures.push(`${response.request().method()} ${response.url()} returned ${response.status()}`) + } + } + + page.on('requestfailed', recordFailedRequest) + page.on('response', recordServerError) + await use() + page.off('requestfailed', recordFailedRequest) + page.off('response', recordServerError) + + expect(failures, `Unexpected API failures:\n${failures.join('\n')}`).toEqual([]) + }, { auto: true }], +}) + +export { expect } + +function isApiUrl(value: string) { + const pathname = new URL(value).pathname + return pathname.startsWith('/api/') || pathname.startsWith('/sanctum/') +} diff --git a/frontend/e2e/invoice-word-wizard.spec.ts b/frontend/e2e/invoice-word-wizard.spec.ts new file mode 100644 index 0000000..75742e4 --- /dev/null +++ b/frontend/e2e/invoice-word-wizard.spec.ts @@ -0,0 +1,70 @@ +import { expect, test } from './fixtures' + +test('creates an invoice through the wizard and downloads an editable Word file', async ({ page }) => { + let savedPayload: Record | null = null + + await page.route('**/api/**', async (route) => { + const request = route.request() + const url = new URL(request.url()) + if (!url.pathname.startsWith('/api/')) return route.continue() + if (url.pathname === '/api/auth/me') return route.fulfill({ json: { data: { + id: 1, name: 'فروشنده تست', email: 'seller@example.test', roles: ['admin'], + permissions: ['view_admin_dashboard', 'view_invoices', 'create_invoices'], is_active: true, + } } }) + if (url.pathname === '/api/settings/public') return route.fulfill({ json: { data: {} } }) + if (url.pathname === '/api/notifications/unread-count') return route.fulfill({ json: { data: 0 } }) + if (url.pathname === '/api/leads/7') return route.fulfill({ json: { + id: 7, first_name: 'علی', last_name: 'خریدار', company: 'شرکت نمونه', phone: '09120000000', + email: 'buyer@example.test', national_code: '0012345678', province: 'تهران', city: 'تهران', + address: 'خیابان نمونه', final_result: 'موفق', sold_product: 'خدمت مشاوره', deal_value: 500000, + } }) + if (url.pathname === '/api/leads/7/invoice' && request.method() === 'POST') { + savedPayload = request.postDataJSON() + return route.fulfill({ status: 201, json: { + id: 51, number: 'INV-2026-000051', lead_id: 7, status: 'pending_approval', + currency: 'IRR', customer_snapshot: (savedPayload as any).customer_snapshot, + seller_snapshot: (savedPayload as any).seller_snapshot, items: (savedPayload as any).items, + lead_snapshot: {}, resolved_fields: {}, subtotal: 500000, discount: 0, tax: 0, + total: 500000, paid_amount: 0, payment_status: 'unpaid', balance_due: 500000, + notes: null, version: 1, created_at: '', updated_at: '', + capabilities: { update: true, approve: true, issue: true, void: false }, + } }) + } + if (url.pathname === '/api/invoices/51/word') return route.fulfill({ + status: 200, + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + headers: { 'Content-Disposition': 'attachment; filename="invoice-INV-2026-000051.docx"' }, + body: Buffer.from('PK-test-docx'), + }) + if (url.pathname === '/api/invoices-summary') return route.fulfill({ json: { total: 0, counts: {}, issued_total: 0, paid_total: 0, outstanding_total: 0, currency: 'IRR' } }) + if (url.pathname === '/api/invoices') return route.fulfill({ json: paginated([]) }) + return route.fulfill({ json: { data: [] } }) + }) + + await page.goto('/invoices?create_from=7') + await expect(page.getByRole('heading', { name: 'ایجاد فاکتور' })).toBeVisible() + await expect(page.getByText('فروشنده تست')).toBeVisible() + + await page.getByRole('button', { name: 'ادامه' }).click() + await expect(page.getByText('مشخصات خریدار')).toBeVisible() + await expect(page.getByLabel('نام خریدار')).toHaveValue('علی خریدار') + await page.getByRole('button', { name: 'ادامه' }).click() + await expect(page.getByLabel('شرح کالا یا خدمت')).toHaveValue('خدمت مشاوره') + await page.getByRole('button', { name: 'ادامه' }).click() + await page.getByLabel('شرایط پرداخت').fill('پرداخت طی هفت روز') + await page.getByRole('button', { name: 'ادامه' }).click() + + const download = page.waitForEvent('download') + await page.getByRole('button', { name: 'ثبت و دریافت Word' }).click() + const file = await download + expect(file.suggestedFilename()).toContain('.docx') + await expect.poll(() => savedPayload).not.toBeNull() + expect((savedPayload as any).page_width_mm).toBe(210) + expect((savedPayload as any).page_height_mm).toBe(297) + expect((savedPayload as any).payment_terms).toBe('پرداخت طی هفت روز') + expect((savedPayload as any).items[0].unit).toBe('عدد') +}) + +function paginated(items: Record[]) { + return { data: items, current_page: 1, last_page: 1, per_page: 15, total: items.length, from: items.length ? 1 : 0, to: items.length } +} diff --git a/frontend/e2e/lead-layout.spec.ts b/frontend/e2e/lead-layout.spec.ts new file mode 100644 index 0000000..a07e7ab --- /dev/null +++ b/frontend/e2e/lead-layout.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from './fixtures' + +test.beforeEach(async ({ page }) => { + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()) + if (!url.pathname.startsWith('/api/')) return route.continue() + + if (url.pathname === '/api/auth/me') { + return route.fulfill({ json: { data: { + id: 1, + name: 'مدیر تست', + email: 'admin@example.test', + roles: ['admin'], + permissions: ['view_leads', 'create_calls', 'assign_leads'], + is_active: true, + } } }) + } + + if (url.pathname === '/api/leads/1') { + return route.fulfill({ json: { + id: 1, + company: 'شرکت تست چیدمان', + first_name: 'مینا', + last_name: 'احمدی', + phone: '09120000000', + notes: [], + contacts: [], + contactRelations: [], + callLogs: [], + call_attempts: 2, + assignee: { id: 2, name: 'کارشناس تست' }, + } }) + } + + if (url.pathname === '/api/calls' || url.pathname === '/api/follow-ups') { + return route.fulfill({ json: { data: [], meta: { current_page: 1, last_page: 1, per_page: 50, total: 0 } } }) + } + + if (url.pathname === '/api/call-results') return route.fulfill({ json: [] }) + if (url.pathname === '/api/timeline' || url.pathname === '/api/attachments') return route.fulfill({ json: { data: [] } }) + if (url.pathname === '/api/users/referral-targets') return route.fulfill({ json: [] }) + if (url.pathname === '/api/users') return route.fulfill({ json: { data: [] } }) + if (url.pathname === '/api/notifications/unread-count') return route.fulfill({ json: { data: 0 } }) + if (url.pathname === '/api/settings/public') return route.fulfill({ json: { data: {} } }) + + return route.fulfill({ json: { data: [] } }) + }) +}) + +test('lead header stays compact and does not cover content in a short desktop viewport', async ({ page }) => { + await page.setViewportSize({ width: 1917, height: 375 }) + await page.goto('/leads/1') + + const appHeader = page.getByTestId('app-header') + const actionBar = page.getByTestId('lead-action-bar') + const scrollContainer = page.getByTestId('app-scroll-container') + const summaryLabel = page.getByText('مخاطب اصلی برای تماس بعدی') + + await expect(page.getByRole('heading', { name: 'شرکت تست چیدمان' })).toBeVisible() + await actionBar.evaluate(async (element) => { + const animations = element.parentElement?.getAnimations() ?? [] + await Promise.all(animations.map((animation) => animation.finished)) + }) + + const initialHeaderBox = await appHeader.boundingBox() + const initialActionBox = await actionBar.boundingBox() + const initialSummaryBox = await summaryLabel.boundingBox() + + expect(initialHeaderBox).not.toBeNull() + expect(initialActionBox).not.toBeNull() + expect(initialSummaryBox).not.toBeNull() + expect(initialHeaderBox!.height).toBeLessThanOrEqual(65) + expect(initialActionBox!.height).toBeLessThanOrEqual(65) + expect(Math.abs(initialActionBox!.y - (initialHeaderBox!.y + initialHeaderBox!.height))).toBeLessThanOrEqual(1) + expect(initialSummaryBox!.y).toBeGreaterThanOrEqual(initialActionBox!.y + initialActionBox!.height) + + await scrollContainer.evaluate((element) => { element.scrollTop = 320 }) + await expect.poll(async () => (await actionBar.boundingBox())?.y).toBe(initialHeaderBox!.y + initialHeaderBox!.height) + + const stickyActionBox = await actionBar.boundingBox() + const followupsBox = await page.getByTestId('lead-followups').boundingBox() + expect(stickyActionBox).not.toBeNull() + expect(followupsBox).not.toBeNull() + expect(followupsBox!.y).toBeGreaterThanOrEqual(stickyActionBox!.y + stickyActionBox!.height + 15) +}) + +test('lead actions remain in normal flow on mobile', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('/leads/1') + + const actionBar = page.getByTestId('lead-action-bar') + const scrollContainer = page.getByTestId('app-scroll-container') + const initialY = (await actionBar.boundingBox())?.y + + expect(initialY).toBeDefined() + await scrollContainer.evaluate((element) => { element.scrollTop = 240 }) + await expect.poll(async () => (await actionBar.boundingBox())?.y).toBeLessThan(initialY!) +}) diff --git a/frontend/e2e/mobile-shell-calendar.spec.ts b/frontend/e2e/mobile-shell-calendar.spec.ts new file mode 100644 index 0000000..4671c1e --- /dev/null +++ b/frontend/e2e/mobile-shell-calendar.spec.ts @@ -0,0 +1,127 @@ +import { expect, test } from './fixtures' + +test.use({ viewport: { width: 390, height: 844 } }) + +test('mobile shell exposes floating navigation, notification sheet, calendar and date picker', async ({ page }) => { + const now = new Date().toISOString() + await page.route('**/api/**', async (route) => { + const request = route.request() + const url = new URL(request.url()) + if (!url.pathname.startsWith('/api/')) return route.continue() + if (url.pathname === '/api/auth/me') return route.fulfill({ json: { data: { + id: 11, name: 'کارشناس موبایل', email: 'agent@example.test', phone: null, avatar: null, + roles: ['agent'], permissions: ['view_agent_dashboard', 'view_leads', 'view_own_tasks', 'create_tasks'], + is_active: true, team_id: 2, created_at: '', updated_at: '', + } } }) + if (url.pathname === '/api/settings/public') return route.fulfill({ json: { data: {} } }) + if (url.pathname === '/api/notifications/unread-count') return route.fulfill({ json: { data: 1 } }) + if (url.pathname === '/api/notifications') return route.fulfill({ json: paginated([{ + id: 81, type: 'task_assigned', title: 'کار جدید', message: 'پیگیری قرارداد', data: { url: '/tasks' }, + is_read: false, read_at: null, created_at: now, + }]) }) + if (url.pathname === '/api/calendar/events') return route.fulfill({ json: { events: [{ + id: 'task-31', entity_id: 31, type: 'task', title: 'پیگیری قرارداد', starts_at: now, + status: 'open', priority: 'normal', assignee: { id: 11, name: 'کارشناس موبایل' }, related: null, url: '/tasks', + }] } }) + if (url.pathname === '/api/tasks') return route.fulfill({ json: paginated([]) }) + if (url.pathname === '/api/users/assignable') return route.fulfill({ json: { data: [] } }) + return route.fulfill({ json: { data: {} } }) + }) + + await page.goto('/tasks') + await expect(page.getByRole('navigation', { name: 'ناوبری اصلی موبایل' })).toBeVisible() + await expect(page.locator('aside')).toBeHidden() + await expect(page.getByRole('button', { name: 'تقویم کارها و پیگیری‌ها' })).toBeVisible() + await page.getByRole('button', { name: 'تقویم کارها و پیگیری‌ها' }).click() + await expect(page).toHaveURL(/\/calendar$/) + await page.getByRole('tab', { name: 'برنامه روز' }).click() + await expect(page.getByRole('button').filter({ hasText: 'پیگیری قرارداد' }).last()).toBeVisible() + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth === document.documentElement.clientWidth)).toBe(true) + + const calendarDialog = page.getByRole('dialog', { name: 'تقویم کارها و پیگیری‌ها' }) + await calendarDialog.getByRole('button', { name: 'بستن' }).click() + await expect(calendarDialog).toBeHidden() + await expect(page).toHaveURL(/\/tasks$/) + + await page.getByRole('button', { name: /اعلان خوانده‌نشده/ }).click() + const notificationDialog = page.getByRole('dialog', { name: 'اعلان‌ها' }) + await expect(notificationDialog).toBeVisible() + await expect(notificationDialog.getByText('کار جدید')).toBeVisible() + const notificationBox = await notificationDialog.boundingBox() + expect(notificationBox?.x).toBeGreaterThanOrEqual(12) + expect(notificationBox ? 390 - notificationBox.x - notificationBox.width : 0).toBeGreaterThanOrEqual(12) + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog', { name: 'اعلان‌ها' })).toBeHidden() + + await expect(page.getByRole('heading', { name: 'مرکز کارها' })).toBeVisible() + await page.getByRole('button', { name: 'کار جدید' }).click() + await page.getByLabel('موضوع کار').fill('کار آزمایشی') + await page.getByRole('button', { name: 'ادامه' }).click() + await page.getByRole('button', { name: /^موعد:/ }).click() + await expect(page.getByRole('dialog', { name: 'موعد' })).toBeVisible() + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth === document.documentElement.clientWidth)).toBe(true) + await page.getByRole('button', { name: 'امروز' }).click() + await expect(page.getByRole('button', { name: /^موعد:/ })).not.toContainText('انتخاب تاریخ و ساعت') +}) + +test('selected calendar day number stays visible in dark mode', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('theme', 'dark')) + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()) + if (!url.pathname.startsWith('/api/')) return route.continue() + if (url.pathname === '/api/auth/me') return route.fulfill({ json: { data: { + id: 11, name: 'کارشناس موبایل', email: 'agent@example.test', roles: ['agent'], + permissions: ['view_agent_dashboard', 'view_leads', 'view_own_tasks'], is_active: true, + } } }) + if (url.pathname === '/api/settings/public') return route.fulfill({ json: { data: {} } }) + if (url.pathname === '/api/notifications/unread-count') return route.fulfill({ json: { data: 0 } }) + if (url.pathname === '/api/calendar/events') return route.fulfill({ json: { events: [] } }) + return route.fulfill({ json: { data: [] } }) + }) + + await page.goto('/calendar') + const selectedNumber = page.locator('button[aria-pressed="true"] > span').first() + await expect(selectedNumber).toBeVisible() + const colors = await selectedNumber.evaluate((element) => { + const style = getComputedStyle(element) + return { foreground: style.color, background: style.backgroundColor, text: element.textContent?.trim() } + }) + expect(colors.text).toBeTruthy() + expect(colors.background).not.toBe('rgba(0, 0, 0, 0)') + expect(colors.foreground).not.toBe(colors.background) +}) + +test('desktop calendar keeps the first event readable inside its day cell', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 820 }) + const now = new Date().toISOString() + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()) + if (!url.pathname.startsWith('/api/')) return route.continue() + if (url.pathname === '/api/auth/me') return route.fulfill({ json: { data: { + id: 11, name: 'کارشناس تقویم', email: 'agent@example.test', roles: ['agent'], + permissions: ['view_agent_dashboard', 'view_leads', 'view_own_tasks'], is_active: true, + } } }) + if (url.pathname === '/api/settings/public') return route.fulfill({ json: { data: {} } }) + if (url.pathname === '/api/notifications/unread-count') return route.fulfill({ json: { data: 0 } }) + if (url.pathname === '/api/calendar/events') return route.fulfill({ json: { events: [ + { id: 'task-1', entity_id: 1, type: 'task', title: 'رویداد قابل خواندن', starts_at: now, status: 'open', priority: 'normal', assignee: null, related: null, url: '/tasks' }, + { id: 'follow-2', entity_id: 2, type: 'follow_up', title: 'پیگیری دوم', starts_at: now, status: 'pending', assignee: null, related: null, url: '/follow-ups' }, + ] } }) + return route.fulfill({ json: { data: [] } }) + }) + + await page.goto('/calendar') + const event = page.locator('span[title="رویداد قابل خواندن"]') + await expect(event).toBeVisible() + await expect(page.getByText('+۱ مورد دیگر')).toBeVisible() + const cell = event.locator('xpath=ancestor::button[1]') + const eventBox = await event.boundingBox() + const cellBox = await cell.boundingBox() + expect(eventBox?.height).toBeGreaterThanOrEqual(12) + expect(eventBox?.y).toBeGreaterThanOrEqual(cellBox?.y ?? 0) + expect((eventBox?.y ?? 0) + (eventBox?.height ?? 0)).toBeLessThanOrEqual((cellBox?.y ?? 0) + (cellBox?.height ?? 0)) +}) + +function paginated(items: Record[]) { + return { data: items, meta: { current_page: 1, last_page: 1, per_page: 20, total: items.length, from: items.length ? 1 : 0, to: items.length } } +} diff --git a/frontend/e2e/reports-layout.spec.ts b/frontend/e2e/reports-layout.spec.ts new file mode 100644 index 0000000..e06130b --- /dev/null +++ b/frontend/e2e/reports-layout.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from './fixtures' + +test('KPI trend keeps the sales-agent leaderboard readable at wide desktop widths', async ({ page }) => { + await page.setViewportSize({ width: 1624, height: 720 }) + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()) + if (!url.pathname.startsWith('/api/')) return route.continue() + if (url.pathname === '/api/auth/me') return route.fulfill({ json: { data: { + id: 1, + name: 'مدیر گزارش', + email: 'admin@example.test', + roles: ['admin'], + permissions: ['view_admin_dashboard', 'view_reports', 'view_pipelines'], + is_active: true, + } } }) + if (url.pathname === '/api/settings/public') return route.fulfill({ json: { data: {} } }) + if (url.pathname === '/api/notifications/unread-count') return route.fulfill({ json: { data: 0 } }) + if (url.pathname === '/api/agents') return route.fulfill({ json: [] }) + if (url.pathname === '/api/campaigns') return route.fulfill({ json: { data: [], current_page: 1, last_page: 1, per_page: 100, total: 0, from: 0, to: 0 } }) + if (url.pathname === '/api/reports/kpi') return route.fulfill({ json: { + range: { date_from: '2026-06-22', date_to: '2026-07-23', working_days: 27 }, + kpis: [], + trend: Array.from({ length: 31 }, (_, index) => ({ date: `2026-07-${String(index + 1).padStart(2, '0')}`, calls: index % 7 === 0 ? 10 : 0, successful_calls: index % 7 === 0 ? 3 : 0, follow_ups: 0, won: 0 })), + leaderboard: [ + { agent_id: 1, agent_name: 'کارشناس فروش شماره یک', calls: 20, successful_calls: 6, conversion_rate: 30, won_value: 0 }, + { agent_id: 2, agent_name: 'کارشناس فروش شماره دو', calls: 10, successful_calls: 2, conversion_rate: 20, won_value: 0 }, + ], + updated_at: '2026-07-23T10:00:00Z', + } }) + return route.fulfill({ json: { data: [] } }) + }) + + await page.goto('/reports') + const leaderboard = page.getByRole('heading', { name: 'رتبه کارشناسان' }).locator('..') + const trend = page.getByRole('heading', { name: 'روند روزانه تماس' }).locator('..') + await expect(leaderboard).toBeVisible() + await expect(page.getByText('کارشناس فروش شماره یک')).toBeVisible() + + const leaderboardBox = await leaderboard.boundingBox() + const trendBox = await trend.boundingBox() + expect(leaderboardBox?.width).toBeGreaterThanOrEqual(280) + expect(trendBox?.width).toBeGreaterThan(leaderboardBox?.width ?? 0) + expect((leaderboardBox?.x ?? -1) + (leaderboardBox?.width ?? 0)).toBeLessThanOrEqual(1624) + + await expect(page.getByRole('link', { name: /فرصت‌های فروش/ })).toHaveCount(0) + await expect(page.getByRole('link', { name: 'گزارشات', exact: true })).toBeVisible() +}) diff --git a/frontend/e2e/task-center.spec.ts b/frontend/e2e/task-center.spec.ts new file mode 100644 index 0000000..12fad32 --- /dev/null +++ b/frontend/e2e/task-center.spec.ts @@ -0,0 +1,204 @@ +import { expect, test } from './fixtures' + +test('supervisor creates an assigned task from Task Center', async ({ page }) => { + let createdTask: Record | null = null + const pageErrors: string[] = [] + page.on('pageerror', (error) => pageErrors.push(error.message)) + page.on('console', (message) => { if (message.type() === 'error') pageErrors.push(message.text()) }) + + await page.route('**/api/**', async (route) => { + const request = route.request() + const url = new URL(request.url()) + if (!url.pathname.startsWith('/api/')) return route.continue() + + if (url.pathname === '/api/auth/me') { + return route.fulfill({ json: { data: { + id: 7, name: 'سرپرست تست', email: 'supervisor@example.test', phone: null, + voip_extension: null, avatar: null, is_active: true, last_login_at: null, + roles: ['supervisor'], team_id: 2, created_at: '', updated_at: '', + permissions: ['view_team_tasks', 'create_tasks', 'assign_tasks', 'bulk_manage_tasks'], + } } }) + } + + if (url.pathname === '/api/users/assignable') { + return route.fulfill({ json: { data: [{ + id: 11, name: 'کارشناس تیم', avatar: null, role_label: 'کارشناس', + team_label: 'فروش', is_active: true, open_tasks_count: 2, + }] } }) + } + + if (url.pathname === '/api/tasks' && request.method() === 'POST') { + const body = request.postDataJSON() + createdTask = task({ id: 31, subject: body.subject, assigned_to: body.assigned_to }) + return route.fulfill({ status: 201, json: { data: createdTask } }) + } + + if (url.pathname === '/api/tasks') { + const tasks = createdTask ? [createdTask] : [] + return route.fulfill({ json: { data: tasks, meta: { + current_page: 1, last_page: 1, per_page: 20, total: tasks.length, + from: tasks.length ? 1 : 0, to: tasks.length, + } } }) + } + + if (url.pathname === '/api/notifications/unread-count') return route.fulfill({ json: { data: { count: 0 } } }) + if (url.pathname === '/api/settings/public') return route.fulfill({ json: { data: {} } }) + return route.fulfill({ json: { data: [] } }) + }) + + await page.goto('/tasks') + await page.waitForTimeout(500) + expect(pageErrors, `Browser errors: ${pageErrors.join(' | ')}`).toEqual([]) + await expect(page).toHaveURL(/\/tasks$/) + expect(await page.locator('body').innerText()).toContain('مرکز کارها') + await page.getByRole('button', { name: 'کار جدید' }).click() + await page.getByLabel('موضوع کار').fill('پیگیری قرارداد آزمایشی') + await page.getByText('کارشناس تیم').click() + await page.getByRole('button', { name: 'ادامه' }).click() + await page.getByRole('button', { name: 'ایجاد کار' }).click() + + await expect(page.getByText('پیگیری قرارداد آزمایشی')).toBeVisible() +}) + +test('agent opens an assignment notification, starts and completes the task, then supervisor sees it', async ({ page }) => { + let role: 'agent' | 'supervisor' = 'agent' + let status: 'open' | 'in_progress' | 'done' = 'open' + let version = 1 + let notificationRead = false + + await page.route('**/api/**', async (route) => { + const request = route.request() + const url = new URL(request.url()) + if (!url.pathname.startsWith('/api/')) return route.continue() + + if (url.pathname === '/api/auth/me') { + return route.fulfill({ json: { data: user(role, role === 'agent' + ? ['view_own_tasks', 'edit_own_tasks', 'complete_tasks'] + : ['view_team_tasks', 'edit_team_tasks', 'complete_tasks']) } }) + } + if (url.pathname === '/api/notifications') { + return route.fulfill({ json: paginated([{ + id: 81, title: 'کار جدید به شما تخصیص یافت', message: 'پیگیری مشتری', + type: 'task_assigned', data: { url: '/tasks?task=31', task_id: 31 }, + is_read: notificationRead, read_at: notificationRead ? new Date().toISOString() : null, + created_at: new Date().toISOString(), + }]) }) + } + if (url.pathname === '/api/notifications/81/read') { + notificationRead = true + return route.fulfill({ json: { data: { read_at: new Date().toISOString() } } }) + } + if (url.pathname === '/api/notifications/unread-count') { + return route.fulfill({ json: { data: notificationRead ? 0 : 1 } }) + } + if (url.pathname === '/api/tasks/31/start') { + status = 'in_progress'; version += 1 + return route.fulfill({ json: { data: task({ id: 31, subject: 'پیگیری مشتری', status, version }) } }) + } + if (url.pathname === '/api/tasks/31/complete') { + status = 'done'; version += 1 + return route.fulfill({ json: { data: task({ id: 31, subject: 'پیگیری مشتری', status, version }) } }) + } + if (url.pathname === '/api/tasks/31') { + return route.fulfill({ json: { data: task({ id: 31, subject: 'پیگیری مشتری', status, version }) } }) + } + if (url.pathname === '/api/tasks') { + return route.fulfill({ json: paginated([task({ id: 31, subject: 'پیگیری مشتری', status, version })]) }) + } + if (url.pathname === '/api/users/assignable') return route.fulfill({ json: { data: [] } }) + return route.fulfill({ json: { data: {} } }) + }) + + await page.goto('/notifications') + await expect(page.getByText('کار جدید به شما تخصیص یافت')).toBeVisible() + await page.getByRole('link', { name: 'باز کردن' }).click() + await expect(page.getByRole('dialog', { name: 'کار: پیگیری مشتری' })).toBeVisible() + await page.getByRole('button', { name: 'شروع کار' }).click() + await expect(page.getByRole('cell', { name: 'در حال انجام' })).toBeVisible() + await page.getByRole('button', { name: 'مشاهده' }).click() + await page.getByRole('button', { name: 'تکمیل کار' }).click() + await page.getByRole('button', { name: 'ادامه و بررسی نهایی' }).click() + await page.getByRole('button', { name: 'اجرای تغییر وضعیت' }).click() + await expect(page.getByRole('cell', { name: 'انجام‌شده' })).toBeVisible() + + role = 'supervisor' + await page.reload() + await expect(page.getByRole('cell', { name: 'انجام‌شده' })).toBeVisible() +}) + +test('agent adds a call note while out-of-scope task and call details stay closed', async ({ page }) => { + let notes: Record[] = [] + let denyDirectAccess = false + const call = { + id: 51, user_id: 11, lead_id: 21, direction: 'outbound', status: 'completed', + duration_seconds: 45, result: 'answered', notes: null, started_at: null, + created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + agent: { id: 11, name: 'کارشناس تیم' }, lead: { id: 21, first_name: 'رضا', last_name: 'محمدی' }, + } + + await page.route('**/api/**', async (route) => { + const request = route.request() + const url = new URL(request.url()) + if (!url.pathname.startsWith('/api/')) return route.continue() + if (url.pathname === '/api/auth/me') return route.fulfill({ json: { data: user('agent', ['view_own_calls', 'view_own_tasks', 'manage_call_notes']) } }) + if (url.pathname === '/api/calls' && !denyDirectAccess) return route.fulfill({ json: paginated([call]) }) + if (url.pathname === '/api/calls/51' && !denyDirectAccess) return route.fulfill({ json: { data: call } }) + if (url.pathname === '/api/calls/51/notes' && request.method() === 'POST') { + const body = request.postDataJSON() + notes = [{ id: 91, content: body.content, type: body.type, visibility: body.visibility, + is_pinned: false, author: { id: 11, name: 'کارشناس تیم' }, edited_at: null, + created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + capabilities: { edit: true, delete: true, pin: false } }] + return route.fulfill({ status: 201, json: { data: notes[0] } }) + } + if (url.pathname === '/api/calls/51/notes') return route.fulfill({ json: { data: notes } }) + if (url.pathname === '/api/tasks/999' || url.pathname === '/api/calls/999') { + return route.fulfill({ status: 403, json: { message: 'Forbidden' } }) + } + if (url.pathname === '/api/tasks') return route.fulfill({ json: paginated([]) }) + if (url.pathname === '/api/calls' && denyDirectAccess) return route.fulfill({ json: paginated([{ ...call, id: 999 }]) }) + if (url.pathname === '/api/notifications/unread-count') return route.fulfill({ json: { data: 0 } }) + return route.fulfill({ json: { data: {} } }) + }) + + await page.goto('/calls') + await page.getByText('رضا محمدی').click() + await page.getByLabel('یادداشت جدید تماس').fill('تعهد به ارسال قرارداد') + await page.getByRole('button', { name: 'ثبت یادداشت' }).click() + await expect(page.getByText('تعهد به ارسال قرارداد')).toBeVisible() + + denyDirectAccess = true + await page.goto('/tasks?task=999') + await expect(page.getByRole('dialog')).toHaveCount(0) + await page.goto('/calls') + await page.getByText('رضا محمدی').click() + await expect(page.getByRole('dialog')).toHaveCount(0) +}) + +function task(overrides: Record) { + return { + id: 1, subject: 'کار', description: null, taskable_type: null, taskable_id: null, + taskable: null, assigned_to: null, assignee: { id: 11, name: 'کارشناس تیم' }, + assigned_by: 7, assigner: { id: 7, name: 'سرپرست تست' }, created_by: 7, + creator: { id: 7, name: 'سرپرست تست' }, priority: 'normal', status: 'open', + due_at: null, started_at: null, completed_at: null, reminder_at: null, + parent_task_id: null, parent: null, estimated_minutes: null, visibility: 'team', + version: 1, is_overdue: false, + capabilities: { update: true, assign: true, transition: true, delete: true }, + created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + ...overrides, + } +} + +function paginated(items: Record[]) { + return { data: items, meta: { current_page: 1, last_page: 1, per_page: 20, total: items.length, from: items.length ? 1 : 0, to: items.length } } +} + +function user(role: 'agent' | 'supervisor', permissions: string[]) { + return { + id: role === 'agent' ? 11 : 7, name: role === 'agent' ? 'کارشناس تیم' : 'سرپرست تست', + email: `${role}@example.test`, phone: null, voip_extension: null, avatar: null, + is_active: true, last_login_at: null, roles: [role], permissions, team_id: 2, + created_at: '', updated_at: '', + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index add4f4d..38ed5ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@hookform/resolvers": "5.4.0", "@tanstack/react-query": "5.101.1", "axios": "1.18.1", + "pdfjs-dist": "^6.1.200", "react": "19.2.7", "react-dom": "19.2.7", "react-hook-form": "7.80.0", @@ -19,16 +20,270 @@ "zustand": "5.0.14" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/vite": "4.3.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "24.13.2", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.3", "concurrently": "^9.2.1", + "jsdom": "^29.1.1", "oxlint": "1.71.0", "tailwindcss": "4.3.1", "typescript": "6.0.3", - "vite": "8.1.0" + "vite": "8.1.0", + "vitest": "^4.1.10" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" } }, "node_modules/@emnapi/core": { @@ -65,6 +320,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@hookform/resolvers": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", @@ -127,6 +400,271 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", + "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.2", + "@napi-rs/canvas-darwin-arm64": "1.0.2", + "@napi-rs/canvas-darwin-x64": "1.0.2", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", + "@napi-rs/canvas-linux-arm64-musl": "1.0.2", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-musl": "1.0.2", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", + "@napi-rs/canvas-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz", + "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz", + "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz", + "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -503,6 +1041,22 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", @@ -785,6 +1339,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -1101,6 +1662,96 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -1112,6 +1763,39 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", @@ -1168,6 +1852,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -1206,6 +2003,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1224,6 +2041,16 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1237,6 +2064,16 @@ "node": ">= 0.4" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1339,6 +2176,13 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1352,6 +2196,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1359,6 +2224,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1376,6 +2255,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1385,6 +2271,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1395,6 +2291,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1430,6 +2334,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1448,6 +2365,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1485,6 +2409,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1678,6 +2622,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1691,6 +2648,16 @@ "node": ">= 6" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1701,6 +2668,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1711,6 +2685,55 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1984,6 +3007,27 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2003,6 +3047,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2024,6 +3075,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2049,6 +3110,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oxlint": { "version": "1.71.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz", @@ -2098,6 +3173,38 @@ } } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pdfjs-dist": { + "version": "6.1.200", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.1.200.tgz", + "integrity": "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2118,6 +3225,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2147,6 +3301,36 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -2156,6 +3340,16 @@ "node": ">=10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -2193,6 +3387,14 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", @@ -2231,6 +3433,20 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2241,6 +3457,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", @@ -2285,6 +3511,19 @@ "tslib": "^2.1.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2310,6 +3549,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2320,6 +3566,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2348,6 +3608,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -2364,6 +3637,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", @@ -2385,6 +3665,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2402,6 +3699,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -2433,6 +3786,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -2518,6 +3881,161 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -2536,6 +4054,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0d8119d..5cdc1b9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,12 +9,16 @@ "dev:frontend": "vite --host 127.0.0.1 --port 5173", "build": "tsc -b && vite build", "lint": "oxlint", + "test": "vitest", + "test:run": "vitest run", + "test:e2e": "npm run build && playwright test", "preview": "vite preview" }, "dependencies": { "@hookform/resolvers": "5.4.0", "@tanstack/react-query": "5.101.1", "axios": "1.18.1", + "pdfjs-dist": "^6.1.200", "react": "19.2.7", "react-dom": "19.2.7", "react-hook-form": "7.80.0", @@ -23,15 +27,21 @@ "zustand": "5.0.14" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/vite": "4.3.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "24.13.2", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.3", "concurrently": "^9.2.1", + "jsdom": "^29.1.1", "oxlint": "1.71.0", "tailwindcss": "4.3.1", "typescript": "6.0.3", - "vite": "8.1.0" + "vite": "8.1.0", + "vitest": "^4.1.10" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..728b2e5 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry', + serviceWorkers: 'block', + launchOptions: process.env.PLAYWRIGHT_EXECUTABLE_PATH + ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } + : undefined, + }, + webServer: { + command: 'npx vite preview --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 98dd938..00b8f6c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { Suspense, lazy, useEffect } from 'react' import { useAuthStore } from '@/stores/authStore' import ProtectedRoute from '@/components/ProtectedRoute' -import Spinner from '@/components/ui/Spinner' +import { PageSkeleton } from '@/components/ui/Skeleton' const AppLayout = lazy(() => import('@/components/layout/AppLayout')) const Login = lazy(() => import('@/pages/Login')) @@ -11,7 +11,7 @@ const SupervisorDashboard = lazy(() => import('@/pages/Dashboard/SupervisorDashb const AgentDashboard = lazy(() => import('@/pages/Dashboard/AgentDashboard')) const LeadList = lazy(() => import('@/pages/Leads/LeadList')) const LeadShow = lazy(() => import('@/pages/Leads/LeadShow')) -const LeadFunnel = lazy(() => import('@/pages/Leads/LeadFunnel')) +const SalesPipelinePage = lazy(() => import('@/pages/Sales/SalesPipelinePage')) const CoreCrmPage = lazy(() => import('@/pages/CoreCrm/CoreCrmPage')) const CampaignList = lazy(() => import('@/pages/Campaigns/CampaignList')) const UserList = lazy(() => import('@/pages/Users/UserList')) @@ -21,6 +21,13 @@ const ReportsIndex = lazy(() => import('@/pages/Reports/ReportsIndex')) const SettingsPage = lazy(() => import('@/pages/Settings/SettingsPage')) const NotificationList = lazy(() => import('@/pages/Notifications/NotificationList')) const ProfilePage = lazy(() => import('@/pages/Profile/ProfilePage')) +const ScriptList = lazy(() => import('@/pages/SalesScripts/ScriptList')) +const QualityReviewList = lazy(() => import('@/pages/QualityReviews/QualityReviewList')) +const TaskCenter = lazy(() => import('@/pages/Tasks/TaskCenter')) +const CalendarPage = lazy(() => import('@/pages/Calendar/CalendarPage')) +const OperationsCenter = lazy(() => import('@/pages/Operations/OperationsCenter')) +const InvoiceCenter = lazy(() => import('@/pages/Invoices/InvoiceCenter')) +const InvoicePrintPage = lazy(() => import('@/pages/Invoices/InvoicePrintPage')) const AgentMobileLayout = lazy(() => import('@/pages/AgentMobile/AgentMobileLayout')) const AgentRouteRedirect = lazy(() => import('@/components/AgentRouteRedirect')) const AgentMobilePages = lazy(() => import('@/pages/AgentMobile/AgentMobilePages')) @@ -64,6 +71,14 @@ export default function App() { } /> + + + + } + /> @@ -72,23 +87,27 @@ export default function App() { } > } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> - } /> + } /> + } /> + } /> + } /> + } /> + } @@ -119,6 +138,8 @@ export default function App() { /> } /> } /> + } /> + } /> } /> @@ -129,16 +150,17 @@ export default function App() { function RouteFallback() { return ( -
- +
+
) } function DashboardRedirect() { - const { user } = useAuthStore() + const { user, can } = useAuthStore() if (!user) return null - if (user.roles?.includes('admin')) return - if (user.roles?.includes('supervisor')) return - return + if (can('view_admin_dashboard')) return + if (can('view_supervisor_dashboard')) return + if (can('view_agent_dashboard')) return + return } diff --git a/frontend/src/api/assignments.ts b/frontend/src/api/assignments.ts index d6ef380..e31eda5 100644 --- a/frontend/src/api/assignments.ts +++ b/frontend/src/api/assignments.ts @@ -9,6 +9,11 @@ export async function assignLeadToAgent(leadId: number, agentId: number): Promis return 'data' in data ? data.data : data } +export async function referLead(leadId: number, userId: number): Promise { + const { data } = await client.post(`/leads/${leadId}/refer`, { user_id: userId }) + return 'data' in data ? data.data : data +} + export async function bulkAssignToAgent(leadIds: number[], agentId: number): Promise<{ assigned: number }> { const { data } = await client.post<{ assigned: number }>('/assignments/bulk-assign', { lead_ids: leadIds, diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index a5797df..0003678 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -25,9 +25,9 @@ export async function logout(): Promise { await client.post('/auth/logout') } -export async function me(): Promise { - const { data } = await client.get<{ data: AuthUserResponse }>('/auth/me') - return normalizeUser(data.data) +export async function me(): Promise { + const { data } = await client.get<{ authenticated?: boolean; data: AuthUserResponse | null }>('/auth/me') + return data.data ? normalizeUser(data.data) : null } export async function updateProfile(payload: { diff --git a/frontend/src/api/calendar.ts b/frontend/src/api/calendar.ts new file mode 100644 index 0000000..5e95870 --- /dev/null +++ b/frontend/src/api/calendar.ts @@ -0,0 +1,19 @@ +import client from './client' + +export interface CalendarEvent { + id: string + entity_id: number + type: 'task' | 'follow_up' + title: string + starts_at: string + status: string + priority: string | null + assignee: { id: number; name: string } | null + related: { type: string; id: number; label?: string } | null + url: string +} + +export async function getCalendarEvents(params: { from: string; to: string; type?: 'task' | 'follow_up' }): Promise { + const { data } = await client.get<{ events: CalendarEvent[] }>('/calendar/events', { params }) + return data.events +} diff --git a/frontend/src/api/calls.ts b/frontend/src/api/calls.ts index 40fe7fa..e93c87b 100644 --- a/frontend/src/api/calls.ts +++ b/frontend/src/api/calls.ts @@ -1,9 +1,16 @@ import client from './client' import type { Call, CallResult, PaginatedResponse } from '@/types' +import { unwrapData, unwrapPaginated } from './normalizers' +import { cachedRequest } from './requestCache' export interface CallFilters { lead_id?: number - caller_id?: number + user_id?: number + search?: string + result?: string + direction?: 'outbound' | 'inbound' + date_from?: string + date_to?: string page?: number per_page?: number } @@ -21,22 +28,24 @@ export interface InitiateCallResponse { export async function getCalls(filters: CallFilters = {}): Promise> { const { data } = await client.get('/calls', { params: filters }) - return data + return unwrapPaginated(data) } export async function getCallResults(): Promise { - const { data } = await client.get('/call-results') - return data + return cachedRequest('reference:call-results', async () => { + const { data } = await client.get('/call-results') + return unwrapData(data) + }) } export async function getCall(id: number): Promise { - const { data } = await client.get<{ data: Call }>(`/calls/${id}`) - return data.data + const { data } = await client.get(`/calls/${id}`) + return unwrapData(data) } export async function createCall(call: Partial): Promise { - const { data } = await client.post('/calls', call) - return data + const { data } = await client.post('/calls', call) + return unwrapData(data) } export interface ReferralPayload { @@ -58,5 +67,17 @@ export async function registerResult(callId: number, result: string, notes?: str next_follow_up_at: nextFollowUpAt, referral, }) - return 'data' in data ? data.data : data + return unwrapData(data) +} + +export async function recordManualCallResult(payload: { + lead_id: number + contact_phone_id: number + result: string + notes?: string + next_follow_up_at?: string + referral?: ReferralPayload +}): Promise { + const { data } = await client.post('/calls/manual-result', payload) + return unwrapData(data) } diff --git a/frontend/src/api/campaigns.ts b/frontend/src/api/campaigns.ts index e6451d0..3c6e57e 100644 --- a/frontend/src/api/campaigns.ts +++ b/frontend/src/api/campaigns.ts @@ -1,24 +1,42 @@ import client from './client' import type { Campaign, PaginatedResponse } from '@/types' +import { unwrapData, unwrapPaginated } from './normalizers' + +export interface CampaignPayload { + name: string + description?: string | null + product_service?: string | null + product_id?: number | null + channel?: string | null + start_date?: string | null + end_date?: string | null + target?: number | null + budget?: number | null + actual_cost?: number | null + status?: Campaign['status'] + sales_script_id?: number | null + agent_ids?: number[] + supervisor_ids?: number[] +} export async function getCampaigns(params?: Record): Promise> { const { data } = await client.get('/campaigns', { params }) - return data + return unwrapPaginated(data) } export async function getCampaign(id: number): Promise { - const { data } = await client.get<{ data: Campaign }>(`/campaigns/${id}`) - return data.data + const { data } = await client.get(`/campaigns/${id}`) + return unwrapData(data) } -export async function createCampaign(c: Partial): Promise { - const { data } = await client.post<{ data: Campaign }>('/campaigns', c) - return data.data +export async function createCampaign(c: CampaignPayload): Promise { + const { data } = await client.post('/campaigns', c) + return unwrapData(data) } -export async function updateCampaign(id: number, c: Partial): Promise { - const { data } = await client.put<{ data: Campaign }>(`/campaigns/${id}`, c) - return data.data +export async function updateCampaign(id: number, c: CampaignPayload): Promise { + const { data } = await client.put(`/campaigns/${id}`, c) + return unwrapData(data) } export async function deleteCampaign(id: number): Promise { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index adb61a1..1eaba22 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,6 @@ import axios from 'axios' import type { AxiosError } from 'axios' +import { publishDataChanged } from '@/utils/dataEvents' const client = axios.create({ baseURL: '/api', @@ -12,7 +13,13 @@ const client = axios.create({ }) client.interceptors.response.use( - (response) => response, + (response) => { + const method = response.config.method?.toUpperCase() + if (method && method !== 'GET' && method !== 'HEAD' && response.status >= 200 && response.status < 300) { + publishDataChanged({ method, url: response.config.url }) + } + return response + }, (error) => { if (error.response?.status === 401 && window.location.pathname !== '/login') { window.location.href = '/login' diff --git a/frontend/src/api/followups.ts b/frontend/src/api/followups.ts index 3bbeaae..5ef63b7 100644 --- a/frontend/src/api/followups.ts +++ b/frontend/src/api/followups.ts @@ -1,27 +1,50 @@ import client from './client' import type { FollowUp, PaginatedResponse } from '@/types' +import { unwrapData, unwrapPaginated, type ApiEnvelope } from './normalizers' + +export interface FollowUpPayload { + lead_id: number + user_id?: number + call_id?: number + scheduled_at: string + notes?: string | null +} export async function getFollowUps(params?: Record): Promise> { - const { data } = await client.get('/follow-ups', { params }) - return data + const { data } = await client.get | ApiEnvelope>('/follow-ups', { params }) + return unwrapPaginated(data) } -export async function getTodayFollowUps(): Promise> { - const { data } = await client.get('/follow-ups/today') - return data +export async function getTodayFollowUps(params?: Record): Promise> { + const { data } = await client.get | ApiEnvelope>('/follow-ups/today', { params }) + return unwrapPaginated(data) } -export async function getOverdueFollowUps(): Promise> { - const { data } = await client.get('/follow-ups/overdue') - return data +export async function getOverdueFollowUps(params?: Record): Promise> { + const { data } = await client.get | ApiEnvelope>('/follow-ups/overdue', { params }) + return unwrapPaginated(data) } -export async function createFollowUp(followUp: Partial): Promise { - const { data } = await client.post<{ data: FollowUp }>('/follow-ups', followUp) - return data.data +export async function createFollowUp(followUp: FollowUpPayload): Promise { + const { data } = await client.post>('/follow-ups', followUp) + return unwrapData(data) +} + +export async function getFollowUp(id: number): Promise { + const { data } = await client.get>(`/follow-ups/${id}`) + return unwrapData(data) +} + +export async function updateFollowUp(id: number, payload: Partial>): Promise { + const { data } = await client.patch>(`/follow-ups/${id}`, payload) + return unwrapData(data) +} + +export async function deleteFollowUp(id: number): Promise { + await client.delete(`/follow-ups/${id}`) } export async function markFollowUpDone(id: number): Promise { - const { data } = await client.patch<{ data: FollowUp }>(`/follow-ups/${id}/mark-done`) - return data.data + const { data } = await client.patch>(`/follow-ups/${id}/mark-done`) + return unwrapData(data) } diff --git a/frontend/src/api/invoices.ts b/frontend/src/api/invoices.ts new file mode 100644 index 0000000..a654cfb --- /dev/null +++ b/frontend/src/api/invoices.ts @@ -0,0 +1,128 @@ +import client from './client' +import type { Invoice, InvoiceTemplate, PaginatedResponse } from '@/types' + +export interface InvoicePayload { + invoice_template_id?: number + currency?: string + customer_snapshot?: Partial + seller_snapshot?: Partial> + items?: Array<{ description: string; unit?: string; quantity: number; unit_price: number }> + discount?: number + tax?: number + paid_amount?: number + notes?: string | null + payment_terms?: string | null + due_date?: string | null + resolved_fields?: Invoice['resolved_fields'] + page_width_mm?: number + page_height_mm?: number +} + +export async function getInvoices(params: Record = {}): Promise> { + const { data } = await client.get('/invoices', { params }) + return data +} + +export interface InvoiceSummary { + total: number + counts: Record + issued_total: number + paid_total: number + outstanding_total: number + currency: string +} + +export async function getInvoiceSummary(): Promise { + const { data } = await client.get('/invoices-summary') + return data +} + +export async function getInvoice(id: number): Promise { + const { data } = await client.get(`/invoices/${id}`) + return data +} + +export async function downloadInvoiceWord(id: number, number?: string): Promise { + const response = await client.get(`/invoices/${id}/word`, { responseType: 'blob' }) + const url = URL.createObjectURL(response.data) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `invoice-${number || id}.docx` + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) +} + +export async function createInvoiceFromLead(leadId: number, payload: InvoicePayload = {}): Promise { + const { data } = await client.post(`/leads/${leadId}/invoice`, payload) + return data +} + +export async function updateInvoice(id: number, payload: InvoicePayload): Promise { + const { data } = await client.put(`/invoices/${id}`, payload) + return data +} + +export async function issueInvoice(id: number): Promise { + const { data } = await client.post(`/invoices/${id}/issue`) + return data +} + +export async function approveInvoice(id: number): Promise { + const { data } = await client.post(`/invoices/${id}/approve`) + return data +} + +export async function rejectInvoice(id: number, reason: string): Promise { + const { data } = await client.post(`/invoices/${id}/reject`, { reason }) + return data +} + +export async function voidInvoice(id: number): Promise { + const { data } = await client.post(`/invoices/${id}/void`) + return data +} + +export async function getInvoiceTemplates(includeInactive = false): Promise { + const { data } = await client.get('/invoice-templates', { params: includeInactive ? { include_inactive: 1 } : undefined }) + return data +} + +export async function getInvoiceTemplateFields(): Promise> { + const { data } = await client.get>('/invoice-template-fields') + return data +} + +export async function createInvoiceTemplate(payload: Partial): Promise { + const { data } = await client.post('/invoice-templates', payload) + return data +} + +export async function updateInvoiceTemplate(id: number, payload: Partial): Promise { + const { data } = await client.put(`/invoice-templates/${id}`, payload) + return data +} + +export async function uploadInvoiceTemplateBackground(id: number, file: File, source?: { name: string; mime: string; file?: File }): Promise { + const form = new FormData() + form.append('file', file) + if (source) { + form.append('source_name', source.name) + form.append('source_mime', source.mime) + if (source.file) form.append('source_file', source.file) + } + const { data } = await client.post(`/invoice-templates/${id}/background`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return data +} + +export async function deleteInvoiceTemplateBackground(id: number): Promise { + const { data } = await client.delete(`/invoice-templates/${id}/background`) + return data +} + +export async function deleteInvoiceTemplate(id: number): Promise { + await client.delete(`/invoice-templates/${id}`) +} diff --git a/frontend/src/api/normalizers.ts b/frontend/src/api/normalizers.ts new file mode 100644 index 0000000..b4cee4b --- /dev/null +++ b/frontend/src/api/normalizers.ts @@ -0,0 +1,30 @@ +import type { PaginatedResponse } from '@/types' + +export interface ApiEnvelope { + data: T + meta?: Partial, 'data'>> + links?: Record + message?: string | null +} + +export function unwrapData(payload: T | ApiEnvelope): T { + return isEnvelope(payload) ? payload.data : payload +} + +export function unwrapPaginated(payload: PaginatedResponse | ApiEnvelope): PaginatedResponse { + if (!isEnvelope(payload) || !payload.meta) return payload as PaginatedResponse + const meta = payload.meta + return { + data: payload.data, + current_page: meta.current_page ?? 1, + last_page: meta.last_page ?? 1, + per_page: meta.per_page ?? payload.data.length, + total: meta.total ?? payload.data.length, + from: meta.from ?? (payload.data.length ? 1 : 0), + to: meta.to ?? payload.data.length, + } +} + +function isEnvelope(payload: unknown): payload is ApiEnvelope { + return typeof payload === 'object' && payload !== null && 'data' in payload +} diff --git a/frontend/src/api/notes.ts b/frontend/src/api/notes.ts new file mode 100644 index 0000000..b6c562c --- /dev/null +++ b/frontend/src/api/notes.ts @@ -0,0 +1,38 @@ +import client from './client' +import { unwrapData } from './normalizers' +import type { CallNote, NoteType, NoteVisibility } from '@/types' + +export interface NotePayload { + content: string + type?: NoteType + visibility?: NoteVisibility +} + +export async function getCallNotes(callId: number): Promise { + const { data } = await client.get(`/calls/${callId}/notes`) + return unwrapData(data) +} + +export async function createCallNote(callId: number, payload: NotePayload): Promise { + const { data } = await client.post(`/calls/${callId}/notes`, payload) + return unwrapData(data) +} + +export async function createEntityNote(entityType: 'lead' | 'company' | 'deal', entityId: number, content: string): Promise { + const { data } = await client.post('/notes', { entity_type: entityType, entity_id: entityId, content }) + return unwrapData(data) +} + +export async function updateNote(id: number, payload: Partial): Promise { + const { data } = await client.patch(`/notes/${id}`, payload) + return unwrapData(data) +} + +export async function deleteNote(id: number): Promise { + await client.delete(`/notes/${id}`) +} + +export async function setNotePinned(id: number, pinned: boolean): Promise { + const { data } = await client.post(`/notes/${id}/${pinned ? 'pin' : 'unpin'}`) + return unwrapData(data) +} diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index 166813f..bcf99f5 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -1,20 +1,30 @@ import client from './client' import type { InternalNotification, PaginatedResponse } from '@/types' +import { unwrapData, unwrapPaginated } from './normalizers' -export async function getNotifications(): Promise> { - const { data } = await client.get('/notifications') - return data +export async function getNotifications(params?: { type?: string; unread?: boolean; archived?: boolean }): Promise> { + const { data } = await client.get('/notifications', { params }) + return unwrapPaginated(data) } export async function getUnreadCount(): Promise { const { data } = await client.get<{ count?: number; data?: number }>('/notifications/unread-count') - return data.count ?? data.data ?? 0 + return typeof data === 'number' ? data : data.count ?? (typeof data.data === 'number' ? data.data : 0) } export async function markRead(id: number): Promise { - await client.patch(`/notifications/${id}/read`) + const { data } = await client.patch(`/notifications/${id}/read`) + unwrapData(data) } export async function markAllRead(): Promise { await client.patch('/notifications/read-all') } + +export async function archiveNotification(id: number): Promise { + await client.patch(`/notifications/${id}/archive`) +} + +export async function deleteNotification(id: number): Promise { + await client.delete(`/notifications/${id}`) +} diff --git a/frontend/src/api/p2.ts b/frontend/src/api/p2.ts new file mode 100644 index 0000000..5d3ddff --- /dev/null +++ b/frontend/src/api/p2.ts @@ -0,0 +1,28 @@ +import client from './client' +import type { Pipeline, PipelineBoard, SavedView, SearchResults, DealCard } from '@/types/p2' +import { cachedRequest } from './requestCache' + +export const getPipelines = () => cachedRequest('reference:pipelines', () => client.get('/pipelines').then((r) => r.data)) +export const getPipelineBoard = (id: number, params?: Record) => client.get(`/pipelines/${id}/board`, { params }).then((r) => r.data) +export const moveDeal = (id: number, data: { deal_stage_id: number; version: number; reason?: string; final_amount?: number }) => client.patch(`/deals/${id}/stage`, data).then((r) => r.data) +export const getDeal = (id: number) => client.get(`/deals/${id}`).then((r) => r.data) +export const globalSearch = (q: string) => client.get('/global-search', { params: { q } }).then((r) => r.data) +export const getSavedViews = (entity_type: string) => client.get('/saved-views', { params: { entity_type } }).then((r) => r.data) +export const createSavedView = (data: Omit) => client.post('/saved-views', data).then((r) => r.data) +export const scoreLead = (id: number) => client.post(`/leads/${id}/score`).then((r) => r.data) + +export const getSlaRules = () => client.get('/sla-rules').then((r) => r.data) +export const createSlaRule = (data: Record) => client.post('/sla-rules', data).then((r) => r.data) +export const getSlaBreaches = () => client.get('/sla-breaches').then((r) => r.data) +export const detectSla = () => client.post('/sla/detect').then((r) => r.data) +export const resolveSla = (id: number) => client.patch(`/sla-breaches/${id}/resolve`).then((r) => r.data) + +export const getAutomations = () => client.get('/automations').then((r) => r.data) +export const createAutomation = (data: Record) => client.post('/automations', data).then((r) => r.data) +export const runAutomation = (id: number, data: Record) => client.post(`/automations/${id}/run`, data).then((r) => r.data) +export const getAutomationRuns = () => client.get('/automation-runs').then((r) => r.data) + +export const getCustomFields = (entity_type: string) => client.get('/custom-fields', { params: { entity_type } }).then((r) => r.data) +export const createCustomField = (data: Record) => client.post('/custom-fields', data).then((r) => r.data) +export const getPreferences = () => client.get('/workspace-preferences').then((r) => r.data) +export const updatePreferences = (data: Record) => client.put('/workspace-preferences', data).then((r) => r.data) diff --git a/frontend/src/api/pipeline.ts b/frontend/src/api/pipeline.ts index 5a14320..35aab15 100644 --- a/frontend/src/api/pipeline.ts +++ b/frontend/src/api/pipeline.ts @@ -13,6 +13,7 @@ export async function moveLeadToStage(leadId: number, stageId: number) { export interface MoveLeadPayload { next_follow_up_at?: string + follow_up_notes?: string final_result?: 'موفق' | 'ناموفق' lost_reason?: string deal_value?: number diff --git a/frontend/src/api/qualityReviews.ts b/frontend/src/api/qualityReviews.ts index a77febb..3648b75 100644 --- a/frontend/src/api/qualityReviews.ts +++ b/frontend/src/api/qualityReviews.ts @@ -1,17 +1,30 @@ import client from './client' +import { cachedRequest } from './requestCache' import type { PaginatedResponse, QualityReview } from '@/types' export async function getQualityReviews(params?: Record): Promise> { - const { data } = await client.get('/quality-reviews', { params }) - return data + const normalizedParams = { + ...params, + current_only: params?.current_only === true ? 1 : params?.current_only === false ? 0 : params?.current_only, + } + const key = `quality-reviews:${JSON.stringify(normalizedParams)}` + return cachedRequest(key, async () => { + const { data } = await client.get('/quality-reviews', { params: normalizedParams }) + return data + }, 1_000) } export async function createQualityReview(r: Partial): Promise { - const { data } = await client.post<{ data: QualityReview }>('/quality-reviews', r) - return data.data + const { data } = await client.post('/quality-reviews', r) + return 'data' in data ? data.data : data } export async function updateQualityReview(id: number, r: Partial): Promise { - const { data } = await client.put<{ data: QualityReview }>(`/quality-reviews/${id}`, r) - return data.data + const { data } = await client.put(`/quality-reviews/${id}`, r) + return 'data' in data ? data.data : data +} + +export async function acknowledgeQualityReview(id: number, agent_response?: string): Promise { + const { data } = await client.post(`/quality-reviews/${id}/acknowledge`, { agent_response }) + return 'data' in data ? data.data : data } diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts index b1ba826..45c8972 100644 --- a/frontend/src/api/reports.ts +++ b/frontend/src/api/reports.ts @@ -1,5 +1,10 @@ import client from './client' +export async function getKpiReport(params?: Record) { + const { data } = await client.get('/reports/kpi', { params }) + return data +} + export async function getAgentPerformance(params?: Record) { const { data } = await client.get('/reports/agent-performance', { params }) return data @@ -60,6 +65,11 @@ export async function getBestContactTimeReport(params?: Record) return data } +export async function getOperationsReport(params?: Record) { + const { data } = await client.get('/reports/operations', { params }) + return data +} + export async function exportReportCsv(params?: Record) { const { data } = await client.get('/reports/export/excel', { params, diff --git a/frontend/src/api/requestCache.ts b/frontend/src/api/requestCache.ts new file mode 100644 index 0000000..8c8340f --- /dev/null +++ b/frontend/src/api/requestCache.ts @@ -0,0 +1,26 @@ +import { CRM_DATA_CHANGED } from '@/utils/dataEvents' + +const cache = new Map() +const inflight = new Map>() + +if (typeof window !== 'undefined') { + window.addEventListener(CRM_DATA_CHANGED, () => { + cache.clear() + }) +} + +export async function cachedRequest(key: string, fetcher: () => Promise, ttlMs = 60_000): Promise { + const existing = cache.get(key) + if (existing && existing.expiresAt > Date.now()) return existing.value as T + const pending = inflight.get(key) + if (pending) return pending as Promise + + const request = fetcher() + .then((value) => { + cache.set(key, { value, expiresAt: Date.now() + ttlMs }) + return value + }) + .finally(() => inflight.delete(key)) + inflight.set(key, request) + return request +} diff --git a/frontend/src/api/roles.ts b/frontend/src/api/roles.ts index 2889919..934acbd 100644 --- a/frontend/src/api/roles.ts +++ b/frontend/src/api/roles.ts @@ -3,22 +3,22 @@ import type { Permission, Role } from '@/types' export async function getRoles(): Promise { const { data } = await client.get('/roles') - return Array.isArray(data) ? data : data.data + return (Array.isArray(data) ? data : data.data).map(normalizeRole) } export async function getRole(id: number): Promise { - const { data } = await client.get<{ data: Role }>(`/roles/${id}`) - return data.data + const { data } = await client.get(`/roles/${id}`) + return normalizeRole('data' in data ? data.data : data) } export async function createRole(role: Partial): Promise { - const { data } = await client.post<{ data: Role }>('/roles', role) - return data.data + const { data } = await client.post('/roles', role) + return normalizeRole('data' in data ? data.data : data) } export async function updateRole(id: number, role: Partial): Promise { - const { data } = await client.put<{ data: Role }>(`/roles/${id}`, role) - return data.data + const { data } = await client.put(`/roles/${id}`, role) + return normalizeRole('data' in data ? data.data : data) } export async function deleteRole(id: number): Promise { @@ -30,6 +30,15 @@ export async function syncPermissions(roleId: number, permissions: string[]): Pr } export async function getPermissions(): Promise { - const { data } = await client.get<{ data: Permission[] }>('/permissions') - return data.data + const { data } = await client.get('/permissions') + return Array.isArray(data) ? data : data.data +} + +type RawRole = Omit & { permissions?: Array } + +function normalizeRole(role: RawRole): Role { + return { + ...role, + permissions: (role.permissions ?? []).map((permission) => typeof permission === 'string' ? permission : permission.name), + } } diff --git a/frontend/src/api/scripts.ts b/frontend/src/api/scripts.ts index 4026a6a..3db15a1 100644 --- a/frontend/src/api/scripts.ts +++ b/frontend/src/api/scripts.ts @@ -1,24 +1,39 @@ import client from './client' -import type { SalesScript } from '@/types' +import type { PaginatedResponse, SalesScript, ScriptSection } from '@/types' -export async function getScripts(): Promise { - const { data } = await client.get<{ data: SalesScript[] }>('/scripts') - return data.data +export interface SalesScriptPayload { + title?: string + description?: string | null + version?: string + is_active?: boolean + campaign_id?: number | null + product_id?: number | null + category?: string | null + lead_source?: string | null + suggested_questions?: string[] | null + required_disclosures?: string[] | null + is_template?: boolean + sections?: Array & Partial>> +} + +export async function getScripts(params?: Record): Promise> { + const { data } = await client.get>('/scripts', { params }) + return data } export async function getScript(id: number): Promise { - const { data } = await client.get<{ data: SalesScript }>(`/scripts/${id}`) - return data.data + const { data } = await client.get(`/scripts/${id}`) + return 'data' in data ? data.data : data } -export async function createScript(s: Partial): Promise { - const { data } = await client.post<{ data: SalesScript }>('/scripts', s) - return data.data +export async function createScript(s: SalesScriptPayload): Promise { + const { data } = await client.post('/scripts', s) + return 'data' in data ? data.data : data } -export async function updateScript(id: number, s: Partial): Promise { - const { data } = await client.put<{ data: SalesScript }>(`/scripts/${id}`, s) - return data.data +export async function updateScript(id: number, s: SalesScriptPayload): Promise { + const { data } = await client.put(`/scripts/${id}`, s) + return 'data' in data ? data.data : data } export async function deleteScript(id: number): Promise { diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index f2c512d..07e85c8 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -19,11 +19,25 @@ export async function updateSettings(settings: SettingUpdate[]): Promise { await client.put('/settings', { settings }) } -export async function testVoipSettings(): Promise<{ ok: boolean; provider: string; message: string; missing: string[] }> { +export interface VoipTestResult { + ok: boolean + provider: string + message: string + missing: string[] + latency_ms?: number + http_status?: number +} + +export async function testVoipSettings(): Promise { const { data } = await client.post('/settings/voip/test') return data } +export async function testVoipCall(phone: string, extension: string): Promise<{ ok: boolean; message: string; provider_call_id?: string; status?: string }> { + const { data } = await client.post('/settings/voip/test-call', { phone, extension }) + return data +} + export async function getPublicSettings(): Promise> { const { data } = await client.get('/settings/public') return data diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts new file mode 100644 index 0000000..8a1c9e6 --- /dev/null +++ b/frontend/src/api/tasks.ts @@ -0,0 +1,73 @@ +import client from './client' +import { unwrapData, unwrapPaginated } from './normalizers' +import type { AssignableUser, PaginatedResponse, Task, TaskPayload, TaskPriority, TaskStatus, TaskableType } from '@/types' + +export interface TaskFilters { + status?: TaskStatus + priority?: TaskPriority + assigned_to?: number + created_by?: number + due_from?: string + due_to?: string + overdue?: boolean + taskable_type?: TaskableType + taskable_id?: number + search?: string + sort?: 'created_at' | '-created_at' | 'due_at' | '-due_at' | 'priority' | '-priority' + page?: number + per_page?: number +} + +export async function getTasks(filters: TaskFilters = {}): Promise> { + const { data } = await client.get('/tasks', { params: filters }) + return unwrapPaginated(data) +} + +export async function getTask(id: number): Promise { + const { data } = await client.get(`/tasks/${id}`) + return unwrapData(data) +} + +export async function createTask(payload: TaskPayload): Promise { + const { data } = await client.post('/tasks', payload) + return unwrapData(data) +} + +export async function updateTask(id: number, payload: Partial & { version: number }): Promise { + const { data } = await client.patch(`/tasks/${id}`, payload) + return unwrapData(data) +} + +export async function deleteTask(id: number): Promise { + await client.delete(`/tasks/${id}`) +} + +export async function assignTask(id: number, assignedTo: number, version: number): Promise { + const { data } = await client.post(`/tasks/${id}/assign`, { assigned_to: assignedTo, version }) + return unwrapData(data) +} + +export async function transitionTask(id: number, action: 'start' | 'complete' | 'reopen' | 'cancel', version: number): Promise { + const { data } = await client.post(`/tasks/${id}/${action}`, { version }) + return unwrapData(data) +} + +export async function bulkCompleteTasks(taskIds: number[]): Promise { + const { data } = await client.post('/tasks/bulk-complete', { task_ids: taskIds }) + return unwrapData(data) +} + +export async function bulkAssignTasks(taskIds: number[], assignedTo: number): Promise { + const { data } = await client.post('/tasks/bulk-assign', { task_ids: taskIds, assigned_to: assignedTo }) + return unwrapData(data) +} + +export async function getAssignableUsers(search = '', taskable?: { type: TaskableType; id: number }): Promise { + const { data } = await client.get('/users/assignable', { + params: { + context: 'task', search: search || undefined, + entity_type: taskable?.type, entity_id: taskable?.id, + }, + }) + return unwrapData(data) +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index dbaab03..5de4d51 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -1,5 +1,6 @@ import client from './client' import type { PaginatedResponse, User } from '@/types' +import { cachedRequest } from './requestCache' export type CreateUserPayload = Pick & { password: string @@ -18,9 +19,18 @@ export async function getUsers(params?: Record): Promise { - const { data } = await client.get('/agents') - const agents = Array.isArray(data) ? data : data.data - return agents.map(normalizeUser) + return cachedRequest('reference:agents', async () => { + const { data } = await client.get('/agents') + const agents = Array.isArray(data) ? data : data.data + return agents.map(normalizeUser) + }) +} + +export interface ReferralTarget { id: number; name: string; role: 'agent' | 'supervisor'; team?: string | null } + +export async function getReferralTargets(): Promise { + const { data } = await client.get('/users/referral-targets') + return data } export async function getUser(id: number): Promise { diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index ee1161f..f70daa9 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -1,21 +1,22 @@ import { useAuthStore } from '@/stores/authStore' import { Navigate } from 'react-router-dom' import type { ReactNode } from 'react' -import Spinner from './ui/Spinner' +import { PageSkeleton } from './ui/Skeleton' interface Props { children: ReactNode roles?: string[] permission?: string + permissions?: string[] } -export default function ProtectedRoute({ children, roles, permission }: Props) { +export default function ProtectedRoute({ children, roles, permission, permissions }: Props) { const { user, ready } = useAuthStore() if (!ready) { return ( -
- +
+
) } @@ -30,5 +31,9 @@ export default function ProtectedRoute({ children, roles, permission }: Props) { return } + if (permissions && !permissions.some((item) => user.permissions?.includes(item))) { + return + } + return <>{children} } diff --git a/frontend/src/components/activity/ActivityComposer.tsx b/frontend/src/components/activity/ActivityComposer.tsx new file mode 100644 index 0000000..14c4647 --- /dev/null +++ b/frontend/src/components/activity/ActivityComposer.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react' +import { createEntityNote } from '@/api/notes' +import TaskForm from '@/components/tasks/TaskForm' +import Button from '@/components/ui/Button' +import Modal from '@/components/ui/Modal' +import Textarea from '@/components/ui/Textarea' +import { toast } from '@/components/ui/toastStore' +import type { TaskableType } from '@/types' + +export default function ActivityComposer({ entityType, entityId, onChanged }: { entityType: TaskableType; entityId: number; onChanged?: () => void }) { + const [taskOpen, setTaskOpen] = useState(false) + const [noteOpen, setNoteOpen] = useState(false) + const [note, setNote] = useState('') + const [saving, setSaving] = useState(false) + const supportsGenericNote = ['lead', 'company', 'deal'].includes(entityType) + + const saveNote = async () => { + if (!note.trim() || !supportsGenericNote) return + setSaving(true) + try { + await createEntityNote(entityType as 'lead' | 'company' | 'deal', entityId, note.trim()) + setNote(''); setNoteOpen(false); toast('یادداشت ثبت شد', 'success'); onChanged?.() + } catch { toast('ثبت یادداشت انجام نشد', 'error') } + finally { setSaving(false) } + } + + return ( +
+ فعالیت سریع: + + {supportsGenericNote && } + + setTaskOpen(false)} title="کار جدید" size="lg"> + { setTaskOpen(false); onChanged?.() }} onCancel={() => setTaskOpen(false)} /> + + setNoteOpen(false)} title="یادداشت جدید" size="md"> +