From e1a86ead41d82909a866698a070bfe49c6ca855b Mon Sep 17 00:00:00 2001 From: Toornaa Date: Sat, 25 Jul 2026 15:18:46 +0330 Subject: [PATCH] update project --- .github/workflows/ci.yml | 104 ++ .gitignore | 3 + README.md | 92 + backend/app/Enums/TaskPriority.php | 11 + backend/app/Enums/TaskStatus.php | 16 + backend/app/Enums/TaskVisibility.php | 10 + .../TaskVersionConflictException.php | 13 + .../Controllers/Api/AgentMobileController.php | 144 +- .../Controllers/Api/AssignmentController.php | 40 +- .../Controllers/Api/AttachmentController.php | 27 +- .../Http/Controllers/Api/AuthController.php | 24 +- .../Controllers/Api/CalendarController.php | 82 + .../Http/Controllers/Api/CallController.php | 82 +- .../Controllers/Api/CampaignController.php | 104 +- .../Controllers/Api/CompanyController.php | 73 +- .../Api/ConfigurationController.php | 142 ++ .../Controllers/Api/ContactController.php | 148 +- .../Controllers/Api/DashboardController.php | 16 +- .../Http/Controllers/Api/DealController.php | 73 +- .../Controllers/Api/DuplicateController.php | 4 +- .../Controllers/Api/FollowUpController.php | 188 +- .../Http/Controllers/Api/ImportController.php | 11 +- .../Api/IntelligenceController.php | 88 + .../Controllers/Api/InvoiceController.php | 383 ++++ .../Http/Controllers/Api/LeadController.php | 42 +- .../Controllers/Api/LeadStatusController.php | 3 +- .../Controllers/Api/LostReasonController.php | 2 +- .../Http/Controllers/Api/MediaController.php | 27 + .../Http/Controllers/Api/NoteController.php | 120 +- .../Api/NotificationController.php | 68 +- .../Controllers/Api/PipelineController.php | 84 + .../Api/PipelineStageController.php | 34 +- .../Controllers/Api/ProductController.php | 30 +- .../Api/QualityReviewController.php | 178 +- .../Http/Controllers/Api/ReportController.php | 137 +- .../Http/Controllers/Api/RoleController.php | 5 +- .../Http/Controllers/Api/ScriptController.php | 227 ++- .../Controllers/Api/SettingController.php | 58 +- .../Http/Controllers/Api/TaskController.php | 168 ++ .../Controllers/Api/TimelineController.php | 12 +- .../Http/Controllers/Api/UserController.php | 92 +- .../Controllers/Api/VoipWebhookController.php | 52 + .../Controllers/Api/WorkspaceController.php | 107 ++ backend/app/Http/Middleware/LogActivity.php | 4 +- .../app/Http/Middleware/MaskPhoneNumber.php | 13 +- .../app/Http/Middleware/SecurityHeaders.php | 4 +- .../app/Http/Requests/AssignTaskRequest.php | 21 + .../Http/Requests/StoreCallNoteRequest.php | 22 + .../app/Http/Requests/StoreTaskRequest.php | 43 + .../app/Http/Requests/TaskIndexRequest.php | 35 + .../app/Http/Requests/UpdateNoteRequest.php | 22 + .../app/Http/Requests/UpdateTaskRequest.php | 43 + backend/app/Http/Resources/CallResource.php | 40 + .../app/Http/Resources/CampaignResource.php | 45 + .../app/Http/Resources/FollowUpResource.php | 41 + backend/app/Http/Resources/NoteResource.php | 34 + .../Http/Resources/NotificationResource.php | 26 + backend/app/Http/Resources/TaskResource.php | 72 + backend/app/Http/Responses/ApiResponse.php | 53 + backend/app/Jobs/SendTaskReminders.php | 17 + backend/app/Models/ActivityLog.php | 6 + backend/app/Models/AutomationRule.php | 35 + backend/app/Models/AutomationRun.php | 27 + backend/app/Models/Call.php | 22 +- backend/app/Models/Campaign.php | 25 + backend/app/Models/Company.php | 10 + backend/app/Models/Contact.php | 11 + backend/app/Models/ContactPhone.php | 3 + backend/app/Models/CustomFieldDefinition.php | 24 + backend/app/Models/CustomFieldValue.php | 27 + backend/app/Models/DashboardPreference.php | 21 + backend/app/Models/Deal.php | 36 +- backend/app/Models/DealStage.php | 27 + backend/app/Models/DealStageHistory.php | 31 + backend/app/Models/FollowUp.php | 7 +- backend/app/Models/Invoice.php | 61 + backend/app/Models/InvoiceTemplate.php | 38 + backend/app/Models/Lead.php | 28 +- backend/app/Models/Note.php | 13 +- backend/app/Models/Notification.php | 7 +- backend/app/Models/NotificationPreference.php | 21 + backend/app/Models/Pipeline.php | 35 + backend/app/Models/Product.php | 5 + backend/app/Models/QualityReview.php | 14 +- backend/app/Models/SalesScript.php | 23 +- backend/app/Models/SavedView.php | 29 + backend/app/Models/SlaBreach.php | 32 + backend/app/Models/SlaRule.php | 27 + backend/app/Models/Task.php | 79 + backend/app/Models/User.php | 47 +- .../TaskAssignedNotification.php | 33 + .../app/Notifications/TaskDueNotification.php | 37 + backend/app/Policies/AttachmentPolicy.php | 21 + backend/app/Policies/CallPolicy.php | 5 + backend/app/Policies/CampaignPolicy.php | 11 +- backend/app/Policies/CompanyPolicy.php | 40 + backend/app/Policies/ContactPolicy.php | 40 + backend/app/Policies/DashboardPolicy.php | 23 + backend/app/Policies/DealPolicy.php | 40 + backend/app/Policies/FollowUpPolicy.php | 20 +- backend/app/Policies/InvoicePolicy.php | 65 + backend/app/Policies/NotePolicy.php | 43 + backend/app/Policies/NotificationPolicy.php | 24 + backend/app/Policies/ProductPolicy.php | 39 + backend/app/Policies/QualityReviewPolicy.php | 46 + backend/app/Policies/SalesScriptPolicy.php | 40 + backend/app/Policies/TaskPolicy.php | 62 + backend/app/Providers/AppServiceProvider.php | 43 +- backend/app/Services/ActivityLogger.php | 30 +- backend/app/Services/AssignmentService.php | 7 +- backend/app/Services/AutomationDispatcher.php | 58 + backend/app/Services/AutomationEngine.php | 73 + backend/app/Services/CallService.php | 103 +- backend/app/Services/DashboardService.php | 222 ++- backend/app/Services/DealPipelineService.php | 95 + backend/app/Services/DuplicateService.php | 84 +- .../app/Services/FollowUpReminderService.php | 35 + backend/app/Services/FollowUpService.php | 83 + backend/app/Services/ImportService.php | 25 +- backend/app/Services/InvoiceService.php | 427 +++++ backend/app/Services/InvoiceWordService.php | 157 ++ backend/app/Services/LeadScoringService.php | 28 + backend/app/Services/LeadService.php | 34 +- .../LegacyCallNoteBackfillService.php | 40 + backend/app/Services/NotificationService.php | 54 +- backend/app/Services/ReportService.php | 302 +++- backend/app/Services/SlaMonitorService.php | 78 + backend/app/Services/TaskReminderService.php | 37 + backend/app/Services/TaskService.php | 245 +++ backend/app/Services/VoIP/AmiProvider.php | 21 +- backend/app/Services/VoIP/ApiProvider.php | 40 +- .../app/Services/VoIP/DisabledProvider.php | 26 + backend/app/Services/VoIP/MockProvider.php | 4 +- backend/app/Services/VoIP/SocketProvider.php | 43 +- backend/app/Services/VoIP/VoIPManager.php | 17 +- .../Services/VoIP/VoIPProviderInterface.php | 5 +- backend/app/Support/AccessControl.php | 218 ++- backend/app/Support/EntityResolver.php | 56 + backend/app/Support/PermissionCatalog.php | 167 ++ backend/app/Support/SettingsCatalog.php | 15 +- backend/app/Support/WorkingHours.php | 2 +- backend/bootstrap/app.php | 40 +- backend/composer.json | 1 + backend/composer.lock | 218 ++- ...26_06_27_123132_create_campaigns_table.php | 3 +- ...2026_06_27_200000_enhance_sales_funnel.php | 16 +- ...202000_simplify_telephone_sales_funnel.php | 6 +- ...000_add_presence_fields_to_users_table.php | 6 +- ..._28_150000_create_contact_route_tables.php | 2 +- ...120000_structure_workflow_and_settings.php | 76 +- ...026_07_07_130000_phase5_core_sales_crm.php | 50 +- ...0000_add_voip_extension_to_users_table.php | 2 +- ...0_normalize_sales_script_relationships.php | 83 + ...10000_add_quality_and_script_integrity.php | 53 + .../2026_07_15_020000_create_tasks_table.php | 43 + ..._enhance_notes_and_backfill_call_notes.php | 64 + ...notifications_audit_and_contact_phones.php | 46 + ...030000_create_deal_pipeline_foundation.php | 114 ++ ...000_create_saved_views_and_preferences.php | 54 + ..._15_032000_create_lead_scoring_and_sla.php | 58 + ...00_create_automation_and_custom_fields.php | 92 + ..._034000_enhance_quality_scripts_for_p2.php | 43 + ...035000_disable_mock_voip_outside_tests.php | 27 + ...6_07_15_040000_create_invoice_workflow.php | 98 ++ ..._default_workweek_to_saturday_thursday.php | 23 + ...042000_add_provider_lifecycle_to_calls.php | 26 + ...000000_add_page_dimensions_to_invoices.php | 23 + ...d_source_settings_to_invoice_templates.php | 36 + ..._000000_complete_workflow_interactions.php | 61 + ...07_23_010000_enhance_campaign_planning.php | 27 + ..._23_020000_add_word_fields_to_invoices.php | 24 + backend/database/seeders/DatabaseSeeder.php | 40 +- .../database/seeders/RolePermissionSeeder.php | 35 + backend/routes/api.php | 97 +- backend/routes/console.php | 35 + ...abase-before-crm-ux-20260715-195530.sqlite | Bin 0 -> 991232 bytes ...tabase-before-p1-p2-20260715-185234.sqlite | Bin 0 -> 733184 bytes backend/tests/Feature/CalendarAccessTest.php | 66 + backend/tests/Feature/CallContractTest.php | 122 ++ .../CallNoteNotificationIntegrityTest.php | 146 ++ backend/tests/Feature/CampaignMetricsTest.php | 50 + .../Feature/CoreCrmAuthorizationTest.php | 118 ++ .../tests/Feature/CoreCrmIntegrityTest.php | 115 ++ backend/tests/Feature/CoreCrmTest.php | 3 +- .../tests/Feature/FollowUpContractTest.php | 89 + backend/tests/Feature/InvoiceWorkflowTest.php | 311 ++++ backend/tests/Feature/LeadActionsTest.php | 6 +- .../tests/Feature/ProfessionalCrmP2Test.php | 155 ++ .../QualityScriptAuthorizationTest.php | 139 ++ backend/tests/Feature/RegressionFixesTest.php | 54 + backend/tests/Feature/ReportKpiTest.php | 86 + .../Feature/RolePermissionDashboardTest.php | 78 + .../Feature/SecurityAuthorizationTest.php | 6 +- .../tests/Feature/SettingsBehaviorTest.php | 68 + .../TaskAuthorizationLifecycleTest.php | 137 ++ backend/tests/Feature/UserManagementTest.php | 35 +- .../tests/Feature/WorkflowInteractionTest.php | 102 ++ docs/CRM_CHANGE_CHECKLIST_FA.md | 77 + docs/P1_TASKS_AND_CALL_NOTES_FA.md | 85 + docs/P2_PROFESSIONAL_CRM_FA.md | 100 ++ docs/PERFORMANCE_AUDIT_FA.md | 36 + frontend/e2e/dark-mode-lead.spec.ts | 57 + frontend/e2e/fixtures.ts | 42 + frontend/e2e/invoice-word-wizard.spec.ts | 70 + frontend/e2e/lead-layout.spec.ts | 98 ++ frontend/e2e/mobile-shell-calendar.spec.ts | 127 ++ frontend/e2e/reports-layout.spec.ts | 47 + frontend/e2e/task-center.spec.ts | 204 +++ frontend/package-lock.json | 1537 ++++++++++++++++- frontend/package.json | 12 +- frontend/playwright.config.ts | 24 + frontend/src/App.tsx | 52 +- frontend/src/api/assignments.ts | 5 + frontend/src/api/auth.ts | 6 +- frontend/src/api/calendar.ts | 19 + frontend/src/api/calls.ts | 39 +- frontend/src/api/campaigns.ts | 36 +- frontend/src/api/client.ts | 9 +- frontend/src/api/followups.ts | 49 +- frontend/src/api/invoices.ts | 128 ++ frontend/src/api/normalizers.ts | 30 + frontend/src/api/notes.ts | 38 + frontend/src/api/notifications.ts | 20 +- frontend/src/api/p2.ts | 28 + frontend/src/api/pipeline.ts | 1 + frontend/src/api/qualityReviews.ts | 25 +- frontend/src/api/reports.ts | 10 + frontend/src/api/requestCache.ts | 26 + frontend/src/api/roles.ts | 27 +- frontend/src/api/scripts.ts | 39 +- frontend/src/api/settings.ts | 16 +- frontend/src/api/tasks.ts | 73 + frontend/src/api/users.ts | 16 +- frontend/src/components/ProtectedRoute.tsx | 13 +- .../components/activity/ActivityComposer.tsx | 41 + .../dashboard/DashboardPreferencesCard.tsx | 32 + .../dashboard/LiveDashboardCharts.tsx | 114 ++ .../components/dashboard/MyAgendaWidget.tsx | 170 ++ .../invoices/InvoiceTemplateRenderer.tsx | 74 + .../invoices/invoiceTemplateAssets.ts | 70 + .../src/components/invoices/paperSizes.ts | 24 + frontend/src/components/layout/AppLayout.tsx | 13 +- frontend/src/components/layout/Header.tsx | 33 +- .../src/components/layout/MobileBottomNav.tsx | 50 + .../components/layout/NotificationPopover.tsx | 131 ++ frontend/src/components/layout/Sidebar.tsx | 88 +- frontend/src/components/layout/navigation.ts | 52 + .../src/components/notes/NoteComposer.tsx | 40 + .../src/components/notes/NoteTimeline.tsx | 35 + .../src/components/search/GlobalSearch.tsx | 70 + .../src/components/tasks/AssigneePicker.tsx | 58 + frontend/src/components/tasks/TaskDetails.tsx | 22 + frontend/src/components/tasks/TaskDrawer.tsx | 65 + frontend/src/components/tasks/TaskForm.tsx | 158 ++ frontend/src/components/tasks/TaskList.tsx | 50 + frontend/src/components/ui/Button.tsx | 14 +- frontend/src/components/ui/Card.tsx | 9 +- frontend/src/components/ui/Icon.tsx | 12 + frontend/src/components/ui/Input.tsx | 30 +- frontend/src/components/ui/Modal.tsx | 62 +- .../src/components/ui/PersianDateInput.tsx | 219 ++- frontend/src/components/ui/Select.tsx | 50 +- frontend/src/components/ui/Skeleton.tsx | 30 + frontend/src/components/ui/Switch.tsx | 30 + frontend/src/components/ui/Table.tsx | 26 +- frontend/src/components/ui/Textarea.tsx | 29 +- .../src/components/ui/TwoStepConfirmModal.tsx | 38 + frontend/src/index.css | 105 +- .../pages/AgentMobile/AgentMobileLayout.tsx | 6 +- .../pages/AgentMobile/AgentMobilePages.tsx | 22 +- frontend/src/pages/Calendar/CalendarPage.tsx | 104 ++ frontend/src/pages/Calls/CallList.tsx | 67 +- frontend/src/pages/Campaigns/CampaignList.tsx | 211 ++- frontend/src/pages/CoreCrm/CoreCrmPage.tsx | 149 +- .../src/pages/Dashboard/AdminDashboard.tsx | 31 +- .../src/pages/Dashboard/AgentDashboard.tsx | 50 +- .../pages/Dashboard/SupervisorDashboard.tsx | 36 +- frontend/src/pages/Deals/DealKanban.tsx | 138 ++ frontend/src/pages/Deals/DealWorkspace.tsx | 23 + frontend/src/pages/FollowUps/FollowUpList.tsx | 203 +-- frontend/src/pages/Invoices/InvoiceCenter.tsx | 266 +++ .../pages/Invoices/InvoiceCreateWizard.tsx | 216 +++ .../src/pages/Invoices/InvoicePrintPage.tsx | 35 + .../Invoices/InvoiceTemplateCreatePage.tsx | 49 + .../pages/Invoices/InvoiceTemplateList.tsx | 130 ++ .../pages/Invoices/InvoiceTemplateWizard.tsx | 490 ++++++ frontend/src/pages/Leads/LeadFunnel.tsx | 35 +- frontend/src/pages/Leads/LeadList.tsx | 30 +- frontend/src/pages/Leads/LeadShow.tsx | 240 ++- .../pages/Notifications/NotificationList.tsx | 40 +- .../src/pages/Operations/OperationsCenter.tsx | 166 ++ frontend/src/pages/Profile/ProfileModal.tsx | 109 ++ frontend/src/pages/Profile/ProfilePage.tsx | 90 +- .../QualityReviews/QualityReviewList.tsx | 282 +++ frontend/src/pages/Reports/ReportsIndex.tsx | 137 +- .../src/pages/Sales/SalesPipelinePage.tsx | 15 + .../src/pages/SalesScripts/ScriptList.tsx | 185 ++ frontend/src/pages/Settings/SettingsPage.tsx | 366 +++- frontend/src/pages/Tasks/TaskCenter.tsx | 138 ++ frontend/src/stores/authStore.ts | 25 +- frontend/src/stores/notificationStore.ts | 10 +- frontend/src/stores/settingsStore.ts | 17 + frontend/src/test/CallNotes.test.tsx | 37 + frontend/src/test/DarkModeWorkspace.test.tsx | 94 + frontend/src/test/DashboardAgenda.test.tsx | 48 + frontend/src/test/LeadList.test.tsx | 55 + .../src/test/LiveDashboardCharts.test.tsx | 54 + frontend/src/test/MediaUrl.test.ts | 18 + frontend/src/test/NavigationIcons.test.ts | 18 + frontend/src/test/NotificationList.test.tsx | 20 + frontend/src/test/P2Workspace.test.tsx | 42 + frontend/src/test/SalesWorkspaceUX.test.tsx | 37 + frontend/src/test/TaskComponents.test.tsx | 46 + frontend/src/test/date.test.ts | 18 + frontend/src/test/setup.ts | 5 + frontend/src/types/index.ts | 229 ++- frontend/src/types/note.ts | 15 + frontend/src/types/p2.ts | 68 + frontend/src/types/task.ts | 65 + frontend/src/utils/dataEvents.ts | 16 + frontend/src/utils/date.ts | 40 +- frontend/src/utils/media.ts | 28 +- frontend/src/utils/notificationSound.ts | 32 + frontend/vite.config.ts | 9 +- run-project.bat | 66 + scripts/package-release.ps1 | 82 + 326 files changed, 20110 insertions(+), 2009 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 backend/app/Enums/TaskPriority.php create mode 100644 backend/app/Enums/TaskStatus.php create mode 100644 backend/app/Enums/TaskVisibility.php create mode 100644 backend/app/Exceptions/TaskVersionConflictException.php create mode 100644 backend/app/Http/Controllers/Api/CalendarController.php create mode 100644 backend/app/Http/Controllers/Api/ConfigurationController.php create mode 100644 backend/app/Http/Controllers/Api/IntelligenceController.php create mode 100644 backend/app/Http/Controllers/Api/InvoiceController.php create mode 100644 backend/app/Http/Controllers/Api/MediaController.php create mode 100644 backend/app/Http/Controllers/Api/PipelineController.php create mode 100644 backend/app/Http/Controllers/Api/TaskController.php create mode 100644 backend/app/Http/Controllers/Api/VoipWebhookController.php create mode 100644 backend/app/Http/Controllers/Api/WorkspaceController.php create mode 100644 backend/app/Http/Requests/AssignTaskRequest.php create mode 100644 backend/app/Http/Requests/StoreCallNoteRequest.php create mode 100644 backend/app/Http/Requests/StoreTaskRequest.php create mode 100644 backend/app/Http/Requests/TaskIndexRequest.php create mode 100644 backend/app/Http/Requests/UpdateNoteRequest.php create mode 100644 backend/app/Http/Requests/UpdateTaskRequest.php create mode 100644 backend/app/Http/Resources/CallResource.php create mode 100644 backend/app/Http/Resources/CampaignResource.php create mode 100644 backend/app/Http/Resources/FollowUpResource.php create mode 100644 backend/app/Http/Resources/NoteResource.php create mode 100644 backend/app/Http/Resources/NotificationResource.php create mode 100644 backend/app/Http/Resources/TaskResource.php create mode 100644 backend/app/Http/Responses/ApiResponse.php create mode 100644 backend/app/Jobs/SendTaskReminders.php create mode 100644 backend/app/Models/AutomationRule.php create mode 100644 backend/app/Models/AutomationRun.php create mode 100644 backend/app/Models/CustomFieldDefinition.php create mode 100644 backend/app/Models/CustomFieldValue.php create mode 100644 backend/app/Models/DashboardPreference.php create mode 100644 backend/app/Models/DealStage.php create mode 100644 backend/app/Models/DealStageHistory.php create mode 100644 backend/app/Models/Invoice.php create mode 100644 backend/app/Models/InvoiceTemplate.php create mode 100644 backend/app/Models/NotificationPreference.php create mode 100644 backend/app/Models/Pipeline.php create mode 100644 backend/app/Models/SavedView.php create mode 100644 backend/app/Models/SlaBreach.php create mode 100644 backend/app/Models/SlaRule.php create mode 100644 backend/app/Models/Task.php create mode 100644 backend/app/Notifications/TaskAssignedNotification.php create mode 100644 backend/app/Notifications/TaskDueNotification.php create mode 100644 backend/app/Policies/AttachmentPolicy.php create mode 100644 backend/app/Policies/CompanyPolicy.php create mode 100644 backend/app/Policies/ContactPolicy.php create mode 100644 backend/app/Policies/DashboardPolicy.php create mode 100644 backend/app/Policies/DealPolicy.php create mode 100644 backend/app/Policies/InvoicePolicy.php create mode 100644 backend/app/Policies/NotePolicy.php create mode 100644 backend/app/Policies/NotificationPolicy.php create mode 100644 backend/app/Policies/ProductPolicy.php create mode 100644 backend/app/Policies/QualityReviewPolicy.php create mode 100644 backend/app/Policies/SalesScriptPolicy.php create mode 100644 backend/app/Policies/TaskPolicy.php create mode 100644 backend/app/Services/AutomationDispatcher.php create mode 100644 backend/app/Services/AutomationEngine.php create mode 100644 backend/app/Services/DealPipelineService.php create mode 100644 backend/app/Services/FollowUpReminderService.php create mode 100644 backend/app/Services/FollowUpService.php create mode 100644 backend/app/Services/InvoiceService.php create mode 100644 backend/app/Services/InvoiceWordService.php create mode 100644 backend/app/Services/LeadScoringService.php create mode 100644 backend/app/Services/LegacyCallNoteBackfillService.php create mode 100644 backend/app/Services/SlaMonitorService.php create mode 100644 backend/app/Services/TaskReminderService.php create mode 100644 backend/app/Services/TaskService.php create mode 100644 backend/app/Services/VoIP/DisabledProvider.php create mode 100644 backend/app/Support/EntityResolver.php create mode 100644 backend/app/Support/PermissionCatalog.php create mode 100644 backend/database/migrations/2026_07_15_000000_normalize_sales_script_relationships.php create mode 100644 backend/database/migrations/2026_07_15_010000_add_quality_and_script_integrity.php create mode 100644 backend/database/migrations/2026_07_15_020000_create_tasks_table.php create mode 100644 backend/database/migrations/2026_07_15_021000_enhance_notes_and_backfill_call_notes.php create mode 100644 backend/database/migrations/2026_07_15_022000_extend_notifications_audit_and_contact_phones.php create mode 100644 backend/database/migrations/2026_07_15_030000_create_deal_pipeline_foundation.php create mode 100644 backend/database/migrations/2026_07_15_031000_create_saved_views_and_preferences.php create mode 100644 backend/database/migrations/2026_07_15_032000_create_lead_scoring_and_sla.php create mode 100644 backend/database/migrations/2026_07_15_033000_create_automation_and_custom_fields.php create mode 100644 backend/database/migrations/2026_07_15_034000_enhance_quality_scripts_for_p2.php create mode 100644 backend/database/migrations/2026_07_15_035000_disable_mock_voip_outside_tests.php create mode 100644 backend/database/migrations/2026_07_15_040000_create_invoice_workflow.php create mode 100644 backend/database/migrations/2026_07_15_040500_set_default_workweek_to_saturday_thursday.php create mode 100644 backend/database/migrations/2026_07_15_042000_add_provider_lifecycle_to_calls.php create mode 100644 backend/database/migrations/2026_07_16_000000_add_page_dimensions_to_invoices.php create mode 100644 backend/database/migrations/2026_07_16_010000_add_source_settings_to_invoice_templates.php create mode 100644 backend/database/migrations/2026_07_22_000000_complete_workflow_interactions.php create mode 100644 backend/database/migrations/2026_07_23_010000_enhance_campaign_planning.php create mode 100644 backend/database/migrations/2026_07_23_020000_add_word_fields_to_invoices.php create mode 100644 backend/database/seeders/RolePermissionSeeder.php create mode 100644 backend/storage/backups/database-before-crm-ux-20260715-195530.sqlite create mode 100644 backend/storage/backups/database-before-p1-p2-20260715-185234.sqlite create mode 100644 backend/tests/Feature/CalendarAccessTest.php create mode 100644 backend/tests/Feature/CallContractTest.php create mode 100644 backend/tests/Feature/CallNoteNotificationIntegrityTest.php create mode 100644 backend/tests/Feature/CampaignMetricsTest.php create mode 100644 backend/tests/Feature/CoreCrmAuthorizationTest.php create mode 100644 backend/tests/Feature/CoreCrmIntegrityTest.php create mode 100644 backend/tests/Feature/FollowUpContractTest.php create mode 100644 backend/tests/Feature/InvoiceWorkflowTest.php create mode 100644 backend/tests/Feature/ProfessionalCrmP2Test.php create mode 100644 backend/tests/Feature/QualityScriptAuthorizationTest.php create mode 100644 backend/tests/Feature/RegressionFixesTest.php create mode 100644 backend/tests/Feature/ReportKpiTest.php create mode 100644 backend/tests/Feature/RolePermissionDashboardTest.php create mode 100644 backend/tests/Feature/SettingsBehaviorTest.php create mode 100644 backend/tests/Feature/TaskAuthorizationLifecycleTest.php create mode 100644 backend/tests/Feature/WorkflowInteractionTest.php create mode 100644 docs/CRM_CHANGE_CHECKLIST_FA.md create mode 100644 docs/P1_TASKS_AND_CALL_NOTES_FA.md create mode 100644 docs/P2_PROFESSIONAL_CRM_FA.md create mode 100644 docs/PERFORMANCE_AUDIT_FA.md create mode 100644 frontend/e2e/dark-mode-lead.spec.ts create mode 100644 frontend/e2e/fixtures.ts create mode 100644 frontend/e2e/invoice-word-wizard.spec.ts create mode 100644 frontend/e2e/lead-layout.spec.ts create mode 100644 frontend/e2e/mobile-shell-calendar.spec.ts create mode 100644 frontend/e2e/reports-layout.spec.ts create mode 100644 frontend/e2e/task-center.spec.ts create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/src/api/calendar.ts create mode 100644 frontend/src/api/invoices.ts create mode 100644 frontend/src/api/normalizers.ts create mode 100644 frontend/src/api/notes.ts create mode 100644 frontend/src/api/p2.ts create mode 100644 frontend/src/api/requestCache.ts create mode 100644 frontend/src/api/tasks.ts create mode 100644 frontend/src/components/activity/ActivityComposer.tsx create mode 100644 frontend/src/components/dashboard/DashboardPreferencesCard.tsx create mode 100644 frontend/src/components/dashboard/LiveDashboardCharts.tsx create mode 100644 frontend/src/components/dashboard/MyAgendaWidget.tsx create mode 100644 frontend/src/components/invoices/InvoiceTemplateRenderer.tsx create mode 100644 frontend/src/components/invoices/invoiceTemplateAssets.ts create mode 100644 frontend/src/components/invoices/paperSizes.ts create mode 100644 frontend/src/components/layout/MobileBottomNav.tsx create mode 100644 frontend/src/components/layout/NotificationPopover.tsx create mode 100644 frontend/src/components/layout/navigation.ts create mode 100644 frontend/src/components/notes/NoteComposer.tsx create mode 100644 frontend/src/components/notes/NoteTimeline.tsx create mode 100644 frontend/src/components/search/GlobalSearch.tsx create mode 100644 frontend/src/components/tasks/AssigneePicker.tsx create mode 100644 frontend/src/components/tasks/TaskDetails.tsx create mode 100644 frontend/src/components/tasks/TaskDrawer.tsx create mode 100644 frontend/src/components/tasks/TaskForm.tsx create mode 100644 frontend/src/components/tasks/TaskList.tsx create mode 100644 frontend/src/components/ui/Skeleton.tsx create mode 100644 frontend/src/components/ui/Switch.tsx create mode 100644 frontend/src/components/ui/TwoStepConfirmModal.tsx create mode 100644 frontend/src/pages/Calendar/CalendarPage.tsx create mode 100644 frontend/src/pages/Deals/DealKanban.tsx create mode 100644 frontend/src/pages/Deals/DealWorkspace.tsx create mode 100644 frontend/src/pages/Invoices/InvoiceCenter.tsx create mode 100644 frontend/src/pages/Invoices/InvoiceCreateWizard.tsx create mode 100644 frontend/src/pages/Invoices/InvoicePrintPage.tsx create mode 100644 frontend/src/pages/Invoices/InvoiceTemplateCreatePage.tsx create mode 100644 frontend/src/pages/Invoices/InvoiceTemplateList.tsx create mode 100644 frontend/src/pages/Invoices/InvoiceTemplateWizard.tsx create mode 100644 frontend/src/pages/Operations/OperationsCenter.tsx create mode 100644 frontend/src/pages/Profile/ProfileModal.tsx create mode 100644 frontend/src/pages/QualityReviews/QualityReviewList.tsx create mode 100644 frontend/src/pages/Sales/SalesPipelinePage.tsx create mode 100644 frontend/src/pages/SalesScripts/ScriptList.tsx create mode 100644 frontend/src/pages/Tasks/TaskCenter.tsx create mode 100644 frontend/src/test/CallNotes.test.tsx create mode 100644 frontend/src/test/DarkModeWorkspace.test.tsx create mode 100644 frontend/src/test/DashboardAgenda.test.tsx create mode 100644 frontend/src/test/LeadList.test.tsx create mode 100644 frontend/src/test/LiveDashboardCharts.test.tsx create mode 100644 frontend/src/test/MediaUrl.test.ts create mode 100644 frontend/src/test/NavigationIcons.test.ts create mode 100644 frontend/src/test/NotificationList.test.tsx create mode 100644 frontend/src/test/P2Workspace.test.tsx create mode 100644 frontend/src/test/SalesWorkspaceUX.test.tsx create mode 100644 frontend/src/test/TaskComponents.test.tsx create mode 100644 frontend/src/test/date.test.ts create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/types/note.ts create mode 100644 frontend/src/types/p2.ts create mode 100644 frontend/src/types/task.ts create mode 100644 frontend/src/utils/dataEvents.ts create mode 100644 frontend/src/utils/notificationSound.ts create mode 100644 run-project.bat create mode 100644 scripts/package-release.ps1 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 0000000000000000000000000000000000000000..5e2705f0e5269c040f3f643f4dd6869096b83580 GIT binary patch literal 991232 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%0A(AMB>wj+|d5TH|hyW2F0z`la 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 LOkaOoim`tI+WE_y literal 0 HcmV?d00001 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"> +