Files
minicrm/AGENTS.md
phuongtc efa97b6c71
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
them log, chinh upload
2026-05-09 11:00:59 +00:00

504 lines
21 KiB
Markdown

# 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
> - `docs/I18N_GUIDE.md` — Hướng dẫn đa ngôn ngữ
> - `docs/ENUM_GUIDE.md` — Hướng dẫn thêm Enum mới
## 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`.
16. **FileUpload disk**: Always use `disk('public')` for all FileUpload components. NEVER use `disk('local')` — causes file not found errors.
17. **FileUpload dehydrated**: Do NOT use `dehydrated(false)` — prevents file paths from being available in afterSave()/afterCreate(). Use property to store paths in mutateFormDataBeforeSave().
18. **File metadata safety**: Always check `$disk->exists($path)` before calling `$disk->mimeType()` or `$disk->size()` — prevents UnableToRetrieveMetadata error.
19. **FileUpload in Actions**: When handling file uploads in Action callbacks (e.g., transfer attachments), file paths are strings (not UploadedFile). Create records directly instead of using FileService::upload().
20. **Tailwind CSS in Filament**: Custom blade views render inside Filament layout which does NOT include full Tailwind CSS build. Must add needed utilities to `public/css/filament-custom.css` with `!important`.
21. **SVG icon sizing**: Use inline `width`/`height` attributes on SVG icons (e.g., `<svg width="14" height="14">`) instead of Tailwind classes (`w-3.5 h-3.5`) — prevents Filament CSS from overriding sizes.
## File Upload Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ FileUpload Component (disk='public') │
│ → Filament uploads via AJAX to storage/app/public/ │
│ → File path stored in Livewire state │
│ │
│ Form Submit │
│ → mutateFormDataBeforeSave() stores paths in property │
│ → Removes 'attachments' from $data before saving model │
│ │
│ afterSave()/afterCreate() │
│ → Uses stored paths to create FeedbackAttachment records │
│ → Checks $disk->exists($path) before getting metadata │
│ → Uses asset('storage/' . $path) for URLs │
└─────────────────────────────────────────────────────────────────┘
```
### FileUpload Pattern (correct):
```php
// In Form
FileUpload::make('attachments')
->disk('public') // ALWAYS public
->directory('feedback-attachments')
// Do NOT use ->dehydrated(false)
// In Page class
protected array $pendingAttachments = [];
protected function mutateFormDataBeforeSave(array $data): array
{
$this->pendingAttachments = $data['attachments'] ?? [];
unset($data['attachments']); // Remove before saving model
return $data;
}
protected function afterSave(): void
{
$disk = Storage::disk('public');
foreach ($this->pendingAttachments as $path) {
if (! $disk->exists($path)) continue; // Safety check
$this->getRecord()->attachments()->create([
'path' => $path,
'disk' => 'public',
'mime_type' => $disk->mimeType($path),
'size' => $disk->size($path),
// ...
]);
}
}
```
### Tailwind CSS in Filament (correct):
```php
// Filament uses its own CSS build, NOT the project's Tailwind build
// Custom blade views need utilities added to public/css/filament-custom.css
// In filament-custom.css:
.flex { display: flex !important; }
.items-center { align-items: center !important; }
.gap-2 { gap: 0.5rem !important; }
// ... add all needed utilities with !important
// For SVG icons, use inline attributes:
<svg width="14" height="14" class="text-blue-600">...</svg>
// NOT: <svg class="w-3.5 h-3.5 text-blue-600">...</svg>