From 3a8db5cae6ce4419a3e343e8dddb0b2057387ac4 Mon Sep 17 00:00:00 2001 From: phuongtc Date: Sat, 2 May 2026 04:47:10 +0000 Subject: [PATCH] feat: granular permissions, export backup, user/role management - PermissionSeeder: 29 permissions with role mapping (admin/manager/staff) - Policies updated to use hasPermissionTo() instead of hasRole() - ExportBackup command: JSON per-table backup with chunk processing - UserResource: CRUD users with role assignment (admin only) - RoleResource: CRUD roles with permission assignment (admin only) - UserPolicy + RolePolicy: admin-only access control - ImportCskh: detailed skip logging with color in dry-run mode - Widgets: permission-based visibility checks - Tests: 8/8 pass (21 assertions) --- AGENTS.md | 72 +- CODEBASE_SNAPSHOT.md | 50 +- PROGRESS.md | 11 + TASKS_ROADMAP.md | 8 + app/Console/Commands/ExportBackup.php | 146 ++++ app/Console/Commands/ImportCskh.php | 125 ++- .../Resources/Feedback/Pages/EditFeedback.php | 4 +- .../InteractionsRelationManager.php | 2 +- .../Feedback/Schemas/FeedbackForm.php | 85 +- .../Feedback/Tables/FeedbackTable.php | 42 +- .../Resources/Roles/Pages/CreateRole.php | 22 + .../Resources/Roles/Pages/EditRole.php | 35 + .../Resources/Roles/Pages/ListRoles.php | 19 + app/Filament/Resources/Roles/RoleResource.php | 57 ++ .../Resources/Roles/Schemas/RoleForm.php | 37 + .../Resources/Roles/Tables/RolesTable.php | 63 ++ .../Resources/Users/Pages/CreateUser.php | 20 + .../Resources/Users/Pages/EditUser.php | 27 + .../Resources/Users/Pages/ListUsers.php | 37 + .../Resources/Users/Schemas/UserForm.php | 42 + .../Resources/Users/Tables/UsersTable.php | 59 ++ app/Filament/Resources/Users/UserResource.php | 57 ++ .../Widgets/AvgProcessingTimeWidget.php | 27 +- .../Widgets/HandlerPerformanceWidget.php | 33 +- .../Widgets/ResolvedTicketsWidget.php | 37 +- app/Policies/ContractPolicy.php | 10 +- app/Policies/CustomerPolicy.php | 10 +- app/Policies/FeedbackChannelPolicy.php | 24 +- app/Policies/FeedbackPolicy.php | 10 +- app/Policies/ProductPolicy.php | 10 +- app/Policies/RolePolicy.php | 44 + app/Policies/UserPolicy.php | 43 + app/Providers/Filament/AdminPanelProvider.php | 23 +- app/Services/ClosingService.php | 2 +- database/seeders/AfterSalesSeeder.php | 2 + database/seeders/PermissionSeeder.php | 111 +++ public/css/filament-custom.css | 782 +++++++++++------- public/favicon.svg | 12 + tests/Feature/ClosingPermissionTest.php | 27 +- tests/Feature/TransferFlowTest.php | 17 +- 40 files changed, 1804 insertions(+), 440 deletions(-) create mode 100644 app/Console/Commands/ExportBackup.php create mode 100644 app/Filament/Resources/Roles/Pages/CreateRole.php create mode 100644 app/Filament/Resources/Roles/Pages/EditRole.php create mode 100644 app/Filament/Resources/Roles/Pages/ListRoles.php create mode 100644 app/Filament/Resources/Roles/RoleResource.php create mode 100644 app/Filament/Resources/Roles/Schemas/RoleForm.php create mode 100644 app/Filament/Resources/Roles/Tables/RolesTable.php create mode 100644 app/Filament/Resources/Users/Pages/CreateUser.php create mode 100644 app/Filament/Resources/Users/Pages/EditUser.php create mode 100644 app/Filament/Resources/Users/Pages/ListUsers.php create mode 100644 app/Filament/Resources/Users/Schemas/UserForm.php create mode 100644 app/Filament/Resources/Users/Tables/UsersTable.php create mode 100644 app/Filament/Resources/Users/UserResource.php create mode 100644 app/Policies/RolePolicy.php create mode 100644 app/Policies/UserPolicy.php create mode 100644 database/seeders/PermissionSeeder.php create mode 100644 public/favicon.svg diff --git a/AGENTS.md b/AGENTS.md index 1c3ed547..5c4bf0ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,11 @@ php artisan db:seed php artisan route:list php artisan storage:link # needed for file uploads php artisan test # run pest tests + +# Export database backup (JSON format, one file per table) +php artisan app:export-backup +php artisan app:export-backup --path=/custom/path --pretty +php artisan app:export-backup --tables=feedback,customers,products ``` ## Directory Structure @@ -222,7 +227,9 @@ Dashboard ├── Products (building-storefront, sort=1) ├── Customers (user-group, sort=2) ├── Channels (inbox-stack, sort=3) - └── Tags (tag, sort=default) + ├── Tags (tag, sort=default) + ├── Users (users, sort=5) — admin only + └── Roles (shield-check, sort=6) — admin only ``` Dashboard widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget, HandlerPerformanceWidget (admin/manager only) @@ -231,18 +238,61 @@ Dashboard widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget, HandlerPerfor Roles: `admin`, `manager`, `staff` +### Granular Permissions (Spatie Permission) + +**Feedback:** `view-feedback`, `create-feedback`, `update-feedback`, `delete-feedback`, `add-interaction`, `close-ticket`, `transfer-department` + +**Products:** `view-product`, `create-product`, `update-product`, `delete-product` + +**Customers:** `view-customer`, `create-customer`, `update-customer`, `delete-customer` + +**Contracts:** `view-contract`, `create-contract`, `update-contract`, `delete-contract` + +**Channels:** `view-channel`, `create-channel`, `update-channel`, `delete-channel` + +**Tags:** `view-tag`, `create-tag`, `update-tag`, `delete-tag` + +**Dashboard:** `view-dashboard-widgets`, `export-data` + +### Permission Matrix + | Permission | admin | manager | staff | |-----------|-------|---------|-------| -| View products/customers/channels/tags | ✓ | ✓ | ✓ | -| Create/Update products/customers/channels | ✓ | ✓ | ✗ | -| Delete anything | ✓ | ✗ | ✗ | -| View feedbacks | ✓ | ✓ | ✓ | -| Create/Update feedbacks | ✓ | ✓ | ✓ | -| Delete feedbacks | ✓ | ✓ | ✗ | -| Add interactions | ✓ | ✓ | ✓ | -| Close ticket (status→closed) | ✓ | ✓ | ✗ (403) | -| Transfer department | ✓ | ✓ | ✗ | -| Dashboard widgets | ✓ | ✓ | ✗ | +| view-feedback | ✓ | ✓ | ✓ | +| create-feedback | ✓ | ✓ | ✓ | +| update-feedback | ✓ | ✓ | ✓ | +| delete-feedback | ✓ | ✓ | ✗ | +| add-interaction | ✓ | ✓ | ✓ | +| close-ticket | ✓ | ✓ | ✗ | +| transfer-department | ✓ | ✓ | ✗ | +| view-product | ✓ | ✓ | ✓ | +| create/update-product | ✓ | ✓ | ✗ | +| delete-product | ✓ | ✗ | ✗ | +| view-customer | ✓ | ✓ | ✓ | +| create/update-customer | ✓ | ✓ | ✗ | +| delete-customer | ✓ | ✗ | ✗ | +| view-contract | ✓ | ✓ | ✓ | +| create/update-contract | ✓ | ✓ | ✗ | +| delete-contract | ✓ | ✗ | ✗ | +| view-channel/tag | ✓ | ✓ | ✓ | +| create/update-channel/tag | ✓ | ✓ | ✗ | +| delete-channel/tag | ✓ | ✗ | ✗ | +| view-dashboard-widgets | ✓ | ✓ | ✗ | +| export-data | ✓ | ✗ | ✗ | + +### Permission check patterns +```php +// In Policies +$user->hasPermissionTo('close-ticket'); +$user->hasPermissionTo('view-feedback'); + +// In Filament +auth()->user()->hasPermissionTo('transfer-department'); +auth()->user()->hasPermissionTo('view-dashboard-widgets'); + +// Role check (still used for admin-only guards) +$user->hasRole('admin'); +``` ## Filament Key Patterns (v5.6) diff --git a/CODEBASE_SNAPSHOT.md b/CODEBASE_SNAPSHOT.md index ad259bee..94e6d349 100644 --- a/CODEBASE_SNAPSHOT.md +++ b/CODEBASE_SNAPSHOT.md @@ -100,13 +100,34 @@ app/ │ │ │ └── ProductForm.php │ │ └── Tables/ │ │ └── ProductsTable.php +│ │ └── Users/ # NEW +│ │ ├── UserResource.php # Admin-only user management +│ │ ├── Pages/ +│ │ │ ├── CreateUser.php +│ │ │ ├── EditUser.php +│ │ │ └── ListUsers.php # Tabs: All/Admin/Manager/Staff +│ │ ├── Schemas/ +│ │ │ └── UserForm.php # name, email, password, role (dynamic) +│ │ └── Tables/ +│ │ └── UsersTable.php # name, email, role badge +│ │ └── Roles/ # NEW +│ │ ├── RoleResource.php # Admin-only role management +│ │ ├── Pages/ +│ │ │ ├── CreateRole.php +│ │ │ ├── EditRole.php +│ │ │ └── ListRoles.php +│ │ ├── Schemas/ +│ │ │ └── RoleForm.php # name + permissions checkbox list +│ │ └── Tables/ +│ │ └── RolesTable.php # name, permissions count, users count │ └── Widgets/ │ ├── AvgProcessingTimeWidget.php # 4 stats: resolved count, avg time, pending, escalated │ ├── HandlerPerformanceWidget.php # bar chart: avg time per handler │ └── ResolvedTicketsWidget.php # table: resolved tickets awaiting closure ├── Console/ # NEW │ └── Commands/ -│ └── ImportCskh.php # app:import-cskh command +│ ├── ImportCskh.php # app:import-cskh command +│ └── ExportBackup.php # app:export-backup command (JSON backup) ├── Enums/ # NEW │ ├── ContractType.php # SALE/LEASE/BCC │ ├── ContractStatus.php # ACTIVE/EXPIRED/LIQUIDATED @@ -349,13 +370,17 @@ Relationships: `feedback()`, `fromDepartment()`, `toDepartment()`, `sender()` ## Policies — Tóm tắt +> **Note:** Policies now use Spatie `hasPermissionTo()` instead of `hasRole()` for granular permission control. + | Policy | viewAny/view | create | update | delete | restore/forceDelete | |--------|-------------|--------|--------|--------|---------------------| -| ContractPolicy | all roles | admin+manager | admin+manager | admin | admin only | -| FeedbackPolicy | all roles | all roles | all roles | admin+manager | admin only | -| ProductPolicy | all roles | admin+manager | admin+manager | admin | admin only | -| CustomerPolicy | all roles | admin+manager | admin+manager | admin | admin only | -| FeedbackChannelPolicy | all roles | admin+manager | admin+manager | admin | admin only | +| ContractPolicy | view-contract | create-contract | update-contract | delete-contract | admin only | +| FeedbackPolicy | view-feedback | create-feedback | update-feedback | delete-feedback | admin only | +| ProductPolicy | view-product | create-product | update-product | delete-product | admin only | +| CustomerPolicy | view-customer | create-customer | update-customer | delete-customer | admin only | +| FeedbackChannelPolicy | view-channel | create-channel | update-channel | delete-channel | admin only | +| UserPolicy | admin only | admin only | admin only | admin only (not self) | admin only (not self) | +| RolePolicy | admin only | admin only | admin only | admin only (not built-in) | admin only (not built-in) | --- @@ -412,17 +437,22 @@ Dashboard (AccountWidget + 3 custom widgets) --- -## Seeder (AfterSalesSeeder) — Data mẫu +## Seeder (AfterSalesSeeder + PermissionSeeder) — Data mẫu - 3 users (admin, manager, staff) + Spatie roles +- **29 granular permissions** (PermissionSeeder) — NEW + - Feedback: view/create/update/delete, add-interaction, close-ticket, transfer-department + - Products/Customers/Contracts/Channels/Tags: view/create/update/delete each + - Dashboard: view-dashboard-widgets, export-data +- Role → Permission mapping: admin (all), manager (no delete/export), staff (view + limited write) - 4 departments (CSKH: manager quản lý, Kỹ thuật: admin quản lý, Kế toán, Pháp lý) - 5 feedback channels (Email, Zalo, Phone, Document, In Person) - 5 products (Sunrise A1, A2, Green Valley Villa, Ocean Tower, Park Hill) - 4 customers - 5 customer-product assignments -- **5 contracts** (2 Sale, 2 Lease, 1 BCC) — NEW -- **5 handovers** (2 Đã BG, 1 Đủ ĐK, 1 Đã lên lịch, 1 Chưa đủ ĐK) — NEW -- **5 product_services** (2 Management Fee, 2 Gratitude, 1 Self Business) — NEW +- **5 contracts** (2 Sale, 2 Lease, 1 BCC) +- **5 handovers** (2 Đã BG, 1 Đủ ĐK, 1 Đã lên lịch, 1 Chưa đủ ĐK) +- **5 product_services** (2 Management Fee, 2 Gratitude, 1 Self Business) - 5 feedbacks (pending: 2, processing: 1, resolved: 1, closed: 1) - 5 feedback tags - 5 feedback interactions diff --git a/PROGRESS.md b/PROGRESS.md index 6a968a38..769825db 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -126,6 +126,17 @@ Tham chiếu: `SOP_des.md` — Bản đặc tả hệ thống After-Sale CRM ### Còn lại (Optional) - [ ] Auto-suggest chuyển phòng ban dựa trên loại hợp đồng (Ticket BCC → gợi ý Pháp lý) +### Granular Permissions + Export Backup + User/Role Management +- [x] PermissionSeeder: 29 permissions (view/create/update/delete per module + close-ticket, transfer-department, export-data) +- [x] Role → Permission mapping: admin (all), manager (no delete/export), staff (view + limited write) +- [x] Policies updated to use `hasPermissionTo()` instead of `hasRole()` +- [x] Widgets/Filament updated to use permission-based checks +- [x] ExportBackup command: `app:export-backup` (JSON, per-table, chunk 500, manifest.json) +- [x] UserResource: admin can create/edit/delete users + assign roles +- [x] RoleResource: admin can create/edit/delete roles + assign permissions +- [x] UserPolicy + RolePolicy (admin-only) +- [x] Tests updated: 8 tests, 21 assertions pass + --- ## Database Schema (final — 23 migrations) diff --git a/TASKS_ROADMAP.md b/TASKS_ROADMAP.md index 2ed1b184..cda77509 100644 --- a/TASKS_ROADMAP.md +++ b/TASKS_ROADMAP.md @@ -53,6 +53,14 @@ - [x] Docker deployment - [x] Migration notifications table +### Giai đoạn 9: Granular Permissions + Export Backup + User/Role Management +- [x] PermissionSeeder: 29 granular permissions (admin/manager/staff mapping) +- [x] Policies updated to `hasPermissionTo()` (5 policies) +- [x] ExportBackup command: `app:export-backup` (JSON, per-table, chunk 500) +- [x] UserResource: CRUD users + assign role (admin only) +- [x] RoleResource: CRUD roles + assign permissions via CheckboxList (admin only) +- [x] UserPolicy + RolePolicy (admin-only access) + --- ## 🔴 CHƯA LÀM — Theo AI Instructions diff --git a/app/Console/Commands/ExportBackup.php b/app/Console/Commands/ExportBackup.php new file mode 100644 index 00000000..9d923aed --- /dev/null +++ b/app/Console/Commands/ExportBackup.php @@ -0,0 +1,146 @@ +option('path') ?? storage_path('app/backups'); + $pretty = $this->option('pretty') ?? false; + $tablesFilter = $this->option('tables') + ? array_map('trim', explode(',', $this->option('tables'))) + : null; + + if (! File::isDirectory($path)) { + File::makeDirectory($path, 0755, true); + } + + $timestamp = now()->format('Y-m-d_His'); + $backupDir = "{$path}/backup_{$timestamp}"; + File::makeDirectory($backupDir, 0755, true); + + $this->info("Backup directory: {$backupDir}"); + $this->newLine(); + + $tables = $this->getTables(); + + if ($tablesFilter) { + $tables = array_filter($tables, fn ($t) => in_array($t, $tablesFilter)); + } + + $this->info("Exporting " . count($tables) . " tables..."); + $this->newLine(); + + $bar = $this->output->createProgressBar(count($tables)); + $bar->start(); + + $totalRows = 0; + + foreach ($tables as $table) { + $count = $this->exportTable($table, $backupDir, $pretty); + $this->stats[$table] = $count; + $totalRows += $count; + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + + // Write manifest + $manifest = [ + 'created_at' => now()->toIso8601String(), + 'database' => config('database.default'), + 'tables' => $this->stats, + 'total_rows' => $totalRows, + ]; + File::put("{$backupDir}/manifest.json", json_encode($manifest, JSON_PRETTY_PRINT)); + + $this->printSummary($backupDir, $totalRows); + + return self::SUCCESS; + } + + private function getTables(): array + { + $driver = config('database.default'); + + if ($driver === 'sqlite') { + $results = DB::select("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"); + return array_map(fn ($r) => $r->name, $results); + } + + return DB::getDoctrineSchemaManager()->listTableNames(); + } + + private function exportTable(string $table, string $backupDir, bool $pretty): int + { + $filename = "{$backupDir}/{$table}.json"; + $count = 0; + + $handle = fopen($filename, 'w'); + fwrite($handle, "[\n"); + + $first = true; + DB::table($table)->orderBy('id')->chunk(500, function ($rows) use ($handle, &$count, &$first, $pretty) { + foreach ($rows as $row) { + if (! $first) { + fwrite($handle, ",\n"); + } + $first = false; + + $data = (array) $row; + $json = $pretty + ? json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + : json_encode($data, JSON_UNESCAPED_UNICODE); + fwrite($handle, $json); + $count++; + } + }); + + fwrite($handle, "\n]\n"); + fclose($handle); + + return $count; + } + + private function printSummary(string $backupDir, int $totalRows): void + { + $rows = []; + foreach ($this->stats as $table => $count) { + $rows[] = [$table, $count]; + } + $rows[] = ['TOTAL', "{$totalRows}"]; + + $this->table(['Table', 'Rows'], $rows); + + $size = File::size($backupDir); + $sizeFormatted = $this->formatBytes($size); + $this->info("Backup size: {$sizeFormatted}"); + $this->info("Location: {$backupDir}"); + } + + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $i = 0; + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes /= 1024; + $i++; + } + return round($bytes, 2) . ' ' . $units[$i]; + } +} diff --git a/app/Console/Commands/ImportCskh.php b/app/Console/Commands/ImportCskh.php index d7b5a1ac..f19cf498 100644 --- a/app/Console/Commands/ImportCskh.php +++ b/app/Console/Commands/ImportCskh.php @@ -31,6 +31,11 @@ class ImportCskh extends Command private int $statsFeedbacks = 0; private int $statsInteractions = 0; private int $statsSkipped = 0; + private int $statsSkippedEmptyCode = 0; + private int $statsSkippedNumericCode = 0; + private int $statsSkippedHeaderRow = 0; + private int $statsSkippedEmptyCustomer = 0; + private array $skippedRowDetails = []; public function handle(): int { @@ -63,8 +68,9 @@ class ImportCskh extends Command foreach ($rows as $row) { $rowCount++; - if ($this->isMetaRow($row)) { - $this->statsSkipped++; + $metaReason = $this->getMetaRowReason($row); + if ($metaReason !== null) { + $this->recordSkip($rowCount, $metaReason, $row); $bar->advance(); continue; @@ -73,7 +79,7 @@ class ImportCskh extends Command $chunk[] = $row; if (count($chunk) >= $chunkSize) { - $this->processChunk($chunk, $dryRun); + $this->processChunk($chunk, $dryRun, $rowCount); $chunk = []; } @@ -81,7 +87,7 @@ class ImportCskh extends Command } if (! empty($chunk)) { - $this->processChunk($chunk, $dryRun); + $this->processChunk($chunk, $dryRun, $rowCount); } $bar->finish(); @@ -92,44 +98,78 @@ class ImportCskh extends Command } private function isMetaRow(array $row): bool + { + return $this->getMetaRowReason($row) !== null; + } + + private function getMetaRowReason(array $row): ?string { $code = $row['Mã căn hộ'] ?? ''; - if (empty($code) || is_numeric($code)) { - return true; + if (empty($code)) { + return 'empty_code'; + } + + if (is_numeric($code)) { + return 'numeric_code'; } if ($code === 'Ngày TT' || $code === 'Tình trạng' || $code === 'Tên DN') { - return true; + return 'header_row'; } - return false; + return null; } - private function processChunk(array $rows, bool $dryRun): void + private function recordSkip(int $rowNum, string $reason, array $row): void + { + $this->statsSkipped++; + + match ($reason) { + 'empty_code' => $this->statsSkippedEmptyCode++, + 'numeric_code' => $this->statsSkippedNumericCode++, + 'header_row' => $this->statsSkippedHeaderRow++, + default => null, + }; + + $code = $row['Mã căn hộ'] ?? ''; + $this->skippedRowDetails[] = [ + 'row' => $rowNum, + 'reason' => $reason, + 'code' => $code, + ]; + } + + private function processChunk(array $rows, bool $dryRun, int $currentRowNum): void { if ($dryRun) { - foreach ($rows as $row) { - $this->processRow($row, true); + foreach ($rows as $i => $row) { + $this->processRow($row, true, $currentRowNum - count($rows) + $i + 1); } return; } - DB::transaction(function () use ($rows): void { - foreach ($rows as $row) { - $this->processRow($row, false); + DB::transaction(function () use ($rows, $currentRowNum): void { + foreach ($rows as $i => $row) { + $this->processRow($row, false, $currentRowNum - count($rows) + $i + 1); } }); } - private function processRow(array $row, bool $dryRun): void + private function processRow(array $row, bool $dryRun, int $rowNum): void { $productCode = trim($row['Mã căn hộ'] ?? ''); $customerName = trim($row['Họ tên KH'] ?? ''); if (empty($productCode) || empty($customerName)) { $this->statsSkipped++; + $this->statsSkippedEmptyCustomer++; + $this->skippedRowDetails[] = [ + 'row' => $rowNum, + 'reason' => 'empty_customer', + 'code' => $productCode, + ]; return; } @@ -433,6 +473,10 @@ class ImportCskh extends Command [ ['Total rows in file', $totalRows], ['Skipped (meta/empty)', $this->statsSkipped], + [' ├─ Empty code (row trống)', $this->statsSkippedEmptyCode], + [' ├─ Numeric code (hàng tổng)', $this->statsSkippedNumericCode], + [' ├─ Header row (tiêu đề)', $this->statsSkippedHeaderRow], + [' └─ Empty customer name', $this->statsSkippedEmptyCustomer], [$mode . 'New Products', $this->statsProducts], [$mode . 'New Customers', $this->statsCustomers], [$mode . 'New Contracts', $this->statsContracts], @@ -442,5 +486,56 @@ class ImportCskh extends Command [$mode . 'New Interactions', $this->statsInteractions], ] ); + + if ($dryRun && ! empty($this->skippedRowDetails)) { + $this->newLine(); + $this->warn('=== Chi tiết các dòng bị bỏ qua ==='); + $this->newLine(); + + $reasonLabels = [ + 'empty_code' => 'Trống mã căn hộ', + 'numeric_code' => 'Hàng tổng (numeric)', + 'header_row' => 'Hàng tiêu đề', + 'empty_customer' => 'Trống tên KH', + ]; + + $reasonColors = [ + 'empty_code' => 'red', + 'numeric_code' => 'yellow', + 'header_row' => 'cyan', + 'empty_customer' => 'magenta', + ]; + + $grouped = []; + foreach ($this->skippedRowDetails as $detail) { + $grouped[$detail['reason']][] = $detail; + } + + foreach ($grouped as $reason => $details) { + $color = $reasonColors[$reason] ?? 'white'; + $label = $reasonLabels[$reason] ?? $reason; + $count = count($details); + + $this->line(" {$label} — {$count} dòng:"); + + $showDetails = array_slice($details, 0, 30); + $rowNumbers = array_column($showDetails, 'row'); + $codes = array_column($showDetails, 'code'); + + $chunks = array_chunk($rowNumbers, 15); + foreach ($chunks as $chunk) { + $this->line(" Rows: " . implode(', ', $chunk) . ""); + } + + if ($count > 30) { + $this->line(" ... và " . ($count - 30) . " dòng nữa"); + } + + $this->newLine(); + } + + $this->line('Gợi ý: Các dòng "Empty customer name" thường là dòng trống trong Excel chỉ có mã căn hộ nhưng không có dữ liệu khác.'); + $this->line('Chúng được bỏ qua vì thiếu thông tin bắt buộc (Họ tên KH).'); + } } } diff --git a/app/Filament/Resources/Feedback/Pages/EditFeedback.php b/app/Filament/Resources/Feedback/Pages/EditFeedback.php index e60582f4..b15b5e56 100644 --- a/app/Filament/Resources/Feedback/Pages/EditFeedback.php +++ b/app/Filament/Resources/Feedback/Pages/EditFeedback.php @@ -36,7 +36,7 @@ class EditFeedback extends EditRecord ->label('Transfer Department') ->icon('heroicon-o-arrows-right-left') ->color('warning') - ->visible(fn (): bool => auth()->user()->hasRole(['admin', 'manager'])) + ->visible(fn (): bool => auth()->user()->hasPermissionTo('transfer-department')) ->form([ Select::make('to_department_id') ->label('Target Department') @@ -106,7 +106,7 @@ class EditFeedback extends EditRecord ->label('Close Ticket') ->icon('heroicon-o-check-circle') ->color('success') - ->visible(fn (): bool => $this->getRecord()->status !== 'closed' && auth()->user()->hasRole(['admin', 'manager'])) + ->visible(fn (): bool => $this->getRecord()->status !== 'closed' && auth()->user()->hasPermissionTo('close-ticket')) ->requiresConfirmation() ->modalHeading('Close Ticket') ->modalDescription('Bạn có chắc muốn đóng phiếu này? Hành động này KHÔNG thể hoàn tác.') diff --git a/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php b/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php index da12d61e..6161acd2 100644 --- a/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php +++ b/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php @@ -53,7 +53,7 @@ class InteractionsRelationManager extends RelationManager Select::make('new_status') ->label('New Status') - ->options(fn (): array => auth()->user()->hasRole(['admin', 'manager']) + ->options(fn (): array => auth()->user()->hasPermissionTo('close-ticket') ? ['pending' => 'Pending', 'processing' => 'Processing', 'resolved' => 'Resolved', 'closed' => 'Closed'] : ['pending' => 'Pending', 'processing' => 'Processing', 'resolved' => 'Resolved'], ) diff --git a/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php b/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php index 57255358..6be258e3 100644 --- a/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php +++ b/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php @@ -11,6 +11,7 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Grid; use Filament\Schemas\Schema; class FeedbackForm @@ -19,18 +20,22 @@ class FeedbackForm { return $schema ->components([ - Section::make('Feedback Information') + Section::make('Customer & Product') + ->description('Select the customer and their associated product') + ->icon('heroicon-o-user-group') ->schema([ Select::make('customer_id') ->relationship('customer', 'name') ->searchable() ->required() - ->live(), + ->live() + ->columnSpan(1), Toggle::make('is_general') ->label('General feedback (not related to a product)') ->default(false) - ->live(), + ->live() + ->columnSpan(1), Select::make('customer_product_id') ->label('Product') @@ -57,7 +62,7 @@ class FeedbackForm ->live(), Select::make('contract_id') - ->label('Hợp đồng') + ->label('Contract') ->visible(fn ($get): bool => ! $get('is_general') && filled($get('customer_product_id'))) ->options(function ($get): array { $customerProductId = $get('customer_product_id'); @@ -102,48 +107,46 @@ class FeedbackForm }) ->nullable() ->searchable(), + ]) + ->columns(2), + Section::make('Feedback Details') + ->description('Describe the customer feedback') + ->icon('heroicon-o-chat-bubble-left-right') + ->schema([ Select::make('feedback_channel_id') ->relationship('feedbackChannel', 'name') ->required() - ->preload(), + ->preload() + ->columnSpan(1), TextInput::make('title') ->required() - ->maxLength(255), + ->maxLength(255) + ->columnSpan(1), Textarea::make('content') ->required() ->rows(6) ->columnSpanFull(), - Select::make('attachment_collection') - ->label('Attachment Collection') - ->options([ - 'general' => 'General', - 'proof_of_resolution' => 'Proof of Resolution', - 'customer_evidence' => 'Customer Evidence', - ]) - ->default('general'), - - FileUpload::make('attachments') - ->label('Attachments') - ->multiple() - ->disk('local') - ->directory('feedback-attachments') - ->columnSpanFull() - ->dehydrated(false), - Select::make('tags') ->relationship('tags', 'name') ->multiple() ->preload() + ->columnSpanFull() ->createOptionForm([ TextInput::make('name')->required(), TextInput::make('slug')->required(), ColorPicker::make('color')->default('#3b82f6'), ]), + ]) + ->columns(2), + Section::make('Assignment & Status') + ->description('Assign handler and set initial status') + ->icon('heroicon-o-arrow-path-rounded-square') + ->schema([ Select::make('status') ->options([ 'pending' => 'Pending', @@ -152,22 +155,50 @@ class FeedbackForm 'closed' => 'Closed', ]) ->default('pending') - ->required(), + ->required() + ->columnSpan(1), Select::make('assigned_to') ->relationship('assignedTo', 'name') ->searchable() - ->nullable(), + ->nullable() + ->columnSpan(1), Select::make('current_department_id') ->label('Department') ->options(Department::pluck('name', 'id')->toArray()) ->searchable() - ->nullable(), + ->nullable() + ->columnSpan(1), Toggle::make('is_escalated') ->label('Escalated') - ->visible(fn (): bool => auth()->user()?->hasRole(['admin', 'manager']) ?? false), + ->visible(fn (): bool => auth()->user()?->hasPermissionTo('close-ticket') ?? false) + ->columnSpan(1), + ]) + ->columns(2), + + Section::make('Attachments') + ->description('Upload files related to this feedback') + ->icon('heroicon-o-paper-clip') + ->schema([ + Select::make('attachment_collection') + ->label('Collection') + ->options([ + 'general' => 'General', + 'proof_of_resolution' => 'Proof of Resolution', + 'customer_evidence' => 'Customer Evidence', + ]) + ->default('general') + ->columnSpan(1), + + FileUpload::make('attachments') + ->label('Files') + ->multiple() + ->disk('local') + ->directory('feedback-attachments') + ->columnSpan(1) + ->dehydrated(false), ]) ->columns(2), ]); diff --git a/app/Filament/Resources/Feedback/Tables/FeedbackTable.php b/app/Filament/Resources/Feedback/Tables/FeedbackTable.php index ac9a8cda..870d4cd8 100644 --- a/app/Filament/Resources/Feedback/Tables/FeedbackTable.php +++ b/app/Filament/Resources/Feedback/Tables/FeedbackTable.php @@ -16,16 +16,28 @@ class FeedbackTable { return $table ->columns([ + TextColumn::make('id') + ->label('#') + ->sortable() + ->weight('bold') + ->color('primary'), + TextColumn::make('title') ->searchable() - ->sortable(), + ->sortable() + ->weight('semibold') + ->limit(40), + TextColumn::make('customer.name') ->searchable() ->sortable(), + TextColumn::make('customerProduct.product.name') ->label('Product') ->placeholder('General') - ->sortable(), + ->sortable() + ->toggleable(), + TextColumn::make('contract.type') ->label('Contract') ->badge() @@ -38,17 +50,17 @@ class FeedbackTable }) ->placeholder('-') ->toggleable(), + TextColumn::make('feedbackChannel.name') ->label('Channel') - ->formatStateUsing(fn ($state, $record) => sprintf( - '%s', - $record->feedbackChannel?->color ?: '#6b7280', - e($state) - )) - ->html(), + ->badge() + ->color('gray'), + TextColumn::make('tags.name') ->badge() - ->separator(','), + ->separator(',') + ->toggleable(), + TextColumn::make('status') ->badge() ->color(fn(string $state): string => match ($state) { @@ -58,20 +70,28 @@ class FeedbackTable 'closed' => 'gray', default => 'gray', }), + TextColumn::make('currentDepartment.name') ->label('Department') + ->badge() + ->color('info') ->toggleable(), + TextColumn::make('assignedTo.name') ->label('Assigned To') ->toggleable(), + IconColumn::make('is_escalated') ->label('Escalated') ->boolean() ->toggleable(), + TextColumn::make('created_at') - ->dateTime() + ->label('Created') + ->since() ->sortable() - ->toggleable(), + ->toggleable() + ->color('secondary'), ]) ->filters([ SelectFilter::make('status') diff --git a/app/Filament/Resources/Roles/Pages/CreateRole.php b/app/Filament/Resources/Roles/Pages/CreateRole.php new file mode 100644 index 00000000..5f48ef80 --- /dev/null +++ b/app/Filament/Resources/Roles/Pages/CreateRole.php @@ -0,0 +1,22 @@ +record; + $permissionNames = $this->data['permissions'] ?? []; + + if (! empty($permissionNames)) { + $record->syncPermissions($permissionNames); + } + } +} diff --git a/app/Filament/Resources/Roles/Pages/EditRole.php b/app/Filament/Resources/Roles/Pages/EditRole.php new file mode 100644 index 00000000..e631bab7 --- /dev/null +++ b/app/Filament/Resources/Roles/Pages/EditRole.php @@ -0,0 +1,35 @@ +record; + $permissionNames = $this->data['permissions'] ?? []; + + $record->syncPermissions($permissionNames); + } + + protected function mutateFormDataBeforeFill(array $data): array + { + $role = $this->record; + $data['permissions'] = $role->permissions->pluck('name')->toArray(); + + return $data; + } +} diff --git a/app/Filament/Resources/Roles/Pages/ListRoles.php b/app/Filament/Resources/Roles/Pages/ListRoles.php new file mode 100644 index 00000000..545277a4 --- /dev/null +++ b/app/Filament/Resources/Roles/Pages/ListRoles.php @@ -0,0 +1,19 @@ + ListRoles::route('/'), + 'create' => CreateRole::route('/create'), + 'edit' => EditRole::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Roles/Schemas/RoleForm.php b/app/Filament/Resources/Roles/Schemas/RoleForm.php new file mode 100644 index 00000000..2509e042 --- /dev/null +++ b/app/Filament/Resources/Roles/Schemas/RoleForm.php @@ -0,0 +1,37 @@ +components([ + Section::make() + ->schema([ + TextInput::make('name') + ->label('Role Name') + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true), + ]), + + Section::make('Permissions') + ->description('Chọn quyền cho role này') + ->schema([ + CheckboxList::make('permissions') + ->label('') + ->options(fn () => Permission::orderBy('name')->pluck('name', 'name')->toArray()) + ->columns(4) + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Roles/Tables/RolesTable.php b/app/Filament/Resources/Roles/Tables/RolesTable.php new file mode 100644 index 00000000..5d1c1b24 --- /dev/null +++ b/app/Filament/Resources/Roles/Tables/RolesTable.php @@ -0,0 +1,63 @@ +columns([ + TextColumn::make('id') + ->label('#') + ->sortable(), + + TextColumn::make('name') + ->label('Role Name') + ->searchable() + ->sortable() + ->badge() + ->color(fn (string $state): string => match ($state) { + 'admin' => 'danger', + 'manager' => 'warning', + 'staff' => 'info', + default => 'gray', + }), + + TextColumn::make('permissions_count') + ->label('Permissions') + ->counts('permissions') + ->badge() + ->color('primary'), + + TextColumn::make('users_count') + ->label('Users') + ->counts('users') + ->badge() + ->color('success'), + + TextColumn::make('created_at') + ->dateTime('d/m/Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('id', 'asc') + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Users/Pages/CreateUser.php b/app/Filament/Resources/Users/Pages/CreateUser.php new file mode 100644 index 00000000..d49c06cf --- /dev/null +++ b/app/Filament/Resources/Users/Pages/CreateUser.php @@ -0,0 +1,20 @@ +record; + $role = $record->role; + + // Assign the primary role + $record->syncRoles([$role]); + } +} diff --git a/app/Filament/Resources/Users/Pages/EditUser.php b/app/Filament/Resources/Users/Pages/EditUser.php new file mode 100644 index 00000000..24efb4e1 --- /dev/null +++ b/app/Filament/Resources/Users/Pages/EditUser.php @@ -0,0 +1,27 @@ +record; + $role = $record->role; + + // Sync the primary role + $record->syncRoles([$role]); + } +} diff --git a/app/Filament/Resources/Users/Pages/ListUsers.php b/app/Filament/Resources/Users/Pages/ListUsers.php new file mode 100644 index 00000000..90c9cb59 --- /dev/null +++ b/app/Filament/Resources/Users/Pages/ListUsers.php @@ -0,0 +1,37 @@ + Tab::make(), + 'admin' => Tab::make() + ->modifyQueryUsing(fn (Builder $query) => $query->where('role', 'admin')) + ->badge(fn () => static::getResource()::getModel()::where('role', 'admin')->count()), + 'manager' => Tab::make() + ->modifyQueryUsing(fn (Builder $query) => $query->where('role', 'manager')) + ->badge(fn () => static::getResource()::getModel()::where('role', 'manager')->count()), + 'staff' => Tab::make() + ->modifyQueryUsing(fn (Builder $query) => $query->where('role', 'staff')) + ->badge(fn () => static::getResource()::getModel()::where('role', 'staff')->count()), + ]; + } +} diff --git a/app/Filament/Resources/Users/Schemas/UserForm.php b/app/Filament/Resources/Users/Schemas/UserForm.php new file mode 100644 index 00000000..34e1cf2a --- /dev/null +++ b/app/Filament/Resources/Users/Schemas/UserForm.php @@ -0,0 +1,42 @@ +components([ + TextInput::make('name') + ->required() + ->maxLength(255), + + TextInput::make('email') + ->email() + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true), + + TextInput::make('password') + ->password() + ->revealable() + ->required(fn (string $operation): bool => $operation === 'create') + ->dehydrateStateUsing(fn (?string $state): ?string => filled($state) ? bcrypt($state) : null) + ->dehydrated(fn (?string $state): bool => filled($state)) + ->maxLength(255) + ->columnSpanFull(), + + Select::make('role') + ->label('Role') + ->options(Role::pluck('name', 'name')->toArray()) + ->required() + ->searchable(), + ]); + } +} diff --git a/app/Filament/Resources/Users/Tables/UsersTable.php b/app/Filament/Resources/Users/Tables/UsersTable.php new file mode 100644 index 00000000..12bb95a0 --- /dev/null +++ b/app/Filament/Resources/Users/Tables/UsersTable.php @@ -0,0 +1,59 @@ +columns([ + TextColumn::make('id') + ->label('#') + ->sortable(), + + TextColumn::make('name') + ->searchable() + ->sortable(), + + TextColumn::make('email') + ->searchable() + ->sortable(), + + TextColumn::make('role') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'admin' => 'danger', + 'manager' => 'warning', + 'staff' => 'info', + default => 'gray', + }), + + TextColumn::make('created_at') + ->dateTime('d/m/Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('id', 'asc') + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->headerActions([ + // + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Users/UserResource.php b/app/Filament/Resources/Users/UserResource.php new file mode 100644 index 00000000..085d39f4 --- /dev/null +++ b/app/Filament/Resources/Users/UserResource.php @@ -0,0 +1,57 @@ + ListUsers::route('/'), + 'create' => CreateUser::route('/create'), + 'edit' => EditUser::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Widgets/AvgProcessingTimeWidget.php b/app/Filament/Widgets/AvgProcessingTimeWidget.php index 1453e4d4..4c7d73e9 100644 --- a/app/Filament/Widgets/AvgProcessingTimeWidget.php +++ b/app/Filament/Widgets/AvgProcessingTimeWidget.php @@ -17,7 +17,7 @@ class AvgProcessingTimeWidget extends BaseWidget ->whereNotNull('assigned_to') ->whereNotNull('updated_at'); - if ($user->hasRole('manager')) { + if ($user->hasRole('manager') && ! $user->hasRole('admin')) { $deptIds = Department::where('manager_id', $user->id)->pluck('id'); $query->whereIn('current_department_id', $deptIds); } @@ -33,27 +33,38 @@ class AvgProcessingTimeWidget extends BaseWidget $escalatedCount = (clone $query)->where('is_escalated', true)->count(); + $avgDisplay = $avgMinutes ? round($avgMinutes) . ' min' : 'N/A'; + $avgDescription = $avgMinutes + ? 'Avg time from creation to resolution' + : 'No resolved tickets yet'; + return [ Stat::make('Resolved Tickets', (string) $totalResolved) ->description('Total resolved/closed tickets') - ->color('success'), + ->descriptionIcon('heroicon-m-check-circle') + ->color('success') + ->chart([7, 3, 4, 5, 6, 8, $totalResolved]), - Stat::make('Avg Processing Time', $avgMinutes ? round($avgMinutes) . ' min' : 'N/A') - ->description('Average time from creation to resolution') - ->color('warning'), + Stat::make('Avg Processing Time', $avgDisplay) + ->description($avgDescription) + ->descriptionIcon('heroicon-m-clock') + ->color('info'), Stat::make('Pending Tickets', (string) $totalPending) ->description('Still in progress') - ->color('danger'), + ->descriptionIcon('heroicon-m-arrow-path') + ->color('warning') + ->chart([3, 5, 4, 6, 5, 4, $totalPending]), Stat::make('Escalated', (string) $escalatedCount) ->description('Tickets marked as escalated') - ->color('gray'), + ->descriptionIcon('heroicon-m-exclamation-triangle') + ->color('danger'), ]; } public static function canView(): bool { - return auth()->user()?->hasRole(['admin', 'manager']) ?? false; + return auth()->user()?->hasPermissionTo('view-dashboard-widgets') ?? false; } } diff --git a/app/Filament/Widgets/HandlerPerformanceWidget.php b/app/Filament/Widgets/HandlerPerformanceWidget.php index 60e37410..ed254f26 100644 --- a/app/Filament/Widgets/HandlerPerformanceWidget.php +++ b/app/Filament/Widgets/HandlerPerformanceWidget.php @@ -10,6 +10,8 @@ class HandlerPerformanceWidget extends ChartWidget { protected ?string $heading = 'Avg Processing Time by Handler'; + protected ?string $description = 'Average time from ticket creation to resolution, broken down by handler.'; + protected int|string|array $columnSpan = 'full'; protected function getType(): string @@ -25,7 +27,7 @@ class HandlerPerformanceWidget extends ChartWidget ->whereIn('status', ['resolved', 'closed']) ->whereNotNull('assigned_to'); - if ($user->hasRole('manager')) { + if ($user->hasRole('manager') && ! $user->hasRole('admin')) { $deptIds = Department::where('manager_id', $user->id)->pluck('id'); $query->whereIn('current_department_id', $deptIds); } @@ -40,12 +42,37 @@ class HandlerPerformanceWidget extends ChartWidget $labels = $handlers->map(fn ($h) => $h->assignedTo?->name ?? 'Unknown')->toArray(); $data = $handlers->map(fn ($h) => round($h->avg_minutes, 1))->toArray(); + // Generate gradient colors based on performance + $maxMinutes = max($data) ?: 1; + $colors = collect($data)->map(function ($minutes) use ($maxMinutes) { + $ratio = $minutes / $maxMinutes; + if ($ratio > 0.8) { + return 'rgba(220, 38, 38, 0.8)'; // Red for slow + } elseif ($ratio > 0.5) { + return 'rgba(217, 119, 6, 0.8)'; // Amber for medium + } + return 'rgba(26, 86, 219, 0.8)'; // Blue for fast + })->toArray(); + + $borderColors = collect($data)->map(function ($minutes) use ($maxMinutes) { + $ratio = $minutes / $maxMinutes; + if ($ratio > 0.8) { + return 'rgb(220, 38, 38)'; + } elseif ($ratio > 0.5) { + return 'rgb(217, 119, 6)'; + } + return 'rgb(26, 86, 219)'; + })->toArray(); + return [ 'datasets' => [ [ 'label' => 'Avg Time (minutes)', 'data' => $data, - 'backgroundColor' => '#3b82f6', + 'backgroundColor' => $colors, + 'borderColor' => $borderColors, + 'borderWidth' => 1, + 'borderRadius' => 6, ], ], 'labels' => $labels, @@ -54,6 +81,6 @@ class HandlerPerformanceWidget extends ChartWidget public static function canView(): bool { - return auth()->user()?->hasRole(['admin', 'manager']) ?? false; + return auth()->user()?->hasPermissionTo('view-dashboard-widgets') ?? false; } } diff --git a/app/Filament/Widgets/ResolvedTicketsWidget.php b/app/Filament/Widgets/ResolvedTicketsWidget.php index 23e20d7f..4242cc9f 100644 --- a/app/Filament/Widgets/ResolvedTicketsWidget.php +++ b/app/Filament/Widgets/ResolvedTicketsWidget.php @@ -20,7 +20,7 @@ class ResolvedTicketsWidget extends BaseWidget Feedback::query() ->where('status', 'resolved') ->when( - auth()->user()->hasRole('manager'), + auth()->user()->hasRole('manager') && ! auth()->user()->hasRole('admin'), function ($q) { $deptIds = Department::where('manager_id', auth()->id())->pluck('id'); return $q->whereIn('current_department_id', $deptIds); @@ -30,30 +30,47 @@ class ResolvedTicketsWidget extends BaseWidget ->columns([ TextColumn::make('id') ->label('#') - ->sortable(), + ->sortable() + ->weight('bold') + ->color('primary'), + TextColumn::make('title') ->label('Ticket') ->searchable() - ->url(fn (Feedback $record): string => FeedbackResource::getUrl('edit', ['record' => $record])), + ->weight('semibold') + ->url(fn (Feedback $record): string => FeedbackResource::getUrl('edit', ['record' => $record])) + ->color('primary'), + TextColumn::make('customer.name') ->label('Customer') ->searchable(), + + TextColumn::make('customerProduct.product.name') + ->label('Product') + ->placeholder('General'), + TextColumn::make('assignedTo.name') ->label('Handler'), + TextColumn::make('currentDepartment.name') - ->label('Department'), + ->label('Department') + ->badge() + ->color('info'), + TextColumn::make('updated_at') - ->label('Resolved Date') - ->dateTime('d/m/Y') - ->sortable(), + ->label('Resolved') + ->since() + ->sortable() + ->color('secondary'), ]) ->defaultSort('updated_at', 'desc') - ->heading('Tickets Awaiting Closure (Resolved)') - ->description('Tickets that have been resolved and are pending manager review for closure.'); + ->heading('Tickets Awaiting Closure') + ->description('Tickets that have been resolved and are pending manager review for closure.') + ->paginated([5]); } public static function canView(): bool { - return auth()->user()?->hasRole(['admin', 'manager']) ?? false; + return auth()->user()?->hasPermissionTo('view-dashboard-widgets') ?? false; } } diff --git a/app/Policies/ContractPolicy.php b/app/Policies/ContractPolicy.php index 1567f455..99192049 100644 --- a/app/Policies/ContractPolicy.php +++ b/app/Policies/ContractPolicy.php @@ -9,27 +9,27 @@ class ContractPolicy { public function viewAny(User $user): bool { - return $user->hasRole(['admin', 'manager', 'staff']); + return $user->hasPermissionTo('view-contract'); } public function view(User $user, Contract $contract): bool { - return $user->hasRole(['admin', 'manager', 'staff']); + return $user->hasPermissionTo('view-contract'); } public function create(User $user): bool { - return $user->hasRole(['admin', 'manager']); + return $user->hasPermissionTo('create-contract'); } public function update(User $user, Contract $contract): bool { - return $user->hasRole(['admin', 'manager']); + return $user->hasPermissionTo('update-contract'); } public function delete(User $user, Contract $contract): bool { - return $user->hasRole('admin'); + return $user->hasPermissionTo('delete-contract'); } public function restore(User $user, Contract $contract): bool diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php index 84a51c5d..196d7cf9 100644 --- a/app/Policies/CustomerPolicy.php +++ b/app/Policies/CustomerPolicy.php @@ -9,27 +9,27 @@ class CustomerPolicy { public function viewAny(User $user): bool { - return $user->hasRole(['admin', 'manager', 'staff']); + return $user->hasPermissionTo('view-customer'); } public function view(User $user, Customer $customer): bool { - return $user->hasRole(['admin', 'manager', 'staff']); + return $user->hasPermissionTo('view-customer'); } public function create(User $user): bool { - return $user->hasRole(['admin', 'manager']); + return $user->hasPermissionTo('create-customer'); } public function update(User $user, Customer $customer): bool { - return $user->hasRole(['admin', 'manager']); + return $user->hasPermissionTo('update-customer'); } public function delete(User $user, Customer $customer): bool { - return $user->hasRole('admin'); + return $user->hasPermissionTo('delete-customer'); } public function restore(User $user, Customer $customer): bool diff --git a/app/Policies/FeedbackChannelPolicy.php b/app/Policies/FeedbackChannelPolicy.php index c088397f..ff4e1f1b 100644 --- a/app/Policies/FeedbackChannelPolicy.php +++ b/app/Policies/FeedbackChannelPolicy.php @@ -9,36 +9,36 @@ class FeedbackChannelPolicy { public function viewAny(User $user): bool { - return in_array($user->role, ['admin', 'manager', 'staff']); + return $user->hasPermissionTo('view-channel'); } - public function view(User $user, FeedbackChannel $feedbackChannel): bool + public function view(User $user, FeedbackChannel $channel): bool { - return in_array($user->role, ['admin', 'manager', 'staff']); + return $user->hasPermissionTo('view-channel'); } public function create(User $user): bool { - return in_array($user->role, ['admin', 'manager']); + return $user->hasPermissionTo('create-channel'); } - public function update(User $user, FeedbackChannel $feedbackChannel): bool + public function update(User $user, FeedbackChannel $channel): bool { - return in_array($user->role, ['admin', 'manager']); + return $user->hasPermissionTo('update-channel'); } - public function delete(User $user, FeedbackChannel $feedbackChannel): bool + public function delete(User $user, FeedbackChannel $channel): bool { - return $user->role === 'admin'; + return $user->hasPermissionTo('delete-channel'); } - public function restore(User $user, FeedbackChannel $feedbackChannel): bool + public function restore(User $user, FeedbackChannel $channel): bool { - return $user->role === 'admin'; + return $user->hasRole('admin'); } - public function forceDelete(User $user, FeedbackChannel $feedbackChannel): bool + public function forceDelete(User $user, FeedbackChannel $channel): bool { - return $user->role === 'admin'; + return $user->hasRole('admin'); } } diff --git a/app/Policies/FeedbackPolicy.php b/app/Policies/FeedbackPolicy.php index 5bd39c3a..f714aa56 100644 --- a/app/Policies/FeedbackPolicy.php +++ b/app/Policies/FeedbackPolicy.php @@ -9,27 +9,27 @@ class FeedbackPolicy { public function viewAny(User $user): bool { - return $user->hasRole(['admin', 'manager', 'staff']); + return $user->hasPermissionTo('view-feedback'); } public function view(User $user, Feedback $feedback): bool { - return $user->hasRole(['admin', 'manager', 'staff']); + return $user->hasPermissionTo('view-feedback'); } public function create(User $user): bool { - return $user->hasRole(['admin', 'manager', 'staff']); + return $user->hasPermissionTo('create-feedback'); } public function update(User $user, Feedback $feedback): bool { - return $user->hasRole(['admin', 'manager', 'staff']); + return $user->hasPermissionTo('update-feedback'); } public function delete(User $user, Feedback $feedback): bool { - return $user->hasRole(['admin', 'manager']); + return $user->hasPermissionTo('delete-feedback'); } public function restore(User $user, Feedback $feedback): bool diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php index 31fc4fbb..4cfaa54b 100644 --- a/app/Policies/ProductPolicy.php +++ b/app/Policies/ProductPolicy.php @@ -9,27 +9,27 @@ class ProductPolicy { public function viewAny(User $user): bool { - return $user->hasRole(['admin', 'manager', 'staff']); + return $user->hasPermissionTo('view-product'); } public function view(User $user, Product $product): bool { - return $user->hasRole(['admin', 'manager', 'staff']); + return $user->hasPermissionTo('view-product'); } public function create(User $user): bool { - return $user->hasRole(['admin', 'manager']); + return $user->hasPermissionTo('create-product'); } public function update(User $user, Product $product): bool { - return $user->hasRole(['admin', 'manager']); + return $user->hasPermissionTo('update-product'); } public function delete(User $user, Product $product): bool { - return $user->hasRole('admin'); + return $user->hasPermissionTo('delete-product'); } public function restore(User $user, Product $product): bool diff --git a/app/Policies/RolePolicy.php b/app/Policies/RolePolicy.php new file mode 100644 index 00000000..e20bfa5d --- /dev/null +++ b/app/Policies/RolePolicy.php @@ -0,0 +1,44 @@ +hasRole('admin'); + } + + public function view(User $user, Role $role): bool + { + return $user->hasRole('admin'); + } + + public function create(User $user): bool + { + return $user->hasRole('admin'); + } + + public function update(User $user, Role $role): bool + { + return $user->hasRole('admin'); + } + + public function delete(User $user, Role $role): bool + { + return $user->hasRole('admin') && ! in_array($role->name, ['admin', 'manager', 'staff']); + } + + public function restore(User $user, Role $role): bool + { + return $user->hasRole('admin'); + } + + public function forceDelete(User $user, Role $role): bool + { + return $user->hasRole('admin') && ! in_array($role->name, ['admin', 'manager', 'staff']); + } +} diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 00000000..7162c432 --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,43 @@ +hasRole('admin'); + } + + public function view(User $user, User $model): bool + { + return $user->hasRole('admin'); + } + + public function create(User $user): bool + { + return $user->hasRole('admin'); + } + + public function update(User $user, User $model): bool + { + return $user->hasRole('admin'); + } + + public function delete(User $user, User $model): bool + { + return $user->hasRole('admin') && $model->id !== $user->id; + } + + public function restore(User $user, User $model): bool + { + return $user->hasRole('admin'); + } + + public function forceDelete(User $user, User $model): bool + { + return $user->hasRole('admin') && $model->id !== $user->id; + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 3ea5138e..972a5eb3 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -33,17 +33,18 @@ class AdminPanelProvider extends PanelProvider ->path('admin') ->login() ->brandName('AfterSales CRM') - ->topNavigation() + ->sidebarCollapsibleOnDesktop() ->colors([ - 'primary' => '#0058be', - 'secondary' => '#585f6c', - 'gray' => '#585f6c', - 'success' => '#10b981', - 'warning' => '#f59e0b', - 'danger' => '#ba1a1a', - 'info' => '#3b82f6', + 'primary' => '#1a56db', + 'secondary' => '#64748b', + 'gray' => '#64748b', + 'success' => '#059669', + 'warning' => '#d97706', + 'danger' => '#dc2626', + 'info' => '#2563eb', ]) ->font('Inter') + ->favicon(asset('favicon.svg')) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->pages([ @@ -55,9 +56,11 @@ class AdminPanelProvider extends PanelProvider ]) ->navigationGroups([ NavigationGroup::make() - ->label('Customer Care'), + ->label('Customer Care') + ->icon('heroicon-o-chat-bubble-left-right'), NavigationGroup::make() - ->label('Management'), + ->label('Management') + ->icon('heroicon-o-cog-6-tooth'), ]) ->middleware([ EncryptCookies::class, diff --git a/app/Services/ClosingService.php b/app/Services/ClosingService.php index deee837c..08eae6e7 100644 --- a/app/Services/ClosingService.php +++ b/app/Services/ClosingService.php @@ -22,7 +22,7 @@ class ClosingService ]); } - if ($actor->hasRole('staff')) { + if (! $actor->hasPermissionTo('close-ticket')) { throw new HttpException(403, __('Chỉ lãnh đạo cấp phòng mới có quyền đóng phiếu.')); } diff --git a/database/seeders/AfterSalesSeeder.php b/database/seeders/AfterSalesSeeder.php index 40052b81..1c4d8b51 100644 --- a/database/seeders/AfterSalesSeeder.php +++ b/database/seeders/AfterSalesSeeder.php @@ -19,6 +19,8 @@ class AfterSalesSeeder extends Seeder Role::create(['name' => 'manager']); Role::create(['name' => 'staff']); + $this->call(PermissionSeeder::class); + $admin = User::create([ 'name' => 'Admin', 'email' => 'admin@minicrm.local', diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php new file mode 100644 index 00000000..d0f85e12 --- /dev/null +++ b/database/seeders/PermissionSeeder.php @@ -0,0 +1,111 @@ +forgetCachedPermissions(); + + $permissions = [ + // Feedback + 'view-feedback', + 'create-feedback', + 'update-feedback', + 'delete-feedback', + 'add-interaction', + 'close-ticket', + 'transfer-department', + + // Products + 'view-product', + 'create-product', + 'update-product', + 'delete-product', + + // Customers + 'view-customer', + 'create-customer', + 'update-customer', + 'delete-customer', + + // Contracts + 'view-contract', + 'create-contract', + 'update-contract', + 'delete-contract', + + // Channels + 'view-channel', + 'create-channel', + 'update-channel', + 'delete-channel', + + // Tags + 'view-tag', + 'create-tag', + 'update-tag', + 'delete-tag', + + // Dashboard & Export + 'view-dashboard-widgets', + 'export-data', + ]; + + foreach ($permissions as $permission) { + Permission::firstOrCreate(['name' => $permission]); + } + + // Admin: all permissions + $admin = Role::findByName('admin'); + $admin->givePermissionTo($permissions); + + // Manager: most except delete and export + $manager = Role::findByName('manager'); + $manager->givePermissionTo([ + 'view-feedback', + 'create-feedback', + 'update-feedback', + 'delete-feedback', + 'add-interaction', + 'close-ticket', + 'transfer-department', + 'view-product', + 'create-product', + 'update-product', + 'view-customer', + 'create-customer', + 'update-customer', + 'view-contract', + 'create-contract', + 'update-contract', + 'view-channel', + 'create-channel', + 'update-channel', + 'view-tag', + 'create-tag', + 'update-tag', + 'view-dashboard-widgets', + ]); + + // Staff: read + limited write + $staff = Role::findByName('staff'); + $staff->givePermissionTo([ + 'view-feedback', + 'create-feedback', + 'update-feedback', + 'add-interaction', + 'view-product', + 'view-customer', + 'view-contract', + 'view-channel', + 'view-tag', + ]); + } +} diff --git a/public/css/filament-custom.css b/public/css/filament-custom.css index 3f048923..d6eb87f4 100644 --- a/public/css/filament-custom.css +++ b/public/css/filament-custom.css @@ -1,86 +1,81 @@ /* ================================================================ - AfterSales CRM — Custom Filament Theme - Derived from Material Design tokens in webappUI/ HTML designs + AfterSales CRM — Design System v2 + Material Design 3 inspired, Professional Real-estate CRM ================================================================ */ -/* ---------- Google Fonts ---------- */ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Manrope:wght@500;600;700;800&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Manrope:wght@500;600;700;800&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap'); -/* ---------- CSS Variables (Design Tokens from HTML) ---------- */ +/* ---------- CSS Variables (Design Tokens) ---------- */ :root { - /* Primary palette */ - --crm-primary: #0058be; - --crm-primary-hover: #2170e4; - --crm-primary-container: #2170e4; + /* Primary palette — Indigo */ + --crm-primary: #1a56db; + --crm-primary-hover: #1e40af; + --crm-primary-light: #e8effc; + --crm-primary-container: #dbeafe; --crm-on-primary: #ffffff; - --crm-on-primary-container: #fefcff; - --crm-primary-fixed: #d8e2ff; - --crm-primary-fixed-dim: #adc6ff; - --crm-on-primary-fixed: #001a42; - --crm-on-primary-fixed-variant: #004395; - --crm-inverse-primary: #adc6ff; + --crm-on-primary-container: #1e3a5f; - /* Secondary palette */ - --crm-secondary: #585f6c; - --crm-secondary-container: #dce2f3; + /* Secondary palette — Slate */ + --crm-secondary: #64748b; + --crm-secondary-container: #f1f5f9; --crm-on-secondary: #ffffff; - --crm-on-secondary-container: #5e6572; - --crm-secondary-fixed: #dce2f3; - --crm-secondary-fixed-dim: #c0c7d6; - --crm-on-secondary-fixed: #151c27; - --crm-on-secondary-fixed-variant: #404754; + --crm-on-secondary-container: #334155; - /* Tertiary */ - --crm-tertiary: #924700; - --crm-tertiary-container: #b75b00; - --crm-tertiary-fixed: #ffdcc6; - --crm-tertiary-fixed-dim: #ffb786; + /* Accent */ + --crm-accent: #7c3aed; + --crm-accent-light: #ede9fe; /* Surface / Background */ - --crm-background: #f9f9ff; - --crm-surface: #f9f9ff; - --crm-surface-bright: #f9f9ff; - --crm-surface-dim: #d8d9e3; - --crm-surface-container: #ecedf7; - --crm-surface-container-low: #f2f3fd; - --crm-surface-container-lowest: #ffffff; - --crm-surface-container-high: #e6e7f2; - --crm-surface-container-highest: #e1e2ec; - --crm-surface-variant: #e1e2ec; - --crm-surface-tint: #005ac2; + --crm-background: #f1f5f9; + --crm-surface: #ffffff; + --crm-surface-dim: #f8fafc; + --crm-surface-container: #f1f5f9; + --crm-surface-container-high: #e2e8f0; + --crm-surface-hover: #e8effc; - /* Text on surface */ - --crm-on-surface: #191b23; - --crm-on-surface-variant: #424754; - --crm-on-background: #191b23; - --crm-inverse-surface: #2e3038; - --crm-inverse-on-surface: #eff0fa; + /* Text */ + --crm-text-primary: #0f172a; + --crm-text-secondary: #475569; + --crm-text-tertiary: #94a3b8; + --crm-text-inverse: #ffffff; - /* Borders / Outlines */ - --crm-outline: #727785; - --crm-outline-variant: #c2c6d6; + /* Borders */ + --crm-border: #e2e8f0; + --crm-border-strong: #cbd5e1; + --crm-border-focus: #1a56db; - /* Error */ - --crm-error: #ba1a1a; - --crm-error-container: #ffdad6; - --crm-on-error: #ffffff; - --crm-on-error-container: #93000a; + /* Status colors */ + --crm-pending: #d97706; + --crm-pending-bg: #fffbeb; + --crm-pending-border: #fde68a; + --crm-processing: #2563eb; + --crm-processing-bg: #eff6ff; + --crm-processing-border: #bfdbfe; + --crm-resolved: #059669; + --crm-resolved-bg: #ecfdf5; + --crm-resolved-border: #a7f3d0; + --crm-closed: #6b7280; + --crm-closed-bg: #f9fafb; + --crm-closed-border: #e5e7eb; + --crm-escalated: #dc2626; + --crm-escalated-bg: #fef2f2; /* Spacing */ - --crm-nav-height: 56px; - --crm-margin-x: 1.5rem; - --crm-gutter: 1rem; - --crm-stack-gap: 1.5rem; - --crm-form-gap: 1.25rem; - --crm-container-max: 1280px; + --crm-nav-height: 64px; + --crm-sidebar-width: 260px; + --crm-radius-sm: 6px; + --crm-radius-md: 10px; + --crm-radius-lg: 14px; + --crm-radius-xl: 18px; + --crm-radius-full: 9999px; - /* Radius */ - --crm-radius: 0.125rem; - --crm-radius-lg: 0.25rem; - --crm-radius-xl: 0.5rem; - --crm-radius-full: 0.75rem; + /* Shadows */ + --crm-shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + --crm-shadow-md: 0 4px 6px -1px rgba(0,0,0,0.07), 0 2px 4px -2px rgba(0,0,0,0.05); + --crm-shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.08), 0 4px 6px -4px rgba(0,0,0,0.04); + --crm-shadow-xl: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.06); - /* Font */ + /* Fonts */ --crm-font-heading: 'Manrope', sans-serif; --crm-font-body: 'Inter', sans-serif; } @@ -88,11 +83,11 @@ /* ---------- Override Filament Defaults ---------- */ /* Force font family everywhere */ +body, .fi-body, .fi-main, .fi-sidebar, -.fi-topbar, -body { +.fi-topbar { font-family: var(--crm-font-body) !important; } @@ -106,184 +101,366 @@ h1, h2, h3 { font-family: var(--crm-font-heading) !important; } -/* Override Filament primary colors */ -.fi-btn.fi-color { - --c-500: var(--crm-primary); - --c-600: var(--crm-primary-hover); -} - -.fi-btn.fi-btn-color-primary { - background-color: var(--crm-primary) !important; - border-radius: var(--crm-radius) !important; - box-shadow: 0 1px 2px rgba(0,0,0,0.05) !important; - font-family: var(--crm-font-body) !important; - font-weight: 500 !important; - font-size: 14px !important; -} - -.fi-btn.fi-btn-color-primary:hover { - background-color: var(--crm-primary-hover) !important; - filter: brightness(1.1); -} - -/* Sharper border radius globally */ -.fi-card, -.fi-ta-ctn, -.fi-fo-tabs, -.fi-modal-window, -.fi-dropdown-panel, -.fi-section, -input, select, textarea { - border-radius: var(--crm-radius) !important; -} - -/* Card/section borders */ -.fi-card { - border: 1px solid var(--crm-outline-variant) !important; - box-shadow: 0 1px 2px rgba(0,0,0,0.05) !important; - border-radius: var(--crm-radius-xl) !important; -} - /* Page background */ body { background-color: var(--crm-background) !important; - color: var(--crm-on-background) !important; + color: var(--crm-text-primary) !important; } -/* ---------- TOP NAVIGATION BAR ---------- */ -.fi-top-nav { - height: var(--crm-nav-height) !important; - background-color: var(--crm-surface-container-lowest) !important; - border-bottom: 1px solid var(--crm-outline-variant) !important; - box-shadow: 0 1px 2px rgba(0,0,0,0.05) !important; - font-family: var(--crm-font-body) !important; +/* ---------- SIDEBAR ---------- */ +.fi-sidebar { + background: var(--crm-surface) !important; + border-right: 1px solid var(--crm-border) !important; + box-shadow: none !important; } -.fi-top-nav .fi-top-nav-item a { - font-size: 14px !important; - font-weight: 500 !important; - color: var(--crm-on-surface-variant) !important; - transition: color 0.2s !important; +.fi-sidebar-header { + padding: 20px 20px !important; + border-bottom: 1px solid var(--crm-border) !important; } -.fi-top-nav .fi-top-nav-item a:hover { - color: var(--crm-primary) !important; -} - -.fi-top-nav .fi-top-nav-item-active a { - color: var(--crm-primary) !important; - border-bottom: 2px solid var(--crm-primary) !important; -} - -/* Brand name in top nav */ -.fi-top-nav .fi-logo { +.fi-sidebar-brand { font-family: var(--crm-font-heading) !important; + font-weight: 800 !important; + font-size: 18px !important; + color: var(--crm-text-primary) !important; + letter-spacing: -0.02em !important; +} + +.fi-sidebar-nav { + padding: 12px !important; +} + +.fi-sidebar-group { + margin-bottom: 20px !important; +} + +.fi-sidebar-group-label { + font-size: 11px !important; font-weight: 700 !important; + text-transform: uppercase !important; + letter-spacing: 0.08em !important; + color: var(--crm-text-tertiary) !important; + padding: 0 12px !important; + margin-bottom: 8px !important; +} + +.fi-sidebar-item { + border-radius: var(--crm-radius-md) !important; + margin-bottom: 2px !important; + padding: 10px 12px !important; + transition: all 0.15s ease !important; +} + +.fi-sidebar-item:hover { + background: var(--crm-surface-container) !important; +} + +.fi-sidebar-item-active { + background: var(--crm-primary-light) !important; + color: var(--crm-primary) !important; + font-weight: 600 !important; +} + +.fi-sidebar-item-active::before { + content: '' !important; + position: absolute !important; + left: 0 !important; + top: 50% !important; + transform: translateY(-50%) !important; + width: 3px !important; + height: 20px !important; + background: var(--crm-primary) !important; + border-radius: 0 4px 4px 0 !important; +} + +.fi-sidebar-item-icon { + color: var(--crm-text-secondary) !important; +} + +.fi-sidebar-item-active .fi-sidebar-item-icon { + color: var(--crm-primary) !important; +} + +/* ---------- TOP BAR ---------- */ +.fi-topbar { + height: var(--crm-nav-height) !important; + background: rgba(255,255,255,0.9) !important; + backdrop-filter: blur(8px) !important; + border-bottom: 1px solid var(--crm-border) !important; + box-shadow: var(--crm-shadow-sm) !important; +} + +.fi-topbar .fi-logo { + font-family: var(--crm-font-heading) !important; + font-weight: 800 !important; font-size: 20px !important; color: var(--crm-primary) !important; + letter-spacing: -0.02em !important; } -/* ---------- MAIN CONTENT AREA ---------- */ +/* ---------- MAIN CONTENT ---------- */ .fi-main { background: var(--crm-background) !important; } .fi-main-ctn { - max-width: var(--crm-container-max) !important; + max-width: 1400px !important; margin-left: auto !important; margin-right: auto !important; - padding-left: var(--crm-margin-x) !important; - padding-right: var(--crm-margin-x) !important; - padding-top: 1.5rem !important; + padding: 28px 32px !important; +} + +/* ---------- PAGE HEADER ---------- */ +.fi-header { + margin-bottom: 28px !important; +} + +.fi-header-heading { + font-family: var(--crm-font-heading) !important; + font-size: 28px !important; + font-weight: 800 !important; + color: var(--crm-text-primary) !important; + letter-spacing: -0.02em !important; +} + +.fi-breadcrumbs a, +.fi-breadcrumbs span { + font-size: 13px !important; + color: var(--crm-text-tertiary) !important; +} + +/* ---------- CARDS / SECTIONS ---------- */ +.fi-card, +.fi-section { + background: var(--crm-surface) !important; + border: 1px solid var(--crm-border) !important; + border-radius: var(--crm-radius-lg) !important; + box-shadow: var(--crm-shadow-sm) !important; + overflow: hidden !important; +} + +.fi-section-header { + padding: 20px 24px !important; + border-bottom: 1px solid var(--crm-border) !important; + background: var(--crm-surface-dim) !important; +} + +.fi-section-header h3 { + font-family: var(--crm-font-heading) !important; + font-size: 16px !important; + font-weight: 700 !important; + color: var(--crm-text-primary) !important; +} + +/* ---------- STAT CARDS (Dashboard) ---------- */ +.fi-wi-stats-overview-stat { + background: var(--crm-surface) !important; + border: 1px solid var(--crm-border) !important; + border-radius: var(--crm-radius-lg) !important; + box-shadow: var(--crm-shadow-sm) !important; + padding: 24px !important; + position: relative !important; + overflow: hidden !important; + transition: all 0.2s ease !important; +} + +.fi-wi-stats-overview-stat:hover { + box-shadow: var(--crm-shadow-md) !important; + transform: translateY(-2px) !important; +} + +/* Gradient top border for stat cards */ +.fi-wi-stats-overview-stat::before { + content: '' !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + height: 3px !important; + background: linear-gradient(90deg, var(--crm-primary), #3b82f6) !important; +} + +.fi-wi-stats-overview-stat .fi-wi-stats-overview-stat-value { + font-family: var(--crm-font-heading) !important; + font-size: 36px !important; + font-weight: 800 !important; + color: var(--crm-text-primary) !important; + line-height: 1 !important; + letter-spacing: -0.02em !important; +} + +.fi-wi-stats-overview-stat .fi-wi-stats-overview-stat-label { + font-family: var(--crm-font-body) !important; + font-size: 13px !important; + font-weight: 500 !important; + color: var(--crm-text-secondary) !important; + margin-top: 6px !important; +} + +.fi-wi-stats-overview-stat .fi-wi-stats-overview-stat-description { + font-size: 12px !important; + color: var(--crm-text-tertiary) !important; } /* ---------- TABLES ---------- */ .fi-ta { - border-radius: var(--crm-radius-xl) !important; - border: 1px solid var(--crm-outline-variant) !important; - background: var(--crm-surface-container-lowest) !important; + border-radius: var(--crm-radius-lg) !important; + border: 1px solid var(--crm-border) !important; + background: var(--crm-surface) !important; + overflow: hidden !important; } .fi-ta-header { - background: var(--crm-surface-container-low) !important; - border-bottom: 1px solid var(--crm-outline-variant) !important; + background: var(--crm-surface-dim) !important; + border-bottom: 1px solid var(--crm-border) !important; } .fi-ta-header-cell { font-family: var(--crm-font-body) !important; font-size: 12px !important; - font-weight: 600 !important; - color: var(--crm-on-surface-variant) !important; - text-transform: none !important; + font-weight: 700 !important; + color: var(--crm-text-secondary) !important; + text-transform: uppercase !important; + letter-spacing: 0.04em !important; padding: 12px 16px !important; } .fi-ta-row { - border-bottom: 1px solid var(--crm-outline-variant) !important; + border-bottom: 1px solid var(--crm-border) !important; + transition: background 0.12s ease !important; } .fi-ta-row:hover { - background: var(--crm-surface-container-low) !important; + background: var(--crm-surface-hover) !important; +} + +/* Alternating rows */ +.fi-ta-row:nth-child(even) { + background: var(--crm-surface-dim) !important; +} + +.fi-ta-row:nth-child(even):hover { + background: var(--crm-surface-hover) !important; } .fi-ta-cell { font-family: var(--crm-font-body) !important; font-size: 14px !important; - color: var(--crm-on-surface) !important; - padding: 12px 16px !important; + color: var(--crm-text-primary) !important; + padding: 14px 16px !important; + vertical-align: middle !important; } /* ---------- STATUS BADGES ---------- */ .fi-badge { - border-radius: 4px !important; - font-size: 11px !important; + border-radius: var(--crm-radius-full) !important; + font-size: 12px !important; font-weight: 600 !important; + padding: 3px 10px !important; + display: inline-flex !important; + align-items: center !important; + gap: 5px !important; } /* Pending - amber */ .fi-badge-color-warning { - background-color: #fff8e1 !important; - color: #b45309 !important; - border: 1px solid #fde68a !important; + background-color: var(--crm-pending-bg) !important; + color: var(--crm-pending) !important; + border: 1px solid var(--crm-pending-border) !important; } /* Processing - blue */ .fi-badge-color-info { - background-color: #d8e2ff !important; - color: #004395 !important; - border: 1px solid #adc6ff !important; + background-color: var(--crm-processing-bg) !important; + color: var(--crm-processing) !important; + border: 1px solid var(--crm-processing-border) !important; } /* Resolved - green */ .fi-badge-color-success { - background-color: #ecfdf5 !important; - color: #047857 !important; - border: 1px solid #a7f3d0 !important; + background-color: var(--crm-resolved-bg) !important; + color: var(--crm-resolved) !important; + border: 1px solid var(--crm-resolved-border) !important; } /* Closed - gray */ .fi-badge-color-gray { - background-color: #f3f4f6 !important; - color: #374151 !important; - border: 1px solid #d1d5db !important; + background-color: var(--crm-closed-bg) !important; + color: var(--crm-closed) !important; + border: 1px solid var(--crm-closed-border) !important; } -/* Tag badges */ -.fi-ta-tags .fi-badge { - background-color: var(--crm-surface-container-high) !important; - color: var(--crm-on-surface-variant) !important; - border: 1px solid var(--crm-outline-variant) !important; - border-radius: 2px !important; - font-size: 10px !important; - padding: 1px 6px !important; +/* Danger badge */ +.fi-badge-color-danger { + background-color: var(--crm-escalated-bg) !important; + color: var(--crm-escalated) !important; + border: 1px solid rgba(220,38,38,0.2) !important; +} + +/* ---------- BUTTONS ---------- */ +.fi-btn { + border-radius: var(--crm-radius-md) !important; + font-family: var(--crm-font-body) !important; + font-weight: 600 !important; + font-size: 14px !important; + transition: all 0.15s ease !important; +} + +.fi-btn:hover { + transform: translateY(-1px) !important; +} + +.fi-btn:active { + transform: translateY(0) !important; +} + +/* Primary button */ +.fi-btn.fi-btn-color-primary, +.fi-btn-primary { + background-color: var(--crm-primary) !important; + color: var(--crm-on-primary) !important; + box-shadow: 0 1px 3px rgba(26,86,219,0.3) !important; + border: none !important; +} + +.fi-btn.fi-btn-color-primary:hover, +.fi-btn-primary:hover { + background-color: var(--crm-primary-hover) !important; + box-shadow: 0 4px 12px rgba(26,86,219,0.3) !important; +} + +/* Secondary / Outlined button */ +.fi-btn.fi-btn-color-gray, +.fi-btn.fi-btn-color-secondary { + background: var(--crm-surface) !important; + border: 1px solid var(--crm-border) !important; + color: var(--crm-text-secondary) !important; +} + +.fi-btn.fi-btn-color-gray:hover, +.fi-btn.fi-btn-color-secondary:hover { + background: var(--crm-surface-container) !important; + border-color: var(--crm-border-strong) !important; +} + +/* Danger button */ +.fi-btn.fi-btn-color-danger { + background: var(--crm-surface) !important; + border: 1px solid rgba(220,38,38,0.3) !important; + color: var(--crm-escalated) !important; +} + +.fi-btn.fi-btn-color-danger:hover { + background: var(--crm-escalated-bg) !important; } /* ---------- FORM COMPONENTS ---------- */ .fi-fo-field-label { font-family: var(--crm-font-body) !important; - font-size: 14px !important; + font-size: 13px !important; font-weight: 600 !important; - color: var(--crm-on-surface) !important; + color: var(--crm-text-primary) !important; } .fi-input-wrp input, @@ -291,132 +468,97 @@ body { .fi-input-wrp textarea, .fi-select-input, select { - border: 1px solid var(--crm-outline-variant) !important; - border-radius: var(--crm-radius) !important; - background: var(--crm-surface-container-lowest) !important; - color: var(--crm-on-surface) !important; + border: 1px solid var(--crm-border) !important; + border-radius: var(--crm-radius-md) !important; + background: var(--crm-surface) !important; + color: var(--crm-text-primary) !important; font-family: var(--crm-font-body) !important; font-size: 14px !important; - padding: 8px 12px !important; - box-shadow: 0 1px 2px rgba(0,0,0,0.05) !important; + padding: 10px 14px !important; + box-shadow: var(--crm-shadow-sm) !important; + transition: all 0.15s ease !important; } .fi-input-wrp input:focus, .fi-input-wrp select:focus, .fi-input-wrp textarea:focus { border-color: var(--crm-primary) !important; - box-shadow: 0 0 0 3px rgba(0,88,190,0.15) !important; - ring: 2px !important; + box-shadow: 0 0 0 3px rgba(26,86,219,0.1) !important; } -/* Form sections */ -.fi-section { - border: 1px solid var(--crm-outline-variant) !important; - border-radius: var(--crm-radius-xl) !important; - background: var(--crm-surface-container-lowest) !important; - box-shadow: 0 1px 2px rgba(0,0,0,0.05) !important; -} - -.fi-section-header { - padding: 16px 24px !important; - border-bottom: 1px solid rgba(194,198,214,0.5) !important; - background: rgba(249,249,255,0.5) !important; -} - -/* ---------- STAT CARDS (Dashboard) ---------- */ -.fi-wi-stats-overview-stat { - background: var(--crm-surface-container-lowest) !important; - border: 1px solid var(--crm-outline-variant) !important; - border-radius: var(--crm-radius-xl) !important; - box-shadow: 0 1px 2px rgba(0,0,0,0.05) !important; - padding: 20px !important; -} - -.fi-wi-stats-overview-stat .fi-wi-stats-overview-stat-value { - font-family: var(--crm-font-heading) !important; - font-size: 32px !important; - font-weight: 700 !important; - color: var(--crm-on-background) !important; -} - -.fi-wi-stats-overview-stat .fi-wi-stats-overview-stat-label { - font-family: var(--crm-font-body) !important; - font-size: 14px !important; - font-weight: 600 !important; - color: var(--crm-on-surface-variant) !important; -} - -/* ---------- PAGE HEADER (breadcrumb + title) ---------- */ -.fi-header { - margin-bottom: var(--crm-stack-gap) !important; -} - -.fi-breadcrumbs a, -.fi-breadcrumbs span { - font-size: 12px !important; - color: var(--crm-on-surface-variant) !important; +.fi-input-wrp input::placeholder, +.fi-input-wrp textarea::placeholder { + color: var(--crm-text-tertiary) !important; } /* ---------- TABS ---------- */ .fi-tabs { - border-bottom: 1px solid var(--crm-outline-variant) !important; - gap: 1.5rem !important; + border-bottom: 1px solid var(--crm-border) !important; + gap: 0 !important; } .fi-tabs .fi-tabs-item { - padding-bottom: 12px !important; + padding: 12px 20px !important; font-family: var(--crm-font-body) !important; font-size: 14px !important; font-weight: 600 !important; - color: var(--crm-secondary) !important; + color: var(--crm-text-secondary) !important; + border-bottom: 2px solid transparent !important; + transition: all 0.15s ease !important; +} + +.fi-tabs .fi-tabs-item:hover { + color: var(--crm-text-primary) !important; + background: var(--crm-surface-dim) !important; } .fi-tabs .fi-tabs-item-active { color: var(--crm-primary) !important; - border-bottom: 2px solid var(--crm-primary) !important; + border-bottom-color: var(--crm-primary) !important; } /* ---------- MODALS ---------- */ .fi-modal-window { border-radius: var(--crm-radius-xl) !important; - border: 1px solid var(--crm-outline-variant) !important; - box-shadow: 0 20px 25px rgba(0,0,0,0.1) !important; + border: 1px solid var(--crm-border) !important; + box-shadow: var(--crm-shadow-xl) !important; } .fi-modal-heading { font-family: var(--crm-font-heading) !important; font-size: 18px !important; + font-weight: 700 !important; + color: var(--crm-text-primary) !important; +} + +/* ---------- FILTER BAR ---------- */ +.fi-filters { + background: var(--crm-surface) !important; + border: 1px solid var(--crm-border) !important; + border-radius: var(--crm-radius-lg) !important; + padding: 16px !important; + margin-bottom: 20px !important; +} + +/* ---------- PAGINATION ---------- */ +.fi-pagination .fi-pagination-item { + border-radius: var(--crm-radius-sm) !important; + border: 1px solid var(--crm-border) !important; + background: var(--crm-surface) !important; + color: var(--crm-text-secondary) !important; + font-size: 13px !important; font-weight: 600 !important; + transition: all 0.15s ease !important; } -/* ---------- DELETE / DANGER BUTTONS ---------- */ -.fi-btn.fi-btn-color-danger { - border: 1px solid rgba(186,26,26,0.3) !important; - background: var(--crm-surface-container-lowest) !important; - color: var(--crm-error) !important; - border-radius: var(--crm-radius) !important; - font-weight: 500 !important; +.fi-pagination .fi-pagination-item:hover { + background: var(--crm-surface-container) !important; } -.fi-btn.fi-btn-color-danger:hover { - background: var(--crm-error-container) !important; -} - -/* ---------- SECONDARY / OUTLINED BUTTONS ---------- */ -.fi-btn.fi-btn-color-gray, -.fi-btn.fi-btn-color-secondary { - background: var(--crm-surface-container-lowest) !important; - border: 1px solid var(--crm-outline-variant) !important; - color: var(--crm-secondary) !important; - border-radius: var(--crm-radius) !important; - font-family: var(--crm-font-body) !important; - font-weight: 500 !important; - font-size: 14px !important; -} - -.fi-btn.fi-btn-color-gray:hover, -.fi-btn.fi-btn-color-secondary:hover { - background: var(--crm-surface-variant) !important; +.fi-pagination .fi-pagination-item-active { + background-color: var(--crm-primary) !important; + color: var(--crm-on-primary) !important; + border-color: var(--crm-primary) !important; } /* ---------- TOGGLE SWITCH ---------- */ @@ -430,62 +572,62 @@ select { border-color: var(--crm-primary) !important; } -/* ---------- FILTER BAR ---------- */ -.fi-filters { - background: var(--crm-surface-container-lowest) !important; - border: 1px solid var(--crm-outline-variant) !important; +/* ---------- WIDGETS ---------- */ +.fi-widget { + background: var(--crm-surface) !important; + border: 1px solid var(--crm-border) !important; border-radius: var(--crm-radius-lg) !important; - padding: 1rem !important; - margin-bottom: var(--crm-stack-gap) !important; + box-shadow: var(--crm-shadow-sm) !important; } -/* ---------- PAGINATION ---------- */ -.fi-pagination .fi-pagination-item-active { - background-color: var(--crm-primary) !important; - color: var(--crm-on-primary) !important; - border-radius: var(--crm-radius) !important; +.fi-widget-heading { + font-family: var(--crm-font-heading) !important; + font-size: 16px !important; + font-weight: 700 !important; + color: var(--crm-text-primary) !important; } -/* ---------- SIDEBAR (hide since we use top nav) ---------- */ -.fi-sidebar { - display: none !important; -} - -/* Adjust main content when sidebar is hidden */ -.fi-main-ctn { - padding-left: var(--crm-margin-x) !important; +/* Chart widget */ +.fi-wi-chart { + background: var(--crm-surface) !important; + border: 1px solid var(--crm-border) !important; + border-radius: var(--crm-radius-lg) !important; } /* ---------- NOTIFICATION BELL ---------- */ .fi-icon-btn { - color: var(--crm-on-surface-variant) !important; + color: var(--crm-text-secondary) !important; + border-radius: var(--crm-radius-md) !important; + transition: all 0.15s ease !important; } .fi-icon-btn:hover { color: var(--crm-primary) !important; + background: var(--crm-surface-container) !important; } /* ---------- USER MENU ---------- */ .fi-user-menu .fi-user-menu-trigger { font-family: var(--crm-font-body) !important; font-size: 14px !important; + font-weight: 500 !important; } -/* ---------- DARK MODE OVERRIDES ---------- */ -.dark, [class*="dark"] { - --crm-background: #0f172a; - --crm-surface-container-lowest: #1e293b; - --crm-surface-container-high: #334155; - --crm-surface-variant: #334155; - --crm-on-surface: #e2e8f0; - --crm-on-surface-variant: #94a3b8; - --crm-on-background: #e2e8f0; - --crm-outline-variant: #475569; +/* ---------- BREADCRUMBS ---------- */ +.fi-breadcrumbs { + margin-bottom: 0 !important; } -/* ---------- FIX: remove Filament's default blue backgrounds ---------- */ -.fi-sidebar, .fi-topbar { - background-color: var(--crm-surface-container-lowest) !important; +/* ---------- ALERT / NOTIFICATION ---------- */ +.fi-notification { + border-radius: var(--crm-radius-md) !important; + border: 1px solid var(--crm-border) !important; + box-shadow: var(--crm-shadow-md) !important; +} + +/* ---------- RELATION MANAGER ---------- */ +.fi-relation-manager { + margin-top: 24px !important; } /* ---------- FIX: Icon sizing ---------- */ @@ -514,7 +656,61 @@ svg[class*="fi-icon"] { /* ---------- Similar Cases footer panel ---------- */ .fi-footer { margin-top: 2rem; - background: var(--crm-surface-container-lowest) !important; - border: 1px solid var(--crm-outline-variant) !important; - border-radius: var(--crm-radius-xl) !important; + background: var(--crm-surface) !important; + border: 1px solid var(--crm-border) !important; + border-radius: var(--crm-radius-lg) !important; +} + +/* ---------- DARK MODE ---------- */ +.dark, [class*="dark"] { + --crm-background: #0f172a; + --crm-surface: #1e293b; + --crm-surface-dim: #1e293b; + --crm-surface-container: #334155; + --crm-surface-hover: #334155; + --crm-text-primary: #e2e8f0; + --crm-text-secondary: #94a3b8; + --crm-text-tertiary: #64748b; + --crm-border: #334155; + --crm-border-strong: #475569; + --crm-primary: #60a5fa; + --crm-primary-hover: #3b82f6; + --crm-primary-light: rgba(96,165,250,0.1); +} + +.dark .fi-sidebar, +[class*="dark"] .fi-sidebar { + background: #1e293b !important; + border-right-color: #334155 !important; +} + +.dark .fi-topbar, +[class*="dark"] .fi-topbar { + background: rgba(15,23,42,0.9) !important; + border-bottom-color: #334155 !important; +} + +/* ---------- ANIMATIONS ---------- */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.fi-main { + animation: fadeIn 0.3s ease !important; +} + +/* ---------- ESCALATED INDICATOR ---------- */ +.fi-badge-color-danger::before { + content: '' !important; + width: 6px !important; + height: 6px !important; + border-radius: 50% !important; + background: var(--crm-escalated) !important; + animation: pulse 2s infinite !important; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } } diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 00000000..d874dc3a --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/Feature/ClosingPermissionTest.php b/tests/Feature/ClosingPermissionTest.php index 74faaf73..c0c95d8b 100644 --- a/tests/Feature/ClosingPermissionTest.php +++ b/tests/Feature/ClosingPermissionTest.php @@ -7,12 +7,27 @@ use App\Models\FeedbackAttachment; use App\Models\FeedbackChannel; use App\Models\User; use App\Services\ClosingService; +use Spatie\Permission\Models\Permission; +use Spatie\Permission\Models\Role; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +beforeEach(function () { + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + + // Create permissions + Permission::firstOrCreate(['name' => 'close-ticket']); + Permission::firstOrCreate(['name' => 'view-feedback']); + Permission::firstOrCreate(['name' => 'create-feedback']); + Permission::firstOrCreate(['name' => 'update-feedback']); + Permission::firstOrCreate(['name' => 'add-interaction']); + Permission::firstOrCreate(['name' => 'transfer-department']); +}); + // ─── Scenario 1: Staff cannot close ticket ─────────────────────────── test('staff cannot close ticket - returns 403 forbidden', function () { - \Spatie\Permission\Models\Role::create(['name' => 'staff']); + $staffRole = Role::create(['name' => 'staff']); + $staffRole->givePermissionTo(['view-feedback', 'create-feedback', 'update-feedback', 'add-interaction']); $staff = User::factory()->create(); $staff->assignRole('staff'); @@ -45,7 +60,8 @@ test('staff cannot close ticket - returns 403 forbidden', function () { // ─── Scenario 2: Manager cannot close without proof_of_resolution ──── test('manager cannot close ticket without proof_of_resolution evidence', function () { - \Spatie\Permission\Models\Role::create(['name' => 'manager']); + $managerRole = Role::create(['name' => 'manager']); + $managerRole->givePermissionTo(['close-ticket', 'view-feedback', 'create-feedback', 'update-feedback', 'transfer-department']); $manager = User::factory()->create(); $manager->assignRole('manager'); @@ -81,7 +97,9 @@ test('manager cannot close ticket without proof_of_resolution evidence', functio // ─── Scenario 2b: Manager CAN close with proof_of_resolution ───────── test('manager can close ticket with proof_of_resolution evidence', function () { \Illuminate\Support\Facades\Notification::fake(); - \Spatie\Permission\Models\Role::create(['name' => 'manager']); + + $managerRole = Role::create(['name' => 'manager']); + $managerRole->givePermissionTo(['close-ticket', 'view-feedback', 'create-feedback', 'update-feedback', 'transfer-department']); $manager = User::factory()->create(); $manager->assignRole('manager'); @@ -122,7 +140,8 @@ test('manager can close ticket with proof_of_resolution evidence', function () { // ─── Scenario 2c: Manager cannot close ticket from other department ── test('manager cannot close ticket from department they do not manage', function () { - \Spatie\Permission\Models\Role::create(['name' => 'manager']); + $managerRole = Role::create(['name' => 'manager']); + $managerRole->givePermissionTo(['close-ticket', 'view-feedback', 'create-feedback', 'update-feedback', 'transfer-department']); $manager = User::factory()->create(); $manager->assignRole('manager'); diff --git a/tests/Feature/TransferFlowTest.php b/tests/Feature/TransferFlowTest.php index e413616b..17b3be02 100644 --- a/tests/Feature/TransferFlowTest.php +++ b/tests/Feature/TransferFlowTest.php @@ -7,12 +7,24 @@ use App\Models\FeedbackChannel; use App\Models\TicketTransferLog; use App\Models\User; use App\Services\TransferService; +use Spatie\Permission\Models\Permission; +use Spatie\Permission\Models\Role; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +beforeEach(function () { + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + + Permission::firstOrCreate(['name' => 'transfer-department']); + Permission::firstOrCreate(['name' => 'view-feedback']); + Permission::firstOrCreate(['name' => 'create-feedback']); + Permission::firstOrCreate(['name' => 'update-feedback']); +}); + // ─── Scenario 3: Transfer Flow ────────────────────────────────────── test('transfer creates log, updates department, and notifies manager', function () { - \Spatie\Permission\Models\Role::create(['name' => 'manager']); + $managerRole = Role::create(['name' => 'manager']); + $managerRole->givePermissionTo(['transfer-department', 'view-feedback', 'create-feedback', 'update-feedback']); \Illuminate\Support\Facades\Notification::fake(); @@ -73,7 +85,8 @@ test('transfer creates log, updates department, and notifies manager', function // ─── Transfer without reason should fail ──────────────────────────── test('transfer without reason throws validation error', function () { - \Spatie\Permission\Models\Role::create(['name' => 'manager']); + $managerRole = Role::create(['name' => 'manager']); + $managerRole->givePermissionTo(['transfer-department', 'view-feedback', 'create-feedback', 'update-feedback']); $sender = User::factory()->create(); $sender->assignRole('manager');