# AfterSales CRM - AI Agent Instructions > **Trước khi làm việc, đọc các file quan trọng:** > - `CODEBASE_SNAPSHOT.md` — Toàn bộ cấu trúc codebase (thay vì scan lại code) > - `TASKS_ROADMAP.md` — Roadmap: đã làm, đang làm, sẽ làm > - `PROGRESS.md` — Lịch sử tiến độ hoàn thành ## Overview AfterSales CRM is a real-estate after-sales customer care system built with **Laravel 13.6** + **Filament 5.6**. ## How to Run ```bash cd /home/phuongtc/vibecode/minicrm php artisan migrate:fresh --seed php artisan serve # Open http://localhost:8000/admin ``` ### Test Accounts (seeded) | Role | Email | Password | |------|-------|----------| | Admin | admin@minicrm.local | password | | Manager | manager@minicrm.local | password | | Staff | staff@minicrm.local | password | ## Tech Stack Versions - PHP 8.3 - Laravel 13.6.0 (`vendor/laravel/framework`) - Filament 5.6.1 (`vendor/filament/filament`) - Database: SQLite (`database/database.sqlite`) - Composer: `/home/phuongtc/composer` - Spatie: laravel-permission (role-based auth) - Testing: Pest PHP ## Running Commands ```bash cd /home/phuongtc/vibecode/minicrm php artisan make:model Foo php artisan make:filament-resource Foo --generate php artisan make:filament-relation-manager ResourceName relationship titleAttribute php artisan make:migration create_foo_table php artisan migrate 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 ``` minicrm/ ├── app/ │ ├── Filament/ │ │ ├── Resources/ │ │ │ ├── Feedback/ │ │ │ │ ├── FeedbackResource.php │ │ │ │ ├── Schemas/FeedbackForm.php │ │ │ │ ├── Tables/FeedbackTable.php │ │ │ │ ├── Pages/{Create,Edit,List}Feedback.php │ │ │ │ └── RelationManagers/InteractionsRelationManager.php │ │ │ ├── Products/ │ │ │ ├── Customers/ │ │ │ │ └── RelationManagers/FeedbacksRelationManager.php │ │ │ ├── FeedbackChannels/ │ │ │ ├── FeedbackTags/ │ │ │ ├── Contracts/ │ │ │ ├── Users/ │ │ │ └── Roles/ │ │ └── Widgets/ │ │ ├── ResolvedTicketsWidget.php # Dashboard: resolved tickets table │ │ ├── AvgProcessingTimeWidget.php # Dashboard: stats overview │ │ └── HandlerPerformanceWidget.php # Dashboard: bar chart per handler │ ├── Enums/ │ │ ├── ContractType.php │ │ ├── ContractStatus.php │ │ ├── HandoverStatus.php │ │ └── ServiceType.php │ ├── Models/ │ │ ├── User.php # HasRoles (Spatie), isAdmin(), isManager() │ │ ├── Department.php # NEW: name, manager_id → User │ │ ├── Product.php │ │ ├── Customer.php │ │ ├── CustomerProduct.php │ │ ├── Feedback.php # added: current_department_id, is_escalated, transferLogs │ │ ├── FeedbackChannel.php │ │ ├── FeedbackTag.php │ │ ├── FeedbackInteraction.php │ │ ├── FeedbackAttachment.php # added: uuid, disk, collection │ │ └── TicketTransferLog.php # NEW: department-to-department transfer history │ ├── Policies/ │ │ ├── FeedbackPolicy.php # Spatie hasRole() checks │ │ ├── ProductPolicy.php │ │ ├── CustomerPolicy.php │ │ └── FeedbackChannelPolicy.php │ ├── Services/ │ │ ├── FileService.php # Upload, validation, temp URLs, collections │ │ ├── TransferService.php # Cross-dept transfer + notification │ │ └── ClosingService.php # Role-gated closing + evidence check │ ├── Rules/ │ │ └── RequireProofOfResolution.php # Custom validation: proof_of_resolution required for close │ ├── Notifications/ │ │ ├── TicketTransferred.php # DB + Mail for department transfer │ │ └── TicketClosed.php # DB + Mail for ticket closure │ └── Providers/Filament/ │ └── AdminPanelProvider.php ├── lang/ # i18n translation files │ ├── en/ │ │ ├── app.php # General UI labels, navigation, status, widgets │ │ ├── feedback.php # Feedback domain: services, notifications, interactions │ │ └── enums.php # Enum labels │ └── vi/ │ ├── app.php │ ├── feedback.php │ └── enums.php ├── resources/views/filament/resources/feedback/ │ ├── similar-cases.blade.php │ └── attachment-links.blade.php # Modal listing attachments with signed download URLs ├── database/ │ ├── migrations/ │ └── seeders/AfterSalesSeeder.php # includes departments, logs, attachments ├── tests/ │ ├── Feature/ │ │ ├── ClosingPermissionTest.php # 4 tests: staff forbid, no-evidence, with-evidence, cross-dept │ │ └── TransferFlowTest.php # 2 tests: successful + missing reason │ └── Pest.php ├── storage/app/private/feedback-attachments/ # Private disk upload directory ├── composer.json ├── PROGRESS.md └── AGENTS.md ``` ## Database Schema ### departments (NEW) | Column | Type | Notes | |--------|------|-------| | id | bigint PK | | | name | string | | | manager_id | fk -> users nullable | Department head | | created_at, updated_at | timestamp | | ### ticket_transfer_logs (NEW) | Column | Type | Notes | |--------|------|-------| | id | bigint PK | | | feedback_id | fk -> feedback | cascadeOnDelete | | from_department_id | fk -> departments nullable | | | to_department_id | fk -> departments nullable | | | sender_id | fk -> users | Who initiated transfer | | reason | text | Mandatory | | created_at, updated_at | timestamp | | ### feedback (expanded) | Column | Type | Notes | |--------|------|-------| | id | bigint PK | | | customer_id | fk -> customers | cascadeOnDelete | | customer_product_id | fk -> customer_product nullable | null = general feedback | | feedback_channel_id | fk -> feedback_channels | | | assigned_to | fk -> users nullable | Current handler | | current_department_id | fk -> departments nullable | **(NEW)** Current department | | is_escalated | boolean default false | **(NEW)** | | title | string | | | content | text | | | status | string | pending, processing, resolved, closed | | created_at, updated_at | timestamp | | ### feedback_attachments (expanded) | Column | Type | Notes | |--------|------|-------| | id | bigint PK | | | uuid | string nullable unique | **(NEW)** | | feedback_id | fk -> feedback | | | feedback_interaction_id | fk -> feedback_interactions nullable | | | user_id | fk -> users | | | name | string | | | path | string | | | disk | string default 'local' | **(NEW)** | | collection | string default 'general' | **(NEW)** proof_of_resolution, customer_evidence, general | | mime_type | string nullable | | | size | bigint nullable | | | created_at, updated_at | timestamp | | ### Other tables feedback_interactions, feedback_tags, feedback_feedback_tag, products, customers, customer_product, feedback_channels, users (Spatie permission tables: roles, permissions, model_has_roles, etc.) ## Workflow ``` 1. Record Feedback → Assign handler → Assign department 2. Handler contacts customer / does work 3. Handler updates → May forward to another user (assignment interaction) 4. OR: Transfer to another department → TransferDepartment Action (reason required, notification sent) 5. Loop until → Marked as "resolved" (any role) 6. Manager reviews → Close Ticket Action (requires role=admin/manager + proof_of_resolution evidence) ``` ## Key Features ### Transfer Department (NEW - SOP compliant) - Action on EditFeedback page: TransferDepartment - Modal: select target department + reason (required) + optional attachments - Calls TransferService: creates TicketTransferLog, updates current_department_id, resets handler to dept manager - Sends TicketTransferred notification to target department manager ### Close Ticket (NEW - SOP compliant) - Action on EditFeedback page: CloseTicket - Visible only to admin/manager, only when status != 'closed' - Requires confirmation - Staff (403) if tries to close - 422 if no proof_of_resolution attachment exists - Sends TicketClosed notification to handler and department manager ### Status Change in Interactions (Updated) - Staff: only pending, processing, resolved (no "closed" option) - Manager/Admin: all options including "closed" - When "closed" selected: calls ClosingService which validates evidence ### File Management (Updated - SOP compliant) - Files stored on private `local` disk (storage/app/private/feedback-attachments/) - Collection system: `proof_of_resolution`, `customer_evidence`, `general` - FileService validates: jpg, png, pdf, mp4, mov, max 20MB - Temporary signed URLs for private file access - FileService::hasCollection() checks existence of required evidence ### Dashboard Widgets (NEW) - ResolvedTicketsWidget: table of resolved tickets awaiting closure (manager: filtered to own department) - AvgProcessingTimeWidget: 4 stats (resolved count, avg time, pending count, escalated count) - HandlerPerformanceWidget: bar chart of avg processing time per handler ## Internationalization (i18n) ### Configuration - Default locale: `vi` (Vietnamese), fallback: `en` (English) - Set in `.env`: `APP_LOCALE=vi`, `APP_FALLBACK_LOCALE=en` - Filament uses `app()->getLocale()` — no extra panel config needed ### Translation Files ``` lang/ ├── en/ │ ├── app.php # General UI: navigation, labels, status, widgets, blade strings │ ├── feedback.php # Feedback domain: services, notifications, interactions │ └── enums.php # Enum labels (HandoverStatus, ServiceType, ContractStatus, ContractType) └── vi/ ├── app.php ├── feedback.php └── enums.php ``` ### Translation Patterns ```php // Resource labels — MUST override getPluralLabel() (not auto-translated) public static function getPluralLabel(): ?string { return __('app.resource_feedbacks'); } // Navigation group — MUST override getNavigationGroup() (not auto-translated) public static function getNavigationGroup(): ?string { return __('app.nav_customer_care'); } // Form fields — MUST add explicit ->label() TextInput::make('name')->label(__('app.name')) // Status options 'options' => [ 'pending' => __('app.status_pending'), 'processing' => __('app.status_processing'), ] // Enum labels public function label(): string { return match ($this) { self::ACTIVE => __('enums.contract_status.active'), }; } // Widget heading/description — override getHeading()/getDescription() public function getHeading(): string | Htmlable | null { return __('app.widget_avg_time_by_handler'); } ``` ### Key Gotchas - `$pluralLabel` property does NOT auto-translate — must override `getPluralLabel()` - `getNavigationGroup()` does NOT auto-translate — must call `__()` explicitly - `$heading`/`$description` in ChartWidget are raw properties — must override `getHeading()`/`getDescription()` - Form fields without `->label()` use Filament's `filament-panels::` namespace (may not resolve correctly) - Always add `->label(__('key'))` to TextInput, Select, Textarea, etc. ## Navigation Structure ``` Dashboard ├── Customer Care / Chăm sóc khách hàng (group) │ └── Feedbacks / Phản ánh (chat-bubble-left-right, sort=1) └── Management / Quản lý (group) ├── Products / Sản phẩm (building-storefront, sort=1) ├── Customers / Khách hàng (user-group, sort=2) ├── Channels / Kênh phản ánh (inbox-stack, sort=3) ├── Tags / Thẻ (tag, sort=default) ├── Users / Người dùng (users, sort=5) — admin only └── Roles / Vai trò (shield-check, sort=6) — admin only ``` Dashboard widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget, HandlerPerformanceWidget (admin/manager only) ## RBAC (Role-Based Access Control) - Spatie 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-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) ### Form components use `Filament\Forms\Components` ```php use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Toggle; use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\RichEditor; ``` ### Schema/layout components use `Filament\Schemas\Components` ```php use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; ``` ### Services access pattern ```php use Illuminate\Support\Facades\App; $service = App::make(FileService::class); ``` ### Spatie role check ```php $user->hasRole('admin'); $user->hasRole(['admin', 'manager']); $user->assignRole('manager'); ``` ## Known Gotchas 1. **Navigation group icons**: Filament 5 raises exception if both a navigation group AND its child items have icons. 2. **Form component namespaces**: Form input components under `Filament\Forms\Components\*`, layout under `Filament\Schemas\Components\*`. 3. **Dynamic selects**: Use `$get` function injection, NOT `$component->getLivewire()->data[...]`. 4. **Table name**: Model `Feedback` maps to table `feedback` (singular). 5. **FileUpload with dehydrated(false)**: Attachments stored by Filament first; create FeedbackAttachment record directly in after()/afterSave()/afterCreate() — do NOT re-upload via FileService. 6. **Spatie roles in tests**: Create roles explicitly in each test (RefreshDatabase rolls back). 7. **Private disk**: Files use `local` disk (storage/app/private/); access via signed URLs from FileService. 8. **ChartWidget properties**: `$heading` and `$columnSpan` are non-static in Filament's ChartWidget. 9. **Manager closing permission**: Manager must be department head (`Department.manager_id`) to close tickets in that department. 10. **Docker**: Entrypoint auto-generates APP_KEY and runs migrations. SQLite persists via volume mount. 11. **i18n - pluralLabel**: `$pluralLabel` property does NOT auto-translate — must override `getPluralLabel()` and call `__()`. 12. **i18n - navigationGroup**: `getNavigationGroup()` does NOT auto-translate — must return `__('key')` explicitly. 13. **i18n - ChartWidget heading**: `$heading`/`$description` are raw properties — must override `getHeading()`/`getDescription()`. 14. **i18n - form labels**: Form fields without `->label()` use Filament's `filament-panels::` namespace — always add explicit `->label(__('key'))`. 15. **i18n - Filament vendor**: Filament ships 57 Vietnamese translation files — auto-activated when `APP_LOCALE=vi`.