From b246b94754ce839f853214dceef22db0a81fe301 Mon Sep 17 00:00:00 2001 From: Toornaa Date: Sat, 11 Jul 2026 12:23:45 +0330 Subject: [PATCH] first commit --- .gitignore | 58 + README.md | 1 + backend/.editorconfig | 18 + backend/.env.example | 76 + backend/.gitattributes | 11 + backend/.gitignore | 44 + backend/README.md | 38 + .../Controllers/Api/ActivityLogController.php | 34 + .../Controllers/Api/AgentMobileController.php | 595 ++ .../Controllers/Api/AssignmentController.php | 147 + .../Controllers/Api/AttachmentController.php | 85 + .../Http/Controllers/Api/AuthController.php | 139 + .../Http/Controllers/Api/CallController.php | 118 + .../Controllers/Api/CampaignController.php | 116 + .../Controllers/Api/CompanyController.php | 121 + .../Controllers/Api/ContactController.php | 178 + .../Controllers/Api/DashboardController.php | 58 + .../Http/Controllers/Api/DealController.php | 87 + .../Controllers/Api/DuplicateController.php | 32 + .../Controllers/Api/FollowUpController.php | 186 + .../Http/Controllers/Api/ImportController.php | 161 + .../Http/Controllers/Api/LeadController.php | 396 + .../Controllers/Api/LeadStatusController.php | 77 + .../Controllers/Api/LostReasonController.php | 57 + .../Http/Controllers/Api/NoteController.php | 50 + .../Api/NotificationController.php | 49 + .../Api/PipelineStageController.php | 145 + .../Controllers/Api/ProductController.php | 69 + .../Api/QualityReviewController.php | 77 + .../Http/Controllers/Api/ReportController.php | 373 + .../Http/Controllers/Api/RoleController.php | 90 + .../Http/Controllers/Api/ScriptController.php | 128 + .../Controllers/Api/SettingController.php | 178 + .../Controllers/Api/TimelineController.php | 84 + .../Http/Controllers/Api/UserController.php | 170 + backend/app/Http/Controllers/Controller.php | 8 + backend/app/Http/Middleware/LogActivity.php | 31 + .../app/Http/Middleware/MaskPhoneNumber.php | 58 + .../app/Http/Middleware/SecurityHeaders.php | 39 + backend/app/Models/ActivityLog.php | 19 + backend/app/Models/Attachment.php | 28 + backend/app/Models/Call.php | 45 + backend/app/Models/CallLog.php | 39 + backend/app/Models/CallResult.php | 36 + backend/app/Models/Campaign.php | 49 + backend/app/Models/Company.php | 49 + backend/app/Models/Contact.php | 46 + backend/app/Models/ContactPhone.php | 41 + backend/app/Models/ContactRelation.php | 24 + backend/app/Models/Deal.php | 63 + backend/app/Models/FollowUp.php | 38 + backend/app/Models/ImportBatch.php | 43 + backend/app/Models/ImportBatchRow.php | 26 + backend/app/Models/Lead.php | 166 + backend/app/Models/LeadAssignment.php | 26 + backend/app/Models/LeadStatus.php | 21 + backend/app/Models/LostReason.php | 18 + backend/app/Models/MergeHistory.php | 21 + backend/app/Models/Note.php | 22 + backend/app/Models/Notification.php | 26 + backend/app/Models/PipelineStage.php | 21 + backend/app/Models/Product.php | 38 + backend/app/Models/QualityReview.php | 37 + backend/app/Models/SalesScript.php | 36 + backend/app/Models/ScriptSection.php | 21 + backend/app/Models/Setting.php | 29 + backend/app/Models/Team.php | 38 + backend/app/Models/User.php | 95 + backend/app/Policies/CallPolicy.php | 36 + backend/app/Policies/CampaignPolicy.php | 35 + backend/app/Policies/FollowUpPolicy.php | 31 + backend/app/Policies/ImportBatchPolicy.php | 29 + backend/app/Policies/LeadPolicy.php | 45 + backend/app/Policies/SettingPolicy.php | 18 + backend/app/Policies/UserPolicy.php | 33 + backend/app/Providers/AppServiceProvider.php | 49 + backend/app/Services/ActivityLogger.php | 26 + backend/app/Services/AssignmentService.php | 54 + backend/app/Services/CallService.php | 310 + backend/app/Services/DashboardService.php | 419 + backend/app/Services/DuplicateService.php | 110 + backend/app/Services/ImportService.php | 194 + backend/app/Services/LeadService.php | 216 + backend/app/Services/NotificationService.php | 79 + backend/app/Services/ReportService.php | 416 + backend/app/Services/VoIP/AmiProvider.php | 222 + backend/app/Services/VoIP/ApiProvider.php | 90 + backend/app/Services/VoIP/MockProvider.php | 39 + backend/app/Services/VoIP/SocketProvider.php | 90 + backend/app/Services/VoIP/VoIPManager.php | 44 + .../Services/VoIP/VoIPProviderInterface.php | 11 + backend/app/Support/AccessControl.php | 207 + backend/app/Support/PasswordPolicy.php | 27 + backend/app/Support/SettingsCatalog.php | 182 + backend/app/Support/WorkingHours.php | 34 + backend/artisan | 18 + backend/bootstrap/app.php | 43 + backend/bootstrap/cache/.gitignore | 2 + backend/bootstrap/providers.php | 7 + backend/composer.json | 90 + backend/composer.lock | 9147 +++++++++++++++++ backend/config/app.php | 126 + backend/config/auth.php | 117 + backend/config/cache.php | 117 + backend/config/cors.php | 17 + backend/config/database.php | 184 + backend/config/filesystems.php | 80 + backend/config/logging.php | 132 + backend/config/mail.php | 118 + backend/config/permission.php | 206 + backend/config/queue.php | 129 + backend/config/sanctum.php | 87 + backend/config/services.php | 38 + backend/config/session.php | 217 + backend/database/.gitignore | 1 + backend/database/factories/UserFactory.php | 45 + .../0001_01_01_000000_create_users_table.php | 49 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 57 + ...00_create_personal_access_tokens_table.php | 33 + ..._06_27_122703_create_permission_tables.php | 134 + ...6_27_123131_create_lead_statuses_table.php | 27 + .../2026_06_27_123131_create_teams_table.php | 32 + ...26_06_27_123132_create_campaigns_table.php | 37 + ...27_123132_create_pipeline_stages_table.php | 26 + .../2026_06_27_123133_create_leads_table.php | 53 + .../2026_06_27_123134_create_calls_table.php | 35 + ...6_06_27_123134_create_follow_ups_table.php | 33 + ...7_123135_create_lead_assignments_table.php | 25 + .../2026_06_27_123135_create_notes_table.php | 24 + ..._123136_create_import_batch_rows_table.php | 26 + ..._27_123136_create_import_batches_table.php | 32 + ...6_27_123137_create_sales_scripts_table.php | 26 + ...27_123137_create_script_sections_table.php | 25 + ...27_123138_create_quality_reviews_table.php | 35 + ...06_27_123139_create_call_results_table.php | 29 + ...026_06_27_123139_create_settings_table.php | 25 + ...6_27_123757_create_activity_logs_table.php | 32 + ...57_create_internal_notifications_table.php | 30 + ...2026_06_27_200000_enhance_sales_funnel.php | 71 + ..._27_201000_persian_sales_funnel_stages.php | 53 + ...202000_simplify_telephone_sales_funnel.php | 156 + ...70144_make_phone_unique_in_users_table.php | 20 + ...000_add_presence_fields_to_users_table.php | 38 + ..._28_150000_create_contact_route_tables.php | 118 + ...es_agent_preferred_view_to_users_table.php | 22 + ...120000_structure_workflow_and_settings.php | 278 + ...026_07_07_130000_phase5_core_sales_crm.php | 173 + ...0000_add_voip_extension_to_users_table.php | 27 + backend/database/seeders/DatabaseSeeder.php | 213 + backend/package.json | 17 + backend/phpunit.xml | 37 + backend/public/.htaccess | 25 + backend/public/favicon.ico | 0 backend/public/index.php | 20 + backend/public/robots.txt | 2 + backend/resources/css/app.css | 11 + backend/resources/js/app.js | 1 + backend/resources/js/bootstrap.js | 4 + backend/resources/views/welcome.blade.php | 277 + backend/routes/api.php | 188 + backend/routes/console.php | 8 + backend/routes/web.php | 7 + backend/start-backend.bat | 2 + backend/storage/app/.gitignore | 4 + backend/storage/app/private/.gitignore | 2 + backend/storage/app/public/.gitignore | 2 + backend/storage/framework/.gitignore | 9 + backend/storage/framework/cache/.gitignore | 3 + .../storage/framework/cache/data/.gitignore | 2 + backend/storage/framework/testing/.gitignore | 2 + backend/tests/Feature/AgentMobileTest.php | 250 + backend/tests/Feature/AuthLoginTest.php | 54 + backend/tests/Feature/CoreCrmTest.php | 106 + backend/tests/Feature/ExampleTest.php | 19 + backend/tests/Feature/LeadActionsTest.php | 219 + .../Feature/SecurityAuthorizationTest.php | 236 + backend/tests/Feature/UserManagementTest.php | 152 + backend/tests/TestCase.php | 10 + backend/tests/Unit/ExampleTest.php | 16 + backend/vite.config.js | 18 + frontend/.env.example | 3 + frontend/.gitignore | 28 + frontend/.oxlintrc.json | 8 + frontend/README.md | 38 + frontend/index.html | 23 + frontend/package-lock.json | 2617 +++++ frontend/package.json | 37 + frontend/public/agent-mobile-offline.html | 46 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/public/icons/icon-192.png | Bin 0 -> 3071 bytes frontend/public/icons/icon-512.png | Bin 0 -> 9540 bytes frontend/public/manifest.json | 85 + frontend/public/pwa-icon.svg | 7 + frontend/public/sample-leads-b2b.csv | 3 + frontend/public/service-worker.js | 112 + frontend/public/sw.js | 7 + frontend/src/App.css | 0 frontend/src/App.tsx | 144 + frontend/src/api/agentMobile.ts | 246 + frontend/src/api/assignments.ts | 18 + frontend/src/api/auth.ts | 86 + frontend/src/api/calls.ts | 62 + frontend/src/api/campaigns.ts | 26 + frontend/src/api/client.ts | 50 + frontend/src/api/coreCrm.ts | 91 + frontend/src/api/dashboard.ts | 21 + frontend/src/api/followups.ts | 27 + frontend/src/api/imports.ts | 60 + frontend/src/api/leadStatuses.ts | 21 + frontend/src/api/leads.ts | 126 + frontend/src/api/notifications.ts | 20 + frontend/src/api/pipeline.ts | 28 + frontend/src/api/qualityReviews.ts | 17 + frontend/src/api/reports.ts | 76 + frontend/src/api/roles.ts | 35 + frontend/src/api/scripts.ts | 26 + frontend/src/api/settings.ts | 30 + frontend/src/api/users.ts | 69 + frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + .../src/components/AgentRouteRedirect.tsx | 13 + frontend/src/components/ProtectedRoute.tsx | 34 + frontend/src/components/layout/AppLayout.tsx | 39 + frontend/src/components/layout/Header.tsx | 52 + frontend/src/components/layout/Sidebar.tsx | 148 + frontend/src/components/ui/Badge.tsx | 34 + frontend/src/components/ui/Button.tsx | 48 + frontend/src/components/ui/Card.tsx | 22 + frontend/src/components/ui/Icon.tsx | 48 + frontend/src/components/ui/Input.tsx | 24 + frontend/src/components/ui/Modal.tsx | 46 + frontend/src/components/ui/Pagination.tsx | 40 + .../src/components/ui/PersianDateInput.tsx | 49 + frontend/src/components/ui/Select.tsx | 40 + frontend/src/components/ui/Spinner.tsx | 15 + frontend/src/components/ui/Table.tsx | 74 + frontend/src/components/ui/Textarea.tsx | 25 + frontend/src/components/ui/Toast.tsx | 31 + frontend/src/components/ui/toastStore.ts | 32 + frontend/src/index.css | 550 + frontend/src/main.tsx | 43 + .../pages/AgentMobile/AgentMobileLayout.tsx | 249 + .../pages/AgentMobile/AgentMobilePages.tsx | 947 ++ frontend/src/pages/Calls/CallList.tsx | 131 + frontend/src/pages/Campaigns/CampaignList.tsx | 180 + frontend/src/pages/CoreCrm/CoreCrmPage.tsx | 277 + .../src/pages/Dashboard/AdminDashboard.tsx | 208 + .../src/pages/Dashboard/AgentDashboard.tsx | 184 + .../pages/Dashboard/SupervisorDashboard.tsx | 139 + frontend/src/pages/FollowUps/FollowUpList.tsx | 153 + frontend/src/pages/Leads/LeadFunnel.tsx | 856 ++ frontend/src/pages/Leads/LeadList.tsx | 644 ++ frontend/src/pages/Leads/LeadShow.tsx | 811 ++ frontend/src/pages/Login.tsx | 77 + .../pages/Notifications/NotificationList.tsx | 93 + frontend/src/pages/Profile/ProfilePage.tsx | 89 + frontend/src/pages/Reports/ReportsIndex.tsx | 480 + frontend/src/pages/Settings/SettingsPage.tsx | 230 + frontend/src/pages/Users/UserList.tsx | 213 + frontend/src/stores/authStore.ts | 94 + frontend/src/stores/notificationStore.ts | 21 + frontend/src/stores/settingsStore.ts | 32 + frontend/src/types/index.ts | 573 ++ frontend/src/utils/agentMobileOffline.ts | 199 + frontend/src/utils/agentView.ts | 38 + frontend/src/utils/date.ts | 87 + frontend/src/utils/media.ts | 16 + frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.app.json | 25 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 42 + 275 files changed, 36088 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.editorconfig create mode 100644 backend/.env.example create mode 100644 backend/.gitattributes create mode 100644 backend/.gitignore create mode 100644 backend/README.md create mode 100644 backend/app/Http/Controllers/Api/ActivityLogController.php create mode 100644 backend/app/Http/Controllers/Api/AgentMobileController.php create mode 100644 backend/app/Http/Controllers/Api/AssignmentController.php create mode 100644 backend/app/Http/Controllers/Api/AttachmentController.php create mode 100644 backend/app/Http/Controllers/Api/AuthController.php create mode 100644 backend/app/Http/Controllers/Api/CallController.php create mode 100644 backend/app/Http/Controllers/Api/CampaignController.php create mode 100644 backend/app/Http/Controllers/Api/CompanyController.php create mode 100644 backend/app/Http/Controllers/Api/ContactController.php create mode 100644 backend/app/Http/Controllers/Api/DashboardController.php create mode 100644 backend/app/Http/Controllers/Api/DealController.php create mode 100644 backend/app/Http/Controllers/Api/DuplicateController.php create mode 100644 backend/app/Http/Controllers/Api/FollowUpController.php create mode 100644 backend/app/Http/Controllers/Api/ImportController.php create mode 100644 backend/app/Http/Controllers/Api/LeadController.php create mode 100644 backend/app/Http/Controllers/Api/LeadStatusController.php create mode 100644 backend/app/Http/Controllers/Api/LostReasonController.php create mode 100644 backend/app/Http/Controllers/Api/NoteController.php create mode 100644 backend/app/Http/Controllers/Api/NotificationController.php create mode 100644 backend/app/Http/Controllers/Api/PipelineStageController.php create mode 100644 backend/app/Http/Controllers/Api/ProductController.php create mode 100644 backend/app/Http/Controllers/Api/QualityReviewController.php create mode 100644 backend/app/Http/Controllers/Api/ReportController.php create mode 100644 backend/app/Http/Controllers/Api/RoleController.php create mode 100644 backend/app/Http/Controllers/Api/ScriptController.php create mode 100644 backend/app/Http/Controllers/Api/SettingController.php create mode 100644 backend/app/Http/Controllers/Api/TimelineController.php create mode 100644 backend/app/Http/Controllers/Api/UserController.php create mode 100644 backend/app/Http/Controllers/Controller.php create mode 100644 backend/app/Http/Middleware/LogActivity.php create mode 100644 backend/app/Http/Middleware/MaskPhoneNumber.php create mode 100644 backend/app/Http/Middleware/SecurityHeaders.php create mode 100644 backend/app/Models/ActivityLog.php create mode 100644 backend/app/Models/Attachment.php create mode 100644 backend/app/Models/Call.php create mode 100644 backend/app/Models/CallLog.php create mode 100644 backend/app/Models/CallResult.php create mode 100644 backend/app/Models/Campaign.php create mode 100644 backend/app/Models/Company.php create mode 100644 backend/app/Models/Contact.php create mode 100644 backend/app/Models/ContactPhone.php create mode 100644 backend/app/Models/ContactRelation.php create mode 100644 backend/app/Models/Deal.php create mode 100644 backend/app/Models/FollowUp.php create mode 100644 backend/app/Models/ImportBatch.php create mode 100644 backend/app/Models/ImportBatchRow.php create mode 100644 backend/app/Models/Lead.php create mode 100644 backend/app/Models/LeadAssignment.php create mode 100644 backend/app/Models/LeadStatus.php create mode 100644 backend/app/Models/LostReason.php create mode 100644 backend/app/Models/MergeHistory.php create mode 100644 backend/app/Models/Note.php create mode 100644 backend/app/Models/Notification.php create mode 100644 backend/app/Models/PipelineStage.php create mode 100644 backend/app/Models/Product.php create mode 100644 backend/app/Models/QualityReview.php create mode 100644 backend/app/Models/SalesScript.php create mode 100644 backend/app/Models/ScriptSection.php create mode 100644 backend/app/Models/Setting.php create mode 100644 backend/app/Models/Team.php create mode 100644 backend/app/Models/User.php create mode 100644 backend/app/Policies/CallPolicy.php create mode 100644 backend/app/Policies/CampaignPolicy.php create mode 100644 backend/app/Policies/FollowUpPolicy.php create mode 100644 backend/app/Policies/ImportBatchPolicy.php create mode 100644 backend/app/Policies/LeadPolicy.php create mode 100644 backend/app/Policies/SettingPolicy.php create mode 100644 backend/app/Policies/UserPolicy.php create mode 100644 backend/app/Providers/AppServiceProvider.php create mode 100644 backend/app/Services/ActivityLogger.php create mode 100644 backend/app/Services/AssignmentService.php create mode 100644 backend/app/Services/CallService.php create mode 100644 backend/app/Services/DashboardService.php create mode 100644 backend/app/Services/DuplicateService.php create mode 100644 backend/app/Services/ImportService.php create mode 100644 backend/app/Services/LeadService.php create mode 100644 backend/app/Services/NotificationService.php create mode 100644 backend/app/Services/ReportService.php create mode 100644 backend/app/Services/VoIP/AmiProvider.php create mode 100644 backend/app/Services/VoIP/ApiProvider.php create mode 100644 backend/app/Services/VoIP/MockProvider.php create mode 100644 backend/app/Services/VoIP/SocketProvider.php create mode 100644 backend/app/Services/VoIP/VoIPManager.php create mode 100644 backend/app/Services/VoIP/VoIPProviderInterface.php create mode 100644 backend/app/Support/AccessControl.php create mode 100644 backend/app/Support/PasswordPolicy.php create mode 100644 backend/app/Support/SettingsCatalog.php create mode 100644 backend/app/Support/WorkingHours.php create mode 100644 backend/artisan create mode 100644 backend/bootstrap/app.php create mode 100644 backend/bootstrap/cache/.gitignore create mode 100644 backend/bootstrap/providers.php create mode 100644 backend/composer.json create mode 100644 backend/composer.lock create mode 100644 backend/config/app.php create mode 100644 backend/config/auth.php create mode 100644 backend/config/cache.php create mode 100644 backend/config/cors.php create mode 100644 backend/config/database.php create mode 100644 backend/config/filesystems.php create mode 100644 backend/config/logging.php create mode 100644 backend/config/mail.php create mode 100644 backend/config/permission.php create mode 100644 backend/config/queue.php create mode 100644 backend/config/sanctum.php create mode 100644 backend/config/services.php create mode 100644 backend/config/session.php create mode 100644 backend/database/.gitignore create mode 100644 backend/database/factories/UserFactory.php create mode 100644 backend/database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 backend/database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 backend/database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 backend/database/migrations/2026_06_27_122700_create_personal_access_tokens_table.php create mode 100644 backend/database/migrations/2026_06_27_122703_create_permission_tables.php create mode 100644 backend/database/migrations/2026_06_27_123131_create_lead_statuses_table.php create mode 100644 backend/database/migrations/2026_06_27_123131_create_teams_table.php create mode 100644 backend/database/migrations/2026_06_27_123132_create_campaigns_table.php create mode 100644 backend/database/migrations/2026_06_27_123132_create_pipeline_stages_table.php create mode 100644 backend/database/migrations/2026_06_27_123133_create_leads_table.php create mode 100644 backend/database/migrations/2026_06_27_123134_create_calls_table.php create mode 100644 backend/database/migrations/2026_06_27_123134_create_follow_ups_table.php create mode 100644 backend/database/migrations/2026_06_27_123135_create_lead_assignments_table.php create mode 100644 backend/database/migrations/2026_06_27_123135_create_notes_table.php create mode 100644 backend/database/migrations/2026_06_27_123136_create_import_batch_rows_table.php create mode 100644 backend/database/migrations/2026_06_27_123136_create_import_batches_table.php create mode 100644 backend/database/migrations/2026_06_27_123137_create_sales_scripts_table.php create mode 100644 backend/database/migrations/2026_06_27_123137_create_script_sections_table.php create mode 100644 backend/database/migrations/2026_06_27_123138_create_quality_reviews_table.php create mode 100644 backend/database/migrations/2026_06_27_123139_create_call_results_table.php create mode 100644 backend/database/migrations/2026_06_27_123139_create_settings_table.php create mode 100644 backend/database/migrations/2026_06_27_123757_create_activity_logs_table.php create mode 100644 backend/database/migrations/2026_06_27_123757_create_internal_notifications_table.php create mode 100644 backend/database/migrations/2026_06_27_200000_enhance_sales_funnel.php create mode 100644 backend/database/migrations/2026_06_27_201000_persian_sales_funnel_stages.php create mode 100644 backend/database/migrations/2026_06_27_202000_simplify_telephone_sales_funnel.php create mode 100644 backend/database/migrations/2026_06_28_070144_make_phone_unique_in_users_table.php create mode 100644 backend/database/migrations/2026_06_28_120000_add_presence_fields_to_users_table.php create mode 100644 backend/database/migrations/2026_06_28_150000_create_contact_route_tables.php create mode 100644 backend/database/migrations/2026_06_30_000000_add_sales_agent_preferred_view_to_users_table.php create mode 100644 backend/database/migrations/2026_07_02_120000_structure_workflow_and_settings.php create mode 100644 backend/database/migrations/2026_07_07_130000_phase5_core_sales_crm.php create mode 100644 backend/database/migrations/2026_07_07_160000_add_voip_extension_to_users_table.php create mode 100644 backend/database/seeders/DatabaseSeeder.php create mode 100644 backend/package.json create mode 100644 backend/phpunit.xml create mode 100644 backend/public/.htaccess create mode 100644 backend/public/favicon.ico create mode 100644 backend/public/index.php create mode 100644 backend/public/robots.txt create mode 100644 backend/resources/css/app.css create mode 100644 backend/resources/js/app.js create mode 100644 backend/resources/js/bootstrap.js create mode 100644 backend/resources/views/welcome.blade.php create mode 100644 backend/routes/api.php create mode 100644 backend/routes/console.php create mode 100644 backend/routes/web.php create mode 100644 backend/start-backend.bat create mode 100644 backend/storage/app/.gitignore create mode 100644 backend/storage/app/private/.gitignore create mode 100644 backend/storage/app/public/.gitignore create mode 100644 backend/storage/framework/.gitignore create mode 100644 backend/storage/framework/cache/.gitignore create mode 100644 backend/storage/framework/cache/data/.gitignore create mode 100644 backend/storage/framework/testing/.gitignore create mode 100644 backend/tests/Feature/AgentMobileTest.php create mode 100644 backend/tests/Feature/AuthLoginTest.php create mode 100644 backend/tests/Feature/CoreCrmTest.php create mode 100644 backend/tests/Feature/ExampleTest.php create mode 100644 backend/tests/Feature/LeadActionsTest.php create mode 100644 backend/tests/Feature/SecurityAuthorizationTest.php create mode 100644 backend/tests/Feature/UserManagementTest.php create mode 100644 backend/tests/TestCase.php create mode 100644 backend/tests/Unit/ExampleTest.php create mode 100644 backend/vite.config.js create mode 100644 frontend/.env.example create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/agent-mobile-offline.html create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/public/icons/icon-192.png create mode 100644 frontend/public/icons/icon-512.png create mode 100644 frontend/public/manifest.json create mode 100644 frontend/public/pwa-icon.svg create mode 100644 frontend/public/sample-leads-b2b.csv create mode 100644 frontend/public/service-worker.js create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/agentMobile.ts create mode 100644 frontend/src/api/assignments.ts create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/api/calls.ts create mode 100644 frontend/src/api/campaigns.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/coreCrm.ts create mode 100644 frontend/src/api/dashboard.ts create mode 100644 frontend/src/api/followups.ts create mode 100644 frontend/src/api/imports.ts create mode 100644 frontend/src/api/leadStatuses.ts create mode 100644 frontend/src/api/leads.ts create mode 100644 frontend/src/api/notifications.ts create mode 100644 frontend/src/api/pipeline.ts create mode 100644 frontend/src/api/qualityReviews.ts create mode 100644 frontend/src/api/reports.ts create mode 100644 frontend/src/api/roles.ts create mode 100644 frontend/src/api/scripts.ts create mode 100644 frontend/src/api/settings.ts create mode 100644 frontend/src/api/users.ts create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/AgentRouteRedirect.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/components/layout/AppLayout.tsx create mode 100644 frontend/src/components/layout/Header.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/ui/Badge.tsx create mode 100644 frontend/src/components/ui/Button.tsx create mode 100644 frontend/src/components/ui/Card.tsx create mode 100644 frontend/src/components/ui/Icon.tsx create mode 100644 frontend/src/components/ui/Input.tsx create mode 100644 frontend/src/components/ui/Modal.tsx create mode 100644 frontend/src/components/ui/Pagination.tsx create mode 100644 frontend/src/components/ui/PersianDateInput.tsx create mode 100644 frontend/src/components/ui/Select.tsx create mode 100644 frontend/src/components/ui/Spinner.tsx create mode 100644 frontend/src/components/ui/Table.tsx create mode 100644 frontend/src/components/ui/Textarea.tsx create mode 100644 frontend/src/components/ui/Toast.tsx create mode 100644 frontend/src/components/ui/toastStore.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AgentMobile/AgentMobileLayout.tsx create mode 100644 frontend/src/pages/AgentMobile/AgentMobilePages.tsx create mode 100644 frontend/src/pages/Calls/CallList.tsx create mode 100644 frontend/src/pages/Campaigns/CampaignList.tsx create mode 100644 frontend/src/pages/CoreCrm/CoreCrmPage.tsx create mode 100644 frontend/src/pages/Dashboard/AdminDashboard.tsx create mode 100644 frontend/src/pages/Dashboard/AgentDashboard.tsx create mode 100644 frontend/src/pages/Dashboard/SupervisorDashboard.tsx create mode 100644 frontend/src/pages/FollowUps/FollowUpList.tsx create mode 100644 frontend/src/pages/Leads/LeadFunnel.tsx create mode 100644 frontend/src/pages/Leads/LeadList.tsx create mode 100644 frontend/src/pages/Leads/LeadShow.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Notifications/NotificationList.tsx create mode 100644 frontend/src/pages/Profile/ProfilePage.tsx create mode 100644 frontend/src/pages/Reports/ReportsIndex.tsx create mode 100644 frontend/src/pages/Settings/SettingsPage.tsx create mode 100644 frontend/src/pages/Users/UserList.tsx create mode 100644 frontend/src/stores/authStore.ts create mode 100644 frontend/src/stores/notificationStore.ts create mode 100644 frontend/src/stores/settingsStore.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/utils/agentMobileOffline.ts create mode 100644 frontend/src/utils/agentView.ts create mode 100644 frontend/src/utils/date.ts create mode 100644 frontend/src/utils/media.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4c64e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Root hygiene +.DS_Store +Thumbs.db +*.log +*.tmp +*.bak + +# Local environment and secrets +.env +.env.* +!.env.example +auth.json +cookies*.txt +**/cookies*.txt + +# Dependencies +backend/vendor/ +frontend/node_modules/ +node_modules/ +vendor/ + +# Build output +backend/public/build/ +backend/public/hot +frontend/dist/ +frontend/dist-ssr/ + +# Laravel runtime data +backend/storage/app/* +!backend/storage/app/.gitignore +!backend/storage/app/public/ +backend/storage/app/public/* +!backend/storage/app/public/.gitignore +backend/storage/framework/cache/* +!backend/storage/framework/cache/.gitignore +backend/storage/framework/sessions/* +!backend/storage/framework/sessions/.gitignore +backend/storage/framework/testing/* +!backend/storage/framework/testing/.gitignore +backend/storage/framework/views/* +!backend/storage/framework/views/.gitignore +backend/storage/logs/* +!backend/storage/logs/.gitignore +backend/bootstrap/cache/* +!backend/bootstrap/cache/.gitignore +backend/database/*.sqlite +backend/database/*.sqlite-* +backend/.phpunit.result.cache + +# Frontend local files +frontend/*.local + +# Editors +.fleet/ +.idea/ +.nova/ +.vscode/ +.zed/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ + diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[compose.yaml] +indent_size = 4 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..cd39dbf --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,76 @@ +APP_NAME="CRM" +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://127.0.0.1:8800 +FRONTEND_URL=http://localhost:5173 +SANCTUM_STATEFUL_DOMAINS=localhost:5173,127.0.0.1:5173 +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +APP_LOCALE=fa +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=fa_IR + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +DB_DATABASE=database/database.sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=file +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +# Keep this null for local dev so Laravel creates host-only cookies for localhost/127.0.0.1. +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 +MEMCACHED_PORT=11211 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_USERNAME=null +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +POSTMARK_API_KEY= +RESEND_API_KEY= +SLACK_BOT_USER_OAUTH_TOKEN= +SLACK_BOT_USER_DEFAULT_CHANNEL= + +VITE_APP_NAME="${APP_NAME}" diff --git a/backend/.gitattributes b/backend/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/backend/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..99aa854 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,44 @@ +*.log +.DS_Store +.env +.env.* +!.env.example +.phpactor.json +.phpunit.result.cache +cookies*.txt +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/app/* +!/storage/app/.gitignore +!/storage/app/public/ +/storage/app/public/* +!/storage/app/public/.gitignore +/storage/framework/cache/* +!/storage/framework/cache/.gitignore +/storage/framework/sessions/* +!/storage/framework/sessions/.gitignore +/storage/framework/testing/* +!/storage/framework/testing/.gitignore +/storage/framework/views/* +!/storage/framework/views/.gitignore +/storage/logs/* +!/storage/logs/.gitignore +/storage/pail +/bootstrap/cache/* +!/bootstrap/cache/.gitignore +/database/*.sqlite +/database/*.sqlite-* +/vendor +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..753c2de --- /dev/null +++ b/backend/README.md @@ -0,0 +1,38 @@ +# CRM Backend + +Laravel 12 API for the CRM. See the root `README.md` for full setup, testing, security, and project-structure notes. + +## Quick Start + +```bash +composer install +copy .env.example .env +php artisan key:generate +type nul > database\database.sqlite +php artisan migrate --seed +php artisan serve --host=127.0.0.1 --port=8800 +``` + +Required PHP extensions include `gd`, `mbstring`, `openssl`, `pdo_sqlite`, `zip`, `xml`, `xmlreader`, `xmlwriter`, `simplexml`, `dom`, `fileinfo`, and `curl`. Check the current machine with: + +```bash +composer check-platform-reqs +``` + +Default local backend: + +```text +Host: 127.0.0.1 +Port: 8800 +URL: http://127.0.0.1:8800 +``` + +To use another port, pass a different `--port` to `php artisan serve` and update `APP_URL`, `SANCTUM_STATEFUL_DOMAINS`, `CORS_ALLOWED_ORIGINS`, and the frontend `VITE_BACKEND_URL`. + +Run tests with: + +```bash +php artisan test +``` + +Do not commit `.env`, `database/*.sqlite`, `storage` runtime data, `bootstrap/cache` output, cookie files, or `vendor`. diff --git a/backend/app/Http/Controllers/Api/ActivityLogController.php b/backend/app/Http/Controllers/Api/ActivityLogController.php new file mode 100644 index 0000000..20b43db --- /dev/null +++ b/backend/app/Http/Controllers/Api/ActivityLogController.php @@ -0,0 +1,34 @@ +action) { + $query->where('action', 'like', "%{$request->action}%"); + } + + if ($request->user_id) { + $query->where('user_id', $request->user_id); + } + + if ($request->date_from) { + $query->whereDate('created_at', '>=', $request->date_from); + } + + if ($request->date_to) { + $query->whereDate('created_at', '<=', $request->date_to); + } + + return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 30)); + } +} diff --git a/backend/app/Http/Controllers/Api/AgentMobileController.php b/backend/app/Http/Controllers/Api/AgentMobileController.php new file mode 100644 index 0000000..c63b203 --- /dev/null +++ b/backend/app/Http/Controllers/Api/AgentMobileController.php @@ -0,0 +1,595 @@ +json([ + 'data' => [ + 'preferred_view' => $request->user()->sales_agent_preferred_view ?? 'auto', + 'call_results' => CallResult::where('is_active', true) + ->orderBy('sort_order') + ->get(['name', 'slug', 'requires_follow_up', 'is_final']) + ->map(fn(CallResult $result) => [ + 'name' => $result->name, + 'slug' => $result->slug, + 'requires_follow_up' => $result->requires_follow_up, + 'is_final' => $result->is_final, + ]) + ->values(), + ], + ]); + } + + public function dashboard(Request $request): JsonResponse + { + $user = $request->user(); + $today = now()->toDateString(); + $leadQuery = $this->assignedLeadQuery($user->id); + $callQuery = Call::where('user_id', $user->id)->whereDate('created_at', $today); + $followUpQuery = FollowUp::where('user_id', $user->id); + $nextLead = $this->queueQuery($user->id)->first(); + + return response()->json([ + 'data' => [ + 'stats' => [ + 'today_calls_count' => (clone $callQuery)->count(), + 'today_follow_ups_count' => (clone $followUpQuery)->whereDate('scheduled_at', $today)->where('status', 'pending')->count(), + 'overdue_follow_ups_count' => (clone $followUpQuery)->where('status', 'pending')->where('scheduled_at', '<', now())->count(), + 'new_assigned_leads_count' => (clone $leadQuery)->whereDate('created_at', $today)->count(), + 'successful_calls_today' => (clone $callQuery)->whereIn('result', $this->successfulResults())->count(), + 'failed_calls_today' => (clone $callQuery)->whereNotNull('result')->whereNotIn('result', $this->successfulResults())->count(), + 'leads_needing_action' => $this->queueQuery($user->id)->count(), + ], + 'suggested_call' => $nextLead ? $this->leadCard($nextLead) : null, + ], + ]); + } + + public function callQueue(Request $request): JsonResponse + { + $query = $this->queueQuery($request->user()->id); + $this->applyMobileFilter($query, $request->query('filter')); + + return response()->json([ + 'data' => $query->limit(30)->get()->map(fn(Lead $lead) => $this->leadCard($lead))->values(), + ]); + } + + public function leads(Request $request): JsonResponse + { + $query = $this->assignedLeadQuery($request->user()->id); + $this->applyMobileFilter($query, $request->query('filter')); + + if ($request->query('filter') === 'interested') { + $query->where(function ($q) { + $q->where('interest_level', 'hot') + ->orWhere('last_call_result', 'علاقه‌مند بود') + ->orWhereHas('pipelineStage', fn($stage) => $stage->where('name', 'like', '%علاقه‌مند%')); + }); + } + + if ($request->query('filter') === 'proposal') { + $query->whereHas('pipelineStage', fn($stage) => $stage->where('name', 'like', '%پیشنهاد%')); + } + + if ($request->query('filter') === 'closed') { + $query->whereNotNull('final_result'); + } + + return response()->json([ + 'data' => $query + ->orderByDesc('priority') + ->orderByRaw('next_follow_up_at IS NULL') + ->orderBy('next_follow_up_at') + ->limit(40) + ->get() + ->map(fn(Lead $lead) => $this->leadCard($lead)) + ->values(), + ]); + } + + public function lead(Request $request, Lead $lead): JsonResponse + { + if ($lead->assigned_to !== $request->user()->id) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + $lead->load([ + 'leadStatus:id,name,color', + 'pipelineStage:id,name,color', + '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), + ]); + + $primary = $this->primaryContact($lead); + + return response()->json([ + 'data' => [ + 'card' => $this->leadCard($lead), + 'primary_contact' => $primary ? $this->contactSummary($primary) : null, + 'summary' => [ + 'id' => $lead->id, + 'name' => $this->leadName($lead), + 'company' => $lead->company, + 'city' => $lead->city, + 'province' => $lead->province, + 'source' => $lead->source, + 'interest_level' => $lead->interest_level, + 'priority' => $lead->priority, + 'score' => $lead->lead_score, + 'status' => $lead->leadStatus?->name, + 'stage' => $lead->pipelineStage?->name, + 'notes' => $lead->notes, + 'tags' => $this->tags($lead), + 'final_result' => $lead->final_result, + ], + '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(), + '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) => [ + 'id' => $relation->id, + 'from' => $relation->fromContact?->name, + 'to' => $relation->toContact?->name, + 'type' => $relation->relation_type, + 'description' => $relation->description, + ])->values(), + ], + ]); + } + + 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', + ]) + ->where('user_id', $request->user()->id) + ->where('scheduled_at', '<=', now()->addWeek()) + ->orderBy('scheduled_at') + ->limit(80) + ->get() + ->map(fn(FollowUp $followUp) => $this->followUpCard($followUp)); + + return response()->json([ + 'data' => [ + 'overdue' => $items->where('group', 'overdue')->values(), + 'today' => $items->where('group', 'today')->values(), + 'tomorrow' => $items->where('group', 'tomorrow')->values(), + 'week' => $items->where('group', 'week')->values(), + 'done' => $items->where('group', 'done')->values(), + ], + ]); + } + + public function preferredView(Request $request): JsonResponse + { + $validated = $request->validate([ + 'preferred_view' => ['required', Rule::in(['auto', 'mobile', 'desktop'])], + ]); + + $user = $request->user(); + $user->forceFill([ + 'sales_agent_preferred_view' => $validated['preferred_view'], + ])->save(); + + ActivityLogger::log('agent_mobile_preferred_view_updated', "User {$user->phone} updated Sales Agent preferred view"); + + $user->load('roles.permissions', 'teams'); + $user->permissions = $user->getAllPermissions()->pluck('name'); + + return response()->json(['data' => $user]); + } + + public function startCall(Request $request): JsonResponse + { + $validated = $request->validate([ + 'lead_id' => 'required|exists:leads,id', + 'contact_phone_id' => 'nullable|exists:contact_phones,id', + ]); + + $lead = Lead::findOrFail($validated['lead_id']); + if (!$this->canAccessLead($request, $lead)) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + if (!empty($validated['contact_phone_id'])) { + $belongsToLead = ContactPhone::where('id', $validated['contact_phone_id']) + ->whereHas('contact', fn($query) => $query->where('lead_id', $lead->id)) + ->exists(); + + if (!$belongsToLead) { + return response()->json(['message' => 'شماره انتخاب‌شده برای این لید معتبر نیست'], 422); + } + } + + $payload = $this->callService->initiateCall( + $lead->id, + $request->user()->id, + $validated['contact_phone_id'] ?? null, + false + ); + unset($payload['recording_url'], $payload['provider_call_id']); + + return response()->json(['data' => $payload], 201); + } + + public function callResult(Request $request): JsonResponse + { + $validated = $request->validate([ + 'call_id' => 'required|exists:calls,id', + 'result' => ['required', Rule::in($this->mobileCallResults())], + 'notes' => 'nullable|string|max:2000', + 'next_follow_up_at' => 'nullable|date', + 'mark_phone_wrong' => 'nullable|boolean', + 'disinterest_reason' => 'nullable|string|max:500', + 'close_lead' => 'nullable|boolean', + 'referral' => 'nullable|array', + 'referral.name' => 'required_if:result,معرفی شماره جدید|string|max:255', + 'referral.phone' => 'required_if:result,معرفی شماره جدید|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|max:1000', + 'referral.make_primary' => 'nullable|boolean', + 'referral.next_follow_up_at' => 'nullable|date', + ]); + + $call = Call::with('lead')->findOrFail($validated['call_id']); + 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) { + return response()->json(['message' => 'برای این نتیجه تماس، زمان پیگیری الزامی است'], 422); + } + + $registeredCall = $this->callService->registerResult( + $call->id, + $validated['result'], + $validated['notes'] ?? null, + $followUpAt, + $validated['referral'] ?? null, + [ + 'mark_phone_wrong' => (bool) ($validated['mark_phone_wrong'] ?? false), + 'disinterest_reason' => $validated['disinterest_reason'] ?? null, + 'close_lead' => (bool) ($validated['close_lead'] ?? false), + ] + ); + + return response()->json([ + 'message' => 'نتیجه تماس ثبت شد', + 'data' => [ + '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)), + ], + ]); + } + + public function storeFollowUp(Request $request): JsonResponse + { + $validated = $request->validate([ + 'lead_id' => 'required|exists:leads,id', + 'call_id' => 'nullable|exists:calls,id', + 'scheduled_at' => 'required|date', + 'notes' => 'nullable|string|max:2000', + ]); + + $lead = Lead::findOrFail($validated['lead_id']); + 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()) { + 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); + + return response()->json(['data' => $this->followUpCard($followUp->load('lead.contacts.phones'))], 201); + } + + public function newContact(Request $request): JsonResponse + { + $validated = $request->validate([ + 'lead_id' => 'required|exists:leads,id', + 'from_contact_id' => 'nullable|exists:contacts,id', + 'name' => 'required|string|max:255', + 'phone' => 'required|string|max:30', + 'phone_type' => 'nullable|string|in:mobile,landline,extension', + 'role' => 'nullable|string|max:80', + 'relation_description' => 'nullable|string|max:255', + 'description' => 'nullable|string|max:1000', + 'make_primary' => 'nullable|boolean', + ]); + + $lead = Lead::findOrFail($validated['lead_id']); + 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()) { + return response()->json(['message' => 'مخاطب معرف برای این لید معتبر نیست'], 422); + } + + $contact = DB::transaction(function () use ($validated, $lead, $request) { + if ($validated['make_primary'] ?? false) { + Contact::where('lead_id', $lead->id)->update(['is_primary' => false]); + } + + $contact = Contact::create([ + 'lead_id' => $lead->id, + 'name' => $validated['name'], + 'role' => $validated['role'] ?? null, + 'description' => $validated['description'] ?? null, + 'status' => 'active', + 'is_primary' => (bool) ($validated['make_primary'] ?? false), + 'primary_reason' => ($validated['make_primary'] ?? false) ? 'ثبت‌شده از نسخه موبایل' : null, + 'created_by' => $request->user()->id, + ]); + + ContactPhone::create([ + 'contact_id' => $contact->id, + 'phone' => $validated['phone'], + 'type' => $validated['phone_type'] ?? 'mobile', + 'status' => 'active', + ]); + + ContactRelation::create([ + 'lead_id' => $lead->id, + 'from_contact_id' => $validated['from_contact_id'] ?? null, + 'to_contact_id' => $contact->id, + 'relation_type' => 'introduced', + 'description' => $validated['relation_description'] ?? null, + 'created_by' => $request->user()->id, + ]); + + return $contact->load('phones'); + }); + + ActivityLogger::log('agent_mobile_contact_created', "Mobile contact {$contact->name} created for lead {$lead->id}", $contact); + + return response()->json(['data' => $this->contactSummary($contact)], 201); + } + + private function assignedLeadQuery(int $userId) + { + return Lead::query() + ->with([ + 'leadStatus:id,name,color', + 'pipelineStage:id,name,color', + 'contacts.phones:id,contact_id,type,status,call_count,last_call_result,last_called_at', + ]) + ->where('assigned_to', $userId); + } + + private function canAccessLead(Request $request, Lead $lead): bool + { + return (int) $lead->assigned_to === (int) $request->user()->id; + } + + private function mobileCallResults(): array + { + $results = CallResult::where('is_active', true)->orderBy('sort_order')->pluck('name')->all(); + + return array_values(array_unique(array_merge($results, [ + 'پاسخ داد', + 'جواب نداد', + 'اشغال بود', + 'بعداً تماس بگیر', + 'علاقه‌مند است', + 'پیشنهاد ارسال شد', + 'شماره اشتباه', + 'معرفی شماره جدید', + 'عدم تمایل', + ]))); + } + + private function queueQuery(int $userId) + { + return $this->assignedLeadQuery($userId) + ->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())); + }) + ->orderByDesc('priority') + ->orderByRaw('next_follow_up_at IS NULL') + ->orderBy('next_follow_up_at') + ->orderBy('created_at'); + } + + private function applyMobileFilter($query, ?string $filter): void + { + match ($filter) { + 'today' => $query->whereDate('next_follow_up_at', now()->toDateString()), + 'overdue' => $query->where('next_follow_up_at', '<', now()), + 'no-call' => $query->whereNull('last_call_at'), + 'high-priority' => $query->where('priority', '>=', 7), + default => null, + }; + } + + private function leadCard(Lead $lead): array + { + $primary = $this->primaryContact($lead); + + return [ + 'id' => $lead->id, + 'lead_name' => $this->leadName($lead), + 'main_contact_name' => $primary?->name, + 'main_contact_role' => $primary?->role, + 'main_contact_phone_id' => $primary?->phones?->first()?->id, + 'lead_status' => $lead->leadStatus?->name, + 'lead_status_color' => $lead->leadStatus?->color, + 'stage' => $lead->pipelineStage?->name, + 'stage_color' => $lead->pipelineStage?->color, + 'last_call_result' => $lead->last_call_result, + 'last_call_at' => optional($lead->last_call_at)->toIso8601String(), + 'follow_up_time' => optional($lead->next_follow_up_at)->toIso8601String(), + 'priority' => $lead->priority ?? 0, + 'priority_label' => $this->priorityLabel($lead->priority ?? 0), + 'short_note' => $lead->notes ? mb_strimwidth($lead->notes, 0, 120, '...') : null, + 'next_action' => $this->nextAction($lead), + 'follow_up_status' => $this->followUpStatus($lead), + 'tags' => array_slice($this->tags($lead), 0, 4), + 'call_attempts' => $lead->call_attempts ?? 0, + ]; + } + + private function followUpCard(FollowUp $followUp): array + { + $lead = $followUp->lead; + $primary = $lead ? $this->primaryContact($lead) : null; + $scheduled = $followUp->scheduled_at; + + return [ + 'id' => $followUp->id, + 'lead_id' => $lead?->id, + 'lead_name' => $lead ? $this->leadName($lead) : 'لید حذف‌شده', + 'contact_name' => $primary?->name, + 'contact_phone_id' => $primary?->phones?->first()?->id, + 'follow_up_time' => optional($scheduled)->toIso8601String(), + 'follow_up_type' => $followUp->call_id ? 'بعد از تماس' : 'پیگیری', + 'last_call_result' => $lead?->last_call_result, + 'notes' => $followUp->notes, + 'status' => $followUp->status, + 'group' => $this->followUpGroup($followUp), + ]; + } + + private function contactSummary($contact): array + { + $phones = $contact->phones ?? collect(); + $mainPhone = $phones->first(); + + return [ + 'id' => $contact->id, + 'name' => $contact->name, + 'role' => $contact->role, + 'description' => $contact->description, + 'status' => $contact->status, + 'is_primary' => (bool) $contact->is_primary, + 'phone_id' => $mainPhone?->id, + 'phone_type' => $mainPhone?->type, + 'phone_status' => $mainPhone?->status, + 'call_count' => (int) $phones->sum('call_count'), + 'successful_call_count' => (int) $phones->sum('successful_call_count'), + 'failed_call_count' => (int) $phones->sum('failed_call_count'), + 'last_call_result' => $mainPhone?->last_call_result, + 'last_called_at' => optional($mainPhone?->last_called_at)->toIso8601String(), + ]; + } + + private function primaryContact(Lead $lead) + { + $contacts = $lead->relationLoaded('contacts') ? $lead->contacts : $lead->contacts()->with('phones')->get(); + return $contacts->firstWhere('is_primary', true) ?? $contacts->first(); + } + + private function leadName(Lead $lead): string + { + return trim("{$lead->first_name} {$lead->last_name}") ?: ($lead->company ?: "لید {$lead->id}"); + } + + private function tags(Lead $lead): array + { + 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(array_map('trim', explode(',', $lead->tags)))); + } + + private function priorityLabel(int $priority): string + { + 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 'پیگیری امروز'; + 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 'امروز'; + 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'; + return 'week'; + } + + private function successfulResults(): array + { + return CallResult::where('is_positive', true)->pluck('name')->all(); + } +} diff --git a/backend/app/Http/Controllers/Api/AssignmentController.php b/backend/app/Http/Controllers/Api/AssignmentController.php new file mode 100644 index 0000000..f0a525c --- /dev/null +++ b/backend/app/Http/Controllers/Api/AssignmentController.php @@ -0,0 +1,147 @@ +validate([ + 'lead_id' => 'required|exists:leads,id', + 'agent_id' => 'required|exists:users,id', + ]); + $lead = Lead::findOrFail($validated['lead_id']); + $agent = User::findOrFail($validated['agent_id']); + if (!$this->canAssignToAgent($lead, $agent)) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + $lead = $this->assignmentService->assignToAgent( + $validated['lead_id'], + $validated['agent_id'], + auth()->id() + ); + ActivityLogger::log('lead_owner_changed', "Lead {$lead->id} assigned to user {$agent->id}", $lead); + + return response()->json($lead->load('assignedAgent')); + } + + public function bulkAssign(Request $request): JsonResponse + { + $validated = $request->validate([ + 'lead_ids' => 'required|array|min:1', + 'lead_ids.*' => 'exists:leads,id', + 'agent_id' => 'required|exists:users,id', + ]); + $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))) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + $leads = $this->assignmentService->bulkAssign( + $validated['lead_ids'], + $validated['agent_id'], + auth()->id() + ); + ActivityLogger::log('lead_bulk_owner_changed', count($leads) . " leads assigned to user {$agent->id}"); + + return response()->json(['assigned' => count($leads)]); + } + + public function roundRobin(Request $request): JsonResponse + { + $validated = $request->validate([ + 'lead_ids' => 'required|array|min:1', + 'lead_ids.*' => 'exists:leads,id', + 'agent_ids' => 'required|array|min:1', + 'agent_ids.*' => 'exists:users,id', + ]); + $agents = User::whereIn('id', $validated['agent_ids'])->get()->keyBy('id'); + $leads = Lead::whereIn('id', $validated['lead_ids'])->get(); + 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)) + ) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + $leads = $this->assignmentService->roundRobin( + $validated['lead_ids'], + $validated['agent_ids'], + auth()->id() + ); + ActivityLogger::log('lead_round_robin_owner_changed', count($leads) . ' leads assigned by round robin'); + + return response()->json(['assigned' => count($leads)]); + } + + public function reassign(Request $request): JsonResponse + { + $validated = $request->validate([ + 'lead_id' => 'required|exists:leads,id', + 'agent_id' => 'required|exists:users,id', + ]); + $lead = Lead::findOrFail($validated['lead_id']); + $agent = User::findOrFail($validated['agent_id']); + if (!$this->canAssignToAgent($lead, $agent)) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + $lead = $this->assignmentService->assignToAgent( + $validated['lead_id'], + $validated['agent_id'], + auth()->id() + ); + ActivityLogger::log('lead_owner_changed', "Lead {$lead->id} reassigned to user {$agent->id}", $lead); + + return response()->json($lead->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); + } + + private function canAssignToAgent(Lead $lead, User $agent): bool + { + return Gate::allows('assign', $lead) && $this->agentIsAssignable($agent); + } + + private function agentIsAssignable(User $agent): bool + { + $user = auth()->user(); + + if (!$agent->hasRole('agent') || !$agent->is_active) { + return false; + } + + if ($user?->hasRole('admin')) { + return true; + } + + if ($user?->hasRole('supervisor')) { + return (bool) array_intersect(AccessControl::teamIds($user), $agent->teams()->pluck('teams.id')->all()); + } + + return false; + } +} diff --git a/backend/app/Http/Controllers/Api/AttachmentController.php b/backend/app/Http/Controllers/Api/AttachmentController.php new file mode 100644 index 0000000..8b3c525 --- /dev/null +++ b/backend/app/Http/Controllers/Api/AttachmentController.php @@ -0,0 +1,85 @@ +validate(['entity_type' => 'required|in:lead,company,deal', 'entity_id' => 'required|integer']); + $model = $this->resolve($validated['entity_type'], $validated['entity_id']); + return response()->json(['data' => $model->attachments()->with('uploader:id,name')->latest()->get()]); + } + + public function store(Request $request): JsonResponse + { + $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.mimes' => 'نوع فایل پیوست مجاز نیست.', + 'file.max' => 'حجم فایل پیوست بیش از حد مجاز است.', + ]); + $model = $this->resolve($validated['entity_type'], $validated['entity_id']); + $file = $request->file('file'); + $path = $file->store('attachments'); + + $attachment = $model->attachments()->create([ + 'uploaded_by' => auth()->id(), + 'original_name' => $file->getClientOriginalName(), + 'path' => $path, + 'mime_type' => $file->getMimeType() ?: 'application/octet-stream', + 'size' => $file->getSize(), + ]); + + ActivityLogger::log('attachment_uploaded', "File {$attachment->original_name} uploaded", $model); + return response()->json($attachment->load('uploader:id,name'), 201); + } + + public function download(Attachment $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, 'دسترسی غیرمجاز'); + 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), + }; + } + + private function allowedMimes(): string + { + return Setting::where('key', 'attachment_allowed_file_types')->value('value') + ?: 'pdf,jpg,jpeg,png,doc,docx,xls,xlsx,txt'; + } + + private function maxFileMb(): int + { + return max(1, (int) (Setting::where('key', 'attachment_max_file_size_mb')->value('value') ?: 10)); + } +} diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..04383c0 --- /dev/null +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,139 @@ +validate([ + 'phone' => 'required|string', + 'password' => 'required', + ]); + + $login = $this->normalizeLoginIdentifier($request->phone); + $user = str_contains($login, '@') + ? 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)) { + throw ValidationException::withMessages([ + 'phone' => ['شماره موبایل یا رمز عبور اشتباه است'], + ]); + } + + if (!$user->is_active) { + return response()->json(['message' => 'حساب کاربری شما غیرفعال است'], 403); + } + + Auth::guard('web')->login($user); + $request->session()->regenerate(); + + $user->update([ + 'last_login_at' => now(), + 'last_login_ip' => $request->ip(), + 'current_login_at' => now(), + ]); + + ActivityLogger::log('login', "User {$user->phone} logged in"); + + $user->load('roles.permissions', 'teams'); + + return response()->json([ + 'data' => $user, + ]); + } + + public function logout(Request $request): JsonResponse + { + $user = $request->user(); + + if ($user?->current_login_at) { + $user->increment('total_presence_seconds', max(0, $user->current_login_at->diffInSeconds(now()))); + $user->forceFill([ + 'current_login_at' => null, + 'last_logout_at' => now(), + ])->save(); + } + + ActivityLogger::log('logout', "User logged out"); + + Auth::guard('web')->logout(); + if ($request->hasSession()) { + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + + return response()->json(['message' => 'خروج با موفقیت انجام شد']); + } + + public function me(Request $request): JsonResponse + { + $user = $request->user()->load('roles.permissions', 'teams'); + $user->permissions = $user->getAllPermissions()->pluck('name'); + + return response()->json([ + 'data' => $user, + ]); + } + + public function updateProfile(Request $request): JsonResponse + { + $user = $request->user(); + + $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, + 'password' => ['nullable', ...PasswordPolicy::rules()], + 'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048', + ]); + + if ($request->hasFile('avatar')) { + $path = $request->file('avatar')->store('avatars', 'public'); + $validated['avatar'] = $path; + } + + if (!empty($validated['password'])) { + $validated['password'] = Hash::make($validated['password']); + } else { + unset($validated['password']); + } + + $user->update($validated); + $user->load('roles.permissions', 'teams'); + $user->permissions = $user->getAllPermissions()->pluck('name'); + + ActivityLogger::log('profile_updated', "User {$user->phone} updated profile"); + + return response()->json(['data' => $user]); + } + + private function normalizeLoginIdentifier(?string $value): string + { + $value = trim((string) $value); + + if (str_contains($value, '@')) { + return strtolower($value); + } + + $value = strtr($value, [ + '۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4', + '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9', + '٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4', + '٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9', + ]); + + return preg_replace('/\D+/', '', $value) ?? ''; + } +} diff --git a/backend/app/Http/Controllers/Api/CallController.php b/backend/app/Http/Controllers/Api/CallController.php new file mode 100644 index 0000000..25a1d94 --- /dev/null +++ b/backend/app/Http/Controllers/Api/CallController.php @@ -0,0 +1,118 @@ +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'); + + AccessControl::scopeCalls($query, $user); + + if ($request->lead_id) { + $query->where('lead_id', $request->lead_id); + } + if ($request->result) { + $query->where('result', $request->result); + } + if ($request->date_from) { + $query->whereDate('created_at', '>=', $request->date_from); + } + if ($request->date_to) { + $query->whereDate('created_at', '<=', $request->date_to); + } + + return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15)); + } + + public function results(): JsonResponse + { + return response()->json( + CallResult::where('is_active', true) + ->orderBy('sort_order') + ->get(['id', 'name', 'slug', 'color', 'requires_follow_up', 'is_positive', 'is_negative', 'is_final']) + ); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'lead_id' => 'required|exists:leads,id', + 'contact_phone_id' => 'nullable|exists:contact_phones,id', + ]); + + $lead = Lead::findOrFail($validated['lead_id']); + Gate::authorize('create', [Call::class, $lead]); + + $result = $this->callService->initiateCall($validated['lead_id'], auth()->id(), $validated['contact_phone_id'] ?? null); + + return response()->json($result, 201); + } + + public function show(Call $call): JsonResponse + { + Gate::authorize('view', $call); + if ($call->recording_url && Gate::allows('viewRecording', $call)) { + ActivityLogger::log('recording_viewed', "Recording viewed for call {$call->id}", $call); + } + + return response()->json($call->load('lead', 'user', 'contact', 'contactPhone')); + } + + public function registerResult(Request $request): JsonResponse + { + $validated = $request->validate([ + 'call_id' => 'required|exists:calls,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', + ]); + + $call = Call::with('lead')->findOrFail($validated['call_id']); + Gate::authorize('registerResult', $call); + $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->registerResult( + $validated['call_id'], + $validated['result'], + $validated['notes'] ?? null, + $validated['next_follow_up_at'] ?? null, + $validated['referral'] ?? null + ); + + return response()->json($call); + } +} diff --git a/backend/app/Http/Controllers/Api/CampaignController.php b/backend/app/Http/Controllers/Api/CampaignController.php new file mode 100644 index 0000000..7b089b6 --- /dev/null +++ b/backend/app/Http/Controllers/Api/CampaignController.php @@ -0,0 +1,116 @@ +with('assignedAgents:id,name', 'assignedSupervisors:id,name'); + Gate::authorize('viewAny', Campaign::class); + AccessControl::scopeCampaigns($query, auth()->user()); + + if ($request->search) { + $query->where('name', 'like', "%{$request->search}%"); + } + + if ($request->status) { + $query->where('status', $request->status); + } + + return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15)); + } + + public function store(Request $request): JsonResponse + { + Gate::authorize('create', Campaign::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'product_service' => 'nullable|string|max:255', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'target' => 'nullable|integer|min:0', + 'status' => 'nullable|string|in:draft,active,paused,completed,archived', + 'agent_ids' => 'nullable|array', + 'agent_ids.*' => 'exists:users,id', + 'supervisor_ids' => 'nullable|array', + 'supervisor_ids.*' => 'exists:users,id', + ]); + + $campaign = Campaign::create($validated); + + if ($request->agent_ids) { + $campaign->assignedAgents()->sync($request->agent_ids); + } + if ($request->supervisor_ids) { + $campaign->assignedSupervisors()->sync($request->supervisor_ids); + } + + ActivityLogger::log('campaign_created', "Campaign {$campaign->name} created", $campaign); + + return response()->json($campaign->load('assignedAgents', 'assignedSupervisors'), 201); + } + + public function show(Campaign $campaign): JsonResponse + { + Gate::authorize('view', $campaign); + + return response()->json( + $campaign->load([ + 'assignedAgents', 'assignedSupervisors', 'salesScript', + 'leads' => fn($q) => $q->with('leadStatus'), + ]) + ); + } + + public function update(Request $request, Campaign $campaign): JsonResponse + { + Gate::authorize('update', $campaign); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'description' => 'nullable|string', + 'product_service' => 'nullable|string|max:255', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'target' => 'nullable|integer|min:0', + 'status' => 'nullable|string|in:draft,active,paused,completed,archived', + '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); + } + + ActivityLogger::log('campaign_updated', "Campaign {$campaign->name} updated", $campaign); + + return response()->json($campaign->load('assignedAgents', 'assignedSupervisors')); + } + + public function destroy(Campaign $campaign): JsonResponse + { + Gate::authorize('delete', $campaign); + + $campaign->delete(); + ActivityLogger::log('campaign_deleted', "Campaign {$campaign->name} deleted"); + return response()->json(['message' => 'کمپین حذف شد']); + } +} diff --git a/backend/app/Http/Controllers/Api/CompanyController.php b/backend/app/Http/Controllers/Api/CompanyController.php new file mode 100644 index 0000000..86877d9 --- /dev/null +++ b/backend/app/Http/Controllers/Api/CompanyController.php @@ -0,0 +1,121 @@ +withCount(['contacts', 'leads', 'deals']); + $this->scope($query); + + if ($request->search) { + $search = $request->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); + + return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100))); + } + + public function store(Request $request): JsonResponse + { + $validated = $this->validated($request); + $suggestions = $this->duplicates->companySuggestions($validated); + if ($request->boolean('block_duplicates') && $suggestions) { + return response()->json(['message' => 'شرکت مشابهی در سیستم وجود دارد.', 'duplicates' => $suggestions], 422); + } + + $company = Company::create($this->prepare($validated) + ['created_by' => auth()->id()]); + ActivityLogger::log('company_created', "Company {$company->name} created", $company); + + $payload = $company->load('owner:id,name')->toArray(); + if ($suggestions) $payload['duplicate_suggestions'] = $suggestions; + return response()->json($payload, 201); + } + + public function show(Company $company): JsonResponse + { + $this->authorizeAccess($company); + return response()->json($company->load([ + 'owner:id,name', 'contacts.phones', 'leads.pipelineStage', 'deals.product', 'notes.user:id,name', 'attachments.uploader:id,name', + ])); + } + + public function update(Request $request, Company $company): JsonResponse + { + $this->authorizeAccess($company); + $validated = $this->validated($request, true); + $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); + $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]); + $target = Company::findOrFail($validated['target_id']); + 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', + 'company_type' => 'nullable|string|max:80', + 'industry' => 'nullable|string|max:120', + 'city' => 'nullable|string|max:120', + 'address' => 'nullable|string', + 'website' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'status' => 'nullable|string|max:40', + 'owner_id' => 'nullable|exists:users,id', + ]); + } + + 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']); + 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/ContactController.php b/backend/app/Http/Controllers/Api/ContactController.php new file mode 100644 index 0000000..ebb365f --- /dev/null +++ b/backend/app/Http/Controllers/Api/ContactController.php @@ -0,0 +1,178 @@ +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}%")); + } + + return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100))); + } + + public function storeStandalone(Request $request): JsonResponse + { + $validated = $this->validateContact($request); + $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); + } + + public function store(Request $request, Lead $lead): JsonResponse + { + $this->authorizeLeadAccess($lead); + + $validated = $this->validateContact($request); + $validated['lead_id'] = $lead->id; + + $contact = DB::transaction(function () use ($lead, $validated) { + if (!empty($validated['is_primary'])) { + Contact::where('lead_id', $lead->id)->update(['is_primary' => false]); + } + + $contact = $this->createContact($validated); + + if ($contact->is_primary) { + ActivityLogger::log('primary_contact_changed', "Primary contact changed to {$contact->name}", $lead); + } + + return $contact; + }); + + ActivityLogger::log('contact_created', "Contact {$contact->name} created for lead {$lead->id}", $contact); + + return response()->json($contact->load('phones.lastCaller'), 201); + } + + public function setPrimary(Request $request, Contact $contact): JsonResponse + { + if ($contact->lead) { + $this->authorizeLeadAccess($contact->lead); + } + + $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]); + $contact->update([ + 'is_primary' => true, + 'primary_reason' => $validated['reason'] ?? 'انتخاب دستی توسط کاربر', + ]); + }); + + ActivityLogger::log('primary_contact_changed', "Primary contact changed to {$contact->name}", $contact->lead); + + return response()->json($contact->fresh('phones.lastCaller')); + } + + public function update(Request $request, Contact $contact): JsonResponse + { + $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', + ]); + } + } + ActivityLogger::log('contact_updated', "Contact {$contact->name} updated", $contact); + return response()->json($contact->fresh('phones.lastCaller')); + } + + public function destroy(Contact $contact): JsonResponse + { + $contact->delete(); + ActivityLogger::log('contact_deleted', "Contact {$contact->id} deleted"); + return response()->json(['message' => 'مخاطب حذف شد']); + } + + private function authorizeLeadAccess(Lead $lead): void + { + Gate::authorize('update', $lead); + } + + 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', + 'first_name' => 'nullable|string|max:120', + 'last_name' => 'nullable|string|max:120', + 'role' => 'nullable|string|max:80', + 'job_title' => 'nullable|string|max:120', + 'email' => 'nullable|email|max:255', + 'preferred_channel' => 'nullable|string|max:40', + 'description' => 'nullable|string', + 'status' => 'nullable|string|max:30', + 'is_primary' => 'nullable|boolean', + 'primary_reason' => 'nullable|string|max:255', + 'phones' => ($partial ? 'nullable' : 'required') . '|array|min:1', + 'phones.*.phone' => 'required|string|max:30', + 'phones.*.type' => 'nullable|string|in:mobile,landline,extension', + 'phones.*.status' => 'nullable|string|max:30', + ]); + } + + private function createContact(array $validated): Contact + { + $contact = Contact::create([ + 'lead_id' => $validated['lead_id'] ?? null, + 'company_id' => $validated['company_id'] ?? null, + 'deal_id' => $validated['deal_id'] ?? null, + 'name' => $validated['name'], + 'first_name' => $validated['first_name'] ?? null, + 'last_name' => $validated['last_name'] ?? null, + 'role' => $validated['role'] ?? null, + 'job_title' => $validated['job_title'] ?? null, + 'email' => $validated['email'] ?? null, + 'preferred_channel' => $validated['preferred_channel'] ?? null, + '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), + 'created_by' => auth()->id(), + ]); + + foreach ($validated['phones'] as $phone) { + ContactPhone::create([ + 'contact_id' => $contact->id, + 'phone' => $phone['phone'], + 'type' => $phone['type'] ?? 'mobile', + 'status' => $phone['status'] ?? 'active', + ]); + } + + return $contact; + } +} diff --git a/backend/app/Http/Controllers/Api/DashboardController.php b/backend/app/Http/Controllers/Api/DashboardController.php new file mode 100644 index 0000000..9f1ed37 --- /dev/null +++ b/backend/app/Http/Controllers/Api/DashboardController.php @@ -0,0 +1,58 @@ +dashboardService->admin(); + + // Agent ranking + $agents = \App\Models\User::role('agent') + ->withCount(['assignedLeads', 'calls']) + ->get() + ->map(fn($agent) => [ + 'id' => $agent->id, + 'name' => $agent->name, + 'leads_count' => $agent->assigned_leads_count, + 'calls_count' => $agent->calls_count, + ]) + ->sortByDesc('calls_count') + ->values(); + + $stats['agent_ranking'] = $agents; + + return response()->json($stats); + } + + public function supervisor(): JsonResponse + { + $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) => [ + 'id' => $member->id, + 'name' => $member->name, + 'is_online' => $member->last_login_at && $member->last_login_at->gt(now()->subMinutes(15)), + 'calls_today' => $member->calls()->whereDate('created_at', now()->today())->count(), + ]); + + return response()->json($stats); + } + + public function agent(): JsonResponse + { + $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 new file mode 100644 index 0000000..bf65ff2 --- /dev/null +++ b/backend/app/Http/Controllers/Api/DealController.php @@ -0,0 +1,87 @@ +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)); + } + 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()]); + 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')); + } + + public function update(Request $request, Deal $deal): JsonResponse + { + $this->authorizeAccess($deal); + $deal->update($this->validated($request, true)); + 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); + $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', + 'company_id' => 'nullable|exists:companies,id', + 'lead_id' => 'nullable|exists:leads,id', + 'contact_id' => 'nullable|exists:contacts,id', + 'product_id' => 'nullable|exists:products,id', + 'estimated_value' => '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', + 'notes' => 'nullable|string', + ]); + } + + 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->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, 'دسترسی غیرمجاز'); + } +} diff --git a/backend/app/Http/Controllers/Api/DuplicateController.php b/backend/app/Http/Controllers/Api/DuplicateController.php new file mode 100644 index 0000000..7ab341e --- /dev/null +++ b/backend/app/Http/Controllers/Api/DuplicateController.php @@ -0,0 +1,32 @@ +validate([ + 'entity_type' => 'required|in:lead,company', + 'exclude_id' => 'nullable|integer', + 'name' => 'nullable|string', + 'company' => 'nullable|string', + 'website' => 'nullable|string', + 'email' => 'nullable|string', + 'phone' => 'nullable|string', + ]); + + $items = $validated['entity_type'] === 'company' + ? $this->duplicates->companySuggestions($validated, $validated['exclude_id'] ?? null) + : $this->duplicates->leadSuggestions($validated, $validated['exclude_id'] ?? null); + + return response()->json(['data' => $items]); + } +} diff --git a/backend/app/Http/Controllers/Api/FollowUpController.php b/backend/app/Http/Controllers/Api/FollowUpController.php new file mode 100644 index 0000000..aa72f6d --- /dev/null +++ b/backend/app/Http/Controllers/Api/FollowUpController.php @@ -0,0 +1,186 @@ +user(); + Gate::authorize('viewAny', FollowUp::class); + + $query = FollowUp::with('lead:id,first_name,last_name,phone', 'user:id,name'); + + 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)); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'lead_id' => 'required|exists:leads,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); + } + + $followUp = FollowUp::create([ + 'lead_id' => $validated['lead_id'], + 'user_id' => auth()->id(), + 'scheduled_at' => $validated['scheduled_at'], + 'notes' => $validated['notes'] ?? null, + 'status' => 'pending', + ]); + + ActivityLogger::log('follow_up_created', "Follow-up for lead {$validated['lead_id']} scheduled", $followUp); + NotificationService::notifyFollowUpReminder($followUp->user_id, $lead->full_name ?: ($lead->company ?? "لید {$lead->id}")); + + return response()->json($followUp->load('lead'), 201); + } + + public function update(Request $request, FollowUp $followUp): JsonResponse + { + Gate::authorize('update', $followUp); + + $validated = $request->validate([ + 'scheduled_at' => 'sometimes|date', + 'notes' => 'nullable|string', + 'status' => 'sometimes|string|in:pending,completed,cancelled', + ]); + + $followUp->update($validated); + + ActivityLogger::log('follow_up_updated', "Follow-up {$followUp->id} updated", $followUp); + + return response()->json($followUp->load('lead')); + } + + public function markDone(FollowUp $followUp): JsonResponse + { + Gate::authorize('update', $followUp); + + $followUp->update([ + 'status' => 'completed', + 'completed_at' => now(), + ]); + + ActivityLogger::log('follow_up_completed', "Follow-up {$followUp->id} completed", $followUp); + + return response()->json($followUp); + } + + public function today(): JsonResponse + { + $user = auth()->user(); + $query = FollowUp::with('lead:id,first_name,last_name,phone') + ->whereDate('scheduled_at', now()->toDateString()) + ->where('status', 'pending'); + AccessControl::scopeFollowUps($query, $user); + + $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); + } + + public function overdue(): JsonResponse + { + $user = auth()->user(); + $query = FollowUp::with('lead:id,first_name,last_name,phone') + ->where('status', 'pending') + ->where(function ($q) { + $q->where('is_overdue', true) + ->orWhere('scheduled_at', '<', now()); + }); + AccessControl::scopeFollowUps($query, $user); + + return response()->json($query->orderBy('scheduled_at')->get()); + } + + private function canManageFollowUp(FollowUp $followUp): bool + { + $user = auth()->user(); + + if (!$user) { + return false; + } + + if ($user->hasRole('admin')) { + return true; + } + + if ($user->hasRole('agent')) { + return $followUp->user_id === $user->id; + } + + if ($user->hasRole('supervisor')) { + $teamIds = $user->teams->pluck('id')->toArray(); + $agentIds = \App\Models\Team::whereIn('id', $teamIds)->with('members')->get()->pluck('members.*.id')->flatten(); + return $agentIds->contains($followUp->user_id); + } + + return false; + } + + private function canAccessLead(\App\Models\Lead $lead): bool + { + $user = auth()->user(); + + 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; + } +} diff --git a/backend/app/Http/Controllers/Api/ImportController.php b/backend/app/Http/Controllers/Api/ImportController.php new file mode 100644 index 0000000..0728365 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ImportController.php @@ -0,0 +1,161 @@ +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.mimes' => 'نوع فایل import مجاز نیست.', + 'file.max' => "حجم فایل import نباید بیشتر از {$maxMb} مگابایت باشد.", + ]); + + $file = $request->file('file'); + $path = $file->store('imports'); + + $batch = ImportBatch::create([ + 'user_id' => auth()->id(), + 'filename' => $file->getClientOriginalName(), + 'status' => 'pending', + 'can_rollback' => Setting::where('key', 'import_rollback_allowed')->value('value') !== 'false', + ]); + + try { + $fullPath = Storage::path($path); + $preview = $this->importService->preview($fullPath); + + // Store rows for later processing + $rows = \Maatwebsite\Excel\Facades\Excel::toArray([], $fullPath)[0] ?? []; + $dataRows = array_slice($rows, 1); + + foreach ($dataRows as $index => $row) { + $batch->rows()->create([ + 'original_data' => $row, + 'status' => 'pending', + ]); + } + + $batch->update(['total_rows' => count($dataRows)]); + ActivityLogger::log('lead_import_uploaded', "Import batch {$batch->id} uploaded", $batch); + + return response()->json([ + 'batch_id' => $batch->id, + 'filename' => $file->getClientOriginalName(), + 'headers' => $preview['headers'], + 'total_rows' => $preview['total_rows'], + 'preview' => $preview['preview'], + ]); + } catch (\Exception $e) { + $batch->update(['status' => 'failed', 'errors' => $e->getMessage()]); + return response()->json(['message' => $e->getMessage()], 422); + } + } + + public function confirm(Request $request): JsonResponse + { + $user = auth()->user(); + Gate::authorize('import', ImportBatch::class); + + $validated = $request->validate([ + 'batch_id' => 'required|exists:import_batches,id', + 'column_mapping' => 'required|array', + 'column_mapping.company' => 'required|integer', + 'column_mapping.first_name' => 'nullable|integer', + 'column_mapping.last_name' => 'nullable|integer', + 'column_mapping.phone' => 'required|integer', + 'column_mapping.phone_secondary' => 'nullable|integer', + 'column_mapping.email' => 'nullable|integer', + 'column_mapping.city' => 'nullable|integer', + 'column_mapping.province' => 'nullable|integer', + 'column_mapping.source' => 'nullable|integer', + 'column_mapping.product_interest' => 'nullable|integer', + 'column_mapping.priority' => 'nullable|integer', + 'column_mapping.lead_score' => 'nullable|integer', + 'column_mapping.interest_level' => 'nullable|integer', + 'column_mapping.notes' => 'nullable|integer', + 'column_mapping.tags' => 'nullable|integer', + 'campaign_id' => 'nullable|exists:campaigns,id', + 'agent_ids' => 'nullable|array', + 'agent_ids.*' => 'exists:users,id', + ]); + + $agentIds = $validated['agent_ids'] ?? null; + if ($user?->hasRole('agent')) { + $agentIds = [$user->id]; + } + + $pendingBatch = ImportBatch::findOrFail($validated['batch_id']); + Gate::authorize('view', $pendingBatch); + + $batch = $this->importService->confirm( + $validated['batch_id'], + $validated['column_mapping'], + $validated['campaign_id'] ?? null, + $agentIds, + auth()->id() + ); + ActivityLogger::log('lead_import_confirmed', "Import batch {$batch->id} confirmed", $batch); + + return response()->json($batch->load('rows')); + } + + public function rollback(int $batchId): JsonResponse + { + $batch = ImportBatch::findOrFail($batchId); + Gate::authorize('rollback', $batch); + + $this->importService->rollback($batchId); + ActivityLogger::log('lead_import_rolled_back', "Import batch {$batchId} rolled back", $batch); + return response()->json(['message' => 'بازگشت import انجام شد']); + } + + public function index(): JsonResponse + { + Gate::authorize('viewAny', ImportBatch::class); + + return response()->json( + ImportBatch::with('user:id,name') + ->orderBy('created_at', 'desc') + ->paginate(15) + ); + } + + public function template() + { + Gate::authorize('import', ImportBatch::class); + + $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'], + ['Acme Industrial', '02112345678', '09121234567', 'Ali', 'Karimi', 'ali@example.com', 'Tehran', 'Tehran', 'Website', 'CRM', '5', '70', 'warm', 'Initial import note', 'b2b,website'], + ['Pars Co', '02187654321', '', 'Sara', 'Ahmadi', 'sara@example.com', 'Shiraz', 'Fars', 'Exhibition', 'Support', '3', '40', 'cold', '', 'exhibition'], + ]); + + $path = storage_path('app/import-template-b2b.xlsx'); + (new Xlsx($spreadsheet))->save($path); + + return response()->download($path, 'sample-leads-b2b.xlsx')->deleteFileAfterSend(true); + } +} diff --git a/backend/app/Http/Controllers/Api/LeadController.php b/backend/app/Http/Controllers/Api/LeadController.php new file mode 100644 index 0000000..f61ef52 --- /dev/null +++ b/backend/app/Http/Controllers/Api/LeadController.php @@ -0,0 +1,396 @@ +user(); + Gate::authorize('viewAny', Lead::class); + + $filters = $request->only([ + 'search', 'status_id', 'pipeline_stage_id', 'assigned_to', + 'status', 'stage', 'agent', + 'team_id', 'campaign_id', 'source', 'priority', + 'call_result', 'interest_level', 'overdue_follow_up', + 'is_unassigned', 'date_from', 'date_to', 'has_follow_up', + 'sort_field', 'sort_direction', 'sort', 'direction', 'per_page', + ]); + + if ($user->hasRole('agent')) { + $filters['assigned_to'] = $user->id; + } elseif ($user->hasRole('supervisor')) { + $teamId = $user->teams->first()?->id; + if ($teamId) { + $filters['team_id'] = $teamId; + } + } + + $leads = $this->leadService->getFilteredLeads($filters); + + return response()->json($leads); + } + + public function funnel(Request $request): JsonResponse + { + $user = auth()->user(); + Gate::authorize('viewAny', Lead::class); + + $filters = $request->only([ + 'search', 'status_id', 'pipeline_stage_id', 'assigned_to', + 'status', 'stage', 'agent', + 'team_id', 'campaign_id', 'source', 'priority', + 'call_result', 'interest_level', 'overdue_follow_up', + 'is_unassigned', 'date_from', 'date_to', 'has_follow_up', + 'sort_field', 'sort_direction', 'sort', 'direction', + ]); + + if ($user->hasRole('agent')) { + $filters['assigned_to'] = $user->id; + } elseif ($user->hasRole('supervisor')) { + $teamId = $user->teams->first()?->id; + if ($teamId) { + $filters['team_id'] = $teamId; + } + } + + return response()->json($this->leadService->getGroupedFunnel($filters, (int) $request->get('per_stage', 25))); + } + + public function export(Request $request): StreamedResponse|JsonResponse + { + $user = auth()->user(); + if (!$user?->hasRole('admin') && !$user?->can('export_leads')) { + return response()->json(['message' => 'شما مجوز خروجی گرفتن از لیدها را ندارید.'], 403); + } + + $filters = $request->only([ + 'search', 'status_id', 'pipeline_stage_id', 'assigned_to', + 'status', 'stage', 'agent', + 'team_id', 'campaign_id', 'source', 'priority', + 'call_result', 'interest_level', 'overdue_follow_up', + 'is_unassigned', 'date_from', 'date_to', 'has_follow_up', + 'sort_field', 'sort_direction', 'sort', 'direction', + ]); + + if ($user->hasRole('agent')) { + $filters['assigned_to'] = $user->id; + } elseif ($user->hasRole('supervisor')) { + $teamId = $user->teams->first()?->id; + if ($teamId) { + $filters['team_id'] = $teamId; + } + } + + $exportPhoneMode = Setting::where('key', 'export_phone_mode')->value('value') ?: 'permission_based'; + $canViewFullPhone = $exportPhoneMode !== 'masked_only' && ($user->hasRole('admin') || $user->can('view_full_phone')); + $query = $this->leadService->getFilteredLeadQuery($filters); + + ActivityLogger::log('leads_exported', 'Lead export requested'); + + return response()->streamDownload(function () use ($query, $canViewFullPhone) { + $handle = fopen('php://output', 'w'); + fputcsv($handle, ['id', 'company', 'first_name', 'last_name', 'phone', 'email', 'city', 'source', 'stage', 'status', 'campaign', 'assigned_to', 'created_at']); + + $query->chunk(500, function ($leads) use ($handle, $canViewFullPhone) { + foreach ($leads as $lead) { + fputcsv($handle, [ + $lead->id, + $lead->company, + $lead->first_name, + $lead->last_name, + $canViewFullPhone ? $lead->phone : $lead->masked_phone, + $lead->email, + $lead->city, + $lead->source, + $lead->pipelineStage?->name, + $lead->leadStatus?->name, + $lead->campaign?->name, + $lead->assignedAgent?->name, + optional($lead->created_at)->toDateTimeString(), + ]); + } + }); + + fclose($handle); + }, 'leads-export.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + public function store(Request $request): JsonResponse + { + Gate::authorize('create', Lead::class); + + $validated = $request->validate([ + 'company' => 'required|string|max:255', + 'first_name' => 'nullable|string|max:255', + 'last_name' => 'nullable|string|max:255', + 'phone' => 'required|string|max:20', + 'phone_secondary' => 'nullable|string|max:20', + 'email' => 'nullable|email|max:255', + 'city' => 'nullable|string|max:255', + 'province' => 'nullable|string|max:255', + 'source' => 'nullable|string|max:255', + 'product_interest' => 'nullable|string|max:255', + 'priority' => 'nullable|integer|min:0|max:10', + 'lead_score' => 'nullable|integer|min:0|max:100', + 'interest_level' => 'nullable|in:cold,warm,hot', + 'last_call_result' => 'nullable|string|max:50', + 'notes' => 'nullable|string', + 'tags' => 'nullable|string', + 'lead_status_id' => 'nullable|exists:lead_statuses,id', + 'pipeline_stage_id' => 'nullable|exists:pipeline_stages,id', + 'campaign_id' => 'nullable|exists:campaigns,id', + 'assigned_to' => 'nullable|exists:users,id', + ]); + + $user = auth()->user(); + $duplicatePolicy = Setting::where('key', 'duplicate_phone_policy')->value('value') ?? 'warn'; + $duplicateLead = Lead::where('phone', $validated['phone'])->first(); + if ($duplicateLead && $duplicatePolicy === 'block') { + return response()->json(['message' => 'شماره تلفن تکراری است', 'duplicate_lead_id' => $duplicateLead->id], 422); + } + + if ($user?->hasRole('agent')) { + $validated['assigned_to'] = $user->id; + } 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)) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + } + + $validated['first_name'] = $validated['first_name'] ?: $validated['company']; + $validated['last_name'] = $validated['last_name'] ?: 'رابط'; + $validated['pipeline_stage_id'] = $validated['pipeline_stage_id'] + ?? PipelineStage::where('is_default', true)->value('id') + ?? PipelineStage::orderBy('sort_order')->value('id'); + $assignedTo = $validated['assigned_to'] ?? null; + if (!$assignedTo) { + $strategy = Setting::where('key', 'assignment_strategy')->value('value') ?? 'round_robin'; + if ($strategy !== 'manual' || auth()->user()?->hasRole('admin')) { + $assignedTo = User::role('agent') + ->withCount('assignedLeads') + ->orderBy('assigned_leads_count') + ->value('id'); + } + } + + if ($assignedTo) { + $validated['assigned_to'] = $assignedTo; + $assignee = User::with('teams')->find($assignedTo); + $validated['assigned_by'] = auth()->id(); + $validated['team_id'] = $assignee?->teams->first()?->id; + $validated['is_unassigned'] = false; + $validated['pipeline_stage_id'] = $request->filled('pipeline_stage_id') + ? $validated['pipeline_stage_id'] + : (PipelineStage::where('slug', 'waiting_call')->value('id') ?? $validated['pipeline_stage_id']); + } + + $lead = Lead::create($validated); + + $contact = \App\Models\Contact::create([ + 'lead_id' => $lead->id, + 'name' => trim(($lead->first_name ?? '') . ' ' . ($lead->last_name ?? '')) ?: ($lead->company ?? 'مخاطب اولیه'), + 'role' => 'رابط', + 'description' => 'مخاطب اولیه لید', + 'status' => 'active', + 'is_primary' => true, + 'primary_reason' => 'مخاطب اولیه لید', + 'created_by' => auth()->id(), + ]); + + foreach (array_filter([$validated['phone'] ?? null, $validated['phone_secondary'] ?? null]) as $phone) { + \App\Models\ContactPhone::create([ + 'contact_id' => $contact->id, + 'phone' => $phone, + 'type' => 'mobile', + 'status' => 'active', + ]); + } + + if (!empty($validated['assigned_to'])) { + \App\Models\LeadAssignment::create([ + 'lead_id' => $lead->id, + 'user_id' => $validated['assigned_to'], + 'assigned_by' => auth()->id(), + 'method' => 'manual', + ]); + } + + ActivityLogger::log('lead_created', "Lead {$lead->full_name} created", $lead); + + $payload = $lead->load(['leadStatus', 'pipelineStage', 'campaign'])->toArray(); + if ($duplicateLead && in_array($duplicatePolicy, ['warn', 'merge_suggestion'], true)) { + $payload['duplicate_warning'] = [ + 'policy' => $duplicatePolicy, + 'duplicate_lead_id' => $duplicateLead->id, + ]; + } + + return response()->json($payload, 201); + } + + public function show(Lead $lead): JsonResponse + { + Gate::authorize('view', $lead); + + return response()->json( + $lead->load([ + 'leadStatus', 'pipelineStage', 'campaign', + 'assignedAgent', 'team', + 'contacts.phones.lastCaller:id,name', + 'contactRelations.fromContact:id,name,role', + 'contactRelations.toContact:id,name,role', + '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(), + 'notes.user:id,name', + 'attachments.uploader:id,name', + ]) + ); + } + + public function update(Request $request, Lead $lead): JsonResponse + { + Gate::authorize('update', $lead); + + $validated = $request->validate([ + 'first_name' => 'sometimes|string|max:255', + 'last_name' => 'sometimes|string|max:255', + 'phone' => 'sometimes|string|max:20', + 'phone_secondary' => 'nullable|string|max:20', + 'email' => 'nullable|email|max:255', + 'company' => 'nullable|string|max:255', + 'city' => 'nullable|string|max:255', + 'province' => 'nullable|string|max:255', + 'source' => 'nullable|string|max:255', + 'product_interest' => 'nullable|string|max:255', + 'priority' => 'nullable|integer|min:0|max:10', + 'lead_score' => 'nullable|integer|min:0|max:100', + 'interest_level' => 'nullable|in:cold,warm,hot', + 'last_call_result' => 'nullable|string|max:50', + 'notes' => 'nullable|string', + 'tags' => 'nullable|string', + 'lead_status_id' => 'nullable|exists:lead_statuses,id', + 'pipeline_stage_id' => 'nullable|exists:pipeline_stages,id', + ]); + + if (($request->has('lead_status_id') || $request->has('pipeline_stage_id')) && Gate::denies('changeStage', $lead)) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + $lead->update($validated); + + ActivityLogger::log('lead_updated', "Lead {$lead->id} updated", $lead); + + return response()->json($lead->fresh()->load(['leadStatus', 'pipelineStage', 'campaign', 'assignedAgent'])); + } + + public function destroy(Lead $lead): JsonResponse + { + Gate::authorize('delete', $lead); + + $lead->delete(); + + ActivityLogger::log('lead_deleted', "Lead {$lead->id} deleted", $lead); + + return response()->json(['message' => 'لید حذف شد']); + } + + public function bulkDestroy(Request $request): JsonResponse + { + $validated = $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'integer|exists:leads,id', + ]); + + $leads = Lead::whereIn('id', $validated['ids'])->get(); + if ($leads->count() !== count(array_unique($validated['ids'])) || $leads->contains(fn(Lead $lead) => Gate::denies('delete', $lead))) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + $deleted = Lead::whereIn('id', $validated['ids'])->delete(); + + ActivityLogger::log('leads_bulk_deleted', "{$deleted} leads deleted"); + + return response()->json([ + 'deleted' => $deleted, + 'message' => "{$deleted} لید حذف شد", + ]); + } + + public function nextLead(): JsonResponse + { + $user = auth()->user(); + + $lead = Lead::where('assigned_to', $user->id) + ->where(function ($q) { + $q->whereNull('last_call_at') + ->orWhere('next_follow_up_at', '<=', now()); + }) + ->orderBy('priority', 'desc') + ->orderBy('created_at', 'asc') + ->first(); + + if (!$lead) { + $lead = Lead::where('assigned_to', $user->id) + ->orderBy('priority', 'desc') + ->orderBy('last_call_at', 'asc') + ->first(); + } + + 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)]) + ); + } + + private function canManageLead(Lead $lead): bool + { + $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; + } +} diff --git a/backend/app/Http/Controllers/Api/LeadStatusController.php b/backend/app/Http/Controllers/Api/LeadStatusController.php new file mode 100644 index 0000000..1cb85c3 --- /dev/null +++ b/backend/app/Http/Controllers/Api/LeadStatusController.php @@ -0,0 +1,77 @@ +json(LeadStatus::orderBy('sort_order')->get()); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'slug' => 'required|string|max:80|unique:lead_statuses,slug', + 'color' => 'nullable|string|max:20', + 'icon' => 'nullable|string|max:255', + 'sort_order' => 'nullable|integer', + 'is_active' => 'nullable|boolean', + 'is_default' => 'nullable|boolean', + 'is_won' => 'nullable|boolean', + 'is_lost' => 'nullable|boolean', + ]); + + $status = LeadStatus::create($validated); + + ActivityLogger::log('lead_status_created', "Status {$status->name} created"); + + return response()->json($status, 201); + } + + public function update(Request $request, LeadStatus $leadStatus): JsonResponse + { + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + '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', + 'is_active' => 'nullable|boolean', + 'is_default' => 'nullable|boolean', + 'is_won' => 'nullable|boolean', + 'is_lost' => 'nullable|boolean', + ]); + + $leadStatus->update($validated); + + ActivityLogger::log('lead_status_updated', "Status {$leadStatus->name} updated"); + + return response()->json($leadStatus); + } + + public function destroy(LeadStatus $leadStatus): JsonResponse + { + $leadStatus->delete(); + ActivityLogger::log('lead_status_deleted', "Status {$leadStatus->name} deleted"); + return response()->json(['message' => 'وضعیت حذف شد']); + } + + public function reorder(Request $request): JsonResponse + { + $request->validate(['order' => 'required|array', 'order.*.id' => 'exists:lead_statuses,id', 'order.*.sort_order' => 'required|integer']); + + foreach ($request->order as $item) { + LeadStatus::where('id', $item['id'])->update(['sort_order' => $item['sort_order']]); + } + + return response()->json(['message' => 'مرتب‌سازی انجام شد']); + } +} diff --git a/backend/app/Http/Controllers/Api/LostReasonController.php b/backend/app/Http/Controllers/Api/LostReasonController.php new file mode 100644 index 0000000..6db174c --- /dev/null +++ b/backend/app/Http/Controllers/Api/LostReasonController.php @@ -0,0 +1,57 @@ +json(LostReason::orderBy('sort_order')->get()); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'slug' => 'required|string|max:80|unique:lost_reasons,slug', + 'color' => 'nullable|string|max:20', + 'sort_order' => 'nullable|integer', + 'is_active' => 'nullable|boolean', + ]); + + $reason = LostReason::create($validated); + ActivityLogger::log('lost_reason_created', "Lost reason {$reason->name} created", $reason); + + return response()->json($reason, 201); + } + + public function update(Request $request, LostReason $lostReason): JsonResponse + { + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'slug' => 'sometimes|string|max:80|unique:lost_reasons,slug,' . $lostReason->id, + 'color' => 'nullable|string|max:20', + 'sort_order' => 'nullable|integer', + 'is_active' => 'nullable|boolean', + ]); + + $lostReason->update($validated); + ActivityLogger::log('lost_reason_updated', "Lost reason {$lostReason->name} updated", $lostReason); + + return response()->json($lostReason); + } + + public function destroy(LostReason $lostReason): JsonResponse + { + $lostReason->update(['is_active' => false]); + ActivityLogger::log('lost_reason_disabled', "Lost reason {$lostReason->name} disabled", $lostReason); + + return response()->json(['message' => 'دلیل شکست غیرفعال شد']); + } +} diff --git a/backend/app/Http/Controllers/Api/NoteController.php b/backend/app/Http/Controllers/Api/NoteController.php new file mode 100644 index 0000000..f89ef17 --- /dev/null +++ b/backend/app/Http/Controllers/Api/NoteController.php @@ -0,0 +1,50 @@ +validate([ + 'entity_type' => 'required|in:lead,company,deal', + 'entity_id' => 'required|integer', + 'content' => 'required|string', + ]); + + $model = $this->resolve($validated['entity_type'], $validated['entity_id']); + $note = $model->notes()->create([ + 'user_id' => auth()->id(), + 'content' => $validated['content'], + ]); + + ActivityLogger::log('note_created', 'یادداشت جدید ثبت شد', $model); + return response()->json($note->load('user:id,name'), 201); + } + + 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' => 'یادداشت حذف شد']); + } + + 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), + }; + } +} diff --git a/backend/app/Http/Controllers/Api/NotificationController.php b/backend/app/Http/Controllers/Api/NotificationController.php new file mode 100644 index 0000000..ce00618 --- /dev/null +++ b/backend/app/Http/Controllers/Api/NotificationController.php @@ -0,0 +1,49 @@ +id()) + ->orderBy('created_at', 'desc') + ->paginate(20); + + return response()->json($notifications); + } + + public function markRead(Notification $notification): JsonResponse + { + if ($notification->user_id !== auth()->id()) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + $notification->update(['is_read' => true]); + + return response()->json($notification); + } + + public function markAllRead(): JsonResponse + { + Notification::where('user_id', auth()->id()) + ->where('is_read', false) + ->update(['is_read' => true]); + + return response()->json(['message' => 'همه اعلان‌ها خوانده شد']); + } + + public function unreadCount(): JsonResponse + { + $count = Notification::where('user_id', auth()->id()) + ->where('is_read', false) + ->count(); + + return response()->json(['count' => $count]); + } +} diff --git a/backend/app/Http/Controllers/Api/PipelineStageController.php b/backend/app/Http/Controllers/Api/PipelineStageController.php new file mode 100644 index 0000000..30e93c8 --- /dev/null +++ b/backend/app/Http/Controllers/Api/PipelineStageController.php @@ -0,0 +1,145 @@ +json( + PipelineStage::withCount('leads') + ->where('is_active', true) + ->orderBy('sort_order') + ->get() + ); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'slug' => 'required|string|max:80|unique:pipeline_stages,slug', + 'color' => 'nullable|string|max:20', + 'sort_order' => 'nullable|integer', + 'is_active' => 'nullable|boolean', + 'is_default' => 'nullable|boolean', + 'is_won' => 'nullable|boolean', + 'is_lost' => 'nullable|boolean', + 'requires_follow_up' => 'nullable|boolean', + ]); + + $stage = PipelineStage::create($validated); + + ActivityLogger::log('pipeline_stage_created', "Stage {$stage->name} created"); + + return response()->json($stage, 201); + } + + public function update(Request $request, PipelineStage $pipelineStage): JsonResponse + { + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'slug' => 'nullable|string|max:80|unique:pipeline_stages,slug,' . $pipelineStage->id, + 'color' => 'nullable|string|max:20', + 'sort_order' => 'nullable|integer', + 'is_active' => 'nullable|boolean', + 'is_default' => 'nullable|boolean', + 'is_won' => 'nullable|boolean', + 'is_lost' => 'nullable|boolean', + 'requires_follow_up' => 'nullable|boolean', + ]); + + $pipelineStage->update($validated); + + ActivityLogger::log('pipeline_stage_updated', "Stage {$pipelineStage->name} updated"); + + return response()->json($pipelineStage); + } + + public function destroy(PipelineStage $pipelineStage): JsonResponse + { + $pipelineStage->delete(); + ActivityLogger::log('pipeline_stage_deleted', "Stage {$pipelineStage->name} deleted"); + return response()->json(['message' => 'مرحله حذف شد']); + } + + public function updateLeadStage(Request $request, PipelineStage $pipelineStage): JsonResponse + { + $validated = $request->validate([ + 'lead_id' => 'required|exists:leads,id', + 'next_follow_up_at' => 'nullable|date', + 'final_result' => 'nullable|in:موفق,ناموفق', + 'lost_reason' => 'nullable|string|max:255', + 'deal_value' => 'nullable|numeric|min:0', + 'sold_product' => 'nullable|string|max:255', + 'contract_date' => 'nullable|date', + 'payment_status' => 'nullable|string|max:30', + 'customer_notes' => 'nullable|string', + ]); + + $lead = \App\Models\Lead::findOrFail($validated['lead_id']); + Gate::authorize('changeStage', $lead); + + $update = ['pipeline_stage_id' => $pipelineStage->id]; + + if ($pipelineStage->requires_follow_up) { + $request->validate(['next_follow_up_at' => 'required|date']); + $update['next_follow_up_at'] = $validated['next_follow_up_at']; + } + + if ($pipelineStage->is_won || $pipelineStage->is_lost) { + $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'); + } + + if (($pipelineStage->is_won || $pipelineStage->is_lost) && ($validated['final_result'] ?? null) === 'ناموفق') { + $request->validate(['lost_reason' => 'required|string|max:255|exists:lost_reasons,name']); + $update['lost_reason'] = $validated['lost_reason']; + $update['deal_value'] = null; + $update['sold_product'] = null; + $update['contract_date'] = null; + $update['payment_status'] = null; + $update['customer_notes'] = null; + } + + if (($pipelineStage->is_won || $pipelineStage->is_lost) && ($validated['final_result'] ?? null) === 'موفق') { + $update['lost_reason'] = null; + $update['deal_value'] = $validated['deal_value'] ?? null; + $update['sold_product'] = $validated['sold_product'] ?? null; + $update['contract_date'] = $validated['contract_date'] ?? null; + $update['payment_status'] = $validated['payment_status'] ?? null; + $update['customer_notes'] = $validated['customer_notes'] ?? null; + } + + $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); + } + } + + ActivityLogger::log('lead_stage_changed', "Lead {$lead->id} moved to stage {$pipelineStage->name}", $lead); + + return response()->json($lead->load(['pipelineStage', 'assignedAgent', 'campaign'])); + } +} diff --git a/backend/app/Http/Controllers/Api/ProductController.php b/backend/app/Http/Controllers/Api/ProductController.php new file mode 100644 index 0000000..5fc2f19 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ProductController.php @@ -0,0 +1,69 @@ +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)); + return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100))); + } + + public function store(Request $request): JsonResponse + { + $this->authorizeManage(); + $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 + { + return response()->json($product->load('salesScript.sections', 'deals:id,title,product_id,status')); + } + + public function update(Request $request, Product $product): JsonResponse + { + $this->authorizeManage(); + $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(); + $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', + 'category' => 'nullable|string|max:120', + 'base_price' => 'nullable|numeric|min:0', + 'description' => 'nullable|string', + 'is_active' => 'nullable|boolean', + 'sales_script_id' => 'nullable|exists:sales_scripts,id', + 'faq' => 'nullable|array', + 'objection_handling' => 'nullable|array', + ]); + } + + private function authorizeManage(): void + { + abort_unless(auth()->user()?->hasRole('admin') || auth()->user()?->hasRole('supervisor') || auth()->user()?->can('manage_products'), 403, 'شما مجوز مدیریت محصولات را ندارید.'); + } +} diff --git a/backend/app/Http/Controllers/Api/QualityReviewController.php b/backend/app/Http/Controllers/Api/QualityReviewController.php new file mode 100644 index 0000000..6a6086d --- /dev/null +++ b/backend/app/Http/Controllers/Api/QualityReviewController.php @@ -0,0 +1,77 @@ +user(); + $query = QualityReview::with('call:id,lead_id,created_at', 'agent:id,name', 'reviewer:id,name'); + + 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); + } + + return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15)); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + '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', + ]); + + $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()); + + $review = QualityReview::create($validated); + + ActivityLogger::log('quality_review_created', "Quality review for agent {$validated['agent_id']} created", $review); + + return response()->json($review->load('agent:id,name', 'reviewer:id,name'), 201); + } + + public function show(QualityReview $qualityReview): JsonResponse + { + return response()->json($qualityReview->load('call.lead', 'agent', 'reviewer')); + } + + public function update(Request $request, QualityReview $qualityReview): JsonResponse + { + $validated = $request->validate([ + 'feedback' => 'nullable|string', + 'tag' => 'nullable|string|max:30', + 'is_shared_with_agent' => 'nullable|boolean', + ]); + + $qualityReview->update($validated); + + return response()->json($qualityReview); + } +} diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php new file mode 100644 index 0000000..961a733 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -0,0 +1,373 @@ +validate([ + 'agent_id' => 'nullable|exists:users,id', + 'date_from' => 'nullable|date', + 'date_to' => 'nullable|date', + ]); + + $allowedAgentIds = $this->allowedAgentIds(); + 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( + $agent->id, + $validated['date_from'] ?? null, + $validated['date_to'] ?? null + ))->values()); + } + + return response()->json( + $this->reportService->agentPerformance( + $validated['agent_id'], + $validated['date_from'] ?? null, + $validated['date_to'] ?? null + ) + ); + } + + public function teamPerformance(Request $request): JsonResponse + { + Gate::authorize('view-report-data'); + + $validated = $request->validate([ + 'team_id' => 'nullable|exists:teams,id', + 'date_from' => 'nullable|date', + 'date_to' => 'nullable|date', + ]); + + $allowedTeamIds = $this->allowedTeamIds(); + 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( + $team->id, + $validated['date_from'] ?? null, + $validated['date_to'] ?? null + ))->values()); + } + + return response()->json( + $this->reportService->teamPerformance( + $validated['team_id'], + $validated['date_from'] ?? null, + $validated['date_to'] ?? null + ) + ); + } + + public function campaignReport(Request $request, int $campaignId): JsonResponse + { + Gate::authorize('view-report-data'); + + $validated = $request->validate([ + 'date_from' => 'nullable|date', + 'date_to' => 'nullable|date', + ]); + + $campaign = Campaign::findOrFail($campaignId); + Gate::authorize('view', $campaign); + + return response()->json($this->reportService->campaignReport( + $campaignId, + $validated['date_from'] ?? null, + $validated['date_to'] ?? null + )); + } + + public function conversionReport(Request $request): JsonResponse + { + Gate::authorize('view-report-data'); + + $validated = $request->validate([ + 'agent_id' => 'nullable|exists:users,id', + 'date_from' => 'nullable|date', + 'date_to' => 'nullable|date', + ]); + + $agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null); + if ($agentIds === false) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + return response()->json( + $this->reportService->conversionReport( + $validated['date_from'] ?? null, + $validated['date_to'] ?? null, + $validated['agent_id'] ?? null, + $agentIds + ) + ); + } + + public function callReport(Request $request): JsonResponse + { + Gate::authorize('view-report-data'); + + $validated = $request->validate([ + 'agent_id' => 'nullable|exists:users,id', + 'date_from' => 'nullable|date', + 'date_to' => 'nullable|date', + ]); + + $agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null); + if ($agentIds === false) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + return response()->json( + $this->reportService->callReport( + $validated['date_from'] ?? null, + $validated['date_to'] ?? null, + $validated['agent_id'] ?? null, + $agentIds + ) + ); + } + + public function followUpReport(Request $request): JsonResponse + { + Gate::authorize('view-report-data'); + + $validated = $request->validate([ + 'agent_id' => 'nullable|exists:users,id', + 'date_from' => 'nullable|date', + 'date_to' => 'nullable|date', + ]); + + $agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null); + if ($agentIds === false) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + return response()->json( + $this->reportService->followUpReport( + $validated['date_from'] ?? null, + $validated['date_to'] ?? null, + $validated['agent_id'] ?? null, + $agentIds + ) + ); + } + + public function lostReasonReport(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->lostReasonReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds)); + } + + public function sourcePerformance(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->sourcePerformanceReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds)); + } + + public function duplicateLeads(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->duplicateLeadsReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds)); + } + + public function importQuality(Request $request): JsonResponse + { + Gate::authorize('view-report-data'); + $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)); + } + + public function callQuality(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->callQualityReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds)); + } + + public function bestContactTime(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->bestContactTimeReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds)); + } + + public function exportExcel(Request $request): StreamedResponse|JsonResponse + { + Gate::authorize('export-report-data'); + + $type = $request->type ?? 'agent'; + $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)) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + ActivityLogger::log('report_exported', "Report export requested: {$type}"); + + $data = match ($type) { + '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) : [], + 'conversion' => $this->reportService->conversionReport($request->date_from, $request->date_to, null, $agentIds), + 'call' => $this->reportService->callReport($request->date_from, $request->date_to, null, $agentIds), + 'follow_up' => $this->reportService->followUpReport($request->date_from, $request->date_to, null, $agentIds), + 'lost_reason' => $this->reportService->lostReasonReport($request->date_from, $request->date_to, null, $agentIds), + 'source' => $this->reportService->sourcePerformanceReport($request->date_from, $request->date_to, null, $agentIds), + 'duplicate' => $this->reportService->duplicateLeadsReport($request->date_from, $request->date_to, null, $agentIds), + 'import_quality' => $this->reportService->importQualityReport($request->date_from, $request->date_to), + 'call_quality' => $this->reportService->callQualityReport($request->date_from, $request->date_to, null, $agentIds), + 'best_contact_time' => $this->reportService->bestContactTimeReport($request->date_from, $request->date_to, null, $agentIds), + default => [], + }; + + $rows = $this->flattenForCsv($data); + $filename = 'report-' . $type . '-' . now()->format('Ymd-His') . '.csv'; + + return response()->streamDownload(function () use ($rows) { + $out = fopen('php://output', 'w'); + fwrite($out, "\xEF\xBB\xBF"); + if ($rows === []) { + 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), ','); + } + fclose($out); + }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + private function validateScopedReport(Request $request): array + { + return $request->validate([ + 'agent_id' => 'nullable|exists:users,id', + 'date_from' => 'nullable|date', + 'date_to' => 'nullable|date', + ]); + } + + /** + * @return list + */ + private function allowedAgentIds(): array + { + $user = auth()->user(); + + if ($user->hasRole('admin')) { + return User::role('agent')->pluck('id')->all(); + } + + if ($user->hasRole('supervisor')) { + return AccessControl::teamMemberIds($user); + } + + return [$user->id]; + } + + /** + * @return list + */ + private function allowedTeamIds(): array + { + $user = auth()->user(); + + if ($user->hasRole('admin')) { + return Team::pluck('id')->all(); + } + + if ($user->hasRole('supervisor')) { + return AccessControl::teamIds($user); + } + + return []; + } + + /** + * @return list|false + */ + private function requestedAgentScope(?int $agentId): array|false + { + $allowedAgentIds = $this->allowedAgentIds(); + + if ($agentId) { + return in_array($agentId, $allowedAgentIds, true) ? [$agentId] : false; + } + + return auth()->user()->hasRole('admin') ? [] : $allowedAgentIds; + } + + private function flattenForCsv(mixed $data): array + { + if ($data instanceof \Illuminate\Support\Collection) { + $data = $data->toArray(); + } + if ($data instanceof \Illuminate\Database\Eloquent\Model) { + $data = $data->toArray(); + } + if (is_array($data) && array_is_list($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 [$data]; + } + return []; + } +} diff --git a/backend/app/Http/Controllers/Api/RoleController.php b/backend/app/Http/Controllers/Api/RoleController.php new file mode 100644 index 0000000..b5a7cf3 --- /dev/null +++ b/backend/app/Http/Controllers/Api/RoleController.php @@ -0,0 +1,90 @@ +orderBy('name')->get(); + return response()->json($roles); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|unique:roles,name', + 'permissions' => 'sometimes|array', + 'permissions.*' => 'exists:permissions,name', + ]); + + $role = Role::create(['name' => $validated['name'], 'guard_name' => 'web']); + + if ($request->permissions) { + $role->syncPermissions($validated['permissions']); + } + + ActivityLogger::log('role_created', "Role {$role->name} created"); + + return response()->json($role->load('permissions'), 201); + } + + public function show(Role $role): JsonResponse + { + return response()->json($role->load('permissions')); + } + + public function update(Request $request, Role $role): JsonResponse + { + $validated = $request->validate([ + 'name' => 'sometimes|string|unique:roles,name,' . $role->id, + 'permissions' => 'sometimes|array', + 'permissions.*' => 'exists:permissions,name', + ]); + + if (isset($validated['name'])) { + $role->update(['name' => $validated['name']]); + } + + if ($request->has('permissions')) { + $role->syncPermissions($validated['permissions']); + } + + ActivityLogger::log('role_updated', "Role {$role->name} updated"); + + return response()->json($role->load('permissions')); + } + + public function destroy(Role $role): JsonResponse + { + if ($role->name === 'admin') { + return response()->json(['message' => 'نقش ادمین قابل حذف نیست'], 422); + } + + $role->delete(); + + ActivityLogger::log('role_deleted', "Role {$role->name} deleted"); + + return response()->json(['message' => 'نقش حذف شد']); + } + + public function syncPermissions(Request $request, Role $role): JsonResponse + { + $request->validate(['permissions' => 'required|array', 'permissions.*' => 'exists:permissions,name']); + $role->syncPermissions($request->permissions); + + return response()->json($role->load('permissions')); + } + + public function permissions(): JsonResponse + { + return response()->json(Permission::orderBy('name')->get()); + } +} diff --git a/backend/app/Http/Controllers/Api/ScriptController.php b/backend/app/Http/Controllers/Api/ScriptController.php new file mode 100644 index 0000000..dc56b8c --- /dev/null +++ b/backend/app/Http/Controllers/Api/ScriptController.php @@ -0,0 +1,128 @@ +json( + SalesScript::with('sections', 'campaign:id,name') + ->orderBy('created_at', 'desc') + ->paginate(15) + ); + } + + 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', + ]); + + $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, + ]); + + if ($request->sections) { + foreach ($validated['sections'] as $i => $section) { + $script->sections()->create([ + 'title' => $section['title'], + 'content' => $section['content'], + 'sort_order' => $section['sort_order'] ?? $i, + ]); + } + } + + ActivityLogger::log('script_created', "Script {$script->title} created", $script); + + return response()->json($script->load('sections'), 201); + } + + public function show(SalesScript $script): JsonResponse + { + return response()->json($script->load('sections', 'campaign')); + } + + public function update(Request $request, SalesScript $script): JsonResponse + { + $validated = $request->validate([ + 'title' => 'sometimes|string|max:255', + 'description' => 'nullable|string', + 'campaign_id' => 'nullable|exists:campaigns,id', + 'product_id' => 'nullable|exists:products,id', + 'version' => 'nullable|string|max:20', + 'is_active' => 'nullable|boolean', + 'checklist' => 'nullable|array', + 'objection_handling' => 'nullable|array', + 'sections' => 'nullable|array', + 'sections.*.id' => 'nullable|exists:script_sections,id', + 'sections.*.title' => 'required|string|max:255', + 'sections.*.content' => 'required|string', + 'sections.*.sort_order' => 'nullable|integer', + ]); + + $script->update($validated); + + if ($request->has('sections')) { + $existingIds = $script->sections->pluck('id')->toArray(); + $updatedIds = []; + + foreach ($validated['sections'] as $i => $section) { + if (isset($section['id']) && in_array($section['id'], $existingIds)) { + $script->sections()->where('id', $section['id'])->update([ + 'title' => $section['title'], + 'content' => $section['content'], + 'sort_order' => $section['sort_order'] ?? $i, + ]); + $updatedIds[] = $section['id']; + } else { + $newSection = $script->sections()->create([ + 'title' => $section['title'], + 'content' => $section['content'], + 'sort_order' => $section['sort_order'] ?? $i, + ]); + $updatedIds[] = $newSection->id; + } + } + + $toDelete = array_diff($existingIds, $updatedIds); + if (!empty($toDelete)) { + $script->sections()->whereIn('id', $toDelete)->delete(); + } + } + + ActivityLogger::log('script_updated', "Script {$script->title} updated", $script); + + return response()->json($script->load('sections')); + } + + public function destroy(SalesScript $script): JsonResponse + { + $script->delete(); + ActivityLogger::log('script_deleted', "Script {$script->title} deleted"); + return response()->json(['message' => 'اسکریپت حذف شد']); + } +} diff --git a/backend/app/Http/Controllers/Api/SettingController.php b/backend/app/Http/Controllers/Api/SettingController.php new file mode 100644 index 0000000..b7c04de --- /dev/null +++ b/backend/app/Http/Controllers/Api/SettingController.php @@ -0,0 +1,178 @@ +map(function (Setting $setting) { + $meta = SettingsCatalog::metaFor($setting->key) ?? []; + if ($setting->is_secret) { + $setting->value = $setting->value ? '********' : ''; + } + + return array_merge($setting->toArray(), [ + 'label' => $meta['label'] ?? $setting->key, + 'hint' => $meta['hint'] ?? $setting->description, + 'used_by' => $meta['used_by'] ?? [], + 'coming_soon' => $meta['coming_soon'] ?? !$setting->is_runtime_enforced, + ]); + }) + ->groupBy('group'); + return response()->json($settings); + } + + public function update(Request $request): JsonResponse + { + Gate::authorize('update', Setting::class); + SettingsCatalog::ensureDefaults(); + + $request->validate([ + 'settings' => 'required|array', + 'settings.*.key' => 'required|string', + 'settings.*.value' => 'nullable', + 'settings.*.group' => 'nullable|string', + 'settings.*.type' => 'nullable|string', + ]); + + foreach ($request->settings as $setting) { + $existing = Setting::where('key', $setting['key'])->first(); + $this->validateSettingValue($setting, $existing); + + $oldValue = $existing?->value; + $newValue = ($existing?->is_secret && ($setting['value'] ?? '') === '********') + ? $oldValue + : $this->normalizeValue($setting['value'] ?? '', $setting['type'] ?? $existing?->type ?? 'string'); + + $meta = SettingsCatalog::metaFor($setting['key']); + Setting::updateOrCreate( + ['key' => $setting['key']], + [ + 'value' => $newValue, + 'group' => $meta['group'] ?? $setting['group'] ?? $existing?->group ?? 'general', + 'type' => $meta['type'] ?? $setting['type'] ?? $existing?->type ?? 'string', + 'default_value' => $meta['default_value'] ?? $existing?->default_value, + 'allowed_values' => $meta['allowed_values'] ?? $existing?->allowed_values, + 'is_secret' => $meta['is_secret'] ?? $existing?->is_secret ?? false, + 'is_public' => $meta['is_public'] ?? $existing?->is_public ?? false, + 'is_runtime_enforced' => $meta['is_runtime_enforced'] ?? $existing?->is_runtime_enforced ?? true, + 'description' => $meta['hint'] ?? $existing?->description, + ] + ); + + if ($this->isSensitiveChange($setting['key'], $meta ?? null)) { + $old = ($existing?->is_secret ?? false) ? '[secret]' : $oldValue; + ActivityLogger::log('sensitive_setting_changed', "Setting {$setting['key']} changed from {$old}"); + } + } + + ActivityLogger::log('settings_updated', 'System settings updated'); + + return response()->json(['message' => 'تنظیمات ذخیره شد']); + } + + 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()]); + + return response()->json($settings); + } + + public function testVoip(): JsonResponse + { + Gate::authorize('viewAny', Setting::class); + $provider = Setting::where('key', 'voip_provider')->value('value') ?: 'mock'; + $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 ($provider === 'api') { + foreach (['voip_api_base_url', 'voip_api_token'] as $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; + } + } + + 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, + 'message' => $result['message'] ?? 'نتیجه تست اتصال مشخص نیست.', + 'missing' => $result['missing'] ?? [], + ]); + } + + return response()->json([ + 'ok' => !$missing, + 'provider' => $provider, + 'message' => $missing ? 'تنظیمات اتصال کامل نیست.' : 'تنظیمات اتصال معتبر است.', + 'missing' => $missing, + ]); + } + + private function validateSettingValue(array $payload, ?Setting $existing): void + { + $type = $payload['type'] ?? $existing?->type ?? 'string'; + $value = $payload['value'] ?? ''; + $rules = $existing?->validation_rules ?: match ($type) { + 'boolean' => ['in:true,false,1,0'], + 'integer' => ['integer'], + default => ['nullable', 'string'], + }; + if ($type === 'boolean') { + $rules = ['in:true,false,1,0']; + } + + if ($existing?->allowed_values) { + $rules[] = 'in:' . implode(',', $existing->allowed_values); + } + + Validator::make(['value' => $value], ['value' => $rules])->validate(); + } + + private function normalizeValue(mixed $value, string $type): string + { + return match ($type) { + 'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false', + 'integer' => (string) (int) $value, + default => (string) $value, + }; + } + + private function isSensitiveChange(string $key, ?array $meta): bool + { + return ($meta['is_secret'] ?? false) + || in_array($meta['group'] ?? '', ['security', 'import_export', 'voip'], true) + || str_contains($key, 'password') + || str_contains($key, 'token') + || str_contains($key, 'export'); + } +} diff --git a/backend/app/Http/Controllers/Api/TimelineController.php b/backend/app/Http/Controllers/Api/TimelineController.php new file mode 100644 index 0000000..0811246 --- /dev/null +++ b/backend/app/Http/Controllers/Api/TimelineController.php @@ -0,0 +1,84 @@ +validate([ + 'entity_type' => 'required|in:lead,company,deal', + 'entity_id' => 'required|integer', + ]); + + [$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))); + + 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'))); + + 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'))); + + 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'))); + } + + return response()->json([ + 'data' => $items->sortByDesc('created_at')->values()->take(150)->all(), + ]); + } + + private function classFor(string $type): string + { + return match ($type) { + 'lead' => Lead::class, + 'company' => Company::class, + 'deal' => Deal::class, + }; + } + + private function item($date, string $title, ?string $description, ?string $user, string $type): array + { + return [ + 'type' => $type, + 'title' => $title, + 'description' => $description, + 'user' => $user ?: 'سیستم', + 'created_at' => optional($date)->toDateTimeString(), + ]; + } + + private function label(string $action): string + { + return [ + 'call_result_registered' => 'نتیجه تماس ثبت شد', + 'lead_stage_changed' => 'مرحله تغییر کرد', + 'lead_assigned' => 'مالک تغییر کرد', + 'lead_owner_changed' => 'مالک تغییر کرد', + 'company_created' => 'شرکت ایجاد شد', + 'company_updated' => 'شرکت ویرایش شد', + 'company_merged' => 'شرکت ادغام شد', + 'deal_created' => 'فرصت فروش ایجاد شد', + 'deal_updated' => 'فرصت فروش ویرایش شد', + 'attachment_uploaded' => 'فایل بارگذاری شد', + 'attachment_downloaded' => 'فایل دریافت شد', + ][$action] ?? 'رویداد ثبت شد'; + } +} diff --git a/backend/app/Http/Controllers/Api/UserController.php b/backend/app/Http/Controllers/Api/UserController.php new file mode 100644 index 0000000..57e8679 --- /dev/null +++ b/backend/app/Http/Controllers/Api/UserController.php @@ -0,0 +1,170 @@ +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}%"); + }); + } + + if ($request->role) { + $query->role($request->role); + } + + if ($request->team_id) { + $query->whereHas('teams', fn($q) => $q->where('teams.id', $request->team_id)); + } + + if ($request->has('is_active')) { + $query->where('is_active', filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN)); + } + + $users = $query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15); + + return response()->json($users); + } + + public function agents(): JsonResponse + { + $user = auth()->user(); + + if ($user?->hasRole('agent')) { + return response()->json([$user->load('roles', 'teams')]); + } + + return response()->json( + User::role('agent') + ->where('is_active', true) + ->with('roles', 'teams') + ->orderBy('name') + ->get() + ); + } + + public function store(Request $request): JsonResponse + { + Gate::authorize('create', User::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|unique:users', + 'password' => ['required', ...PasswordPolicy::rules()], + 'phone' => 'nullable|string|max:20', + 'voip_extension' => ['nullable', 'string', 'max:20', 'regex:/^[0-9*#]+$/', 'unique:users,voip_extension'], + 'role' => 'required|string|exists:roles,name', + ]); + + $user = User::create([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password' => Hash::make($validated['password']), + 'phone' => $validated['phone'] ?? null, + 'voip_extension' => $validated['voip_extension'] ?? null, + ]); + + $user->assignRole($validated['role']); + + ActivityLogger::log('user_created', "User {$user->email} created"); + + return response()->json($user->load('roles'), 201); + } + + public function show(User $user): JsonResponse + { + Gate::authorize('view', $user); + + return response()->json($user->load('roles.permissions', 'teams')); + } + + public function update(Request $request, User $user): JsonResponse + { + Gate::authorize('update', $user); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + '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], + 'is_active' => 'sometimes|boolean', + ]); + + if (isset($validated['password'])) { + $validated['password'] = Hash::make($validated['password']); + } + + $user->update($validated); + + if ($request->role) { + $user->syncRoles([$request->role]); + } + + ActivityLogger::log('user_updated', "User {$user->email} updated"); + + return response()->json($user->load('roles', 'teams')); + } + + public function destroy(User $user): JsonResponse + { + Gate::authorize('delete', $user); + + $user->delete(); + + ActivityLogger::log('user_deleted', "User {$user->email} deleted"); + + return response()->json(['message' => 'کاربر حذف شد']); + } + + public function toggleActive(User $user): JsonResponse + { + Gate::authorize('update', $user); + + $user->update(['is_active' => !$user->is_active]); + + ActivityLogger::log('user_toggled', "User {$user->email} active: {$user->is_active}"); + + return response()->json($user); + } + + public function assignRole(Request $request, User $user): JsonResponse + { + Gate::authorize('update', $user); + + $request->validate(['role' => 'required|string|exists:roles,name']); + $user->syncRoles([$request->role]); + + return response()->json($user->load('roles')); + } + + public function assignTeam(Request $request, User $user): JsonResponse + { + Gate::authorize('update', $user); + + $request->validate(['team_ids' => 'required|array', 'team_ids.*' => 'exists:teams,id']); + $user->teams()->sync($request->team_ids); + + return response()->json($user->load('teams')); + } +} diff --git a/backend/app/Http/Controllers/Controller.php b/backend/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/backend/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +method() !== 'GET' && auth()->check()) { + $action = $request->method() . ' ' . $request->path(); + $description = null; + + if (str_contains($request->path(), 'login')) { + $action = 'login'; + } elseif (str_contains($request->path(), 'logout')) { + $action = 'logout'; + } + + ActivityLogger::log($action, $description); + } + + return $response; + } +} diff --git a/backend/app/Http/Middleware/MaskPhoneNumber.php b/backend/app/Http/Middleware/MaskPhoneNumber.php new file mode 100644 index 0000000..fabb661 --- /dev/null +++ b/backend/app/Http/Middleware/MaskPhoneNumber.php @@ -0,0 +1,58 @@ +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')); + + if ($maskPhones || $maskRecordings) { + $data = $response->getData(true); + if (is_array($data)) { + $this->maskInArray($data, $maskPhones, $maskRecordings); + $response->setData($data); + } + } + + return $response; + } + + private function maskInArray(array &$data, bool $maskPhones, bool $maskRecordings): void + { + foreach ($data as $key => &$value) { + if ($maskPhones && in_array($key, ['phone', 'phone_secondary'], true) && is_string($value)) { + $value = $this->mask($value); + } elseif ($maskRecordings && in_array($key, ['recording_url', 'recordingUrl'], true)) { + $value = null; + } elseif (is_array($value)) { + $this->maskInArray($value, $maskPhones, $maskRecordings); + } + } + } + + private function mask(string $phone): string + { + 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); + } +} diff --git a/backend/app/Http/Middleware/SecurityHeaders.php b/backend/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..606a794 --- /dev/null +++ b/backend/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,39 @@ + 'nosniff', + 'X-Frame-Options' => 'DENY', + 'Referrer-Policy' => 'strict-origin-when-cross-origin', + 'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()', + 'Content-Security-Policy' => "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", + ]; + + foreach ($headers as $name => $value) { + if (!$response->headers->has($name)) { + $response->headers->set($name, $value); + } + } + + if ($request->is('api/*') || $request->is('sanctum/*')) { + $response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, private'); + $response->headers->set('Pragma', 'no-cache'); + $response->headers->set('Expires', '0'); + } + + return $response; + } +} diff --git a/backend/app/Models/ActivityLog.php b/backend/app/Models/ActivityLog.php new file mode 100644 index 0000000..f9731a2 --- /dev/null +++ b/backend/app/Models/ActivityLog.php @@ -0,0 +1,19 @@ +belongsTo(User::class); + } +} diff --git a/backend/app/Models/Attachment.php b/backend/app/Models/Attachment.php new file mode 100644 index 0000000..3021849 --- /dev/null +++ b/backend/app/Models/Attachment.php @@ -0,0 +1,28 @@ +morphTo(); + } + + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by'); + } +} diff --git a/backend/app/Models/Call.php b/backend/app/Models/Call.php new file mode 100644 index 0000000..9ccf87f --- /dev/null +++ b/backend/app/Models/Call.php @@ -0,0 +1,45 @@ + 'boolean', 'duration' => 'integer']; + } + + public function lead(): BelongsTo + { + return $this->belongsTo(Lead::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function contactPhone(): BelongsTo + { + return $this->belongsTo(ContactPhone::class); + } + + public function qualityReview(): HasOne + { + return $this->hasOne(QualityReview::class); + } +} diff --git a/backend/app/Models/CallLog.php b/backend/app/Models/CallLog.php new file mode 100644 index 0000000..0bb8eb1 --- /dev/null +++ b/backend/app/Models/CallLog.php @@ -0,0 +1,39 @@ + 'datetime']; + } + + public function lead(): BelongsTo + { + return $this->belongsTo(Lead::class); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function phone(): BelongsTo + { + return $this->belongsTo(ContactPhone::class, 'contact_phone_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/CallResult.php b/backend/app/Models/CallResult.php new file mode 100644 index 0000000..1f5438a --- /dev/null +++ b/backend/app/Models/CallResult.php @@ -0,0 +1,36 @@ + 'boolean', + 'is_positive' => 'boolean', + 'is_negative' => 'boolean', + 'is_final' => 'boolean', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function nextPipelineStage() + { + return $this->belongsTo(PipelineStage::class, 'next_pipeline_stage_id'); + } + + public function leadStatus() + { + return $this->belongsTo(LeadStatus::class); + } +} diff --git a/backend/app/Models/Campaign.php b/backend/app/Models/Campaign.php new file mode 100644 index 0000000..c664114 --- /dev/null +++ b/backend/app/Models/Campaign.php @@ -0,0 +1,49 @@ + 'date', + 'end_date' => 'date', + 'target' => 'integer', + ]; + } + + public function assignedAgents(): BelongsToMany + { + return $this->belongsToMany(User::class) + ->wherePivot('role', 'agent') + ->withPivot('role'); + } + + public function assignedSupervisors(): BelongsToMany + { + return $this->belongsToMany(User::class) + ->wherePivot('role', 'supervisor') + ->withPivot('role'); + } + + public function leads(): HasMany + { + return $this->hasMany(Lead::class); + } +} diff --git a/backend/app/Models/Company.php b/backend/app/Models/Company.php new file mode 100644 index 0000000..f830082 --- /dev/null +++ b/backend/app/Models/Company.php @@ -0,0 +1,49 @@ +belongsTo(User::class, 'owner_id'); + } + + public function contacts(): HasMany + { + return $this->hasMany(Contact::class); + } + + public function leads(): HasMany + { + return $this->hasMany(Lead::class); + } + + public function deals(): HasMany + { + return $this->hasMany(Deal::class); + } + + public function notes(): MorphMany + { + return $this->morphMany(Note::class, 'notable'); + } + + public function attachments(): MorphMany + { + return $this->morphMany(Attachment::class, 'attachable'); + } +} diff --git a/backend/app/Models/Contact.php b/backend/app/Models/Contact.php new file mode 100644 index 0000000..b592b7b --- /dev/null +++ b/backend/app/Models/Contact.php @@ -0,0 +1,46 @@ + 'boolean']; + } + + public function lead(): BelongsTo + { + return $this->belongsTo(Lead::class); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function deal(): BelongsTo + { + return $this->belongsTo(Deal::class); + } + + public function phones(): HasMany + { + return $this->hasMany(ContactPhone::class); + } + + public function callLogs(): HasMany + { + return $this->hasMany(CallLog::class); + } +} diff --git a/backend/app/Models/ContactPhone.php b/backend/app/Models/ContactPhone.php new file mode 100644 index 0000000..ae2e867 --- /dev/null +++ b/backend/app/Models/ContactPhone.php @@ -0,0 +1,41 @@ + 'integer', + 'successful_call_count' => 'integer', + 'failed_call_count' => 'integer', + 'last_called_at' => 'datetime', + ]; + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function lastCaller(): BelongsTo + { + return $this->belongsTo(User::class, 'last_called_by'); + } + + public function callLogs(): HasMany + { + return $this->hasMany(CallLog::class); + } +} diff --git a/backend/app/Models/ContactRelation.php b/backend/app/Models/ContactRelation.php new file mode 100644 index 0000000..a5af375 --- /dev/null +++ b/backend/app/Models/ContactRelation.php @@ -0,0 +1,24 @@ +belongsTo(Contact::class, 'from_contact_id'); + } + + public function toContact(): BelongsTo + { + return $this->belongsTo(Contact::class, 'to_contact_id'); + } +} diff --git a/backend/app/Models/Deal.php b/backend/app/Models/Deal.php new file mode 100644 index 0000000..b2a125d --- /dev/null +++ b/backend/app/Models/Deal.php @@ -0,0 +1,63 @@ + 'decimal:2', + 'win_probability' => 'integer', + 'expected_close_date' => 'date', + ]; + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function lead(): BelongsTo + { + return $this->belongsTo(Lead::class); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_id'); + } + + public function notes(): MorphMany + { + return $this->morphMany(Note::class, 'notable'); + } + + public function attachments(): MorphMany + { + return $this->morphMany(Attachment::class, 'attachable'); + } +} diff --git a/backend/app/Models/FollowUp.php b/backend/app/Models/FollowUp.php new file mode 100644 index 0000000..0d4a492 --- /dev/null +++ b/backend/app/Models/FollowUp.php @@ -0,0 +1,38 @@ + 'datetime', + 'completed_at' => 'datetime', + 'is_overdue' => 'boolean', + ]; + } + + public function lead(): BelongsTo + { + return $this->belongsTo(Lead::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function call(): BelongsTo + { + return $this->belongsTo(Call::class); + } +} diff --git a/backend/app/Models/ImportBatch.php b/backend/app/Models/ImportBatch.php new file mode 100644 index 0000000..8236e75 --- /dev/null +++ b/backend/app/Models/ImportBatch.php @@ -0,0 +1,43 @@ + 'array', + 'can_rollback' => 'boolean', + 'total_rows' => 'integer', + 'imported_rows' => 'integer', + 'skipped_rows' => 'integer', + 'failed_rows' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function campaign(): BelongsTo + { + return $this->belongsTo(Campaign::class); + } + + public function rows(): HasMany + { + return $this->hasMany(ImportBatchRow::class); + } +} diff --git a/backend/app/Models/ImportBatchRow.php b/backend/app/Models/ImportBatchRow.php new file mode 100644 index 0000000..621a843 --- /dev/null +++ b/backend/app/Models/ImportBatchRow.php @@ -0,0 +1,26 @@ + 'array']; + } + + public function importBatch(): BelongsTo + { + return $this->belongsTo(ImportBatch::class); + } + + public function lead(): BelongsTo + { + return $this->belongsTo(Lead::class); + } +} diff --git a/backend/app/Models/Lead.php b/backend/app/Models/Lead.php new file mode 100644 index 0000000..ac7376d --- /dev/null +++ b/backend/app/Models/Lead.php @@ -0,0 +1,166 @@ + 'integer', + 'lead_score' => 'integer', + 'call_attempts' => 'integer', + 'deal_value' => 'decimal:2', + 'is_unassigned' => 'boolean', + 'last_call_at' => 'datetime', + 'next_follow_up_at' => 'datetime', + 'contract_date' => 'date', + ]; + } + + public function getFullNameAttribute(): string + { + 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); + } + + public function leadStatus(): BelongsTo + { + return $this->belongsTo(LeadStatus::class); + } + + public function account(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + public function pipelineStage(): BelongsTo + { + return $this->belongsTo(PipelineStage::class); + } + + public function campaign(): BelongsTo + { + return $this->belongsTo(Campaign::class); + } + + public function assignedAgent(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function assignedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_by'); + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function calls(): HasMany + { + return $this->hasMany(Call::class); + } + + public function contacts(): HasMany + { + return $this->hasMany(Contact::class); + } + + public function primaryContact(): HasMany + { + return $this->hasMany(Contact::class)->where('is_primary', true); + } + + public function contactRelations(): HasMany + { + return $this->hasMany(ContactRelation::class); + } + + public function callLogs(): HasMany + { + return $this->hasMany(CallLog::class); + } + + public function followUps(): HasMany + { + return $this->hasMany(FollowUp::class); + } + + public function notes(): MorphMany + { + return $this->morphMany(Note::class, 'notable'); + } + + public function attachments(): MorphMany + { + return $this->morphMany(Attachment::class, 'attachable'); + } + + public function deals(): HasMany + { + return $this->hasMany(Deal::class); + } + + public function leadAssignments(): HasMany + { + return $this->hasMany(LeadAssignment::class); + } + + public function scopeAssignedTo($query, $userId) + { + return $query->where('assigned_to', $userId); + } + + public function scopeUnassigned($query) + { + return $query->where('is_unassigned', true); + } + + public function scopeByTeam($query, $teamId) + { + return $query->where('team_id', $teamId); + } + + public function scopeByCampaign($query, $campaignId) + { + return $query->where('campaign_id', $campaignId); + } + + public function scopeByStatus($query, $statusId) + { + return $query->where('lead_status_id', $statusId); + } + + public function scopeByPipelineStage($query, $stageId) + { + return $query->where('pipeline_stage_id', $stageId); + } +} diff --git a/backend/app/Models/LeadAssignment.php b/backend/app/Models/LeadAssignment.php new file mode 100644 index 0000000..62d9614 --- /dev/null +++ b/backend/app/Models/LeadAssignment.php @@ -0,0 +1,26 @@ +belongsTo(Lead::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function assignedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_by'); + } +} diff --git a/backend/app/Models/LeadStatus.php b/backend/app/Models/LeadStatus.php new file mode 100644 index 0000000..b0a9a78 --- /dev/null +++ b/backend/app/Models/LeadStatus.php @@ -0,0 +1,21 @@ + 'boolean', 'is_default' => 'boolean', 'is_won' => 'boolean', 'is_lost' => 'boolean']; + } + + public function leads(): HasMany + { + return $this->hasMany(Lead::class); + } +} diff --git a/backend/app/Models/LostReason.php b/backend/app/Models/LostReason.php new file mode 100644 index 0000000..da6af2b --- /dev/null +++ b/backend/app/Models/LostReason.php @@ -0,0 +1,18 @@ + 'integer', + 'is_active' => 'boolean', + ]; + } +} diff --git a/backend/app/Models/MergeHistory.php b/backend/app/Models/MergeHistory.php new file mode 100644 index 0000000..6027962 --- /dev/null +++ b/backend/app/Models/MergeHistory.php @@ -0,0 +1,21 @@ + 'array']; + } + + public function mergedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'merged_by'); + } +} diff --git a/backend/app/Models/Note.php b/backend/app/Models/Note.php new file mode 100644 index 0000000..6fdf0a6 --- /dev/null +++ b/backend/app/Models/Note.php @@ -0,0 +1,22 @@ +morphTo(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/Notification.php b/backend/app/Models/Notification.php new file mode 100644 index 0000000..124c815 --- /dev/null +++ b/backend/app/Models/Notification.php @@ -0,0 +1,26 @@ + 'array', + 'is_read' => 'boolean', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/PipelineStage.php b/backend/app/Models/PipelineStage.php new file mode 100644 index 0000000..13915a8 --- /dev/null +++ b/backend/app/Models/PipelineStage.php @@ -0,0 +1,21 @@ + 'boolean', 'is_default' => 'boolean', 'is_won' => 'boolean', 'is_lost' => 'boolean', 'requires_follow_up' => 'boolean']; + } + + public function leads(): HasMany + { + return $this->hasMany(Lead::class); + } +} diff --git a/backend/app/Models/Product.php b/backend/app/Models/Product.php new file mode 100644 index 0000000..90a9f76 --- /dev/null +++ b/backend/app/Models/Product.php @@ -0,0 +1,38 @@ + 'decimal:2', + 'is_active' => 'boolean', + 'faq' => 'array', + 'objection_handling' => 'array', + ]; + } + + public function salesScript(): BelongsTo + { + return $this->belongsTo(SalesScript::class); + } + + public function deals(): HasMany + { + return $this->hasMany(Deal::class); + } +} diff --git a/backend/app/Models/QualityReview.php b/backend/app/Models/QualityReview.php new file mode 100644 index 0000000..1d6c646 --- /dev/null +++ b/backend/app/Models/QualityReview.php @@ -0,0 +1,37 @@ + 'boolean']; + } + + public function call(): BelongsTo + { + return $this->belongsTo(Call::class); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewer_id'); + } + + public function agent(): BelongsTo + { + return $this->belongsTo(User::class, 'agent_id'); + } +} diff --git a/backend/app/Models/SalesScript.php b/backend/app/Models/SalesScript.php new file mode 100644 index 0000000..483c090 --- /dev/null +++ b/backend/app/Models/SalesScript.php @@ -0,0 +1,36 @@ + 'boolean', + 'checklist' => 'array', + 'objection_handling' => 'array', + ]; + } + + public function campaign(): BelongsTo + { + return $this->belongsTo(Campaign::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function sections(): HasMany + { + return $this->hasMany(ScriptSection::class); + } +} diff --git a/backend/app/Models/ScriptSection.php b/backend/app/Models/ScriptSection.php new file mode 100644 index 0000000..5c64ae1 --- /dev/null +++ b/backend/app/Models/ScriptSection.php @@ -0,0 +1,21 @@ + 'integer']; + } + + public function salesScript(): BelongsTo + { + return $this->belongsTo(SalesScript::class); + } +} diff --git a/backend/app/Models/Setting.php b/backend/app/Models/Setting.php new file mode 100644 index 0000000..8d70897 --- /dev/null +++ b/backend/app/Models/Setting.php @@ -0,0 +1,29 @@ + 'array', + 'allowed_values' => 'array', + 'is_secret' => 'boolean', + 'is_public' => 'boolean', + 'is_runtime_enforced' => 'boolean', + ]; + } + + public function effectiveValue(): mixed + { + return $this->value ?? $this->default_value; + } +} diff --git a/backend/app/Models/Team.php b/backend/app/Models/Team.php new file mode 100644 index 0000000..e9704fe --- /dev/null +++ b/backend/app/Models/Team.php @@ -0,0 +1,38 @@ + 'boolean']; + } + + public function supervisor(): BelongsTo + { + return $this->belongsTo(User::class, 'supervisor_id'); + } + + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class); + } + + public function leads(): HasMany + { + return $this->hasMany(Lead::class); + } + + public function campaigns(): HasMany + { + return $this->hasMany(Campaign::class); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php new file mode 100644 index 0000000..9c903f9 --- /dev/null +++ b/backend/app/Models/User.php @@ -0,0 +1,95 @@ + */ + use HasFactory, Notifiable, HasApiTokens, HasRoles, SoftDeletes; + + protected $fillable = [ + 'name', 'email', 'password', 'phone', 'voip_extension', 'avatar', 'is_active', + 'last_login_at', 'last_login_ip', 'current_login_at', + 'last_logout_at', 'total_presence_seconds', 'sales_agent_preferred_view', + ]; + + protected $hidden = [ + 'password', 'remember_token', + ]; + + protected $appends = [ + 'avatar_url', + ]; + + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'is_active' => 'boolean', + 'last_login_at' => 'datetime', + 'current_login_at' => 'datetime', + 'last_logout_at' => 'datetime', + 'total_presence_seconds' => 'integer', + ]; + } + + public function teams(): BelongsToMany + { + return $this->belongsToMany(Team::class); + } + + public function assignedLeads(): HasMany + { + return $this->hasMany(Lead::class, 'assigned_to'); + } + + public function calls(): HasMany + { + return $this->hasMany(Call::class); + } + + public function followUps(): HasMany + { + return $this->hasMany(FollowUp::class); + } + + public function leadAssignments(): HasMany + { + return $this->hasMany(LeadAssignment::class, 'assigned_by'); + } + + protected function avatarUrl(): Attribute + { + return Attribute::get(function (): ?string { + if (!$this->avatar) { + return null; + } + + $path = parse_url($this->avatar, PHP_URL_PATH) ?: $this->avatar; + $path = ltrim($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)); + }); + } +} diff --git a/backend/app/Policies/CallPolicy.php b/backend/app/Policies/CallPolicy.php new file mode 100644 index 0000000..4767ca6 --- /dev/null +++ b/backend/app/Policies/CallPolicy.php @@ -0,0 +1,36 @@ +hasRole('admin') || $user->can('view_all_calls') || $user->can('view_team_calls') || $user->can('view_own_calls'); + } + + public function view(User $user, Call $call): bool + { + return AccessControl::canAccessCall($user, $call); + } + + public function create(User $user, Lead $lead): bool + { + return AccessControl::canAccessLead($user, $lead, allowUnassignedClaim: true); + } + + public function registerResult(User $user, Call $call): bool + { + return AccessControl::canAccessCall($user, $call); + } + + public function viewRecording(User $user, Call $call): bool + { + return AccessControl::canAccessCall($user, $call) && ($user->hasRole('admin') || $user->can('listen_recordings')); + } +} diff --git a/backend/app/Policies/CampaignPolicy.php b/backend/app/Policies/CampaignPolicy.php new file mode 100644 index 0000000..5f337f1 --- /dev/null +++ b/backend/app/Policies/CampaignPolicy.php @@ -0,0 +1,35 @@ +hasAnyRole(['admin', 'supervisor', 'agent']); + } + + public function view(User $user, Campaign $campaign): bool + { + return AccessControl::canAccessCampaign($user, $campaign); + } + + public function create(User $user): bool + { + return $user->hasRole('admin') || $user->can('manage_campaigns'); + } + + public function update(User $user, Campaign $campaign): bool + { + return AccessControl::canAccessCampaign($user, $campaign) && ($user->hasRole('admin') || $user->can('manage_campaigns')); + } + + public function delete(User $user, Campaign $campaign): bool + { + return $this->update($user, $campaign); + } +} diff --git a/backend/app/Policies/FollowUpPolicy.php b/backend/app/Policies/FollowUpPolicy.php new file mode 100644 index 0000000..dbcf565 --- /dev/null +++ b/backend/app/Policies/FollowUpPolicy.php @@ -0,0 +1,31 @@ +hasAnyRole(['admin', 'supervisor', 'agent']); + } + + public function view(User $user, FollowUp $followUp): bool + { + return AccessControl::canAccessFollowUp($user, $followUp); + } + + public function create(User $user, Lead $lead): bool + { + return AccessControl::canAccessLead($user, $lead); + } + + public function update(User $user, FollowUp $followUp): bool + { + return AccessControl::canAccessFollowUp($user, $followUp); + } +} diff --git a/backend/app/Policies/ImportBatchPolicy.php b/backend/app/Policies/ImportBatchPolicy.php new file mode 100644 index 0000000..12675c3 --- /dev/null +++ b/backend/app/Policies/ImportBatchPolicy.php @@ -0,0 +1,29 @@ +hasRole('admin') || $user->can('import_leads'); + } + + public function import(User $user): bool + { + return $user->hasRole('admin') || $user->can('import_leads') || $user->hasRole('agent'); + } + + public function view(User $user, ImportBatch $batch): bool + { + return $user->hasRole('admin') || $batch->user_id === $user->id; + } + + public function rollback(User $user, ImportBatch $batch): bool + { + return $user->hasRole('admin') && (bool) $batch->can_rollback; + } +} diff --git a/backend/app/Policies/LeadPolicy.php b/backend/app/Policies/LeadPolicy.php new file mode 100644 index 0000000..0788d16 --- /dev/null +++ b/backend/app/Policies/LeadPolicy.php @@ -0,0 +1,45 @@ +hasAnyRole(['admin', 'supervisor', 'agent']); + } + + public function view(User $user, Lead $lead): bool + { + return AccessControl::canAccessLead($user, $lead); + } + + public function create(User $user): bool + { + return $user->hasRole('admin') || $user->can('create_leads') || $user->hasRole('supervisor') || $user->hasRole('agent'); + } + + public function update(User $user, Lead $lead): bool + { + return AccessControl::canAccessLead($user, $lead) && ($user->hasRole('admin') || $user->can('edit_leads') || $user->hasRole('agent')); + } + + public function delete(User $user, Lead $lead): bool + { + return AccessControl::canAccessLead($user, $lead) && ($user->hasRole('admin') || $user->can('delete_leads')); + } + + public function assign(User $user, Lead $lead): bool + { + return AccessControl::canAccessLead($user, $lead) && ($user->hasRole('admin') || $user->can('assign_leads') || $user->can('reassign_leads')); + } + + public function changeStage(User $user, Lead $lead): bool + { + return AccessControl::canAccessLead($user, $lead) && ($user->hasRole('admin') || $user->can('edit_leads') || $user->hasRole('agent')); + } +} diff --git a/backend/app/Policies/SettingPolicy.php b/backend/app/Policies/SettingPolicy.php new file mode 100644 index 0000000..8507732 --- /dev/null +++ b/backend/app/Policies/SettingPolicy.php @@ -0,0 +1,18 @@ +hasRole('admin') || $user->can('manage_settings'); + } + + public function update(User $user): bool + { + return $user->hasRole('admin') || $user->can('manage_settings'); + } +} diff --git a/backend/app/Policies/UserPolicy.php b/backend/app/Policies/UserPolicy.php new file mode 100644 index 0000000..c616df3 --- /dev/null +++ b/backend/app/Policies/UserPolicy.php @@ -0,0 +1,33 @@ +hasRole('admin') || $user->can('manage_users'); + } + + public function view(User $user, User $target): bool + { + return $user->id === $target->id || $user->hasRole('admin') || $user->can('manage_users'); + } + + public function create(User $user): bool + { + return $user->hasRole('admin') || $user->can('manage_users'); + } + + public function update(User $user, User $target): bool + { + return ($user->hasRole('admin') || $user->can('manage_users')) && $target->exists; + } + + public function delete(User $user, User $target): bool + { + return ($user->hasRole('admin') || $user->can('manage_users')) && $user->id !== $target->id; + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..85ee7ae --- /dev/null +++ b/backend/app/Providers/AppServiceProvider.php @@ -0,0 +1,49 @@ + $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')); + } +} diff --git a/backend/app/Services/ActivityLogger.php b/backend/app/Services/ActivityLogger.php new file mode 100644 index 0000000..e922835 --- /dev/null +++ b/backend/app/Services/ActivityLogger.php @@ -0,0 +1,26 @@ + auth()->id(), + 'action' => $action, + 'description' => $description, + 'subject_type' => $subject ? get_class($subject) : null, + 'subject_id' => $subject?->id, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]); + } catch (\Exception $e) { + // Silent fail for logging + } + } +} diff --git a/backend/app/Services/AssignmentService.php b/backend/app/Services/AssignmentService.php new file mode 100644 index 0000000..edf2740 --- /dev/null +++ b/backend/app/Services/AssignmentService.php @@ -0,0 +1,54 @@ +leadService->assignLead($lead, $agentId, $assignedById); + } + + public function bulkAssign(array $leadIds, int $agentId, int $assignedById): array + { + $results = []; + 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); + } + + public function returnToPool(int $leadId): Lead + { + $lead = Lead::findOrFail($leadId); + $lead->update([ + 'assigned_to' => null, + 'assigned_by' => null, + 'is_unassigned' => true, + ]); + + ActivityLogger::log('lead_returned_to_pool', "Lead {$leadId} returned to unassigned pool", $lead); + + return $lead->fresh(); + } +} diff --git a/backend/app/Services/CallService.php b/backend/app/Services/CallService.php new file mode 100644 index 0000000..ca8d70a --- /dev/null +++ b/backend/app/Services/CallService.php @@ -0,0 +1,310 @@ +findOrFail($leadId); + $user = User::findOrFail($userId); + $callerExtension = trim((string) ($user->voip_extension ?: '')); + $providerName = Setting::where('key', 'voip_provider')->value('value') ?: 'mock'; + + if ($providerName === 'ami' && $callerExtension === '') { + throw ValidationException::withMessages([ + 'voip_extension' => ['شماره داخلی برای این کاربر ثبت نشده است.'], + ]); + } + + if (!$lead->assigned_to) { + $lead->update([ + 'assigned_to' => $userId, + 'assigned_by' => $userId, + 'is_unassigned' => false, + 'pipeline_stage_id' => PipelineStage::where('slug', 'waiting_call')->value('id') ?? $lead->pipeline_stage_id, + ]); + + \App\Models\LeadAssignment::create([ + 'lead_id' => $lead->id, + 'user_id' => $userId, + 'assigned_by' => $userId, + 'method' => 'call_claim', + ]); + } + + $phone = $this->resolvePhone($lead, $contactPhoneId); + + $callerId = $callerExtension !== '' ? $callerExtension : (string) $userId; + $providerResult = $this->voipManager->initiateCall($phone->phone, $callerId); + if (($providerResult['success'] ?? false) !== true) { + throw ValidationException::withMessages([ + 'voip' => [$providerResult['message'] ?? 'درخواست تماس به مرکز تلفن ارسال نشد.'], + ]); + } + $providerCallId = $providerResult['provider_call_id'] ?? null; + $recordingEnabled = Setting::where('key', 'call_recording_enabled')->value('value') === 'true'; + + $call = Call::create([ + 'lead_id' => $leadId, + 'contact_id' => $phone->contact_id, + 'contact_phone_id' => $phone->id, + 'user_id' => $userId, + 'direction' => 'outbound', + 'phone' => $phone->phone, + 'result' => null, + 'provider_call_id' => $providerCallId, + 'recording_url' => $recordingEnabled && $providerCallId ? $this->voipManager->getRecordingUrl($providerCallId) : null, + 'is_manual' => true, + ]); + + $phone->increment('call_count'); + $phone->update([ + 'last_called_at' => now(), + 'last_called_by' => $userId, + ]); + + CallLog::create([ + 'lead_id' => $leadId, + 'contact_id' => $phone->contact_id, + 'contact_phone_id' => $phone->id, + 'call_id' => $call->id, + 'user_id' => $userId, + 'called_at' => now(), + 'recording_url' => $call->recording_url, + ]); + + $lead->update([ + 'last_call_at' => now(), + 'call_attempts' => $lead->call_attempts + 1, + ]); + + ActivityLogger::log('call_initiated', "Call initiated for lead {$leadId}", $call); + + $payload = [ + 'call_id' => $call->id, + 'contact_id' => $phone->contact_id, + 'contact_phone_id' => $phone->id, + 'lead_name' => $lead->full_name, + 'recording_enabled' => $recordingEnabled, + 'recording_url' => $call->recording_url, + 'provider_call_id' => $providerCallId, + ]; + + if ($includePhone) { + $payload['phone'] = $phone->phone; + } + + return $payload; + } + + public function registerResult(int $callId, string $result, ?string $notes = null, ?string $followUpAt = null, ?array $referral = null, array $options = []): Call + { + $call = Call::findOrFail($callId); + + return DB::transaction(function () use ($call, $callId, $result, $notes, $followUpAt, $referral, $options) { + $call->update([ + 'result' => $result, + 'notes' => $notes, + ]); + + $lead = $call->lead; + $lead->update([ + 'last_call_result' => $result, + 'last_call_at' => now(), + ]); + + $callResult = CallResult::where('name', $result)->orWhere('slug', $result)->first(); + $nextAction = $callResult?->next_action ?? ($followUpAt ? 'پیگیری در زمان مشخص‌شده' : null); + if ($callResult?->next_pipeline_stage_id) { + $lead->update(['pipeline_stage_id' => $callResult->next_pipeline_stage_id]); + } elseif ($followUpAt && $followUpStageId = PipelineStage::where('slug', 'follow_up')->value('id')) { + $lead->update(['pipeline_stage_id' => $followUpStageId]); + } + if ($callResult?->lead_status_id) { + $lead->update(['lead_status_id' => $callResult->lead_status_id]); + } + + if ($call->contactPhone) { + $success = (bool) $callResult?->is_positive; + $call->contactPhone->increment($success ? 'successful_call_count' : 'failed_call_count'); + $call->contactPhone->update([ + 'last_call_result' => $result, + 'last_called_at' => now(), + 'last_called_by' => $call->user_id, + 'status' => $callResult?->phone_status ?? $call->contactPhone->status, + ]); + } + + $newContact = null; + if (in_array($result, ['معرفی شماره یا شخص جدید', 'معرفی شماره جدید'], true) && $referral) { + $newContact = $this->createReferralContact($lead, $call, $referral, $notes); + $nextAction = 'تماس با مخاطب معرفی‌شده'; + $followUpAt = $referral['next_follow_up_at'] ?? $followUpAt; + } + + if ($result === 'شماره اشتباه' && ($options['mark_phone_wrong'] ?? false) && $call->contactPhone) { + $call->contactPhone->update(['status' => 'wrong']); + ActivityLogger::log('contact_phone_marked_wrong', "Contact phone {$call->contactPhone->id} marked wrong from mobile call result", $call->contactPhone); + } + + if ($result === 'عدم تمایل') { + $reason = $options['disinterest_reason'] ?? null; + if (($options['close_lead'] ?? false) === true) { + $lead->update([ + 'final_result' => 'ناموفق', + 'lost_reason' => $reason, + 'lead_status_id' => LeadStatus::where('slug', 'lost')->value('id') ?? $lead->lead_status_id, + 'pipeline_stage_id' => PipelineStage::where('slug', 'closed')->value('id') ?? $lead->pipeline_stage_id, + 'next_follow_up_at' => null, + ]); + $nextAction = 'لید بسته شد'; + ActivityLogger::log('lead_closed_from_mobile_call_result', "Lead {$lead->id} closed after disinterest: {$reason}", $lead); + } elseif ($call->contact) { + $call->contact->update(['status' => 'inactive']); + $nextAction = 'مخاطب غیرفعال شد'; + ActivityLogger::log('contact_marked_inactive_from_mobile_call_result', "Contact {$call->contact->id} marked inactive after disinterest: {$reason}", $call->contact); + } + } + + 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}")); + } + + CallLog::updateOrCreate( + ['call_id' => $call->id], + [ + 'lead_id' => $lead->id, + 'contact_id' => $call->contact_id, + 'contact_phone_id' => $call->contact_phone_id, + 'user_id' => $call->user_id, + 'called_at' => $call->created_at ?? now(), + 'result' => $result, + 'notes' => $notes, + 'next_action' => $nextAction, + 'recording_url' => $call->recording_url, + ] + ); + + ActivityLogger::log('call_result_registered', "Call {$callId} result: {$result}", $call); + + return $call->fresh(['lead', 'contact', 'contactPhone']); + }); + } + + private function resolvePhone(Lead $lead, ?int $contactPhoneId): ContactPhone + { + if ($contactPhoneId) { + return ContactPhone::whereHas('contact', fn($query) => $query->where('lead_id', $lead->id)) + ->findOrFail($contactPhoneId); + } + + $contact = $lead->contacts->firstWhere('is_primary', true) ?? $lead->contacts->first(); + if ($contact) { + $phone = $contact->phones->firstWhere('status', 'active') ?? $contact->phones->first(); + if ($phone) { + return $phone; + } + } + + $contact = Contact::create([ + 'lead_id' => $lead->id, + 'name' => $lead->full_name ?: ($lead->company ?? 'مخاطب اولیه'), + 'role' => 'رابط', + 'status' => 'active', + 'is_primary' => true, + 'primary_reason' => 'مخاطب اولیه لید', + 'created_by' => $lead->assigned_to, + ]); + + return ContactPhone::create([ + 'contact_id' => $contact->id, + 'phone' => $lead->phone, + 'type' => 'mobile', + 'status' => 'active', + ]); + } + + private function createReferralContact(Lead $lead, Call $call, array $referral, ?string $notes): Contact + { + $makePrimary = (bool) ($referral['make_primary'] ?? false); + + if ($makePrimary) { + Contact::where('lead_id', $lead->id)->update(['is_primary' => false]); + } + + $fromContact = $call->contact; + $primaryReason = $fromContact + ? "معرفی‌شده توسط {$fromContact->name}" + : 'معرفی‌شده در تماس'; + + $contact = Contact::create([ + 'lead_id' => $lead->id, + 'name' => $referral['name'], + 'role' => $referral['role'] ?? null, + 'description' => $referral['description'] ?? $notes, + 'status' => 'active', + 'is_primary' => $makePrimary, + 'primary_reason' => $makePrimary ? $primaryReason : null, + 'created_by' => $call->user_id, + ]); + + ContactPhone::create([ + 'contact_id' => $contact->id, + 'phone' => $referral['phone'], + 'type' => $referral['phone_type'] ?? 'mobile', + 'status' => 'active', + ]); + + ContactRelation::create([ + 'lead_id' => $lead->id, + 'from_contact_id' => $call->contact_id, + 'to_contact_id' => $contact->id, + 'relation_type' => 'introduced', + 'description' => $referral['relation_description'] ?? null, + 'created_by' => $call->user_id, + ]); + + if ($makePrimary) { + ActivityLogger::log('primary_contact_changed', "Primary contact changed to {$contact->name}: {$primaryReason}", $lead); + } + + return $contact; + } + +} diff --git a/backend/app/Services/DashboardService.php b/backend/app/Services/DashboardService.php new file mode 100644 index 0000000..b738baa --- /dev/null +++ b/backend/app/Services/DashboardService.php @@ -0,0 +1,419 @@ + Lead::count(), + 'new_leads_today' => Lead::whereDate('created_at', $today)->count(), + 'calls_today' => Call::whereDate('created_at', $today)->count(), + 'successful_calls_today' => Call::whereDate('created_at', $today) + ->whereIn('result', $this->successfulResultNames()) + ->count(), + 'conversions_this_month' => Lead::where('final_result', 'موفق') + ->whereMonth('updated_at', $today->month) + ->whereYear('updated_at', $today->year) + ->count(), + 'assigned_leads' => Lead::where('is_unassigned', false)->count(), + 'unassigned_leads' => Lead::where('is_unassigned', true)->count(), + 'leads_contacted_today' => Lead::whereDate('last_call_at', $today)->count(), + 'total_calls_today' => Call::whereDate('created_at', $today)->count(), + 'answered_calls' => Call::whereDate('created_at', $today) + ->whereIn('result', $this->successfulResultNames()) + ->count(), + 'no_answer_calls' => Call::whereDate('created_at', $today) + ->whereIn('result', ['پاسخ نداد', 'اشغال بود', 'خاموش بود']) + ->count(), + 'average_call_duration' => Call::whereDate('created_at', $today)->avg('duration') ?? 0, + 'won_sales' => Lead::where('final_result', 'موفق')->count(), + 'lost_sales' => Lead::where('final_result', 'ناموفق')->count(), + '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)) + ->count(), + 'overdue_leads' => Lead::whereNotNull('next_follow_up_at')->where('next_follow_up_at', '<', $now)->whereNull('final_result')->count(), + 'pipeline_data' => $this->pipelineSummary(), + 'funnel_conversion_chart' => $this->funnelConversion(), + 'call_trend_chart' => $this->callTrend(), + 'source_performance_chart' => $this->sourcePerformance(), + 'campaign_performance' => $this->campaignPerformance(), + 'team_performance' => $this->teamPerformance(), + 'top_agents' => $this->agentPerformance(null, 5), + 'low_performance_alerts' => $this->lowPerformanceAlerts(), + 'recent_imports' => ImportBatch::with('user:id,name') + ->latest() + ->limit(5) + ->get(['id', 'user_id', 'filename', 'total_rows', 'imported_rows', 'failed_rows', 'skipped_rows', 'status', 'created_at']), + 'voip_status' => $this->voipStatus(), + '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') + ->get() + ->map(fn (LeadStatus $status) => [ + 'status' => $status->name, + 'count' => $status->leads_count, + 'color' => $status->color, + ]) + ->values(), + 'recent_activities' => ActivityLog::with('user:id,name') + ->latest() + ->limit(10) + ->get(), + ]; + } + + public function supervisor(?int $teamId = null): array + { + $teamId = $teamId ?? auth()->user()->teams->first()?->id; + $agentIds = $teamId ? Team::find($teamId)?->members->pluck('id')->toArray() : []; + + $today = Carbon::today(); + $now = Carbon::now(); + + return [ + 'total_leads' => Lead::whereIn('assigned_to', $agentIds)->count(), + 'new_leads_today' => Lead::whereIn('assigned_to', $agentIds)->whereDate('created_at', $today)->count(), + 'calls_today' => Call::whereIn('user_id', $agentIds)->whereDate('created_at', $today)->count(), + 'conversions_this_month' => Lead::whereIn('assigned_to', $agentIds)->where('final_result', 'موفق') + ->whereMonth('updated_at', $today->month)->whereYear('updated_at', $today->year)->count(), + 'total_team_leads' => Lead::whereIn('assigned_to', $agentIds)->count(), + 'team_calls_today' => Call::whereIn('user_id', $agentIds)->whereDate('created_at', $today)->count(), + 'team_successful_calls_today' => Call::whereIn('user_id', $agentIds)->whereDate('created_at', $today)->whereIn('result', $this->successfulResultNames())->count(), + 'team_won_sales' => Lead::whereIn('assigned_to', $agentIds) + ->where('final_result', 'موفق') + ->count(), + 'team_conversion_rate' => $this->calculateTeamConversionRate($agentIds), + '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(), + '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), + 'calls_needing_review' => Call::whereIn('user_id', $agentIds) + ->whereNotNull('recording_url') + ->whereDoesntHave('qualityReview') + ->latest() + ->limit(10) + ->get(['id', 'lead_id', 'user_id', 'result', 'duration', 'created_at']), + 'quality_review_summary' => $this->qualitySummary($agentIds), + '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)) + ->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), + ]; + } + + public function agent(?int $userId = null): array + { + $userId = $userId ?? auth()->id(); + $today = Carbon::today(); + + 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(), + '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(), + 'won_sales' => Lead::where('assigned_to', $userId) + ->where('final_result', 'موفق') + ->count(), + 'conversion_rate' => $this->calculateAgentConversionRate($userId), + 'daily_call_target' => $this->intSetting('daily_call_target', 40), + 'successful_call_target' => $this->intSetting('successful_call_target', 15), + 'follow_up_target' => $this->intSetting('follow_up_target', 20), + 'progress' => [ + 'calls' => $this->progress(Call::where('user_id', $userId)->whereDate('created_at', $today)->count(), $this->intSetting('daily_call_target', 40)), + 'successful_calls' => $this->progress(Call::where('user_id', $userId)->whereDate('created_at', $today)->whereIn('result', $this->successfulResultNames())->count(), $this->intSetting('successful_call_target', 15)), + 'follow_ups' => $this->progress(FollowUp::where('user_id', $userId)->whereDate('scheduled_at', $today)->count(), $this->intSetting('follow_up_target', 20)), + ], + 'next_call' => Lead::where('assigned_to', $userId) + ->whereNull('final_result') + ->orderByRaw('CASE WHEN next_follow_up_at IS NOT NULL AND next_follow_up_at <= ? THEN 0 ELSE 1 END', [Carbon::now()]) + ->orderByDesc('priority') + ->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)) + ->latest() + ->limit(5) + ->get(['id', 'company', 'first_name', 'last_name', 'interest_level', 'priority', 'next_follow_up_at']), + 'urgent_tasks' => FollowUp::with('lead:id,company,first_name,last_name') + ->where('user_id', $userId) + ->where('status', 'pending') + ->where('scheduled_at', '<=', Carbon::now()->addDay()) + ->orderBy('scheduled_at') + ->limit(8) + ->get(['id', 'lead_id', 'scheduled_at', 'notes', 'status']), + 'suggested_next_action' => $this->suggestedNextAction($userId), + ]; + } + + private function calculateConversionRate(): float + { + $total = Lead::count(); + if ($total === 0) return 0; + $won = Lead::where('final_result', 'موفق')->count(); + return round(($won / $total) * 100, 1); + } + + private function pipelineSummary() + { + return PipelineStage::query() + ->where('is_active', true) + ->orderBy('sort_order') + ->get() + ->groupBy('name') + ->map(function ($stages, string $name) { + $ids = $stages->pluck('id'); + $first = $stages->first(); + return [ + 'stage' => $name, + 'count' => Lead::whereIn('pipeline_stage_id', $ids)->count(), + 'color' => $first->color, + ]; + }) + ->values(); + } + + private function calculateTeamConversionRate(array $agentIds): float + { + $total = Lead::whereIn('assigned_to', $agentIds)->count(); + 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; + $won = Lead::where('assigned_to', $userId) + ->where('final_result', 'موفق') + ->count(); + return round(($won / $total) * 100, 1); + } + + private function successfulResultNames(): array + { + return CallResult::where('is_positive', true)->pluck('name')->all(); + } + + private function intSetting(string $key, int $fallback): int + { + return (int) (Setting::where('key', $key)->value('value') ?: $fallback); + } + + private function progress(int $value, int $target): array + { + return ['value' => $value, 'target' => $target, 'percent' => $target > 0 ? min(100, round(($value / $target) * 100, 1)) : 0]; + } + + private function callTrend(?array $agentIds = null): array + { + 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); + return [ + 'date' => $date->toDateString(), + 'calls' => (clone $query)->count(), + 'successful' => (clone $query)->whereIn('result', $this->successfulResultNames())->count(), + ]; + })->values()->all(); + } + + private function sourcePerformance(?array $agentIds = null): array + { + $query = Lead::query(); + 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) => [ + 'source' => $row->source, + 'total' => (int) $row->total, + 'won' => (int) $row->won, + 'conversion_rate' => $row->total > 0 ? round(($row->won / $row->total) * 100, 1) : 0, + ])->all(); + } + + private function campaignPerformance(): array + { + return Campaign::withCount('leads') + ->latest() + ->limit(8) + ->get() + ->map(fn(Campaign $campaign) => [ + 'id' => $campaign->id, + 'name' => $campaign->name, + 'target' => $campaign->target, + 'leads' => $campaign->leads_count, + 'won' => Lead::where('campaign_id', $campaign->id)->where('final_result', 'موفق')->count(), + ])->values()->all(); + } + + private function teamPerformance(): array + { + return Team::with('members:id,name')->get()->map(function (Team $team) { + $agentIds = $team->members->pluck('id')->all(); + return [ + 'id' => $team->id, + 'name' => $team->name, + 'agents' => count($agentIds), + 'leads' => Lead::whereIn('assigned_to', $agentIds)->count(), + 'calls_today' => Call::whereIn('user_id', $agentIds)->whereDate('created_at', Carbon::today())->count(), + 'conversion_rate' => $this->calculateTeamConversionRate($agentIds), + ]; + })->values()->all(); + } + + 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(); + $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, + 'leads' => Lead::where('assigned_to', $agent->id)->count(), + 'calls' => $todayCalls, + 'successful_calls' => $successful, + 'conversions' => Lead::where('assigned_to', $agent->id)->where('final_result', 'موفق')->count(), + 'call_target_percent' => $this->progress($todayCalls, $this->intSetting('daily_call_target', 40))['percent'], + ]; + })->sortByDesc('calls')->values(); + + return $limit ? $rows->take($limit)->values()->all() : $rows->all(); + } + + 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' => 'کمتر از ۵۰٪ هدف تماس امروز انجام شده است.']) + ->values() + ->all(); + } + + private function stuckLeadsByStage(array $agentIds): array + { + $threshold = Carbon::now()->subDays(7); + 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(); + } + + 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), + 'low_score_count' => (clone $query)->where('overall_score', '<', 60)->count(), + ]; + } + + private function agentsBehindTarget(array $agentIds): array + { + return collect($this->agentPerformance($agentIds)) + ->filter(fn($row) => $row['call_target_percent'] < 80) + ->values() + ->all(); + } + + 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()], + ['label' => 'تماس نیازمند بازبینی', 'count' => Call::whereIn('user_id', $agentIds)->whereNotNull('recording_url')->whereDoesntHave('qualityReview')->count()], + ]; + } + + private function voipStatus(): array + { + $provider = Setting::where('key', 'voip_provider')->value('value') ?: 'none'; + $configured = match ($provider) { + 'ami' => (bool) Setting::where('key', 'voip_ami_host')->value('value') + && (bool) Setting::where('key', 'voip_ami_port')->value('value') + && (bool) Setting::where('key', 'voip_ami_username')->value('value') + && (bool) Setting::where('key', 'voip_ami_secret')->value('value'), + 'api' => (bool) Setting::where('key', 'voip_api_base_url')->value('value') + && (bool) Setting::where('key', 'voip_api_token')->value('value'), + 'socket' => (bool) Setting::where('key', 'voip_socket_host')->value('value') + && (bool) Setting::where('key', 'voip_socket_port')->value('value'), + 'mock' => true, + default => false, + }; + return [ + 'provider' => $provider, + 'configured' => $configured, + 'message' => $configured ? 'تنظیمات اصلی تلفن اینترنتی ثبت شده است.' : 'تلفن اینترنتی کامل پیکربندی نشده است.', + ]; + } + + private function funnelConversion(): array + { + $total = max(Lead::count(), 1); + return collect($this->pipelineSummary())->map(fn($row) => [ + 'stage' => $row['stage'], + 'count' => $row['count'], + 'percentage' => round(($row['count'] / $total) * 100, 1), + 'color' => $row['color'], + ])->all(); + } + + private function suggestedNextAction(int $userId): string + { + if (FollowUp::where('user_id', $userId)->where('status', 'pending')->where('scheduled_at', '<', Carbon::now())->exists()) { + return 'ابتدا پیگیری‌های عقب‌افتاده را انجام دهید.'; + } + if (Lead::where('assigned_to', $userId)->whereNull('last_call_at')->exists()) { + return 'با لیدهای بدون تماس شروع کنید.'; + } + return 'تماس بعدی پیشنهادی را از صف تماس بردارید.'; + } +} diff --git a/backend/app/Services/DuplicateService.php b/backend/app/Services/DuplicateService.php new file mode 100644 index 0000000..3e3d6f1 --- /dev/null +++ b/backend/app/Services/DuplicateService.php @@ -0,0 +1,110 @@ +'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; + $value = strtolower(trim($website)); + $value = preg_replace('#^https?://#', '', $value); + $value = preg_replace('#^www\.#', '', $value); + return rtrim($value, '/'); + } + + public static function normalizeName(?string $name): ?string + { + return $name ? Str::of($name)->lower()->squish()->toString() : null; + } + + public function companySuggestions(array $payload, ?int $excludeId = null): array + { + $name = self::normalizeName($payload['name'] ?? null); + $website = self::normalizeWebsite($payload['website'] ?? null); + $email = strtolower((string) ($payload['email'] ?? '')); + $phone = self::normalizePhone($payload['phone'] ?? null); + + $query = Company::query()->with('owner:id,name')->limit(10); + 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 ($phone) { + $q->orWhereHas('contacts.phones', fn($phoneQuery) => $phoneQuery->where('phone', 'like', "%{$phone}%")); + } + }); + + return $query->get()->map(fn(Company $company) => [ + 'id' => $company->id, + 'type' => 'company', + 'title' => $company->name, + 'reason' => 'شباهت در نام شرکت، وب‌سایت، ایمیل یا شماره تماس', + ])->values()->all(); + } + + public function leadSuggestions(array $payload, ?int $excludeId = 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); + + $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]); + }); + + return $query->get()->map(fn(Lead $lead) => [ + 'id' => $lead->id, + 'type' => 'lead', + 'title' => $lead->company ?: $lead->full_name, + 'reason' => 'شباهت در تلفن، ایمیل یا نام شرکت', + ])->values()->all(); + } + + public function mergeCompanies(Company $source, Company $target, int $userId): Company + { + 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]); + + MergeHistory::create([ + 'entity_type' => 'company', + 'source_id' => $source->id, + 'target_id' => $target->id, + 'merged_fields' => $source->only(['name', 'website', 'city', 'industry']), + 'merged_by' => $userId, + ]); + + $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/ImportService.php b/backend/app/Services/ImportService.php new file mode 100644 index 0000000..20c4478 --- /dev/null +++ b/backend/app/Services/ImportService.php @@ -0,0 +1,194 @@ + $headers, + 'total_rows' => count($dataRows), + 'preview' => array_slice($dataRows, 0, 5), + ]; + } + + public function confirm(int $batchId, array $columnMapping, ?int $campaignId = null, ?array $agentIds = null, ?int $assignedById = null): ImportBatch + { + $batch = ImportBatch::with('rows')->findOrFail($batchId); + $defaultStatus = LeadStatus::where('is_default', true)->first(); + $defaultStage = PipelineStage::where('is_default', true)->first(); + $assignedStage = PipelineStage::where('slug', 'waiting_call')->first(); + $duplicatePolicy = Setting::where('key', 'duplicate_phone_policy')->value('value') ?? 'warn'; + + DB::transaction(function () use ($batch, $columnMapping, $campaignId, $agentIds, $assignedById, $defaultStatus, $defaultStage, $assignedStage, $duplicatePolicy) { + $agentIndex = 0; + $agentCount = count($agentIds ?? []); + + foreach ($batch->rows as $row) { + if ($row->status !== 'pending') continue; + + $data = $row->original_data; + $leadData = $this->mapColumns($data, $columnMapping); + + if (!$leadData['company']) { + $row->update(['status' => 'failed', 'error' => 'نام کسب‌وکار الزامی است']); + $batch->increment('failed_rows'); + continue; + } + + if (!$leadData['phone']) { + $row->update(['status' => 'failed', 'error' => 'شماره تلفن الزامی است']); + $batch->increment('failed_rows'); + continue; + } + + $existing = Lead::where('phone', $leadData['phone'])->first(); + if ($existing) { + if ($duplicatePolicy === 'allow') { + // Continue and create a new lead below. + } 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; + } + } + + $leadData['first_name'] = $leadData['first_name'] ?: $leadData['company']; + $leadData['last_name'] = $leadData['last_name'] ?: 'رابط'; + $leadData['lead_status_id'] = $defaultStatus?->id; + $leadData['pipeline_stage_id'] = $defaultStage?->id; + $leadData['campaign_id'] = $campaignId; + $leadData['priority'] = isset($leadData['priority']) && $leadData['priority'] !== '' ? (int) $leadData['priority'] : 0; + $leadData['lead_score'] = isset($leadData['lead_score']) && $leadData['lead_score'] !== '' ? (int) $leadData['lead_score'] : 0; + $leadData['interest_level'] = $leadData['interest_level'] ?: 'cold'; + + if ($agentCount > 0) { + $leadData['assigned_to'] = $agentIds[$agentIndex % $agentCount]; + $leadData['assigned_by'] = $assignedById; + $leadData['is_unassigned'] = false; + $leadData['pipeline_stage_id'] = $assignedStage?->id ?? $leadData['pipeline_stage_id']; + $agentIndex++; + } + + $lead = Lead::create($leadData); + $this->createPrimaryContact($lead, $assignedById); + + if ($leadData['assigned_to'] ?? null) { + \App\Models\LeadAssignment::create([ + 'lead_id' => $lead->id, + 'user_id' => $leadData['assigned_to'], + 'assigned_by' => $assignedById, + 'method' => 'import', + ]); + NotificationService::notifyAssignment((int) $leadData['assigned_to'], $lead->full_name ?: ($lead->company ?? "لید {$lead->id}")); + } + + $row->update(['status' => 'imported', 'lead_id' => $lead->id]); + $batch->increment('imported_rows'); + } + + $batch->update([ + 'campaign_id' => $campaignId, + 'column_mapping' => $columnMapping, + 'status' => 'completed', + ]); + }); + + ActivityLogger::log('import_completed', "Import batch {$batchId} completed", $batch); + + return $batch->fresh(); + } + + public function rollback(int $batchId): void + { + $batch = ImportBatch::with('rows')->findOrFail($batchId); + + DB::transaction(function () use ($batch) { + foreach ($batch->rows as $row) { + if ($row->status === 'imported' && $row->lead_id) { + $row->lead?->delete(); + $row->update(['status' => 'rolled_back']); + } + } + $batch->update(['status' => 'rolled_back', 'can_rollback' => false]); + }); + + ActivityLogger::log('import_rolled_back', "Import batch {$batchId} rolled back", $batch); + } + + private function mapColumns(array $data, array $mapping): array + { + $leadData = [ + 'company' => null, + 'first_name' => null, + 'last_name' => null, + 'phone' => null, + 'phone_secondary' => null, + 'email' => null, + 'city' => null, + 'province' => null, + 'source' => null, + 'product_interest' => null, + 'priority' => null, + 'lead_score' => null, + 'interest_level' => null, + 'notes' => null, + 'tags' => null, + ]; + foreach ($mapping as $field => $columnIndex) { + if (isset($data[$columnIndex])) { + $leadData[$field] = is_string($data[$columnIndex]) ? trim($data[$columnIndex]) : $data[$columnIndex]; + } + } + return $leadData; + } + + private function createPrimaryContact(Lead $lead, ?int $createdById): void + { + $contact = Contact::create([ + 'lead_id' => $lead->id, + 'name' => trim(($lead->first_name ?? '') . ' ' . ($lead->last_name ?? '')) ?: ($lead->company ?? 'مخاطب اولیه'), + 'role' => 'رابط', + 'description' => 'مخاطب اولیه import', + 'status' => 'active', + 'is_primary' => true, + 'primary_reason' => 'مخاطب اولیه import', + 'created_by' => $createdById, + ]); + + foreach (array_filter([$lead->phone, $lead->phone_secondary]) as $phone) { + ContactPhone::create([ + 'contact_id' => $contact->id, + 'phone' => $phone, + 'type' => 'mobile', + 'status' => 'active', + ]); + } + } +} diff --git a/backend/app/Services/LeadService.php b/backend/app/Services/LeadService.php new file mode 100644 index 0000000..099b17c --- /dev/null +++ b/backend/app/Services/LeadService.php @@ -0,0 +1,216 @@ +getFilteredLeadQuery($filters); + + $perPage = min(max((int) ($filters['per_page'] ?? 15), 1), 100); + return $query->paginate($perPage); + } + + public function getFilteredLeadQuery(array $filters): Builder + { + $filters = $this->normalizeFilters($filters); + + $query = Lead::with([ + 'leadStatus', + 'pipelineStage', + 'campaign', + 'assignedAgent', + 'team', + 'contacts.phones.lastCaller:id,name', + ]); + + 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}%"); + }); + } + + if (isset($filters['status_id'])) { + $query->where('lead_status_id', $filters['status_id']); + } + + if (isset($filters['pipeline_stage_id'])) { + $query->where('pipeline_stage_id', $filters['pipeline_stage_id']); + } + + 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); + }); + }); + } elseif (isset($filters['assigned_to'])) { + $query->where('assigned_to', $filters['assigned_to']); + } + + if (isset($filters['team_id'])) { + $query->where('team_id', $filters['team_id']); + } + + if (isset($filters['campaign_id'])) { + $query->where('campaign_id', $filters['campaign_id']); + } + + if (isset($filters['call_result'])) { + $query->where('last_call_result', $filters['call_result']); + } + + if (isset($filters['interest_level'])) { + $query->where('interest_level', $filters['interest_level']); + } + + if (isset($filters['overdue_follow_up']) && filter_var($filters['overdue_follow_up'], FILTER_VALIDATE_BOOLEAN)) { + $query->whereNotNull('next_follow_up_at')->where('next_follow_up_at', '<', now()); + } + + if (isset($filters['source'])) { + $query->where('source', $filters['source']); + } + + if (isset($filters['priority'])) { + $query->where('priority', $filters['priority']); + } + + if (isset($filters['is_unassigned'])) { + $query->where('is_unassigned', filter_var($filters['is_unassigned'], FILTER_VALIDATE_BOOLEAN)); + } + + if (isset($filters['date_from'])) { + $query->whereDate('created_at', '>=', $filters['date_from']); + } + + if (isset($filters['date_to'])) { + $query->whereDate('created_at', '<=', $filters['date_to']); + } + + if (isset($filters['has_follow_up'])) { + if (filter_var($filters['has_follow_up'], FILTER_VALIDATE_BOOLEAN)) { + $query->whereNotNull('next_follow_up_at'); + } else { + $query->whereNull('next_follow_up_at'); + } + } + + $allowedSortFields = [ + 'created_at', 'updated_at', 'first_name', 'last_name', 'company', + 'priority', 'lead_score', 'last_call_at', 'next_follow_up_at', + ]; + $sortField = in_array($filters['sort_field'] ?? null, $allowedSortFields, true) + ? $filters['sort_field'] + : 'created_at'; + $sortDirection = strtolower((string) ($filters['sort_direction'] ?? 'desc')) === 'asc' ? 'asc' : 'desc'; + $query->orderBy($sortField, $sortDirection); + + return $query; + } + + public function getGroupedFunnel(array $filters, int $perStage = 25): array + { + $perStage = min(max($perStage, 1), 100); + $baseQuery = $this->getFilteredLeadQuery($filters); + + $counts = (clone $baseQuery) + ->reorder() + ->selectRaw('pipeline_stage_id, count(*) as aggregate') + ->groupBy('pipeline_stage_id') + ->pluck('aggregate', 'pipeline_stage_id'); + + $stages = PipelineStage::orderBy('sort_order')->get(); + + return [ + 'total' => (int) $counts->sum(), + 'stages' => $stages->map(function (PipelineStage $stage) use ($baseQuery, $counts, $perStage) { + $page = (clone $baseQuery) + ->where('pipeline_stage_id', $stage->id) + ->paginate($perStage, ['*'], "stage_{$stage->id}_page"); + + return [ + 'stage' => $stage, + 'count' => (int) ($counts[$stage->id] ?? 0), + 'leads' => $page->items(), + 'pagination' => [ + 'current_page' => $page->currentPage(), + 'last_page' => $page->lastPage(), + 'per_page' => $page->perPage(), + 'total' => $page->total(), + ], + ]; + })->values(), + ]; + } + + private function normalizeFilters(array $filters): array + { + $aliases = [ + 'status' => 'status_id', + 'stage' => 'pipeline_stage_id', + 'agent' => 'assigned_to', + 'sort' => 'sort_field', + 'direction' => 'sort_direction', + ]; + + foreach ($aliases as $alias => $canonical) { + if (!isset($filters[$canonical]) && isset($filters[$alias])) { + $filters[$canonical] = $filters[$alias]; + } + } + + return array_filter($filters, fn($value) => $value !== '' && $value !== null); + } + + public function assignLead(Lead $lead, int $agentId, int $assignedById, string $method = 'manual'): Lead + { + $lead->update([ + 'assigned_to' => $agentId, + 'assigned_by' => $assignedById, + 'is_unassigned' => false, + 'pipeline_stage_id' => PipelineStage::where('slug', 'waiting_call')->value('id') ?? $lead->pipeline_stage_id, + ]); + + \App\Models\LeadAssignment::create([ + 'lead_id' => $lead->id, + 'user_id' => $agentId, + 'assigned_by' => $assignedById, + 'method' => $method, + ]); + + NotificationService::notifyAssignment($agentId, $lead->full_name ?: ($lead->company ?? "لید {$lead->id}")); + ActivityLogger::log('lead_assigned', "Lead {$lead->id} assigned to user {$agentId}", $lead); + + return $lead->fresh(); + } + + public function reassignLead(Lead $lead, int $newAgentId, int $assignedById): Lead + { + return $this->assignLead($lead, $newAgentId, $assignedById, 'reassign'); + } + + public function roundRobinAssign($leads, array $agentIds, int $assignedById): array + { + $results = []; + $index = 0; + foreach ($leads as $lead) { + $agentId = $agentIds[$index % count($agentIds)]; + $results[] = $this->assignLead($lead, $agentId, $assignedById, 'round_robin'); + $index++; + } + return $results; + } +} diff --git a/backend/app/Services/NotificationService.php b/backend/app/Services/NotificationService.php new file mode 100644 index 0000000..2174e39 --- /dev/null +++ b/backend/app/Services/NotificationService.php @@ -0,0 +1,79 @@ + $userId, + 'title' => $title, + 'message' => $message, + 'type' => $type, + 'data' => $data, + 'is_read' => false, + ]); + } + + public static function sendOnce(int $userId, string $title, string $message, string $type = 'info', ?array $data = null): ?NotificationModel + { + $exists = NotificationModel::where('user_id', $userId) + ->where('type', $type) + ->where('data->follow_up_id', $data['follow_up_id'] ?? null) + ->whereDate('created_at', now()->toDateString()) + ->exists(); + + return $exists ? null : self::send($userId, $title, $message, $type, $data); + } + + public static function notifyAssignment(int $userId, string $leadName): void + { + self::send($userId, 'لید جدید', "لید {$leadName} به شما اختصاص داده شد", 'assignment'); + } + + public static function notifyFollowUpReminder(int $userId, string $leadName): void + { + self::send($userId, 'یادآوری پیگیری', "موعد پیگیری لید {$leadName} رسیده است", 'follow_up'); + } + + public static function notifyFeedback(int $userId, string $agentName): void + { + self::send($userId, 'بازخورد جدید', "بازخورد جدیدی برای شما ثبت شده است", 'feedback'); + } + + public static function notifyReassignment(int $userId, string $leadName): void + { + self::send($userId, 'تغییر وضعیت لید', "لید {$leadName} دوباره به شما اختصاص داده شد", 'reassignment'); + } + + public static function notifyToAllAgents(string $title, string $message, string $type = 'system'): void + { + $agents = User::role('agent')->get(); + foreach ($agents as $agent) { + self::send($agent->id, $title, $message, $type); + } + } + + private static function enabled(string $type): bool + { + if (Setting::where('key', 'in_app_notifications_enabled')->value('value') === 'false') { + return false; + } + + return match ($type) { + 'assignment', 'reassignment' => Setting::where('key', 'assigned_lead_notification_enabled')->value('value') !== 'false', + 'follow_up' => Setting::where('key', 'follow_up_notification_enabled')->value('value') !== 'false', + 'overdue_follow_up' => Setting::where('key', 'overdue_notification_enabled')->value('value') !== 'false', + default => true, + }; + } +} diff --git a/backend/app/Services/ReportService.php b/backend/app/Services/ReportService.php new file mode 100644 index 0000000..a4b8274 --- /dev/null +++ b/backend/app/Services/ReportService.php @@ -0,0 +1,416 @@ +whereDate('created_at', '>=', $dateFrom); + $leadsQuery->whereDate('created_at', '>=', $dateFrom); + } + if ($dateTo) { + $callsQuery->whereDate('created_at', '<=', $dateTo); + $leadsQuery->whereDate('created_at', '<=', $dateTo); + } + + $totalCalls = (clone $callsQuery)->count(); + $answeredCalls = (clone $callsQuery)->whereIn('result', $this->successfulResultNames())->count(); + $totalLeads = (clone $leadsQuery)->count(); + $wonLeads = (clone $leadsQuery)->where('final_result', 'موفق')->count(); + + return [ + 'agent' => $agent->only(['id', 'name', 'email']), + 'total_calls' => $totalCalls, + 'answered_calls' => $answeredCalls, + 'no_answer_calls' => $totalCalls - $answeredCalls, + 'answer_rate' => $totalCalls > 0 ? round(($answeredCalls / $totalCalls) * 100, 1) : 0, + 'total_leads_assigned' => $totalLeads, + 'won_sales' => $wonLeads, + 'conversion_rate' => $totalLeads > 0 ? round(($wonLeads / $totalLeads) * 100, 1) : 0, + 'avg_call_duration' => (clone $callsQuery)->avg('duration') ?? 0, + ]; + } + + public function teamPerformance(int $teamId, ?string $dateFrom = null, ?string $dateTo = null): array + { + $team = Team::with('members')->findOrFail($teamId); + $agentIds = $team->members->pluck('id')->toArray(); + + $totalCalls = Call::whereIn('user_id', $agentIds); + $totalLeads = Lead::whereIn('assigned_to', $agentIds); + + if ($dateFrom) { + $totalCalls->whereDate('created_at', '>=', $dateFrom); + $totalLeads->whereDate('created_at', '>=', $dateFrom); + } + if ($dateTo) { + $totalCalls->whereDate('created_at', '<=', $dateTo); + $totalLeads->whereDate('created_at', '<=', $dateTo); + } + + $wonLeads = (clone $totalLeads)->where('final_result', 'موفق')->count(); + + $agentPerformance = []; + foreach ($team->members as $member) { + $agentPerformance[] = $this->agentPerformance($member->id, $dateFrom, $dateTo); + } + + return [ + 'team' => $team->only(['id', 'name']), + 'member_count' => count($agentIds), + 'total_calls' => $totalCalls->count(), + 'total_leads' => $totalLeads->count(), + 'won_sales' => $wonLeads, + 'conversion_rate' => $totalLeads->count() > 0 ? round(($wonLeads / $totalLeads->count()) * 100, 1) : 0, + 'agents' => $agentPerformance, + ]; + } + + public function campaignReport(int $campaignId, ?string $dateFrom = null, ?string $dateTo = null): array + { + $campaign = \App\Models\Campaign::with('assignedAgents')->findOrFail($campaignId); + $leads = Lead::where('campaign_id', $campaignId); + + if ($dateFrom) { + $leads->whereDate('created_at', '>=', $dateFrom); + } + if ($dateTo) { + $leads->whereDate('created_at', '<=', $dateTo); + } + + $leadIds = (clone $leads)->pluck('id'); + $calls = Call::whereIn('lead_id', $leadIds); + + if ($dateFrom) { + $calls->whereDate('created_at', '>=', $dateFrom); + } + if ($dateTo) { + $calls->whereDate('created_at', '<=', $dateTo); + } + + $wonLeads = (clone $leads)->where('final_result', 'موفق')->count(); + $totalLeads = (clone $leads)->count(); + + return [ + 'campaign' => $campaign->only(['id', 'name', 'target', 'start_date', 'end_date', 'status']), + 'total_leads' => $totalLeads, + 'assigned_leads' => (clone $leads)->where('is_unassigned', false)->count(), + 'total_calls' => (clone $calls)->count(), + 'won_sales' => $wonLeads, + 'target_progress' => $campaign->target > 0 ? round(($wonLeads / $campaign->target) * 100, 1) : 0, + 'conversion_rate' => $totalLeads > 0 ? round(($wonLeads / $totalLeads) * 100, 1) : 0, + ]; + } + + 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'); + + $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(); + $byStage = []; + + foreach ($stages as $stage) { + $count = (clone $leadsQuery)->where('pipeline_stage_id', $stage->id)->count(); + $byStage[] = [ + 'name' => $stage->name, + 'color' => $stage->color, + 'count' => $count, + 'percentage' => $total > 0 ? round(($count / $total) * 100, 1) : 0, + ]; + } + + return [ + 'total_leads' => $total, + 'by_stage' => $byStage, + 'conversion_rate' => $total > 0 ? round(($won / $total) * 100, 1) : 0, + 'won_leads' => $won, + 'lost_leads' => $lost, + 'follow_ups_due_today' => $this->filterFollowUpsByAgent( + FollowUp::whereDate('scheduled_at', $today)->where('status', 'pending'), + $agentId, + $agentIds + )->count(), + 'overdue_follow_ups' => $this->filterFollowUpsByAgent( + FollowUp::where('scheduled_at', '<', $now)->where('status', 'pending'), + $agentId, + $agentIds + )->count(), + 'lost_reason_analysis' => (clone $leadsQuery) + ->where('final_result', 'ناموفق') + ->whereNotNull('lost_reason') + ->selectRaw('lost_reason as name, count(*) as count') + ->groupBy('lost_reason') + ->orderByDesc('count') + ->get(), + 'proposal_sent_not_closed' => (clone $leadsQuery) + ->whereHas('pipelineStage', fn($q) => $q->where('slug', 'proposal_sent')) + ->whereNull('final_result') + ->count(), + ]; + } + + 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'); + + $total = $query->count(); + $results = \App\Models\CallResult::all(); + $byResult = []; + + foreach ($results as $result) { + $count = (clone $query)->where('result', $result->name)->count(); + $byResult[] = [ + 'name' => $result->name, + 'color' => $result->color, + 'count' => $count, + 'percentage' => $total > 0 ? round(($count / $total) * 100, 1) : 0, + ]; + } + + return [ + 'total_calls' => $total, + 'avg_duration' => $query->avg('duration') ?? 0, + 'by_result' => $byResult, + ]; + } + + 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'); + + return [ + 'total' => $query->count(), + 'pending' => (clone $query)->where('status', 'pending')->count(), + '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())) + ->latest('scheduled_at') + ->limit(50) + ->get(), + ]; + } + + public function lostReasonReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array + { + $query = $this->scopedLeads($dateFrom, $dateTo, $agentId, $agentIds) + ->where('final_result', 'ناموفق') + ->whereNotNull('lost_reason'); + + $total = (clone $query)->count(); + + return [ + 'total_lost' => $total, + 'by_reason' => (clone $query)->selectRaw('lost_reason as name, count(*) as count') + ->groupBy('lost_reason') + ->orderByDesc('count') + ->get() + ->map(fn($row) => [ + 'name' => $row->name, + 'count' => (int) $row->count, + 'percentage' => $total > 0 ? round(($row->count / $total) * 100, 1) : 0, + ])->values(), + ]; + } + + public function sourcePerformanceReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array + { + $query = $this->scopedLeads($dateFrom, $dateTo, $agentId, $agentIds); + + return [ + 'sources' => (clone $query) + ->selectRaw("COALESCE(source, 'نامشخص') as source, count(*) as total, sum(case when final_result = 'موفق' then 1 else 0 end) as won, sum(case when final_result = 'ناموفق' then 1 else 0 end) as lost") + ->groupBy('source') + ->orderByDesc('total') + ->get() + ->map(fn($row) => [ + 'source' => $row->source, + 'total' => (int) $row->total, + 'won' => (int) $row->won, + 'lost' => (int) $row->lost, + 'conversion_rate' => $row->total > 0 ? round(($row->won / $row->total) * 100, 1) : 0, + ])->values(), + ]; + } + + public function duplicateLeadsReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array + { + $query = $this->scopedLeads($dateFrom, $dateTo, $agentId, $agentIds); + $duplicatePhones = (clone $query) + ->whereNotNull('phone') + ->selectRaw('phone, count(*) as count') + ->groupBy('phone') + ->havingRaw('count(*) > 1') + ->orderByDesc('count') + ->limit(50) + ->get(); + $duplicateEmails = (clone $query) + ->whereNotNull('email') + ->selectRaw('email, count(*) as count') + ->groupBy('email') + ->havingRaw('count(*) > 1') + ->orderByDesc('count') + ->limit(50) + ->get(); + $duplicateCompanies = (clone $query) + ->whereNotNull('company') + ->selectRaw('company, count(*) as count') + ->groupBy('company') + ->havingRaw('count(*) > 1') + ->orderByDesc('count') + ->limit(50) + ->get(); + + return [ + 'phone_duplicates' => $duplicatePhones, + 'email_duplicates' => $duplicateEmails, + 'company_duplicates' => $duplicateCompanies, + 'merge_history_count' => MergeHistory::where('entity_type', 'like', '%Lead%')->count(), + ]; + } + + 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); + + $batches = $query->latest()->limit(50)->get(); + $totalRows = max($batches->sum('total_rows'), 1); + + return [ + 'total_batches' => $batches->count(), + 'total_rows' => $batches->sum('total_rows'), + 'imported_rows' => $batches->sum('imported_rows'), + 'failed_rows' => $batches->sum('failed_rows'), + 'skipped_rows' => $batches->sum('skipped_rows'), + 'success_rate' => round(($batches->sum('imported_rows') / $totalRows) * 100, 1), + 'batches' => $batches, + ]; + } + + 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'); + + return [ + 'reviewed_calls' => (clone $query)->count(), + 'average_score' => round((float) ((clone $query)->avg('overall_score') ?? 0), 1), + 'low_score_count' => (clone $query)->where('overall_score', '<', 60)->count(), + 'by_agent' => (clone $query)->selectRaw('agent_id, count(*) as reviews, avg(overall_score) as average_score') + ->groupBy('agent_id') + ->with('agent:id,name') + ->get(), + 'recent_reviews' => (clone $query)->latest()->limit(20)->get(), + ]; + } + + 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'); + + $total = (clone $query)->count(); + if ($total < 20) { + return [ + 'enough_data' => false, + 'message' => 'برای تحلیل بهترین زمان تماس، حداقل ۲۰ تماس ثبت‌شده لازم است.', + 'total_calls' => $total, + 'by_hour' => [], + ]; + } + + return [ + 'enough_data' => true, + 'total_calls' => $total, + 'by_hour' => (clone $query)->get(['created_at', 'result']) + ->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(), + 'successful' => $successful, + 'success_rate' => $calls->count() > 0 ? round(($successful / $calls->count()) * 100, 1) : 0, + ]; + })->values(), + ]; + } + + private function filterFollowUpsByAgent($query, ?int $agentId, ?array $agentIds = null) + { + 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 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'); + + return $query; + } + + private function successfulResultNames(): array + { + return CallResult::where('is_positive', true)->pluck('name')->all(); + } +} diff --git a/backend/app/Services/VoIP/AmiProvider.php b/backend/app/Services/VoIP/AmiProvider.php new file mode 100644 index 0000000..002c0f7 --- /dev/null +++ b/backend/app/Services/VoIP/AmiProvider.php @@ -0,0 +1,222 @@ + false, + 'provider_call_id' => null, + 'message' => 'شماره داخلی برای این کاربر ثبت نشده است.', + 'status' => 'missing_extension', + ]; + } + + $config = $this->config(); + $missing = $this->missingConfig($config); + if ($missing !== []) { + return [ + 'success' => false, + 'provider_call_id' => null, + 'message' => 'تنظیمات AMI کامل نیست.', + 'status' => 'configuration_error', + 'missing' => $missing, + ]; + } + + $connection = $this->connect($config, $error); + if (!$connection) { + return [ + 'success' => false, + 'provider_call_id' => null, + 'message' => $error ?: 'اتصال به مرکز تلفن برقرار نشد.', + 'status' => 'connection_failed', + ]; + } + + $login = $this->login($connection, $config); + if (!$this->isSuccess($login)) { + fclose($connection); + return [ + 'success' => false, + 'provider_call_id' => null, + 'message' => $login['Message'] ?? 'ورود به AMI انجام نشد.', + 'status' => 'login_failed', + 'raw' => $login, + ]; + } + + $providerCallId = 'ami_' . uniqid(); + $channel = "{$config['technology']}/{$extension}"; + $response = $this->sendAction($connection, [ + 'Action' => 'Originate', + 'ActionID' => $providerCallId, + 'Channel' => $channel, + 'Context' => $config['context'], + 'Exten' => $phone, + 'Priority' => '1', + 'CallerID' => $this->callerId($extension), + 'Timeout' => (string) ((int) $config['originate_timeout'] * 1000), + 'Async' => 'true', + 'Variable' => "CRM_USER_EXTENSION={$extension}", + ]); + + $this->logout($connection); + fclose($connection); + + $ok = $this->isSuccess($response); + return [ + 'success' => $ok, + 'provider_call_id' => $providerCallId, + 'message' => $ok ? 'درخواست تماس به مرکز تلفن ارسال شد.' : ($response['Message'] ?? 'درخواست تماس به مرکز تلفن ارسال نشد.'), + 'status' => $ok ? 'initiated' : 'failed', + 'raw' => $response, + 'channel' => $channel, + ]; + } + + public function getCallStatus(string $providerCallId): array + { + return [ + 'status' => 'pending', + 'provider_call_id' => $providerCallId, + ]; + } + + public function getRecordingUrl(string $providerCallId): ?string + { + $template = Setting::where('key', 'voip_ami_recording_url')->value('value'); + return $template ? str_replace('{id}', $providerCallId, $template) : null; + } + + public function testConnection(): array + { + $config = $this->config(); + $missing = $this->missingConfig($config); + if ($missing !== []) { + return [ + 'ok' => false, + 'message' => 'تنظیمات AMI کامل نیست.', + 'missing' => $missing, + ]; + } + + $connection = $this->connect($config, $error); + if (!$connection) { + return [ + 'ok' => false, + 'message' => $error ?: 'اتصال به AMI برقرار نشد.', + 'missing' => [], + ]; + } + + $login = $this->login($connection, $config); + $this->logout($connection); + fclose($connection); + + return [ + 'ok' => $this->isSuccess($login), + 'message' => $this->isSuccess($login) ? 'اتصال AMI موفق بود.' : ($login['Message'] ?? 'ورود به AMI انجام نشد.'), + 'missing' => [], + ]; + } + + private function config(): array + { + return [ + 'host' => trim((string) Setting::where('key', 'voip_ami_host')->value('value')), + 'port' => (int) (Setting::where('key', 'voip_ami_port')->value('value') ?: 5038), + 'username' => trim((string) Setting::where('key', 'voip_ami_username')->value('value')), + 'secret' => (string) Setting::where('key', 'voip_ami_secret')->value('value'), + 'technology' => trim((string) (Setting::where('key', 'voip_ami_channel_technology')->value('value') ?: 'SIP')), + 'context' => trim((string) (Setting::where('key', 'voip_ami_context')->value('value') ?: 'from-internal')), + 'timeout' => (float) (Setting::where('key', 'voip_ami_timeout')->value('value') ?: 5), + 'originate_timeout' => (int) (Setting::where('key', 'voip_ami_originate_timeout')->value('value') ?: 30), + ]; + } + + private function missingConfig(array $config): array + { + 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 + { + $error = null; + $connection = @stream_socket_client( + "tcp://{$config['host']}:{$config['port']}", + $errorCode, + $errorMessage, + $config['timeout'] + ); + + if (!$connection) { + $error = $errorMessage ?: "خطای اتصال AMI ({$errorCode})"; + return false; + } + + stream_set_timeout($connection, (int) ceil($config['timeout'])); + fgets($connection); + + return $connection; + } + + private function login(mixed $connection, array $config): array + { + return $this->sendAction($connection, [ + 'Action' => 'Login', + 'Username' => $config['username'], + 'Secret' => $config['secret'], + 'Events' => 'off', + ]); + } + + private function logout(mixed $connection): void + { + $this->sendAction($connection, ['Action' => 'Logoff']); + } + + private function sendAction(mixed $connection, array $headers): array + { + foreach ($headers as $key => $value) { + fwrite($connection, "{$key}: {$value}\r\n"); + } + fwrite($connection, "\r\n"); + + return $this->readResponse($connection); + } + + private function readResponse(mixed $connection): array + { + $response = []; + while (!feof($connection)) { + $line = fgets($connection); + if ($line === false || trim($line) === '') { + break; + } + if (str_contains($line, ':')) { + [$key, $value] = explode(':', $line, 2); + $response[trim($key)] = trim($value); + } + } + + return $response; + } + + private function isSuccess(array $response): bool + { + return strtolower((string) ($response['Response'] ?? '')) === 'success'; + } + + 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 new file mode 100644 index 0000000..b48c6bf --- /dev/null +++ b/backend/app/Services/VoIP/ApiProvider.php @@ -0,0 +1,90 @@ +value('value'), '/'); + $token = Setting::where('key', 'voip_api_token')->value('value'); + $callPath = Setting::where('key', 'voip_api_call_path')->value('value') ?: '/calls'; + + if (!$baseUrl) { + return [ + 'success' => false, + 'provider_call_id' => null, + 'message' => 'نشانی ای‌پی‌آی تماس تنظیم نشده است', + 'status' => 'configuration_error', + ]; + } + + $request = Http::acceptJson()->timeout(10); + if ($token) { + $request = $request->withToken($token); + } + + $response = $request->post($baseUrl . '/' . ltrim($callPath, '/'), [ + 'phone' => $phone, + 'caller_id' => $callerId, + ]); + + $body = $response->json() ?: []; + + return [ + 'success' => $response->successful(), + 'provider_call_id' => $body['provider_call_id'] ?? $body['call_id'] ?? null, + 'message' => $body['message'] ?? ($response->successful() ? 'تماس از راه ای‌پی‌آی ارسال شد' : 'خطا در ارسال تماس از راه ای‌پی‌آی'), + 'status' => $body['status'] ?? ($response->successful() ? 'initiated' : 'failed'), + 'raw' => $body, + ]; + } + + public function getCallStatus(string $providerCallId): array + { + $baseUrl = rtrim((string) Setting::where('key', 'voip_api_base_url')->value('value'), '/'); + $token = Setting::where('key', 'voip_api_token')->value('value'); + $statusPath = Setting::where('key', 'voip_api_status_path')->value('value') ?: '/calls/{id}'; + + if (!$baseUrl) { + return ['status' => 'configuration_error']; + } + + $request = Http::acceptJson()->timeout(10); + if ($token) { + $request = $request->withToken($token); + } + + $url = $baseUrl . '/' . ltrim(str_replace('{id}', $providerCallId, $statusPath), '/'); + $response = $request->get($url); + + return $response->json() ?: [ + 'status' => $response->successful() ? 'unknown' : 'failed', + ]; + } + + public function getRecordingUrl(string $providerCallId): ?string + { + $template = Setting::where('key', 'voip_api_recording_url')->value('value'); + return $template ? str_replace('{id}', $providerCallId, $template) : null; + } + + public function testConnection(): array + { + $missing = []; + foreach (['voip_api_base_url', 'voip_api_token'] as $key) { + if (!Setting::where('key', $key)->value('value')) { + $missing[] = $key; + } + } + + return [ + 'ok' => $missing === [], + 'message' => $missing ? 'تنظیمات اتصال API کامل نیست.' : 'تنظیمات اتصال API معتبر است.', + 'missing' => $missing, + ]; + } +} diff --git a/backend/app/Services/VoIP/MockProvider.php b/backend/app/Services/VoIP/MockProvider.php new file mode 100644 index 0000000..43c2e6e --- /dev/null +++ b/backend/app/Services/VoIP/MockProvider.php @@ -0,0 +1,39 @@ + true, + 'provider_call_id' => 'mock_' . uniqid(), + 'message' => "تماس با {$phone} در حال انجام است", + 'status' => 'initiated', + ]; + } + + public function getCallStatus(string $providerCallId): array + { + return [ + 'status' => 'completed', + 'duration' => rand(30, 300), + 'answered' => true, + ]; + } + + public function getRecordingUrl(string $providerCallId): ?string + { + return "/recordings/{$providerCallId}.mp3"; + } + + public function testConnection(): array + { + return [ + 'ok' => true, + 'message' => 'اتصال شبیه‌ساز آماده است.', + 'missing' => [], + ]; + } +} diff --git a/backend/app/Services/VoIP/SocketProvider.php b/backend/app/Services/VoIP/SocketProvider.php new file mode 100644 index 0000000..b2a4a74 --- /dev/null +++ b/backend/app/Services/VoIP/SocketProvider.php @@ -0,0 +1,90 @@ +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) { + return [ + 'success' => false, + 'provider_call_id' => null, + 'message' => 'میزبان یا پورت سوکت تنظیم نشده است', + 'status' => 'configuration_error', + ]; + } + + $connection = @stream_socket_client("tcp://{$host}:{$port}", $errorCode, $errorMessage, $timeout); + if (!$connection) { + return [ + 'success' => false, + 'provider_call_id' => null, + 'message' => $errorMessage ?: 'اتصال سوکت برقرار نشد', + 'status' => 'connection_failed', + 'error_code' => $errorCode, + ]; + } + + stream_set_timeout($connection, (int) ceil($timeout)); + $providerCallId = 'socket_' . uniqid(); + $payload = [ + 'type' => 'call.initiate', + 'provider_call_id' => $providerCallId, + 'phone' => $phone, + 'caller_id' => $callerId, + 'token' => $token, + ]; + + 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, + 'provider_call_id' => $response['provider_call_id'] ?? $providerCallId, + 'message' => $response['message'] ?? 'درخواست تماس از راه سوکت ارسال شد', + 'status' => $response['status'] ?? 'initiated', + 'raw' => $response, + ]; + } + + public function getCallStatus(string $providerCallId): array + { + return [ + 'status' => 'pending', + 'provider_call_id' => $providerCallId, + ]; + } + + public function getRecordingUrl(string $providerCallId): ?string + { + $template = Setting::where('key', 'voip_socket_recording_url')->value('value'); + return $template ? str_replace('{id}', $providerCallId, $template) : null; + } + + public function testConnection(): array + { + $missing = []; + foreach (['voip_socket_host', 'voip_socket_port'] as $key) { + if (!Setting::where('key', $key)->value('value')) { + $missing[] = $key; + } + } + + return [ + 'ok' => $missing === [], + 'message' => $missing ? 'تنظیمات اتصال سوکت کامل نیست.' : 'تنظیمات اتصال سوکت معتبر است.', + 'missing' => $missing, + ]; + } +} diff --git a/backend/app/Services/VoIP/VoIPManager.php b/backend/app/Services/VoIP/VoIPManager.php new file mode 100644 index 0000000..4c1154c --- /dev/null +++ b/backend/app/Services/VoIP/VoIPManager.php @@ -0,0 +1,44 @@ +provider === null) { + $providerName = Setting::where('key', 'voip_provider')->value('value') ?? 'mock'; + $this->provider = match ($providerName) { + 'ami' => new AmiProvider(), + 'api' => new ApiProvider(), + 'socket' => new SocketProvider(), + default => new MockProvider(), + }; + } + return $this->provider; + } + + public function initiateCall(string $phone, string $callerId = null): array + { + return $this->provider()->initiateCall($phone, $callerId); + } + + public function getCallStatus(string $providerCallId): array + { + return $this->provider()->getCallStatus($providerCallId); + } + + public function getRecordingUrl(string $providerCallId): ?string + { + return $this->provider()->getRecordingUrl($providerCallId); + } + + public function testConnection(): array + { + return $this->provider()->testConnection(); + } +} diff --git a/backend/app/Services/VoIP/VoIPProviderInterface.php b/backend/app/Services/VoIP/VoIPProviderInterface.php new file mode 100644 index 0000000..1aa60e0 --- /dev/null +++ b/backend/app/Services/VoIP/VoIPProviderInterface.php @@ -0,0 +1,11 @@ +hasRole('admin')) { + return true; + } + + if ($user->hasRole('agent')) { + return $lead->assigned_to === $user->id + || ($allowUnassignedClaim && is_null($lead->assigned_to) && (bool) $lead->is_unassigned); + } + + if ($user->hasRole('supervisor')) { + return $lead->assigned_to === $user->id + || in_array($lead->team_id, self::teamIds($user), true) + || (is_null($lead->team_id) && is_null($lead->assigned_to)); + } + + return false; + } + + public static function canAccessCall(User $user, Call $call): bool + { + if ($user->hasRole('admin')) { + return true; + } + + if ($user->hasRole('agent')) { + return $call->user_id === $user->id; + } + + if ($user->hasRole('supervisor')) { + return in_array($call->user_id, self::teamMemberIds($user), true) + || ($call->lead && self::canAccessLead($user, $call->lead)); + } + + return false; + } + + public static function canAccessFollowUp(User $user, FollowUp $followUp): bool + { + if ($user->hasRole('admin')) { + return true; + } + + if ($user->hasRole('agent')) { + return $followUp->user_id === $user->id; + } + + if ($user->hasRole('supervisor')) { + return in_array($followUp->user_id, self::teamMemberIds($user), true) + || ($followUp->lead && self::canAccessLead($user, $followUp->lead)); + } + + return false; + } + + public static function canAccessCampaign(User $user, Campaign $campaign): bool + { + if ($user->hasRole('admin')) { + return true; + } + + if ($user->hasRole('agent')) { + return $campaign->assignedAgents()->whereKey($user->id)->exists() + || $campaign->leads()->where('assigned_to', $user->id)->exists(); + } + + if ($user->hasRole('supervisor')) { + $teamIds = self::teamIds($user); + + return $campaign->assignedSupervisors()->whereKey($user->id)->exists() + || $campaign->leads()->whereIn('team_id', $teamIds)->exists(); + } + + return false; + } + + public static function scopeLeads(Builder $query, User $user, bool $allowUnassignedForSupervisor = true): Builder + { + if ($user->hasRole('admin')) { + return $query; + } + + if ($user->hasRole('agent')) { + return $query->where('assigned_to', $user->id); + } + + if ($user->hasRole('supervisor')) { + $teamIds = self::teamIds($user); + + return $query->where(function (Builder $q) use ($user, $teamIds, $allowUnassignedForSupervisor): void { + $q->whereIn('team_id', $teamIds) + ->orWhere('assigned_to', $user->id); + + if ($allowUnassignedForSupervisor) { + $q->orWhere(function (Builder $unassigned): void { + $unassigned->whereNull('team_id')->whereNull('assigned_to'); + }); + } + }); + } + + return $query->whereRaw('1 = 0'); + } + + public static function scopeCalls(Builder $query, User $user): Builder + { + if ($user->hasRole('admin')) { + return $query; + } + + if ($user->hasRole('agent')) { + return $query->where('user_id', $user->id); + } + + 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)); + }); + } + + return $query->whereRaw('1 = 0'); + } + + public static function scopeFollowUps(Builder $query, User $user): Builder + { + if ($user->hasRole('admin')) { + return $query; + } + + if ($user->hasRole('agent')) { + return $query->where('user_id', $user->id); + } + + 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)); + }); + } + + return $query->whereRaw('1 = 0'); + } + + public static function scopeCampaigns(Builder $query, User $user): Builder + { + if ($user->hasRole('admin')) { + return $query; + } + + 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)); + }); + } + + if ($user->hasRole('supervisor')) { + $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)); + }); + } + + return $query->whereRaw('1 = 0'); + } + + public static function teamIds(User $user): array + { + return $user->teams()->pluck('teams.id')->all(); + } + + public static function teamMemberIds(User $user): array + { + $teamIds = self::teamIds($user); + + if (!$teamIds) { + return []; + } + + return Team::whereIn('id', $teamIds) + ->with('members:id') + ->get() + ->pluck('members.*.id') + ->flatten() + ->unique() + ->values() + ->all(); + } +} diff --git a/backend/app/Support/PasswordPolicy.php b/backend/app/Support/PasswordPolicy.php new file mode 100644 index 0000000..6b79b18 --- /dev/null +++ b/backend/app/Support/PasswordPolicy.php @@ -0,0 +1,27 @@ +value('value') ?: 8)); + $rule = Password::min($min); + + if (Setting::where('key', 'password_require_mixed_case')->value('value') === 'true') { + $rule->mixedCase(); + } + if (Setting::where('key', 'password_require_numbers')->value('value') === 'true') { + $rule->numbers(); + } + if (Setting::where('key', 'password_require_symbols')->value('value') === 'true') { + $rule->symbols(); + } + + return ['string', $rule]; + } +} diff --git a/backend/app/Support/SettingsCatalog.php b/backend/app/Support/SettingsCatalog.php new file mode 100644 index 0000000..8959916 --- /dev/null +++ b/backend/app/Support/SettingsCatalog.php @@ -0,0 +1,182 @@ +', 'قالب Caller ID', 'عبارت {extension} با شماره داخلی کاربر جایگزین می‌شود.', true, false, false, null, ['AmiProvider']), + self::d('voip_ami_timeout', 'voip', 'integer', '5', 'مهلت اتصال AMI', 'حداکثر زمان انتظار برای اتصال socket به AMI.', true, false, false, null, ['AmiProvider']), + self::d('voip_ami_originate_timeout', 'voip', 'integer', '30', 'مهلت زنگ خوردن داخلی', 'مهلت Originate بر حسب ثانیه.', true, false, false, null, ['AmiProvider']), + self::d('voip_ami_recording_url', 'voip', 'string', '', 'قالب لینک ضبط AMI', 'اگر لینک ضبط از الگو ساخته می‌شود، {id} با شناسه تماس جایگزین می‌شود.', true, false, false, null, ['AmiProvider']), + self::d('voip_api_base_url', 'voip', 'string', '', 'Base URL', 'در provider API استفاده می‌شود.', true, false, false, null, ['ApiProvider']), + 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_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_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), + self::d('minimum_call_duration_seconds', 'voip', 'integer', '10', 'حداقل مدت تماس', 'اعتبارسنجی duration به‌زودی.', false, false, false, null, [], true), + self::d('call_result_required', 'voip', 'boolean', 'true', 'ثبت نتیجه تماس الزامی', 'فرم‌های ثبت نتیجه در UI/API الزام نتیجه دارند.', true, false, false, null, ['CallController']), + + 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('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']), + + self::d('in_app_notifications_enabled', 'notification', 'boolean', 'true', 'اعلان داخل برنامه', 'سیستم اعلان داخلی فعال است.', true, false, false, null, ['NotificationService']), + self::d('browser_notifications_enabled', 'notification', 'boolean', 'false', 'اعلان مرورگر/PWA', 'اجازه push/browser به‌زودی.', false, false, false, null, [], true), + self::d('new_lead_notification_enabled', 'notification', 'boolean', 'true', 'اعلان لید جدید', 'در تخصیص‌ها و آینده ایجاد لید استفاده می‌شود.', true, false, false, null, ['NotificationService']), + self::d('assigned_lead_notification_enabled', 'notification', 'boolean', 'true', 'اعلان تخصیص لید', 'در LeadService و ImportService استفاده می‌شود.', true, false, false, null, ['LeadService', 'ImportService']), + self::d('follow_up_notification_enabled', 'notification', 'boolean', 'true', 'اعلان پیگیری', 'در FollowUpController و CallService استفاده می‌شود.', true, false, false, null, ['FollowUpController', 'CallService']), + self::d('overdue_notification_enabled', 'notification', 'boolean', 'true', 'اعلان پیگیری عقب‌افتاده', 'در endpoint پیگیری‌های عقب‌افتاده استفاده می‌شود.', true, false, false, null, ['FollowUpController@overdue']), + self::d('quiet_hours_start', 'notification', 'string', '22:00', 'شروع سکوت اعلان', 'اعمال quiet hours به‌زودی.', false, false, false, null, [], true), + self::d('quiet_hours_end', 'notification', 'string', '08:00', 'پایان سکوت اعلان', 'اعمال quiet hours به‌زودی.', false, false, false, null, [], true), + + self::d('import_required_fields', 'import_export', 'json', 'company,phone', 'فیلدهای الزامی ورود داده', 'در ورود فعلی company و phone الزامی هستند.', true, false, false, null, ['ImportController']), + self::d('import_max_file_size_mb', 'import_export', 'integer', '10', 'حداکثر حجم ورود داده', 'در بارگذاری فایل ورود داده اعمال می‌شود.', true, false, false, null, ['ImportController']), + self::d('import_allowed_file_types', 'import_export', 'json', 'xlsx,xls,csv', 'نوع فایل ورود داده', 'در بارگذاری فایل ورود داده اعمال می‌شود.', true, false, false, null, ['ImportController']), + self::d('export_phone_mode', 'import_export', 'string', 'permission_based', 'حالت شماره در خروجی', 'full فقط با view_full_phone؛ masked برای بقیه.', true, false, false, ['permission_based', 'masked_only'], ['LeadController@export']), + self::d('import_rollback_allowed', 'import_export', 'boolean', 'true', 'بازگردانی ورود داده', 'در ساخت دسته ورود و بازگردانی ورود داده استفاده می‌شود.', true, false, false, null, ['ImportController']), + self::d('mapping_presets', 'import_export', 'json', '', 'الگوهای نگاشت', 'الگوهای نگاشت به‌زودی.', false, false, false, null, [], true), + 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('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']), + self::d('default_report_range', 'reports', 'string', 'month', 'بازه پیش‌فرض گزارش', 'اعمال پیش‌فرض گزارش به‌زودی.', false, false, false, ['today', 'week', 'month', 'quarter'], [], true), + + self::d('pwa_enabled', 'pwa', 'boolean', 'true', 'فعال بودن PWA', 'manifest/service worker موجود است؛ خاموش کردن runtime به‌زودی.', false, true, false, null, [], true), + self::d('pwa_install_banner_text', 'pwa', 'string', 'نصب CRM فروش', 'متن بنر نصب PWA', 'در بنر نصب PWA نمایش داده می‌شود.', false, true, false, null, [], true), + self::d('pwa_theme_color', 'pwa', 'string', '#2563eb', 'رنگ theme', 'در پاسخ عمومی منتشر می‌شود؛ تزریق manifest به‌زودی.', false, true, false, null, [], true), + self::d('pwa_status_bar_color', 'pwa', 'string', '#ffffff', 'رنگ نوار وضعیت', 'اعمال کامل به‌زودی.', false, true, false, null, [], true), + self::d('ui_density', 'pwa', 'string', 'comfortable', 'حالت نمایش', 'compact/comfortable در UI آینده اعمال می‌شود.', false, true, false, ['compact', 'comfortable'], [], true), + self::d('default_landing_page', 'pwa', 'string', '/', 'صفحه پیش‌فرض', 'redirect پویا به‌زودی.', false, true, false, ['/', '/leads', '/crm', '/agent-mobile/today'], [], true), + self::d('offline_cache_duration_hours', 'pwa', 'integer', '24', 'مدت cache آفلاین', 'service worker پویا به‌زودی.', false, false, false, null, [], true), + self::d('push_permission_text', 'pwa', 'string', 'برای دریافت یادآوری‌ها اعلان‌ها را فعال کنید.', 'متن اجازه push', 'push notification به‌زودی.', false, true, false, null, [], true), + ]; + } + + public static function ensureDefaults(): void + { + foreach (self::definitions() as $definition) { + $setting = Setting::firstOrNew(['key' => $definition['key']]); + $setting->fill([ + 'value' => $setting->exists ? $setting->value : $definition['default_value'], + 'default_value' => $definition['default_value'], + 'group' => $definition['group'], + 'type' => $definition['type'], + 'allowed_values' => $definition['allowed_values'], + 'is_secret' => $definition['is_secret'], + 'is_public' => $definition['is_public'], + 'is_runtime_enforced' => $definition['is_runtime_enforced'], + 'description' => $definition['hint'], + 'validation_rules' => self::rulesFor($definition), + ])->save(); + } + } + + public static function metaFor(string $key): ?array + { + foreach (self::definitions() as $definition) { + if ($definition['key'] === $key) { + return $definition; + } + } + return null; + } + + private static function d(string $key, string $group, string $type, string $default, string $label, string $hint, bool $enforced, bool $public = false, bool $secret = false, ?array $allowed = null, array $usedBy = [], bool $comingSoon = false): array + { + return [ + 'key' => $key, + 'group' => $group, + 'type' => $type, + 'default_value' => $default, + 'label' => $label, + 'hint' => $hint, + 'allowed_values' => $allowed, + 'is_runtime_enforced' => $enforced, + 'is_public' => $public, + 'is_secret' => $secret, + 'used_by' => $usedBy, + 'coming_soon' => $comingSoon, + ]; + } + + private static function rulesFor(array $definition): array + { + return match ($definition['type']) { + 'boolean' => ['in:true,false,1,0'], + 'integer' => ['integer'], + 'json' => ['nullable', 'string'], + default => ['nullable', 'string'], + }; + } +} diff --git a/backend/app/Support/WorkingHours.php b/backend/app/Support/WorkingHours.php new file mode 100644 index 0000000..e3010ab --- /dev/null +++ b/backend/app/Support/WorkingHours.php @@ -0,0 +1,34 @@ +value('value') !== 'true') { + return true; + } + + $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)) { + return false; + } + + $holidays = array_filter(array_map('trim', explode(',', Setting::where('key', 'custom_holidays')->value('value') ?: ''))); + if (in_array($at->toDateString(), $holidays, true)) { + return false; + } + + $start = Setting::where('key', 'working_hours_start')->value('value') ?: '09:00'; + $end = Setting::where('key', 'working_hours_end')->value('value') ?: '18:00'; + $time = $at->format('H:i'); + + return $time >= $start && $time <= $end; + } +} diff --git a/backend/artisan b/backend/artisan new file mode 100644 index 0000000..c35e31d --- /dev/null +++ b/backend/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php new file mode 100644 index 0000000..2d79dbd --- /dev/null +++ b/backend/bootstrap/app.php @@ -0,0 +1,43 @@ +withRouting( + 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, + 'mask_phone' => MaskPhoneNumber::class, + ]); + + $middleware->api(prepend: [ + \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, + ]); + + $middleware->api(append: [ + MaskPhoneNumber::class, + ]); + + $middleware->redirectGuestsTo(fn (Request $request) => $request->is('api/*') || $request->expectsJson() ? null : '/login'); + + $middleware->append(SecurityHeaders::class); + }) + ->withExceptions(function (Exceptions $exceptions): void { + $exceptions->render(function (AuthenticationException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + }); + })->create(); diff --git a/backend/bootstrap/cache/.gitignore b/backend/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/backend/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/backend/bootstrap/providers.php b/backend/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/backend/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.12.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "9aa17bcdd777ee31df9fc83c337ca4ca2340def3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/9aa17bcdd777ee31df9fc83c337ca4ca2340def3", + "reference": "9aa17bcdd777ee31df9fc83c337ca4ca2340def3", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.12.3", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.5.1", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.12.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-06-23T15:29:02+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:23:43+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.12.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.12.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-06-23T15:21:08+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.8", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/9c19128923b05a5d7355e5d2318d7808b7e33bbd", + "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.8" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-06-23T13:02:23+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.62.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "f7e61eb1e0e06a38996802b769bce9127aec227c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/f7e61eb1e0e06a38996802b769bce9127aec227c", + "reference": "f7e61eb1e0e06a38996802b769bce9127aec227c", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.9.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.1.41", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-09T13:50:13+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.21", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "7753c65c281c2550c7c183f14e18062073b7d821" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821", + "reference": "7753c65c281c2550c7c183f14e18062073b7d821", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.21" + }, + "time": "2026-06-26T00:11:25+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.13", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-04-16T14:03:50+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.11.1" + }, + "time": "2026-02-06T14:12:35+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-03-19T13:16:38+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.35.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f23af6c5aafd958a7593029a271d77baf5ed793c", + "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.35.1" + }, + "time": "2026-06-25T06:52:23+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "maatwebsite/excel", + "version": "3.1.69", + "source": { + "type": "git", + "url": "https://github.com/SpartnerNL/Laravel-Excel.git", + "reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760", + "reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760", + "shasum": "" + }, + "require": { + "composer/semver": "^3.3", + "ext-json": "*", + "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0||^13.0", + "php": "^7.0||^8.0", + "phpoffice/phpspreadsheet": "^1.30.4", + "psr/simple-cache": "^1.0||^2.0||^3.0" + }, + "require-dev": { + "laravel/scout": "^7.0||^8.0||^9.0||^10.0||^11.0", + "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0||^11.0", + "predis/predis": "^1.1" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Excel": "Maatwebsite\\Excel\\Facades\\Excel" + }, + "providers": [ + "Maatwebsite\\Excel\\ExcelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Maatwebsite\\Excel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patrick Brouwers", + "email": "patrick@spartner.nl" + } + ], + "description": "Supercharged Excel exports and imports in Laravel", + "keywords": [ + "PHPExcel", + "batch", + "csv", + "excel", + "export", + "import", + "laravel", + "php", + "phpspreadsheet" + ], + "support": { + "issues": "https://github.com/SpartnerNL/Laravel-Excel/issues", + "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.69" + }, + "funding": [ + { + "url": "https://laravel-excel.com/commercial-support", + "type": "custom" + }, + { + "url": "https://github.com/patrickbrouwers", + "type": "github" + } + ], + "time": "2026-04-30T20:03:58+00:00" + }, + { + "name": "maennchen/zipstream-php", + "version": "3.1.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.2" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^11.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-01-27T12:07:53+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.13.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "40f6618f052df16b545f626fbf9a878e6497d16a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a", + "reference": "40f6618f052df16b545f626fbf9a878e6497d16a", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-06-18T13:49:15+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.4" + }, + "time": "2026-05-11T20:49:54+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "phpoffice/phpspreadsheet", + "version": "1.30.5", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "97bcabd32a64924688487dcd64aceaf158affb5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/97bcabd32a64924688487dcd64aceaf158affb5c", + "reference": "97bcabd32a64924688487dcd64aceaf158affb5c", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "ezyang/htmlpurifier": "^4.15", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": ">=7.4.0 <8.5.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "doctrine/instantiator": "^1.5", + "dompdf/dompdf": "^1.0 || ^2.0 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.3", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^8.5 || ^9.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.5" + }, + "time": "2026-05-31T05:13:11+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.23", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + }, + "time": "2026-05-23T13:41:31+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.3", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "shasum": "" + }, + "require": { + "brick/math": ">=0.8.16 <=0.18", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.3" + }, + "time": "2026-06-18T03:57:49+00:00" + }, + { + "name": "spatie/laravel-permission", + "version": "6.25.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "d7d4cb0d58616722f1afc90e0484e4825155b9b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/d7d4cb0d58616722f1afc90e0484e4825155b9b3", + "reference": "d7d4cb0d58616722f1afc90e0484e4825155b9b3", + "shasum": "" + }, + "require": { + "illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0" + }, + "require-dev": { + "laravel/passport": "^11.0|^12.0|^13.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^2.0|^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^2.0|^3.0|^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "6.x-dev", + "dev-master": "6.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 8.0 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/6.25.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-17T22:46:46+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T11:50:14+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:18:21+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "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" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "4e1a093b481f323e6e326451f9760c3868430673" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/4e1a093b481f323e6e326451f9760c3868430673", + "reference": "4e1a093b481f323e6e326451f9760c3868430673", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:22:21+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-06T11:10:32+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "13b38720174286f55d1761152b575a8d1436fc25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T08:31:18+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/06db5ae1552177bf8572f8908839f12e3c06aed3", + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-11T07:31:44+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e99af79b1e776646eda0e1c23b7b45c184ff99be", + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T09:14:35+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-13T08:51:35+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:22:37+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T15:22:23+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:51:48+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:20:33+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T15:23:29+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "a1af4dacb24eb7ef4f1ca71b94da8ddbce572281" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/a1af4dacb24eb7ef4f1ca71b94da8ddbce572281", + "reference": "a1af4dacb24eb7ef4f1ca71b94da8ddbce572281", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-06T09:33:19+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2676b524340abcfe4d6151ec698463cebafee439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T15:19:22+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-08T20:24:16+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.29.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/da1d1111a6aa2e082d2a388b194afe1ba0a05d14", + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95.8", + "illuminate/view": "^12.62.0", + "larastan/larastan": "^3.10.0", + "laravel-zero/framework": "^12.1.0", + "laravel/agent-detector": "^2.0.2", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-06-16T15:34:04+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.63.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "51bbce3f803c1d386cabbb44e618c955a12ff5fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/51bbce3f803c1d386cabbb44e618c955a12ff5fc", + "reference": "51bbce3f803c1d386cabbb44e618c955a12ff5fc", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2026-06-18T08:54:14+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.4", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.8 || ^8.0.8" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-04-21T14:04:20+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/f8f328665ace2370d1e10645b807ba1646dc7dcc", + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-08T20:24:16+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/backend/config/app.php b/backend/config/app.php new file mode 100644 index 0000000..423eed5 --- /dev/null +++ b/backend/config/app.php @@ -0,0 +1,126 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/backend/config/auth.php b/backend/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/backend/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/backend/config/cache.php b/backend/config/cache.php new file mode 100644 index 0000000..b32aead --- /dev/null +++ b/backend/config/cache.php @@ -0,0 +1,117 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", + | "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/backend/config/cors.php b/backend/config/cors.php new file mode 100644 index 0000000..b02bd8c --- /dev/null +++ b/backend/config/cors.php @@ -0,0 +1,17 @@ + ['api/*', 'sanctum/csrf-cookie'], + 'allowed_methods' => ['*'], + 'allowed_origins' => $allowedOrigins, + 'allowed_origins_patterns' => [], + 'allowed_headers' => ['*'], + 'exposed_headers' => [], + 'max_age' => 0, + 'supports_credentials' => true, +]; diff --git a/backend/config/database.php b/backend/config/database.php new file mode 100644 index 0000000..64709ce --- /dev/null +++ b/backend/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/backend/config/filesystems.php b/backend/config/filesystems.php new file mode 100644 index 0000000..37d8fca --- /dev/null +++ b/backend/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/backend/config/logging.php b/backend/config/logging.php new file mode 100644 index 0000000..b09cb25 --- /dev/null +++ b/backend/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/backend/config/mail.php b/backend/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/backend/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/backend/config/permission.php b/backend/config/permission.php new file mode 100644 index 0000000..85b8832 --- /dev/null +++ b/backend/config/permission.php @@ -0,0 +1,206 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Spatie\Permission\Contracts\Permission` contract. + */ + + 'permission' => Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Spatie\Permission\Contracts\Role` contract. + */ + + 'role' => Role::class, + + ], + + 'table_names' => [ + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_permissions' => 'model_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_roles' => 'model_has_roles', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `model_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `model_uuid`. + */ + + 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', + ], + + /* + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Events will fire when a role or permission is assigned/unassigned: + * \Spatie\Permission\Events\RoleAttached + * \Spatie\Permission\Events\RoleDetached + * \Spatie\Permission\Events\PermissionAttached + * \Spatie\Permission\Events\PermissionDetached + * + * To enable, set to true, and then create listeners to watch these events. + */ + 'events_enabled' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => false, + + /* + * The class to use to resolve the permissions team id + */ + 'team_resolver' => DefaultTeamResolver::class, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_permission_in_exception' => false, + + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + + 'cache' => [ + + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_time' => DateInterval::createFromDateString('24 hours'), + + /* + * The cache key used to store all permissions. + */ + + 'key' => 'spatie.permission.cache', + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => 'default', + ], +]; diff --git a/backend/config/queue.php b/backend/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/backend/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php new file mode 100644 index 0000000..5a89a23 --- /dev/null +++ b/backend/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:5173,127.0.0.1,127.0.0.1:5173,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/backend/config/services.php b/backend/config/services.php new file mode 100644 index 0000000..6a90eb8 --- /dev/null +++ b/backend/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/backend/config/session.php b/backend/config/session.php new file mode 100644 index 0000000..5b541b7 --- /dev/null +++ b/backend/config/session.php @@ -0,0 +1,217 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/backend/database/.gitignore b/backend/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/backend/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/backend/database/factories/UserFactory.php b/backend/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/backend/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/backend/database/migrations/0001_01_01_000000_create_users_table.php b/backend/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..01fb368 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->string('phone', 20)->nullable(); + $table->string('avatar')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamp('last_login_at')->nullable(); + $table->string('last_login_ip', 45)->nullable(); + $table->rememberToken(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/backend/database/migrations/0001_01_01_000001_create_cache_table.php b/backend/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..ed758bd --- /dev/null +++ b/backend/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/backend/database/migrations/0001_01_01_000002_create_jobs_table.php b/backend/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/backend/database/migrations/2026_06_27_122700_create_personal_access_tokens_table.php b/backend/database/migrations/2026_06_27_122700_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/backend/database/migrations/2026_06_27_122700_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/backend/database/migrations/2026_06_27_122703_create_permission_tables.php b/backend/database/migrations/2026_06_27_122703_create_permission_tables.php new file mode 100644 index 0000000..66ce1f9 --- /dev/null +++ b/backend/database/migrations/2026_06_27_122703_create_permission_tables.php @@ -0,0 +1,134 @@ +engine('InnoDB'); + $table->bigIncrements('id'); // permission id + $table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format) + $table->string('guard_name'); // For MyISAM use string('guard_name', 25); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { + // $table->engine('InnoDB'); + $table->bigIncrements('id'); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format) + $table->string('guard_name'); // For MyISAM use string('guard_name', 25); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->onDelete('cascade'); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->onDelete('cascade'); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->onDelete('cascade'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->onDelete('cascade'); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + Schema::drop($tableNames['role_has_permissions']); + Schema::drop($tableNames['model_has_roles']); + Schema::drop($tableNames['model_has_permissions']); + Schema::drop($tableNames['roles']); + Schema::drop($tableNames['permissions']); + } +}; diff --git a/backend/database/migrations/2026_06_27_123131_create_lead_statuses_table.php b/backend/database/migrations/2026_06_27_123131_create_lead_statuses_table.php new file mode 100644 index 0000000..b5084c0 --- /dev/null +++ b/backend/database/migrations/2026_06_27_123131_create_lead_statuses_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('name'); + $table->string('color', 20)->default('#6B7280'); + $table->string('icon')->nullable(); + $table->integer('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->boolean('is_default')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('lead_statuses'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123131_create_teams_table.php b/backend/database/migrations/2026_06_27_123131_create_teams_table.php new file mode 100644 index 0000000..2b27f2a --- /dev/null +++ b/backend/database/migrations/2026_06_27_123131_create_teams_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + $table->foreignId('supervisor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('team_user', function (Blueprint $table) { + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->primary(['team_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('team_user'); + Schema::dropIfExists('teams'); + } +}; 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 new file mode 100644 index 0000000..38f5dbe --- /dev/null +++ b/backend/database/migrations/2026_06_27_123132_create_campaigns_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('product_service')->nullable(); + $table->date('start_date')->nullable(); + $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(); + $table->timestamps(); + }); + + Schema::create('campaign_user', function (Blueprint $table) { + $table->foreignId('campaign_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('role', 20)->default('agent'); + $table->primary(['campaign_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('campaign_user'); + Schema::dropIfExists('campaigns'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123132_create_pipeline_stages_table.php b/backend/database/migrations/2026_06_27_123132_create_pipeline_stages_table.php new file mode 100644 index 0000000..5038e8d --- /dev/null +++ b/backend/database/migrations/2026_06_27_123132_create_pipeline_stages_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('name'); + $table->string('color', 20)->default('#6B7280'); + $table->integer('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->boolean('is_default')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pipeline_stages'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123133_create_leads_table.php b/backend/database/migrations/2026_06_27_123133_create_leads_table.php new file mode 100644 index 0000000..57319aa --- /dev/null +++ b/backend/database/migrations/2026_06_27_123133_create_leads_table.php @@ -0,0 +1,53 @@ +id(); + $table->string('first_name'); + $table->string('last_name'); + $table->string('company')->nullable(); + $table->string('phone', 20); + $table->string('phone_secondary', 20)->nullable(); + $table->string('email')->nullable(); + $table->string('city')->nullable(); + $table->string('province')->nullable(); + $table->string('source')->nullable(); + $table->string('product_interest')->nullable(); + $table->integer('priority')->default(0); + $table->integer('lead_score')->default(0); + $table->text('notes')->nullable(); + $table->text('tags')->nullable(); + $table->foreignId('lead_status_id')->nullable()->constrained('lead_statuses')->nullOnDelete(); + $table->foreignId('pipeline_stage_id')->nullable()->constrained('pipeline_stages')->nullOnDelete(); + $table->foreignId('campaign_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('assigned_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('team_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamp('last_call_at')->nullable(); + $table->string('last_call_result', 50)->nullable(); + $table->timestamp('next_follow_up_at')->nullable(); + $table->boolean('is_unassigned')->default(true); + $table->timestamps(); + $table->softDeletes(); + + $table->index('assigned_to'); + $table->index('lead_status_id'); + $table->index('pipeline_stage_id'); + $table->index('campaign_id'); + $table->index('team_id'); + $table->index('is_unassigned'); + }); + } + + public function down(): void + { + Schema::dropIfExists('leads'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123134_create_calls_table.php b/backend/database/migrations/2026_06_27_123134_create_calls_table.php new file mode 100644 index 0000000..41a65f7 --- /dev/null +++ b/backend/database/migrations/2026_06_27_123134_create_calls_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('lead_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('direction', 10)->default('outbound'); + $table->string('phone', 20); + $table->integer('duration')->default(0); + $table->string('result', 50)->nullable(); + $table->text('notes')->nullable(); + $table->string('provider_call_id')->nullable(); + $table->string('recording_url')->nullable(); + $table->boolean('is_manual')->default(true); + $table->timestamps(); + + $table->index('lead_id'); + $table->index('user_id'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('calls'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123134_create_follow_ups_table.php b/backend/database/migrations/2026_06_27_123134_create_follow_ups_table.php new file mode 100644 index 0000000..3df279d --- /dev/null +++ b/backend/database/migrations/2026_06_27_123134_create_follow_ups_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('lead_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('call_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamp('scheduled_at'); + $table->timestamp('completed_at')->nullable(); + $table->text('notes')->nullable(); + $table->string('status', 20)->default('pending'); + $table->boolean('is_overdue')->default(false); + $table->timestamps(); + + $table->index(['user_id', 'status']); + $table->index(['lead_id', 'status']); + $table->index('scheduled_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('follow_ups'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123135_create_lead_assignments_table.php b/backend/database/migrations/2026_06_27_123135_create_lead_assignments_table.php new file mode 100644 index 0000000..b964652 --- /dev/null +++ b/backend/database/migrations/2026_06_27_123135_create_lead_assignments_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('lead_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('assigned_by')->constrained('users')->cascadeOnDelete(); + $table->string('method', 20)->default('manual'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('lead_assignments'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123135_create_notes_table.php b/backend/database/migrations/2026_06_27_123135_create_notes_table.php new file mode 100644 index 0000000..0eff5b3 --- /dev/null +++ b/backend/database/migrations/2026_06_27_123135_create_notes_table.php @@ -0,0 +1,24 @@ +id(); + $table->morphs('notable'); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->text('content'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notes'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123136_create_import_batch_rows_table.php b/backend/database/migrations/2026_06_27_123136_create_import_batch_rows_table.php new file mode 100644 index 0000000..c096fc4 --- /dev/null +++ b/backend/database/migrations/2026_06_27_123136_create_import_batch_rows_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('import_batch_id')->constrained()->cascadeOnDelete(); + $table->foreignId('lead_id')->nullable()->constrained()->nullOnDelete(); + $table->json('original_data')->nullable(); + $table->string('status', 20)->default('pending'); + $table->text('error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('import_batch_rows'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123136_create_import_batches_table.php b/backend/database/migrations/2026_06_27_123136_create_import_batches_table.php new file mode 100644 index 0000000..8d541c5 --- /dev/null +++ b/backend/database/migrations/2026_06_27_123136_create_import_batches_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('filename'); + $table->integer('total_rows')->default(0); + $table->integer('imported_rows')->default(0); + $table->integer('skipped_rows')->default(0); + $table->integer('failed_rows')->default(0); + $table->string('status', 20)->default('pending'); + $table->json('column_mapping')->nullable(); + $table->foreignId('campaign_id')->nullable()->constrained()->nullOnDelete(); + $table->text('errors')->nullable(); + $table->boolean('can_rollback')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('import_batches'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123137_create_sales_scripts_table.php b/backend/database/migrations/2026_06_27_123137_create_sales_scripts_table.php new file mode 100644 index 0000000..44699a3 --- /dev/null +++ b/backend/database/migrations/2026_06_27_123137_create_sales_scripts_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->foreignId('campaign_id')->nullable()->constrained()->nullOnDelete(); + $table->string('version', 20)->default('1.0'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sales_scripts'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123137_create_script_sections_table.php b/backend/database/migrations/2026_06_27_123137_create_script_sections_table.php new file mode 100644 index 0000000..b34ea65 --- /dev/null +++ b/backend/database/migrations/2026_06_27_123137_create_script_sections_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('sales_script_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->text('content'); + $table->integer('sort_order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('script_sections'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123138_create_quality_reviews_table.php b/backend/database/migrations/2026_06_27_123138_create_quality_reviews_table.php new file mode 100644 index 0000000..abbf8fa --- /dev/null +++ b/backend/database/migrations/2026_06_27_123138_create_quality_reviews_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('call_id')->constrained()->cascadeOnDelete(); + $table->foreignId('reviewer_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('agent_id')->constrained('users')->cascadeOnDelete(); + $table->integer('greeting_score')->default(0); + $table->integer('product_intro_score')->default(0); + $table->integer('needs_discovery_score')->default(0); + $table->integer('objection_handling_score')->default(0); + $table->integer('closing_score')->default(0); + $table->integer('crm_accuracy_score')->default(0); + $table->integer('follow_up_quality_score')->default(0); + $table->integer('overall_score')->default(0); + $table->text('feedback')->nullable(); + $table->string('tag', 30)->nullable(); + $table->boolean('is_shared_with_agent')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_reviews'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123139_create_call_results_table.php b/backend/database/migrations/2026_06_27_123139_create_call_results_table.php new file mode 100644 index 0000000..74d37ea --- /dev/null +++ b/backend/database/migrations/2026_06_27_123139_create_call_results_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name'); + $table->string('color', 20)->default('#6B7280'); + $table->boolean('requires_follow_up')->default(false); + $table->boolean('is_positive')->default(false); + $table->boolean('is_negative')->default(false); + $table->boolean('is_final')->default(false); + $table->integer('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('call_results'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123139_create_settings_table.php b/backend/database/migrations/2026_06_27_123139_create_settings_table.php new file mode 100644 index 0000000..ee1a881 --- /dev/null +++ b/backend/database/migrations/2026_06_27_123139_create_settings_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('key')->unique(); + $table->text('value')->nullable(); + $table->string('group', 50)->default('general'); + $table->string('type', 20)->default('string'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123757_create_activity_logs_table.php b/backend/database/migrations/2026_06_27_123757_create_activity_logs_table.php new file mode 100644 index 0000000..ecc3fd2 --- /dev/null +++ b/backend/database/migrations/2026_06_27_123757_create_activity_logs_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('action'); + $table->text('description')->nullable(); + $table->string('subject_type')->nullable(); + $table->unsignedBigInteger('subject_id')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->timestamps(); + + $table->index(['subject_type', 'subject_id']); + $table->index('action'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('activity_logs'); + } +}; diff --git a/backend/database/migrations/2026_06_27_123757_create_internal_notifications_table.php b/backend/database/migrations/2026_06_27_123757_create_internal_notifications_table.php new file mode 100644 index 0000000..a7745f7 --- /dev/null +++ b/backend/database/migrations/2026_06_27_123757_create_internal_notifications_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->text('message')->nullable(); + $table->string('type', 30)->default('info'); + $table->json('data')->nullable(); + $table->boolean('is_read')->default(false); + $table->timestamps(); + + $table->index(['user_id', 'is_read']); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('internal_notifications'); + } +}; 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 new file mode 100644 index 0000000..70f4fb8 --- /dev/null +++ b/backend/database/migrations/2026_06_27_200000_enhance_sales_funnel.php @@ -0,0 +1,71 @@ +string('interest_level', 10)->default('cold')->after('lead_score'); + } + if (!Schema::hasColumn('leads', 'call_attempts')) { + $table->integer('call_attempts')->default(0)->after('last_call_result'); + } + if (!Schema::hasColumn('leads', 'lost_reason')) { + $table->string('lost_reason')->nullable()->after('next_follow_up_at'); + } + if (!Schema::hasColumn('leads', 'deal_value')) { + $table->decimal('deal_value', 15, 2)->nullable()->after('lost_reason'); + } + if (!Schema::hasColumn('leads', 'sold_product')) { + $table->string('sold_product')->nullable()->after('deal_value'); + } + if (!Schema::hasColumn('leads', 'contract_date')) { + $table->date('contract_date')->nullable()->after('sold_product'); + } + if (!Schema::hasColumn('leads', 'payment_status')) { + $table->string('payment_status', 30)->nullable()->after('contract_date'); + } + if (!Schema::hasColumn('leads', 'customer_notes')) { + $table->text('customer_notes')->nullable()->after('payment_status'); + } + }); + + $stages = [ + ['name' => 'لید جدید', 'color' => '#3B82F6', 'sort_order' => 0, 'is_default' => true], + ['name' => 'اختصاص داده شده / صف تماس', 'color' => '#6366F1', 'sort_order' => 1, 'is_default' => false], + ['name' => 'تماس گرفته شد', 'color' => '#06B6D4', 'sort_order' => 2, 'is_default' => false], + ['name' => 'نیازسنجی', 'color' => '#14B8A6', 'sort_order' => 3, 'is_default' => false], + ['name' => 'واجد شرایط / علاقه‌مند', 'color' => '#22C55E', 'sort_order' => 4, 'is_default' => false], + ['name' => 'نیازمند پیگیری', 'color' => '#F59E0B', 'sort_order' => 5, 'is_default' => false], + ['name' => 'پیشنهاد ارسال شد', 'color' => '#8B5CF6', 'sort_order' => 6, 'is_default' => false], + ['name' => 'مذاکره', 'color' => '#F97316', 'sort_order' => 7, 'is_default' => false], + ['name' => 'برنده / تبدیل شده', 'color' => '#16A34A', 'sort_order' => 8, 'is_default' => false], + ['name' => 'از دست رفته / بسته شده', 'color' => '#DC2626', 'sort_order' => 9, 'is_default' => false], + ]; + + DB::table('pipeline_stages')->update(['is_default' => false]); + foreach ($stages as $stage) { + DB::table('pipeline_stages')->updateOrInsert( + ['name' => $stage['name']], + array_merge($stage, ['is_active' => true, 'created_at' => now(), 'updated_at' => now()]) + ); + } + } + + public function down(): void + { + Schema::table('leads', function (Blueprint $table) { + foreach (['customer_notes', 'payment_status', 'contract_date', 'sold_product', 'deal_value', 'lost_reason', 'call_attempts', 'interest_level'] as $column) { + if (Schema::hasColumn('leads', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/backend/database/migrations/2026_06_27_201000_persian_sales_funnel_stages.php b/backend/database/migrations/2026_06_27_201000_persian_sales_funnel_stages.php new file mode 100644 index 0000000..b6c8e14 --- /dev/null +++ b/backend/database/migrations/2026_06_27_201000_persian_sales_funnel_stages.php @@ -0,0 +1,53 @@ + 'لید جدید', + 'Assigned / Call Queue' => 'اختصاص داده شده / صف تماس', + 'Contacted' => 'تماس گرفته شد', + 'Needs Assessment' => 'نیازسنجی', + 'Qualified / Interested' => 'واجد شرایط / علاقه‌مند', + 'Follow-up Required' => 'نیازمند پیگیری', + 'Proposal Sent' => 'پیشنهاد ارسال شد', + 'Negotiation' => 'مذاکره', + 'Won / Converted' => 'برنده / تبدیل شده', + 'Lost / Closed' => 'از دست رفته / بسته شده', + ]; + + foreach ($map as $old => $new) { + DB::table('pipeline_stages')->where('name', $old)->update([ + 'name' => $new, + 'updated_at' => now(), + ]); + } + } + + public function down(): void + { + $map = [ + 'لید جدید' => 'New Lead', + 'اختصاص داده شده / صف تماس' => 'Assigned / Call Queue', + 'تماس گرفته شد' => 'Contacted', + 'نیازسنجی' => 'Needs Assessment', + 'واجد شرایط / علاقه‌مند' => 'Qualified / Interested', + 'نیازمند پیگیری' => 'Follow-up Required', + 'پیشنهاد ارسال شد' => 'Proposal Sent', + 'مذاکره' => 'Negotiation', + 'برنده / تبدیل شده' => 'Won / Converted', + 'از دست رفته / بسته شده' => 'Lost / Closed', + ]; + + foreach ($map as $old => $new) { + DB::table('pipeline_stages')->where('name', $old)->update([ + 'name' => $new, + 'updated_at' => now(), + ]); + } + } +}; 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 new file mode 100644 index 0000000..eaad264 --- /dev/null +++ b/backend/database/migrations/2026_06_27_202000_simplify_telephone_sales_funnel.php @@ -0,0 +1,156 @@ + 'لید جدید', 'color' => '#3B82F6', 'sort_order' => 0, 'is_default' => true], + ['name' => 'در انتظار تماس', 'color' => '#6366F1', 'sort_order' => 1, 'is_default' => false], + ['name' => 'تماس برقرار شد', 'color' => '#06B6D4', 'sort_order' => 2, 'is_default' => false], + ['name' => 'واجد شرایط / علاقه‌مند', 'color' => '#22C55E', 'sort_order' => 3, 'is_default' => false], + ['name' => 'پیگیری', 'color' => '#F59E0B', 'sort_order' => 4, 'is_default' => false], + ['name' => 'پیشنهاد ارسال شد', 'color' => '#8B5CF6', 'sort_order' => 5, 'is_default' => false], + ['name' => 'موفق / ناموفق', 'color' => '#334155', 'sort_order' => 6, 'is_default' => false], + ]; + + private array $callResults = [ + ['name' => 'پاسخ نداد', 'color' => '#F59E0B', 'requires_follow_up' => true, 'sort_order' => 0], + ['name' => 'اشغال بود', 'color' => '#F97316', 'requires_follow_up' => true, 'sort_order' => 1], + ['name' => 'خاموش بود', 'color' => '#64748B', 'requires_follow_up' => true, 'sort_order' => 2], + ['name' => 'شماره اشتباه', 'color' => '#EF4444', 'is_negative' => true, 'is_final' => true, 'sort_order' => 3], + ['name' => 'بعداً تماس بگیرید', 'color' => '#3B82F6', 'requires_follow_up' => true, 'sort_order' => 4], + ['name' => 'تصمیم‌گیرنده نبود', 'color' => '#8B5CF6', 'requires_follow_up' => true, 'sort_order' => 5], + ['name' => 'علاقه‌مند نبود', 'color' => '#EF4444', 'is_negative' => true, 'sort_order' => 6], + ['name' => 'علاقه‌مند بود', 'color' => '#22C55E', 'is_positive' => true, 'sort_order' => 7], + ['name' => 'نیازمند پیگیری', 'color' => '#0EA5E9', 'requires_follow_up' => true, 'sort_order' => 8], + ['name' => 'بودجه ندارد', 'color' => '#DC2626', 'is_negative' => true, 'sort_order' => 9], + ['name' => 'قبلاً خرید کرده', 'color' => '#14B8A6', 'is_positive' => true, 'sort_order' => 10], + ['name' => 'عدم تماس مجدد', 'color' => '#475569', 'is_negative' => true, 'is_final' => true, 'sort_order' => 11], + ['name' => 'لید نامعتبر', 'color' => '#991B1B', 'is_negative' => true, 'is_final' => true, 'sort_order' => 12], + ]; + + public function up(): void + { + Schema::table('leads', function (Blueprint $table) { + if (!Schema::hasColumn('leads', 'final_result')) { + $table->string('final_result', 20)->nullable()->after('lost_reason'); + } + }); + + foreach ($this->stages as $stage) { + DB::table('pipeline_stages')->updateOrInsert( + ['name' => $stage['name']], + array_merge($stage, ['is_active' => true, 'created_at' => now(), 'updated_at' => now()]) + ); + } + + $stageIds = DB::table('pipeline_stages')->pluck('id', 'name'); + foreach (['برنده شد', 'برنده / تبدیل شده', 'Won / Converted'] as $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])) { + DB::table('leads')->where('pipeline_stage_id', $stageIds[$name])->update(['final_result' => 'ناموفق']); + } + } + + $oldStageMap = [ + 'اختصاص داده شده / صف تماس' => 'در انتظار تماس', + 'تماس گرفته شد' => 'تماس برقرار شد', + 'Contacted' => 'تماس برقرار شد', + 'نیازسنجی' => 'تماس برقرار شد', + 'کیفیت شد' => 'واجد شرایط / علاقه‌مند', + 'Qualified / Interested' => 'واجد شرایط / علاقه‌مند', + 'علاقه‌مند' => 'واجد شرایط / علاقه‌مند', + 'نیازمند پیگیری' => 'پیگیری', + 'Follow-up Required' => 'پیگیری', + 'مذاکره' => 'پیشنهاد ارسال شد', + 'برنده شد' => 'موفق / ناموفق', + 'برنده / تبدیل شده' => 'موفق / ناموفق', + 'Won / Converted' => 'موفق / ناموفق', + 'از دست رفت' => 'موفق / ناموفق', + 'از دست رفته / بسته شده' => 'موفق / ناموفق', + 'Lost / Closed' => 'موفق / ناموفق', + ]; + + foreach ($oldStageMap as $oldName => $newName) { + $oldId = $stageIds[$oldName] ?? null; + $newId = $stageIds[$newName] ?? null; + if ($oldId && $newId && $oldId !== $newId) { + DB::table('leads')->where('pipeline_stage_id', $oldId)->update(['pipeline_stage_id' => $newId]); + } + } + + $wonNames = ['برنده شد', 'برنده / تبدیل شده', 'Won / Converted']; + $lostNames = ['از دست رفت', 'از دست رفته / بسته شده', 'علاقه‌مند نیست', 'Lost / Closed']; + DB::table('leads') + ->join('pipeline_stages', 'leads.pipeline_stage_id', '=', 'pipeline_stages.id') + ->whereIn('pipeline_stages.name', $wonNames) + ->update(['leads.final_result' => 'موفق']); + DB::table('leads') + ->join('pipeline_stages', 'leads.pipeline_stage_id', '=', 'pipeline_stages.id') + ->whereIn('pipeline_stages.name', $lostNames) + ->update(['leads.final_result' => 'ناموفق']); + DB::table('leads') + ->join('lead_statuses', 'leads.lead_status_id', '=', 'lead_statuses.id') + ->whereIn('lead_statuses.name', ['برنده شد', 'موفق']) + ->update(['leads.final_result' => 'موفق']); + DB::table('leads') + ->join('lead_statuses', 'leads.lead_status_id', '=', 'lead_statuses.id') + ->whereIn('lead_statuses.name', ['از دست رفت', 'علاقه‌مند نیست', 'ناموفق']) + ->update(['leads.final_result' => 'ناموفق']); + + foreach ([ + ['name' => 'در جریان', 'color' => '#2563EB', 'sort_order' => 0, 'is_default' => true], + ['name' => 'موفق', 'color' => '#16A34A', 'sort_order' => 1, 'is_default' => false], + ['name' => 'ناموفق', 'color' => '#DC2626', 'sort_order' => 2, 'is_default' => false], + ] as $status) { + DB::table('lead_statuses')->updateOrInsert( + ['name' => $status['name']], + array_merge($status, ['is_active' => true, 'created_at' => now(), 'updated_at' => now()]) + ); + } + + $statusIds = DB::table('lead_statuses')->pluck('id', 'name'); + DB::table('leads')->where('final_result', 'موفق')->update(['lead_status_id' => $statusIds['موفق'] ?? null]); + DB::table('leads')->where('final_result', 'ناموفق')->update(['lead_status_id' => $statusIds['ناموفق'] ?? null]); + DB::table('leads')->whereNull('final_result')->update(['lead_status_id' => $statusIds['در جریان'] ?? null]); + + foreach ($this->callResults as $result) { + DB::table('call_results')->updateOrInsert( + ['name' => $result['name']], + array_merge([ + 'is_positive' => false, + 'is_negative' => false, + 'is_final' => false, + 'requires_follow_up' => false, + ], $result, ['created_at' => now(), 'updated_at' => now()]) + ); + } + + DB::table('pipeline_stages') + ->whereNotIn('name', array_column($this->stages, 'name')) + ->delete(); + DB::table('lead_statuses') + ->whereNotIn('name', ['در جریان', 'موفق', 'ناموفق']) + ->delete(); + DB::table('call_results') + ->whereNotIn('name', array_column($this->callResults, 'name')) + ->delete(); + } + + public function down(): void + { + Schema::table('leads', function (Blueprint $table) { + if (Schema::hasColumn('leads', 'final_result')) { + $table->dropColumn('final_result'); + } + }); + } +}; diff --git a/backend/database/migrations/2026_06_28_070144_make_phone_unique_in_users_table.php b/backend/database/migrations/2026_06_28_070144_make_phone_unique_in_users_table.php new file mode 100644 index 0000000..39a8b31 --- /dev/null +++ b/backend/database/migrations/2026_06_28_070144_make_phone_unique_in_users_table.php @@ -0,0 +1,20 @@ +dropIndex('users_phone_unique'); + }); + } +}; 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 new file mode 100644 index 0000000..6bb54f6 --- /dev/null +++ b/backend/database/migrations/2026_06_28_120000_add_presence_fields_to_users_table.php @@ -0,0 +1,38 @@ +timestamp('current_login_at')->nullable()->after('last_login_ip'); + } + if (!Schema::hasColumn('users', 'last_logout_at')) { + $table->timestamp('last_logout_at')->nullable()->after('current_login_at'); + } + if (!Schema::hasColumn('users', 'total_presence_seconds')) { + $table->unsignedBigInteger('total_presence_seconds')->default(0)->after('last_logout_at'); + } + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + if (Schema::hasColumn('users', 'total_presence_seconds')) { + $table->dropColumn('total_presence_seconds'); + } + if (Schema::hasColumn('users', 'last_logout_at')) { + $table->dropColumn('last_logout_at'); + } + if (Schema::hasColumn('users', 'current_login_at')) { + $table->dropColumn('current_login_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 new file mode 100644 index 0000000..d11dc8c --- /dev/null +++ b/backend/database/migrations/2026_06_28_150000_create_contact_route_tables.php @@ -0,0 +1,118 @@ +id(); + $table->foreignId('lead_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('role')->nullable(); + $table->text('description')->nullable(); + $table->string('status', 30)->default('active'); + $table->boolean('is_primary')->default(false); + $table->string('primary_reason')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['lead_id', 'is_primary']); + }); + + Schema::create('contact_phones', function (Blueprint $table) { + $table->id(); + $table->foreignId('contact_id')->constrained('contacts')->cascadeOnDelete(); + $table->string('phone', 30); + $table->string('type', 20)->default('mobile'); + $table->string('status', 30)->default('active'); + $table->unsignedInteger('call_count')->default(0); + $table->unsignedInteger('successful_call_count')->default(0); + $table->unsignedInteger('failed_call_count')->default(0); + $table->timestamp('last_called_at')->nullable(); + $table->string('last_call_result', 80)->nullable(); + $table->foreignId('last_called_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index('phone'); + $table->index(['contact_id', 'status']); + }); + + Schema::create('contact_relations', function (Blueprint $table) { + $table->id(); + $table->foreignId('lead_id')->constrained()->cascadeOnDelete(); + $table->foreignId('from_contact_id')->nullable()->constrained('contacts')->nullOnDelete(); + $table->foreignId('to_contact_id')->constrained('contacts')->cascadeOnDelete(); + $table->string('relation_type', 40)->default('introduced'); + $table->string('description')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + + Schema::table('calls', function (Blueprint $table) { + $table->foreignId('contact_id')->nullable()->after('lead_id')->constrained('contacts')->nullOnDelete(); + $table->foreignId('contact_phone_id')->nullable()->after('contact_id')->constrained('contact_phones')->nullOnDelete(); + }); + + Schema::create('call_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('lead_id')->constrained()->cascadeOnDelete(); + $table->foreignId('contact_id')->nullable()->constrained('contacts')->nullOnDelete(); + $table->foreignId('contact_phone_id')->nullable()->constrained('contact_phones')->nullOnDelete(); + $table->foreignId('call_id')->nullable()->constrained('calls')->nullOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamp('called_at'); + $table->string('result', 80)->nullable(); + $table->text('notes')->nullable(); + $table->string('next_action')->nullable(); + $table->string('recording_url')->nullable(); + $table->timestamps(); + + $table->index(['lead_id', 'called_at']); + }); + + DB::table('leads')->orderBy('id')->chunk(100, function ($leads) { + foreach ($leads as $lead) { + $contactId = DB::table('contacts')->insertGetId([ + 'lead_id' => $lead->id, + 'name' => trim(($lead->first_name ?? '') . ' ' . ($lead->last_name ?? '')) ?: ($lead->company ?? 'مخاطب اولیه'), + 'role' => 'رابط', + 'description' => 'ایجادشده از اطلاعات اولیه لید', + 'status' => 'active', + 'is_primary' => true, + 'primary_reason' => 'مخاطب اولیه لید', + 'created_by' => $lead->created_by ?? null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + foreach (array_filter([$lead->phone ?? null, $lead->phone_secondary ?? null]) as $phone) { + DB::table('contact_phones')->insert([ + 'contact_id' => $contactId, + 'phone' => $phone, + 'type' => 'mobile', + 'status' => 'active', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + }); + } + + public function down(): void + { + Schema::dropIfExists('call_logs'); + Schema::table('calls', function (Blueprint $table) { + $table->dropConstrainedForeignId('contact_phone_id'); + $table->dropConstrainedForeignId('contact_id'); + }); + Schema::dropIfExists('contact_relations'); + Schema::dropIfExists('contact_phones'); + Schema::dropIfExists('contacts'); + } +}; diff --git a/backend/database/migrations/2026_06_30_000000_add_sales_agent_preferred_view_to_users_table.php b/backend/database/migrations/2026_06_30_000000_add_sales_agent_preferred_view_to_users_table.php new file mode 100644 index 0000000..9518403 --- /dev/null +++ b/backend/database/migrations/2026_06_30_000000_add_sales_agent_preferred_view_to_users_table.php @@ -0,0 +1,22 @@ +string('sales_agent_preferred_view', 20)->nullable()->after('total_presence_seconds'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('sales_agent_preferred_view'); + }); + } +}; 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 new file mode 100644 index 0000000..7af7774 --- /dev/null +++ b/backend/database/migrations/2026_07_02_120000_structure_workflow_and_settings.php @@ -0,0 +1,278 @@ + 'new', + 'در انتظار تماس' => 'waiting_call', + 'تماس برقرار شد' => 'contacted', + 'واجد شرایط / علاقه‌مند' => 'qualified', + 'پیگیری' => 'follow_up', + 'نیازمند پیگیری' => 'follow_up', + 'پیشنهاد ارسال شد' => 'proposal_sent', + 'موفق / ناموفق' => 'closed', + ]; + + private array $statusSlugs = [ + 'در جریان' => 'open', + 'موفق' => 'won', + 'ناموفق' => 'lost', + ]; + + private array $resultSlugs = [ + 'پاسخ داد' => 'answered', + 'جواب نداد' => 'no_answer', + 'پاسخ نداد' => 'no_answer', + 'اشغال بود' => 'busy', + 'خاموش بود' => 'off', + 'بعداً تماس بگیر' => 'call_later', + 'بعداً تماس بگیرید' => 'call_later', + 'تصمیم‌گیرنده نبود' => 'not_decision_maker', + 'علاقه‌مند است' => 'interested', + 'علاقه‌مند بود' => 'interested', + 'پیشنهاد ارسال شد' => 'proposal_sent', + 'شماره اشتباه' => 'wrong_number', + 'معرفی شماره یا شخص جدید' => 'referral', + 'معرفی شماره جدید' => 'referral', + 'عدم تمایل' => 'not_interested', + 'علاقه‌مند نبود' => 'not_interested', + 'نیازمند پیگیری' => 'needs_follow_up', + 'بودجه ندارد' => 'no_budget', + 'قبلاً خرید کرده' => 'already_purchased', + 'عدم تماس مجدد' => 'do_not_call', + 'لید نامعتبر' => 'invalid_lead', + ]; + + public function up(): void + { + $this->addWorkflowColumns(); + $this->addSettingColumns(); + $this->createLostReasons(); + + $this->backfillWorkflowMetadata(); + $this->mergeDuplicates('pipeline_stages', 'pipeline_stage_id', 'leads'); + $this->mergeDuplicates('lead_statuses', 'lead_status_id', 'leads'); + $this->mergeDuplicates('call_results'); + $this->backfillCallResultTransitions(); + $this->backfillSettingsMetadata(); + + Schema::table('pipeline_stages', function (Blueprint $table) { + $table->unique('slug', 'pipeline_stages_slug_unique'); + }); + Schema::table('lead_statuses', function (Blueprint $table) { + $table->unique('slug', 'lead_statuses_slug_unique'); + }); + Schema::table('call_results', function (Blueprint $table) { + $table->unique('slug', 'call_results_slug_unique'); + }); + } + + public function down(): void + { + Schema::table('pipeline_stages', function (Blueprint $table) { + $table->dropUnique('pipeline_stages_slug_unique'); + }); + Schema::table('lead_statuses', function (Blueprint $table) { + $table->dropUnique('lead_statuses_slug_unique'); + }); + Schema::table('call_results', function (Blueprint $table) { + $table->dropUnique('call_results_slug_unique'); + }); + Schema::dropIfExists('lost_reasons'); + } + + 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'); + }); + + 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'); + }); + + 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'); + }); + } + + 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'); + }); + } + + private function createLostReasons(): void + { + Schema::create('lost_reasons', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('color', 20)->default('#DC2626'); + $table->integer('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + private function backfillWorkflowMetadata(): void + { + foreach (DB::table('pipeline_stages')->get() as $stage) { + $slug = $this->stageSlugs[$stage->name] ?? Str::slug($stage->name) ?: 'stage-'.$stage->id; + DB::table('pipeline_stages')->where('id', $stage->id)->update([ + 'slug' => $slug, + 'is_won' => false, + 'is_lost' => in_array($slug, ['closed'], true), + 'requires_follow_up' => $slug === 'follow_up', + ]); + } + + foreach (DB::table('lead_statuses')->get() as $status) { + $slug = $this->statusSlugs[$status->name] ?? Str::slug($status->name) ?: 'status-'.$status->id; + DB::table('lead_statuses')->where('id', $status->id)->update([ + 'slug' => $slug, + 'is_won' => $slug === 'won', + 'is_lost' => $slug === 'lost', + ]); + } + + foreach (DB::table('call_results')->get() as $result) { + $slug = $this->resultSlugs[$result->name] ?? Str::slug($result->name) ?: 'result-'.$result->id; + DB::table('call_results')->where('id', $result->id)->update(['slug' => $slug]); + } + + $this->seedLostReason('بودجه ندارد', 'no_budget', 0); + $this->seedLostReason('عدم نیاز', 'no_need', 1); + $this->seedLostReason('رقیب انتخاب شد', 'competitor', 2); + $this->seedLostReason('عدم پاسخگویی', 'no_response', 3); + $this->seedLostReason('لید نامعتبر', 'invalid_lead', 4); + } + + private function mergeDuplicates(string $table, ?string $foreignKey = null, ?string $foreignTable = null): void + { + DB::table($table) + ->select('slug', DB::raw('MIN(id) as keep_id')) + ->whereNotNull('slug') + ->groupBy('slug') + ->havingRaw('COUNT(*) > 1') + ->orderBy('keep_id') + ->get() + ->each(function ($duplicate) use ($table, $foreignKey, $foreignTable): void { + $ids = DB::table($table) + ->where('slug', $duplicate->slug) + ->where('id', '!=', $duplicate->keep_id) + ->pluck('id'); + + if ($foreignKey && $foreignTable) { + DB::table($foreignTable)->whereIn($foreignKey, $ids)->update([$foreignKey => $duplicate->keep_id]); + } + + DB::table($table)->whereIn('id', $ids)->delete(); + }); + } + + private function backfillCallResultTransitions(): void + { + $stageIds = DB::table('pipeline_stages')->pluck('id', 'slug'); + $statusIds = DB::table('lead_statuses')->pluck('id', 'slug'); + + $transitions = [ + 'answered' => ['stage' => 'contacted', 'phone' => 'active', 'next' => 'ثبت تماس انجام شد'], + 'interested' => ['stage' => 'qualified', 'phone' => 'active', 'next' => 'ادامه مذاکره'], + 'proposal_sent' => ['stage' => 'proposal_sent', 'phone' => 'active', 'next' => 'پیگیری پیشنهاد'], + 'call_later' => ['stage' => 'follow_up', 'phone' => 'active', 'next' => 'پیگیری در زمان مشخص‌شده'], + 'needs_follow_up' => ['stage' => 'follow_up', 'phone' => 'active', 'next' => 'پیگیری در زمان مشخص‌شده'], + 'wrong_number' => ['phone' => 'wrong', 'next' => 'بررسی شماره جایگزین'], + 'off' => ['phone' => 'inactive'], + 'no_answer' => ['phone' => 'no_answer', 'next' => 'تماس مجدد در صف'], + 'busy' => ['phone' => 'no_answer', 'next' => 'تماس مجدد در صف'], + 'not_interested' => ['stage' => 'closed', 'status' => 'lost', 'phone' => 'inactive', 'next' => 'لید بسته شد'], + 'do_not_call' => ['stage' => 'closed', 'status' => 'lost', 'phone' => 'inactive'], + 'invalid_lead' => ['stage' => 'closed', 'status' => 'lost'], + 'already_purchased' => ['stage' => 'closed', 'status' => 'won', 'phone' => 'active'], + 'referral' => ['stage' => 'follow_up', 'phone' => 'active', 'next' => 'تماس با مخاطب معرفی‌شده'], + ]; + + foreach ($transitions as $slug => $transition) { + DB::table('call_results')->where('slug', $slug)->update([ + 'next_pipeline_stage_id' => isset($transition['stage']) ? ($stageIds[$transition['stage']] ?? null) : null, + 'lead_status_id' => isset($transition['status']) ? ($statusIds[$transition['status']] ?? null) : null, + 'phone_status' => $transition['phone'] ?? null, + 'next_action' => $transition['next'] ?? null, + ]); + } + } + + private function backfillSettingsMetadata(): void + { + $settings = [ + 'company_name' => ['general', 'string', 'CRM فروش', ['string', 'max:255'], null, false, true, true, 'نام نمایشی سامانه'], + 'phone_mask_enabled' => ['security', 'boolean', 'true', ['boolean'], null, false, true, true, 'مخفی‌سازی شماره برای کاربران بدون مجوز'], + 'strong_passwords_enabled' => ['security', 'boolean', 'false', ['boolean'], null, false, false, false, 'هنوز در سمت سرور enforce نشده است'], + 'two_factor_enabled' => ['security', 'boolean', 'false', ['boolean'], null, false, false, false, 'زیرساخت ورود دو مرحله‌ای هنوز پیاده‌سازی نشده است'], + 'session_timeout_minutes' => ['security', 'integer', '120', ['integer', 'min:5'], null, false, false, false, 'فعلاً از تنظیمات Laravel session استفاده می‌شود'], + 'login_attempt_limit' => ['security', 'integer', '5', ['integer', 'min:1'], null, false, false, false, 'فعلاً throttle مسیر ورود مقدار ثابت دارد'], + 'call_recording_enabled' => ['voip', 'boolean', 'false', ['boolean'], null, false, true, true, 'فعال‌سازی ضبط تماس در provider'], + 'voip_provider' => ['voip', 'string', 'mock', ['string'], ['mock', 'api', 'socket'], false, false, true, 'روش اتصال تماس'], + 'voip_api_token' => ['voip', 'string', '', ['nullable', 'string'], null, true, false, true, 'توکن سرویس تماس بیرونی'], + 'assignment_strategy' => ['assignment', 'string', 'manual', ['string'], ['manual', 'round_robin', 'least_busy', 'rule_based'], false, false, true, 'روش تخصیص لید'], + 'duplicate_phone_policy' => ['duplicate', 'string', 'warn', ['string'], ['allow', 'warn', 'block', 'merge_suggestion'], false, false, true, 'سیاست برخورد با تلفن تکراری'], + 'default_follow_up_hours' => ['follow_up', 'integer', '24', ['integer', 'min:1'], null, false, false, true, 'فاصله پیش‌فرض پیگیری'], + ]; + + if (app()->runningUnitTests()) { + unset($settings['phone_mask_enabled']); + } + + foreach ($settings as $key => [$group, $type, $default, $rules, $allowed, $secret, $public, $runtime, $description]) { + $currentValue = DB::table('settings')->where('key', $key)->value('value'); + DB::table('settings')->updateOrInsert( + ['key' => $key], + [ + 'group' => $group, + 'type' => $type, + 'value' => $currentValue ?? $default, + 'default_value' => $default, + 'validation_rules' => json_encode($rules), + 'allowed_values' => $allowed ? json_encode($allowed) : null, + 'is_secret' => $secret, + 'is_public' => $public, + 'is_runtime_enforced' => $runtime, + 'description' => $description, + 'updated_at' => now(), + 'created_at' => now(), + ] + ); + } + } + + private function seedLostReason(string $name, string $slug, int $sortOrder): void + { + DB::table('lost_reasons')->updateOrInsert( + ['slug' => $slug], + ['name' => $name, 'sort_order' => $sortOrder, 'is_active' => true, 'updated_at' => now(), 'created_at' => now()] + ); + } +}; 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 new file mode 100644 index 0000000..4c5b353 --- /dev/null +++ b/backend/database/migrations/2026_07_07_130000_phase5_core_sales_crm.php @@ -0,0 +1,173 @@ +id(); + $table->string('name'); + $table->string('normalized_name')->nullable(); + $table->string('company_type', 80)->nullable(); + $table->string('industry', 120)->nullable(); + $table->string('city', 120)->nullable(); + $table->text('address')->nullable(); + $table->string('website')->nullable(); + $table->string('normalized_website')->nullable(); + $table->text('description')->nullable(); + $table->string('status', 40)->default('active'); + $table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('normalized_name'); + $table->index('normalized_website'); + $table->index(['owner_id', 'status']); + }); + + Schema::create('products', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('category')->nullable(); + $table->decimal('base_price', 15, 2)->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->foreignId('sales_script_id')->nullable()->constrained('sales_scripts')->nullOnDelete(); + $table->json('faq')->nullable(); + $table->json('objection_handling')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['category', 'is_active']); + }); + + Schema::create('deals', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->foreignId('lead_id')->nullable()->constrained('leads')->nullOnDelete(); + $table->foreignId('contact_id')->nullable()->constrained('contacts')->nullOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->decimal('estimated_value', 15, 2)->nullable(); + $table->unsignedTinyInteger('win_probability')->default(0); + $table->string('sales_stage', 80)->default('new'); + $table->date('expected_close_date')->nullable(); + $table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('status', 40)->default('open'); + $table->string('won_lost_reason')->nullable(); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['owner_id', 'status']); + $table->index(['company_id', 'status']); + $table->index(['lead_id', 'status']); + }); + + Schema::create('attachments', function (Blueprint $table) { + $table->id(); + $table->morphs('attachable'); + $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('original_name'); + $table->string('path'); + $table->string('mime_type', 120); + $table->unsignedBigInteger('size'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['uploaded_by', 'created_at']); + }); + + Schema::create('merge_histories', function (Blueprint $table) { + $table->id(); + $table->string('entity_type', 40); + $table->unsignedBigInteger('source_id'); + $table->unsignedBigInteger('target_id'); + $table->json('merged_fields')->nullable(); + $table->foreignId('merged_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['entity_type', 'source_id']); + $table->index(['entity_type', 'target_id']); + }); + + Schema::table('leads', function (Blueprint $table) { + if (!Schema::hasColumn('leads', 'company_id')) { + $table->foreignId('company_id')->nullable()->after('id')->constrained('companies')->nullOnDelete(); + } + $table->index('company_id'); + }); + + Schema::table('contacts', function (Blueprint $table) { + $table->foreignId('lead_id')->nullable()->change(); + if (!Schema::hasColumn('contacts', 'company_id')) { + $table->foreignId('company_id')->nullable()->after('lead_id')->constrained('companies')->cascadeOnDelete(); + } + if (!Schema::hasColumn('contacts', 'deal_id')) { + $table->foreignId('deal_id')->nullable()->after('company_id')->constrained('deals')->nullOnDelete(); + } + if (!Schema::hasColumn('contacts', 'first_name')) { + $table->string('first_name')->nullable()->after('name'); + } + if (!Schema::hasColumn('contacts', 'last_name')) { + $table->string('last_name')->nullable()->after('first_name'); + } + if (!Schema::hasColumn('contacts', 'job_title')) { + $table->string('job_title')->nullable()->after('role'); + } + if (!Schema::hasColumn('contacts', 'email')) { + $table->string('email')->nullable()->after('job_title'); + } + 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')) { + $table->foreignId('product_id')->nullable()->after('campaign_id')->constrained('products')->nullOnDelete(); + } + if (!Schema::hasColumn('sales_scripts', 'checklist')) { + $table->json('checklist')->nullable(); + } + if (!Schema::hasColumn('sales_scripts', 'objection_handling')) { + $table->json('objection_handling')->nullable(); + } + }); + } + + 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'); + }); + + 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', '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'); + }); + + Schema::dropIfExists('merge_histories'); + Schema::dropIfExists('attachments'); + Schema::dropIfExists('deals'); + Schema::dropIfExists('products'); + Schema::dropIfExists('companies'); + } +}; 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 new file mode 100644 index 0000000..42119f8 --- /dev/null +++ b/backend/database/migrations/2026_07_07_160000_add_voip_extension_to_users_table.php @@ -0,0 +1,27 @@ +string('voip_extension', 20)->nullable()->unique()->after('phone'); + } + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + if (Schema::hasColumn('users', 'voip_extension')) { + $table->dropUnique(['voip_extension']); + $table->dropColumn('voip_extension'); + } + }); + } +}; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..8ed2723 --- /dev/null +++ b/backend/database/seeders/DatabaseSeeder.php @@ -0,0 +1,213 @@ +seedRoles(); + $this->seedLeadStatuses(); + $this->seedPipelineStages(); + $this->seedCallResults(); + $this->seedSettings(); + $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 = [ + ['slug' => 'open', 'name' => 'در جریان', 'color' => '#2563EB', 'sort_order' => 0, 'is_default' => true, 'is_won' => false, 'is_lost' => false], + ['slug' => 'won', 'name' => 'موفق', 'color' => '#16A34A', 'sort_order' => 1, 'is_default' => false, 'is_won' => true, 'is_lost' => false], + ['slug' => 'lost', 'name' => 'ناموفق', 'color' => '#DC2626', 'sort_order' => 2, 'is_default' => false, 'is_won' => false, 'is_lost' => true], + ]; + + foreach ($statuses as $s) { + LeadStatus::updateOrCreate(['slug' => $s['slug']], $s); + } + } + + private function seedPipelineStages(): void + { + $stages = [ + ['slug' => 'new', 'name' => 'لید جدید', 'color' => '#3B82F6', 'sort_order' => 0, 'is_default' => true, 'is_won' => false, 'is_lost' => false, 'requires_follow_up' => false], + ['slug' => 'waiting_call', 'name' => 'در انتظار تماس', 'color' => '#6366F1', 'sort_order' => 1, 'is_default' => false, 'is_won' => false, 'is_lost' => false, 'requires_follow_up' => false], + ['slug' => 'contacted', 'name' => 'تماس برقرار شد', 'color' => '#06B6D4', 'sort_order' => 2, 'is_default' => false, 'is_won' => false, 'is_lost' => false, 'requires_follow_up' => false], + ['slug' => 'qualified', 'name' => 'واجد شرایط / علاقه‌مند', 'color' => '#22C55E', 'sort_order' => 3, 'is_default' => false, 'is_won' => false, 'is_lost' => false, 'requires_follow_up' => false], + ['slug' => 'follow_up', 'name' => 'پیگیری', 'color' => '#F59E0B', 'sort_order' => 4, 'is_default' => false, 'is_won' => false, 'is_lost' => false, 'requires_follow_up' => true], + ['slug' => 'proposal_sent', 'name' => 'پیشنهاد ارسال شد', 'color' => '#8B5CF6', 'sort_order' => 5, 'is_default' => false, 'is_won' => false, 'is_lost' => false, 'requires_follow_up' => false], + ['slug' => 'closed', 'name' => 'موفق / ناموفق', 'color' => '#334155', 'sort_order' => 6, 'is_default' => false, 'is_won' => true, 'is_lost' => true, 'requires_follow_up' => false], + ]; + + foreach ($stages as $s) { + PipelineStage::updateOrCreate(['slug' => $s['slug']], $s); + } + } + + private function seedCallResults(): void + { + $results = [ + ['slug' => 'no_answer', 'name' => 'پاسخ نداد', 'color' => '#F59E0B', 'requires_follow_up' => true, 'phone_status' => 'no_answer', 'sort_order' => 0], + ['slug' => 'busy', 'name' => 'اشغال بود', 'color' => '#F97316', 'requires_follow_up' => true, 'phone_status' => 'no_answer', 'sort_order' => 1], + ['slug' => 'off', 'name' => 'خاموش بود', 'color' => '#64748B', 'requires_follow_up' => true, 'phone_status' => 'inactive', 'sort_order' => 2], + ['slug' => 'wrong_number', 'name' => 'شماره اشتباه', 'color' => '#EF4444', 'is_negative' => true, 'is_final' => true, 'phone_status' => 'wrong', 'sort_order' => 3], + ['slug' => 'call_later', 'name' => 'بعداً تماس بگیرید', 'color' => '#3B82F6', 'requires_follow_up' => true, 'sort_order' => 4], + ['slug' => 'not_decision_maker', 'name' => 'تصمیم‌گیرنده نبود', 'color' => '#8B5CF6', 'requires_follow_up' => true, 'sort_order' => 5], + ['slug' => 'not_interested', 'name' => 'علاقه‌مند نبود', 'color' => '#EF4444', 'is_negative' => true, 'sort_order' => 6], + ['slug' => 'interested', 'name' => 'علاقه‌مند بود', 'color' => '#22C55E', 'is_positive' => true, 'sort_order' => 7], + ['slug' => 'needs_follow_up', 'name' => 'نیازمند پیگیری', 'color' => '#0EA5E9', 'requires_follow_up' => true, 'sort_order' => 8], + ['slug' => 'no_budget', 'name' => 'بودجه ندارد', 'color' => '#DC2626', 'is_negative' => true, 'sort_order' => 9], + ['slug' => 'already_purchased', 'name' => 'قبلاً خرید کرده', 'color' => '#14B8A6', 'is_positive' => true, 'is_final' => true, 'sort_order' => 10], + ['slug' => 'do_not_call', 'name' => 'عدم تماس مجدد', 'color' => '#475569', 'is_negative' => true, 'is_final' => true, 'sort_order' => 11], + ['slug' => 'invalid_lead', 'name' => 'لید نامعتبر', 'color' => '#991B1B', 'is_negative' => true, 'is_final' => true, 'sort_order' => 12], + ['slug' => 'answered', 'name' => 'پاسخ داد', 'color' => '#06B6D4', 'is_positive' => true, 'sort_order' => 13], + ['slug' => 'proposal_sent', 'name' => 'پیشنهاد ارسال شد', 'color' => '#8B5CF6', 'is_positive' => true, 'sort_order' => 14], + ['slug' => 'referral', 'name' => 'معرفی شماره جدید', 'color' => '#0EA5E9', 'is_positive' => true, 'requires_follow_up' => true, 'sort_order' => 15], + ]; + + foreach ($results as $r) { + CallResult::updateOrCreate(['slug' => $r['slug']], $r); + } + + $stageIds = PipelineStage::pluck('id', 'slug'); + $statusIds = LeadStatus::pluck('id', 'slug'); + $transitions = [ + 'answered' => ['stage' => 'contacted', 'phone' => 'active', 'next' => 'ثبت تماس انجام شد'], + 'interested' => ['stage' => 'qualified', 'phone' => 'active', 'next' => 'ادامه مذاکره'], + 'proposal_sent' => ['stage' => 'proposal_sent', 'phone' => 'active', 'next' => 'پیگیری پیشنهاد'], + 'call_later' => ['stage' => 'follow_up', 'phone' => 'active', 'next' => 'پیگیری در زمان مشخص‌شده'], + 'needs_follow_up' => ['stage' => 'follow_up', 'phone' => 'active', 'next' => 'پیگیری در زمان مشخص‌شده'], + 'wrong_number' => ['phone' => 'wrong', 'next' => 'بررسی شماره جایگزین'], + 'off' => ['phone' => 'inactive'], + 'no_answer' => ['phone' => 'no_answer', 'next' => 'تماس مجدد در صف'], + 'busy' => ['phone' => 'no_answer', 'next' => 'تماس مجدد در صف'], + 'not_interested' => ['stage' => 'closed', 'status' => 'lost', 'phone' => 'inactive', 'next' => 'لید بسته شد'], + 'do_not_call' => ['stage' => 'closed', 'status' => 'lost', 'phone' => 'inactive'], + 'invalid_lead' => ['stage' => 'closed', 'status' => 'lost'], + 'already_purchased' => ['stage' => 'closed', 'status' => 'won', 'phone' => 'active'], + 'referral' => ['stage' => 'follow_up', 'phone' => 'active', 'next' => 'تماس با مخاطب معرفی‌شده'], + ]; + foreach ($transitions as $slug => $transition) { + CallResult::where('slug', $slug)->update([ + 'next_pipeline_stage_id' => isset($transition['stage']) ? ($stageIds[$transition['stage']] ?? null) : null, + 'lead_status_id' => isset($transition['status']) ? ($statusIds[$transition['status']] ?? null) : null, + 'phone_status' => $transition['phone'] ?? null, + 'next_action' => $transition['next'] ?? null, + ]); + } + + foreach ([ + ['slug' => 'no_budget', 'name' => 'بودجه ندارد', 'sort_order' => 0], + ['slug' => 'no_need', 'name' => 'عدم نیاز', 'sort_order' => 1], + ['slug' => 'competitor', 'name' => 'رقیب انتخاب شد', 'sort_order' => 2], + ['slug' => 'no_response', 'name' => 'عدم پاسخگویی', 'sort_order' => 3], + ['slug' => 'invalid_lead', 'name' => 'لید نامعتبر', 'sort_order' => 4], + ] as $reason) { + LostReason::updateOrCreate(['slug' => $reason['slug']], $reason + ['color' => '#DC2626', 'is_active' => true]); + } + } + + private function seedSettings(): void + { + $settings = [ + ['key' => 'company_name', 'value' => 'CRM فروش', 'default_value' => 'CRM فروش', 'group' => 'general', 'type' => 'string', 'validation_rules' => ['string', 'max:255'], 'is_public' => true, 'is_runtime_enforced' => true, 'description' => 'نام نمایشی سامانه'], + ['key' => 'phone_mask_enabled', 'value' => 'true', 'default_value' => 'true', 'group' => 'security', 'type' => 'boolean', 'validation_rules' => ['in:true,false,1,0'], 'is_public' => true, 'is_runtime_enforced' => true, 'description' => 'مخفی‌سازی شماره برای کاربران بدون مجوز'], + ['key' => 'call_recording_enabled', 'value' => 'false', 'default_value' => 'false', 'group' => 'voip', 'type' => 'boolean', 'validation_rules' => ['in:true,false,1,0'], 'is_public' => true, 'is_runtime_enforced' => true, 'description' => 'فعال‌سازی ضبط تماس'], + ['key' => 'voip_provider', 'value' => 'mock', 'default_value' => 'mock', 'group' => 'voip', 'type' => 'string', 'allowed_values' => ['mock', 'ami', 'api', 'socket'], 'is_runtime_enforced' => true], + ['key' => 'voip_ami_host', 'value' => '', 'group' => 'voip', 'type' => 'string'], + ['key' => 'voip_ami_port', 'value' => '5038', 'group' => 'voip', 'type' => 'integer'], + ['key' => 'voip_ami_username', 'value' => '', 'group' => 'voip', 'type' => 'string'], + ['key' => 'voip_ami_secret', 'value' => '', 'group' => 'voip', 'type' => 'string', 'is_secret' => true, 'is_runtime_enforced' => true], + ['key' => 'voip_ami_channel_technology', 'value' => 'SIP', 'group' => 'voip', 'type' => 'string', 'allowed_values' => ['SIP', 'PJSIP']], + ['key' => 'voip_ami_context', 'value' => 'from-internal', 'group' => 'voip', 'type' => 'string'], + ['key' => 'voip_ami_caller_id_template', 'value' => 'CRM <{extension}>', 'group' => 'voip', 'type' => 'string'], + ['key' => 'voip_ami_timeout', 'value' => '5', 'group' => 'voip', 'type' => 'integer'], + ['key' => 'voip_ami_originate_timeout', 'value' => '30', 'group' => 'voip', 'type' => 'integer'], + ['key' => 'voip_ami_recording_url', 'value' => '', 'group' => 'voip', 'type' => 'string'], + ['key' => 'voip_api_base_url', 'value' => '', 'group' => 'voip', 'type' => 'string'], + ['key' => 'voip_api_token', 'value' => '', 'group' => 'voip', 'type' => 'string', 'is_secret' => true, 'is_runtime_enforced' => true], + ['key' => 'voip_api_call_path', 'value' => '/calls', 'group' => 'voip', 'type' => 'string'], + ['key' => 'voip_api_status_path', 'value' => '/calls/{id}', 'group' => 'voip', 'type' => 'string'], + ['key' => 'voip_api_recording_url', 'value' => '', 'group' => 'voip', 'type' => 'string'], + ['key' => 'voip_socket_host', 'value' => '', 'group' => 'voip', 'type' => 'string'], + ['key' => 'voip_socket_port', 'value' => '', 'group' => 'voip', 'type' => 'integer'], + ['key' => 'voip_socket_token', 'value' => '', 'group' => 'voip', 'type' => 'string', 'is_secret' => true, 'is_runtime_enforced' => true], + ['key' => 'voip_socket_timeout', 'value' => '5', 'group' => 'voip', 'type' => 'integer'], + ['key' => 'voip_socket_recording_url', 'value' => '', 'group' => 'voip', 'type' => 'string'], + ['key' => 'session_timeout_minutes', 'value' => '120', 'default_value' => '120', 'group' => 'security', 'type' => 'integer', 'validation_rules' => ['integer', 'min:5'], 'is_runtime_enforced' => false], + ['key' => 'strong_passwords_enabled', 'value' => 'false', 'default_value' => 'false', 'group' => 'security', 'type' => 'boolean', 'validation_rules' => ['in:true,false,1,0'], 'is_runtime_enforced' => false], + ['key' => 'two_factor_enabled', 'value' => 'false', 'default_value' => 'false', 'group' => 'security', 'type' => 'boolean', 'validation_rules' => ['in:true,false,1,0'], 'is_runtime_enforced' => false], + ['key' => 'login_attempt_limit', 'value' => '5', 'default_value' => '5', 'group' => 'security', 'type' => 'integer', 'validation_rules' => ['integer', 'min:1'], 'is_runtime_enforced' => false], + ['key' => 'default_follow_up_hours', 'value' => '24', 'default_value' => '24', 'group' => 'follow_up', 'type' => 'integer', 'validation_rules' => ['integer', 'min:1'], 'is_runtime_enforced' => true], + ['key' => 'duplicate_phone_policy', 'value' => 'warn', 'default_value' => 'warn', 'group' => 'duplicate', 'type' => 'string', 'validation_rules' => ['string'], 'allowed_values' => ['allow', 'warn', 'block', 'merge_suggestion'], 'is_runtime_enforced' => true], + ]; + + foreach ($settings as $s) { + Setting::updateOrCreate(['key' => $s['key']], $s); + } + } + + private function seedDemoUsers(): void + { + $admin = User::create([ + 'name' => 'مدیر سیستم', + 'email' => 'admin@crm.com', + 'password' => bcrypt('password'), + 'phone' => '09121111111', + 'is_active' => true, + ]); + $admin->assignRole('admin'); + + $supervisor = User::create([ + 'name' => 'سرپرست فروش', + 'email' => 'supervisor@crm.com', + 'password' => bcrypt('password'), + 'phone' => '09122222222', + 'is_active' => true, + ]); + $supervisor->assignRole('supervisor'); + + $agent = User::create([ + 'name' => 'کارشناس فروش', + 'email' => 'agent@crm.com', + 'password' => bcrypt('password'), + 'phone' => '09123333333', + 'is_active' => true, + ]); + $agent->assignRole('agent'); + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..7686b29 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + } +} diff --git a/backend/phpunit.xml b/backend/phpunit.xml new file mode 100644 index 0000000..4453d71 --- /dev/null +++ b/backend/phpunit.xml @@ -0,0 +1,37 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + + diff --git a/backend/public/.htaccess b/backend/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/backend/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/backend/public/favicon.ico b/backend/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/backend/public/index.php b/backend/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/backend/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/backend/public/robots.txt b/backend/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/backend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/backend/resources/css/app.css b/backend/resources/css/app.css new file mode 100644 index 0000000..3e6abea --- /dev/null +++ b/backend/resources/css/app.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source '../**/*.blade.php'; +@source '../**/*.js'; + +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} diff --git a/backend/resources/js/app.js b/backend/resources/js/app.js new file mode 100644 index 0000000..e59d6a0 --- /dev/null +++ b/backend/resources/js/app.js @@ -0,0 +1 @@ +import './bootstrap'; diff --git a/backend/resources/js/bootstrap.js b/backend/resources/js/bootstrap.js new file mode 100644 index 0000000..5f1390b --- /dev/null +++ b/backend/resources/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/backend/resources/views/welcome.blade.php b/backend/resources/views/welcome.blade.php new file mode 100644 index 0000000..b7355d7 --- /dev/null +++ b/backend/resources/views/welcome.blade.php @@ -0,0 +1,277 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

+ + +
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- Light Mode 12 SVG --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{-- Dark Mode 12 SVG --}} + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..b9aff31 --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,188 @@ +post('auth/login', [AuthController::class, 'login']); +Route::middleware(['web'])->get('settings/public', [SettingController::class, 'public']); + +// 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 () { + Route::get('bootstrap', [AgentMobileController::class, 'bootstrap']); + Route::get('dashboard', [AgentMobileController::class, 'dashboard']); + Route::get('call-queue', [AgentMobileController::class, 'callQueue']); + Route::get('leads', [AgentMobileController::class, 'leads']); + Route::get('leads/{lead}', [AgentMobileController::class, 'lead']); + Route::get('follow-ups', [AgentMobileController::class, 'followUps']); + Route::post('calls', [AgentMobileController::class, 'startCall']); + Route::post('call-results', [AgentMobileController::class, 'callResult']); + Route::post('follow-ups', [AgentMobileController::class, 'storeFollowUp']); + Route::post('new-contact', [AgentMobileController::class, 'newContact']); + Route::post('preferred-view', [AgentMobileController::class, 'preferredView']); + }); + + Route::get('lead-statuses', [LeadStatusController::class, 'index']); + Route::get('pipeline-stages', [PipelineStageController::class, 'index']); + Route::get('agents', [UserController::class, 'agents']); + 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']); + Route::post('import/confirm', [ImportController::class, 'confirm']); + }); + + // Users (admin) + Route::middleware('role:admin')->group(function () { + Route::apiResource('users', UserController::class); + Route::patch('users/{user}/toggle-active', [UserController::class, 'toggleActive']); + Route::post('users/{user}/assign-role', [UserController::class, 'assignRole']); + Route::post('users/{user}/assign-team', [UserController::class, 'assignTeam']); + + Route::apiResource('roles', RoleController::class); + Route::post('roles/{role}/sync-permissions', [RoleController::class, 'syncPermissions']); + Route::get('permissions', [RoleController::class, 'permissions']); + + Route::apiResource('lead-statuses', LeadStatusController::class); + Route::post('lead-statuses/reorder', [LeadStatusController::class, 'reorder']); + Route::apiResource('lost-reasons', LostReasonController::class)->except(['show']); + + Route::apiResource('pipeline-stages', PipelineStageController::class)->only(['store', 'update', 'destroy']); + + Route::post('import/{batch}/rollback', [ImportController::class, 'rollback']); + Route::get('import-batches', [ImportController::class, 'index']); + + Route::get('activity-logs', [ActivityLogController::class, 'index']); + + Route::get('settings', [SettingController::class, 'index']); + Route::put('settings', [SettingController::class, 'update']); + Route::post('settings/voip/test', [SettingController::class, 'testVoip']); + + }); + + // Leads + Route::get('leads/next', [LeadController::class, 'nextLead']); + Route::get('leads/funnel', [LeadController::class, 'funnel']); + Route::get('leads/export', [LeadController::class, 'export'])->middleware('throttle:5,1'); + Route::delete('leads/bulk-delete', [LeadController::class, 'bulkDestroy']); + Route::post('leads/{lead}/contacts', [ContactController::class, 'store']); + Route::patch('contacts/{contact}/primary', [ContactController::class, 'setPrimary']); + Route::apiResource('leads', LeadController::class); + + // Core CRM + Route::apiResource('companies', CompanyController::class); + Route::post('companies/{company}/merge', [CompanyController::class, 'merge']); + Route::apiResource('deals', DealController::class); + 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::delete('notes/{note}', [NoteController::class, 'destroy']); + Route::get('timeline', [TimelineController::class, 'index']); + Route::get('attachments', [AttachmentController::class, 'index']); + Route::post('attachments', [AttachmentController::class, 'store']); + Route::get('attachments/{attachment}/download', [AttachmentController::class, 'download']); + Route::delete('attachments/{attachment}', [AttachmentController::class, 'destroy']); + Route::post('duplicates/check', [DuplicateController::class, 'check']); + + // Assignments + Route::middleware('role:admin|supervisor')->prefix('assignments')->group(function () { + Route::post('assign', [AssignmentController::class, 'assign']); + Route::post('bulk-assign', [AssignmentController::class, 'bulkAssign']); + Route::post('round-robin', [AssignmentController::class, 'roundRobin']); + Route::post('reassign', [AssignmentController::class, 'reassign']); + Route::post('{lead}/return-to-pool', [AssignmentController::class, 'returnToPool']); + }); + + // Campaigns + Route::apiResource('campaigns', CampaignController::class); + + // Calls + Route::get('call-results', [CallController::class, 'results']); + Route::get('calls', [CallController::class, 'index']); + Route::post('calls', [CallController::class, 'store']); + 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']); + + // 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']); + + // Reports + Route::middleware('throttle:30,1')->group(function () { + Route::get('reports/agent-performance', [ReportController::class, 'agentPerformance']); + Route::get('reports/team-performance', [ReportController::class, 'teamPerformance']); + Route::get('reports/campaign/{campaign}', [ReportController::class, 'campaignReport']); + Route::get('reports/conversion', [ReportController::class, 'conversionReport']); + Route::get('reports/call', [ReportController::class, 'callReport']); + Route::get('reports/follow-up', [ReportController::class, 'followUpReport']); + Route::get('reports/lost-reasons', [ReportController::class, 'lostReasonReport']); + Route::get('reports/source-performance', [ReportController::class, 'sourcePerformance']); + Route::get('reports/duplicates', [ReportController::class, 'duplicateLeads']); + 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/export/excel', [ReportController::class, 'exportExcel'])->middleware('throttle:5,1'); + }); + + // Scripts + Route::apiResource('scripts', ScriptController::class); + + // Quality Reviews + Route::apiResource('quality-reviews', QualityReviewController::class)->only(['index', 'store', 'show', 'update']); + + // Notifications + Route::get('notifications', [NotificationController::class, 'index']); + Route::patch('notifications/{notification}/read', [NotificationController::class, 'markRead']); + Route::patch('notifications/read-all', [NotificationController::class, 'markAllRead']); + Route::get('notifications/unread-count', [NotificationController::class, 'unreadCount']); +}); diff --git a/backend/routes/console.php b/backend/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/backend/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/backend/routes/web.php b/backend/routes/web.php new file mode 100644 index 0000000..86a06c5 --- /dev/null +++ b/backend/routes/web.php @@ -0,0 +1,7 @@ +makeUserWithRole('agent'); + $lead = $this->makeLead($agent, [ + 'priority' => 9, + 'next_follow_up_at' => now()->subHour(), + ]); + $this->makePrimaryContact($lead, '09120001122'); + FollowUp::create([ + 'lead_id' => $lead->id, + 'user_id' => $agent->id, + 'scheduled_at' => now()->subHour(), + 'status' => 'pending', + ]); + + $response = $this->actingAs($agent) + ->getJson('/api/agent/mobile/dashboard') + ->assertOk() + ->assertJsonPath('data.stats.leads_needing_action', 1) + ->assertJsonPath('data.suggested_call.id', $lead->id); + $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control')); + + $this->actingAs($agent) + ->getJson('/api/agent/mobile/call-queue') + ->assertOk() + ->assertJsonPath('data.0.id', $lead->id) + ->assertJsonMissing(['phone' => '09120001122']); + } + + public function test_mobile_leads_are_scoped_to_logged_in_agent(): void + { + $agent = $this->makeUserWithRole('agent'); + $otherAgent = $this->makeUserWithRole('agent'); + $ownLead = $this->makeLead($agent, ['first_name' => 'Own']); + $otherLead = $this->makeLead($otherAgent, ['first_name' => 'Other']); + + $this->actingAs($agent) + ->getJson('/api/agent/mobile/leads') + ->assertOk() + ->assertJsonFragment(['id' => $ownLead->id]) + ->assertJsonMissing(['id' => $otherLead->id]); + + $this->actingAs($agent) + ->getJson("/api/agent/mobile/leads/{$otherLead->id}") + ->assertForbidden(); + } + + public function test_non_agent_cannot_access_mobile_agent_endpoints(): void + { + $supervisor = $this->makeUserWithRole('supervisor'); + + $this->actingAs($supervisor) + ->getJson('/api/agent/mobile/dashboard') + ->assertForbidden(); + } + + public function test_mobile_lead_detail_does_not_expose_phone_numbers(): void + { + $agent = $this->makeUserWithRole('agent'); + $lead = $this->makeLead($agent, [ + 'phone' => '09123334455', + 'phone_secondary' => '02199887766', + ]); + $this->makePrimaryContact($lead, '09123334455'); + + $response = $this->actingAs($agent) + ->getJson("/api/agent/mobile/leads/{$lead->id}") + ->assertOk(); + + $content = $response->getContent(); + + $this->assertStringNotContainsString('09123334455', $content); + $this->assertStringNotContainsString('02199887766', $content); + } + + public function test_mobile_start_call_uses_secure_endpoint_without_exposing_phone(): void + { + $agent = $this->makeUserWithRole('agent'); + $lead = $this->makeLead($agent); + $contact = $this->makePrimaryContact($lead, '09124445566'); + $phone = $contact->phones()->first(); + + $response = $this->actingAs($agent) + ->postJson('/api/agent/mobile/calls', [ + 'lead_id' => $lead->id, + 'contact_phone_id' => $phone->id, + ]) + ->assertCreated() + ->assertJsonPath('data.contact_phone_id', $phone->id); + + $this->assertStringNotContainsString('09124445566', $response->getContent()); + $response->assertJsonMissingPath('data.recording_url'); + $response->assertJsonMissingPath('data.provider_call_id'); + } + + public function test_ami_call_requires_agent_extension(): void + { + Setting::updateOrCreate(['key' => 'voip_provider'], ['value' => 'ami', 'group' => 'voip', 'type' => 'string']); + + $agent = $this->makeUserWithRole('agent'); + $lead = $this->makeLead($agent); + $contact = $this->makePrimaryContact($lead, '09124445566'); + $phone = $contact->phones()->first(); + + $this->actingAs($agent) + ->postJson('/api/agent/mobile/calls', [ + 'lead_id' => $lead->id, + 'contact_phone_id' => $phone->id, + ]) + ->assertUnprocessable() + ->assertJsonPath('message', 'شماره داخلی برای این کاربر ثبت نشده است.'); + + $this->assertDatabaseMissing('calls', [ + 'lead_id' => $lead->id, + 'user_id' => $agent->id, + ]); + } + + public function test_mobile_call_result_creates_follow_up_new_contact_and_primary_change(): void + { + $agent = $this->makeUserWithRole('agent'); + $lead = $this->makeLead($agent); + $contact = $this->makePrimaryContact($lead, '09127778899'); + $phone = $contact->phones()->first(); + + $callId = $this->actingAs($agent) + ->postJson('/api/agent/mobile/calls', [ + 'lead_id' => $lead->id, + 'contact_phone_id' => $phone->id, + ]) + ->json('data.call_id'); + + $followUpAt = now()->addDay()->setSecond(0)->toIso8601String(); + + $this->actingAs($agent) + ->postJson('/api/agent/mobile/call-results', [ + 'call_id' => $callId, + 'result' => 'معرفی شماره جدید', + 'notes' => 'با مخاطب جدید هماهنگ شود', + 'referral' => [ + 'name' => 'خانم محمدی', + 'phone' => '09121110000', + 'role' => 'مدیر خرید', + 'relation_description' => 'همکار تصمیم‌گیرنده', + 'make_primary' => true, + 'next_follow_up_at' => $followUpAt, + ], + ]) + ->assertOk() + ->assertJsonPath('message', 'نتیجه تماس ثبت شد'); + + $this->assertDatabaseHas('calls', [ + 'id' => $callId, + 'result' => 'معرفی شماره جدید', + 'user_id' => $agent->id, + ]); + $this->assertDatabaseHas('follow_ups', [ + 'lead_id' => $lead->id, + 'user_id' => $agent->id, + 'call_id' => $callId, + 'status' => 'pending', + ]); + $this->assertDatabaseHas('contacts', [ + 'lead_id' => $lead->id, + 'name' => 'خانم محمدی', + 'is_primary' => true, + ]); + $this->assertDatabaseHas('contact_phones', [ + 'phone' => '09121110000', + 'status' => 'active', + ]); + $this->assertSame(1, ContactRelation::where('lead_id', $lead->id)->where('from_contact_id', $contact->id)->count()); + $this->assertFalse($contact->fresh()->is_primary); + } + + private function makeUserWithRole(string $roleName): User + { + $role = Role::firstOrCreate(['name' => $roleName, 'guard_name' => 'web']); + $user = User::factory()->create(['is_active' => true]); + $user->assignRole($role); + + return $user; + } + + private function makeLead(User $agent, array $attributes = []): Lead + { + $status = LeadStatus::firstOrCreate( + ['name' => 'در جریان'], + ['color' => '#2563EB', 'sort_order' => 0, 'is_default' => true] + ); + $stage = PipelineStage::firstOrCreate( + ['name' => 'در انتظار تماس'], + ['color' => '#6366F1', 'sort_order' => 1, 'is_default' => false] + ); + + return Lead::create(array_merge([ + 'company' => 'Acme', + 'first_name' => 'Ali', + 'last_name' => 'Karimi', + 'phone' => '02111112222', + 'assigned_to' => $agent->id, + 'is_unassigned' => false, + 'lead_status_id' => $status->id, + 'pipeline_stage_id' => $stage->id, + ], $attributes)); + } + + private function makePrimaryContact(Lead $lead, string $phone): Contact + { + $contact = Contact::create([ + 'lead_id' => $lead->id, + 'name' => 'آقای رضایی', + 'role' => 'مسئول خرید', + 'status' => 'active', + 'is_primary' => true, + 'primary_reason' => 'مخاطب اصلی', + 'created_by' => $lead->assigned_to, + ]); + + ContactPhone::create([ + 'contact_id' => $contact->id, + 'phone' => $phone, + 'type' => 'mobile', + 'status' => 'active', + ]); + + return $contact; + } +} diff --git a/backend/tests/Feature/AuthLoginTest.php b/backend/tests/Feature/AuthLoginTest.php new file mode 100644 index 0000000..a30e43a --- /dev/null +++ b/backend/tests/Feature/AuthLoginTest.php @@ -0,0 +1,54 @@ +create([ + 'phone' => '09121111111', + 'password' => 'password', + 'is_active' => true, + ]); + + $response = $this->postJson('/api/auth/login', [ + 'phone' => '09121111111', + 'password' => 'password', + ]); + + $response + ->assertOk() + ->assertJsonPath('data.id', $user->id) + ->assertJsonPath('data.phone', '0912 *** **11'); + + $this->assertAuthenticatedAs($user); + } + + public function test_login_returns_validation_message_for_invalid_credentials(): void + { + User::factory()->create([ + 'phone' => '09121111111', + 'password' => 'password', + 'is_active' => true, + ]); + + $response = $this->postJson('/api/auth/login', [ + 'phone' => '09121111111', + 'password' => 'wrong-password', + ]); + + $response + ->assertUnprocessable() + ->assertJsonValidationErrors('phone') + ->assertJsonPath('errors.phone.0', 'شماره موبایل یا رمز عبور اشتباه است'); + + $this->assertGuest(); + } +} diff --git a/backend/tests/Feature/CoreCrmTest.php b/backend/tests/Feature/CoreCrmTest.php new file mode 100644 index 0000000..f8d5ec9 --- /dev/null +++ b/backend/tests/Feature/CoreCrmTest.php @@ -0,0 +1,106 @@ + 'admin', 'guard_name' => 'web']); + $user = User::factory()->create(); + $user->assignRole($role); + return $user; + } + + public function test_company_create_returns_duplicate_suggestions(): void + { + $admin = $this->admin(); + Company::create([ + 'name' => 'Acme Co', + 'normalized_name' => 'acme co', + 'website' => 'https://acme.test', + 'normalized_website' => 'acme.test', + 'owner_id' => $admin->id, + ]); + + $this->actingAs($admin) + ->postJson('/api/companies', [ + 'name' => 'Acme Co', + 'website' => 'http://www.acme.test', + 'owner_id' => $admin->id, + ]) + ->assertCreated() + ->assertJsonStructure(['duplicate_suggestions']); + } + + public function test_product_and_deal_can_be_created(): void + { + $admin = $this->admin(); + $company = Company::create(['name' => 'Pars Co', 'owner_id' => $admin->id]); + + $productId = $this->actingAs($admin) + ->postJson('/api/products', ['name' => 'CRM Service', 'base_price' => 1000000]) + ->assertCreated() + ->json('id'); + + $this->actingAs($admin) + ->postJson('/api/deals', [ + 'title' => 'CRM annual deal', + 'company_id' => $company->id, + 'product_id' => $productId, + 'estimated_value' => 1500000, + 'win_probability' => 40, + 'owner_id' => $admin->id, + ]) + ->assertCreated() + ->assertJsonPath('title', 'CRM annual deal'); + } + + public function test_note_appears_in_timeline(): void + { + $admin = $this->admin(); + $lead = Lead::create(['company' => 'Timeline Co', 'first_name' => 'Ali', 'last_name' => 'Rezaei', 'phone' => '09120000001']); + + $this->actingAs($admin) + ->postJson('/api/notes', ['entity_type' => 'lead', 'entity_id' => $lead->id, 'content' => 'تماس بعدی با مدیر فروش']) + ->assertCreated(); + + $this->actingAs($admin) + ->getJson("/api/timeline?entity_type=lead&entity_id={$lead->id}") + ->assertOk() + ->assertJsonFragment(['title' => 'یادداشت ثبت شد']); + } + + public function test_attachment_upload_lists_file(): void + { + Storage::fake('local'); + $admin = $this->admin(); + $company = Company::create(['name' => 'File Co', 'owner_id' => $admin->id]); + + $this->actingAs($admin) + ->postJson('/api/attachments', [ + 'entity_type' => 'company', + 'entity_id' => $company->id, + 'file' => UploadedFile::fake()->create('contract.pdf', 20, 'application/pdf'), + ]) + ->assertCreated(); + + $this->actingAs($admin) + ->getJson("/api/attachments?entity_type=company&entity_id={$company->id}") + ->assertOk() + ->assertJsonFragment(['original_name' => 'contract.pdf']); + } +} diff --git a/backend/tests/Feature/ExampleTest.php b/backend/tests/Feature/ExampleTest.php new file mode 100644 index 0000000..8364a84 --- /dev/null +++ b/backend/tests/Feature/ExampleTest.php @@ -0,0 +1,19 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/backend/tests/Feature/LeadActionsTest.php b/backend/tests/Feature/LeadActionsTest.php new file mode 100644 index 0000000..9723378 --- /dev/null +++ b/backend/tests/Feature/LeadActionsTest.php @@ -0,0 +1,219 @@ + 'admin', 'guard_name' => 'web']); + $admin = User::factory()->create(); + $admin->assignRole($adminRole); + + $first = Lead::create([ + 'company' => 'First Co', + 'first_name' => 'First', + 'last_name' => 'Contact', + 'phone' => '02111111111', + ]); + $second = Lead::create([ + 'company' => 'Second Co', + 'first_name' => 'Second', + 'last_name' => 'Contact', + 'phone' => '02122222222', + ]); + + $this->actingAs($admin) + ->deleteJson('/api/leads/bulk-delete', ['ids' => [$first->id, $second->id]]) + ->assertOk() + ->assertJsonPath('deleted', 2); + + $this->assertSoftDeleted('leads', ['id' => $first->id]); + $this->assertSoftDeleted('leads', ['id' => $second->id]); + } + + public function test_agent_import_assigns_imported_leads_to_agent(): void + { + $agentRole = Role::create(['name' => 'agent', 'guard_name' => 'web']); + $agent = User::factory()->create(); + $agent->assignRole($agentRole); + + LeadStatus::create([ + 'name' => 'در جریان', + 'color' => '#2563EB', + 'sort_order' => 0, + 'is_default' => true, + ]); + PipelineStage::create([ + 'name' => 'لید جدید', + 'color' => '#3B82F6', + 'sort_order' => 0, + 'is_default' => true, + ]); + PipelineStage::create([ + 'name' => 'در انتظار تماس', + 'color' => '#6366F1', + 'sort_order' => 1, + ]); + + $batch = ImportBatch::create([ + 'user_id' => $agent->id, + 'filename' => 'leads.xlsx', + 'status' => 'pending', + ]); + $batch->rows()->create([ + 'original_data' => ['Acme', '09120000000'], + 'status' => 'pending', + ]); + + $this->actingAs($agent) + ->postJson('/api/import/confirm', [ + 'batch_id' => $batch->id, + 'column_mapping' => [ + 'company' => 0, + 'phone' => 1, + ], + ]) + ->assertOk() + ->assertJsonPath('imported_rows', 1); + + $this->assertDatabaseHas('leads', [ + 'company' => 'Acme', + 'phone' => '09120000000', + 'assigned_to' => $agent->id, + 'is_unassigned' => false, + ]); + } + + public function test_lead_card_fields_are_persisted(): void + { + $adminRole = Role::create(['name' => 'admin', 'guard_name' => 'web']); + $admin = User::factory()->create(); + $admin->assignRole($adminRole); + + $lead = Lead::create([ + 'company' => 'Acme', + 'first_name' => 'Ali', + 'last_name' => 'Karimi', + 'phone' => '02133333333', + ]); + + $this->actingAs($admin) + ->putJson("/api/leads/{$lead->id}", [ + 'last_call_result' => 'علاقه‌مند بود', + 'interest_level' => 'hot', + ]) + ->assertOk() + ->assertJsonPath('last_call_result', 'علاقه‌مند بود') + ->assertJsonPath('interest_level', 'hot'); + + $this->assertDatabaseHas('leads', [ + 'id' => $lead->id, + 'last_call_result' => 'علاقه‌مند بود', + 'interest_level' => 'hot', + ]); + } + + public function test_referral_call_result_creates_contact_route_records(): void + { + $agentRole = Role::create(['name' => 'agent', 'guard_name' => 'web']); + $agent = User::factory()->create(); + $agent->assignRole($agentRole); + + $lead = Lead::create([ + 'company' => 'Acme', + 'first_name' => 'Ali', + 'last_name' => 'Karimi', + 'phone' => '02144444444', + 'assigned_to' => $agent->id, + 'is_unassigned' => false, + ]); + + $contact = Contact::create([ + 'lead_id' => $lead->id, + 'name' => 'آقای رضایی', + 'role' => 'پذیرش', + 'status' => 'active', + 'is_primary' => true, + 'primary_reason' => 'مخاطب اولیه لید', + 'created_by' => $agent->id, + ]); + + $phone = ContactPhone::create([ + 'contact_id' => $contact->id, + 'phone' => '02144444444', + 'type' => 'landline', + 'status' => 'active', + ]); + + $callId = $this->actingAs($agent) + ->postJson('/api/calls', [ + 'lead_id' => $lead->id, + 'contact_phone_id' => $phone->id, + ]) + ->assertCreated() + ->json('call_id'); + + $followUpAt = now()->addDay()->toISOString(); + + $this->actingAs($agent) + ->postJson('/api/calls/register-result', [ + 'call_id' => $callId, + 'result' => 'معرفی شماره یا شخص جدید', + 'notes' => 'با پذیرش صحبت شد', + 'referral' => [ + 'name' => 'خانم احمدی', + 'phone' => '09125551234', + 'phone_type' => 'mobile', + 'role' => 'مسئول خرید', + 'relation_description' => 'معرفی‌شده توسط پذیرش', + 'description' => 'برای خرید تصمیم می‌گیرد', + 'make_primary' => true, + 'next_follow_up_at' => $followUpAt, + ], + ]) + ->assertOk() + ->assertJsonPath('result', 'معرفی شماره یا شخص جدید'); + + $this->assertDatabaseHas('contacts', [ + 'lead_id' => $lead->id, + 'name' => 'خانم احمدی', + 'role' => 'مسئول خرید', + 'is_primary' => true, + ]); + $this->assertDatabaseHas('contact_phones', [ + 'phone' => '09125551234', + 'type' => 'mobile', + 'status' => 'active', + ]); + $this->assertDatabaseHas('contact_relations', [ + 'lead_id' => $lead->id, + 'from_contact_id' => $contact->id, + 'relation_type' => 'introduced', + ]); + $this->assertDatabaseHas('call_logs', [ + 'lead_id' => $lead->id, + 'call_id' => $callId, + 'result' => 'معرفی شماره یا شخص جدید', + 'next_action' => 'تماس با مخاطب معرفی‌شده', + ]); + $this->assertDatabaseHas('follow_ups', [ + 'lead_id' => $lead->id, + 'user_id' => $agent->id, + 'status' => 'pending', + ]); + } +} diff --git a/backend/tests/Feature/SecurityAuthorizationTest.php b/backend/tests/Feature/SecurityAuthorizationTest.php new file mode 100644 index 0000000..eb1c963 --- /dev/null +++ b/backend/tests/Feature/SecurityAuthorizationTest.php @@ -0,0 +1,236 @@ +getJson('/api/leads')->assertUnauthorized(); + $this->postJson('/api/auth/logout')->assertUnauthorized(); + } + + public function test_agent_cannot_modify_another_agents_lead(): void + { + [$agent, $otherAgent] = $this->makeAgents(); + $lead = $this->makeLead(['assigned_to' => $otherAgent->id, 'is_unassigned' => false]); + + $this->actingAs($agent) + ->putJson("/api/leads/{$lead->id}", ['company' => 'Tampered Co']) + ->assertForbidden(); + + $this->assertDatabaseMissing('leads', [ + 'id' => $lead->id, + 'company' => 'Tampered Co', + ]); + } + + public function test_agent_cannot_delete_another_agents_lead(): void + { + [$agent, $otherAgent] = $this->makeAgents(); + $lead = $this->makeLead(['assigned_to' => $otherAgent->id, 'is_unassigned' => false]); + + $this->actingAs($agent) + ->deleteJson("/api/leads/{$lead->id}") + ->assertForbidden(); + + $this->assertDatabaseHas('leads', ['id' => $lead->id, 'deleted_at' => null]); + } + + public function test_agent_cannot_bulk_delete_another_agents_leads(): void + { + [$agent, $otherAgent] = $this->makeAgents(); + $lead = $this->makeLead(['assigned_to' => $otherAgent->id, 'is_unassigned' => false]); + + $this->actingAs($agent) + ->deleteJson('/api/leads/bulk-delete', ['ids' => [$lead->id]]) + ->assertForbidden(); + + $this->assertDatabaseHas('leads', ['id' => $lead->id, 'deleted_at' => null]); + } + + public function test_agent_cannot_reassign_leads(): void + { + [$agent, $otherAgent] = $this->makeAgents(); + $lead = $this->makeLead(['assigned_to' => $otherAgent->id, 'is_unassigned' => false]); + + $this->actingAs($agent) + ->postJson('/api/assignments/reassign', [ + 'lead_id' => $lead->id, + 'agent_id' => $agent->id, + ]) + ->assertForbidden(); + + $this->assertDatabaseHas('leads', [ + 'id' => $lead->id, + 'assigned_to' => $otherAgent->id, + ]); + } + + public function test_agent_cannot_modify_another_users_follow_up(): void + { + [$agent, $otherAgent] = $this->makeAgents(); + $lead = $this->makeLead(['assigned_to' => $otherAgent->id, 'is_unassigned' => false]); + $followUp = FollowUp::create([ + 'lead_id' => $lead->id, + 'user_id' => $otherAgent->id, + 'scheduled_at' => now()->addDay(), + 'status' => 'pending', + ]); + + $this->actingAs($agent) + ->patchJson("/api/follow-ups/{$followUp->id}/mark-done") + ->assertForbidden(); + + $this->assertDatabaseHas('follow_ups', [ + 'id' => $followUp->id, + 'status' => 'pending', + ]); + } + + public function test_agent_cannot_view_or_register_result_for_another_agents_call(): void + { + [$agent, $otherAgent] = $this->makeAgents(); + $lead = $this->makeLead(['assigned_to' => $otherAgent->id, 'is_unassigned' => false]); + $call = Call::create([ + 'lead_id' => $lead->id, + 'user_id' => $otherAgent->id, + 'direction' => 'outbound', + 'phone' => '09129998877', + ]); + + $this->actingAs($agent) + ->getJson("/api/calls/{$call->id}") + ->assertForbidden(); + + $this->actingAs($agent) + ->postJson('/api/calls/register-result', [ + 'call_id' => $call->id, + 'result' => 'پاسخ داد', + ]) + ->assertForbidden(); + + $this->assertDatabaseHas('calls', [ + 'id' => $call->id, + 'result' => null, + ]); + } + + public function test_agent_cannot_create_follow_up_for_unauthorized_lead(): void + { + [$agent, $otherAgent] = $this->makeAgents(); + $lead = $this->makeLead(['assigned_to' => $otherAgent->id, 'is_unassigned' => false]); + + $this->actingAs($agent) + ->postJson('/api/follow-ups', [ + 'lead_id' => $lead->id, + 'scheduled_at' => now()->addDay()->toISOString(), + 'notes' => 'Unauthorized follow-up', + ]) + ->assertForbidden(); + + $this->assertDatabaseMissing('follow_ups', [ + 'lead_id' => $lead->id, + 'user_id' => $agent->id, + ]); + } + + public function test_phone_numbers_and_recordings_are_masked_without_permissions(): void + { + [$agent] = $this->makeAgents(); + Setting::create(['key' => 'phone_mask_enabled', 'value' => 'true', 'group' => 'security', 'type' => 'boolean']); + $lead = $this->makeLead([ + 'assigned_to' => $agent->id, + 'is_unassigned' => false, + 'phone' => '09121234567', + 'phone_secondary' => '02199887766', + ]); + $call = Call::create([ + 'lead_id' => $lead->id, + 'user_id' => $agent->id, + 'direction' => 'outbound', + 'phone' => '09121234567', + 'recording_url' => 'https://recordings.example.test/call.mp3', + ]); + + $response = $this->actingAs($agent) + ->getJson("/api/calls/{$call->id}") + ->assertOk(); + + $this->assertStringNotContainsString('09121234567', $response->getContent()); + $response->assertJsonPath('recording_url', null); + } + + public function test_supervisor_cannot_access_other_team_lead_or_report(): void + { + $supervisorRole = Role::create(['name' => 'supervisor', 'guard_name' => 'web']); + $agentRole = Role::create(['name' => 'agent', 'guard_name' => 'web']); + $supervisor = User::factory()->create(['is_active' => true]); + $ownAgent = User::factory()->create(['is_active' => true]); + $otherAgent = User::factory()->create(['is_active' => true]); + $supervisor->assignRole($supervisorRole); + Permission::create(['name' => 'view_reports', 'guard_name' => 'web']); + $supervisor->givePermissionTo('view_reports'); + $ownAgent->assignRole($agentRole); + $otherAgent->assignRole($agentRole); + + $ownTeam = Team::create(['name' => 'Own Team', 'supervisor_id' => $supervisor->id, 'is_active' => true]); + $otherTeam = Team::create(['name' => 'Other Team', 'is_active' => true]); + $supervisor->teams()->attach($ownTeam->id); + $ownAgent->teams()->attach($ownTeam->id); + $otherAgent->teams()->attach($otherTeam->id); + + $otherLead = $this->makeLead([ + 'assigned_to' => $otherAgent->id, + 'team_id' => $otherTeam->id, + 'is_unassigned' => false, + ]); + + $this->actingAs($supervisor) + ->getJson("/api/leads/{$otherLead->id}") + ->assertForbidden(); + + $this->actingAs($supervisor) + ->getJson("/api/reports/agent-performance?agent_id={$otherAgent->id}") + ->assertForbidden(); + } + + /** + * @return array{0: User, 1: User} + */ + private function makeAgents(): array + { + $agentRole = Role::create(['name' => 'agent', 'guard_name' => 'web']); + + $agent = User::factory()->create(['is_active' => true]); + $otherAgent = User::factory()->create(['is_active' => true]); + + $agent->assignRole($agentRole); + $otherAgent->assignRole($agentRole); + + return [$agent, $otherAgent]; + } + + private function makeLead(array $attributes = []): Lead + { + return Lead::create(array_merge([ + 'company' => 'Acme', + 'first_name' => 'Ali', + 'last_name' => 'Karimi', + 'phone' => fake()->unique()->numerify('021########'), + ], $attributes)); + } +} diff --git a/backend/tests/Feature/UserManagementTest.php b/backend/tests/Feature/UserManagementTest.php new file mode 100644 index 0000000..4e8bf52 --- /dev/null +++ b/backend/tests/Feature/UserManagementTest.php @@ -0,0 +1,152 @@ + 'admin', 'guard_name' => 'web']); + Role::create(['name' => 'agent', 'guard_name' => 'web']); + + $admin = User::factory()->create([ + 'phone' => '09121111111', + 'is_active' => true, + ]); + $admin->assignRole($adminRole); + + $this->actingAs($admin); + + $response = $this->putJson("/api/users/{$admin->id}", [ + 'name' => 'مدیر سیستم', + 'email' => $admin->email, + 'phone' => $admin->phone, + 'role' => 'admin', + ]); + + $response + ->assertOk() + ->assertJsonPath('id', $admin->id) + ->assertJsonPath('name', 'مدیر سیستم'); + + $this->getJson('/api/auth/me') + ->assertOk() + ->assertJsonPath('data.id', $admin->id); + } + + public function test_admin_can_store_user_voip_extension(): void + { + $adminRole = Role::create(['name' => 'admin', 'guard_name' => 'web']); + 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' => 'کارشناس فروش', + 'email' => 'agent-extension@example.com', + 'password' => 'Password123!', + 'phone' => '09120000000', + 'voip_extension' => '504', + 'role' => 'agent', + ]) + ->assertCreated() + ->assertJsonPath('voip_extension', '504'); + + $this->assertDatabaseHas('users', [ + 'id' => $response->json('id'), + 'voip_extension' => '504', + ]); + } + + public function test_supervisor_and_agent_can_read_pipeline_stages(): void + { + PipelineStage::create([ + 'name' => 'لید جدید', + 'color' => '#3B82F6', + 'sort_order' => 0, + 'is_default' => true, + ]); + + foreach (['supervisor', 'agent'] as $roleName) { + $role = Role::create(['name' => $roleName, 'guard_name' => 'web']); + $user = User::factory()->create(['is_active' => true]); + $user->assignRole($role); + + $this->actingAs($user) + ->getJson('/api/pipeline-stages') + ->assertOk() + ->assertJsonPath('0.name', 'لید جدید'); + } + } + + public function test_agent_only_sees_self_in_agents_endpoint(): void + { + $agentRole = Role::create(['name' => 'agent', 'guard_name' => 'web']); + $firstAgent = User::factory()->create(['name' => 'First Agent', 'is_active' => true]); + $secondAgent = User::factory()->create(['name' => 'Second Agent', 'is_active' => true]); + $firstAgent->assignRole($agentRole); + $secondAgent->assignRole($agentRole); + + $this->actingAs($firstAgent) + ->getJson('/api/agents') + ->assertOk() + ->assertJsonCount(1) + ->assertJsonPath('0.id', $firstAgent->id) + ->assertJsonPath('0.name', 'First Agent'); + } + + public function test_admin_dashboard_groups_duplicate_pipeline_stage_names(): void + { + $adminRole = Role::create(['name' => 'admin', 'guard_name' => 'web']); + Role::create(['name' => 'agent', 'guard_name' => 'web']); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole($adminRole); + + $firstStage = PipelineStage::create([ + 'name' => 'لید جدید', + 'color' => '#3B82F6', + 'sort_order' => 0, + 'is_default' => true, + ]); + $secondStage = PipelineStage::create([ + 'name' => 'لید جدید', + 'color' => '#3B82F6', + 'sort_order' => 0, + ]); + + Lead::create([ + 'company' => 'First Co', + 'first_name' => 'First', + 'last_name' => 'Contact', + 'phone' => '02111111111', + 'pipeline_stage_id' => $firstStage->id, + ]); + Lead::create([ + 'company' => 'Second Co', + 'first_name' => 'Second', + 'last_name' => 'Contact', + 'phone' => '02122222222', + 'pipeline_stage_id' => $secondStage->id, + ]); + + $response = $this->actingAs($admin) + ->getJson('/api/dashboard/admin') + ->assertOk(); + + $pipelineData = $response->json('pipeline_data'); + $newLeadStages = array_values(array_filter($pipelineData, fn ($item) => $item['stage'] === 'لید جدید')); + + $this->assertCount(1, $newLeadStages); + $this->assertSame(2, $newLeadStages[0]['count']); + } +} diff --git a/backend/tests/TestCase.php b/backend/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/backend/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/backend/vite.config.js b/backend/vite.config.js new file mode 100644 index 0000000..f35b4e7 --- /dev/null +++ b/backend/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +}); diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..f208d49 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,3 @@ +VITE_BACKEND_URL=http://127.0.0.1:8800 +VITE_FRONTEND_HOST=127.0.0.1 +VITE_FRONTEND_PORT=5173 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..ebed1bb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env +.env.* +!.env.example +cookies*.txt + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..197f461 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,38 @@ +# CRM Frontend + +React + TypeScript + Vite frontend for the CRM. See the root `README.md` for full setup, build, security, and manual review notes. + +## Quick Start + +```bash +npm install +copy .env.example .env +npm run dev +``` + +Default local frontend env: + +```env +VITE_BACKEND_URL=http://127.0.0.1:8800 +VITE_FRONTEND_HOST=0.0.0.0 +VITE_FRONTEND_PORT=5173 +``` + +The Vite dev server reads host and port from `VITE_FRONTEND_HOST` and `VITE_FRONTEND_PORT`. API and media URLs read `VITE_BACKEND_URL`; keep it aligned with the Laravel backend URL. + +Build with: + +```bash +npm run build +``` + +Verify dependencies with: + +```bash +npm install +npm ls --depth=0 +``` + +Known build note: Vite may warn that `src/api/leads.ts` is dynamically imported and statically imported. The build still succeeds; the warning only means that module cannot be split into a separate lazy chunk. + +Do not commit `.env`, cookie files, `node_modules`, or `dist`. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e925598 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + CRM فروش + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..add4f4d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2617 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@hookform/resolvers": "5.4.0", + "@tanstack/react-query": "5.101.1", + "axios": "1.18.1", + "react": "19.2.7", + "react-dom": "19.2.7", + "react-hook-form": "7.80.0", + "react-router-dom": "7.18.0", + "zod": "4.4.3", + "zustand": "5.0.14" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.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", + "oxlint": "1.71.0", + "tailwindcss": "4.3.1", + "typescript": "6.0.3", + "vite": "8.1.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hookform/resolvers": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", + "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.71.0.tgz", + "integrity": "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.71.0.tgz", + "integrity": "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.71.0.tgz", + "integrity": "sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.71.0.tgz", + "integrity": "sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.71.0.tgz", + "integrity": "sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.71.0.tgz", + "integrity": "sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.71.0.tgz", + "integrity": "sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.71.0.tgz", + "integrity": "sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.71.0.tgz", + "integrity": "sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.71.0.tgz", + "integrity": "sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.71.0.tgz", + "integrity": "sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.71.0.tgz", + "integrity": "sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.71.0.tgz", + "integrity": "sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.71.0.tgz", + "integrity": "sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.71.0.tgz", + "integrity": "sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.71.0.tgz", + "integrity": "sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.71.0.tgz", + "integrity": "sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.71.0.tgz", + "integrity": "sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.71.0.tgz", + "integrity": "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "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", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz", + "integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz", + "integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "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", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concurrently": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", + "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "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", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz", + "integrity": "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.71.0", + "@oxlint/binding-android-arm64": "1.71.0", + "@oxlint/binding-darwin-arm64": "1.71.0", + "@oxlint/binding-darwin-x64": "1.71.0", + "@oxlint/binding-freebsd-x64": "1.71.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", + "@oxlint/binding-linux-arm-musleabihf": "1.71.0", + "@oxlint/binding-linux-arm64-gnu": "1.71.0", + "@oxlint/binding-linux-arm64-musl": "1.71.0", + "@oxlint/binding-linux-ppc64-gnu": "1.71.0", + "@oxlint/binding-linux-riscv64-gnu": "1.71.0", + "@oxlint/binding-linux-riscv64-musl": "1.71.0", + "@oxlint/binding-linux-s390x-gnu": "1.71.0", + "@oxlint/binding-linux-x64-gnu": "1.71.0", + "@oxlint/binding-linux-x64-musl": "1.71.0", + "@oxlint/binding-openharmony-arm64": "1.71.0", + "@oxlint/binding-win32-arm64-msvc": "1.71.0", + "@oxlint/binding-win32-ia32-msvc": "1.71.0", + "@oxlint/binding-win32-x64-msvc": "1.71.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-hook-form": { + "version": "7.80.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.80.0.tgz", + "integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-router": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "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", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0d8119d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "concurrently -k -n backend,frontend -c blue,green \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev:backend": "php ../backend/artisan serve --host=127.0.0.1 --port=8800", + "dev:frontend": "vite --host 127.0.0.1 --port 5173", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "5.4.0", + "@tanstack/react-query": "5.101.1", + "axios": "1.18.1", + "react": "19.2.7", + "react-dom": "19.2.7", + "react-hook-form": "7.80.0", + "react-router-dom": "7.18.0", + "zod": "4.4.3", + "zustand": "5.0.14" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.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", + "oxlint": "1.71.0", + "tailwindcss": "4.3.1", + "typescript": "6.0.3", + "vite": "8.1.0" + } +} diff --git a/frontend/public/agent-mobile-offline.html b/frontend/public/agent-mobile-offline.html new file mode 100644 index 0000000..7e23818 --- /dev/null +++ b/frontend/public/agent-mobile-offline.html @@ -0,0 +1,46 @@ + + + + + + + پنل موبایل CRM فروش + + + +
+

آفلاین هستید

+

اتصال اینترنت برقرار نیست. برای مشاهده اطلاعات جدید دوباره تلاش کنید.

+
+ + diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..a97715ad1bdffb8d8bcb587eac7a407e00999f64 GIT binary patch literal 3071 zcmbVOdo+~m9)9OD#!w&(>bz1MyA1-Y0C;1r5qmg`5jgDbHOf<|xOg!x{w zu>~jiqoSBld4@P88K24i;Xn=%1dp7a<4Jqe+2<5Uq7cE@5|Bu*rks-$^?`!@2Xix# z1Psm0J76A8j26*>f!^Qh?d~Z6_G+vUji@;(;Khr^ti*zsZAA(`T2^;~I*!Q)V=Ag+ z42Gdtpsw8_1gIfC&W>mhg_@0lQ$uL1p$9k}yGY(-a01tFgs7h=ifA6dfc% zCC5inHbxk@8v92~V(T|_L8MGb$N0J{O9*1_ILz?xkcLww&|r2y61+k{pg0y#{r)a2 zbs?-xWL%fln+Ok6c;q$~MAkX_zQ0AjK#W)zW!^L#sVD#A*Kao&^cr(NS;-O$P|K43 zc|>CfaTAs;O(r!~zw7_qvp^KI;;)Tes=Bh~Sbm-#*SK-#QP$MS+RCb9V)^hxi@y%v zbjU7J#OsKb8i8@ggG((Bho6)f5xm|n#0-n6o%HL)_b_M%1-l9C%-s=sQ?30p12nKs z#es`xf^#f*jRSQMEG`yY9FaqimL!x)WOEAK?y+}7ZG(lCETJ)C$*~8R) zvT|-#NDPHN9I9*Xs>ag(rZup*l8_gp+N_h8$ODVJFG4I|mMsKSFJt&E?kaL{B1Q@y zNbCw4^Wb!*qLAp@aUeM4%N@bshWa_zjpvw?+~@Mc8V2Uh7MAK>e1L$EvNF`N1f$Ad z5GT|RS?vGLXpEYB*3*YekQex)0LRwJqBx$IJ7OtNyO!j<5f>Be#kb7_@p54+HNWl^ zQuQ8Q5UTwAHOU|`i!1rQ2uOeQm#Y2`8}9F+R%$nO_jNs1=f3n|nA5PRQY9Jd%k@kI zS}9YocR!2&_+9j@*V&Smr$aeh%Z-uDt^)+jAIcjh`5*pC=Z8gp5>+bwH@!r`{_?-* z#Fw=S_Thh^95_y1{=a^k|4D_h0Fyyl$+7jY(S{2fnSx$ytAgR>??4Z@9_Ir0zXH9- zn{1Q+l=7a-H@=dW@Lkt`fM(K)X$^Zc=3H;V0n4E15Hr+|p?auwrrg#_8@JDl^gv?% zW7hl6jJhGKuIU*YJG^Ug5n~+{(%m(D=$(*@OYzdilwk;GSb8?8+=pwaHMAmCn^!G0 zNfUS_mEmgtf^(G@7t|C!*Cq5&zR2Pi(%QJ)!vj-%??L8J z#ERt_r6QfYI1#rRmEiXfP>O_N+c_S`itB}lZ~m&`hhx~t?P^v+qMEO3qm%dYAj!g6 z^Kq2Ww%Nie!6R8?ETPrOa-HAX_vmsr5bl+kZ8drCjN02%v!#ndtP#U zZ8BywP_@;x>$x2Gp!vP{tL4^XiA$BfoCQVy*)w)qJ^IVJsV94V{e2D!Q|}vhESy%` zOm|Ei+DBUF>7Cwe$V)$A<9E7?0CvUI9(aYzH>VG}ogf9glPW}+^FW%yrm9I%Ex25a+O6P_lVd}gkHcrAvBRl>lD+eq+Q@f#RHRyt41{_{;YLL8Maj;Na z{Yx`n{V8?HZ@D@Z-W|F1&mH{EBy*3AswIt$a-$T1hcdHzg$P@Ev&YO_TE~tk$<7ZY z0}JvqD;YfxPl(kboEMi z-aW!QNLQ>F(89_|=%}PIiaUB3ANLf9xV2RnX+*N;6ZMB}E_1RH%j{XExEn4RAC<%& zsOCv}&u?79#lGs(dxj{m60x*OW6T-KXc>z{E0dSrx_)GI?kNx@ zP#TL&rY5wh{mpgX>LYA1Rrk`iqMt|?y-0z}+1XMeS~@TWQ`SgJF>j3{mhT@{^DZMJ z|1phvT;aasR#0=IwJ{W~q*#Nkdq&x0OH@=HDnlNHERA!0Mv~c2ohG=jCkyr;a=Socs`=GB8cTazn0~M zCg_{FU|lgShZ0R_n@bCf($b$j@6ScV>Z1u~=va6wSF?wGK;yZ za%pHMFQjQya>_k)!$HPZK5B7+5pi@Ml8LTZVw)Pf%|EMl$AQ!7!TKI0GMN&-=ZFDG zUUnQ|B=hK^;N|%nBj!mD1qH(dT_(kO;enaC$KK*oOhRvkUw9K5zAu`L6+kj~EDGjL zFG=2b-JMutHqKyQ_$e8)OPGosm%Pq5+UvDP~>rChVtdZ?(k-Ht=X8B;V@pL9lGHL$% zU0+|{jqe%cMG8X`Z@vvn=)U+S)md2W8o5h$6|!6t zI?%HVLDisCi^`t`Z4X{xDn&tHyjGCo^&tx@n2#HdZ%!0eg34Id3FT}KTEL@A2vLu}eRjjQ(H^1ny+`jw789y78!YoSo<&W1&**p}1^?0ea1mFc{|< zc!YafFXV~8+G^qe#mVgUk=b2HYOaP}KA^RRwPvE4(A7^~3oYnO%nPdJ^DkfJhIU|L LXkk#K=XU*{>bO+P literal 0 HcmV?d00001 diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..0135f1e5810e8f61e36769f679d5294bcceacb1c GIT binary patch literal 9540 zcmeG?XIPU-x03)Opn!tF0t%uuX%;#HQ9wjm#6}MSiUkNo=_ElGX#y&J0Yg)CRjTwB z-Oxo0pdc-7AQWlRTgrVSe(raF-{-kM?)`r7WZq%sv^j0gnP{Yi@osKWZUDe;le30a z0KlOk9PHcy{VWIcZbCmWUn}F&pzwqE6!gLFcFOz|03``Lw5uG@H<#C08(#o;|6={Y zTF}p30XY1{#PHO`0LR&3W|70SES2vJp6<`<;mzYRyJD>Bk6$V>I*bdikH3HW(nBZo0RdZm4Q|s){;T#b)^rFSRM* z$rX1C*PaA^@BqakRQp9sjp4;~pAT*BO%lvQm*(o3VWgG82JM>VQT%}{zs+XS;K;%g z5|85@c3{?&LEbDR$rn7)dJ(YvzJU1a+Kkx>P9q+yRjP%E$&uSQc z@UdLD{?6>W`IK6p=l;g?v%x`jz`48eDv-k23-ON zLhtldY1OZ$?T(Y(3rb@&%Dij7y7B2-S%hXi73PrSO(tGVT;yWI4G?R}2j}~3EFvyg z7C`f{Y3neNTsb(Croyzfh`V4a%ON?kU{}otj3317E{s{k?=&k1Gf&cgQ<+g{3h~n$^{=UcL%|u)CDr6lgNj`(Z(uKW#vfp ziPu6n&4Rxj^ns$IyP}|R*WkI^5b~=@q8QyYh*5KU`>4-7}|E$)p7 zGd%*lU*JD6z{h&cy^Tqx?5P*QP#YiEJQUyBlCjj}O5*t1oq(-bAOJ+Ag-)o8)-?&$ z*dw5%61#x|2z~p;*_YJbP=aC=Am-|8tio`N7^5(r`q;1ccyLP)Sgg0g-w{J6Z=8bI zmQUh?EaSqfKAnraHb8un6K5~vbL8to4#_zKdyF&4CR=E&y!SarR^e0vCAG*6E*MUn z*RLl#TnN;x!Sj|7W0)w1q^GKPi4K{%CMrr!8bMk;IDLUTm}Om>M5#(x%6~85AHS- zmW?S3hM&9w3akBYkE+1H-D*g2XHkGshuBC3xSBdhNxOt`Rf>`yc#^PO;JKyyriL;o z(LruR0z_6JdG%8$2VnyXMB=SEk1B%&391Af+>Oa##PQ%baNwwT3BQ*;py;>jvx9Gf zJj@o3Y#JNL_Ehj2x!Wb%!3Qn2Tlis}i{StJ`)Pl}O&*<)g&qg*RUa71`wqkdZW(bL26JBYb&ee831 zxe-kk!Sl@|mK*7N6V;kqx{$dfHp;Uh50Yd288X;C=@(fx)vRgY$&HAhh@v9tMy`*e zdtjg0dyQbvA97%&y3aUY&3?)DEeBk@-cv1c1XU&_C15(U4;imBmJco-{v*(R{Ge3# zWoM5^uYaG;hGx{~`S;w^DGBw`VmwTMBOh7kACl_!qsZZu!j~?9BYQILMn#z^tnO&F zggqy+ydw%PCH0!xJgXx*%3m!9lA}6&4Go_qYf>j46NUtF+{9|lJ&$h6`iU#G99o1J z<*)UPjxy7!;QU|?dk*QoRJV~L`+I1NsM~2|_-ubIeEb=>DCzjSlvGbV{9y(h3FAN* z)-ve?q*@`~vB3x0!nvDQoH&Y*&@f_2s8% zeYGIhzWEN+%JO;O1E~hm6t@0pPXB>2<6UB0nBc8FPgiqLy%*R8aV$0HMFPK>A)O%V zmjtyPX3t3}c2AC=;PQE3jCI<3^^-qlYDcXl~UGtvcc< zo^*^G0a-4`aUn3se798U$@6NPITu*fM2?$ixa6)&?$KGlR^g%)U`~Ajr)Eano+zOX z$o~?>`bffl4&3|79hZL`#KUr6=*;=X2l0DO)b5hfhOLC3yxIfj?_~;-27(2I>#Ji~ zv_n7f3**iv0skGYXDP?~XA-f7+oE5EAI-4l@`yy-60&1p#xsJgBv$ca+j1@!#GT#s z;5<7e3b8AN3b?-zm9g`+o6!sVkKPXG)7RJreCr61dMvv=76f~yr2aGH_@B$+mI62t z{AB;|`Y<_e#D7H~uSc1zt36lOVjPJt;dvs^D6mlL-JE=gL-m%~^Q2U>)zSh%jDp+xzi;qH;(bZ6%TGr|)Qne8=^UFwp_8ESlj?Tc~GE z6@=MeXuP@%x>R@Ex?$k1q4du(es5{u5!>`E7#~2{NW>2Qq`w*%=CW6vkWAd0n)U~+ z;HU5V!b5O(F0a@RxB0VwaqR?oXtbCqxb0t|W9N^4s}q9|-xCF^7azM?*iW4gy`q1t zp*G*2-jlE`gFm=&QDfWk_+JP$|4&f-C!+onY5x;Z|1F|AZpPu`J%h(0^K>wQ^RVHy zbpvWgUh1}qL%CEGk%W_jdmBpb==xMWj_QrD(+ z%)6UW5?3FqWS!i6IjDDXfJRugND$g~Nl0M$44<~FQnxigA`M!i%|7<5wqt3rBu;-v z-IYAHe?{^~Q)8rp0}_qXj?B0+(L^tl;lb!04nF2;Dm7Jq;TNm{bMfm526I%`L4W3K z%2G=#_h8Jw68iis;=z>%0$BMNLM3B#ZgJ4gZ`V)_ zU%On7f1xiYjz)x1i8dKkwg*3-Q2d#72!cmcw=GMwSkW^T*$&$#Y7D++UV++E{QMU= zDNh1q#l>cm1jdlWeY?F)My<9zJ_5#XV@nBHe>k!tah6rnK?wbH#oV}7ia=yzN3_?Z z^NF1LMr=*UpS&X5qJ{qVPJoAgKctn3q>Qlf3>%*PkwM|$lBLDO4MvqC0`Js@0V0KvaiF0rL>nLbFRsT3$2L^R zVTJt2ADLb@hY7P`D8ikJ-^6Us-{Jv+|6ti-?vp}YE&?$-P9QFg^e|{e~nJq{(`6i z6^pcNyuI9pF;h3TEE`YE3lSDV&K#6unpgczMqdJtp_>6j=8WC)M6(``aO|e@Uewv# zok_QkP{{aT>eIM|sFTq303!LrmP+$!zYPU}SZ4dZmD1Pq;`LvfRBk^o8H4Vn2-w3M z*^#!CMDeAJd7p*|nR5oTt!g})ifU*$Ci5u#$1U-`cqBtu_QRTrYzB6@oUT8SvO7;$ z{O&~;`&{M^9LL9qx-%Un@f!{SedCK$c6V~UlJWj45iM%X(woqQ7CJElbMba%*+b*P zFE?b9A*;F-!bp~_dv;8ngoaXeLGatH_T;Je=(%y-#8%^m#-|G+Ds|(BdEklXS$wwg z3uqMFG&`-T=td%kEiOnnk^?e}7su|#EkrY{ey*q`B@6JIYv_i!ZS*SXV1p<2#kFv~ zRME*frbj|;BeBigLZxY5B%k5E-0+H{)nJ>nj<+(BtAT5k%17`O(T6K`>GfYrjfamx zmwQM&2c`Y;6=e6XYD^hneS7*joen7UHP#%nAn9*|v>KY7|LRO4ho8__fwqq!=NWFV zwI|}@yki*Kv_ITC@7ci>IZ<9RiB?~7WN6^~_kWZQt1s9V|KZhKw3+(Jg4agaM{+{c z(f#k+E5(OJUQP-_7cXwyF6j!oZn3q&6ts4^uZJ8@& zwsyf8ewrk$c2h3LO-~&90^B;U9iwC3mJ~NnA+d}EYs<}^6D5`VGz5S z)jX;+uuoRKSz!|rA)!Pz)4sva=+ATZ*oJHC;>7rs z1F8XI^k@72#=6R5CA(}CH)E}O&!p5%&V$R04ZM1@;^rx+_K}w`cLdcU^@+H_oM_5z z?B}hHGkjtFA-8eEn)4%cjMA*4-(tCs@?pPKX1vr8&RU@K{@uYh~KW&EC3RXFZ}h(A3{cFY<&=pkyvL`(J{WDs5(TwXYO;KDfdH z{2nUuE1^cEUYvNLgmcNYvvA()xs|mT;;ehx?xqWg9OBM3WXX-VMv-HcSQqaqKUPm1 zU~?*oWK49tF(r&kgubTjl}UNcTjcw(q`|pJGN?e4~lgZzB$`4b*8%HjJ3;OF0fH z_dw+($Bk4-dCs$l?f+55+LD?&9u5E4Tsvi*@$n4TjbIXL28-0YJ}8L8KoS-~gjOe&L2o>&%L zBcV)Sld>F6l9X>ijKidPX!(0q9M?5@%8B{_in0eDpc<(h7Kky=u^$8 zv9SGe35iu-Y~gw2D9u}vD$s6K9*EH`O)?d7@hOB%>n7DM-t)mJ2)xsqam&wfYrIi9 z+>V>kmIg)kUQJYptQP}A7qIHFHf0}Ia~m6mNFOf~AmIc0DkW7-CW#tl$Fug7{Y~|x zik|;QfJ5Nh^Tuk}!aEi)5Hugrm)Vc+ApBUY1T4NI}H}f(v@J|)|kEi(kGG59ixj|)3!;KixVdL zasV;kL?7#jzV_Jpo~O=vRcDQcitYl)IeuY(-Uwl0eir`%0r3!(&VFrnt1Mq#eD#m= zPP}Y=HURRk7_D4!ZP&Vwr{JuYHQL1Xn)j;7s$t?GZv*oAHIih-Hl1 z-u)mZrf&@S7+@7OJl4+jOv&aPV#W=LnGK~J0Q6@7Xc6OacIv7!=@rZM>>=M6MXC+s z4O%nQq8kkHsofmltjj0z{=qcY0YQzmDVG$Vo}8PpYGTx`1k8DWQypj!FBa^ZU3<;G z97H0ED>J9{IY8?n3$_ry(lrjvB)w#aN@0Ih#l~j6ptpY;t79?CF%Y5MpDPHQJ*&JJ z9&<9}XiDj5UT*NP{c4fjV4ByngS@q5R@Rw$vfLG7@-|C1XhV|{5H)Tq*Jjb&s`$cl z1am&}mv!491$CzP7FkdbNM_ws*^#Mmj=%~e6O*6!CL~`akriW^P;nQRRHMz!A3uWG z3{b-ivJ$kLLiD)6H9U(L!P@s~GTHKfoZ)IMokU-D@3UuQPO3nB$SZTzU9U-HPZ>~z zdAsB;E(CdW>_}xu=K*l@Zm6Inu`_^zq#sVaMIuYQKKZGZ=?+m*esf4>9Vuf2cZLjA z2dQ_a$iX|;m&qj$5Vs=hpJjx?&Lzsh7UoCaoZhJ3nk&|zPc(N$FfIVx@@2-#W^;aT zL?T)XUFR@wwKRuz=v~hYt^7vQP{SO6(hacjTB~%4BdiQcR@}Lw8kv>;j-++_dbht( zx3~c^B6%7$oSCh?w{K2mc?9O&kNvANLll1~-1>JLhBy?h!Jm5+NF64Bno4XiQo;}H zj;h^t{p5ti<*cRV<|RvI2t=}`tb@g6hZv8Lt{Qsx!d-e9#fz!HTu>3kYV3Z=cnQ&P z*T#43#>yq^=gc>04>aeoz2`e8vhF~Nt37JUrazcq6jtuthSG&5&W##{v6Sm*iYK*g zTD0p#AS8wInmP|JkD|H{WaAlm~=t@m1~Aodwa2)4djm@Fv!R?u5NMCOt4K>v>7i)NvH3zwIOIlNY5Bal zHF~VGt;FZ0%1O^VthgJZC&JDFz9u+_ef`$)4JF2}VOmw=x)u5c6oX7qRW zRbvDJj?LP4tYJUYPt}3$^EY$D33J{S75u_N>pCY>g}w9b4#u z@F@~wQR=oEDS5^(KVUET=FX;nN)>NJUu%rUha&+Hn*M&M#g4jq>+RQlvhkKS)}Ojg zLtU{BegV&>Y854X&6Y${>!n<@1%Uohahh`jMNnY_b*2o z_UDf`&o81XD;BDXt+@d~kQg8Cf@KtpMg(#Sa5%5d*SaQD$o-$}z$xgh>oeBkx4V+| z0d3b%3i>-iuSXLZ`;F=TbDYwJ9<+rX0X!d!QDOe2!9f3LQT}^x^Vo?KOOLjJJUEkQ z8gE_Ou)qm5VVgO=LSf%>ln>yv>u(J&Y;hBw`Rezs8-Q$!g3O@t2DlgXQid27LMcD8 zp1?zh&p><74gzEW)V8ZBVm0}1{`d@@$2y^4B5`N(SV20j5rAIlLRu`;(^aOg7l^TX zwjIsBAIObh2-EVR83hO5eCQQLb11MnH>?vxGVN1mNC9L5UYFL&;E?ob{*obz-21^l3Y6&xSG~(c1eZ%@UXosLa zHLLHeGpxab80#0X^rS(p>wXqHoaQM zt8^%XJ*k{+#E=$07+7o6+gzgx*5#YMf}rlpjR*rD>}Vomc;KW~#9ldX4g4#*gpLQvhakh9vAZp;tLf#1zB>6{T}ndiwfc;-6+l^Zvw$(XAPn4>lwkz4NT7eOHJgBdpJ(?RYH+zTFeN5tDFj^C zSbgrg(#Gj%ta`mX@D+$OaIQ7r*#T=+R9}eh8Sb(E)Y!jV3f`Naym@}d2hHHb9(J&7 zD?XBN=!^HQ+PL8@ne-81-0`&$CSg4~Zuk+!E9NB^m^}SzBq304B)hqmR@JL}fuVnC W#5Pn|J{qDQ023n%!@|=}_x=kma1D|G literal 0 HcmV?d00001 diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..f03b44c --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,85 @@ +{ + "name": "پنل موبایل کارشناس فروش CRM", + "short_name": "CRM فروش", + "description": "پنل موبایل کارشناس فروش برای مدیریت تماس‌ها، لیدها و پیگیری‌ها", + "id": "/agent-mobile/", + "start_url": "/agent-mobile/today?source=pwa", + "scope": "/agent-mobile/", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone", "minimal-ui"], + "orientation": "portrait", + "dir": "rtl", + "lang": "fa", + "background_color": "#f4f7fb", + "theme_color": "#2563eb", + "categories": ["business", "productivity"], + "prefer_related_applications": false, + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "تماس بعدی", + "short_name": "تماس بعدی", + "description": "رفتن به تماس بعدی پیشنهادی", + "url": "/agent-mobile/today?action=next-call", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "پیگیری‌های امروز", + "short_name": "پیگیری امروز", + "description": "مشاهده پیگیری‌های امروز و عقب‌افتاده", + "url": "/agent-mobile/follow-ups", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "لیدهای من", + "short_name": "لیدها", + "description": "مشاهده لیدهای اختصاص‌یافته", + "url": "/agent-mobile/leads", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "ایجاد لید جدید", + "short_name": "لید جدید", + "description": "شروع سریع فرایند ثبت لید؛ این امکان در نسخه موبایل به‌زودی کامل می‌شود", + "url": "/agent-mobile/leads?action=new", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + } + ] +} diff --git a/frontend/public/pwa-icon.svg b/frontend/public/pwa-icon.svg new file mode 100644 index 0000000..ea09046 --- /dev/null +++ b/frontend/public/pwa-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/sample-leads-b2b.csv b/frontend/public/sample-leads-b2b.csv new file mode 100644 index 0000000..06e0991 --- /dev/null +++ b/frontend/public/sample-leads-b2b.csv @@ -0,0 +1,3 @@ +company,phone,first_name,last_name,email,city,source +Acme Industrial,02112345678,Ali,Karimi,ali@example.com,Tehran,Website +Pars Co,02187654321,Sara,Ahmadi,sara@example.com,Shiraz,Exhibition diff --git a/frontend/public/service-worker.js b/frontend/public/service-worker.js new file mode 100644 index 0000000..b9330d9 --- /dev/null +++ b/frontend/public/service-worker.js @@ -0,0 +1,112 @@ +const VERSION = 'v4' +const SHELL_CACHE = `crm-agent-shell-${VERSION}` +const STATIC_CACHE = `crm-agent-static-${VERSION}` +const API_CACHE = `crm-agent-api-${VERSION}` +const OFFLINE_URL = '/agent-mobile-offline.html' + +const APP_SHELL = [ + OFFLINE_URL, + '/manifest.json', + '/favicon.svg', + '/icons/icon-192.png', + '/icons/icon-512.png', +] + +const CACHEABLE_API_PREFIXES = [ + '/api/agent/mobile/bootstrap', + '/api/agent/mobile/dashboard', + '/api/agent/mobile/call-queue', + '/api/agent/mobile/leads', + '/api/agent/mobile/follow-ups', +] + +const BLOCKED_PATHS = ['/storage/', '/uploads/', '/sanctum'] + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(SHELL_CACHE) + .then((cache) => cache.addAll(APP_SHELL)) + .then(() => self.skipWaiting()) + .catch(() => undefined) + ) +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys + .filter((key) => ![SHELL_CACHE, STATIC_CACHE, API_CACHE].includes(key)) + .map((key) => caches.delete(key)) + )) + .then(() => self.clients.claim()) + ) +}) + +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + self.skipWaiting() + } +}) + +function isBlocked(url) { + return BLOCKED_PATHS.some((path) => url.pathname.startsWith(path)) || url.pathname.includes('recording') +} + +function isMobileApi(url) { + return CACHEABLE_API_PREFIXES.some((path) => url.pathname.startsWith(path)) +} + +async function networkFirst(request, cacheName) { + const cache = await caches.open(cacheName) + try { + const response = await fetch(request) + if (response && response.ok) { + cache.put(request, response.clone()) + } + return response + } catch (error) { + const cached = await cache.match(request) + if (cached) return cached + throw error + } +} + +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName) + const cached = await cache.match(request) + const fetched = fetch(request).then((response) => { + if (response && response.ok && response.type !== 'opaque') { + cache.put(request, response.clone()) + } + return response + }).catch(() => cached) + return cached || fetched +} + +self.addEventListener('fetch', (event) => { + const request = event.request + const url = new URL(request.url) + + if (request.method !== 'GET' || isBlocked(url)) return + + if (request.mode === 'navigate') { + event.respondWith( + fetch(request).catch(() => { + if (url.pathname.startsWith('/agent-mobile')) { + return caches.match(OFFLINE_URL) + } + return Response.error() + }) + ) + return + } + + if (isMobileApi(url)) { + event.respondWith(networkFirst(request, API_CACHE)) + return + } + + if (['script', 'style', 'font', 'image', 'manifest'].includes(request.destination)) { + event.respondWith(staleWhileRevalidate(request, STATIC_CACHE)) + } +}) diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..f7fb6db --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,7 @@ +self.addEventListener('install', () => { + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + event.waitUntil(self.registration.unregister()) +}) diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..98dd938 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,144 @@ +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' + +const AppLayout = lazy(() => import('@/components/layout/AppLayout')) +const Login = lazy(() => import('@/pages/Login')) +const AdminDashboard = lazy(() => import('@/pages/Dashboard/AdminDashboard')) +const SupervisorDashboard = lazy(() => import('@/pages/Dashboard/SupervisorDashboard')) +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 CoreCrmPage = lazy(() => import('@/pages/CoreCrm/CoreCrmPage')) +const CampaignList = lazy(() => import('@/pages/Campaigns/CampaignList')) +const UserList = lazy(() => import('@/pages/Users/UserList')) +const CallList = lazy(() => import('@/pages/Calls/CallList')) +const FollowUpList = lazy(() => import('@/pages/FollowUps/FollowUpList')) +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 AgentMobileLayout = lazy(() => import('@/pages/AgentMobile/AgentMobileLayout')) +const AgentRouteRedirect = lazy(() => import('@/components/AgentRouteRedirect')) +const AgentMobilePages = lazy(() => import('@/pages/AgentMobile/AgentMobilePages')) + +export default function App() { + const initialize = useAuthStore((s) => s.initialize) + const ready = useAuthStore((s) => s.ready) + + useEffect(() => { + if (!ready) { + initialize() + } + }, [ready, initialize]) + + return ( + + }> + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + } + /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + + + + } + /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + + } /> + + + + ) +} + +function RouteFallback() { + return ( +
+ +
+ ) +} + +function DashboardRedirect() { + const { user } = useAuthStore() + if (!user) return null + if (user.roles?.includes('admin')) return + if (user.roles?.includes('supervisor')) return + return +} diff --git a/frontend/src/api/agentMobile.ts b/frontend/src/api/agentMobile.ts new file mode 100644 index 0000000..959f6db --- /dev/null +++ b/frontend/src/api/agentMobile.ts @@ -0,0 +1,246 @@ +import client from './client' +import { + cacheKey, + readAgentMobileCache, + saveAgentMobileCache, +} from '@/utils/agentMobileOffline' + +export interface MobileLeadCard { + id: number + lead_name: string + main_contact_name: string | null + main_contact_role: string | null + main_contact_phone_id: number | null + lead_status: string | null + lead_status_color: string | null + stage: string | null + stage_color: string | null + last_call_result: string | null + last_call_at: string | null + follow_up_time: string | null + priority: number + priority_label: string + short_note: string | null + next_action: string + follow_up_status: string + tags: string[] + call_attempts: number +} + +export interface MobileDashboard { + stats: { + today_calls_count: number + today_follow_ups_count: number + overdue_follow_ups_count: number + new_assigned_leads_count: number + successful_calls_today: number + failed_calls_today: number + leads_needing_action: number + } + suggested_call: MobileLeadCard | null +} + +export interface MobileContactSummary { + id: number + name: string + role: string | null + description: string | null + status: string + is_primary: boolean + phone_id: number | null + phone_type: string | null + phone_status: string | null + call_count: number + successful_call_count: number + failed_call_count: number + last_call_result: string | null + last_called_at: string | null +} + +export interface MobileLeadDetail { + card: MobileLeadCard + primary_contact: MobileContactSummary | null + summary: { + id: number + name: string + company: string | null + city: string | null + province: string | null + source: string | null + interest_level: string | null + priority: number + score: number | null + status: string | null + stage: string | null + notes: string | null + tags: string[] + final_result: string | null + } + calls: { + id: number + contact_name: string | null + result: string | null + notes: string | null + created_at: string | null + }[] + contacts: MobileContactSummary[] + notes: { + id: string + text: string + created_at: string | null + }[] + files: unknown[] + contact_routes: { + id: number + from: string | null + to: string | null + type: string + description: string | null + }[] +} + +export interface MobileFollowUpCard { + id: number + lead_id: number | null + lead_name: string + contact_name: string | null + contact_phone_id: number | null + follow_up_time: string | null + follow_up_type: string + last_call_result: string | null + notes: string | null + status: string + group: 'overdue' | 'today' | 'tomorrow' | 'week' | 'done' +} + +export type MobileFollowUpGroups = Record + +export type MobileFilter = 'all' | 'today' | 'overdue' | 'no-call' | 'high-priority' | 'interested' | 'proposal' | 'closed' + +export type MobileCallResult = string + +export interface MobileBootstrap { + preferred_view: 'auto' | 'mobile' | 'desktop' + call_results: { + name: string + slug: string + requires_follow_up: boolean + is_final: boolean + }[] +} + +export interface MobileCallSession { + call_id: number + contact_id: number | null + contact_phone_id: number | null + lead_name: string + recording_enabled?: boolean +} + +export interface MobileReferralPayload { + name: string + phone: string + phone_type?: 'mobile' | 'landline' | 'extension' + role?: string + relation_description?: string + description?: string + make_primary?: boolean + next_follow_up_at?: string +} + +export interface MobileCallResultPayload { + call_id: number + result: MobileCallResult + notes?: string + next_follow_up_at?: string + mark_phone_wrong?: boolean + disinterest_reason?: string + close_lead?: boolean + referral?: MobileReferralPayload +} + +export interface MobileCallResultResponse { + call_id: number + lead_id: number + result: MobileCallResult + next_call: MobileLeadCard | null +} + +export async function getMobileDashboard(): Promise { + try { + const { data } = await client.get<{ data: MobileDashboard }>('/agent/mobile/dashboard') + return saveAgentMobileCache(cacheKey('dashboard'), data.data) + } catch (error) { + const cached = await readAgentMobileCache(cacheKey('dashboard')) + if (cached) return cached + throw error + } +} + +export async function getMobileBootstrap(): Promise { + try { + const { data } = await client.get<{ data: MobileBootstrap }>('/agent/mobile/bootstrap') + return saveAgentMobileCache(cacheKey('bootstrap'), data.data) + } catch (error) { + const cached = await readAgentMobileCache(cacheKey('bootstrap')) + if (cached) return cached + throw error + } +} + +export async function getMobileCallQueue(filter: MobileFilter = 'all'): Promise { + try { + const { data } = await client.get<{ data: MobileLeadCard[] }>('/agent/mobile/call-queue', { params: { filter } }) + return saveAgentMobileCache(cacheKey('call-queue', filter), data.data) + } catch (error) { + const cached = await readAgentMobileCache(cacheKey('call-queue', filter)) + if (cached) return cached + throw error + } +} + +export async function getMobileLeads(filter: MobileFilter = 'all'): Promise { + try { + const { data } = await client.get<{ data: MobileLeadCard[] }>('/agent/mobile/leads', { params: { filter } }) + return saveAgentMobileCache(cacheKey('leads', filter), data.data) + } catch (error) { + const cached = await readAgentMobileCache(cacheKey('leads', filter)) + if (cached) return cached + throw error + } +} + +export async function getMobileLead(id: number): Promise { + try { + const { data } = await client.get<{ data: MobileLeadDetail }>(`/agent/mobile/leads/${id}`) + return saveAgentMobileCache(cacheKey('lead-detail', id), data.data) + } catch (error) { + const cached = await readAgentMobileCache(cacheKey('lead-detail', id)) + if (cached) return cached + throw error + } +} + +export async function getMobileFollowUps(): Promise { + try { + const { data } = await client.get<{ data: MobileFollowUpGroups }>('/agent/mobile/follow-ups') + return saveAgentMobileCache(cacheKey('follow-ups'), data.data) + } catch (error) { + const cached = await readAgentMobileCache(cacheKey('follow-ups')) + if (cached) return cached + throw error + } +} + +export async function startMobileCall(leadId: number, contactPhoneId?: number | null): Promise { + const { data } = await client.post<{ data: MobileCallSession }>('/agent/mobile/calls', { + lead_id: leadId, + contact_phone_id: contactPhoneId, + }) + return data.data +} + +export async function submitMobileCallResult(payload: MobileCallResultPayload): Promise { + const { data } = await client.post<{ data: MobileCallResultResponse; message: string }>('/agent/mobile/call-results', payload) + return data.data +} diff --git a/frontend/src/api/assignments.ts b/frontend/src/api/assignments.ts new file mode 100644 index 0000000..d6ef380 --- /dev/null +++ b/frontend/src/api/assignments.ts @@ -0,0 +1,18 @@ +import client from './client' +import type { Lead } from '@/types' + +export async function assignLeadToAgent(leadId: number, agentId: number): Promise { + const { data } = await client.post('/assignments/assign', { + lead_id: leadId, + agent_id: agentId, + }) + 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, + agent_id: agentId, + }) + return data +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..a5797df --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,86 @@ +import client, { csrf } from './client' +import type { AgentPreferredView, LoginPayload, User } from '@/types' + +interface RoleResponse { + name?: string + permissions?: PermissionResponse[] +} + +interface PermissionResponse { + name?: string +} + +type AuthUserResponse = Omit & { + roles?: Array + permissions?: Array +} + +export async function login(payload: LoginPayload): Promise { + await csrf() + const { data } = await client.post<{ data: AuthUserResponse }>('/auth/login', payload) + return normalizeUser(data.data) +} + +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 updateProfile(payload: { + name: string + email: string + phone?: string | null + password?: string + avatar?: File | null +}): Promise { + const formData = new FormData() + formData.append('name', payload.name) + formData.append('email', payload.email) + if (payload.phone) formData.append('phone', payload.phone) + if (payload.password) formData.append('password', payload.password) + if (payload.avatar) formData.append('avatar', payload.avatar) + + const { data } = await client.post<{ data: AuthUserResponse }>('/auth/profile', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return normalizeUser(data.data) +} + +export async function updateAgentPreferredView(preferredView: AgentPreferredView): Promise { + const { data } = await client.post<{ data: AuthUserResponse }>('/agent/mobile/preferred-view', { + preferred_view: preferredView, + }) + return normalizeUser(data.data) +} + +export async function getAgentMobileBootstrap(): Promise<{ preferred_view: AgentPreferredView }> { + const { data } = await client.get<{ data: { preferred_view: AgentPreferredView } }>('/agent/mobile/bootstrap') + return data.data +} + +function normalizeUser(user: AuthUserResponse): User { + const roles = (user.roles ?? []) + .map((role) => typeof role === 'string' ? role : role.name) + .filter((role): role is string => Boolean(role)) + + const directPermissions = (user.permissions ?? []) + .map((permission) => typeof permission === 'string' ? permission : permission.name) + .filter((permission): permission is string => Boolean(permission)) + + const rolePermissions = (user.roles ?? []).flatMap((role) => { + if (typeof role === 'string') return [] + return (role.permissions ?? []) + .map((permission) => permission.name) + .filter((permission): permission is string => Boolean(permission)) + }) + + return { + ...user, + roles, + permissions: [...new Set([...directPermissions, ...rolePermissions])], + } +} diff --git a/frontend/src/api/calls.ts b/frontend/src/api/calls.ts new file mode 100644 index 0000000..40fe7fa --- /dev/null +++ b/frontend/src/api/calls.ts @@ -0,0 +1,62 @@ +import client from './client' +import type { Call, CallResult, PaginatedResponse } from '@/types' + +export interface CallFilters { + lead_id?: number + caller_id?: number + page?: number + per_page?: number +} + +export interface InitiateCallResponse { + call_id: number + contact_id?: number + contact_phone_id?: number + phone: string + lead_name: string + recording_enabled?: boolean + recording_url?: string | null + provider_call_id?: string | null +} + +export async function getCalls(filters: CallFilters = {}): Promise> { + const { data } = await client.get('/calls', { params: filters }) + return data +} + +export async function getCallResults(): Promise { + const { data } = await client.get('/call-results') + return data +} + +export async function getCall(id: number): Promise { + const { data } = await client.get<{ data: Call }>(`/calls/${id}`) + return data.data +} + +export async function createCall(call: Partial): Promise { + const { data } = await client.post('/calls', call) + return data +} + +export interface ReferralPayload { + name: string + phone: string + phone_type?: 'mobile' | 'landline' | 'extension' + role?: string + relation_description?: string + description?: string + make_primary?: boolean + next_follow_up_at?: string +} + +export async function registerResult(callId: number, result: string, notes?: string, nextFollowUpAt?: string, referral?: ReferralPayload): Promise { + const { data } = await client.post('/calls/register-result', { + call_id: callId, + result, + notes, + next_follow_up_at: nextFollowUpAt, + referral, + }) + return 'data' in data ? data.data : data +} diff --git a/frontend/src/api/campaigns.ts b/frontend/src/api/campaigns.ts new file mode 100644 index 0000000..e6451d0 --- /dev/null +++ b/frontend/src/api/campaigns.ts @@ -0,0 +1,26 @@ +import client from './client' +import type { Campaign, PaginatedResponse } from '@/types' + +export async function getCampaigns(params?: Record): Promise> { + const { data } = await client.get('/campaigns', { params }) + return data +} + +export async function getCampaign(id: number): Promise { + const { data } = await client.get<{ data: Campaign }>(`/campaigns/${id}`) + return data.data +} + +export async function createCampaign(c: Partial): Promise { + const { data } = await client.post<{ data: Campaign }>('/campaigns', c) + return data.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 deleteCampaign(id: number): Promise { + await client.delete(`/campaigns/${id}`) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..adb61a1 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,50 @@ +import axios from 'axios' +import type { AxiosError } from 'axios' + +const client = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + withCredentials: true, + withXSRFToken: true, +}) + +client.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401 && window.location.pathname !== '/login') { + window.location.href = '/login' + } + return Promise.reject(error) + }, +) + +export async function csrf(): Promise { + await axios.get('/sanctum/csrf-cookie', { withCredentials: true, withXSRFToken: true }) +} + +export function getApiErrorMessage(error: unknown, fallback = 'خطا در انجام عملیات'): string { + const axiosError = error as AxiosError<{ message?: string; errors?: Record }> + if (!axiosError.response) { + return 'اتصال به سرور برقرار نیست. لطفاً backend را اجرا یا ری‌استارت کنید.' + } + if (import.meta.env.DEV && axiosError.response.status === 502) { + return 'Backend server is not running or API proxy is misconfigured.' + } + if (axiosError.response.status === 403) { + return axiosError.response.data?.message || 'شما مجوز انجام این عملیات را ندارید.' + } + const errors = axiosError.response?.data?.errors + const firstError = errors ? Object.values(errors).flat()[0] : undefined + if (axiosError.response.status === 422) { + return firstError || axiosError.response.data?.message || 'اطلاعات واردشده معتبر نیست. لطفاً فیلدها را بررسی کنید.' + } + if (axiosError.response.status >= 500) { + return 'خطای داخلی سرور رخ داد. لطفاً چند لحظه دیگر دوباره تلاش کنید.' + } + return firstError || axiosError.response?.data?.message || fallback +} + +export default client diff --git a/frontend/src/api/coreCrm.ts b/frontend/src/api/coreCrm.ts new file mode 100644 index 0000000..6b229f3 --- /dev/null +++ b/frontend/src/api/coreCrm.ts @@ -0,0 +1,91 @@ +import client from './client' +import type { Attachment, Company, Contact, Deal, DuplicateSuggestion, PaginatedResponse, Product, TimelineItem } from '@/types' + +export async function getCompanies(params?: Record): Promise> { + const { data } = await client.get('/companies', { params }) + return data +} + +export async function createCompany(payload: Partial & { block_duplicates?: boolean }): Promise { + const { data } = await client.post('/companies', payload) + return data +} + +export async function updateCompany(id: number, payload: Partial): Promise { + const { data } = await client.put(`/companies/${id}`, payload) + return data +} + +export async function mergeCompany(sourceId: number, targetId: number): Promise { + const { data } = await client.post(`/companies/${sourceId}/merge`, { target_id: targetId }) + return data +} + +export async function getDeals(params?: Record): Promise> { + const { data } = await client.get('/deals', { params }) + return data +} + +export async function createDeal(payload: Partial): Promise { + const { data } = await client.post('/deals', payload) + return data +} + +export async function updateDeal(id: number, payload: Partial): Promise { + const { data } = await client.put(`/deals/${id}`, payload) + return data +} + +export async function getProducts(params?: Record): Promise> { + const { data } = await client.get('/products', { params }) + return data +} + +export async function createProduct(payload: Partial): Promise { + const { data } = await client.post('/products', payload) + return data +} + +export async function updateProduct(id: number, payload: Partial): Promise { + const { data } = await client.put(`/products/${id}`, payload) + return data +} + +export async function getContacts(params?: Record): Promise> { + const { data } = await client.get('/contacts', { params }) + return data +} + +export async function createContact(payload: Partial & { phones: { phone: string; type?: string; status?: string }[] }): Promise { + const { data } = await client.post('/contacts', payload) + return data +} + +export async function addNote(entityType: 'lead' | 'company' | 'deal', entityId: number, content: string) { + const { data } = await client.post('/notes', { entity_type: entityType, entity_id: entityId, content }) + return data +} + +export async function getTimeline(entityType: 'lead' | 'company' | 'deal', entityId: number): Promise { + const { data } = await client.get<{ data: TimelineItem[] }>('/timeline', { params: { entity_type: entityType, entity_id: entityId } }) + return data.data +} + +export async function getAttachments(entityType: 'lead' | 'company' | 'deal', entityId: number): Promise { + const { data } = await client.get<{ data: Attachment[] }>('/attachments', { params: { entity_type: entityType, entity_id: entityId } }) + return data.data +} + +export async function uploadAttachment(entityType: 'lead' | 'company' | 'deal', entityId: number, file: File): Promise { + const formData = new FormData() + formData.append('entity_type', entityType) + formData.append('entity_id', String(entityId)) + formData.append('file', file) + const { data } = await client.post('/attachments', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) + return data +} + +export async function checkDuplicates(payload: Record & { entity_type: 'lead' | 'company' }): Promise { + const { data } = await client.post<{ data: DuplicateSuggestion[] }>('/duplicates/check', payload) + return data.data +} diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..3086ca1 --- /dev/null +++ b/frontend/src/api/dashboard.ts @@ -0,0 +1,21 @@ +import client from './client' +import type { DashboardStats } from '@/types' + +export async function getAdminDashboard(): Promise { + const { data } = await client.get('/dashboard/admin') + return unwrapStats(data) +} + +export async function getSupervisorDashboard(): Promise { + const { data } = await client.get('/dashboard/supervisor') + return unwrapStats(data) +} + +export async function getAgentDashboard(): Promise { + const { data } = await client.get('/dashboard/agent') + return unwrapStats(data) +} + +function unwrapStats(data: DashboardStats | { data: DashboardStats }): DashboardStats { + return 'data' in data ? data.data : data +} diff --git a/frontend/src/api/followups.ts b/frontend/src/api/followups.ts new file mode 100644 index 0000000..3bbeaae --- /dev/null +++ b/frontend/src/api/followups.ts @@ -0,0 +1,27 @@ +import client from './client' +import type { FollowUp, PaginatedResponse } from '@/types' + +export async function getFollowUps(params?: Record): Promise> { + const { data } = await client.get('/follow-ups', { params }) + return data +} + +export async function getTodayFollowUps(): Promise> { + const { data } = await client.get('/follow-ups/today') + return data +} + +export async function getOverdueFollowUps(): Promise> { + const { data } = await client.get('/follow-ups/overdue') + return data +} + +export async function createFollowUp(followUp: Partial): Promise { + const { data } = await client.post<{ data: FollowUp }>('/follow-ups', followUp) + return data.data +} + +export async function markFollowUpDone(id: number): Promise { + const { data } = await client.patch<{ data: FollowUp }>(`/follow-ups/${id}/mark-done`) + return data.data +} diff --git a/frontend/src/api/imports.ts b/frontend/src/api/imports.ts new file mode 100644 index 0000000..0d5416e --- /dev/null +++ b/frontend/src/api/imports.ts @@ -0,0 +1,60 @@ +import client from './client' +import type { ImportBatch } from '@/types' + +export interface ImportPreview { + batch_id: number + filename: string + headers: string[] + total_rows: number + preview: unknown[][] +} + +export interface ImportConfirmPayload { + batch_id: number + column_mapping: { + company: number + phone: number + phone_secondary?: number + first_name?: number + last_name?: number + email?: number + city?: number + province?: number + source?: number + product_interest?: number + priority?: number + lead_score?: number + interest_level?: number + notes?: number + tags?: number + } + campaign_id?: number + agent_ids?: number[] +} + +export async function uploadImport(file: File): Promise { + const formData = new FormData() + formData.append('file', file) + + const { data } = await client.post('/import/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return data +} + +export async function confirmImport(payload: ImportConfirmPayload): Promise { + const { data } = await client.post('/import/confirm', payload) + return data +} + +export async function downloadImportTemplate(): Promise { + const { data } = await client.get('/import/template', { responseType: 'blob' }) + const url = URL.createObjectURL(data) + const link = document.createElement('a') + link.href = url + link.download = 'sample-leads-b2b.xlsx' + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) +} diff --git a/frontend/src/api/leadStatuses.ts b/frontend/src/api/leadStatuses.ts new file mode 100644 index 0000000..c7d1717 --- /dev/null +++ b/frontend/src/api/leadStatuses.ts @@ -0,0 +1,21 @@ +import client from './client' +import type { LeadStatus } from '@/types' + +export async function getLeadStatuses(): Promise { + const { data } = await client.get('/lead-statuses') + return Array.isArray(data) ? data : data.data +} + +export async function createLeadStatus(s: Partial): Promise { + const { data } = await client.post<{ data: LeadStatus }>('/lead-statuses', s) + return data.data +} + +export async function updateLeadStatus(id: number, s: Partial): Promise { + const { data } = await client.put<{ data: LeadStatus }>(`/lead-statuses/${id}`, s) + return data.data +} + +export async function deleteLeadStatus(id: number): Promise { + await client.delete(`/lead-statuses/${id}`) +} diff --git a/frontend/src/api/leads.ts b/frontend/src/api/leads.ts new file mode 100644 index 0000000..5ada2b0 --- /dev/null +++ b/frontend/src/api/leads.ts @@ -0,0 +1,126 @@ +import client from './client' +import type { Contact, Lead, PaginatedResponse, PipelineStage } from '@/types' + +export interface LeadFilters { + search?: string + status_id?: number + campaign_id?: number + assigned_to?: number + pipeline_stage_id?: number + call_result?: string + interest_level?: string + overdue_follow_up?: boolean + source?: string + date_from?: string + date_to?: string + page?: number + per_page?: number + per_stage?: number + sort?: string + direction?: 'asc' | 'desc' + sort_field?: string + sort_direction?: 'asc' | 'desc' +} + +export interface FunnelStageGroup { + stage: PipelineStage + count: number + leads: Lead[] + pagination: { + current_page: number + last_page: number + per_page: number + total: number + } +} + +export interface FunnelResponse { + total: number + stages: FunnelStageGroup[] +} + +export type LeadPayload = Omit, 'notes'> & { + notes?: string | null +} + +export async function getLeads(filters: LeadFilters = {}): Promise> { + const { data } = await client.get('/leads', { params: normalizeLeadFilters(filters) }) + return data +} + +export async function getLeadFunnel(filters: LeadFilters & { per_stage?: number } = {}): Promise { + const { data } = await client.get('/leads/funnel', { params: normalizeLeadFilters(filters) }) + return data +} + +export async function exportLeads(filters: LeadFilters = {}): Promise { + const { data } = await client.get('/leads/export', { + params: normalizeLeadFilters(filters), + responseType: 'blob', + }) + const url = URL.createObjectURL(data) + const link = document.createElement('a') + link.href = url + link.download = 'leads-export.csv' + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) +} + +export async function getLead(id: number): Promise { + const { data } = await client.get(`/leads/${id}`) + return 'data' in data ? data.data : data +} + +export async function createLead(lead: LeadPayload): Promise { + const { data } = await client.post('/leads', lead) + return 'data' in data ? data.data : data +} + +export async function updateLead(id: number, lead: Partial): Promise { + const { data } = await client.put(`/leads/${id}`, lead) + return 'data' in data ? data.data : data +} + +export async function deleteLead(id: number): Promise { + await client.delete(`/leads/${id}`) +} + +export async function deleteLeads(ids: number[]): Promise<{ deleted: number; message?: string }> { + const { data } = await client.delete<{ deleted: number; message?: string }>('/leads/bulk-delete', { + data: { ids }, + }) + return data +} + +export async function getNextLead(): Promise { + const { data } = await client.get('/leads/next') + return 'data' in data ? data.data : data +} + +export async function createLeadContact(leadId: number, payload: { + name: string + role?: string + description?: string + status?: string + is_primary?: boolean + primary_reason?: string + phones: { phone: string; type?: string; status?: string }[] +}): Promise { + const { data } = await client.post(`/leads/${leadId}/contacts`, payload) + return 'data' in data ? data.data : data +} + +export async function setPrimaryContact(contactId: number, reason?: string): Promise { + const { data } = await client.patch(`/contacts/${contactId}/primary`, { reason }) + return 'data' in data ? data.data : data +} + +function normalizeLeadFilters(filters: T): T & { sort_field?: string; sort_direction?: string } { + return { + ...filters, + sort_field: filters.sort_field ?? filters.sort, + sort_direction: filters.sort_direction ?? filters.direction, + } +} diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts new file mode 100644 index 0000000..166813f --- /dev/null +++ b/frontend/src/api/notifications.ts @@ -0,0 +1,20 @@ +import client from './client' +import type { InternalNotification, PaginatedResponse } from '@/types' + +export async function getNotifications(): Promise> { + const { data } = await client.get('/notifications') + return data +} + +export async function getUnreadCount(): Promise { + const { data } = await client.get<{ count?: number; data?: number }>('/notifications/unread-count') + return data.count ?? data.data ?? 0 +} + +export async function markRead(id: number): Promise { + await client.patch(`/notifications/${id}/read`) +} + +export async function markAllRead(): Promise { + await client.patch('/notifications/read-all') +} diff --git a/frontend/src/api/pipeline.ts b/frontend/src/api/pipeline.ts new file mode 100644 index 0000000..5a14320 --- /dev/null +++ b/frontend/src/api/pipeline.ts @@ -0,0 +1,28 @@ +import client from './client' +import type { PipelineStage } from '@/types' + +export async function getPipelineStages(): Promise { + const { data } = await client.get('/pipeline-stages') + return Array.isArray(data) ? data : data.data +} + +export async function moveLeadToStage(leadId: number, stageId: number) { + const { data } = await client.put(`/pipeline/${stageId}/lead/${leadId}`, { lead_id: leadId }) + return data +} + +export interface MoveLeadPayload { + next_follow_up_at?: string + final_result?: 'موفق' | 'ناموفق' + lost_reason?: string + deal_value?: number + sold_product?: string + contract_date?: string + payment_status?: string + customer_notes?: string +} + +export async function moveLeadToStageWithDetails(leadId: number, stageId: number, payload: MoveLeadPayload) { + const { data } = await client.put(`/pipeline/${stageId}/lead/${leadId}`, { lead_id: leadId, ...payload }) + return data +} diff --git a/frontend/src/api/qualityReviews.ts b/frontend/src/api/qualityReviews.ts new file mode 100644 index 0000000..a77febb --- /dev/null +++ b/frontend/src/api/qualityReviews.ts @@ -0,0 +1,17 @@ +import client from './client' +import type { PaginatedResponse, QualityReview } from '@/types' + +export async function getQualityReviews(params?: Record): Promise> { + const { data } = await client.get('/quality-reviews', { params }) + return data +} + +export async function createQualityReview(r: Partial): Promise { + const { data } = await client.post<{ data: QualityReview }>('/quality-reviews', r) + return 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 +} diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts new file mode 100644 index 0000000..b1ba826 --- /dev/null +++ b/frontend/src/api/reports.ts @@ -0,0 +1,76 @@ +import client from './client' + +export async function getAgentPerformance(params?: Record) { + const { data } = await client.get('/reports/agent-performance', { params }) + return data +} + +export async function getTeamPerformance(params?: Record) { + const { data } = await client.get('/reports/team-performance', { params }) + return data +} + +export async function getCampaignReport(id: number, params?: Record) { + const { data } = await client.get(`/reports/campaign/${id}`, { params }) + return data +} + +export async function getConversionReport(params?: Record) { + const { data } = await client.get('/reports/conversion', { params }) + return data +} + +export async function getCallReport(params?: Record) { + const { data } = await client.get('/reports/call', { params }) + return data +} + +export async function getFollowUpReport(params?: Record) { + const { data } = await client.get('/reports/follow-up', { params }) + return data +} + +export async function getLostReasonReport(params?: Record) { + const { data } = await client.get('/reports/lost-reasons', { params }) + return data +} + +export async function getSourcePerformanceReport(params?: Record) { + const { data } = await client.get('/reports/source-performance', { params }) + return data +} + +export async function getDuplicateReport(params?: Record) { + const { data } = await client.get('/reports/duplicates', { params }) + return data +} + +export async function getImportQualityReport(params?: Record) { + const { data } = await client.get('/reports/import-quality', { params }) + return data +} + +export async function getCallQualityReport(params?: Record) { + const { data } = await client.get('/reports/call-quality', { params }) + return data +} + +export async function getBestContactTimeReport(params?: Record) { + const { data } = await client.get('/reports/best-contact-time', { params }) + return data +} + +export async function exportReportCsv(params?: Record) { + const { data } = await client.get('/reports/export/excel', { + params, + responseType: 'blob', + }) + const url = URL.createObjectURL(data) + const link = document.createElement('a') + link.href = url + link.download = `crm-report-${params?.type ?? 'report'}.csv` + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) +} diff --git a/frontend/src/api/roles.ts b/frontend/src/api/roles.ts new file mode 100644 index 0000000..2889919 --- /dev/null +++ b/frontend/src/api/roles.ts @@ -0,0 +1,35 @@ +import client from './client' +import type { Permission, Role } from '@/types' + +export async function getRoles(): Promise { + const { data } = await client.get('/roles') + return Array.isArray(data) ? data : data.data +} + +export async function getRole(id: number): Promise { + const { data } = await client.get<{ data: Role }>(`/roles/${id}`) + return data.data +} + +export async function createRole(role: Partial): Promise { + const { data } = await client.post<{ data: Role }>('/roles', role) + return data.data +} + +export async function updateRole(id: number, role: Partial): Promise { + const { data } = await client.put<{ data: Role }>(`/roles/${id}`, role) + return data.data +} + +export async function deleteRole(id: number): Promise { + await client.delete(`/roles/${id}`) +} + +export async function syncPermissions(roleId: number, permissions: string[]): Promise { + await client.post(`/roles/${roleId}/sync-permissions`, { permissions }) +} + +export async function getPermissions(): Promise { + const { data } = await client.get<{ data: Permission[] }>('/permissions') + return data.data +} diff --git a/frontend/src/api/scripts.ts b/frontend/src/api/scripts.ts new file mode 100644 index 0000000..4026a6a --- /dev/null +++ b/frontend/src/api/scripts.ts @@ -0,0 +1,26 @@ +import client from './client' +import type { SalesScript } from '@/types' + +export async function getScripts(): Promise { + const { data } = await client.get<{ data: SalesScript[] }>('/scripts') + return data.data +} + +export async function getScript(id: number): Promise { + const { data } = await client.get<{ data: SalesScript }>(`/scripts/${id}`) + return data.data +} + +export async function createScript(s: Partial): Promise { + const { data } = await client.post<{ data: SalesScript }>('/scripts', s) + return 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 deleteScript(id: number): Promise { + await client.delete(`/scripts/${id}`) +} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 0000000..f2c512d --- /dev/null +++ b/frontend/src/api/settings.ts @@ -0,0 +1,30 @@ +import client from './client' +import type { Setting } from '@/types' + +export interface SettingUpdate { + key: string + value: string + group: string + type: string +} + +export async function getSettings(): Promise { + const { data } = await client.get | { data: Setting[] }>('/settings') + if (Array.isArray(data)) return data + if ('data' in data && Array.isArray(data.data)) return data.data + return Object.values(data).flat() +} + +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[] }> { + const { data } = await client.post('/settings/voip/test') + return data +} + +export async function getPublicSettings(): Promise> { + const { data } = await client.get('/settings/public') + return data +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..dbaab03 --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,69 @@ +import client from './client' +import type { PaginatedResponse, User } from '@/types' + +export type CreateUserPayload = Pick & { + password: string + role: string + phone?: string | null + voip_extension?: string | null + is_active?: boolean +} + +export async function getUsers(params?: Record): Promise> { + const { data } = await client.get('/users', { params }) + return { + ...data, + data: data.data.map(normalizeUser), + } +} + +export async function getAgents(): Promise { + const { data } = await client.get('/agents') + const agents = Array.isArray(data) ? data : data.data + return agents.map(normalizeUser) +} + +export async function getUser(id: number): Promise { + const { data } = await client.get(`/users/${id}`) + return normalizeUser(unwrapUser(data)) +} + +export async function createUser(user: CreateUserPayload): Promise { + const { data } = await client.post('/users', user) + return normalizeUser(unwrapUser(data)) +} + +export async function updateUser(id: number, user: Partial): Promise { + const { data } = await client.put(`/users/${id}`, user) + return normalizeUser(unwrapUser(data)) +} + +export async function deleteUser(id: number): Promise { + await client.delete(`/users/${id}`) +} + +export async function toggleUserActive(id: number): Promise { + const { data } = await client.patch(`/users/${id}/toggle-active`) + return normalizeUser('data' in data ? data.data : data) +} + +export async function assignRole(userId: number, role: string): Promise { + await client.post(`/users/${userId}/assign-role`, { role }) +} + +type UserWithRawRoles = Omit & { + roles?: Array +} + +function unwrapUser(data: UserWithRawRoles | { data: UserWithRawRoles }): UserWithRawRoles { + return 'data' in data ? data.data : data +} + +function normalizeUser(user: UserWithRawRoles): User { + return { + ...user, + roles: (user.roles ?? []) + .map((role) => typeof role === 'string' ? role : role.name) + .filter((role): role is string => Boolean(role)), + } +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 zcmV+cGycqpP)V|)f$;Qooc7=_G zlYe)HToTQIc!$)^+J1M1y0*T%w!p~7%ux`!eRhO?c80XDxKQ*R^lUUMnA>6NT^?feoZ8xxvP32D&s-9ow zqjcM}eesrC)NeDmsf)*P7wJ|K!&xP%Zy4iI8lF)Tv2!reW)tCzg_1=PmOwd1SQfxa z8;58t!=z~Ba7CYlNWVG>he8aRPY|+-JmozNhn!#9i#77Aa_Edt$ijyCWL#=~I>~2X zZNrQ8I0=D+NWD4pq=7~(i zhfThMNw|G>g^y9pGzxX7ZSApl@tIxFcs{p#MX{Ax&XZT+cR#U+OWc@S)pkIuI}dzu zH?^Q=<(y&Vq-oxSLfc0Zmq81bjZWf}RnssBaD6}2g-XJHLcN_|*IOu>m|x$nbm(?E zyNy!Zp=RroS;?Vg*kmoJYBi!n5{_^@rA!)=t#a^;N$8GL!*DsQb}`yvEuX!G@||An znOfUZAevPrkV_qjl|<~3QRZzG&h@C9Y5z zqpNH4xqbF_InIPh)kX}Vn^5kyed|mOuq+2>M;v~KO37a#yrEn3XDqtOl=rc6_KZ!; zreo)DFVB4|>1Zd(bvMI%8uM;3!)YMYu&cG?(PE!B~y@3yKBMt|R zAf=I16tFwPsl)!jDqvYkLHaAQ+f@W1m6F5aZvwhm4JL z{_l)@b;)mDSzle2gyFP5-r1x-5X{G}ot%VyWP@vEW80!Q=f%RTfpg>B*TA^pyWYUQ z<=xPtz}WcZ!;rFl4m1D&FFHv?K~#9!?A%+fn=lXt;9!Fc#kQ;zk~gZFsH z8e5iu@c_pzX&qb8&Dum*oXwB+fm6l6gFfC|o*wgEiy6tw~&co z9Vd_4)P%wP-KwQW7|lN-znGK#?N+j24U=$982myIBM+vsiKsc*@4-rwJxuAaHKna6 zT3wi!C~a4ZKH03qU}_1bKyx0&$CaK7_%Z+Kl$)fF5^op zZApQF2TvDav!s|krTjw-8US6ep z%!VmX4luub+fseQz_D9ATJQ?iQQwD}TZz{-yo#l12a%+7bT@E(X-hyaVS-5vuXc#^ zx^w;L21;NphGVoj*{s3f4dme0y2LC=G1-7THd`#z?;tuC{^9k(dM{Rf2GOxg7Jzho z7nSZHl7?M9kdalX`)YgoKEfiae5+;$(OGeN1eqxrv!ZCVKyH>xiyNqfe8xzY8*7)H zQls8KMp)F4D>ED;idMOU^^WhVF@q>ZSmeB0y~qC~|DB648hr%Sh|*T(4q|w2l?m2+ zvBVw3@7+Mz?^Yc#+se6KM;a<=(W-I>k)$-qL2V*t}VaW`;?P4)WqI%maIDq8!oUcSYAD`}wWjkSyAVsnF65#2zQ zZ>(K*TlS(E#4y$4Zq+e^_&}d)q20hCe3!LfLYP%nQpLJ~gM6a1hJlz3)aS<9C9me| zAcmJ#>tOwBy{HoP0Sm1&_(E+S@6 zgBIFUoei8zJmdpiq8q5=OY7t@`)JWxn_&GvKVr=Zdb_pEL_j|=?f;WK^U9Q0efd#K z9q7SfJTl4pmA$jsZ5oK8@O9#!I3Cv-kL)<8SalSsp#dcpvJ}Nz#G6FC0%9|7Fi#8; zGDJXtj!&GljT3*HE@0EE>G8Se&d)*nkqe}-?`3vPl&UqK?xG z!3XJ4M-x`EuQjhBbu?ik-)rmIt=DF_N?TVMP)8Gjn)TZ2V%H|zENbeix}kOxd@0}Q z>)HuH6Ean!uS#~4g2Ne2WsMGel|h%j9*W_quQheG^JqmKhc*RYzp0wKlGjBq2VzY_ zgOv8WC1+%W=W)k)Yp_`8kfE=uiiwOZTXi8Uj9YGr$f@yJcJ;#&-Nq~sJ7anE(@;QN z=~br%7%7`isKStX|7!1?L(apl^QvPKlrHV4S+6tNVQ*R1iGdC~WMNE1$a+=rpQmcB z>wxiLIBvOnm;u*;9Y!kJdy(T4lk|8>JAm(&wEsFIF1$_*{>2ZNd$V6DS=SfrGxAv0 zzKe377JI`&o9Ljr+VnS*EwehA{f&{cKZF(6*MG5!p5MvrFA3ll{fmRG*L@6^cb;o^ z3Wm8c?Sc6$`>~VEWw(c$Y?nRO;2Q$=ulpqPtM^=1IZx;@xK0PgO7rKQ^WHVLwtgUT z%|JF{^f(VH)wLKQ%dYiu2RmchBdxL0-M?wxxul_z*{h6ZZ`>-k(vizs((vW8Lt6Z6 zY;Dt?@JWyN`O`f;&d1Mb?e%9oyRK1ql?EE5XB2(W)|D1~Rx35$H6@6)$F?)7V|zEO zI}fu0-0}8W5=6sg$fPnZ~7=tTudl?Ecb@pxbo)vni%gP-?hL|%*?62C;x6?@E`VRnJv z?fTb;k4x;TS7Cu-z%J}uy}e-pwpLQ17Q@4DC+FCdAmNKklG$`I_pyw7E{fYmw~{Fj zi?6KcVy=Wrel)EB_DWO|0CKmI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8WL0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e+bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKcw$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(%KtuV^zC&Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5Mo-0$pkyV3VV4B@Qms46M zuBxGRV@HxU7Wwx-6CB zaU*HO<_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#<Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XClkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn=Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>`zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1R$C6ja5!^ZGh;YRhhxs58qJWo9@Bceac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=RNC6nE=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-NueqTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<11$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdNK8ZDKZ?QFLU?zh30G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsbob0{xu@0TB_*>G7w0ICn zr#VoBktqHZ~XxhiKD*lcG|b;H*|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn&E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fRZa#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9& zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g(lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!wUA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?uf$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI)-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr>)f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p= z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z>vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=Gp_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQA zvqp;$kmGJY>lLsN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^&#TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpYR}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+PmNABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx|$S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6}_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh+g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt!MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLjlm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?&Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t`F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/AgentRouteRedirect.tsx b/frontend/src/components/AgentRouteRedirect.tsx new file mode 100644 index 0000000..7db614f --- /dev/null +++ b/frontend/src/components/AgentRouteRedirect.tsx @@ -0,0 +1,13 @@ +import { Navigate } from 'react-router-dom' +import { useAuthStore } from '@/stores/authStore' +import { getSalesAgentPreferredRoute, isSalesAgent } from '@/utils/agentView' + +export default function AgentRouteRedirect() { + const user = useAuthStore((s) => s.user) + + if (!isSalesAgent(user)) { + return + } + + return +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..ee1161f --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,34 @@ +import { useAuthStore } from '@/stores/authStore' +import { Navigate } from 'react-router-dom' +import type { ReactNode } from 'react' +import Spinner from './ui/Spinner' + +interface Props { + children: ReactNode + roles?: string[] + permission?: string +} + +export default function ProtectedRoute({ children, roles, permission }: Props) { + const { user, ready } = useAuthStore() + + if (!ready) { + return ( +
+ +
+ ) + } + + if (!user) return + + if (roles && !roles.some((r) => user.roles?.includes(r))) { + return + } + + if (permission && !user.permissions?.includes(permission)) { + return + } + + return <>{children} +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..8e7a3f0 --- /dev/null +++ b/frontend/src/components/layout/AppLayout.tsx @@ -0,0 +1,39 @@ +import { Outlet } from 'react-router-dom' +import Sidebar from './Sidebar' +import Header from './Header' +import ToastContainer from '@/components/ui/Toast' +import { useEffect } from 'react' +import { useNotificationStore } from '@/stores/notificationStore' +import { useAuthStore } from '@/stores/authStore' +import { useSettingsStore } from '@/stores/settingsStore' + +export default function AppLayout() { + const { user } = useAuthStore() + const fetchUnreadCount = useNotificationStore((s) => s.fetchUnreadCount) + const fetchPublicSettings = useSettingsStore((s) => s.fetchPublicSettings) + + useEffect(() => { + fetchPublicSettings().catch(() => {}) + }, [fetchPublicSettings]) + + useEffect(() => { + if (user) { + fetchUnreadCount() + const interval = setInterval(fetchUnreadCount, 30000) + return () => clearInterval(interval) + } + }, [user, fetchUnreadCount]) + + return ( +
+ +
+
+
+ +
+
+ +
+ ) +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000..5f90553 --- /dev/null +++ b/frontend/src/components/layout/Header.tsx @@ -0,0 +1,52 @@ +import { useAuthStore } from '@/stores/authStore' +import { useNavigate } from 'react-router-dom' +import { useEffect, useState } from 'react' +import Icon from '@/components/ui/Icon' +import { mediaUrl } from '@/utils/media' + +export default function Header() { + const { user, logout } = useAuthStore() + const navigate = useNavigate() + const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light') + + useEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark') + localStorage.setItem('theme', theme) + }, [theme]) + + const handleLogout = async () => { + await logout() + navigate('/login') + } + + return ( +
+
+
+ + + +
+
+ ) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..0159855 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,148 @@ +import { NavLink, useLocation } from 'react-router-dom' +import { useAuthStore } from '@/stores/authStore' +import { useNotificationStore } from '@/stores/notificationStore' +import { useEffect, useState } from 'react' +import Icon from '@/components/ui/Icon' +import type { IconName } from '@/components/ui/Icon' +import { useSettingsStore } from '@/stores/settingsStore' + +type RoleName = 'admin' | 'supervisor' | 'agent' + +interface NavSection { + section: string + roles?: RoleName[] + items: { + label: string + icon: IconName + path: string + roles: RoleName[] + }[] +} + +const navItems: NavSection[] = [ + { + section: 'داشبورد', + items: [ + { label: 'داشبورد', icon: 'dashboard', path: '/', roles: ['admin', 'supervisor', 'agent'] }, + ], + }, + { + section: 'مدیریت', + items: [ + { label: 'لیدها', icon: 'leads', path: '/leads', roles: ['admin', 'supervisor', 'agent'] }, + { label: 'قیف فروش', icon: 'funnel', path: '/leads/funnel', roles: ['admin', 'supervisor', 'agent'] }, + { label: 'CRM فروش', icon: 'briefcase', path: '/crm', roles: ['admin', 'supervisor', 'agent'] }, + { label: 'تماس‌ها', icon: 'phone', path: '/calls', roles: ['admin', 'supervisor', 'agent'] }, + { label: 'پیگیری‌ها', icon: 'tasks', path: '/follow-ups', roles: ['admin', 'supervisor', 'agent'] }, + { label: 'کمپین‌ها', icon: 'briefcase', path: '/campaigns', roles: ['admin', 'supervisor'] }, + ], + }, + { + section: 'گزارشات', + items: [ + { label: 'گزارشات', icon: 'chart', path: '/reports', roles: ['admin', 'supervisor'] }, + ], + }, + { + section: 'مدیریت سیستم', + roles: ['admin'], + items: [ + { label: 'کاربران', icon: 'users', path: '/users', roles: ['admin'] }, + { label: 'تنظیمات', icon: 'settings', path: '/settings', roles: ['admin'] }, + ], + }, +] + +export default function Sidebar() { + const { user } = useAuthStore() + const appName = useSettingsStore((s) => s.appName) + const unreadCount = useNotificationStore((s) => s.unreadCount) + const location = useLocation() + const [collapsed, setCollapsed] = useState(() => window.innerWidth < 768) + + useEffect(() => { + if (window.innerWidth < 768) { + setCollapsed(true) + } + }, [location.pathname]) + + if (!user) return null + + const userRole = (user.roles?.[0] || 'agent') as RoleName + + const filteredNav = navItems + .map((section) => ({ + ...section, + items: section.items.filter((item) => item.roles.includes(userRole)), + })) + .filter((section) => section.items.length > 0 && (!section.roles || section.roles.includes(userRole))) + + return ( + + ) +} diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 0000000..d38ac57 --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react' + +interface Props { + children: ReactNode + color?: string + className?: string +} + +export default function Badge({ children, color, className = '' }: Props) { + const bgColor = color ? `rgba(${hexToRgb(color)}, 0.15)` : undefined + const textColor = color || '#6b7280' + + return ( + + + {children} + + ) +} + +function hexToRgb(hex: string): string { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + if (!result) return '107, 114, 128' + return `${Number.parseInt(result[1], 16)}, ${Number.parseInt(result[2], 16)}, ${Number.parseInt(result[3], 16)}` +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..63f540f --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,48 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react' + +interface Props extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline' + size?: 'sm' | 'md' | 'lg' + loading?: boolean + children: ReactNode +} + +const variants = { + primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500', + secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-400', + danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', + ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-400', + outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-primary-500', +} + +const sizes = { + sm: 'px-2.5 py-1.5 text-xs', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base', +} + +export default function Button({ + variant = 'primary', + size = 'md', + loading = false, + disabled, + children, + className = '', + ...props +}: Props) { + return ( + + ) +} diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..5a0df2c --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' + +interface Props { + children: ReactNode + className?: string + title?: string + actions?: ReactNode +} + +export default function Card({ children, className = '', title, actions }: Props) { + return ( +
+ {(title || actions) && ( +
+ {title &&

{title}

} + {actions &&
{actions}
} +
+ )} +
{children}
+
+ ) +} diff --git a/frontend/src/components/ui/Icon.tsx b/frontend/src/components/ui/Icon.tsx new file mode 100644 index 0000000..2b7493f --- /dev/null +++ b/frontend/src/components/ui/Icon.tsx @@ -0,0 +1,48 @@ +import type { SVGProps } from 'react' + +export type IconName = + | 'activity' + | 'bell' + | 'briefcase' + | 'chart' + | 'chevrons' + | 'dashboard' + | 'funnel' + | 'leads' + | 'moon' + | 'phone' + | 'settings' + | 'sun' + | 'tasks' + | 'user' + | 'users' + +interface Props extends SVGProps { + name: IconName +} + +const paths: Record = { + activity: ['M3 12h4l3-7 4 14 3-7h4'], + bell: ['M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9', 'M13.73 21a2 2 0 0 1-3.46 0'], + briefcase: ['M10 6V5a2 2 0 0 1 2-2h0a2 2 0 0 1 2 2v1', 'M3 8h18v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Z', 'M3 13h18'], + chart: ['M4 19V5', 'M4 19h16', 'M8 16v-5', 'M12 16V8', 'M16 16v-8'], + chevrons: ['M7 7l5 5-5 5', 'M13 7l5 5-5 5'], + dashboard: ['M4 13a8 8 0 1 1 16 0', 'M12 13l4-4', 'M5 19h14'], + funnel: ['M4 5h16l-6 7v5l-4 2v-7L4 5Z'], + leads: ['M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2', 'M9.5 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z', 'M17 11l2 2 4-4'], + moon: ['M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z'], + phone: ['M22 16.92v3a2 2 0 0 1-2.18 2A19.79 19.79 0 0 1 3.08 5.18 2 2 0 0 1 5.06 3h3a2 2 0 0 1 2 1.72c.12.9.32 1.77.59 2.61a2 2 0 0 1-.45 2.11L9 10.64a16 16 0 0 0 4.36 4.36l1.2-1.2a2 2 0 0 1 2.11-.45c.84.27 1.71.47 2.61.59A2 2 0 0 1 22 16.92Z'], + settings: ['M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z', 'M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6V20a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1H4a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6V4a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.36.25.6.6.6 1H20a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-.51 1Z'], + sun: ['M12 4V2', 'M12 22v-2', 'M4.93 4.93 3.52 3.52', 'M20.48 20.48l-1.41-1.41', 'M4 12H2', 'M22 12h-2', 'M4.93 19.07l-1.41 1.41', 'M20.48 3.52l-1.41 1.41', 'M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z'], + tasks: ['M9 6h11', 'M9 12h11', 'M9 18h11', 'M4 6h.01', 'M4 12h.01', 'M4 18h.01'], + user: ['M20 21a8 8 0 0 0-16 0', 'M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z'], + users: ['M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2', 'M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z', 'M22 21v-2a4 4 0 0 0-3-3.87', 'M16 3.13a4 4 0 0 1 0 7.75'], +} + +export default function Icon({ name, className = 'size-5', ...props }: Props) { + return ( + + ) +} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..cc1c979 --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,24 @@ +import { forwardRef } from 'react' +import type { InputHTMLAttributes } from 'react' + +interface Props extends InputHTMLAttributes { + label?: string + error?: string +} + +const Input = forwardRef(({ label, error, className = '', ...props }, ref) => ( +
+ {label && } + + {error &&

{error}

} +
+)) + +Input.displayName = 'Input' +export default Input diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx new file mode 100644 index 0000000..748052b --- /dev/null +++ b/frontend/src/components/ui/Modal.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from 'react' +import { useEffect } from 'react' + +interface Props { + open: boolean + onClose: () => void + title?: string + children: ReactNode + size?: 'sm' | 'md' | 'lg' +} + +const widths = { sm: 'max-w-sm', md: 'max-w-lg', lg: 'max-w-2xl' } + +export default function Modal({ open, onClose, title, children, size = 'md' }: Props) { + useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { + document.body.style.overflow = '' + } + }, [open]) + + if (!open) return null + + return ( +
+
+
+ {title && ( +
+

{title}

+ +
+ )} +
{children}
+
+
+ ) +} diff --git a/frontend/src/components/ui/Pagination.tsx b/frontend/src/components/ui/Pagination.tsx new file mode 100644 index 0000000..dc27e7e --- /dev/null +++ b/frontend/src/components/ui/Pagination.tsx @@ -0,0 +1,40 @@ +import Button from './Button' + +interface Props { + currentPage: number + lastPage: number + total: number + from: number + to: number + onPageChange: (page: number) => void +} + +export default function Pagination({ currentPage, lastPage, total, from, to, onPageChange }: Props) { + if (lastPage <= 1) return null + + return ( +
+ + {from}–{to} از {total} + +
+ + {Array.from({ length: lastPage }, (_, i) => i + 1).map((page) => ( + + ))} + +
+
+ ) +} diff --git a/frontend/src/components/ui/PersianDateInput.tsx b/frontend/src/components/ui/PersianDateInput.tsx new file mode 100644 index 0000000..9c0f0bc --- /dev/null +++ b/frontend/src/components/ui/PersianDateInput.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react' +import Input from './Input' +import { formatJalaliDate, formatJalaliDateTime, jalaliInputToGregorian } from '@/utils/date' + +interface Props { + label?: string + value: string + onChange: (value: string) => void + includeTime?: boolean + required?: boolean + placeholder?: string +} + +export default function PersianDateInput({ + label, + value, + onChange, + includeTime = false, + required, + placeholder, +}: Props) { + const [displayValue, setDisplayValue] = useState('') + + useEffect(() => { + setDisplayValue(includeTime ? formatJalaliDateTime(value) : formatJalaliDate(value)) + }, [includeTime, value]) + + const commit = (nextValue: string) => { + setDisplayValue(nextValue) + const gregorian = jalaliInputToGregorian(nextValue, includeTime) + if (gregorian) onChange(gregorian) + if (!nextValue.trim()) onChange('') + } + + return ( + setDisplayValue(event.target.value)} + onBlur={(event) => commit(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') commit((event.target as HTMLInputElement).value) + }} + placeholder={placeholder ?? (includeTime ? '۱۴۰۳/۰۷/۱۵ ۱۴:۳۰' : '۱۴۰۳/۰۷/۱۵')} + required={required} + dir="ltr" + /> + ) +} diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx new file mode 100644 index 0000000..1b94ad9 --- /dev/null +++ b/frontend/src/components/ui/Select.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from 'react' +import type { SelectHTMLAttributes } from 'react' + +interface Option { + value: string | number + label: string +} + +interface Props extends SelectHTMLAttributes { + label?: string + error?: string + options: Option[] + placeholder?: string +} + +const Select = forwardRef( + ({ label, error, options, placeholder, className = '', ...props }, ref) => ( +
+ {label && } + + {error &&

{error}

} +
+ ), +) + +Select.displayName = 'Select' +export default Select diff --git a/frontend/src/components/ui/Spinner.tsx b/frontend/src/components/ui/Spinner.tsx new file mode 100644 index 0000000..4c83ac8 --- /dev/null +++ b/frontend/src/components/ui/Spinner.tsx @@ -0,0 +1,15 @@ +interface Props { + size?: 'sm' | 'md' | 'lg' + className?: string +} + +const sizes = { sm: 'size-4', md: 'size-8', lg: 'size-12' } + +export default function Spinner({ size = 'md', className = '' }: Props) { + return ( + + + + + ) +} diff --git a/frontend/src/components/ui/Table.tsx b/frontend/src/components/ui/Table.tsx new file mode 100644 index 0000000..836f958 --- /dev/null +++ b/frontend/src/components/ui/Table.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from 'react' +import Spinner from './Spinner' + +export interface Column { + key: string + header: string + render?: (item: T) => ReactNode + sortable?: boolean + className?: string +} + +interface Props { + columns: Column[] + data: T[] + loading?: boolean + onRowClick?: (item: T) => void + emptyMessage?: string +} + +export default function Table({ + columns, + data, + loading, + onRowClick, + emptyMessage = 'داده‌ای یافت نشد', +}: Props) { + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {loading ? ( + + + + ) : data.length === 0 ? ( + + + + ) : ( + data.map((item, i) => ( + onRowClick?.(item)} + > + {columns.map((col) => ( + + ))} + + )) + )} + +
+ {col.header} +
+ +
+ {emptyMessage} +
+ {col.render ? col.render(item) : ((item as Record)[col.key] as ReactNode) ?? '—'} +
+
+ ) +} diff --git a/frontend/src/components/ui/Textarea.tsx b/frontend/src/components/ui/Textarea.tsx new file mode 100644 index 0000000..e8f6bda --- /dev/null +++ b/frontend/src/components/ui/Textarea.tsx @@ -0,0 +1,25 @@ +import { forwardRef } from 'react' +import type { TextareaHTMLAttributes } from 'react' + +interface Props extends TextareaHTMLAttributes { + label?: string + error?: string +} + +const Textarea = forwardRef(({ label, error, className = '', ...props }, ref) => ( +
+ {label && } +