4 Commits
v2 ... main

Author SHA1 Message Date
a5af277294 Deployment: Fix Docker build failure by adding zip extension and .dockerignore
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
2026-05-21 04:09:00 +00:00
ffbfde943c Deployment: Upgrade Dockerfile with multi-stage asset building, add Redis service and queue workers 2026-05-21 04:00:30 +00:00
fc9158b928 Refactor: Enforce type safety, fix close deadlock, implement assignee restrictions and notify admin fallbacks
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
2026-05-20 10:42:30 +00:00
44a7f53deb Add feature tests for CSKH Excel import and database backup CLI commands 2026-05-20 10:34:18 +00:00
63 changed files with 2765 additions and 1113 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
.git
.github
node_modules
vendor
.env
.env.*
!.env.example
bootstrap/cache/*.php
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
storage/logs/*
database/*.sqlite
public/build
README.md
CHANGELOG.md
TASKS_ROADMAP.md
AGENTS.md
PROGRESS.md
SOP_des.md
SYSTEM_ASSESSMENT_PROPOSALS.md
UI_DESIGN_BRIEF.md

229
AGENTS.md
View File

@@ -3,25 +3,19 @@
> **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
> - `luutru/` — Archive các file đã lỗi thời (tham khảo nếu cần)
>
> **File đã lỗi thời trong luutru/:** PROGRESS.md, SOP_des.md, AI_01/02/03, CHANGELOG.md, README.md gốc, SYSTEM_ASSESSMENT_PROPOSALS.md
## Overview
AfterSales CRM is a real-estate after-sales customer care system built with **Laravel 13.6** + **Filament 5.6**.
Hệ thống có **2 Filament Panel**:
- `/admin` — Admin Panel: đầy đủ tất cả modules (Contract, Handover, Service, User/Role...)
- `/support` — Support Panel: rút gọn, tập trung Feedback (Feedback, Customer, Product, Channel, Tag, ActivityLog)
## How to Run
```bash
cd /home/phuongtc/vibecode/minicrm
php artisan migrate:fresh --seed
php artisan serve
# Admin panel: http://localhost:8000/admin
# Support panel: http://localhost:8000/support
# Open http://localhost:8000/admin
```
### Test Accounts (seeded)
@@ -57,30 +51,8 @@ php artisan test # run pest tests
php artisan app:export-backup
php artisan app:export-backup --path=/custom/path --pretty
php artisan app:export-backup --tables=feedback,customers,products
# Import data from Excel
php artisan app:import-cskh ai_instructions/CSKH.xlsx --dry-run
php artisan app:import-cskh ai_instructions/CSKH.xlsx
```
## Dual Panel Architecture
Hệ thống có 2 Filament Panel độc lập:
### Admin Panel (`/admin`) — `AdminPanelProvider.php`
Đầy đủ tất cả modules, bao gồm:
- Feedback, Customer, Product, Contract, FeedbackChannel, FeedbackTag
- User, Role (admin-only)
- 3 Dashboard widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget, HandlerPerformanceWidget
### Support Panel (`/support`) — `SupportPanelProvider.php`
Rút gọn, chỉ tập trung vào quy trình xử lý feedback:
- Feedback, Customer, Product, FeedbackChannel, FeedbackTag, ActivityLog
- 3 Dashboard widgets giống Admin panel
- Không có: Contracts, Users, Roles, Handovers, ProductServices
Cả 2 panel dùng chung: Models, Services, Policies, Database, Translations.
## Directory Structure
```
@@ -88,9 +60,6 @@ minicrm/
├── app/
│ ├── Filament/
│ │ ├── Resources/
│ │ │ ├── ActivityLogs/ # NEW: Audit trail (read-only)
│ │ │ │ ├── ActivityLogResource.php
│ │ │ │ └── Pages/ListActivityLogs.php
│ │ │ ├── Feedback/
│ │ │ │ ├── FeedbackResource.php
│ │ │ │ ├── Schemas/FeedbackForm.php
@@ -98,119 +67,78 @@ minicrm/
│ │ │ │ ├── Pages/{Create,Edit,List}Feedback.php
│ │ │ │ └── RelationManagers/InteractionsRelationManager.php
│ │ │ ├── Products/
│ │ │ │ └── RelationManagers/{Contracts,Handovers,ProductServices}RelationManager.php
│ │ │ ├── Customers/
│ │ │ │ └── RelationManagers/FeedbacksRelationManager.php
│ │ │ ├── Contracts/
│ │ │ ├── FeedbackChannels/
│ │ │ ├── FeedbackTags/
│ │ │ ├── Contracts/
│ │ │ ├── Users/
│ │ │ └── Roles/
│ │ └── Widgets/
│ │ ├── ResolvedTicketsWidget.php # Dashboard: resolved tickets table
│ │ ├── AvgProcessingTimeWidget.php # Dashboard: stats overview
│ │ └── HandlerPerformanceWidget.php # Dashboard: bar chart per handler
│ ├── Console/Commands/
│ │ ├── ImportCskh.php # app:import-cskh
│ │ └── ExportBackup.php # app:export-backup
│ ├── Enums/
│ │ ├── ContractCategory.php # V2: ownership/business
│ │ ├── ContractType.php
│ │ ├── ContractStatus.php
│ │ ├── HandoverStatus.php
│ │ └── ServiceType.php
│ ├── Http/
│ │ ├── Controllers/Controller.php
│ │ └── Middleware/HandleUploadErrors.php
│ ├── Models/
│ │ ├── ActivityLog.php # NEW: Audit trail model
│ │ ├── Contract.php # V2: +category, +representative_*
│ │ ├── Customer.php # V2: ownedProducts() qua contracts
│ │ ├── Department.php
│ │ ├── Feedback.php # V2: bỏ customer_product_id
│ │ ├── FeedbackAttachment.php
│ │ ├── FeedbackChannel.php
│ │ ├── FeedbackInteraction.php
│ │ ├── FeedbackTag.php
│ │ ├── Handover.php
│ │ ├── User.php # HasRoles (Spatie), isAdmin(), isManager()
│ │ ├── Department.php # NEW: name, manager_id → User
│ │ ├── Product.php
│ │ ├── ProductService.php
│ │ ├── TicketTransferLog.php
│ │ ── User.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/
│ │ ├── ActivityLogPolicy.php # NEW
│ │ ├── ContractPolicy.php
│ │ ├── CustomerPolicy.php
│ │ ├── FeedbackChannelPolicy.php
│ │ ├── FeedbackPolicy.php
│ │ ├── FeedbackPolicy.php # Spatie hasRole() checks
│ │ ├── ProductPolicy.php
│ │ ├── RolePolicy.php
│ │ └── UserPolicy.php
│ ├── Notifications/
│ │ ├── TicketTransferred.php
│ │ ── TicketClosed.php
├── Providers/Filament/
│ │ ├── AdminPanelProvider.php # Panel đầy đủ (/admin)
│ │ └── SupportPanelProvider.php # Panel rút gọn (/support)
│ │ ├── 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
── Services/
├── ActivityLogger.php # NEW: static log() cho audit trail
── ClosingService.php
├── FileService.php
│ └── TransferService.php
├── lang/ # i18n translation files
│ ├── en/{app.php, feedback.php, enums.php}
└── vi/{app.php, feedback.php, enums.php}
│ │ └── 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
├── webappUI/aftersales_crm_core/ # Design tokens & system
│ └── DESIGN.md
│ └── attachment-links.blade.php # Modal listing attachments with signed download URLs
├── database/
│ ├── migrations/ # 25 migrations
│ └── seeders/{AfterSalesSeeder,PermissionSeeder}.php
├── tests/Feature/
│ ├── ClosingPermissionTest.php
└── TransferFlowTest.php
├── luutru/ # Archive các file đã lỗi thời
├── UI_DESIGN_BRIEF.md # Thiết kế UI chi tiết
── TASKS_ROADMAP.md
│ ├── 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
### activity_logs (NEW)
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| user_id | fk -> users nullable | Who performed action |
| action | string | import, export, transfer, close, create, update, delete |
| description | text | Human-readable description |
| subject_type | string nullable | Related model class |
| subject_id | bigint nullable | Related model ID |
| metadata | json nullable | Extra data |
| created_at | timestamp | |
### contracts (V2: category + representative)
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| product_id | fk -> products | cascadeOnDelete |
| customer_id | fk -> customers | cascadeOnDelete |
| category | string | **(V2)** ownership \| business |
| type | string | **(V2)** deposit, purchase, transfer, rental, self_business, bcc |
| start_date, end_date | date nullable | |
| status | string | active, expired, liquidated |
| printed_at | date nullable | |
| signed_status | string nullable | |
| representative_name | string nullable | **(V2)** |
| representative_phone | string nullable | **(V2)** |
| representative_email | string nullable | **(V2)** |
| created_at, updated_at | timestamp | |
> **V2 Constraint:** Mỗi product chỉ có tối đa 1 contract active mỗi category. Validation ở cả Model level (`booted::saving`) và UI.
### departments (NEW)
| Column | Type | Notes |
|--------|------|-------|
@@ -219,7 +147,7 @@ minicrm/
| manager_id | fk -> users nullable | Department head |
| created_at, updated_at | timestamp | |
### ticket_transfer_logs
### ticket_transfer_logs (NEW)
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
@@ -235,11 +163,11 @@ minicrm/
|--------|------|-------|
| id | bigint PK | |
| customer_id | fk -> customers | cascadeOnDelete |
| contract_id | fk -> contracts nullable | **(V2)** Snapshot contract (thay thế customer_product_id) |
| 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 | Current department |
| is_escalated | boolean default false | |
| 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 |
@@ -249,20 +177,20 @@ minicrm/
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| uuid | string nullable unique | |
| 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' | |
| collection | string default 'general' | proof_of_resolution, customer_evidence, general |
| 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, feedback_channels, users, contracts, handovers, product_services, roles, permissions, model_has_roles (Spatie), notifications, sessions, cache
feedback_interactions, feedback_tags, feedback_feedback_tag, products, customers, customer_product, feedback_channels, users (Spatie permission tables: roles, permissions, model_has_roles, etc.)
## Workflow
@@ -277,24 +205,14 @@ feedback_interactions, feedback_tags, feedback_feedback_tag, products, customers
## Key Features
### Dual Panel System (NEW)
- **Admin Panel** (`/admin`): Đầy đủ modules, User/Role management
- **Support Panel** (`/support`): Rút gọn, tập trung Feedback. Dùng cho đội support không cần quản lý hệ thống
### ActivityLog — Audit Trail (NEW)
- Tự động ghi log các hành động: import, export, transfer, close, create, update, delete
- `ActivityLogger::log(action, description, subject, metadata, userId)`
- Xem tại `/admin/activity-logs` hoặc `/support/activity-logs`
- Admin-only access (ActivityLogPolicy)
### Transfer Department (SOP compliant)
- Action trên EditFeedback: TransferDepartment
### 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 (SOP compliant)
- Action trên EditFeedback: CloseTicket
### 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
@@ -306,14 +224,14 @@ feedback_interactions, feedback_tags, feedback_feedback_tag, products, customers
- Manager/Admin: all options including "closed"
- When "closed" selected: calls ClosingService which validates evidence
### File Management (SOP compliant)
- Files stored on `public` disk (`storage/app/public/feedback-attachments/`)
### 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
### 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
@@ -385,35 +303,19 @@ public function getHeading(): string | Htmlable | null
## Navigation Structure
### Admin Panel (`/admin`)
```
Dashboard (AccountWidget + 3 custom widgets)
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)
├── Contracts / Hợp đồng (document-text, sort=4)
├── Tags / Thẻ (tag, sort=default)
├── Activity Logs (clock, sort=7) — admin only
├── Users / Người dùng (users, sort=5) — admin only
└── Roles / Vai trò (shield-check, sort=6) — admin only
```
### Support Panel (`/support`)
```
Dashboard (3 custom widgets)
├── Customer Care / Chăm sóc khách hàng (group)
│ └── Feedbacks
└── Management / Quản lý (group)
├── Products
├── Customers
├── Channels
├── Tags
└── Activity Logs
```
Dashboard widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget, HandlerPerformanceWidget (admin/manager only)
## RBAC (Role-Based Access Control) - Spatie
@@ -498,9 +400,6 @@ use Filament\Schemas\Schema;
```php
use Illuminate\Support\Facades\App;
$service = App::make(FileService::class);
// ActivityLogger (static)
\App\Services\ActivityLogger::log('transfer', '...', $feedback, ['dept' => 'CSKH']);
```
### Spatie role check
@@ -533,7 +432,6 @@ $user->assignRole('manager');
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.
22. **Dual Panel**: Support panel at `/support` is separate from Admin panel at `/admin`. Both share the same models/services/policies/database.
## File Upload Architecture
@@ -603,4 +501,3 @@ protected function afterSave(): void
// 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>
```

View File

@@ -10,8 +10,8 @@
| Mục | Giá trị |
|------|--------|
| Framework | Laravel 13.6.0 |
| Panels | Filament 5.6.1 — 2 panels: `/admin` (đầy đủ) + `/support` (rút gọn) |
| Auth/RBAC | Spatie laravel-permission (3 roles: admin, manager, staff, 29 permissions) |
| Admin Panel | Filament 5.6.1 |
| Auth/RBAC | Spatie laravel-permission (3 roles: admin, manager, staff) |
| Database | SQLite (`database/database.sqlite`) |
| Testing | Pest PHP |
| PHP | 8.3 |
@@ -32,9 +32,6 @@
app/
├── Filament/
│ ├── Resources/
│ │ ├── ActivityLogs/ # NEW: Audit trail (read-only)
│ │ │ ├── ActivityLogResource.php
│ │ │ └── Pages/ListActivityLogs.php
│ │ ├── Customers/
│ │ │ ├── CustomerResource.php
│ │ │ ├── Pages/
@@ -59,7 +56,7 @@ app/
│ │ │ │ └── FeedbackForm.php
│ │ │ └── Tables/
│ │ │ └── FeedbackTable.php
│ │ ├── Contracts/
│ │ ├── Contracts/ # NEW
│ │ │ ├── ContractResource.php
│ │ │ ├── Pages/
│ │ │ │ ├── CreateContract.php
@@ -89,32 +86,32 @@ app/
│ │ │ │ └── FeedbackTagForm.php
│ │ │ └── Tables/
│ │ │ └── FeedbackTagsTable.php
│ │ ── Products/
│ │ ├── ProductResource.php
│ │ ├── Pages/
│ │ │ ├── CreateProduct.php
│ │ │ ├── EditProduct.php
│ │ │ └── ListProducts.php
│ │ ├── RelationManagers/
│ │ │ ├── ContractsRelationManager.php
│ │ │ ├── HandoversRelationManager.php
│ │ │ └── ProductServicesRelationManager.php
│ │ ├── Schemas/
│ │ │ └── ProductForm.php
│ │ └── Tables/
│ │ └── ProductsTable.php
│ │ ── Users/ # Admin-only user management
│ │ ├── UserResource.php
│ │ ├── 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/ # Admin-only role management
│ │ ├── RoleResource.php
│ │ ── Products/
│ │ ├── ProductResource.php
│ │ ├── Pages/
│ │ │ ├── CreateProduct.php
│ │ │ ├── EditProduct.php
│ │ │ └── ListProducts.php
│ │ ├── RelationManagers/
│ │ │ ├── ContractsRelationManager.php # NEW
│ │ │ ├── HandoversRelationManager.php # NEW
│ │ │ └── ProductServicesRelationManager.php # NEW
│ │ ├── Schemas/
│ │ │ └── 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
@@ -127,57 +124,49 @@ app/
│ ├── 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/
├── Console/ # NEW
│ └── Commands/
│ ├── ImportCskh.php # app:import-cskh command
│ └── ExportBackup.php # app:export-backup command (JSON backup)
├── Enums/
│ ├── ContractCategory.php # V2: ownership/business
│ ├── ContractType.php # V2: deposit/purchase/transfer/rental/self_business/bcc
├── Enums/ # NEW
│ ├── ContractType.php # SALE/LEASE/BCC
│ ├── ContractStatus.php # ACTIVE/EXPIRED/LIQUIDATED
│ ├── HandoverStatus.php # NOT_READY/READY/SCHEDULED/HANDED_OVER
│ └── ServiceType.php # MANAGEMENT_FEE/GRATITUDE/SELF_BUSINESS
├── Http/
── Controllers/
└── Controller.php
│ └── Middleware/
│ └── HandleUploadErrors.php
── Controllers/
└── Controller.php
├── Models/
│ ├── ActivityLog.php # NEW: Audit trail (user_id, action, description, metadata)
│ ├── Contract.php # V2: +category, +representative_*
│ ├── Customer.php # V2: ownedProducts() qua contracts
│ ├── Contract.php # NEW
│ ├── Customer.php
│ ├── CustomerProduct.php # pivot model (customer_product table)
│ ├── Department.php
│ ├── Feedback.php
│ ├── FeedbackAttachment.php
│ ├── FeedbackChannel.php
│ ├── FeedbackInteraction.php
│ ├── FeedbackTag.php
│ ├── Handover.php
│ ├── Handover.php # NEW
│ ├── Product.php
│ ├── ProductService.php
│ ├── ProductService.php # NEW
│ ├── TicketTransferLog.php
│ └── User.php
├── Notifications/
│ ├── TicketClosed.php
│ └── TicketTransferred.php
├── Policies/
│ ├── ActivityLogPolicy.php # NEW: Admin-only
│ ├── ContractPolicy.php
│ ├── ContractPolicy.php # NEW
│ ├── CustomerPolicy.php
│ ├── FeedbackChannelPolicy.php
│ ├── FeedbackPolicy.php
── ProductPolicy.php
│ ├── RolePolicy.php
│ └── UserPolicy.php
── ProductPolicy.php
├── Providers/
│ ├── AppServiceProvider.php
│ └── Filament/
── AdminPanelProvider.php # Panel đầy đủ (/admin)
│ └── SupportPanelProvider.php # Panel rút gọn (/support)
── AdminPanelProvider.php
├── Rules/
│ └── RequireProofOfResolution.php
└── Services/
├── ActivityLogger.php # NEW: static log() cho audit trail
├── ClosingService.php
├── FileService.php
└── TransferService.php
@@ -185,37 +174,8 @@ app/
---
## Dual Panel Architecture
### Admin Panel (`/admin`) — `AdminPanelProvider.php`
Đầy đủ tất cả modules:
- Resources: Feedback, Customer, Product, Contract, FeedbackChannel, FeedbackTag, User, Role, ActivityLog
- Widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget, HandlerPerformanceWidget
- User/Role management (admin-only)
### Support Panel (`/support`) — `SupportPanelProvider.php`
Rút gọn, tập trung feedback:
- Resources: Feedback, Customer, Product, FeedbackChannel, FeedbackTag, ActivityLog
- Widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget, HandlerPerformanceWidget
---
## Models — Chi tiết
### ActivityLog (`activity_logs`) — NEW
| Cột | Kiểu |
|-----|------|
| id | bigint PK |
| user_id | FK->users nullable |
| action | string (import/export/transfer/close/create/update/delete) |
| description | text |
| subject_type | string nullable |
| subject_id | bigint nullable |
| metadata | json nullable |
| created_at | timestamp |
Relationships: `user()` BelongsTo
### Product (`products`)
| Cột | Kiểu |
|-----|------|
@@ -224,9 +184,9 @@ Relationships: `user()` BelongsTo
| description | text nullable |
| status | string (active/inactive/sold_out) |
Relationships: `contracts()` HasMany, `handovers()` HasMany, `productServices()` HasMany
Relationships: `customers()` BelongsToMany (pivot: customer_product, withPivot: purchase_date), `contracts()` HasMany, `handovers()` HasMany, `productServices()` HasMany
### ProductService (`product_services`)
### ProductService (`product_services`) — NEW
| Cột | Kiểu |
|-----|------|
| id | bigint PK |
@@ -238,7 +198,7 @@ Relationships: `contracts()` HasMany, `handovers()` HasMany, `productServices()`
Relationships: `product()` BelongsTo
### Handover (`handovers`)
### Handover (`handovers`) — NEW
| Cột | Kiểu |
|-----|------|
| id | bigint PK |
@@ -250,28 +210,22 @@ Relationships: `product()` BelongsTo
Relationships: `product()` BelongsTo, `customer()` BelongsTo
### Contract (`contracts`) — V2
### Contract (`contracts`) — NEW
| Cột | Kiểu |
|-----|------|
| id | bigint PK |
| product_id | FK->products cascadeOnDelete |
| customer_id | FK->customers cascadeOnDelete |
| category | string (ownership/business → ContractCategory enum) |
| type | string (deposit/purchase/transfer/rental/self_business/bcc → ContractType enum) |
| type | string (sale/lease/bcc → ContractType enum) |
| start_date | date nullable |
| end_date | date nullable |
| status | string (active/expired/liquidated → ContractStatus enum) |
| printed_at | date nullable |
| signed_status | string nullable |
| representative_name | string nullable |
| representative_phone | string nullable |
| representative_email | string nullable |
Relationships: `product()` BelongsTo, `customer()` BelongsTo, `feedbacks()` HasMany
> **Constraint:** Mỗi product tối đa 1 active contract mỗi category. Validation ở Model `booted::saving`.
### Customer (`customers`) — V2
### Customer (`customers`)
| Cột | Kiểu |
|-----|------|
| id | bigint PK |
@@ -281,7 +235,18 @@ Relationships: `product()` BelongsTo, `customer()` BelongsTo, `feedbacks()` HasM
| address | text nullable |
| status | string (active) |
Relationships: `ownedProducts()` BelongsToMany qua contracts (ownership + active), `feedbacks()` HasMany
Relationships: `products()` BelongsToMany, `feedbacks()` HasMany
### CustomerProduct (`customer_product` — pivot model)
| Cột | Kiểu |
|-----|------|
| id | bigint PK |
| customer_id | FK->customers cascadeOnDelete |
| product_id | FK->products cascadeOnDelete |
| purchase_date | date nullable |
UNIQUE(customer_id, product_id)
Relationships: `customer()` BelongsTo, `product()` BelongsTo, `feedbacks()` HasMany
### Department (`departments`)
| Cột | Kiểu |
@@ -301,13 +266,14 @@ Relationships: `manager()` BelongsTo User, `feedbacks()` HasMany, `transferLogsF
| password | string |
| role | string (admin/manager/staff) |
Uses Spatie `HasRoles`. Relationships: `assignedFeedbacks()` HasMany Feedback, `activityLogs()` HasMany.
Uses Spatie `HasRoles`. Relationships: `assignedFeedbacks()` HasMany Feedback.
### Feedback (`feedback`) — V2
### Feedback (`feedback`)
| Cột | Kiểu |
|-----|------|
| id | bigint PK |
| customer_id | FK->customers cascadeOnDelete |
| customer_product_id | FK->customer_product nullable nullOnDelete |
| contract_id | FK->contracts nullable nullOnDelete |
| feedback_channel_id | FK->feedback_channels |
| assigned_to | FK->users nullable nullOnDelete |
@@ -317,9 +283,9 @@ Uses Spatie `HasRoles`. Relationships: `assignedFeedbacks()` HasMany Feedback, `
| content | text |
| status | string (pending/processing/resolved/closed) |
Fillable: `['customer_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated']`
Fillable: `['customer_id', 'customer_product_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated']`
Relationships: `customer()`, `contract()`, `feedbackChannel()`, `assignedTo()`, `currentDepartment()`, `transferLogs()`, `interactions()` ordered latest, `attachments()`, `tags()` BelongsToMany
Relationships (10): `customer()`, `customerProduct()`, `contract()`, `feedbackChannel()`, `assignedTo()`, `currentDepartment()`, `transferLogs()`, `interactions()` ordered latest, `attachments()`, `tags()` BelongsToMany
### FeedbackInteraction (`feedback_interactions`)
| Cột | Kiểu |
@@ -357,7 +323,6 @@ Relationships: `feedback()`, `interaction()`, `user()`. Scopes: `byCollection()`
| name | string |
| slug | string unique |
| icon | string nullable |
| color | string nullable |
| is_active | boolean default true |
Relationships: `feedbacks()` HasMany
@@ -388,31 +353,27 @@ Relationships: `feedback()`, `fromDepartment()`, `toDepartment()`, `sender()`
## Services — Tóm tắt
### ActivityLogger (`app/Services/ActivityLogger.php`) — NEW
- `ActivityLogger::log(action, description, subject?, metadata?, userId?)`: Static method. Tạo ActivityLog record cho audit trail. Dùng trong Import/Export/Transfer/Close/Create/Update/Delete.
### TransferService (`app/Services/TransferService.php`)
- `transfer(Feedback, Department, reason, User, ?int $newHandlerId)`: Tạo TicketTransferLog, cập nhật current_department_id + assigned_to, gửi TicketTransferred notification cho manager đích.
- Validate reason không được rỗng (ValidationException).
### FileService (`app/Services/FileService.php`)
- `upload()`: Validate (jpg/png/pdf/mp4/mov, ≤20MB), store vào public disk, tạo FeedbackAttachment record.
- `getTemporaryUrl()` / `getPreviewUrl()`: Signed URL cho private file.
- `getByCollection()` / `hasCollection()`: Query collection-based attachments.
### ClosingService (`app/Services/ClosingService.php`)
- `close(Feedback, User)`: 4 guard: status ≠ closed, staff → 403, manager chỉ đóng ticket phòng mình, phải có proof_of_resolution attachment (FileService::hasCollection). Gửi TicketClosed notification.
- `resolve(Feedback, User)`: Đánh dấu resolved (không role gate).
### FileService (`app/Services/FileService.php`)
- `upload()`: Validate (jpg/png/pdf/mp4/mov, ≤20MB), store vào private local disk (`feedback-attachments/`), tạo FeedbackAttachment record.
- `getTemporaryUrl()` / `getPreviewUrl()`: Signed URL cho private file.
- `getByCollection()` / `hasCollection()`: Query collection-based attachments.
---
## Policies — Tóm tắt
> **Note:** Policies use Spatie `hasPermissionTo()` instead of `hasRole()` for granular permission control.
> **Note:** Policies now use Spatie `hasPermissionTo()` instead of `hasRole()` for granular permission control.
| Policy | viewAny/view | create | update | delete | restore/forceDelete |
|--------|-------------|--------|--------|--------|---------------------|
| ActivityLogPolicy | 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 |
@@ -425,7 +386,6 @@ Relationships: `feedback()`, `fromDepartment()`, `toDepartment()`, `sender()`
## Filament Navigation Structure
### Admin Panel (`/admin`)
```
Dashboard (AccountWidget + 3 custom widgets)
├── Customer Care (group)
@@ -434,24 +394,8 @@ Dashboard (AccountWidget + 3 custom widgets)
├── Products (heroicon-o-building-storefront, sort=1)
├── Customers (heroicon-o-user-group, sort=2)
├── Channels (heroicon-o-inbox-stack, sort=3)
├── Contracts (heroicon-o-document-text, sort=4)
── Tags (heroicon-o-tag, default sort)
├── Activity Logs (heroicon-o-clock, sort=7) — admin only
├── Users (heroicon-o-users, sort=5) — admin only
└── Roles (heroicon-o-shield-check, sort=6) — admin only
```
### Support Panel (`/support`)
```
Dashboard (3 custom widgets)
├── Customer Care (group)
│ └── Feedbacks
└── Management (group)
├── Products
├── Customers
├── Channels
├── Tags
└── Activity Logs
├── Contracts (heroicon-o-document-text, sort=4) # NEW
── Tags (heroicon-o-tag, default sort)
```
---
@@ -463,7 +407,7 @@ Dashboard (3 custom widgets)
---
## Database Migration files (25 files)
## Database Migration files (23 files)
| # | File | Tables |
|---|------|--------|
@@ -486,20 +430,17 @@ Dashboard (3 custom widgets)
| 17 | `2026_04_27_083000_add_collection_to_feedback_attachments_table` | (modify feedback_attachments) add uuid, disk, collection |
| 18 | `2026_04_27_140000_fix_attachment_disk_default` | (modify feedback_attachments) fix disk default private→local |
| 19 | `2026_04_27_150000_create_notifications_table` | notifications |
| 20 | `2026_05_01_000000_create_contracts_table` | contracts |
| 21 | `2026_05_01_000001_add_contract_id_to_feedback_table` | (modify feedback) add contract_id FK |
| 22 | `2026_05_01_000002_create_handovers_table` | handovers |
| 23 | `2026_05_01_000003_create_product_services_table` | product_services |
| 24 | `2026_05_01_000004_add_color_to_feedback_channels_table` | (modify feedback_channels) add color |
| 25 | `2026_05_09_104519_create_activity_logs_table` | activity_logs |
| 26 | `2026_05_12_075014_v2_restructure_contracts` | **(V2)** contracts +category, +representative; drop customer_product; drop feedback.customer_product_id |
| 20 | `2026_05_01_000000_create_contracts_table` | **(NEW)** contracts |
| 21 | `2026_05_01_000001_add_contract_id_to_feedback_table` | **(NEW)** add contract_id FK to feedback |
| 22 | `2026_05_01_000002_create_handovers_table` | **(NEW)** handovers |
| 23 | `2026_05_01_000003_create_product_services_table` | **(NEW)** product_services |
---
## Seeder (AfterSalesSeeder + PermissionSeeder) — Data mẫu
- 3 users (admin, manager, staff) + Spatie roles
- **29 granular permissions** (PermissionSeeder)
- **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
@@ -518,9 +459,12 @@ Dashboard (3 custom widgets)
- 1 ticket_transfer_log
- 3 feedback_attachments (2 proof_of_resolution, 1 customer_evidence)
---|---
| 22 | `2026_05_01_000003_create_product_services_table` | **(NEW)** product_services |
---
## Data Import (`app:import-cskh`)
## Data Import (`app:import-cskh`) — NEW
Command: `php artisan app:import-cskh {file_path}`
- Sử dụng `spatie/simple-excel` với Lazy Collection (chống OOM)
@@ -549,13 +493,12 @@ Tổng: 8 tests, 21 assertions.
1. **Form components:** `Filament\Forms\Components\*` (Select, TextInput, Textarea, Toggle, FileUpload, RichEditor)
2. **Schema/layout:** `Filament\Schemas\Components\*` (Section, Schema)
3. **Service access:** `App::make(FileService::class)` hoặc inject constructor. `ActivityLogger::log()` là static.
3. **Service access:** `App::make(FileService::class)` hoặc inject constructor
4. **Role check:** `$user->hasRole('admin')` / `$user->hasRole(['admin', 'manager'])`
5. **Dynamic form field:** dùng `$get` function injection, KHÔNG dùng `$component->getLivewire()->data`
6. **FileUpload với dehydrated(false):** File được Filament lưu trước, chỉ cần tạo FeedbackAttachment record trong after()/afterSave() — KHÔNG re-upload qua FileService
7. **Private disk:** files lưu ở `storage/app/private/feedback-attachments/`, truy cập qua signed URL từ FileService. Luôn dùng `disk('public')` cho FileUpload.
7. **Private disk:** files lưu ở `storage/app/private/feedback-attachments/`, truy cập qua signed URL từ FileService
8. **Navigation group icons:** Filament 5 báo lỗi nếu cả navigation group VÀ child items đều có icons
9. **Dual Panel:** Cả 2 panel share models, services, policies, database, translations
---
@@ -563,10 +506,9 @@ Tổng: 8 tests, 21 assertions.
| File | Nội dung |
|------|----------|
| `AGENTS.md` | Hướng dẫn cho AI Agent: cách chạy, cấu trúc, workflow, RBAC, gotchas, patterns |
| `TASKS_ROADMAP.md` | Roadmap + lịch sử tiến độ hoàn thành |
| `docs/I18N_GUIDE.md` | Hướng dẫn đa ngôn ngữ chi tiết |
| `docs/ENUM_GUIDE.md` | Hướng dẫn thêm Enum mới |
| `UI_DESIGN_BRIEF.md` | Thiết kế UI chi tiết (horizontal top bar) |
| `webappUI/aftersales_crm_core/DESIGN.md` | Design tokens (colors, typography, spacing) |
| `luutru/` | Archive các file đã lỗi thời (tham khảo nếu cần) |
| `AGENTS.md` | Hướng dẫn cho AI Agent: cách chạy, cấu trúc, workflow, RBAC, gotchas |
| `PROGRESS.md` | Lịch sử tiến độ hoàn thành (8 giai đoạn + bug fix) |
| `ai_instructions/AI_01_*.md` | Business Logic & Policies (hợp đồng snapshot, transfer policy, handover) |
| `ai_instructions/AI_02_*.md` | Database Schema Upgrade (contracts, handovers, product_services) |
| `ai_instructions/AI_03_*.md` | Data Import Pipeline (spatie/simple-excel, lazy collection) |
| `TASKS_ROADMAP.md` | Roadmap: việc đã làm, đang làm, sẽ làm |

View File

@@ -1,3 +1,12 @@
# Stage 1: Build front-end assets
FROM node:22-alpine AS assets-builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production PHP FPM + Nginx image
FROM php:8.3-fpm-alpine AS base
RUN apk add --no-cache \
@@ -10,6 +19,8 @@ RUN apk add --no-cache \
freetype-dev \
oniguruma-dev \
libxml2-dev \
icu-dev \
libzip-dev \
zip \
unzip \
curl
@@ -25,7 +36,8 @@ RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
exif \
gd \
intl \
opcache
opcache \
zip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
@@ -36,6 +48,9 @@ RUN composer install --no-dev --no-interaction --no-autoloader --no-scripts --pr
COPY . .
# Copy Vite compiled assets
COPY --from=assets-builder /app/public/build ./public/build
RUN composer dump-autoload --optimize --no-dev \
&& mkdir -p storage/app/private/feedback-attachments \
&& mkdir -p storage/framework/{cache,sessions,views} \

View File

@@ -5,23 +5,12 @@
---
## Trạng thái hiện tại
- **SOP Phase 1-8:** 100% hoàn thành
- **AI Instructions Core:** 85% (6/7, còn auto-suggest transfer)
- **V2 Restructure:** 100% hoàn thành (Contract category, bỏ pivot, đại diện)
- **Test:** Pest 8/8 tests (21 assertions) pass
- **Data import:** 3658 rows từ `ai_instructions/CSKH.xlsx` imported successfully
- **Dual Panel:** Admin (đầy đủ) + Support (rút gọn, tập trung feedback)
---
## ✅ ĐÃ HOÀN THÀNH
## ✅ ĐÃ HOÀN THÀNH (SOP Phase 18)
### Giai đoạn 1: Mở rộng CSDL
- [x] Bảng `departments` (id, name, manager_id → users)
- [x] Bổ sung `feedback`: current_department_id, is_escalated
- [x] Bảng `ticket_transfer_logs` (feedback_id, from/to department, sender, reason)
- [x] Bảng `ticket_transfer_logs`
- [x] Nâng cấp `feedback_attachments`: uuid, disk, collection
- [x] Cài Spatie laravel-permission
@@ -37,224 +26,128 @@
- [x] `TicketClosed` (Database + Mail)
### Giai đoạn 4: Filament UI
- [x] `TransferDepartment` Action trên Edit Feedback (modal: department + reason + attachment)
- [x] `CloseTicket` Action (visible: admin/manager, status ≠ closed, requires confirmation)
- [x] InteractionsRelationManager: role-aware status options (staff: no "closed"), department field
- [x] FeedbackForm: current_department_id, is_escalated, attachment_collection
- [x] `TransferDepartment` Action trên EditFeedback
- [x] `CloseTicket` Action
- [x] InteractionsRelationManager: role-aware status options
- [x] FeedbackForm: current_department_id, is_escalated
### Giai đoạn 5: Dashboard Widgets
- [x] `ResolvedTicketsWidget`: bảng ticket resolved chờ đóng (manager lọc theo phòng quản lý)
- [x] `AvgProcessingTimeWidget`: 4 stats (resolved count, avg time, pending, escalated)
- [x] `HandlerPerformanceWidget`: bar chart thời gian xử lý trung bình theo nhân viên
- [x] `ResolvedTicketsWidget`
- [x] `AvgProcessingTimeWidget`
- [x] `HandlerPerformanceWidget`
### Giai đoạn 6: Testing
- [x] Pest cài đặt + 8 test scenarios
- [x] `ClosingPermissionTest`: 4 tests (staff 403, no evidence 422, with evidence OK, cross-department 403)
- [x] `TransferFlowTest`: 2 tests (success + missing reason)
- [x] Pest: 8 tests (21 assertions)
### Giai đoạn 7: Seeder
- [x] 4 departments (CSKH, Kỹ thuật, Kế toán, Pháp lý)
- [x] 1 ticket_transfer_log mẫu
- [x] 3 feedback_attachments (2 proof_of_resolution, 1 customer_evidence)
- [x] 4 departments, 5 channels, 5 products, 4 customers, 5 feedbacks, 5 tags, 5 interactions, 1 transfer log, 3 attachments
### Giai đoạn 8: Polish & Bug Fix
- [x] Dashboard widget filter Manager theo department
- [x] Migration disk default fix (private → local)
- [x] Migration disk default fix
- [x] File upload pattern fix
- [x] Department + Escalated columns trên FeedbackTable + Department filter
- [x] Department + Escalated columns trên FeedbackTable
- [x] Validation proof_of_resolution khi close từ form
- [x] Manager chỉ đóng ticket phòng mình
- [x] View Attachments trong Interaction History (temporary signed URL)
- [x] Docker deployment (Dockerfile, docker-compose, entrypoint)
- [x] View Attachments trong Interaction History
- [x] Docker deployment
- [x] Migration notifications table
### Giai đoạn 9: Granular Permissions + Export Backup + User/Role Management
- [x] PermissionSeeder: 29 granular 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] 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, manifest.json)
- [x] UserResource: admin-only CRUD users + assign role
- [x] RoleResource: admin-only CRUD roles + assign permissions via CheckboxList
- [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)
### A: Module Hợp đồng (Contract)
- [x] Migration `contracts`: product_id, customer_id, type (sale/lease/bcc), dates, status, signed_status
- [x] Enum `ContractType` (SALE/LEASE/BCC) + `ContractStatus` (ACTIVE/EXPIRED/LIQUIDATED)
- [x] Model `Contract`: relationships product(), customer(), feedbacks()
- [x] Filament Resource `Contracts` (CRUD) + `ContractPolicy`
- [x] RelationManager `ContractsRelationManager` trên ProductResource
- [x] Model `Product` thêm `contracts()` HasMany
- [x] Seeder: 5 contracts mẫu
### B: Cơ chế Snapshot Hợp đồng
- [x] Migration thêm `contract_id` (FK->contracts, nullable) vào bảng `feedback`
- [x] Model `Feedback`: thêm `contract_id` fillable + `contract()` BelongsTo
- [x] FeedbackForm: auto-select active contract khi chọn sản phẩm, cho phép chọn lại thủ công
- [x] FeedbackTable: cột "Contract" hiển thị loại HĐ dạng badge
- [x] Seeder: gán contract_id cho 5 feedback mẫu
### C: Module Bàn giao (Handover)
- [x] Migration `handovers`: product_id, customer_id, handover_date, status, remark
- [x] Enum `HandoverStatus` (NOT_READY/READY/SCHEDULED/HANDED_OVER)
- [x] Model `Handover`: relationships product(), customer()
- [x] RelationManager `HandoversRelationManager` trên ProductResource
- [x] Model `Product` thêm `handovers()` HasMany
- [x] Seeder: 5 handovers mẫu
### D: Module Dịch vụ phụ trợ (ProductService)
- [x] Migration `product_services`: product_id, service_type, status, actual_collection_date, remark
- [x] Enum `ServiceType` (MANAGEMENT_FEE/GRATITUDE/SELF_BUSINESS)
- [x] Model `ProductService`: relationship product()
- [x] RelationManager `ProductServicesRelationManager` trên ProductResource
- [x] Model `Product` thêm `productServices()` HasMany
- [x] Seeder: 5 services mẫu
### E: Enhancement ProductResource
- [x] ProductResource có 3 tabs: Contracts + Handovers + Services (via getRelations())
### G: Data Import Pipeline
- [x] Cài `spatie/simple-excel` (Lazy Collection, chống OOM)
- [x] Command `app:import-cskh {file_path} --dry-run` với ProgressBar, chunk 100 rows
- [x] Pipeline per row: Product/Customer firstOrCreate → customer_product pivot → Contract → Handover → Service → Feedback + Interactions
- [x] Xử lý DateTimeImmutable object từ Excel, skip header/meta rows
- [x] Test thành công: 3658 rows → 3299 products, 2089 customers, 2637 contracts, 2077 handovers, 159 services, 71 feedbacks, 226 interactions
### Internationalization (i18n) — Vietnamese + English
- [x] Config: `APP_LOCALE=vi`, `APP_FALLBACK_LOCALE=en`
- [x] Created `lang/en/` and `lang/vi/` with 3 files each: `app.php`, `feedback.php`, `enums.php`
- [x] ~120 translation keys: navigation, labels, status, widgets, services, notifications, enums
- [x] All Resources: override `getPluralLabel()` + `getNavigationGroup()` with `__()` calls
- [x] All Form fields: explicit `->label(__('key'))`
- [x] All Table columns: explicit `->label(__('key'))`
- [x] All RelationManagers: `$title` uses translation key
- [x] All Widgets: `getHeading()`/`getDescription()` return translated strings
- [x] Enums: `ContractType`, `ContractStatus`, `HandoverStatus`, `ServiceType` use `__()` in `label()`
- [x] Filament vendor: 57 Vietnamese translation files auto-activated
### UI Polish & Bug Fixes
- [x] File upload: Fixed `disk('local')``disk('public')` across all components
- [x] File upload: Added `exists()` check before getting metadata
- [x] File upload: Fixed `dehydrated(false)` issue — use property to store paths
- [x] Tailwind CSS: Added ~200 utility classes to `public/css/filament-custom.css`
- [x] SVG icons: Use inline `width`/`height` attributes instead of Tailwind classes
- [x] GlobalSearch: Customized for all Resources with multi-field search
- [x] RelationManager titles: Override `getTitle()` for proper translation
- [x] Enum refactoring: Added `color()` method, static `options()`, developer comments
- [x] Documentation: Created `docs/I18N_GUIDE.md` and `docs/ENUM_GUIDE.md`
### Support Panel (Dual Panel)
- [x] `SupportPanelProvider`: panel thứ 2 tại path `/support`, rút gọn chỉ tập trung feedback
- [x] Resources trong Support: Feedback, Customer, Product, FeedbackChannel, FeedbackTag, ActivityLog
- [x] Widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget, HandlerPerformanceWidget
### V2: Contract Restructure — Category + Representative + Bỏ Pivot
- [x] Migration: thêm `category` (ownership/business), `representative_*` vào contracts, xóa `customer_product`
- [x] Enum: `ContractCategory` (ownership, business) + cập nhật `ContractType` (deposit, purchase, transfer, rental, self_business, bcc)
- [x] Model: Contract (+category, +representative), Customer (ownedProducts qua contracts), Feedback (-customer_product_id)
- [x] Xóa `CustomerProduct` model/pivot
- [x] Validation: ràng buộc 1 active/category/product (CreateContract/EditContract)
- [x] FeedbackForm: filter mới (Customer → Product qua Contract ownership + đại diện), case-insensitive search
- [x] ContractForm: select category → dynamic type options, representative form section
- [x] FeedbackTable: `contract.product.name` thay `customerProduct.product.name`
- [x] Seeder: 6 contracts V2 (ownership+business, có representative), feedback no customer_product_id
- [x] ImportCskh: cập nhật pipeline, không còn customer_product
- [x] i18n: vi/en cho category, type mới, representative, validation
- [x] Tests: 8/8 pass (21 assertions)
### ActivityLog — Audit Trail
- [x] Migration `activity_logs`: user_id, action, description, subject_type/id, metadata
- [x] Model `ActivityLog` + Service `ActivityLogger` (static log method)
- [x] Filament Resource `ActivityLogResource` (read-only, list view with filters)
- [x] Policy `ActivityLogPolicy` (admin-only)
- [x] Translation keys: `resource_activity_logs`, `action_*`, `details`, `system`
---
## 🔴 CHƯA LÀM
## 🔴 CHƯA LÀM — Theo AI Instructions
### F: Tự đng gợi ý transfer (optional, nice-to-have)
### A: Module Hợp đng (Contract) — từ `AI_01`, `AI_02`
- [x] Tạo migration bảng `contracts`:
- `product_id` FK, `customer_id` FK
- `type` (LEASE/BCC/SALE)
- `start_date`, `end_date`
- `status` (Active/Expired/Liquidated)
- `printed_at`, `signed_status`
- [x] Tạo Model `Contract` + Enum `ContractType`
- [x] Relationships: `product()`, `customer()`, `feedbacks()`
- [x] Tạo ContractResource Filament + ContractPolicy
- [x] Tạo ContractsRelationManager trên ProductResource
### B: Cơ chế Snapshot Hợp đồng — từ `AI_01`
- [x] Tạo migration thêm `contract_id` (FK->contracts, nullable) vào bảng `feedback`
- [x] Cập nhật Model `Feedback`: fillable + relationship `contract()`
- [x] Logic auto-select active contract khi tạo Feedback mới (FeedbackForm)
- [x] Hiển thị contract info trên Feedback list (FeedbackTable column)
- [x] Gán contract_id cho feedback mẫu trong Seeder
### C: Module Bàn giao (Handover) — từ `AI_01`, `AI_02`
- [x] Tạo migration bảng `handovers`:
- `product_id` FK, `customer_id` FK
- `handover_date` (date)
- `status` (Chưa đủ điều kiện/Đủ ĐK/Đã lên lịch/Đã BG)
- `remark` (text nullable)
- [x] Tạo Model `Handover` + Enum `HandoverStatus`
- [x] Relationships: `product()`, `customer()`
- [x] Tạo `HandoversRelationManager` gắn vào `ProductResource`
- [x] Cập nhật Product model: thêm `handovers()` HasMany
### D: Module Dịch vụ phụ trợ (ProductService) — từ `AI_02`
- [x] Tạo migration bảng `product_services`:
- `product_id` FK
- `service_type` (MANAGEMENT_FEE/GRATITUDE/SELF_BUSINESS)
- `status`
- `actual_collection_date` (date nullable)
- `remark` (text nullable)
- [x] Tạo Model `ProductService` + Enum `ServiceType`
- [x] Relationship: `product()`
- [x] Tạo `ProductServicesRelationManager` gắn vào `ProductResource`
- [x] Cập nhật Product model: thêm `productServices()` HasMany
### E: Enhancement ProductResource — từ `AI_02`
- [x] Thêm RelationManagers vào `ProductResource::getRelations()`
- ContractsRelationManager ✅
- HandoversRelationManager ✅
- ProductServicesRelationManager ✅
- [x] Product detail page có 3 tabs: Contracts + Handovers + Services
### F: Tự động gợi ý transfer — từ `AI_01` (optional)
- [ ] Hiển thị badge gợi ý: "Ticket BCC → khuyến nghị chuyển Pháp lý"
- [ ] Logic detect contract type → suggest department
### G: Data Import Pipeline — từ `AI_03`
- [x] Cài package `spatie/simple-excel`
- [x] Tạo Command `app:import-cskh {file_path} --dry-run`
- [x] Logic: Lazy Collection, chunk 100 rows, DB::transaction, ProgressBar
- [x] Pipeline per row: Product/Customer firstOrCreate → Contract/Handover/Service → Feedback + Interactions
- [x] Tạo customer_product pivot record
- [x] Xử lý DateTimeImmutable từ Excel
- [x] Skip header/meta rows
- [x] Đã test thành công với CSKH.xlsx (3658 rows → 3299 products, 2089 customers, 2637 contracts, 2077 handovers, 159 services, 71 feedbacks, 226 interactions)
---
## 📋 Ưu tiên phát triển
| Ưu tiên | Task | Phụ thuộc |
|---------|------|-----------|
| 1 | F: Tự đng gợi ý transfer (nice-to-have) | A |
| 1 | ~~A: Module Hợp đng (Migration + Model)~~ ✅ | Không |
| 2 | ~~B: contract_id trên feedback (Snapshot)~~ ✅ | A |
| 3 | ~~C: Module Bàn giao (Migration + Model + RelationManager)~~ ✅ | Không |
| 4 | ~~D: Module Dịch vụ (Migration + Model + RelationManager)~~ ✅ | Không |
| 5 | ~~E: Enhancement ProductResource~~ ✅ | A, C, D |
| 6 | ~~G: Data Import Pipeline~~ ✅ | A, B, C, D |
| 7 | F: Tự động gợi ý transfer (nice-to-have) | A |
---
## 📊 Tiến độ tổng quan
```
SOP Phase 1-9: ████████████████████████ 100% (Hoàn thành)
AI Instructions Core: ███████████████████████░ 85% (6/7 — Còn: auto-suggest transfer)
Support Panel: ████████████████████████ 100% (Hoàn thành)
ActivityLog: ████████████████████████ 100% (Hoàn thành)
i18n (vi/en): ████████████████████████ 100% (Hoàn thành)
Data Import: ████████████████████████ 100% (3658 rows imported)
Tests: ████████████████████████ 100% (8 tests, 21 assertions)
SOP Phase 1-8: ████████████████████████ 100% (Hoàn thành)
AI Instructions Core: ███████████████████████░ 85% (6/7 — Còn: auto-suggest transfer)
Data Import Tested: ████████████████████████ 100% (3658 rows real data imported successfully)
```
---
## Cách chạy Local Dev
```bash
cd /home/phuongtc/vibecode/minicrm
php artisan migrate:fresh --seed
php artisan serve
# Admin panel: http://localhost:8000/admin
# Support panel: http://localhost:8000/support
php artisan test # 8 tests (21 assertions)
```
### Data Import
```bash
php artisan app:import-cskh ai_instructions/CSKH.xlsx --dry-run # phân tích
php artisan app:import-cskh ai_instructions/CSKH.xlsx # import thật
```
### Export Backup
```bash
php artisan app:export-backup
php artisan app:export-backup --path=/custom/path --pretty
php artisan app:export-backup --tables=feedback,customers,products
```
---
## Tài khoản test (seeded)
| Role | Email | Password |
|------|-------|----------|
| Admin | admin@minicrm.local | password |
| Manager | manager@minicrm.local | password |
| Staff | staff@minicrm.local | password |
---
## Tech Stack
- PHP 8.3, Laravel 13.6.0, Filament 5.6.1
- Database: SQLite (`database/database.sqlite`)
- Composer: `/home/phuongtc/composer`
- Spatie: laravel-permission (RBAC)
- Testing: Pest PHP
---
## File hướng dẫn cho AI Agent
| File | Nội dung |
|------|----------|
| `AGENTS.md` | Hướng dẫn toàn diện: chạy, cấu trúc, workflow, RBAC, gotchas, patterns |
| `CODEBASE_SNAPSHOT.md` | Snapshot cấu trúc codebase (đọc thay vì scan code) |
| `TASKS_ROADMAP.md` | File này — roadmap + tiến độ |
| `docs/I18N_GUIDE.md` | Hướng dẫn đa ngôn ngữ chi tiết |
| `docs/ENUM_GUIDE.md` | Hướng dẫn thêm Enum mới |
| `UI_DESIGN_BRIEF.md` | Thiết kế UI chi tiết (horizontal top bar) |
| `webappUI/aftersales_crm_core/DESIGN.md` | Design tokens (colors, typography, spacing) |
| `luutru/` | Archive các file đã lỗi thời (AI_01-03 specs, SOP_des, PROGRESS cũ...) |

View File

@@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Services\ActivityLogger;
use App\Models\Contract;
use App\Models\Customer;
use App\Models\CustomerProduct;
use App\Models\Feedback;
use App\Models\FeedbackInteraction;
use App\Models\Handover;
@@ -21,7 +22,7 @@ class ImportCskh extends Command
{file_path : Path to CSV/Excel file}
{--dry-run : Analyze only, do not write to database}';
protected $description = 'Import dữ liệu CSKH từ file Excel/CSV vào database relational (V2)';
protected $description = 'Import dữ liệu CSKH từ file Excel/CSV vào database relational';
private int $statsProducts = 0;
private int $statsCustomers = 0;
@@ -122,6 +123,11 @@ class ImportCskh extends Command
return self::SUCCESS;
}
private function isMetaRow(array $row): bool
{
return $this->getMetaRowReason($row) !== null;
}
private function getMetaRowReason(array $row): ?string
{
$code = $row['Mã căn hộ'] ?? '';
@@ -204,6 +210,8 @@ class ImportCskh extends Command
$product = $this->findOrCreateProduct($productCode);
$customer = $this->findOrCreateCustomer($customerName);
$this->ensureCustomerProduct($product, $customer);
$this->processContracts($row, $product, $customer);
$this->processHandover($row, $product, $customer);
$this->processServices($row, $product);
@@ -242,6 +250,14 @@ class ImportCskh extends Command
return $c;
}
private function ensureCustomerProduct(Product $product, Customer $customer): void
{
CustomerProduct::firstOrCreate([
'customer_id' => $customer->id,
'product_id' => $product->id,
]);
}
private function processContracts(array $row, Product $product, Customer $customer): void
{
$leaseValue = $row['HỢP ĐỒNG THUÊ'] ?? null;
@@ -249,16 +265,14 @@ class ImportCskh extends Command
if (! empty($leaseStr) && ! is_numeric($leaseStr) && $leaseStr !== 'Tình trạng') {
$exists = Contract::where('product_id', $product->id)
->where('customer_id', $customer->id)
->where('category', 'business')
->where('type', 'rental')
->where('type', 'lease')
->exists();
if (! $exists) {
Contract::create([
'product_id' => $product->id,
'customer_id' => $customer->id,
'category' => 'business',
'type' => 'rental',
'type' => 'lease',
'status' => 'active',
]);
$this->statsContracts++;
@@ -270,7 +284,6 @@ class ImportCskh extends Command
if (! empty($bccStr) && ! is_numeric($bccStr) && $bccStr !== 'Tình trạng HĐ') {
$exists = Contract::where('product_id', $product->id)
->where('customer_id', $customer->id)
->where('category', 'business')
->where('type', 'bcc')
->exists();
@@ -278,7 +291,6 @@ class ImportCskh extends Command
Contract::create([
'product_id' => $product->id,
'customer_id' => $customer->id,
'category' => 'business',
'type' => 'bcc',
'status' => 'active',
'signed_status' => $bccStr,
@@ -374,15 +386,20 @@ class ImportCskh extends Command
$title = 'Ticket từ dữ liệu lịch sử';
$customerProduct = CustomerProduct::where('customer_id', $customer->id)
->where('product_id', $product->id)
->first();
$feedback = Feedback::firstOrCreate(
[
'customer_id' => $customer->id,
'contract_id' => $activeContract?->id,
'customer_product_id' => $customerProduct?->id,
'title' => $title,
],
[
'content' => $content,
'status' => 'pending',
'contract_id' => $activeContract?->id,
'feedback_channel_id' => 1,
]
);
@@ -412,6 +429,9 @@ class ImportCskh extends Command
}
}
/**
* Parse the PHP DateTime JSON format: {"date":"2021-06-09 00:00:00.000000","timezone_type":3,"timezone":"UTC"}
*/
private function parseHandoverDate(mixed $value): ?string
{
if (empty($value)) {

View File

@@ -1,39 +0,0 @@
<?php
namespace App\Enums;
/**
* Contract Category Enum Phân loại hợp đồng cấp cao
*
* Mỗi căn hộ chỉ tối đa 1 active mỗi category tại cùng thời điểm.
* - ownership: Sở hữu (đặt cọc, mua bán, chuyển nhượng...)
* - business: Kinh doanh (cho thuê, tự doanh, hợp tác KD...)
*/
enum ContractCategory: string
{
case OWNERSHIP = 'ownership';
case BUSINESS = 'business';
public function label(): string
{
return match ($this) {
self::OWNERSHIP => __('enums.contract_category.ownership'),
self::BUSINESS => __('enums.contract_category.business'),
};
}
public function color(): string
{
return match ($this) {
self::OWNERSHIP => 'success',
self::BUSINESS => 'info',
};
}
public static function options(): array
{
return collect(self::cases())->mapWithKeys(fn ($case) => [
$case->value => $case->label(),
])->toArray();
}
}

View File

@@ -3,33 +3,24 @@
namespace App\Enums;
/**
* Contract Type Enum Loại hợp đồng chi tiết (subtype của category)
* Contract Type Enum
*
* Ownership: deposit (đặt cọc), purchase (mua bán), transfer (chuyển nhượng)
* Business: rental (cho thuê), self_business (tự doanh), bcc (hợp tác KD)
*
* Để thêm loại mới:
* 1. Thêm case mới vào enum + gán category phù hợp trong category()
* Để thêm loại hợp đồng mới:
* 1. Thêm case mới vào enum
* 2. Thêm label trong lang/vi/enums.php lang/en/enums.php
* 3. Reload browser
*/
enum ContractType: string
{
case DEPOSIT = 'deposit';
case PURCHASE = 'purchase';
case TRANSFER = 'transfer';
case RENTAL = 'rental';
case SELF_BUSINESS = 'self_business';
case SALE = 'sale';
case LEASE = 'lease';
case BCC = 'bcc';
public function label(): string
{
return match ($this) {
self::DEPOSIT => __('enums.contract_type.deposit'),
self::PURCHASE => __('enums.contract_type.purchase'),
self::TRANSFER => __('enums.contract_type.transfer'),
self::RENTAL => __('enums.contract_type.rental'),
self::SELF_BUSINESS => __('enums.contract_type.self_business'),
self::SALE => __('enums.contract_type.sale'),
self::LEASE => __('enums.contract_type.lease'),
self::BCC => __('enums.contract_type.bcc'),
};
}
@@ -37,20 +28,9 @@ enum ContractType: string
public function color(): string
{
return match ($this) {
self::DEPOSIT => 'warning',
self::PURCHASE => 'success',
self::TRANSFER => 'info',
self::RENTAL => 'info',
self::SELF_BUSINESS => 'warning',
self::BCC => 'danger',
};
}
public function category(): ContractCategory
{
return match ($this) {
self::DEPOSIT, self::PURCHASE, self::TRANSFER => ContractCategory::OWNERSHIP,
self::RENTAL, self::SELF_BUSINESS, self::BCC => ContractCategory::BUSINESS,
self::SALE => 'success',
self::LEASE => 'info',
self::BCC => 'warning',
};
}
@@ -60,14 +40,4 @@ enum ContractType: string
$case->value => $case->label(),
])->toArray();
}
public static function ownershipCases(): array
{
return [self::DEPOSIT, self::PURCHASE, self::TRANSFER];
}
public static function businessCases(): array
{
return [self::RENTAL, self::SELF_BUSINESS, self::BCC];
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Filament\Resources\Contracts;
use App\Enums\ContractCategory;
use App\Filament\Resources\Contracts\Pages\CreateContract;
use App\Filament\Resources\Contracts\Pages\EditContract;
use App\Filament\Resources\Contracts\Pages\ListContracts;
@@ -65,6 +64,7 @@ class ContractResource extends Resource
];
}
// GlobalSearch customization
public static function getGlobalSearchEloquentQuery(): Builder
{
return parent::getGlobalSearchEloquentQuery()
@@ -89,7 +89,6 @@ class ContractResource extends Resource
return [
__('app.product') => $record->product?->name ?? '-',
__('app.widget_customer') => $record->customer?->name ?? '-',
__('enums.contract_category_label') => ContractCategory::from($record->category)->label(),
__('app.blade_status') => \App\Enums\ContractStatus::from($record->status)->label(),
__('app.signed_status') => $record->signed_status ?? '-',
];

View File

@@ -3,38 +3,9 @@
namespace App\Filament\Resources\Contracts\Pages;
use App\Filament\Resources\Contracts\ContractResource;
use App\Models\Contract;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateContract extends CreateRecord
{
protected static string $resource = ContractResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
if (($data['status'] ?? '') === 'active') {
$existing = Contract::where('product_id', $data['product_id'])
->where('category', $data['category'])
->where('status', 'active')
->first();
if ($existing) {
Notification::make()
->title(__('feedback.contract_duplicate_title', [
'category' => \App\Enums\ContractCategory::from($data['category'])->label(),
]))
->body(__('feedback.contract_duplicate_body', [
'product' => $existing->product?->name ?? '?',
'customer' => $existing->customer?->name ?? '?',
]))
->warning()
->send();
$this->halt();
}
}
return $data;
}
}

View File

@@ -3,9 +3,7 @@
namespace App\Filament\Resources\Contracts\Pages;
use App\Filament\Resources\Contracts\ContractResource;
use App\Models\Contract;
use Filament\Actions\DeleteAction;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditContract extends EditRecord
@@ -18,32 +16,4 @@ class EditContract extends EditRecord
DeleteAction::make(),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
if (($data['status'] ?? '') === 'active') {
$existing = Contract::where('product_id', $data['product_id'])
->where('category', $data['category'])
->where('status', 'active')
->where('id', '!=', $this->getRecord()->id)
->first();
if ($existing) {
Notification::make()
->title(__('feedback.contract_duplicate_title', [
'category' => \App\Enums\ContractCategory::from($data['category'])->label(),
]))
->body(__('feedback.contract_duplicate_body', [
'product' => $existing->product?->name ?? '?',
'customer' => $existing->customer?->name ?? '?',
]))
->warning()
->send();
$this->halt();
}
}
return $data;
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Filament\Resources\Contracts\Schemas;
use App\Enums\ContractCategory;
use App\Enums\ContractStatus;
use App\Enums\ContractType;
use Filament\Forms\Components\DatePicker;
@@ -17,36 +16,6 @@ class ContractForm
{
return $schema
->components([
Section::make(__('app.contract_category_section'))
->schema([
Select::make('category')
->label(__('enums.contract_category_label'))
->options(ContractCategory::options())
->required()
->native(false)
->live(),
Select::make('type')
->label(__('enums.contract_type_label'))
->options(function ($get): array {
$category = $get('category');
if ($category === 'ownership') {
return collect(ContractType::ownershipCases())->mapWithKeys(fn ($c) => [
$c->value => $c->label(),
])->toArray();
}
if ($category === 'business') {
return collect(ContractType::businessCases())->mapWithKeys(fn ($c) => [
$c->value => $c->label(),
])->toArray();
}
return ContractType::options();
})
->required()
->native(false),
])
->columns(2),
Section::make(__('app.contract_info'))
->schema([
Select::make('product_id')
@@ -59,6 +28,11 @@ class ContractForm
->relationship('customer', 'name')
->searchable()
->required(),
Select::make('type')
->label(__('app.contract'))
->options(ContractType::options())
->required()
->native(false),
Select::make('status')
->label(__('app.status'))
->options(ContractStatus::ACTIVE->options())
@@ -76,22 +50,6 @@ class ContractForm
->maxLength(255),
])
->columns(2),
Section::make(__('app.representative_info'))
->description(__('app.representative_desc'))
->schema([
TextInput::make('representative_name')
->label(__('app.name'))
->maxLength(255),
TextInput::make('representative_phone')
->label(__('app.phone'))
->maxLength(20),
TextInput::make('representative_email')
->label(__('app.email'))
->email()
->maxLength(255),
])
->columns(3),
]);
}
}

View File

@@ -2,12 +2,10 @@
namespace App\Filament\Resources\Contracts\Tables;
use App\Enums\ContractCategory;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class ContractsTable
@@ -19,25 +17,15 @@ class ContractsTable
TextColumn::make('id')
->sortable(),
TextColumn::make('product.name')
->label(__('app.product'))
->searchable()
->sortable(),
TextColumn::make('customer.name')
->label(__('app.widget_customer'))
->searchable()
->sortable(),
TextColumn::make('category')
->label(__('enums.contract_category_label'))
->badge()
->formatStateUsing(fn (string $state): string => ContractCategory::from($state)->label())
->color(fn (string $state): string => ContractCategory::from($state)->color()),
TextColumn::make('type')
->label(__('enums.contract_type_label'))
->badge()
->formatStateUsing(fn (string $state): string => \App\Enums\ContractType::from($state)->label())
->color(fn (string $state): string => \App\Enums\ContractType::from($state)->color()),
->formatStateUsing(fn (string $state): string => \App\Enums\ContractType::from($state)->label()),
TextColumn::make('status')
->label(__('app.status'))
->badge()
->color(fn (string $state): string => match ($state) {
'active' => 'success',
@@ -46,22 +34,15 @@ class ContractsTable
default => 'gray',
})
->formatStateUsing(fn (string $state): string => \App\Enums\ContractStatus::from($state)->label()),
TextColumn::make('representative_name')
->label(__('app.representative'))
->placeholder('-')
->toggleable(),
TextColumn::make('start_date')
->label(__('app.start_date'))
->date()
->sortable()
->toggleable(),
TextColumn::make('end_date')
->label(__('app.end_date'))
->date()
->sortable()
->toggleable(),
TextColumn::make('signed_status')
->label(__('app.signed_status'))
->toggleable(),
TextColumn::make('feedbacks_count')
->counts('feedbacks')
@@ -72,12 +53,7 @@ class ContractsTable
->toggleable(),
])
->filters([
SelectFilter::make('category')
->label(__('enums.contract_category_label'))
->options(ContractCategory::options()),
SelectFilter::make('status')
->label(__('app.status'))
->options(\App\Enums\ContractStatus::ACTIVE->options()),
//
])
->recordActions([
EditAction::make(),

View File

@@ -23,14 +23,12 @@ class FeedbacksRelationManager extends RelationManager
->recordTitleAttribute('title')
->columns([
TextColumn::make('title')
->label(__('app.blade_title'))
->searchable()
->url(fn ($record) => FeedbackResource::getUrl('edit', ['record' => $record])),
TextColumn::make('contract.product.name')
TextColumn::make('customerProduct.product.name')
->label(__('app.product'))
->placeholder(__('app.general')),
TextColumn::make('status')
->label(__('app.status'))
->badge()
->color(fn (string $state): string => match ($state) {
'pending' => 'warning',

View File

@@ -41,6 +41,15 @@ class CustomerForm
])
->columns(2),
Section::make(__('app.owned_products'))
->schema([
Select::make('products')
->label(__('app.resource_products'))
->relationship('products', 'name')
->multiple()
->preload()
->columnSpanFull(),
]),
]);
}
}

View File

@@ -31,6 +31,9 @@ class CustomersTable
'inactive' => 'gray',
default => 'gray',
}),
TextColumn::make('products_count')
->counts('products')
->label(__('app.resource_products')),
TextColumn::make('feedbacks_count')
->counts('feedbacks')
->label(__('app.feedbacks')),

View File

@@ -65,10 +65,11 @@ class FeedbackResource extends Resource
];
}
// GlobalSearch customization
public static function getGlobalSearchEloquentQuery(): Builder
{
return parent::getGlobalSearchEloquentQuery()
->with(['customer', 'contract.product', 'currentDepartment']);
->with(['customer', 'customerProduct.product', 'currentDepartment']);
}
public static function modifyGlobalSearchQuery(Builder $query, string $search): void
@@ -82,7 +83,7 @@ class FeedbackResource extends Resource
{
return [
__('app.widget_customer') => $record->customer?->name ?? '-',
__('app.product') => $record->contract?->product?->name ?? __('app.general'),
__('app.product') => $record->customerProduct?->product?->name ?? __('app.general'),
__('app.blade_status') => match ($record->status) {
'pending' => __('app.status_pending'),
'processing' => __('app.status_processing'),

View File

@@ -15,10 +15,14 @@ class CreateFeedback extends CreateRecord
protected function mutateFormDataBeforeCreate(array $data): array
{
if (($data['is_general'] ?? false) === true) {
$data['customer_product_id'] = null;
}
$this->pendingAttachments = $data['attachments'] ?? [];
$this->pendingCollection = $data['attachment_collection'] ?? 'general';
unset($data['is_general'], $data['attachment_collection'], $data['attachments'], $data['product_id_virtual']);
unset($data['is_general'], $data['attachment_collection'], $data['attachments']);
return $data;
}

View File

@@ -66,7 +66,7 @@ class EditFeedback extends EditRecord
->disk('public')
->directory('feedback-attachments')
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'video/quicktime'])
->maxSize(51200)
->maxSize(51200) // 50MB in KB
->columnSpanFull(),
])
->action(function (array $data): void {
@@ -166,13 +166,20 @@ class EditFeedback extends EditRecord
protected function mutateFormDataBeforeSave(array $data): array
{
if (($data['is_general'] ?? false) === true) {
$data['customer_product_id'] = null;
}
$this->pendingAttachments = $data['attachments'] ?? [];
$this->pendingCollection = $data['attachment_collection'] ?? 'general';
unset($data['is_general'], $data['attachment_collection'], $data['attachments'], $data['product_id_virtual']);
unset($data['is_general'], $data['attachment_collection'], $data['attachments']);
$newStatus = $data['status'] ?? null;
if ($newStatus === 'closed' && $this->getRecord()->status !== 'closed') {
// Save pending attachments first so ClosingService validation sees them
$this->savePendingAttachments();
$closingService = App::make(ClosingService::class);
$closingService->close($this->getRecord(), auth()->user());
}
@@ -180,7 +187,7 @@ class EditFeedback extends EditRecord
return $data;
}
protected function afterSave(): void
protected function savePendingAttachments(): void
{
if (empty($this->pendingAttachments)) {
return;
@@ -209,6 +216,13 @@ class EditFeedback extends EditRecord
'size' => $this->safeFileSize($disk, $path),
]);
}
$this->pendingAttachments = []; // Clear to prevent double saving in afterSave
}
protected function afterSave(): void
{
$this->savePendingAttachments();
}
protected function safeMimeType($disk, string $path): ?string
@@ -231,7 +245,7 @@ class EditFeedback extends EditRecord
protected function mutateFormDataBeforeFill(array $data): array
{
$data['is_general'] = $data['contract_id'] === null;
$data['is_general'] = $data['customer_product_id'] === null;
return $data;
}

View File

@@ -2,7 +2,7 @@
namespace App\Filament\Resources\Feedback\Schemas;
use App\Models\Contract;
use App\Models\CustomerProduct;
use App\Models\Department;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\FileUpload;
@@ -25,7 +25,6 @@ class FeedbackForm
->icon('heroicon-o-user-group')
->schema([
Select::make('customer_id')
->label(__('app.widget_customer'))
->relationship('customer', 'name')
->searchable()
->required()
@@ -38,51 +37,66 @@ class FeedbackForm
->live()
->columnSpan(1),
Select::make('product_id_virtual')
Select::make('customer_product_id')
->label(__('app.product'))
->searchable()
->options(fn ($get): array => self::getProductOptionsForCustomer($get('customer_id')))
->getSearchResultsUsing(function (string $search, $get): array {
$customerId = $get('customer_id');
if (! $customerId) {
return [];
}
$keyword = mb_strtolower($search);
$allIds = self::getProductIdsForCustomer($customerId);
if ($allIds->isEmpty()) {
return [];
}
return \App\Models\Product::whereIn('id', $allIds)
->whereRaw('LOWER(name) LIKE ?', ["%{$keyword}%"])
return CustomerProduct::where('customer_id', $customerId)
->whereHas('product', fn ($q) => $q->whereRaw('LOWER(name) LIKE ?', ["%{$keyword}%"]))
->with('product')
->limit(50)
->pluck('name', 'id')
->get()
->pluck('product.name', 'id')
->toArray();
})
->getOptionLabelUsing(fn ($value): ?string =>
\App\Models\Product::find($value)?->name
CustomerProduct::find($value)?->product?->name
)
->visible(fn ($get): bool => ! $get('is_general'))
->nullable()
->live()
->columnSpan(1),
->afterStateUpdated(function ($state, $set): void {
if (empty($state)) {
$set('contract_id', null);
return;
}
$cp = CustomerProduct::find($state);
if (! $cp) {
return;
}
$activeContract = \App\Models\Contract::where('product_id', $cp->product_id)
->where('status', 'active')
->first();
if ($activeContract) {
$set('contract_id', $activeContract->id);
} else {
$set('contract_id', null);
}
}),
Select::make('contract_id')
->label(__('app.contract'))
->visible(fn ($get): bool => ! $get('is_general') && filled($get('product_id_virtual')))
->visible(fn ($get): bool => ! $get('is_general') && filled($get('customer_product_id')))
->options(function ($get): array {
$productId = $get('product_id_virtual');
if (! $productId) {
$customerProductId = $get('customer_product_id');
if (! $customerProductId) {
return [];
}
return Contract::where('product_id', $productId)
$cp = \App\Models\CustomerProduct::find($customerProductId);
if (! $cp) {
return [];
}
return \App\Models\Contract::where('product_id', $cp->product_id)
->where('status', 'active')
->with('customer')
->get()
->mapWithKeys(fn ($c) => [
$c->id => sprintf('%s — %s (%s)',
$c->id => sprintf('%s (%s) - %s',
\App\Enums\ContractType::from($c->type)->label(),
$c->customer->name,
$c->start_date?->format('d/m/Y') ?? 'N/A',
@@ -94,11 +108,15 @@ class FeedbackForm
if (filled($state)) {
return;
}
$productId = $get('product_id_virtual');
if (! $productId) {
$customerProductId = $get('customer_product_id');
if (! $customerProductId) {
return;
}
$activeContract = Contract::where('product_id', $productId)
$cp = \App\Models\CustomerProduct::find($customerProductId);
if (! $cp) {
return;
}
$activeContract = \App\Models\Contract::where('product_id', $cp->product_id)
->where('status', 'active')
->first();
if ($activeContract) {
@@ -115,20 +133,17 @@ class FeedbackForm
->icon('heroicon-o-chat-bubble-left-right')
->schema([
Select::make('feedback_channel_id')
->label(__('app.channel'))
->relationship('feedbackChannel', 'name')
->required()
->preload()
->columnSpan(1),
TextInput::make('title')
->label(__('app.blade_title'))
->required()
->maxLength(255)
->columnSpan(1),
Textarea::make('content')
->label(__('app.description'))
->required()
->rows(6)
->columnSpanFull(),
@@ -139,9 +154,9 @@ class FeedbackForm
->preload()
->columnSpanFull()
->createOptionForm([
TextInput::make('name')->label(__('app.name'))->required(),
TextInput::make('slug')->label(__('app.slug'))->required(),
ColorPicker::make('color')->label(__('app.color'))->default('#3b82f6'),
TextInput::make('name')->required(),
TextInput::make('slug')->required(),
ColorPicker::make('color')->default('#3b82f6'),
]),
])
->columns(2),
@@ -151,7 +166,6 @@ class FeedbackForm
->icon('heroicon-o-arrow-path-rounded-square')
->schema([
Select::make('status')
->label(__('app.status'))
->options([
'pending' => __('app.status_pending'),
'processing' => __('app.status_processing'),
@@ -163,7 +177,6 @@ class FeedbackForm
->columnSpan(1),
Select::make('assigned_to')
->label(__('app.assigned_to'))
->relationship('assignedTo', 'name')
->searchable()
->nullable()
@@ -203,42 +216,10 @@ class FeedbackForm
->disk('public')
->directory('feedback-attachments')
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'video/quicktime'])
->maxSize(51200)
->maxSize(51200) // 50MB in KB
->columnSpan(1),
])
->columns(2),
]);
}
private static function getProductOptionsForCustomer(?int $customerId): array
{
$ids = self::getProductIdsForCustomer($customerId);
return \App\Models\Product::whereIn('id', $ids)
->limit(100)
->pluck('name', 'id')
->toArray();
}
private static function getProductIdsForCustomer(?int $customerId): \Illuminate\Support\Collection
{
if (! $customerId) {
return collect();
}
$ownedIds = Contract::where('customer_id', $customerId)
->where('category', 'ownership')
->where('status', 'active')
->pluck('product_id');
$customer = \App\Models\Customer::find($customerId);
$repIds = collect();
if ($customer?->email) {
$repIds = Contract::where('representative_email', $customer->email)
->where('status', 'active')
->pluck('product_id');
}
return $ownedIds->merge($repIds)->unique();
}
}

View File

@@ -23,18 +23,16 @@ class FeedbackTable
->color('primary'),
TextColumn::make('title')
->label(__('app.blade_title'))
->searchable()
->sortable()
->weight('semibold')
->limit(40),
TextColumn::make('customer.name')
->label(__('app.widget_customer'))
->searchable()
->sortable(),
TextColumn::make('contract.product.name')
TextColumn::make('customerProduct.product.name')
->label(__('app.product'))
->placeholder(__('app.general'))
->sortable()
@@ -45,12 +43,9 @@ class FeedbackTable
->badge()
->formatStateUsing(fn (?string $state): string => $state ? \App\Enums\ContractType::from($state)->label() : '-')
->color(fn (?string $state): string => match ($state) {
'deposit' => 'warning',
'purchase' => 'success',
'transfer' => 'info',
'rental' => 'info',
'self_business' => 'warning',
'bcc' => 'danger',
'sale' => 'success',
'lease' => 'info',
'bcc' => 'warning',
default => 'gray',
})
->placeholder('-')
@@ -67,7 +62,6 @@ class FeedbackTable
->toggleable(),
TextColumn::make('status')
->label(__('app.status'))
->badge()
->color(fn(string $state): string => match ($state) {
'pending' => 'warning',

View File

@@ -29,9 +29,9 @@ class ProductsTable
TextColumn::make('description')
->limit(50)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('contracts_count')
->counts('contracts')
->label(__('app.contracts')),
TextColumn::make('customers_count')
->counts('customers')
->label(__('app.owners')),
TextColumn::make('created_at')
->dateTime()
->sortable()

View File

@@ -45,7 +45,7 @@ class ResolvedTicketsWidget extends BaseWidget
->label(__('app.widget_customer'))
->searchable(),
TextColumn::make('contract.product.name')
TextColumn::make('customerProduct.product.name')
->label(__('app.product'))
->placeholder(__('app.general')),

View File

@@ -7,36 +7,20 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['product_id', 'customer_id', 'type', 'category', 'start_date', 'end_date', 'status', 'printed_at', 'signed_status', 'representative_name', 'representative_phone', 'representative_email'])]
use App\Enums\ContractType;
use App\Enums\ContractStatus;
#[Fillable(['product_id', 'customer_id', 'type', 'start_date', 'end_date', 'status', 'printed_at', 'signed_status'])]
class Contract extends Model
{
protected static function booted(): void
{
static::saving(function (self $contract) {
if ($contract->status === 'active') {
$exists = self::where('product_id', $contract->product_id)
->where('category', $contract->category)
->where('status', 'active')
->when($contract->exists, fn ($q) => $q->where('id', '!=', $contract->id))
->exists();
if ($exists) {
throw new \RuntimeException(
__('feedback.contract_duplicate_body', [
'product' => $contract->product?->name ?? $contract->product_id,
'customer' => $contract->customer?->name ?? '?',
])
);
}
}
});
}
protected function casts(): array
{
return [
'start_date' => 'date',
'end_date' => 'date',
'printed_at' => 'date',
'type' => ContractType::class,
'status' => ContractStatus::class,
];
}

View File

@@ -6,19 +6,14 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
#[Fillable(['name', 'email', 'phone', 'address', 'status'])]
class Customer extends Model
{
/**
* Các căn hộ khách hàng sở hữu (qua Ownership active)
*/
public function ownedProducts(): BelongsToMany
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'contracts')
->wherePivot('category', 'ownership')
->wherePivot('status', 'active')
return $this->belongsToMany(Product::class, 'customer_product')
->withPivot('purchase_date')
->withTimestamps();
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class CustomerProduct extends Model
{
protected $table = 'customer_product';
protected $fillable = [
'customer_id',
'product_id',
'purchase_date',
];
protected function casts(): array
{
return [
'purchase_date' => 'date',
];
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function feedbacks(): HasMany
{
return $this->hasMany(Feedback::class);
}
}

View File

@@ -15,6 +15,11 @@ class Department extends Model
return $this->belongsTo(User::class, 'manager_id');
}
public function members(): HasMany
{
return $this->hasMany(User::class);
}
public function feedbacks(): HasMany
{
return $this->hasMany(Feedback::class, 'current_department_id');

View File

@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['customer_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])]
#[Fillable(['customer_id', 'customer_product_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])]
class Feedback extends Model
{
public function customer(): BelongsTo
@@ -16,6 +16,11 @@ class Feedback extends Model
return $this->belongsTo(Customer::class);
}
public function customerProduct(): BelongsTo
{
return $this->belongsTo(CustomerProduct::class, 'customer_product_id');
}
public function contract(): BelongsTo
{
return $this->belongsTo(Contract::class);

View File

@@ -6,9 +6,18 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Enums\HandoverStatus;
#[Fillable(['product_id', 'customer_id', 'handover_date', 'status', 'remark'])]
class Handover extends Model
{
protected function casts(): array
{
return [
'status' => HandoverStatus::class,
'handover_date' => 'date',
];
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);

View File

@@ -4,11 +4,19 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'description', 'status'])]
class Product extends Model
{
public function customers(): BelongsToMany
{
return $this->belongsToMany(Customer::class, 'customer_product')
->withPivot('purchase_date')
->withTimestamps();
}
public function contracts(): HasMany
{
return $this->hasMany(Contract::class);

View File

@@ -6,11 +6,23 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Enums\ServiceType;
use App\Enums\ContractStatus;
#[Fillable(['product_id', 'service_type', 'status', 'actual_collection_date', 'remark'])]
class ProductService extends Model
{
protected $table = 'product_services';
protected function casts(): array
{
return [
'service_type' => ServiceType::class,
'status' => ContractStatus::class,
'actual_collection_date' => 'date',
];
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);

View File

@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
@@ -14,7 +15,7 @@ use Illuminate\Contracts\Auth\Access\Authorizable;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
#[Fillable(['name', 'email', 'password', 'role'])]
#[Fillable(['name', 'email', 'password', 'role', 'department_id'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable implements FilamentUser
{
@@ -29,6 +30,11 @@ class User extends Authenticatable implements FilamentUser
];
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
public function canAccessPanel(Panel $panel): bool
{
return true;

View File

@@ -1,89 +0,0 @@
<?php
namespace App\Providers\Filament;
use App\Filament\Resources\ActivityLogs\ActivityLogResource;
use App\Filament\Resources\Customers\CustomerResource;
use App\Filament\Resources\Feedback\FeedbackResource;
use App\Filament\Resources\FeedbackChannels\FeedbackChannelResource;
use App\Filament\Resources\FeedbackTags\FeedbackTagResource;
use App\Filament\Resources\Products\ProductResource;
use App\Filament\Widgets\AvgProcessingTimeWidget;
use App\Filament\Widgets\HandlerPerformanceWidget;
use App\Filament\Widgets\ResolvedTicketsWidget;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\View\PanelsRenderHook;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class SupportPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('support')
->path('support')
->login()
->brandName('AfterSales CRM — Support')
->sidebarCollapsibleOnDesktop()
->colors([
'primary' => '#1a56db',
'secondary' => '#64748b',
'gray' => '#64748b',
'success' => '#059669',
'warning' => '#d97706',
'danger' => '#dc2626',
'info' => '#2563eb',
])
->font('Inter')
->favicon(asset('favicon.svg'))
->resources([
FeedbackResource::class,
CustomerResource::class,
ProductResource::class,
FeedbackChannelResource::class,
FeedbackTagResource::class,
ActivityLogResource::class,
])
->pages([
Dashboard::class,
])
->widgets([
ResolvedTicketsWidget::class,
AvgProcessingTimeWidget::class,
HandlerPerformanceWidget::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
PreventRequestForgery::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
])
->renderHook(
PanelsRenderHook::HEAD_END,
fn (): string => '<link rel="stylesheet" href="' . asset('css/filament-custom.css') . '">',
)
->renderHook(
PanelsRenderHook::BODY_END,
fn (): string => '<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">',
);
}
}

View File

@@ -23,9 +23,12 @@ class ClosingService
throw new HttpException(403, __('feedback.close_permission_denied'));
}
if ($actor->hasRole('manager') && ! $actor->hasRole('admin')) {
$managedDeptIds = Department::where('manager_id', $actor->id)->pluck('id');
if (! $managedDeptIds->contains($feedback->current_department_id)) {
if (! $actor->hasRole('admin')) {
$managedDeptIds = Department::where('manager_id', $actor->id)->pluck('id')->toArray();
$actorDeptId = $actor->department_id;
$allowedDeptIds = array_filter(array_merge([$actorDeptId], $managedDeptIds));
if (! in_array($feedback->current_department_id, $allowedDeptIds)) {
throw new HttpException(403, __('feedback.close_department_only'));
}
}
@@ -68,13 +71,26 @@ class ClosingService
protected function notifyStakeholders(Feedback $feedback, User $actor): void
{
$notifiedUserIds = [];
if ($feedback->assignedTo) {
$feedback->assignedTo->notify(new \App\Notifications\TicketClosed($feedback, $actor));
$notifiedUserIds[] = $feedback->assignedTo->id;
}
$department = $feedback->currentDepartment;
if ($department && $department->manager && $department->manager->id !== ($feedback->assignedTo?->id ?? null)) {
$department->manager->notify(new \App\Notifications\TicketClosed($feedback, $actor));
if ($department && $department->manager) {
if (! in_array($department->manager->id, $notifiedUserIds)) {
$department->manager->notify(new \App\Notifications\TicketClosed($feedback, $actor));
}
} else {
// No manager! Notify admins
$admins = User::whereHas('roles', fn ($q) => $q->where('name', 'admin'))->get();
foreach ($admins as $admin) {
if (! in_array($admin->id, $notifiedUserIds)) {
$admin->notify(new \App\Notifications\TicketClosed($feedback, $actor));
}
}
}
}
}

View File

@@ -24,6 +24,20 @@ class TransferService
]);
}
if ($newHandlerId !== null) {
$handler = User::find($newHandlerId);
if (! $handler) {
throw ValidationException::withMessages([
'new_assignee_id' => __('feedback.user_not_found'),
]);
}
if ($handler->department_id !== $toDepartment->id && $toDepartment->manager_id !== $handler->id) {
throw ValidationException::withMessages([
'new_assignee_id' => __('feedback.handler_not_in_department'),
]);
}
}
$fromDepartmentId = $feedback->current_department_id;
$fromDepartmentName = $feedback->currentDepartment?->name ?? 'N/A';
@@ -67,6 +81,12 @@ class TransferService
if ($manager) {
$manager->notify(new \App\Notifications\TicketTransferred($feedback, $log, $sender));
} else {
// No manager! Notify admins
$admins = User::whereHas('roles', fn ($q) => $q->where('name', 'admin'))->get();
foreach ($admins as $admin) {
$admin->notify(new \App\Notifications\TicketTransferred($feedback, $log, $sender));
}
}
}
}

View File

@@ -3,5 +3,4 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\Filament\SupportPanelProvider::class,
];

View File

@@ -1,69 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// 1. Add category + representative fields to contracts
Schema::table('contracts', function (Blueprint $table) {
$table->string('category')->after('type')->default('ownership');
$table->string('representative_name')->nullable()->after('signed_status');
$table->string('representative_phone')->nullable()->after('representative_name');
$table->string('representative_email')->nullable()->after('representative_phone');
});
// 2. Drop customer_product_id FK and column from feedback
if (DB::getDriverName() === 'sqlite') {
// SQLite doesn't support dropping foreign keys cleanly, recreate table approach
Schema::table('feedback', function (Blueprint $table) {
// Drop the column directly (SQLite will handle)
});
}
try {
Schema::table('feedback', function (Blueprint $table) {
$table->dropForeign(['customer_product_id']);
});
} catch (\Throwable) {
// FK may not exist or already dropped
}
Schema::table('feedback', function (Blueprint $table) {
if (Schema::hasColumn('feedback', 'customer_product_id')) {
$table->dropColumn('customer_product_id');
}
});
// 3. Drop customer_product pivot table
Schema::dropIfExists('customer_product');
}
public function down(): void
{
// Restore customer_product table
Schema::create('customer_product', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete();
$table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
$table->date('purchase_date')->nullable();
$table->timestamps();
$table->unique(['customer_id', 'product_id']);
});
// Restore customer_product_id on feedback
Schema::table('feedback', function (Blueprint $table) {
$table->foreignId('customer_product_id')->nullable()->after('customer_id');
$table->foreign('customer_product_id')->references('id')->on('customer_product')->nullOnDelete();
});
// Remove V2 columns from contracts
Schema::table('contracts', function (Blueprint $table) {
$table->dropColumn(['category', 'representative_name', 'representative_phone', 'representative_email']);
});
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('department_id')
->nullable()
->after('role')
->constrained('departments')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['department_id']);
$table->dropColumn('department_id');
});
}
};

View File

@@ -2,13 +2,13 @@
namespace Database\Seeders;
use App\Enums\ContractCategory;
use App\Enums\ContractStatus;
use App\Enums\ContractType;
use App\Enums\HandoverStatus;
use App\Enums\ServiceType;
use App\Models\Contract;
use App\Models\Customer;
use App\Models\CustomerProduct;
use App\Models\Department;
use App\Models\Feedback;
use App\Models\FeedbackAttachment;
@@ -119,62 +119,65 @@ class AfterSalesSeeder extends Seeder
$c6 = Customer::create(['name' => 'Đỗ Văn Phúc', 'email' => 'dovanphuc@gmail.com', 'phone' => '0954678901', 'address' => 'Thủ Đức, TP.HCM', 'status' => 'inactive']);
// ============================================================
// 7. CONTRACTS (V2: category + representative)
// 7. CUSTOMER-PRODUCT pivots
// ============================================================
$c1->products()->attach($p1->id, ['purchase_date' => '2024-01-15']);
$c1->products()->attach($p2->id, ['purchase_date' => '2024-06-01']);
$c2->products()->attach($p1->id, ['purchase_date' => '2024-03-20']);
$c3->products()->attach($p3->id, ['purchase_date' => '2024-09-10']);
$c4->products()->attach($p2->id, ['purchase_date' => '2025-01-05']);
$c4->products()->attach($p5->id, ['purchase_date' => '2025-02-15']);
$c5->products()->attach($p5->id, ['purchase_date' => '2024-11-01']);
$c6->products()->attach($p6->id, ['purchase_date' => '2023-06-15']);
// ============================================================
// 8. CONTRACTS (6 contracts)
// ============================================================
$contract1 = Contract::create([
'product_id' => $p1->id, 'customer_id' => $c1->id,
'category' => ContractCategory::OWNERSHIP->value,
'type' => ContractType::PURCHASE->value,
'type' => ContractType::SALE->value,
'start_date' => '2024-01-15', 'end_date' => null,
'status' => ContractStatus::ACTIVE->value,
'signed_status' => 'Đã ký',
'representative_name' => 'Nguyễn Thị Hoa',
'representative_phone' => '0901000111',
'representative_email' => 'hoant@example.com',
]);
$contract2 = Contract::create([
'product_id' => $p2->id, 'customer_id' => $c1->id,
'category' => ContractCategory::BUSINESS->value,
'type' => ContractType::RENTAL->value,
'type' => ContractType::LEASE->value,
'start_date' => '2024-06-01', 'end_date' => '2026-06-01',
'status' => ContractStatus::ACTIVE->value,
'signed_status' => 'Đã ký',
]);
$contract3 = Contract::create([
'product_id' => $p2->id, 'customer_id' => $c4->id,
'category' => ContractCategory::BUSINESS->value,
'type' => ContractType::RENTAL->value,
'start_date' => '2025-01-05', 'end_date' => '2026-01-05',
'status' => ContractStatus::EXPIRED->value,
'product_id' => $p1->id, 'customer_id' => $c2->id,
'type' => ContractType::SALE->value,
'start_date' => '2024-03-20', 'end_date' => null,
'status' => ContractStatus::ACTIVE->value,
'signed_status' => 'Đã ký',
]);
$contract4 = Contract::create([
'product_id' => $p3->id, 'customer_id' => $c3->id,
'category' => ContractCategory::BUSINESS->value,
'type' => ContractType::BCC->value,
'start_date' => '2024-09-10', 'end_date' => '2029-09-10',
'status' => ContractStatus::ACTIVE->value,
'signed_status' => 'Đang chờ ký',
]);
$contract5 = Contract::create([
'product_id' => $p2->id, 'customer_id' => $c4->id,
'type' => ContractType::LEASE->value,
'start_date' => '2025-01-05', 'end_date' => '2026-01-05',
'status' => ContractStatus::EXPIRED->value,
'signed_status' => 'Đã ký',
]);
$contract6 = Contract::create([
'product_id' => $p5->id, 'customer_id' => $c5->id,
'category' => ContractCategory::OWNERSHIP->value,
'type' => ContractType::PURCHASE->value,
'type' => ContractType::SALE->value,
'start_date' => '2024-11-01', 'end_date' => null,
'status' => ContractStatus::ACTIVE->value,
'signed_status' => 'Đã ký',
]);
$contract6 = Contract::create([
'product_id' => $p1->id, 'customer_id' => $c2->id,
'category' => ContractCategory::OWNERSHIP->value,
'type' => ContractType::PURCHASE->value,
'start_date' => '2024-03-20', 'end_date' => '2024-12-31',
'status' => ContractStatus::LIQUIDATED->value,
'signed_status' => 'Đã ký',
]);
// ============================================================
// 8. HANDOVERS (6 handovers)
// 9. HANDOVERS (6 handovers)
// ============================================================
Handover::create(['product_id' => $p1->id, 'customer_id' => $c1->id, 'handover_date' => '2024-02-15', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Đã bàn giao đầy đủ chìa khóa, thẻ từ và sổ bảo hành.']);
Handover::create(['product_id' => $p1->id, 'customer_id' => $c2->id, 'handover_date' => '2024-04-20', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Đã bàn giao, khách yêu cầu kiểm tra lại hệ thống nước nóng.']);
@@ -184,7 +187,7 @@ class AfterSalesSeeder extends Seeder
Handover::create(['product_id' => $p5->id, 'customer_id' => $c5->id, 'handover_date' => '2025-01-15', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Bàn giao thành công, khách hài lòng với chất lượng.']);
// ============================================================
// 9. PRODUCT SERVICES (6 services)
// 10. PRODUCT SERVICES (6 services)
// ============================================================
ProductService::create(['product_id' => $p1->id, 'service_type' => ServiceType::MANAGEMENT_FEE->value, 'status' => 'active', 'actual_collection_date' => '2024-03-01', 'remark' => 'Phí quản lý hàng tháng 5.000.000 VND/căn.']);
ProductService::create(['product_id' => $p1->id, 'service_type' => ServiceType::GRATITUDE->value, 'status' => 'completed', 'actual_collection_date' => '2024-06-15', 'remark' => 'Quà tri ân khách hàng nhân dịp khai trương tiện ích mới.']);
@@ -194,7 +197,7 @@ class AfterSalesSeeder extends Seeder
ProductService::create(['product_id' => $p5->id, 'service_type' => ServiceType::MANAGEMENT_FEE->value, 'status' => 'active', 'actual_collection_date' => '2025-02-01', 'remark' => 'Phí quản lý hàng tháng 3.500.000 VND/căn.']);
// ============================================================
// 10. FEEDBACK TAGS (6 tags)
// 11. FEEDBACK TAGS (6 tags)
// ============================================================
$tagLeaking = FeedbackTag::create(['name' => 'Rò rỉ', 'slug' => 'leaking', 'color' => '#3b82f6']);
$tagElectrical = FeedbackTag::create(['name' => 'Điện nước', 'slug' => 'electrical', 'color' => '#f59e0b']);
@@ -204,12 +207,13 @@ class AfterSalesSeeder extends Seeder
$tagSuggestion = FeedbackTag::create(['name' => 'Đề xuất', 'slug' => 'suggestion', 'color' => '#06b6d4']);
// ============================================================
// 11. FEEDBACKS (8 feedbacks - V2: no customer_product_id, use contract_id)
// 12. FEEDBACKS (8 feedbacks - various statuses)
// ============================================================
// Feedback 1: Pending - Trần rò rỉ
$cp1 = CustomerProduct::where('customer_id', $c1->id)->where('product_id', $p1->id)->first();
$fb1 = Feedback::create([
'customer_id' => $c1->id, 'contract_id' => $contract1->id,
'customer_id' => $c1->id, 'customer_product_id' => $cp1->id, 'contract_id' => $contract1->id,
'feedback_channel_id' => $chEmail->id, 'assigned_to' => $staffA->id,
'current_department_id' => $deptCskh->id,
'title' => 'Trần nhà bị rò rỉ nước mưa',
@@ -218,8 +222,9 @@ class AfterSalesSeeder extends Seeder
]);
// Feedback 2: Processing - Nâng cấp chỗ đậu xe
$cp2 = CustomerProduct::where('customer_id', $c1->id)->where('product_id', $p2->id)->first();
$fb2 = Feedback::create([
'customer_id' => $c1->id, 'contract_id' => $contract2->id,
'customer_id' => $c1->id, 'customer_product_id' => $cp2->id, 'contract_id' => $contract2->id,
'feedback_channel_id' => $chPhone->id, 'assigned_to' => $managerCskh->id,
'current_department_id' => $deptCskh->id,
'title' => 'Yêu cầu nâng cấp chỗ đậu xe',
@@ -228,8 +233,9 @@ class AfterSalesSeeder extends Seeder
]);
// Feedback 3: Resolved - Điều hòa hỏng
$cp3 = CustomerProduct::where('customer_id', $c2->id)->where('product_id', $p1->id)->first();
$fb3 = Feedback::create([
'customer_id' => $c2->id, 'contract_id' => $contract6->id,
'customer_id' => $c2->id, 'customer_product_id' => $cp3->id, 'contract_id' => $contract3->id,
'feedback_channel_id' => $chZalo->id, 'assigned_to' => $staffA->id,
'current_department_id' => $deptKt->id, 'is_escalated' => false,
'title' => 'Điều hòa phòng ngủ chính không mát',
@@ -237,9 +243,9 @@ class AfterSalesSeeder extends Seeder
'status' => 'resolved',
]);
// Feedback 4: Pending - Góp ý cải thiện dịch vụ (general)
// Feedback 4: Pending - Góp ý cải thiện dịch vụ
$fb4 = Feedback::create([
'customer_id' => $c3->id, 'contract_id' => null,
'customer_id' => $c3->id, 'customer_product_id' => null, 'contract_id' => null,
'feedback_channel_id' => $chApp->id, 'assigned_to' => null,
'current_department_id' => $deptCskh->id,
'title' => 'Đề xuất cải thiện dịch vụ vệ sinh',
@@ -247,9 +253,10 @@ class AfterSalesSeeder extends Seeder
'status' => 'pending',
]);
// Feedback 5: Closed - Bản lề tủ bếp (vẫn ref contract đã expired)
// Feedback 5: Closed - Bản lề tủ bếp
$cp5 = CustomerProduct::where('customer_id', $c4->id)->where('product_id', $p2->id)->first();
$fb5 = Feedback::create([
'customer_id' => $c4->id, 'contract_id' => $contract3->id,
'customer_id' => $c4->id, 'customer_product_id' => $cp5->id, 'contract_id' => $contract5->id,
'feedback_channel_id' => $chEmail->id, 'assigned_to' => $staffB->id,
'current_department_id' => $deptKt->id,
'title' => 'Bản lề tủ bếp bị gãy',
@@ -258,18 +265,19 @@ class AfterSalesSeeder extends Seeder
]);
// Feedback 6: Processing - Ổ cắm điện hư
$cp6 = CustomerProduct::where('customer_id', $c5->id)->where('product_id', $p5->id)->first();
$fb6 = Feedback::create([
'customer_id' => $c5->id, 'contract_id' => $contract5->id,
'customer_id' => $c5->id, 'customer_product_id' => $cp6->id, 'contract_id' => $contract6->id,
'feedback_channel_id' => $chPhone->id, 'assigned_to' => $staffA->id,
'current_department_id' => $deptKt->id, 'is_escalated' => true,
'title' => 'Ổ cắm điện phòng bếp bị hư, có mùi khét',
'title' => 'Ổ c插座 điện phòng bếp bị hư, có mùi khét',
'content' => 'Ổ cắm điện cạnh bồn rửa chén bị hư, phát ra mùi khét khi cắm thiết bị. Đây là vấn đề an toàn, cần xử lý gấp.',
'status' => 'processing',
]);
// Feedback 7: Resolved - Hợp đồng thuê
$fb7 = Feedback::create([
'customer_id' => $c4->id, 'contract_id' => $contract3->id,
'customer_id' => $c4->id, 'customer_product_id' => null, 'contract_id' => $contract5->id,
'feedback_channel_id' => $chWalkin->id, 'assigned_to' => $staffB->id,
'current_department_id' => $deptPhaply->id,
'title' => 'Hỏi về gia hạn hợp đồng thuê',
@@ -277,9 +285,9 @@ class AfterSalesSeeder extends Seeder
'status' => 'resolved',
]);
// Feedback 8: Pending - Phàn nàn tiếng ồn
// Feedback 8: Pending - Phàn nàn về tiếng ồn
$fb8 = Feedback::create([
'customer_id' => $c2->id, 'contract_id' => $contract6->id,
'customer_id' => $c2->id, 'customer_product_id' => $cp3->id, 'contract_id' => $contract3->id,
'feedback_channel_id' => $chZalo->id, 'assigned_to' => $staffB->id,
'current_department_id' => $deptCskh->id,
'title' => 'Phàn nàn về tiếng ồn từ tầng trên',
@@ -288,7 +296,7 @@ class AfterSalesSeeder extends Seeder
]);
// ============================================================
// 12. TAG ATTACHMENTS
// 13. TAG ATTACHMENTS
// ============================================================
$fb1->tags()->attach([$tagLeaking->id, $tagMaintenance->id]);
$fb2->tags()->attach([$tagSuggestion->id]);
@@ -300,8 +308,10 @@ class AfterSalesSeeder extends Seeder
$fb8->tags()->attach([$tagComplaint->id]);
// ============================================================
// 13. FEEDBACK INTERACTIONS
// 14. FEEDBACK INTERACTIONS (12 interactions)
// ============================================================
// FB1: Tạo mới + ghi chú
FeedbackInteraction::create([
'feedback_id' => $fb1->id, 'user_id' => $admin->id, 'type' => 'note',
'content' => 'Tiếp nhận phản ánh qua Email. Giao cho nhân viên Phạm Thị xử lý.',
@@ -311,20 +321,28 @@ class AfterSalesSeeder extends Seeder
'feedback_id' => $fb1->id, 'user_id' => $staffA->id, 'type' => 'note',
'content' => 'Đã liên hệ khách hàng qua điện thoại. Lên lịch kiểm tra vào thứ Ba tuần sau.',
]);
// FB2: Phân công
FeedbackInteraction::create([
'feedback_id' => $fb2->id, 'user_id' => $managerCskh->id, 'type' => 'note',
'content' => 'Đã gửi báo giá nâng cấp chỗ đậu xe qua email. Chờ khách xác nhận.',
]);
// FB3: Xử lý xong
FeedbackInteraction::create([
'feedback_id' => $fb3->id, 'user_id' => $staffA->id, 'type' => 'status_change',
'content' => 'Kỹ thuật viên đã kiểm tra và thay thế block điều hòa ngày 10/06. Khách hàng xác nhận hoạt động bình thường.',
'changes' => ['status' => ['old' => 'processing', 'new' => 'resolved']],
]);
// FB5: Đóng phiếu
FeedbackInteraction::create([
'feedback_id' => $fb5->id, 'user_id' => $staffB->id, 'type' => 'status_change',
'content' => 'Đã thay thế bản lề mới ngày 01/03. Tất cả tủ bếp hoạt động bình thường.',
'changes' => ['status' => ['old' => 'processing', 'new' => 'closed']],
]);
// FB6: Đang xử lý + ghi chú khẩn cấp
FeedbackInteraction::create([
'feedback_id' => $fb6->id, 'user_id' => $staffA->id, 'type' => 'note',
'content' => 'Đã cử kỹ thuật viên khẩn cấp đến kiểm tra. Yêu cầu khách không sử dụng ổ cắm cho đến khi sửa xong.',
@@ -334,6 +352,8 @@ class AfterSalesSeeder extends Seeder
'content' => 'Kỹ thuật viên đã kiểm tra, phát hiện dây điện bị hở. Đang chờ vật tư thay thế.',
'changes' => ['status' => ['old' => 'pending', 'new' => 'processing']],
]);
// FB7: Phân công + chuyển phòng ban
FeedbackInteraction::create([
'feedback_id' => $fb7->id, 'user_id' => $managerCskh->id, 'type' => 'assignment',
'content' => 'Chuyển yêu cầu gia hạn hợp đồng sang Phòng Pháp lý.',
@@ -344,13 +364,15 @@ class AfterSalesSeeder extends Seeder
'content' => 'Đã soạn thảo phụ lục hợp đồng gia hạn. Đang chờ khách hàng ký.',
'changes' => ['status' => ['old' => 'processing', 'new' => 'resolved']],
]);
// FB8: Ghi chú
FeedbackInteraction::create([
'feedback_id' => $fb8->id, 'user_id' => $staffB->id, 'type' => 'note',
'content' => 'Đã liên hệ ban quản lý tòa nhà để xác minh thông tin. BQL cho biết sẽ nhắc nhở cư dân tầng trên.',
]);
// ============================================================
// 14. TICKET TRANSFER LOGS
// 15. TICKET TRANSFER LOGS (2 transfers)
// ============================================================
TicketTransferLog::create([
'feedback_id' => $fb3->id,
@@ -368,15 +390,17 @@ class AfterSalesSeeder extends Seeder
]);
// ============================================================
// 15. FEEDBACK ATTACHMENTS
// 16. FEEDBACK ATTACHMENTS (6 attachments - public disk)
// ============================================================
$disk = Storage::disk('public');
// Tạo file mẫu trên public disk
$disk->put('feedback-attachments/bao-cao-kiem-tra-dieu-hoa.pdf', '%PDF-1.4 Mẫu báo cáo kiểm tra điều hòa - Demo');
$disk->put('feedback-attachments/anh-tran-ro-ri.jpg', str_repeat('x', 1000));
$disk->put('feedback-attachments/anh-ban-le-hong.png', str_repeat('y', 1200));
$disk->put('feedback-attachments/anh-tran-ro-ri.jpg', str_repeat('x', 1000)); // dummy image
$disk->put('feedback-attachments/anh-ban-le-hong.png', str_repeat('y', 1200)); // dummy image
$disk->put('feedback-attachments/bao-gia-nang-cap.pdf', '%PDF-1.4 Báo giá nâng cấp chỗ đậu xe - Demo');
$disk->put('feedback-attachments/phu-luc-hop-dong.pdf', '%PDF-1.4 Phụ lục hợp đồng gia hạn - Demo');
$disk->put('feedback-attachments/anh-o-cam-hu.jpg', str_repeat('z', 800));
$disk->put('feedback-attachments/anh-o-cam-hu.jpg', str_repeat('z', 800)); // dummy image
FeedbackAttachment::create([
'uuid' => (string) Str::uuid(),

View File

@@ -1,18 +1,35 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
image: minicrm:latest
container_name: minicrm_app
ports:
- "${APP_PORT:-8080}:80"
env_file:
- .env
volumes:
- ./database/database.sqlite:/var/www/html/database/database.sqlite
- ./database:/var/www/html/database
- ./storage/app:/var/www/html/storage/app
restart: unless-stopped
depends_on:
- redis
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/admin/login"]
interval: 30s
timeout: 5s
retries: 3
redis:
image: redis:7-alpine
container_name: minicrm_redis
command: redis-server --appendonly yes
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
redis_data:

View File

@@ -3,8 +3,9 @@ set -e
if [ ! -f /var/www/html/database/database.sqlite ]; then
touch /var/www/html/database/database.sqlite
chown www-data:www-data /var/www/html/database/database.sqlite
fi
chown -R www-data:www-data /var/www/html/database
chmod -R 775 /var/www/html/database
if [ -z "$APP_KEY" ]; then
php artisan key:generate --force --no-interaction

View File

@@ -13,3 +13,16 @@ stdout_logfile_maxbytes=0
command=nginx -g 'daemon off;'
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
numprocs=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

View File

@@ -88,11 +88,7 @@ return [
'end_date' => 'End Date',
'printed_at' => 'Print Date',
'signed_status' => 'Signed Status',
'contract_category_section' => 'Contract Classification',
'contract_info' => 'Contract Information',
'representative_info' => 'Representative Information',
'representative_desc' => 'Person representing the owner (if any)',
'representative' => 'Representative',
'feedback_history' => 'Feedback History',
'service_type' => 'Service Type',
'tab_all' => 'All',

View File

@@ -32,21 +32,11 @@ return [
'liquidated' => 'Liquidated',
],
// Contract Category
'contract_category' => [
'ownership' => 'Ownership',
'business' => 'Business',
],
'contract_category_label' => 'Contract Category',
// Contract Type (V2)
// Contract Type
'contract_type' => [
'deposit' => 'Deposit',
'purchase' => 'Purchase',
'transfer' => 'Transfer',
'rental' => 'Rental',
'self_business' => 'Self Business',
'bcc' => 'Business Cooperation (BCC)',
'sale' => 'Sale contract',
'lease' => 'Lease contract',
'bcc' => 'Business cooperation contract (BCC)',
],
// Labels for Select filters

View File

@@ -10,6 +10,8 @@ return [
// TransferService
'transfer_reason_required' => 'Please enter a transfer reason.',
'handler_not_in_department' => 'The selected handler does not belong to the target department.',
'user_not_found' => 'The selected handler was not found.',
// FileService
'file_type_not_allowed' => 'File type :type is not allowed. Allowed: jpg, png, pdf, mp4, mov.',
@@ -60,8 +62,4 @@ return [
'log_export_done' => 'Exported :tables tables, :rows rows',
'log_transfer' => ':title transferred from :from to :to',
'log_close' => 'Ticket closed: :title',
// Contract validation
'contract_duplicate_title' => 'Product already has active :category contract',
'contract_duplicate_body' => 'Product :product already has an active contract of this category with :customer. Please liquidate the existing contract before creating a new one.',
];

View File

@@ -88,11 +88,7 @@ return [
'end_date' => 'Ngày kết thúc',
'printed_at' => 'Ngày in HĐ',
'signed_status' => 'Tình trạng ký',
'contract_category_section' => 'Phân loại hợp đồng',
'contract_info' => 'Thông tin hợp đồng',
'representative_info' => 'Thông tin người đại diện',
'representative_desc' => 'Người đại diện thay mặt chủ sở hữu (nếu có)',
'representative' => 'Người đại diện',
'feedback_history' => 'Lịch sử phản ánh',
'service_type' => 'Loại dịch vụ',
'tab_all' => 'Tất cả',

View File

@@ -32,21 +32,11 @@ return [
'liquidated' => 'Đã thanh lý',
],
// Contract Category
'contract_category' => [
'ownership' => 'Sở hữu',
'business' => 'Kinh doanh',
],
'contract_category_label' => 'Phân loại HĐ',
// Contract Type (V2)
// Contract Type
'contract_type' => [
'deposit' => 'Đặt cọc',
'purchase' => 'Mua bán',
'transfer' => 'Chuyển nhượng',
'rental' => 'Cho thuê',
'self_business' => 'Tự doanh',
'bcc' => 'Hợp tác KD (BCC)',
'sale' => 'HĐ Mua bán',
'lease' => 'HĐ Thuê',
'bcc' => 'HĐ Hợp tác kinh doanh (BCC)',
],
// Labels cho Select filters

View File

@@ -10,6 +10,8 @@ return [
// TransferService
'transfer_reason_required' => 'Vui lòng nhập lý do chuyển tiếp.',
'handler_not_in_department' => 'Người xử lý được chọn không thuộc phòng ban đích.',
'user_not_found' => 'Không tìm thấy người xử lý được chọn.',
// FileService
'file_type_not_allowed' => 'Loại file :type không được phép. Cho phép: jpg, png, pdf, mp4, mov.',
@@ -60,8 +62,4 @@ return [
'log_export_done' => 'Xuất :tables bảng, :rows dòng',
'log_transfer' => ':title: chuyển từ :from sang :to',
'log_close' => 'Đóng ticket: :title',
// Contract validation
'contract_duplicate_title' => 'Căn hộ đã có HĐ :category active',
'contract_duplicate_body' => 'Căn hộ :product đã có HĐ thuộc nhóm này với khách :customer. Vui lòng thanh lý HĐ cũ trước khi tạo mới.',
];

1614
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -187,3 +187,157 @@ test('manager cannot close ticket from department they do not manage', function
expect($feedback->fresh()->status)->toBe('resolved');
});
// ─── Scenario 2d: Staff with close permission cannot close ticket from other department ──
test('staff with close permission cannot close ticket from other department', function () {
$staffRole = Role::create(['name' => 'staff']);
$staffRole->givePermissionTo(['close-ticket', 'view-feedback', 'update-feedback']);
// Staff belongs to Department A
$deptA = Department::create(['name' => 'CSKH']);
$staff = User::factory()->create(['department_id' => $deptA->id]);
$staff->assignRole('staff');
// Ticket belongs to Department B
$deptB = Department::create(['name' => 'Ky Thuat']);
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-staff-close']);
$customer = Customer::create(['name' => 'Test Customer Staff', 'email' => 'staff@test.com']);
$feedback = Feedback::create([
'customer_id' => $customer->id,
'feedback_channel_id' => $channel->id,
'title' => 'Test Feedback Staff',
'content' => 'Test content',
'status' => 'resolved',
'current_department_id' => $deptB->id,
]);
FeedbackAttachment::create([
'uuid' => 'test-uuid-staff-1',
'feedback_id' => $feedback->id,
'user_id' => $staff->id,
'name' => 'proof.pdf',
'path' => 'feedback-attachments/proof_staff.pdf',
'disk' => 'local',
'collection' => 'proof_of_resolution',
'mime_type' => 'application/pdf',
'size' => 1024,
]);
$this->actingAs($staff);
$closingService = app(ClosingService::class);
try {
$closingService->close($feedback, $staff);
$this->fail('Expected HttpException (403) but none thrown.');
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
expect($e->getStatusCode())->toBe(403);
expect($e->getMessage())->toBe('Bạn chỉ có thể đóng phiếu thuộc phòng ban mình quản lý.');
}
expect($feedback->fresh()->status)->toBe('resolved');
});
// ─── Scenario 2e: Staff with close permission can close ticket from same department ────
test('staff with close permission can close ticket from same department', function () {
$staffRole = Role::create(['name' => 'staff']);
$staffRole->givePermissionTo(['close-ticket', 'view-feedback', 'update-feedback']);
// Staff belongs to Department A
$deptA = Department::create(['name' => 'CSKH']);
$staff = User::factory()->create(['department_id' => $deptA->id]);
$staff->assignRole('staff');
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-staff-close-success']);
$customer = Customer::create(['name' => 'Test Customer Staff Success', 'email' => 'staff_success@test.com']);
$feedback = Feedback::create([
'customer_id' => $customer->id,
'feedback_channel_id' => $channel->id,
'title' => 'Test Feedback Staff Success',
'content' => 'Test content',
'status' => 'resolved',
'current_department_id' => $deptA->id,
]);
FeedbackAttachment::create([
'uuid' => 'test-uuid-staff-2',
'feedback_id' => $feedback->id,
'user_id' => $staff->id,
'name' => 'proof.pdf',
'path' => 'feedback-attachments/proof_staff2.pdf',
'disk' => 'local',
'collection' => 'proof_of_resolution',
'mime_type' => 'application/pdf',
'size' => 1024,
]);
$this->actingAs($staff);
$closingService = app(ClosingService::class);
$closingService->close($feedback, $staff);
expect($feedback->fresh()->status)->toBe('closed');
});
// ─── Scenario 2f: Notify admin when closing a department ticket with no manager ────────
test('admin notification is sent when closing department ticket with no manager', function () {
\Illuminate\Support\Facades\Notification::fake();
// Create admin role and user
$adminRole = Role::create(['name' => 'admin']);
$admin = User::factory()->create();
$admin->assignRole('admin');
$managerRole = Role::create(['name' => 'manager']);
$managerRole->givePermissionTo(['close-ticket', 'view-feedback', 'update-feedback']);
// Department with no manager
$dept = Department::create(['name' => 'Unmanaged Dept', 'manager_id' => null]);
$manager = User::factory()->create(['department_id' => $dept->id]);
$manager->assignRole('manager');
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-no-manager']);
$customer = Customer::create(['name' => 'Customer', 'email' => 'no_manager@test.com']);
$feedback = Feedback::create([
'customer_id' => $customer->id,
'feedback_channel_id' => $channel->id,
'title' => 'Ticket',
'content' => 'Content',
'status' => 'resolved',
'current_department_id' => $dept->id,
]);
FeedbackAttachment::create([
'uuid' => 'test-uuid-no-manager',
'feedback_id' => $feedback->id,
'user_id' => $manager->id,
'name' => 'proof.pdf',
'path' => 'feedback-attachments/proof3.pdf',
'disk' => 'local',
'collection' => 'proof_of_resolution',
'mime_type' => 'application/pdf',
'size' => 1024,
]);
$this->actingAs($manager);
$closingService = app(ClosingService::class);
$closingService->close($feedback, $manager);
expect($feedback->fresh()->status)->toBe('closed');
// Assert notification sent to admin
\Illuminate\Support\Facades\Notification::assertSentTo(
$admin,
\App\Notifications\TicketClosed::class,
function ($notification) use ($feedback, $manager) {
return $notification->feedback->id === $feedback->id
&& $notification->actor->id === $manager->id;
}
);
});

View File

@@ -0,0 +1,72 @@
<?php
use App\Models\Customer;
use App\Models\Department;
use App\Models\Feedback;
use App\Models\FeedbackChannel;
use App\Models\User;
use App\Filament\Resources\Feedback\Pages\EditFeedback;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Livewire\Livewire;
use Illuminate\Support\Facades\Storage;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
Permission::firstOrCreate(['name' => 'close-ticket']);
Permission::firstOrCreate(['name' => 'view-feedback']);
Permission::firstOrCreate(['name' => 'update-feedback']);
Permission::firstOrCreate(['name' => 'transfer-department']);
Permission::firstOrCreate(['name' => 'delete-feedback']);
Permission::firstOrCreate(['name' => 'create-feedback']);
});
test('can upload proof and close ticket in one save operation', function () {
Storage::fake('public');
// Create manager who has close permission
$managerRole = Role::create(['name' => 'manager']);
$managerRole->givePermissionTo(['close-ticket', 'view-feedback', 'update-feedback']);
$manager = User::factory()->create();
$manager->assignRole('manager');
$dept = Department::create(['name' => 'CSKH', 'manager_id' => $manager->id]);
$channel = FeedbackChannel::create(['name' => 'Email', 'slug' => 'email']);
$customer = Customer::create(['name' => 'Test Customer', 'email' => 'test@test.com']);
$feedback = Feedback::create([
'customer_id' => $customer->id,
'feedback_channel_id' => $channel->id,
'title' => 'Test Feedback',
'content' => 'Test content',
'status' => 'resolved',
'current_department_id' => $dept->id,
]);
// Mock an uploaded file on disk
$path = 'feedback-attachments/mock-proof.pdf';
Storage::disk('public')->put($path, 'dummy content');
$this->actingAs($manager);
// Run Filament form test
Livewire::test(EditFeedback::class, ['record' => $feedback->id])
->fillForm([
'status' => 'closed',
'attachment_collection' => 'proof_of_resolution',
'attachments' => [$path],
])
->call('save')
->assertHasNoFormErrors();
// Verify feedback is closed
expect($feedback->fresh()->status)->toBe('closed');
// Verify attachment exists in database
expect($feedback->attachments()->where('collection', 'proof_of_resolution')->exists())->toBeTrue();
});

View File

@@ -0,0 +1,55 @@
<?php
use App\Models\User;
use App\Models\Product;
use Illuminate\Support\Facades\File;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('export backup command exports database tables to json and writes manifest', function () {
// Create some data
User::factory()->count(3)->create();
Product::create(['name' => 'Sunrise Suite A1', 'status' => 'active']);
Product::create(['name' => 'Sunset Suite A2', 'status' => 'active']);
$backupPath = base_path('tests/Feature/backups_test');
// Clean backup directory if it exists
if (File::isDirectory($backupPath)) {
File::deleteDirectory($backupPath);
}
// Run backup command
$this->artisan('app:export-backup', [
'--path' => $backupPath,
'--tables' => 'users,products',
'--pretty' => true
])
->assertExitCode(0);
// Verify backup folder created
$directories = File::directories($backupPath);
expect($directories)->toHaveCount(1);
$backupDir = $directories[0];
// Verify files created
expect(File::exists("{$backupDir}/manifest.json"))->toBeTrue();
expect(File::exists("{$backupDir}/users.json"))->toBeTrue();
expect(File::exists("{$backupDir}/products.json"))->toBeTrue();
// Verify manifest contents
$manifest = json_decode(File::get("{$backupDir}/manifest.json"), true);
expect($manifest['tables']['users'])->toBe(3);
expect($manifest['tables']['products'])->toBe(2);
expect($manifest['total_rows'])->toBe(5);
// Verify products contents
$productsJson = json_decode(File::get("{$backupDir}/products.json"), true);
expect($productsJson)->toHaveCount(2);
expect($productsJson[0]['name'])->toBe('Sunrise Suite A1');
expect($productsJson[1]['name'])->toBe('Sunset Suite A2');
// Clean up backup directory
File::deleteDirectory($backupPath);
});

View File

@@ -0,0 +1,127 @@
<?php
use App\Models\Contract;
use App\Models\Customer;
use App\Models\Feedback;
use App\Models\FeedbackChannel;
use App\Models\FeedbackInteraction;
use App\Models\Handover;
use App\Models\Product;
use App\Models\ProductService;
use App\Models\User;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
// Seed a default user since the importer associates interactions with the first user
User::factory()->create([
'name' => 'Default Agent',
'email' => 'agent@test.com',
]);
// Create a default FeedbackChannel (ID 1) which is required by the importer
FeedbackChannel::create([
'name' => 'Email',
'slug' => 'email',
'icon' => 'heroicon-o-envelope',
'color' => '#3b82f6'
]);
});
test('cskh import command processes rows correctly in dry run and active modes', function () {
// Create temporary CSV file inside the project directory so WSL has easy access
$csvPath = base_path('tests/Feature/cskh_test.csv');
// Headers: Mã căn hộ, Họ tên KH, BÀN GIÀO CĂN HỘ, TÌNH TRẠNG, HỢP ĐỒNG THUÊ, HĐ HỢP TÁC KINH DOANH, TỰ DOANH, Phí QL, Lịch Sử khách hàng
$headers = [
'Mã căn hộ',
'Họ tên KH',
'BÀN GIÀO CĂN HỘ',
'TÌNH TRẠNG',
'HỢP ĐỒNG THUÊ',
'HĐ HỢP TÁC KINH DOANH',
'TỰ DOANH',
'Phí QL',
'Lịch Sử khách hàng'
];
$row1 = [
'A1-101',
'Nguyen Van A',
'{"date":"2026-01-01 00:00:00.000000","timezone_type":3,"timezone":"UTC"}',
'Đã bàn giao căn hộ cho KH',
'Số HĐ: HĐ-001, Giá: 10M, Thời hạn: 1 năm',
'',
'',
'Đã thu phí QL tháng 1',
"KH báo hỏng bóng đèn\nKỹ thuật đã sửa xong"
];
$row2 = [
'A1-102',
'Tran Thi B',
'',
'',
'',
'HĐ hợp tác BCC-123',
'Doanh nghiệp B',
'',
'Khách hàng thắc mắc hóa đơn tiền nước'
];
$fp = fopen($csvPath, 'w');
// Write UTF-8 BOM for Excel support
fprintf($fp, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($fp, $headers);
fputcsv($fp, $row1);
fputcsv($fp, $row2);
fclose($fp);
// 1. Dry run execution
$this->artisan('app:import-cskh', [
'file_path' => $csvPath,
'--dry-run' => true
])
->expectsOutputToContain('Total rows in file: 2')
->expectsOutputToContain('New Products')
->assertExitCode(0);
// Verify no records created in dry-run
expect(Product::count())->toBe(0);
expect(Customer::count())->toBe(0);
expect(Contract::count())->toBe(0);
expect(Handover::count())->toBe(0);
expect(ProductService::count())->toBe(0);
expect(Feedback::count())->toBe(0);
// 2. Active run execution
$this->artisan('app:import-cskh', [
'file_path' => $csvPath
])
->expectsOutputToContain('Total rows in file: 2')
->assertExitCode(0);
// Verify records created
expect(Product::count())->toBe(2);
expect(Customer::count())->toBe(2);
expect(Contract::count())->toBe(2); // 1 Lease (row1) and 1 BCC (row2)
expect(Handover::count())->toBe(1); // row1 has handover, row2 doesn't
expect(ProductService::count())->toBe(1); // row2 has Self-Business
expect(Feedback::count())->toBe(2); // both have feedback logs
expect(FeedbackInteraction::count())->toBe(3); // row1 has 2 lines, row2 has 1 line
// Verify relationships and fields
$productA = Product::where('name', 'A1-101')->first();
expect($productA)->not->toBeNull();
$handover = Handover::where('product_id', $productA->id)->first();
expect($handover)->not->toBeNull();
expect($handover->handover_date->format('Y-m-d'))->toBe('2026-01-01');
$contract = Contract::where('product_id', $productA->id)->first();
expect($contract)->not->toBeNull();
expect($contract->type->value)->toBe('lease'); // type is ContractType enum
// Clean up temporary file
@unlink($csvPath);
});

View File

@@ -119,3 +119,44 @@ test('transfer without reason throws validation error', function () {
expect(TicketTransferLog::count())->toBe(0);
});
// ─── Transfer to invalid handler should fail ─────────────────────────
test('transfer to handler not in target department throws validation error', function () {
$managerRole = Role::create(['name' => 'manager']);
$managerRole->givePermissionTo(['transfer-department', 'view-feedback', 'create-feedback', 'update-feedback']);
$sender = User::factory()->create();
$sender->assignRole('manager');
$fromDept = Department::create(['name' => 'CSKH']);
$toDept = Department::create(['name' => 'Ky Thuat']);
// Handler belongs to Finance department
$financeDept = Department::create(['name' => 'Tai Chinh']);
$handler = User::factory()->create(['department_id' => $financeDept->id]);
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-transfer']);
$customer = Customer::create(['name' => 'Test', 'email' => 't@test.com']);
$feedback = Feedback::create([
'customer_id' => $customer->id,
'feedback_channel_id' => $channel->id,
'title' => 'Transfer To Invalid Handler',
'content' => 'Test',
'status' => 'pending',
'current_department_id' => $fromDept->id,
]);
$this->actingAs($sender);
$transferService = app(TransferService::class);
try {
$transferService->transfer($feedback, $toDept, 'Chuyen sang kiem tra', $sender, $handler->id);
$this->fail('Expected ValidationException but none thrown.');
} catch (\Illuminate\Validation\ValidationException $e) {
expect($e->errors()['new_assignee_id'][0])->toContain('Người xử lý được chọn không thuộc phòng ban đích');
}
expect(TicketTransferLog::count())->toBe(0);
});