feat: AfterSales CRM - SOP compliant real-estate customer care system

- Departments + cross-department transfer workflow
- Role-based closing (staff→resolved, manager→closed) with proof_of_resolution
- Private file storage with collection system + temporary signed URLs
- Dashboard: resolved tickets table, stats, handler performance bar chart
- Spatie RBAC (admin/manager/staff)
- Notifications: TicketTransferred, TicketClosed (database + mail)
- Pest tests: 8 tests, 21 assertions
- Docker production-ready (Dockerfile + compose + entrypoint)
This commit is contained in:
2026-04-27 05:29:48 +00:00
parent 8ad7826f56
commit 887765bbd7
134 changed files with 17416 additions and 45 deletions

View File

@@ -1,7 +1,7 @@
APP_NAME=Laravel APP_NAME=AfterSalesCRM
APP_ENV=local APP_ENV=production
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
APP_LOCALE=en APP_LOCALE=en
@@ -9,23 +9,21 @@ APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12 BCRYPT_ROUNDS=12
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_STACK=single LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=warning
DB_CONNECTION=sqlite DB_CONNECTION=sqlite
# DB_CONNECTION=mysql
# DB_HOST=127.0.0.1 # DB_HOST=127.0.0.1
# DB_PORT=3306 # DB_PORT=3306
# DB_DATABASE=laravel # DB_DATABASE=minicrm
# DB_USERNAME=root # DB_USERNAME=root
# DB_PASSWORD= # DB_PASSWORD=secret
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
@@ -35,24 +33,13 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=sync
CACHE_STORE=database CACHE_STORE=file
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1 MAIL_HOST=127.0.0.1
MAIL_PORT=2525 MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"

282
AGENTS.md Normal file
View File

@@ -0,0 +1,282 @@
# AfterSales CRM - AI Agent Instructions
## Overview
AfterSales CRM is a real-estate after-sales customer care system built with **Laravel 13.6** + **Filament 5.6**.
## How to Run
```bash
cd /home/phuongtc/vibecode/minicrm
php artisan migrate:fresh --seed
php artisan serve
# Open http://localhost:8000/admin
```
### Test Accounts (seeded)
| Role | Email | Password |
|------|-------|----------|
| Admin | admin@minicrm.local | password |
| Manager | manager@minicrm.local | password |
| Staff | staff@minicrm.local | password |
## Tech Stack Versions
- PHP 8.3
- Laravel 13.6.0 (`vendor/laravel/framework`)
- Filament 5.6.1 (`vendor/filament/filament`)
- Database: SQLite (`database/database.sqlite`)
- Composer: `/home/phuongtc/composer`
- Spatie: laravel-permission (role-based auth)
- Testing: Pest PHP
## Running Commands
```bash
cd /home/phuongtc/vibecode/minicrm
php artisan make:model Foo
php artisan make:filament-resource Foo --generate
php artisan make:filament-relation-manager ResourceName relationship titleAttribute
php artisan make:migration create_foo_table
php artisan migrate
php artisan db:seed
php artisan route:list
php artisan storage:link # needed for file uploads
php artisan test # run pest tests
```
## Directory Structure
```
minicrm/
├── app/
│ ├── Filament/
│ │ ├── Resources/
│ │ │ ├── Feedback/
│ │ │ │ ├── FeedbackResource.php
│ │ │ │ ├── Schemas/FeedbackForm.php
│ │ │ │ ├── Tables/FeedbackTable.php
│ │ │ │ ├── Pages/{Create,Edit,List}Feedback.php
│ │ │ │ └── RelationManagers/InteractionsRelationManager.php
│ │ │ ├── Products/
│ │ │ ├── Customers/
│ │ │ │ └── RelationManagers/FeedbacksRelationManager.php
│ │ │ ├── FeedbackChannels/
│ │ │ └── FeedbackTags/
│ │ └── Widgets/
│ │ ├── ResolvedTicketsWidget.php # Dashboard: resolved tickets table
│ │ ├── AvgProcessingTimeWidget.php # Dashboard: stats overview
│ │ └── HandlerPerformanceWidget.php # Dashboard: bar chart per handler
│ ├── Models/
│ │ ├── User.php # HasRoles (Spatie), isAdmin(), isManager()
│ │ ├── Department.php # NEW: name, manager_id → User
│ │ ├── Product.php
│ │ ├── Customer.php
│ │ ├── CustomerProduct.php
│ │ ├── Feedback.php # added: current_department_id, is_escalated, transferLogs
│ │ ├── FeedbackChannel.php
│ │ ├── FeedbackTag.php
│ │ ├── FeedbackInteraction.php
│ │ ├── FeedbackAttachment.php # added: uuid, disk, collection
│ │ └── TicketTransferLog.php # NEW: department-to-department transfer history
│ ├── Policies/
│ │ ├── FeedbackPolicy.php # Spatie hasRole() checks
│ │ ├── ProductPolicy.php
│ │ ├── CustomerPolicy.php
│ │ └── FeedbackChannelPolicy.php
│ ├── Services/
│ │ ├── FileService.php # Upload, validation, temp URLs, collections
│ │ ├── TransferService.php # Cross-dept transfer + notification
│ │ └── ClosingService.php # Role-gated closing + evidence check
│ ├── Rules/
│ │ └── RequireProofOfResolution.php # Custom validation: proof_of_resolution required for close
│ ├── Notifications/
│ │ ├── TicketTransferred.php # DB + Mail for department transfer
│ │ └── TicketClosed.php # DB + Mail for ticket closure
│ └── Providers/Filament/
│ └── AdminPanelProvider.php
├── resources/views/filament/resources/feedback/
│ └── similar-cases.blade.php
├── database/
│ ├── migrations/
│ └── seeders/AfterSalesSeeder.php # includes departments, logs, attachments
├── tests/
│ ├── Feature/
│ │ ├── ClosingPermissionTest.php # 3 tests: staff forbid, no-evidence, with-evidence
│ │ └── TransferFlowTest.php # 2 tests: successful + missing reason
│ └── Pest.php
├── storage/app/private/feedback-attachments/ # Private disk upload directory
├── composer.json
├── PROGRESS.md
└── AGENTS.md
```
## Database Schema
### departments (NEW)
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| name | string | |
| manager_id | fk -> users nullable | Department head |
| created_at, updated_at | timestamp | |
### ticket_transfer_logs (NEW)
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| feedback_id | fk -> feedback | cascadeOnDelete |
| from_department_id | fk -> departments nullable | |
| to_department_id | fk -> departments nullable | |
| sender_id | fk -> users | Who initiated transfer |
| reason | text | Mandatory |
| created_at, updated_at | timestamp | |
### feedback (expanded)
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| customer_id | fk -> customers | cascadeOnDelete |
| customer_product_id | fk -> customer_product nullable | null = general feedback |
| feedback_channel_id | fk -> feedback_channels | |
| assigned_to | fk -> users nullable | Current handler |
| current_department_id | fk -> departments nullable | **(NEW)** Current department |
| is_escalated | boolean default false | **(NEW)** |
| title | string | |
| content | text | |
| status | string | pending, processing, resolved, closed |
| created_at, updated_at | timestamp | |
### feedback_attachments (expanded)
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| uuid | string nullable unique | **(NEW)** |
| feedback_id | fk -> feedback | |
| feedback_interaction_id | fk -> feedback_interactions nullable | |
| user_id | fk -> users | |
| name | string | |
| path | string | |
| disk | string default 'local' | **(NEW)** |
| collection | string default 'general' | **(NEW)** proof_of_resolution, customer_evidence, general |
| mime_type | string nullable | |
| size | bigint nullable | |
| created_at, updated_at | timestamp | |
### Other tables
feedback_interactions, feedback_tags, feedback_feedback_tag, products, customers, customer_product, feedback_channels, users (Spatie permission tables: roles, permissions, model_has_roles, etc.)
## Workflow
```
1. Record Feedback → Assign handler → Assign department
2. Handler contacts customer / does work
3. Handler updates → May forward to another user (assignment interaction)
4. OR: Transfer to another department → TransferDepartment Action (reason required, notification sent)
5. Loop until → Marked as "resolved" (any role)
6. Manager reviews → Close Ticket Action (requires role=admin/manager + proof_of_resolution evidence)
```
## Key Features
### Transfer Department (NEW - SOP compliant)
- Action on EditFeedback page: TransferDepartment
- Modal: select target department + reason (required) + optional attachments
- Calls TransferService: creates TicketTransferLog, updates current_department_id, resets handler to dept manager
- Sends TicketTransferred notification to target department manager
### Close Ticket (NEW - SOP compliant)
- Action on EditFeedback page: CloseTicket
- Visible only to admin/manager, only when status != 'closed'
- Requires confirmation
- Staff (403) if tries to close
- 422 if no proof_of_resolution attachment exists
- Sends TicketClosed notification to handler and department manager
### Status Change in Interactions (Updated)
- Staff: only pending, processing, resolved (no "closed" option)
- Manager/Admin: all options including "closed"
- When "closed" selected: calls ClosingService which validates evidence
### File Management (Updated - SOP compliant)
- Files stored on private `local` disk (storage/app/private/feedback-attachments/)
- Collection system: `proof_of_resolution`, `customer_evidence`, `general`
- FileService validates: jpg, png, pdf, mp4, mov, max 20MB
- Temporary signed URLs for private file access
- FileService::hasCollection() checks existence of required evidence
### Dashboard Widgets (NEW)
- ResolvedTicketsWidget: table of resolved tickets awaiting closure (manager: filtered to own department)
- AvgProcessingTimeWidget: 4 stats (resolved count, avg time, pending count, escalated count)
## Navigation Structure
```
Dashboard
├── Customer Care (group)
│ └── Feedbacks (chat-bubble-left-right, sort=1)
└── Management (group)
├── Products (building-storefront, sort=1)
├── Customers (user-group, sort=2)
├── Channels (inbox-stack, sort=3)
└── Tags (tag, sort=default)
```
Dashboard widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget (admin/manager only)
## RBAC (Role-Based Access Control) - Spatie
Roles: `admin`, `manager`, `staff`
| Permission | admin | manager | staff |
|-----------|-------|---------|-------|
| View products/customers/channels/tags | ✓ | ✓ | ✓ |
| Create/Update products/customers/channels | ✓ | ✓ | ✗ |
| Delete anything | ✓ | ✗ | ✗ |
| View feedbacks | ✓ | ✓ | ✓ |
| Create/Update feedbacks | ✓ | ✓ | ✓ |
| Delete feedbacks | ✓ | ✓ | ✗ |
| Add interactions | ✓ | ✓ | ✓ |
| Close ticket (status→closed) | ✓ | ✓ | ✗ (403) |
| Transfer department | ✓ | ✓ | ✗ |
| Dashboard widgets | ✓ | ✓ | ✗ |
## Filament Key Patterns (v5.6)
### Form components use `Filament\Forms\Components`
```php
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\RichEditor;
```
### Schema/layout components use `Filament\Schemas\Components`
```php
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
```
### Services access pattern
```php
use Illuminate\Support\Facades\App;
$service = App::make(FileService::class);
```
### Spatie role check
```php
$user->hasRole('admin');
$user->hasRole(['admin', 'manager']);
$user->assignRole('manager');
```
## Known Gotchas
1. **Navigation group icons**: Filament 5 raises exception if both a navigation group AND its child items have icons.
2. **Form component namespaces**: Form input components under `Filament\Forms\Components\*`, layout under `Filament\Schemas\Components\*`.
3. **Dynamic selects**: Use `$get` function injection, NOT `$component->getLivewire()->data[...]`.
4. **Table name**: Model `Feedback` maps to table `feedback` (singular).
5. **FileUpload with dehydrated(false)**: Attachments stored by Filament first; create FeedbackAttachment record directly in after()/afterSave()/afterCreate() — do NOT re-upload via FileService.
6. **Spatie roles in tests**: Create roles explicitly in each test (RefreshDatabase rolls back).
7. **Private disk**: Files use `local` disk (storage/app/private/); access via signed URLs from FileService.
8. **ChartWidget properties**: `$heading` and `$columnSpan` are non-static in Filament's ChartWidget.
9. **Manager closing permission**: Manager must be department head (`Department.manager_id`) to close tickets in that department.
10. **Docker**: Entrypoint auto-generates APP_KEY and runs migrations. SQLite persists via volume mount.

54
Dockerfile Normal file
View File

@@ -0,0 +1,54 @@
FROM php:8.3-fpm-alpine AS base
RUN apk add --no-cache \
nginx \
supervisor \
sqlite \
sqlite-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
oniguruma-dev \
libxml2-dev \
zip \
unzip \
curl
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
pdo \
pdo_sqlite \
pdo_mysql \
bcmath \
fileinfo \
mbstring \
exif \
gd \
intl \
opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --no-autoloader --no-scripts --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --no-dev \
&& mkdir -p storage/app/private/feedback-attachments \
&& mkdir -p storage/framework/{cache,sessions,views} \
&& mkdir -p bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/php.ini /usr/local/etc/php/conf.d/99-app.ini
COPY docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]

113
PROGRESS.md Normal file
View File

@@ -0,0 +1,113 @@
# AfterSales CRM - Tiến độ
## Trạng thái: Hoàn thành SOP — 8 giai đoạn + bug fix + polish
Đã test: Tất cả routes HTTP 200, Pest 7/7 tests pass ✓
Tham chiếu: `SOP_des.md` — Bản đặc tả hệ thống After-Sale CRM
## Công nghệ
- Laravel 13.6.0 + Filament 5.6.1 + SQLite
- PHP 8.3
- Spatie laravel-permission (RBAC)
- Pest PHP (testing)
## Tài khoản test
| Role | Email | Password |
|------|-------|----------|
| admin | admin@minicrm.local | password |
| manager | manager@minicrm.local | password |
| staff | staff@minicrm.local | password |
---
## ĐÃ HOÀN THÀNH
### 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] Nâng cấp `feedback_attachments`: uuid, disk, collection
- [x] Cài Spatie laravel-permission
### Giai đoạn 2: Services & Logic
- [x] `FileService`: upload, validation (jpg/png/pdf/mp4/mov ≤20MB), collection query, temporary URL
- [x] `TransferService`: transfer department + reason required + notification
- [x] `ClosingService`: role-gated close + proof_of_resolution check
- [x] Policies cập nhật dùng Spatie `hasRole()`
- [x] Custom Rule `RequireProofOfResolution`
### Giai đoạn 3: Notifications
- [x] `TicketTransferred` (Database + Mail)
- [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
### 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)
### Giai đoạn 6: Testing
- [x] Pest cài đặt + 5 test scenarios
- [x] `ClosingPermissionTest`: 3 tests (staff 403, no evidence 422, with evidence OK)
- [x] `TransferFlowTest`: 2 tests (success + missing reason)
### 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)
### Giai đoạn 8: Polish
- [x] AGENTS.md cập nhật đầy đủ
### Bug Fix
- [x] Dashboard widget lọc Manager dùng `Department::where('manager_id')` thay vì `auth()->id()`
- [x] Migration default disk `private``local`
- [x] File upload trong Interaction/Create/Edit dùng lưu thẳng FeedbackAttachment, không tạo UploadedFile giả
### PM (Improvements)
- [x] FeedbackTable: thêm cột Department, Escalated icon, Department filter
- [x] EditFeedback: validation `proof_of_resolution` khi đổi status sang `closed` từ form
### PASS 3 — Hoàn thiện SOP
- [x] `HandlerPerformanceWidget`: biểu đồ bar chart thời gian xử lý trung bình theo từng nhân viên
- [x] Department_Head: Manager chỉ đóng được ticket phòng mình quản lý (kèm test)
- [x] Temporary URL tích hợp vào UI: nút "View Attachments" trong Interaction History với download link
- [x] Cho phép upload attachment trong status_change interaction
- [x] Docker sẵn sàng production: Dockerfile, docker-compose, entrypoint
- [x] Migration `notifications` table
---
## Database Schema (final)
```
products customers customer_product (pivot)
feedback_channels feedback feedback_interactions
feedback_attachments feedback_tags feedback_feedback_tag (pivot)
users departments ticket_transfer_logs
permissions roles model_has_roles (spatie)
notifications sessions cache
```
## Docker Deployment
```bash
cp .env.example .env
# Edit .env: set APP_URL, optional: switch to MySQL
docker compose up -d --build
# Open http://localhost:8080/admin
```
## Local Dev
```bash
cd /home/phuongtc/vibecode/minicrm
php artisan migrate:fresh --seed
php artisan serve
# Mở http://localhost:8000/admin
php artisan test # 8 tests, 21 assertions
```
## Hướng dẫn cho AI Agent khác
Xem `AGENTS.md` - chứa đầy đủ cấu trúc thư mục, schema, namespace patterns, workflow, RBAC.

121
SOP_des.md Normal file
View File

@@ -0,0 +1,121 @@
# BẢN ĐẶC TẢ HỆ THỐNG AFTER-SALE CRM (PHIÊN BẢN NÂNG CAO)
## 1. Cấu trúc dữ liệu mở rộng (Extended Database Schema)
AI Agent cần bổ sung các bảng và trường dữ liệu sau để phục vụ việc luân chuyển và lưu trữ bằng chứng:
### A. Quản lý Tài liệu & Bằng chứng (Attachment Management)
- **`media_attachments`**:
- `id`, `uuid`, `disk`, `file_path`, `file_name`, `mime_type`, `file_size`.
- `model_type` (Ticket hoặc Interaction), `model_id`.
- `collection`: (Ví dụ: 'proof_of_resolution' - bằng chứng xử lý, 'customer_evidence' - khách hàng cung cấp).
- `uploader_id` (User ID của người tải lên).
### B. Luồng điều phối đa bộ phận (Cross-department Routing)
- **`departments`**: `id`, `name` (Kỹ thuật, Kế toán, Chăm sóc khách hàng, Pháp lý), `manager_id`.
- **`tickets` (Bổ sung)**:
- `current_department_id`: Phòng ban đang thụ lý.
- `handler_id`: Nhân viên cụ thể đang xử lý.
- `is_escalated`: Boolean (Đánh dấu nếu bị khiếu nại vượt cấp).
- **`ticket_transfer_logs`**: (Theo dõi lịch sử chuyển giao)
- `id`, `ticket_id`, `from_department_id`, `to_department_id`, `sender_id`, `reason`, `created_at`.
---
## 2. Logic luồng xử lý (Workflow Engine Logic)
AI Agent phải triển khai logic xử lý theo các ràng buộc sau:
### A. Quy tắc Chuyển tiếp (Transfer Logic)
- Khi nhân viên A (Phòng CSKH) chuyển Ticket cho Phòng Kỹ thuật:
- Hệ thống yêu cầu nhập `reason` và đính kèm tài liệu liên quan (nếu có).
- `current_department_id` cập nhật sang ID Phòng Kỹ thuật.
- `handler_id` sẽ được reset về `null` (chờ Trưởng phòng Kỹ thuật chỉ định nhân viên mới) hoặc tự động gán cho Trưởng phòng đó.
### B. Quy tắc Đóng Ticket (Closing Permissions)
- **Status: `closed`** là trạng thái cuối cùng.
- **Ràng buộc (Constraint):** * Nếu `user_role` là 'Staff', hệ thống chỉ được phép chuyển trạng thái sang `resolved` (Đã xử lý xong - chờ kiểm duyệt).
- Trạng thái `closed` chỉ khả dụng (Visible/Actionable) khi `user_role` là 'Manager' hoặc 'Department_Head'.
- Trước khi chuyển sang `closed`, hệ thống kiểm tra bắt buộc phải có ít nhất 01 tệp tin thuộc collection `proof_of_resolution`.
---
## 3. Quản lý tệp tin (Document & File Handling)
AI Agent cần thiết lập một Service Layer riêng cho việc xử lý File:
1. **Validation:** Chỉ cho phép định dạng ảnh (jpg, png), PDF và Video ngắn (<20MB).
2. **Versioning:** Nếu tải tài liệu mới lên cùng một Ticket, không được xóa tài liệu cũ, phải lưu trữ theo dạng danh sách để làm căn cứ đối chiếu.
3. **Security:** File tải lên phải được lưu trữ trong `private disk` và chỉ những người có quyền truy cập Ticket đó mới có thể Generate Temporary URL để xem.
---
## 4. Kịch bản kiểm thử cho AI Agent (Test Scripts)
### Kịch bản 1: Kiểm tra quyền Đóng Ticket (Permission Check)
- **Input:** User có role `staff` gửi request `PATCH /api/tickets/{id}` với `status: 'closed'`.
- **Expectation:** Trả về lỗi `403 Forbidden`. Thông báo: "Chỉ lãnh đạo cấp phòng mới có quyền đóng phiếu."
### Kịch bản 2: Kiểm tra bắt buộc bằng chứng (Mandatory Evidence)
- **Input:** Manager thực hiện đóng Ticket nhưng chưa tải lên bất kỳ tài liệu nào trong collection `proof_of_resolution`.
- **Expectation:** Trả về lỗi `422 Unprocessable Entity`. Thông báo: "Yêu cầu cung cấp tài liệu bằng chứng để hoàn tất quy trình."
### Kịch bản 3: Kiểm tra luồng chuyển tiếp (Transfer Flow)
- **Input:** Thực hiện `transfer` Ticket từ Phòng CSKH sang Phòng Pháp lý.
- **Expectation:** * Bản ghi mới trong `ticket_transfer_logs` được tạo.
- `current_department_id` của Ticket thay đổi thành ID Phòng Pháp lý.
- Gửi Notification (Database & Mail) cho Manager của Phòng Pháp lý.
---
## 5. Chỉ dẫn triển khai (Implementation Prompt cho AI Agent)
> "Sử dụng **Laravel 13.x****Filament v5.5**, hãy thực hiện:
>
> 1. Thiết lập **Spatie Media Library** (hoặc Custom File Service) để quản lý tài liệu gắn với Ticket và Interaction. Phân loại rõ collection 'evidence' và 'resolution_proof'.
>
> 2. Tại `TicketResource` của Filament:
>
> - Tạo một Action `Transfer Department`: Hiển thị Modal cho phép chọn Phòng ban và nhập lý do chuyển. Lưu lịch sử vào bảng `ticket_transfer_logs`.
>
> - Tạo logic ẩn/hiện nút `Close Ticket`: Chỉ hiển thị nếu User có role 'Manager'.
>
> - Sử dụng **Validation Rule** tùy chỉnh: Khi status chuyển sang `closed`, kiểm tra sự tồn tại của file trong media collection.
>
> 3. Xây dựng trang Dashboard cho Lãnh đạo phòng:
>
> - Hiển thị danh sách Ticket đang ở trạng thái `resolved` (chờ đóng).
>
> - Biểu đồ thời gian xử lý trung bình của từng nhân viên trong phòng.
>
> 4. Viết **Pest Test** bao phủ toàn bộ logic phân quyền và chuyển tiếp bộ phận."

638
UI_DESIGN_BRIEF.md Normal file
View File

@@ -0,0 +1,638 @@
# AFTERSALES CRM — UI REDESIGN BRIEF
> **Mục tiêu:** Thiết kế lại toàn bộ giao diện webapp AfterSales CRM.
> **Framework:** Laravel 13.6 + Filament 5.6 + Tailwind CSS 4
> **Yêu cầu chính:** Menu ngang (horizontal top bar) thay vì sidebar dọc để tiết kiệm không gian ngang.
> **Đối tượng người dùng:** Nhân viên CSKH, Trưởng phòng, Ban quản lý.
---
## 1. TỔNG QUAN HỆ THỐNG
AfterSales CRM là hệ thống chăm sóc khách hàng sau bán hàng trong lĩnh vực bất động sản. Luồng hoạt động:
```
Khách hàng gửi phản ánh → Nhân viên ghi nhận (Feedback) → Phân công xử lý →
Tương tác với khách → Chuyển phòng ban nếu cần → Xử lý xong (Resolved) →
Trưởng phòng kiểm tra bằng chứng → Đóng phiếu (Closed)
```
### Các Role:
| Role | Quyền chính |
|------|------------|
| **Admin** | Toàn quyền, xem tất cả dashboard, đóng mọi ticket |
| **Manager** | Quản lý phòng ban của mình, đóng ticket trong phòng mình |
| **Staff** | Ghi nhận feedback, tương tác, không được đóng ticket |
---
## 2. YÊU CẦU THIẾT KẾ
### 2.1 Navigation: MENU NGANG (Horizontal Top Bar)
**Thay đổi so với hiện tại:** Hiện tại dùng sidebar dọc bên trái. Yêu cầu chuyển thành menu ngang trên cùng.
**Cấu trúc menu:**
```
┌─────────────────────────────────────────────────────────────────────────┐
│ [Logo AfterSales CRM] Dashboard │ Customer Care ▾ │ Management ▾ [🔔] [👤 Admin ▾] │
└─────────────────────────────────────────────────────────────────────────┘
```
**Menu item chính:**
| STT | Item | Icon | Action | Note |
|-----|------|------|--------|------|
| 1 | Dashboard | `home` | `/admin` | Trang chính |
| 2 | Customer Care (dropdown) | `chat-bubble-left-right` | submenu | Dropdown xuống |
| 3 | Management (dropdown) | `cog` | submenu | Dropdown xuống |
| 4 | Notifications (icon) | `bell` | dropdown notifications | Badge số lượng chưa đọc |
| 5 | User menu (avatar + name) | `user-circle` | dropdown: Profile, Logout | |
**Dropdown "Customer Care":**
- Feedbacks (`chat-bubble-left-right`)
**Dropdown "Management":**
- Products (`building-storefront`)
- Customers (`user-group`)
- Channels (`inbox-stack`)
- Tags (`tag`)
### 2.2 Layout Tổng Thể
```
┌──────────────────────────────────────────────────────────────────────────┐
│ TOP BAR (h=56px): Logo | Nav items | Notification bell | User avatar │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ PAGE HEADER: Tiêu đề trang + Breadcrumb + Action Buttons │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐│
│ │ CONTENT AREA ││
│ │ (Form / Table / Widgets / Detail) ││
│ │ ││
│ └──────────────────────────────────────────────────────────────────────┘│
│ │
└──────────────────────────────────────────────────────────────────────────┘
```
**Yêu cầu:**
- Top bar fixed khi scroll (sticky)
- Responsive: mobile thì menu collapse thành hamburger
- Content area full-width, padding 24px
- Sidebar cũ bỏ hoàn toàn
---
## 3. TỪNG TRANG CHI TIẾT
---
### 3.1 TRANG DASHBOARD (`/admin`)
**Hiển thị cho:** Admin, Manager. Staff không thấy widget (chỉ thấy Account info).
**Bố cục grid:**
```
┌──────────────────────────────────┬──────────────────────────────────┐
│ STAT CARD 1: Resolved Tickets │ STAT CARD 2: Avg Time │
│ (success green) │ (warning amber) │
├──────────────────────────────────┼──────────────────────────────────┤
│ STAT CARD 3: Pending Tickets │ STAT CARD 4: Escalated │
│ (danger red) │ (gray) │
└──────────────────────────────────┴──────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ BAR CHART: Avg Processing Time by Handler │
│ (Trục Y: phút, Trục X: tên nhân viên) │
└──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ TABLE: Tickets Awaiting Closure │
│ # │ Ticket (link) │ Customer │ Handler │ Department │ Resolved Date │
│ 1 │ Leaking... │ Nguyen A │ Staff │ Kỹ thuật │ 15/04/2026 │
│ 2 │ AC not cool │ Tran B │ Staff │ Kỹ thuật │ 12/04/2026 │
└──────────────────────────────────────────────────────────────────────────┘
```
**Manager filter:** Manager chỉ thấy dữ liệu của phòng ban mình quản lý.
---
### 3.2 TRANG DANH SÁCH FEEDBACK (`/admin/feedbacks`)
**Page Header:**
- Title: "Feedbacks"
- Breadcrumb: Dashboard > Feedbacks
- Action button: [+ New Feedback] (primary, góc phải)
**Filters Bar (ngang dưới header):**
| Filter | Loại | Options |
|--------|------|---------|
| Status | Select | Pending / Processing / Resolved / Closed |
| Channel | Select | Email / Zalo / Phone / Document / In Person |
| Department | Select | CSKH / Kỹ thuật / Kế toán / Pháp lý |
| Handler | Select | Danh sách user |
| [Reset Filters] button | | |
**Table:**
| # | Title | Customer | Product | Channel | Tags | Status | Dept | Handler | Escal. | Created |
|---|-------|----------|---------|---------|------|--------|------|---------|--------|---------|
| | (search) | (search) | "General" nếu null | | badge list | badge màu | toggleable | toggleable | ⚠️/✅ | toggleable |
**Status badge colors:**
- Pending: amber/yellow `#f59e0b`
- Processing: blue `#3b82f6`
- Resolved: green `#10b981`
- Closed: gray `#6b7280`
**Row hover + clickable:** Click row → mở Edit page. Hoặc nút Edit cuối hàng.
**Bulk Actions (checkbox đầu hàng):**
- Delete Selected
- Export (tùy chọn)
---
### 3.3 TRANG TẠO FEEDBACK (`/admin/feedbacks/create`)
**Form layout 2 cột:**
```
┌──────────────────────────────────┬──────────────────────────────────┐
│ Customer * (search dropdown) │ Kênh tiếp nhận *
├──────────────────────────────────┼──────────────────────────────────┤
│ ☐ General feedback │ │
├──────────────────────────────────┼──────────────────────────────────┤
│ Product (chỉ hiện nếu bỏ chọn │ │
│ "General", lọc theo customer) │ │
├──────────────────────────────────┴──────────────────────────────────┤
│ Title * │
├─────────────────────────────────────────────────────────────────────┤
│ Content * (textarea 6 dòng, full width) │
├─────────────────────────────────────────────────────────────────────┤
│ Attachment Collection: [General ▾] │
│ 📎 Upload files (drag & drop, multiple, max 20MB) │
├─────────────────────────────────────────────────────────────────────┤
│ Tags (multi-select, có thể tạo mới tag inline) │
├──────────────────────────────────┬──────────────────────────────────┤
│ Status * (mặc định: Pending) │ Assign To │
├──────────────────────────────────┼──────────────────────────────────┤
│ Department │ ☐ Escalated (chỉ admin/manager │
│ │ mới thấy) │
└──────────────────────────────────┴──────────────────────────────────┘
[Cancel] [Create Feedback]
```
**Attachment Collection options:**
- General (mặc định)
- Proof of Resolution (bằng chứng xử lý)
- Customer Evidence (bằng chứng khách hàng cung cấp)
**File validation:** Chỉ chấp nhận jpg, png, pdf, mp4, mov. Max 20MB/file.
**Lưu ý:** Sau khi tạo, tự động ghi 1 interaction "Feedback created via [kênh]".
---
### 3.4 TRANG EDIT FEEDBACK (`/admin/feedbacks/{id}/edit`)
**QUAN TRỌNG:** Đây là trang phức tạp nhất, gồm 3 phần + 2 tab.
**Page Header (sticky khi scroll):**
```
┌─ Breadcrumb: Dashboard > Feedbacks > [Title] ──────────────────────────┐
│ │
│ [← Back] Status: [PENDING ●] Dept: CSP Handler: Staff │
│ │
│ [Transfer Dept] [Close Ticket] [Save] [Delete] │
└─────────────────────────────────────────────────────────────────────────┘
```
**3 Action buttons quan trọng ở header:**
#### Action 1: Transfer Department (chỉ Admin, Manager thấy)
```
┌─────────────────────────────────────────┐
│ Transfer Department │
│ │
│ Target Department * [Kỹ thuật ▾] │
│ Assign To [── select ──▾] │
│ Reason * [___________] │
│ Attachments 📎 Upload files │
│ │
│ [Cancel] [Transfer] │
└─────────────────────────────────────────┘
```
- Reason bắt buộc (nếu để trống: báo lỗi)
- Sau khi transfer: gửi notification cho Manager phòng nhận, cập nhật department, reset handler
#### Action 2: Close Ticket (chỉ Admin, Manager thấy, và status ≠ closed)
```
┌─────────────────────────────────────────┐
│ ⚠️ Bạn có chắc muốn đóng phiếu này? │
│ Hành động này KHÔNG thể hoàn tác. │
│ │
│ [Cancel] [Close] │
└─────────────────────────────────────────┘
```
- Chỉ đóng được nếu có ít nhất 1 file trong collection "proof_of_resolution"
- Manager chỉ đóng được ticket thuộc phòng mình quản lý
- Nếu thiếu bằng chứng: hiển thị lỗi "Yêu cầu cung cấp tài liệu bằng chứng"
- Nếu sai phòng ban: hiển thị lỗi "Bạn chỉ có thể đóng phiếu thuộc phòng ban mình quản lý"
#### Action 3: Delete
---
**Body: 3 TAB layout**
```
┌──────────────────────────────────────────────────────┐
│ [📝 Edit Details] [💬 Interaction History (5)] │
└──────────────────────────────────────────────────────┘
```
#### TAB 1: Edit Details
Form giống hệt Create Page, có thêm pre-fill dữ liệu hiện có.
#### TAB 2: Interaction History (Timeline)
Đây là tab QUAN TRỌNG NHẤT. Thiết kế dạng timeline dọc.
**Header của tab:**
```
┌──────────────────────────────────────────────────────────────┐
│ Interaction History [+ Add Note ▾] │
│ │
│ Dropdown khi click "Add Note": │
│ • 📝 Add Note │
│ • 🔄 Change Status │
│ • 👤 Assign / Forward │
└──────────────────────────────────────────────────────────────┘
```
**Modal "Add Interaction" (mở khi chọn 1 trong 3 action trên):**
```
┌──────────────────────────────────────────────────────────┐
│ Type: [📝 Note ▾ | 🔄 Status Change | 👤 Assignment] │
│ │
│ Title (chỉ hiện khi type=Note) [___________] │
│ │
│ Content * (Rich Editor, full width) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ B I U • ≡ 🔗 🖼 │ │
│ │ │ │
│ │ │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ --- Các field theo type --- │
│ │
│ Nếu Status Change: │
│ New Status * [Pending ▾] │
│ (Staff chỉ thấy: Pending/Processing/Resolved) │
│ (Admin/Manager thấy thêm: Closed) │
│ │
│ Nếu Assignment: │
│ Assign To * [── chọn user ──▾] │
│ Target Department [── chọn phòng ──▾] │
│ │
│ 📎 Attachments (luôn hiện, mọi type) │
│ [Upload files...] │
│ │
│ [Cancel] [Add Interaction] │
└──────────────────────────────────────────────────────────┘
```
**Timeline hiển thị (dạng dọc, newest first):**
```
┌── 15/04/2026 ──────────────────────────────────────────────────────┐
│ │
│ ● 14:30 👤 Staff │
│ │ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ [Status Change] 🔵 │ │
│ │ │ Technician visited site. AC repaired. │ │
│ │ │ Status: processing → resolved │ │
│ │ │ 📎 2 files [View ▾] │ │
│ │ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ● 10:15 👤 Manager │
│ │ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ [Assignment] 🟡 │ │
│ │ │ Chuyển sang Phòng Kỹ thuật để kiểm tra điều hòa. │ │
│ │ │ Assignment: Staff → Manager │ │
│ │ │ Department: CSKH → Kỹ thuật │ │
│ │ │ 📎 1 file [View ▾] │ │
│ │ └─────────────────────────────────────────────────────────┘ │
│ │
└── 14/04/2026 ──────────────────────────────────────────────────────┘
│ ● 09:00 👤 Admin
│ │ ┌─────────────────────────────────────────────────────────┐
│ │ │ [Note] ⚪ │
│ │ │ Feedback received via Email. │
│ │ │ Status: — → pending │
│ │ └─────────────────────────────────────────────────────────┘
```
**Type badge colors:**
- Note: gray `#6b7280`
- Status Change: blue `#3b82f6`
- Assignment: amber/orange `#f59e0b`
**Khi click [View ▾] trên file attachment:**
```
┌───────────────────────────────────────────────┐
│ Attachments │
│ │
│ File │ Collection │ Size │ │
│ repair.pdf │ proof_of_resol. │200KB │[⬇] │
│ photo.jpg │ customer_evid. │500KB │[⬇] │
│ │
│ Links expire after 60 minutes. │
└───────────────────────────────────────────────┘
```
- Collection badge: proof_of_resolution → green, customer_evidence → blue, general → gray
- Download button → mở signed URL (có thời hạn 60 phút) trong tab mới
---
**Below tabs: Similar Cases section**
```
┌──────────────────────────────────────────────────────────────────┐
│ 🔗 Similar Cases (shared tags) │
│ │
│ Title │ Customer │ Status │ Handler │ Date │
│ Leaking bathroom │ Tran B │ Processing│ Manager │10/04/26 │
│ Water damage │ Le Van C │ Pending │ Staff │08/04/26 │
│ │
│ (Chỉ hiện nếu có ≥1 feedback cùng tag, khác ID hiện tại, max 5) │
└──────────────────────────────────────────────────────────────────┘
```
---
### 3.5 TRANG SẢN PHẨM (`/admin/products`)
**Table:**
| Name | Status | Description | Owners | Created |
|------|--------|-------------|--------|---------|
| (search) | badge: active🟢 / inactive⚪ / sold out🔴 | toggleable, limit 50 | count | toggleable |
**Form (2 cột):**
```
Name * Status * [Active ▾]
Description (textarea full width)
```
---
### 3.6 TRANG KHÁCH HÀNG (`/admin/customers`)
**Table:**
| Name | Email | Phone | Status | Products | Feedbacks | Created |
|------|-------|-------|--------|----------|-----------|---------|
| (search) | toggleable | toggleable | badge | count | count | toggleable |
**Form (2 cột):**
```
Name * Email
Phone Status * [Active ▾]
Address (textarea full width)
── Owned Products ──
Products (multi-select) [Sunrise A1 ✕] [Green Valley ✕] [ add]
```
**Tab "Feedback History" trên Edit Customer:**
```
┌─────────────────┬─────────┬────────┬──────┬─────────┬────────────┬────────┐
│ Title (link ▶) │ Product │ Status │ Tags │ Channel │Interactions│ Created │
└─────────────────┴─────────┴────────┴──────┴─────────┴────────────┴────────┘
```
- Title là clickable link → mở Edit Feedback
- Filters: Status, Tags
---
### 3.7 TRANG KÊNH TIẾP NHẬN (`/admin/channels`)
**Table:**
| Name | Slug | Active | Feedbacks | Created |
|------|------|--------|-----------|---------|
| (search) | (search) | ✅/❌ | count | toggleable |
**Form (2 cột):**
```
Name * Slug *
Icon [________] ☑ Active (toggle, default true)
```
---
### 3.8 TRANG TAGS (`/admin/tags`)
**Table:**
| Name | Slug | Color | Created | Updated |
|------|------|-------|---------|---------|
| (search) | (search) | (search) | toggleable | toggleable |
**Form:**
```
Name * Slug * Color [________]
```
---
## 4. HỆ THỐNG THÔNG BÁO
### Notification Bell (góc phải top bar)
Khi có thông báo mới → badge đỏ hiển thị số lượng.
**Dropdown notifications:**
```
┌─────────────────────────────────────────────────────┐
│ 🔔 Notifications Mark all read│
│─────────────────────────────────────────────────────│
│ 🔵 Ticket #3 "AC not cooling" đã được chuyển │
│ đến phòng của bạn bởi Manager. │
│ ⏱ 5 phút trước [Xem ▶] │
│─────────────────────────────────────────────────────│
│ ⚪ Ticket #5 "Kitchen hinge" đã được đóng │
│ bởi Staff. │
│ ⏱ 1 giờ trước [Xem ▶] │
│─────────────────────────────────────────────────────│
│ [Xem tất cả thông báo] │
└─────────────────────────────────────────────────────┘
```
- Chưa đọc: background xanh nhạt, có chấm 🔵
- Đã đọc: background trắng
- [Xem ▶] → mở ticket trong tab mới
---
## 5. MÀU SẮC & DESIGN TOKENS
### Bảng màu chính
| Token | Màu | Dùng cho |
|-------|-----|----------|
| Primary | `#3b82f6` (blue-500) | Buttons chính, links, menu active |
| Success | `#10b981` (emerald-500) | Status resolved, badges proof |
| Warning | `#f59e0b` (amber-500) | Status pending, assignment badges |
| Danger | `#ef4444` (red-500) | Delete buttons, escalated flags |
| Info | `#3b82f6` (blue-500) | Status processing, status_change badges |
| Gray | `#6b7280` (gray-500) | Note badges, closed status, muted text |
| Background | `#f9fafb` (gray-50) | Page background |
| Card | `#ffffff` | Card/panel backgrounds |
| Border | `#e5e7eb` (gray-200) | Dividers, table borders |
### Typography
- Font: System font stack (Inter hoặc SF Pro)
- Headings: font-semibold
- Body: font-normal, text-sm (14px)
- Small: text-xs (12px)
### Shadows
- Card: `shadow-sm` (0 1px 2px)
- Modal: `shadow-xl` (0 20px 25px)
- Top bar: `shadow-sm` + `border-b`
---
## 6. RESPONSIVE BREAKPOINTS
| Breakpoint | Width | Layout |
|------------|-------|--------|
| Mobile | <640px | Menu hamburger, form 1 cột, table scroll ngang |
| Tablet | 640-1024px | Menu rút gọn, form 2 cột |
| Desktop | >1024px | Full layout, menu đầy đủ |
**Lưu ý mobile:**
- Top bar: logo + hamburger menu
- Table: scroll ngang (horizontal scroll)
- Form: chuyển 2 cột thành 1 cột
- Stat cards: chuyển 4 cột → 2 cột → 1 cột
- Modal: full-screen trên mobile
---
## 7. DARK MODE
Hỗ trợ dark mode toggle (nút ☀️/🌙 trong top bar).
| Element | Light | Dark |
|---------|-------|------|
| Background | `bg-gray-50` | `bg-gray-950` |
| Card | `bg-white` | `bg-gray-900` |
| Text chính | `text-gray-900` | `text-gray-100` |
| Text phụ | `text-gray-500` | `text-gray-400` |
| Border | `border-gray-200` | `border-gray-800` |
| Top bar | `bg-white border-b` | `bg-gray-900 border-gray-800` |
---
## 8. CÁC LƯU Ý QUAN TRỌNG KHI THIẾT KẾ
### A. Không được bỏ sót
1. **Form Feedback có toggle "General feedback"** — khi bật, ẩn field Product, set `customer_product_id = null`
2. **Form Edit Feedback có field `is_escalated`** — chỉ admin/manager mới thấy
3. **Dropdown status trong Interaction** — staff KHÔNG thấy option "Closed"
4. **Collection selector cho file upload** — 3 lựa chọn: General / Proof of Resolution / Customer Evidence
5. **Similar Cases** — chỉ hiện khi có tag chung, max 5 items
6. **View Attachments modal** — có collection badge màu, download link hết hạn 60 phút
### B. Điều kiện hiển thị
- **Transfer Department button:** Chỉ admin + manager
- **Close Ticket button:** Chỉ admin + manager, và status ≠ closed
- **Dashboard widgets:** Chỉ admin + manager
- **Escalated toggle:** Chỉ admin + manager
- **Role-based status options:** Staff không thấy "Closed"
### C. Validation
- Transfer yêu cầu reason (không được để trống)
- Close ticket yêu cầu có proof_of_resolution attachment
- Manager chỉ đóng được ticket phòng mình quản lý
- File upload: jpg/png/pdf/mp4/mov, max 20MB
### D. Tương tác ngầm
- Khi chọn Customer → Product dropdown tự lọc theo customer đó
- Khi chọn type="Status Change" → hiện New Status dropdown
- Khi chọn type="Assignment" → hiện Assign To + Department dropdown
- Sau khi Transfer Department → tự động gán handler = Manager của phòng mới (nếu không chọn ai)
### E. SEO/Meta
- Title: "AfterSales CRM"
- Favicon: icon phù hợp
---
## 9. FILE STRUCTURE (để A.I agent biết code ở đâu)
```
app/Filament/
Resources/
Feedback/
Pages/EditFeedback.php ← Header actions (Transfer, Close, Delete)
Schemas/FeedbackForm.php ← Form 13 fields
Tables/FeedbackTable.php ← Table 10 columns
RelationManagers/
InteractionsRelationManager.php ← Timeline + View Attachments
Customers/
CustomerResource.php ← Form + Table
RelationManagers/
FeedbacksRelationManager.php ← Feedback history tab
Products/ProductResource.php
FeedbackChannels/FeedbackChannelResource.php
FeedbackTags/FeedbackTagResource.php
Widgets/
ResolvedTicketsWidget.php
AvgProcessingTimeWidget.php
HandlerPerformanceWidget.php
Providers/Filament/AdminPanelProvider.php ← Panel config, nav groups
resources/views/filament/resources/feedback/
similar-cases.blade.php ← Similar Cases HTML
attachment-links.blade.php ← Attachment links modal HTML
```
---
## 10. TÓM TẮT CÁC MÀN HÌNH
| # | Màn hình | Route | Thành phần chính |
|---|----------|-------|-----------------|
| 1 | Dashboard | `/admin` | 4 stat cards + chart + resolved table |
| 2 | Login | `/admin/login` | Email + Password + Login button |
| 3 | Feedback List | `/admin/feedbacks` | Table 10 cột + 4 filters + Create button |
| 4 | Create Feedback | `/admin/feedbacks/create` | Form 13 fields, 2 cột |
| 5 | Edit Feedback | `/admin/feedbacks/{id}/edit` | Form + 3 header actions + Timeline tab + Similar Cases |
| 6 | Product List | `/admin/products` | Table 5 cột + status filter |
| 7 | Create/Edit Product | `/admin/products/...` | Form 3 fields |
| 8 | Customer List | `/admin/customers` | Table 7 cột + status filter |
| 9 | Create/Edit Customer | `/admin/customers/...` | Form 6 fields + Feedback History tab |
| 10 | Channel List | `/admin/channels` | Table 5 cột |
| 11 | Create/Edit Channel | `/admin/channels/...` | Form 4 fields |
| 12 | Tag List | `/admin/tags` | Table 5 cột |
| 13 | Create/Edit Tag | `/admin/tags/...` | Form 3 fields |
---
**Tổng số màn hình: 13 màn hình | 5 Resource | 3 Widget | 2 Relation Manager | 2 Blade View**
File này đủ chi tiết để một AI Agent khác thiết kế lại toàn bộ giao diện mà không bỏ sót tính năng nào.

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\Customers;
use App\Filament\Resources\Customers\Pages\CreateCustomer;
use App\Filament\Resources\Customers\Pages\EditCustomer;
use App\Filament\Resources\Customers\Pages\ListCustomers;
use App\Filament\Resources\Customers\RelationManagers\FeedbacksRelationManager;
use App\Filament\Resources\Customers\Schemas\CustomerForm;
use App\Filament\Resources\Customers\Tables\CustomersTable;
use App\Models\Customer;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class CustomerResource extends Resource
{
protected static ?string $model = Customer::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUserGroup;
protected static ?string $pluralLabel = 'Customers';
protected static ?int $navigationSort = 2;
public static function getNavigationGroup(): ?string
{
return 'Management';
}
public static function form(Schema $schema): Schema
{
return CustomerForm::configure($schema);
}
public static function table(Table $table): Table
{
return CustomersTable::configure($table);
}
public static function getRelations(): array
{
return [
FeedbacksRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListCustomers::route('/'),
'create' => CreateCustomer::route('/create'),
'edit' => EditCustomer::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Customers\Pages;
use App\Filament\Resources\Customers\CustomerResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCustomer extends CreateRecord
{
protected static string $resource = CustomerResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Customers\Pages;
use App\Filament\Resources\Customers\CustomerResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditCustomer extends EditRecord
{
protected static string $resource = CustomerResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Customers\Pages;
use App\Filament\Resources\Customers\CustomerResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListCustomers extends ListRecords
{
protected static string $resource = CustomerResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Filament\Resources\Customers\RelationManagers;
use App\Filament\Resources\Feedback\FeedbackResource;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class FeedbacksRelationManager extends RelationManager
{
protected static string $relationship = 'feedbacks';
protected static ?string $title = 'Feedback History';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('title')
->columns([
TextColumn::make('title')
->searchable()
->url(fn ($record) => FeedbackResource::getUrl('edit', ['record' => $record])),
TextColumn::make('customerProduct.product.name')
->label('Product')
->placeholder('General'),
TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'pending' => 'warning',
'processing' => 'info',
'resolved' => 'success',
'closed' => 'gray',
default => 'gray',
}),
TextColumn::make('tags.name')
->badge(),
TextColumn::make('feedbackChannel.name')
->label('Channel'),
TextColumn::make('interactions_count')
->counts('interactions')
->label('Interactions'),
TextColumn::make('created_at')
->dateTime('d/m/Y')
->sortable(),
])
->filters([
SelectFilter::make('status')
->options([
'pending' => 'Pending',
'processing' => 'Processing',
'resolved' => 'Resolved',
'closed' => 'Closed',
]),
SelectFilter::make('tags')
->relationship('tags', 'name'),
])
->defaultSort('created_at', 'desc')
->headerActions([])
->recordActions([]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Customers\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class CustomerForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Customer Information')
->schema([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('email')
->email()
->maxLength(255),
TextInput::make('phone')
->tel()
->maxLength(255),
Textarea::make('address')
->columnSpanFull(),
Select::make('status')
->options([
'active' => 'Active',
'inactive' => 'Inactive',
])
->default('active')
->required(),
])
->columns(2),
Section::make('Owned Products')
->schema([
Select::make('products')
->relationship('products', 'name')
->multiple()
->preload()
->columnSpanFull(),
]),
]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Filament\Resources\Customers\Tables;
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 CustomersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('email')
->searchable()
->toggleable(),
TextColumn::make('phone')
->searchable()
->toggleable(),
TextColumn::make('status')
->badge()
->color(fn(string $state): string => match ($state) {
'active' => 'success',
'inactive' => 'gray',
default => 'gray',
}),
TextColumn::make('products_count')
->counts('products')
->label('Products'),
TextColumn::make('feedbacks_count')
->counts('feedbacks')
->label('Feedbacks'),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(),
])
->filters([
SelectFilter::make('status')
->options([
'active' => 'Active',
'inactive' => 'Inactive',
]),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\Feedback;
use App\Filament\Resources\Feedback\Pages\CreateFeedback;
use App\Filament\Resources\Feedback\Pages\EditFeedback;
use App\Filament\Resources\Feedback\Pages\ListFeedback;
use App\Filament\Resources\Feedback\RelationManagers\InteractionsRelationManager;
use App\Filament\Resources\Feedback\Schemas\FeedbackForm;
use App\Filament\Resources\Feedback\Tables\FeedbackTable;
use App\Models\Feedback;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class FeedbackResource extends Resource
{
protected static ?string $model = Feedback::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
protected static ?string $pluralLabel = 'Feedbacks';
protected static ?int $navigationSort = 1;
public static function getNavigationGroup(): ?string
{
return 'Customer Care';
}
public static function form(Schema $schema): Schema
{
return FeedbackForm::configure($schema);
}
public static function table(Table $table): Table
{
return FeedbackTable::configure($table);
}
public static function getRelations(): array
{
return [
InteractionsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListFeedback::route('/'),
'create' => CreateFeedback::route('/create'),
'edit' => EditFeedback::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Filament\Resources\Feedback\Pages;
use App\Filament\Resources\Feedback\FeedbackResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Storage;
class CreateFeedback extends CreateRecord
{
protected static string $resource = FeedbackResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
if (($data['is_general'] ?? false) === true) {
$data['customer_product_id'] = null;
}
unset($data['is_general'], $data['attachment_collection']);
return $data;
}
protected function afterCreate(): void
{
$feedback = $this->getRecord();
$attachments = $this->data['attachments'] ?? [];
$collection = $this->data['attachment_collection'] ?? 'general';
$feedback->interactions()->create([
'user_id' => auth()->id(),
'type' => 'note',
'content' => 'Feedback created via ' . ($feedback->feedbackChannel?->name ?? 'unknown channel'),
'changes' => ['status' => ['old' => null, 'new' => $feedback->status]],
]);
if (! empty($attachments)) {
$disk = Storage::disk('local');
foreach ($attachments as $path) {
$feedback->attachments()->create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'user_id' => auth()->id(),
'name' => basename($path),
'path' => $path,
'disk' => 'local',
'collection' => $collection,
'mime_type' => $disk->mimeType($path),
'size' => $disk->size($path),
]);
}
}
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Filament\Resources\Feedback\Pages;
use App\Filament\Resources\Feedback\FeedbackResource;
use App\Models\Department;
use App\Services\ClosingService;
use App\Services\TransferService;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Storage;
class EditFeedback extends EditRecord
{
protected static string $resource = FeedbackResource::class;
protected function getHeaderActions(): array
{
return [
$this->transferDepartmentAction(),
$this->closeTicketAction(),
DeleteAction::make(),
];
}
protected function transferDepartmentAction(): Action
{
return Action::make('transferDepartment')
->label('Transfer Department')
->icon('heroicon-o-arrows-right-left')
->color('warning')
->visible(fn (): bool => auth()->user()->hasRole(['admin', 'manager']))
->form([
Select::make('to_department_id')
->label('Target Department')
->options(Department::pluck('name', 'id')->toArray())
->required()
->searchable(),
Select::make('new_assignee_id')
->label('Assign To (optional)')
->options(function (\Filament\Forms\Get $get) {
$deptId = $get('to_department_id');
if (! $deptId) {
return [];
}
$dept = Department::with('manager')->find($deptId);
return \App\Models\User::pluck('name', 'id')->toArray();
})
->searchable()
->nullable(),
Textarea::make('reason')
->label('Reason')
->required()
->rows(3),
FileUpload::make('transfer_attachments')
->label('Attachments')
->multiple()
->disk('local')
->directory('feedback-attachments')
->columnSpanFull(),
])
->action(function (array $data): void {
$feedback = $this->getRecord();
$toDepartment = Department::findOrFail($data['to_department_id']);
$sender = auth()->user();
$transferService = App::make(TransferService::class);
$log = $transferService->transfer(
$feedback,
$toDepartment,
$data['reason'],
$sender,
$data['new_assignee_id'] ?? null,
);
if (! empty($data['transfer_attachments'])) {
$fileService = App::make(FileService::class);
$fileService->upload(
$data['transfer_attachments'],
'general',
$feedback,
$sender->id,
);
}
Notification::make()
->title('Transferred successfully')
->success()
->send();
$this->redirect($this->getResource()::getUrl('edit', ['record' => $feedback]));
});
}
protected function closeTicketAction(): Action
{
return Action::make('closeTicket')
->label('Close Ticket')
->icon('heroicon-o-check-circle')
->color('success')
->visible(fn (): bool => $this->getRecord()->status !== 'closed' && auth()->user()->hasRole(['admin', 'manager']))
->requiresConfirmation()
->modalHeading('Close Ticket')
->modalDescription('Bạn có chắc muốn đóng phiếu này? Hành động này KHÔNG thể hoàn tác.')
->modalSubmitActionLabel('Close')
->action(function (): void {
$feedback = $this->getRecord();
$actor = auth()->user();
$closingService = App::make(ClosingService::class);
try {
$closingService->close($feedback, $actor);
Notification::make()
->title('Ticket closed successfully')
->success()
->send();
$this->redirect($this->getResource()::getUrl('edit', ['record' => $feedback]));
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();
} catch (\Illuminate\Validation\ValidationException $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();
}
});
}
public function getFooter(): ?View
{
return view('filament.resources.feedback.similar-cases');
}
protected function mutateFormDataBeforeSave(array $data): array
{
if (($data['is_general'] ?? false) === true) {
$data['customer_product_id'] = null;
}
unset($data['is_general'], $data['attachment_collection']);
$newStatus = $data['status'] ?? null;
if ($newStatus === 'closed' && $this->getRecord()->status !== 'closed') {
$closingService = App::make(ClosingService::class);
$closingService->close($this->getRecord(), auth()->user());
}
return $data;
}
protected function afterSave(): void
{
$attachments = $this->data['attachments'] ?? [];
$collection = $this->data['attachment_collection'] ?? 'general';
if (! empty($attachments)) {
$disk = Storage::disk('local');
$feedback = $this->getRecord();
foreach ($attachments as $path) {
$feedback->attachments()->create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'user_id' => auth()->id(),
'name' => basename($path),
'path' => $path,
'disk' => 'local',
'collection' => $collection,
'mime_type' => $disk->mimeType($path),
'size' => $disk->size($path),
]);
}
}
}
protected function mutateFormDataBeforeFill(array $data): array
{
$data['is_general'] = $data['customer_product_id'] === null;
return $data;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Feedback\Pages;
use App\Filament\Resources\Feedback\FeedbackResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListFeedback extends ListRecords
{
protected static string $resource = FeedbackResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,258 @@
<?php
namespace App\Filament\Resources\Feedback\RelationManagers;
use App\Models\Department;
use App\Models\FeedbackInteraction;
use App\Models\User;
use App\Services\ClosingService;
use App\Services\FileService;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Illuminate\Support\Facades\Storage;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
class InteractionsRelationManager extends RelationManager
{
protected static string $relationship = 'interactions';
protected static ?string $title = 'Interaction History';
public function form(Schema $schema): Schema
{
return $schema
->components([
Select::make('type')
->options([
'note' => 'Note',
'status_change' => 'Status Change',
'assignment' => 'Assignment / Forward',
])
->default('note')
->required()
->live(),
TextInput::make('title')
->maxLength(255)
->visible(fn ($get): bool => $get('type') === 'note'),
RichEditor::make('content')
->required()
->columnSpanFull(),
Select::make('new_status')
->label('New Status')
->options(fn (): array => auth()->user()->hasRole(['admin', 'manager'])
? ['pending' => 'Pending', 'processing' => 'Processing', 'resolved' => 'Resolved', 'closed' => 'Closed']
: ['pending' => 'Pending', 'processing' => 'Processing', 'resolved' => 'Resolved'],
)
->visible(fn ($get): bool => $get('type') === 'status_change'),
Select::make('new_assignee')
->label('Assign To')
->options(User::pluck('name', 'id')->toArray())
->searchable()
->visible(fn ($get): bool => $get('type') === 'assignment'),
Select::make('department_id')
->label('Target Department')
->options(Department::pluck('name', 'id')->toArray())
->searchable()
->nullable()
->visible(fn ($get): bool => $get('type') === 'assignment'),
FileUpload::make('attachments')
->label('Attachments')
->multiple()
->disk('local')
->directory('feedback-attachments')
->columnSpanFull(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('content')
->columns([
TextColumn::make('created_at')
->dateTime('d/m/Y H:i')
->sortable(),
TextColumn::make('user.name')
->label('By'),
TextColumn::make('type')
->badge()
->formatStateUsing(fn (string $state): string => match ($state) {
'note' => 'Note',
'status_change' => 'Status Change',
'assignment' => 'Assignment',
default => $state,
})
->color(fn (string $state): string => match ($state) {
'note' => 'gray',
'status_change' => 'info',
'assignment' => 'warning',
default => 'gray',
}),
TextColumn::make('content')
->html()
->limit(80)
->wrap(),
TextColumn::make('changes')
->label('Details')
->formatStateUsing(function (?array $state): string {
if (! $state) return '';
$lines = [];
foreach ($state as $field => $values) {
if (is_array($values)) {
$lines[] = "<b>{$field}</b>: {$values['old']}{$values['new']}";
}
}
return implode('<br>', $lines);
})
->html(),
TextColumn::make('attachments_count')
->label('Files')
->counts('attachments')
->badge()
->color(fn (int $state): string => $state > 0 ? 'primary' : 'gray'),
])
->defaultSort('created_at', 'desc')
->recordActions([
Action::make('viewAttachments')
->label('View Attachments')
->icon('heroicon-o-paper-clip')
->modalHeading('Attachments')
->modalContent(function (FeedbackInteraction $record): \Illuminate\Contracts\View\View {
$fileService = App::make(FileService::class);
$attachments = $record->attachments;
$links = $attachments->map(function ($a) use ($fileService) {
return [
'name' => $a->name,
'url' => $fileService->getTemporaryUrl($a, 60),
'size' => $a->size ? round($a->size / 1024, 1) . ' KB' : 'N/A',
'collection' => $a->collection,
];
});
return view('filament.resources.feedback.attachment-links', [
'links' => $links,
]);
})
->visible(fn (FeedbackInteraction $record): bool => $record->attachments()->count() > 0),
])
->headerActions([
CreateAction::make()
->label('Add Interaction')
->icon('heroicon-o-plus')
->mutateFormDataUsing(function (array $data, $livewire): array {
$data['user_id'] = auth()->id();
$data['feedback_id'] = $livewire->getOwnerRecord()->id;
if ($data['type'] === 'status_change' && isset($data['new_status'])) {
$feedback = $livewire->getOwnerRecord();
if ($data['new_status'] === 'closed') {
$this->handleClosingViaService($feedback, auth()->user(), $data);
}
$data['changes'] = [
'status' => ['old' => $feedback->status, 'new' => $data['new_status']],
];
$feedback->update(['status' => $data['new_status']]);
unset($data['new_status']);
}
if ($data['type'] === 'assignment' && isset($data['new_assignee'])) {
$feedback = $livewire->getOwnerRecord();
$oldAssignee = $feedback->assignedTo?->name ?? 'Unassigned';
$newAssignee = User::find($data['new_assignee'])?->name ?? 'Unknown';
$data['changes'] = [
'assignment' => ['old' => $oldAssignee, 'new' => $newAssignee],
];
$update = ['assigned_to' => $data['new_assignee']];
if (! empty($data['department_id'])) {
$update['current_department_id'] = $data['department_id'];
$dept = Department::find($data['department_id']);
$data['changes']['department'] = [
'old' => $feedback->currentDepartment?->name ?? 'N/A',
'new' => $dept?->name ?? 'N/A',
];
}
$feedback->update($update);
unset($data['new_assignee'], $data['department_id']);
}
$data['_attachments'] = $data['attachments'] ?? [];
unset($data['attachments'], $data['title']);
return $data;
})
->after(function (FeedbackInteraction $record, array $data): void {
$attachments = $data['_attachments'] ?? [];
if (! empty($attachments)) {
$disk = Storage::disk('local');
foreach ($attachments as $path) {
$record->attachments()->create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'feedback_id' => $record->feedback_id,
'user_id' => auth()->id(),
'name' => basename($path),
'path' => $path,
'disk' => 'local',
'collection' => 'general',
'mime_type' => $disk->mimeType($path),
'size' => $disk->size($path),
]);
}
}
if ($record->type === 'assignment') {
Notification::make()
->title('Feedback assigned successfully')
->success()
->send();
}
if ($record->type === 'status_change') {
Notification::make()
->title('Status updated successfully')
->success()
->send();
}
}),
]);
}
protected function handleClosingViaService($feedback, $actor, array &$data): void
{
try {
$closingService = App::make(ClosingService::class);
$closingService->close($feedback, $actor);
} catch (HttpException $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();
unset($data['new_status']);
throw new \Filament\Exceptions\SilentActionException;
} catch (ValidationException $e) {
Notification::make()
->title(collect($e->errors())->flatten()->first())
->danger()
->send();
unset($data['new_status']);
throw new \Filament\Exceptions\SilentActionException;
}
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Filament\Resources\Feedback\Schemas;
use App\Models\CustomerProduct;
use App\Models\Department;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class FeedbackForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Feedback Information')
->schema([
Select::make('customer_id')
->relationship('customer', 'name')
->searchable()
->preload()
->required()
->live(),
Toggle::make('is_general')
->label('General feedback (not related to a product)')
->default(false)
->live(),
Select::make('customer_product_id')
->label('Product')
->options(function ($get): array {
$customerId = $get('customer_id');
if (! $customerId) {
return [];
}
return CustomerProduct::where('customer_id', $customerId)
->with('product')
->get()
->pluck('product.name', 'id')
->toArray();
})
->visible(fn ($get): bool => ! $get('is_general'))
->nullable()
->searchable(),
Select::make('feedback_channel_id')
->relationship('feedbackChannel', 'name')
->required()
->preload(),
TextInput::make('title')
->required()
->maxLength(255),
Textarea::make('content')
->required()
->rows(6)
->columnSpanFull(),
Select::make('attachment_collection')
->label('Attachment Collection')
->options([
'general' => 'General',
'proof_of_resolution' => 'Proof of Resolution',
'customer_evidence' => 'Customer Evidence',
])
->default('general'),
FileUpload::make('attachments')
->label('Attachments')
->multiple()
->disk('local')
->directory('feedback-attachments')
->columnSpanFull()
->dehydrated(false),
Select::make('tags')
->relationship('tags', 'name')
->multiple()
->preload()
->createOptionForm([
TextInput::make('name')->required(),
TextInput::make('slug')->required(),
TextInput::make('color')->type('color'),
]),
Select::make('status')
->options([
'pending' => 'Pending',
'processing' => 'Processing',
'resolved' => 'Resolved',
'closed' => 'Closed',
])
->default('pending')
->required(),
Select::make('assigned_to')
->relationship('assignedTo', 'name')
->searchable()
->preload()
->nullable(),
Select::make('current_department_id')
->label('Department')
->options(Department::pluck('name', 'id')->toArray())
->searchable()
->nullable(),
Toggle::make('is_escalated')
->label('Escalated')
->visible(fn (): bool => auth()->user()?->hasRole(['admin', 'manager']) ?? false),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Filament\Resources\Feedback\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class FeedbackTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->searchable()
->sortable(),
TextColumn::make('customer.name')
->searchable()
->sortable(),
TextColumn::make('customerProduct.product.name')
->label('Product')
->placeholder('General')
->sortable(),
TextColumn::make('feedbackChannel.name')
->label('Channel'),
TextColumn::make('tags.name')
->badge()
->separator(','),
TextColumn::make('status')
->badge()
->color(fn(string $state): string => match ($state) {
'pending' => 'warning',
'processing' => 'info',
'resolved' => 'success',
'closed' => 'gray',
default => 'gray',
}),
TextColumn::make('currentDepartment.name')
->label('Department')
->toggleable(),
TextColumn::make('assignedTo.name')
->label('Assigned To')
->toggleable(),
IconColumn::make('is_escalated')
->label('Escalated')
->boolean()
->toggleable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(),
])
->filters([
SelectFilter::make('status')
->options([
'pending' => 'Pending',
'processing' => 'Processing',
'resolved' => 'Resolved',
'closed' => 'Closed',
]),
SelectFilter::make('feedback_channel_id')
->relationship('feedbackChannel', 'name')
->label('Channel'),
SelectFilter::make('current_department_id')
->relationship('currentDepartment', 'name')
->label('Department'),
SelectFilter::make('assigned_to')
->relationship('assignedTo', 'name')
->label('Assigned To'),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Filament\Resources\FeedbackChannels;
use App\Filament\Resources\FeedbackChannels\Pages\CreateFeedbackChannel;
use App\Filament\Resources\FeedbackChannels\Pages\EditFeedbackChannel;
use App\Filament\Resources\FeedbackChannels\Pages\ListFeedbackChannels;
use App\Filament\Resources\FeedbackChannels\Schemas\FeedbackChannelForm;
use App\Filament\Resources\FeedbackChannels\Tables\FeedbackChannelsTable;
use App\Models\FeedbackChannel;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class FeedbackChannelResource extends Resource
{
protected static ?string $model = FeedbackChannel::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedInboxStack;
protected static ?string $pluralLabel = 'Channels';
protected static ?int $navigationSort = 3;
public static function getNavigationGroup(): ?string
{
return 'Management';
}
public static function form(Schema $schema): Schema
{
return FeedbackChannelForm::configure($schema);
}
public static function table(Table $table): Table
{
return FeedbackChannelsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListFeedbackChannels::route('/'),
'create' => CreateFeedbackChannel::route('/create'),
'edit' => EditFeedbackChannel::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\FeedbackChannels\Pages;
use App\Filament\Resources\FeedbackChannels\FeedbackChannelResource;
use Filament\Resources\Pages\CreateRecord;
class CreateFeedbackChannel extends CreateRecord
{
protected static string $resource = FeedbackChannelResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\FeedbackChannels\Pages;
use App\Filament\Resources\FeedbackChannels\FeedbackChannelResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditFeedbackChannel extends EditRecord
{
protected static string $resource = FeedbackChannelResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\FeedbackChannels\Pages;
use App\Filament\Resources\FeedbackChannels\FeedbackChannelResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListFeedbackChannels extends ListRecords
{
protected static string $resource = FeedbackChannelResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Filament\Resources\FeedbackChannels\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class FeedbackChannelForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make()
->schema([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
TextInput::make('icon')
->maxLength(255)
->hint('Heroicon name or emoji'),
Toggle::make('is_active')
->default(true),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Filament\Resources\FeedbackChannels\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class FeedbackChannelsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('slug')
->searchable(),
IconColumn::make('is_active')
->boolean()
->label('Active'),
TextColumn::make('feedbacks_count')
->counts('feedbacks')
->label('Feedbacks'),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Filament\Resources\FeedbackTags;
use App\Filament\Resources\FeedbackTags\Pages\CreateFeedbackTag;
use App\Filament\Resources\FeedbackTags\Pages\EditFeedbackTag;
use App\Filament\Resources\FeedbackTags\Pages\ListFeedbackTags;
use App\Filament\Resources\FeedbackTags\Schemas\FeedbackTagForm;
use App\Filament\Resources\FeedbackTags\Tables\FeedbackTagsTable;
use App\Models\FeedbackTag;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class FeedbackTagResource extends Resource
{
protected static ?string $model = FeedbackTag::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTag;
protected static ?string $pluralLabel = 'Tags';
public static function getNavigationGroup(): ?string
{
return 'Management';
}
public static function form(Schema $schema): Schema
{
return FeedbackTagForm::configure($schema);
}
public static function table(Table $table): Table
{
return FeedbackTagsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListFeedbackTags::route('/'),
'create' => CreateFeedbackTag::route('/create'),
'edit' => EditFeedbackTag::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\FeedbackTags\Pages;
use App\Filament\Resources\FeedbackTags\FeedbackTagResource;
use Filament\Resources\Pages\CreateRecord;
class CreateFeedbackTag extends CreateRecord
{
protected static string $resource = FeedbackTagResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\FeedbackTags\Pages;
use App\Filament\Resources\FeedbackTags\FeedbackTagResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditFeedbackTag extends EditRecord
{
protected static string $resource = FeedbackTagResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\FeedbackTags\Pages;
use App\Filament\Resources\FeedbackTags\FeedbackTagResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListFeedbackTags extends ListRecords
{
protected static string $resource = FeedbackTagResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\FeedbackTags\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class FeedbackTagForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required(),
TextInput::make('slug')
->required(),
TextInput::make('color'),
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Filament\Resources\FeedbackTags\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class FeedbackTagsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('slug')
->searchable(),
TextColumn::make('color')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Products\Pages;
use App\Filament\Resources\Products\ProductResource;
use Filament\Resources\Pages\CreateRecord;
class CreateProduct extends CreateRecord
{
protected static string $resource = ProductResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Products\Pages;
use App\Filament\Resources\Products\ProductResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditProduct extends EditRecord
{
protected static string $resource = ProductResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Products\Pages;
use App\Filament\Resources\Products\ProductResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListProducts extends ListRecords
{
protected static string $resource = ProductResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Filament\Resources\Products;
use App\Filament\Resources\Products\Pages\CreateProduct;
use App\Filament\Resources\Products\Pages\EditProduct;
use App\Filament\Resources\Products\Pages\ListProducts;
use App\Filament\Resources\Products\Schemas\ProductForm;
use App\Filament\Resources\Products\Tables\ProductsTable;
use App\Models\Product;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class ProductResource extends Resource
{
protected static ?string $model = Product::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBuildingStorefront;
protected static ?string $pluralLabel = 'Products';
protected static ?int $navigationSort = 1;
public static function getNavigationGroup(): ?string
{
return 'Management';
}
public static function form(Schema $schema): Schema
{
return ProductForm::configure($schema);
}
public static function table(Table $table): Table
{
return ProductsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListProducts::route('/'),
'create' => CreateProduct::route('/create'),
'edit' => EditProduct::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Filament\Resources\Products\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class ProductForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make()
->schema([
TextInput::make('name')
->required()
->maxLength(255),
Textarea::make('description')
->columnSpanFull(),
Select::make('status')
->options([
'active' => 'Active',
'inactive' => 'Inactive',
'sold_out' => 'Sold Out',
])
->default('active')
->required(),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Filament\Resources\Products\Tables;
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 ProductsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('status')
->badge()
->color(fn(string $state): string => match ($state) {
'active' => 'success',
'inactive' => 'gray',
'sold_out' => 'danger',
default => 'gray',
}),
TextColumn::make('description')
->limit(50)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('customers_count')
->counts('customers')
->label('Owners'),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(),
])
->filters([
SelectFilter::make('status')
->options([
'active' => 'Active',
'inactive' => 'Inactive',
'sold_out' => 'Sold Out',
]),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Department;
use App\Models\Feedback;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class AvgProcessingTimeWidget extends BaseWidget
{
protected function getStats(): array
{
$user = auth()->user();
$query = Feedback::query()
->whereNotNull('assigned_to')
->whereNotNull('updated_at');
if ($user->hasRole('manager')) {
$deptIds = Department::where('manager_id', $user->id)->pluck('id');
$query->whereIn('current_department_id', $deptIds);
}
$totalResolved = (clone $query)->whereIn('status', ['resolved', 'closed'])->count();
$avgMinutes = (clone $query)
->whereIn('status', ['resolved', 'closed'])
->selectRaw('AVG(strftime("%s", updated_at) - strftime("%s", created_at)) / 60 as avg_minutes')
->value('avg_minutes');
$totalPending = (clone $query)->whereNotIn('status', ['resolved', 'closed'])->count();
$escalatedCount = (clone $query)->where('is_escalated', true)->count();
return [
Stat::make('Resolved Tickets', (string) $totalResolved)
->description('Total resolved/closed tickets')
->color('success'),
Stat::make('Avg Processing Time', $avgMinutes ? round($avgMinutes) . ' min' : 'N/A')
->description('Average time from creation to resolution')
->color('warning'),
Stat::make('Pending Tickets', (string) $totalPending)
->description('Still in progress')
->color('danger'),
Stat::make('Escalated', (string) $escalatedCount)
->description('Tickets marked as escalated')
->color('gray'),
];
}
public static function canView(): bool
{
return auth()->user()?->hasRole(['admin', 'manager']) ?? false;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Department;
use App\Models\Feedback;
use Filament\Widgets\ChartWidget;
class HandlerPerformanceWidget extends ChartWidget
{
protected ?string $heading = 'Avg Processing Time by Handler';
protected int|string|array $columnSpan = 'full';
protected function getType(): string
{
return 'bar';
}
protected function getData(): array
{
$user = auth()->user();
$query = Feedback::query()
->whereIn('status', ['resolved', 'closed'])
->whereNotNull('assigned_to');
if ($user->hasRole('manager')) {
$deptIds = Department::where('manager_id', $user->id)->pluck('id');
$query->whereIn('current_department_id', $deptIds);
}
$handlers = (clone $query)
->select('assigned_to')
->selectRaw('AVG(strftime("%s", updated_at) - strftime("%s", created_at)) / 60 as avg_minutes')
->groupBy('assigned_to')
->with('assignedTo')
->get();
$labels = $handlers->map(fn ($h) => $h->assignedTo?->name ?? 'Unknown')->toArray();
$data = $handlers->map(fn ($h) => round($h->avg_minutes, 1))->toArray();
return [
'datasets' => [
[
'label' => 'Avg Time (minutes)',
'data' => $data,
'backgroundColor' => '#3b82f6',
],
],
'labels' => $labels,
];
}
public static function canView(): bool
{
return auth()->user()?->hasRole(['admin', 'manager']) ?? false;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Filament\Widgets;
use App\Filament\Resources\Feedback\FeedbackResource;
use App\Models\Department;
use App\Models\Feedback;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
class ResolvedTicketsWidget extends BaseWidget
{
protected int|string|array $columnSpan = 'full';
public function table(Table $table): Table
{
return $table
->query(
Feedback::query()
->where('status', 'resolved')
->when(
auth()->user()->hasRole('manager'),
function ($q) {
$deptIds = Department::where('manager_id', auth()->id())->pluck('id');
return $q->whereIn('current_department_id', $deptIds);
}
)
)
->columns([
TextColumn::make('id')
->label('#')
->sortable(),
TextColumn::make('title')
->label('Ticket')
->searchable()
->url(fn (Feedback $record): string => FeedbackResource::getUrl('edit', ['record' => $record])),
TextColumn::make('customer.name')
->label('Customer')
->searchable(),
TextColumn::make('assignedTo.name')
->label('Handler'),
TextColumn::make('currentDepartment.name')
->label('Department'),
TextColumn::make('updated_at')
->label('Resolved Date')
->dateTime('d/m/Y')
->sortable(),
])
->defaultSort('updated_at', 'desc')
->heading('Tickets Awaiting Closure (Resolved)')
->description('Tickets that have been resolved and are pending manager review for closure.');
}
public static function canView(): bool
{
return auth()->user()?->hasRole(['admin', 'manager']) ?? false;
}
}

24
app/Models/Customer.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
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', 'email', 'phone', 'address', 'status'])]
class Customer extends Model
{
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'customer_product')
->withPivot('purchase_date')
->withTimestamps();
}
public function feedbacks(): HasMany
{
return $this->hasMany(Feedback::class);
}
}

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);
}
}

32
app/Models/Department.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'manager_id'])]
class Department extends Model
{
public function manager(): BelongsTo
{
return $this->belongsTo(User::class, 'manager_id');
}
public function feedbacks(): HasMany
{
return $this->hasMany(Feedback::class, 'current_department_id');
}
public function transferLogsFrom(): HasMany
{
return $this->hasMany(TicketTransferLog::class, 'from_department_id');
}
public function transferLogsTo(): HasMany
{
return $this->hasMany(TicketTransferLog::class, 'to_department_id');
}
}

58
app/Models/Feedback.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['customer_id', 'customer_product_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])]
class Feedback extends Model
{
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function customerProduct(): BelongsTo
{
return $this->belongsTo(CustomerProduct::class, 'customer_product_id');
}
public function feedbackChannel(): BelongsTo
{
return $this->belongsTo(FeedbackChannel::class);
}
public function assignedTo(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function currentDepartment(): BelongsTo
{
return $this->belongsTo(Department::class, 'current_department_id');
}
public function transferLogs(): HasMany
{
return $this->hasMany(TicketTransferLog::class);
}
public function interactions(): HasMany
{
return $this->hasMany(FeedbackInteraction::class)->latest();
}
public function attachments(): HasMany
{
return $this->hasMany(FeedbackAttachment::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(FeedbackTag::class, 'feedback_feedback_tag');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['uuid', 'feedback_id', 'feedback_interaction_id', 'user_id', 'name', 'path', 'disk', 'collection', 'mime_type', 'size'])]
class FeedbackAttachment extends Model
{
public function feedback(): BelongsTo
{
return $this->belongsTo(Feedback::class);
}
public function interaction(): BelongsTo
{
return $this->belongsTo(FeedbackInteraction::class, 'feedback_interaction_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeByCollection($query, string $collection)
{
return $query->where('collection', $collection);
}
public function scopeByDisk($query, string $disk)
{
return $query->where('disk', $disk);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'slug', 'icon', 'is_active'])]
class FeedbackChannel extends Model
{
protected function casts(): array
{
return [
'is_active' => 'boolean',
];
}
public function feedbacks(): HasMany
{
return $this->hasMany(Feedback::class);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['feedback_id', 'user_id', 'type', 'content', 'changes'])]
class FeedbackInteraction extends Model
{
protected function casts(): array
{
return [
'changes' => 'array',
];
}
public function feedback(): BelongsTo
{
return $this->belongsTo(Feedback::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function attachments(): HasMany
{
return $this->hasMany(FeedbackAttachment::class, 'feedback_interaction_id');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
#[Fillable(['name', 'slug', 'color'])]
class FeedbackTag extends Model
{
public function feedbacks(): BelongsToMany
{
return $this->belongsToMany(Feedback::class, 'feedback_feedback_tag');
}
}

18
app/Models/Product.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
#[Fillable(['name', 'description', 'status'])]
class Product extends Model
{
public function customers(): BelongsToMany
{
return $this->belongsToMany(Customer::class, 'customer_product')
->withPivot('purchase_date')
->withTimestamps();
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['feedback_id', 'from_department_id', 'to_department_id', 'sender_id', 'reason'])]
class TicketTransferLog extends Model
{
public function feedback(): BelongsTo
{
return $this->belongsTo(Feedback::class);
}
public function fromDepartment(): BelongsTo
{
return $this->belongsTo(Department::class, 'from_department_id');
}
public function toDepartment(): BelongsTo
{
return $this->belongsTo(Department::class, 'to_department_id');
}
public function sender(): BelongsTo
{
return $this->belongsTo(User::class, 'sender_id');
}
}

View File

@@ -2,26 +2,25 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
use Illuminate\Contracts\Auth\Access\Authorizable;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
#[Fillable(['name', 'email', 'password'])] #[Fillable(['name', 'email', 'password', 'role'])]
#[Hidden(['password', 'remember_token'])] #[Hidden(['password', 'remember_token'])]
class User extends Authenticatable class User extends Authenticatable implements FilamentUser
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable; use HasFactory, Notifiable, HasRoles;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array protected function casts(): array
{ {
return [ return [
@@ -29,4 +28,24 @@ class User extends Authenticatable
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
public function canAccessPanel(Panel $panel): bool
{
return true;
}
public function assignedFeedbacks(): HasMany
{
return $this->hasMany(Feedback::class, 'assigned_to');
}
public function isAdmin(): bool
{
return $this->hasRole('admin');
}
public function isManager(): bool
{
return $this->hasRole('manager');
}
} }

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Notifications;
use App\Models\Feedback;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TicketClosed extends Notification
{
use Queueable;
public function __construct(
public Feedback $feedback,
public User $actor,
) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject(__('Ticket #{id} đã được đóng', ['id' => $this->feedback->id]))
->line(__('Ticket: :title', ['title' => $this->feedback->title]))
->line(__('Đóng bởi: :name', ['name' => $this->actor->name]))
->action(__('Xem Ticket'), url('/admin/feedbacks/' . $this->feedback->id . '/edit'));
}
public function toDatabase(object $notifiable): array
{
return [
'message' => __('Ticket #{id} ":title" đã được đóng bởi :actor.', [
'id' => $this->feedback->id,
'title' => $this->feedback->title,
'actor' => $this->actor->name,
]),
'feedback_id' => $this->feedback->id,
'actor_name' => $this->actor->name,
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use App\Models\Feedback;
use App\Models\TicketTransferLog;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TicketTransferred extends Notification
{
use Queueable;
public function __construct(
public Feedback $feedback,
public TicketTransferLog $transferLog,
public User $sender,
) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject(__('Ticket #{id} đã được chuyển đến phòng của bạn', ['id' => $this->feedback->id]))
->line(__('Ticket: :title', ['title' => $this->feedback->title]))
->line(__('Người chuyển: :name', ['name' => $this->sender->name]))
->line(__('Lý do: :reason', ['reason' => $this->transferLog->reason]))
->action(__('Xem Ticket'), url('/admin/feedbacks/' . $this->feedback->id . '/edit'));
}
public function toDatabase(object $notifiable): array
{
return [
'message' => __('Ticket #{id} ":title" đã được chuyển đến phòng của bạn bởi :sender.', [
'id' => $this->feedback->id,
'title' => $this->feedback->title,
'sender' => $this->sender->name,
]),
'feedback_id' => $this->feedback->id,
'sender_name' => $this->sender->name,
'reason' => $this->transferLog->reason,
'to_department_id' => $this->transferLog->to_department_id,
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Policies;
use App\Models\Customer;
use App\Models\User;
class CustomerPolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole(['admin', 'manager', 'staff']);
}
public function view(User $user, Customer $customer): bool
{
return $user->hasRole(['admin', 'manager', 'staff']);
}
public function create(User $user): bool
{
return $user->hasRole(['admin', 'manager']);
}
public function update(User $user, Customer $customer): bool
{
return $user->hasRole(['admin', 'manager']);
}
public function delete(User $user, Customer $customer): bool
{
return $user->hasRole('admin');
}
public function restore(User $user, Customer $customer): bool
{
return $user->hasRole('admin');
}
public function forceDelete(User $user, Customer $customer): bool
{
return $user->hasRole('admin');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Policies;
use App\Models\FeedbackChannel;
use App\Models\User;
class FeedbackChannelPolicy
{
public function viewAny(User $user): bool
{
return in_array($user->role, ['admin', 'manager', 'staff']);
}
public function view(User $user, FeedbackChannel $feedbackChannel): bool
{
return in_array($user->role, ['admin', 'manager', 'staff']);
}
public function create(User $user): bool
{
return in_array($user->role, ['admin', 'manager']);
}
public function update(User $user, FeedbackChannel $feedbackChannel): bool
{
return in_array($user->role, ['admin', 'manager']);
}
public function delete(User $user, FeedbackChannel $feedbackChannel): bool
{
return $user->role === 'admin';
}
public function restore(User $user, FeedbackChannel $feedbackChannel): bool
{
return $user->role === 'admin';
}
public function forceDelete(User $user, FeedbackChannel $feedbackChannel): bool
{
return $user->role === 'admin';
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Policies;
use App\Models\Feedback;
use App\Models\User;
class FeedbackPolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole(['admin', 'manager', 'staff']);
}
public function view(User $user, Feedback $feedback): bool
{
return $user->hasRole(['admin', 'manager', 'staff']);
}
public function create(User $user): bool
{
return $user->hasRole(['admin', 'manager', 'staff']);
}
public function update(User $user, Feedback $feedback): bool
{
return $user->hasRole(['admin', 'manager', 'staff']);
}
public function delete(User $user, Feedback $feedback): bool
{
return $user->hasRole(['admin', 'manager']);
}
public function restore(User $user, Feedback $feedback): bool
{
return $user->hasRole('admin');
}
public function forceDelete(User $user, Feedback $feedback): bool
{
return $user->hasRole('admin');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Policies;
use App\Models\Product;
use App\Models\User;
class ProductPolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole(['admin', 'manager', 'staff']);
}
public function view(User $user, Product $product): bool
{
return $user->hasRole(['admin', 'manager', 'staff']);
}
public function create(User $user): bool
{
return $user->hasRole(['admin', 'manager']);
}
public function update(User $user, Product $product): bool
{
return $user->hasRole(['admin', 'manager']);
}
public function delete(User $user, Product $product): bool
{
return $user->hasRole('admin');
}
public function restore(User $user, Product $product): bool
{
return $user->hasRole('admin');
}
public function forceDelete(User $user, Product $product): bool
{
return $user->hasRole('admin');
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Providers\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationGroup;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets\AccountWidget;
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 AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->brandName('AfterSales CRM')
->colors([
'primary' => Color::Blue,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
AccountWidget::class,
])
->navigationGroups([
NavigationGroup::make()
->label('Customer Care'),
NavigationGroup::make()
->label('Management'),
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
PreventRequestForgery::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Rules;
use App\Models\Feedback;
use App\Services\FileService;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\App;
class RequireProofOfResolution implements ValidationRule
{
protected Feedback $feedback;
public function __construct(Feedback $feedback)
{
$this->feedback = $feedback;
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value !== 'closed') {
return;
}
$fileService = App::make(FileService::class);
if (! $fileService->hasCollection($this->feedback, 'proof_of_resolution')) {
$fail(__('Yêu cầu cung cấp tài liệu bằng chứng để hoàn tất quy trình.'));
}
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Services;
use App\Models\Department;
use App\Models\Feedback;
use App\Models\User;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
class ClosingService
{
/**
* Close a feedback. Throws if staff tries to close or no proof exists.
*/
public function close(Feedback $feedback, User $actor): void
{
if ($feedback->status === 'closed') {
throw ValidationException::withMessages([
'status' => __('Phiếu này đã được đóng.'),
]);
}
if ($actor->hasRole('staff')) {
throw new HttpException(403, __('Chỉ lãnh đạo cấp phòng mới có quyền đóng phiếu.'));
}
if ($actor->hasRole('manager') && ! $actor->hasRole('admin')) {
$managedDeptIds = Department::where('manager_id', $actor->id)->pluck('id');
if (! $managedDeptIds->contains($feedback->current_department_id)) {
throw new HttpException(403, __('Bạn chỉ có thể đóng phiếu thuộc phòng ban mình quản lý.'));
}
}
$fileService = App::make(FileService::class);
if (! $fileService->hasCollection($feedback, 'proof_of_resolution')) {
throw ValidationException::withMessages([
'status' => __('Yêu cầu cung cấp tài liệu bằng chứng để hoàn tất quy trình.'),
]);
}
$feedback->update(['status' => 'closed']);
$this->notifyStakeholders($feedback, $actor);
}
/**
* Resolve a feedback. Any role can resolve.
*/
public function resolve(Feedback $feedback, User $actor): void
{
if (in_array($feedback->status, ['closed', 'resolved'])) {
throw ValidationException::withMessages([
'status' => __('Phiếu này đã được xử lý hoặc đóng.'),
]);
}
$feedback->update(['status' => 'resolved']);
}
protected function notifyStakeholders(Feedback $feedback, User $actor): void
{
if ($feedback->assignedTo) {
$feedback->assignedTo->notify(new \App\Notifications\TicketClosed($feedback, $actor));
}
$department = $feedback->currentDepartment;
if ($department && $department->manager && $department->manager->id !== ($feedback->assignedTo?->id ?? null)) {
$department->manager->notify(new \App\Notifications\TicketClosed($feedback, $actor));
}
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Services;
use App\Models\FeedbackAttachment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class FileService
{
protected array $allowedMimes = [
'image/jpeg',
'image/jpg',
'image/png',
'application/pdf',
'video/mp4',
'video/quicktime',
'video/mov',
];
protected int $maxSize = 20480;
protected string $defaultDisk = 'local';
/**
* Upload files and create FeedbackAttachment records.
*/
public function upload(
array $files,
string $collection,
Model $model,
int $userId,
?int $interactionId = null,
string $disk = 'local',
): array {
$records = [];
foreach ($files as $file) {
if (! $file instanceof UploadedFile || ! $file->isValid()) {
continue;
}
$this->validate($file);
$path = $file->store('feedback-attachments', $disk);
$records[] = FeedbackAttachment::create([
'uuid' => (string) Str::uuid(),
'feedback_id' => $model instanceof \App\Models\Feedback ? $model->id : null,
'feedback_interaction_id' => $interactionId,
'user_id' => $userId,
'name' => $file->getClientOriginalName(),
'path' => $path,
'disk' => $disk,
'collection' => $collection,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
]);
}
return $records;
}
/**
* Generate a temporary URL for a file on a private disk.
*/
public function getTemporaryUrl(FeedbackAttachment $attachment, int $minutes = 10): string
{
if ($attachment->disk === 'public') {
return Storage::disk('public')->url($attachment->path);
}
return URL::temporarySignedRoute(
'storage.local',
now()->addMinutes($minutes),
['path' => $attachment->path]
);
}
/**
* Get all attachments of a specific collection for a model.
*/
public function getByCollection(Model $model, string $collection)
{
$query = FeedbackAttachment::byCollection($collection);
if ($model instanceof \App\Models\Feedback) {
$query->where('feedback_id', $model->id);
}
return $query->orderBy('created_at', 'desc')->get();
}
/**
* Check if a model has at least one attachment in the given collection.
*/
public function hasCollection(Model $model, string $collection): bool
{
return $this->getByCollection($model, $collection)->isNotEmpty();
}
/**
* Validate file type and size.
*/
protected function validate(UploadedFile $file): void
{
$errors = [];
if (! in_array($file->getMimeType(), $this->allowedMimes)) {
$errors['file'] = __('File type :type is not allowed. Allowed: jpg, png, pdf, mp4, mov.', ['type' => $file->getMimeType()]);
}
if ($file->getSize() > $this->maxSize * 1024) {
$errors['file'] = __('File size exceeds :max KB.', ['max' => $this->maxSize]);
}
if (! empty($errors)) {
throw ValidationException::withMessages($errors);
}
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Services;
use App\Models\Department;
use App\Models\Feedback;
use App\Models\TicketTransferLog;
use App\Models\User;
use Illuminate\Validation\ValidationException;
class TransferService
{
/**
* Transfer a feedback to another department.
*/
public function transfer(
Feedback $feedback,
Department $toDepartment,
string $reason,
User $sender,
?int $newHandlerId = null,
): TicketTransferLog {
if (empty(trim($reason))) {
throw ValidationException::withMessages([
'reason' => __('Vui lòng nhập lý do chuyển tiếp.'),
]);
}
$fromDepartmentId = $feedback->current_department_id;
$log = TicketTransferLog::create([
'feedback_id' => $feedback->id,
'from_department_id' => $fromDepartmentId,
'to_department_id' => $toDepartment->id,
'sender_id' => $sender->id,
'reason' => $reason,
]);
$feedback->update([
'current_department_id' => $toDepartment->id,
'assigned_to' => $newHandlerId ?? $toDepartment->manager_id ?? null,
]);
$this->notifyDepartmentManager($toDepartment, $feedback, $log, $sender);
return $log;
}
protected function notifyDepartmentManager(Department $department, Feedback $feedback, TicketTransferLog $log, User $sender): void
{
$manager = $department->manager;
if ($manager) {
$manager->notify(new \App\Notifications\TicketTransferred($feedback, $log, $sender));
}
}
}

View File

@@ -1,7 +1,6 @@
<?php <?php
use App\Providers\AppServiceProvider;
return [ return [
AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
]; ];

View File

@@ -7,8 +7,10 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"filament/filament": "^5.5",
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"laravel/tinker": "^3.0" "laravel/tinker": "^3.0",
"spatie/laravel-permission": "*"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
@@ -16,6 +18,8 @@
"laravel/pint": "^1.27", "laravel/pint": "^1.27",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.6",
"pestphp/pest-plugin-laravel": "^4.1",
"phpunit/phpunit": "^12.5.12" "phpunit/phpunit": "^12.5.12"
}, },
"autoload": { "autoload": {
@@ -49,7 +53,8 @@
], ],
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi" "@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
], ],
"post-update-cmd": [ "post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force" "@php artisan vendor:publish --tag=laravel-assets --ansi --force"

11432
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

202
config/permission.php Normal file
View File

@@ -0,0 +1,202 @@
<?php
return [
'models' => [
/*
* When using the "HasPermissions" trait from this package, we need to know which
* Eloquent model should be used to retrieve your permissions. Of course, it
* is often just the "Permission" model but you may use whatever you like.
*
* The model you want to use as a Permission model needs to implement the
* `Spatie\Permission\Contracts\Permission` contract.
*/
'permission' => Spatie\Permission\Models\Permission::class,
/*
* When using the "HasRoles" trait from this package, we need to know which
* Eloquent model should be used to retrieve your roles. Of course, it
* is often just the "Role" model but you may use whatever you like.
*
* The model you want to use as a Role model needs to implement the
* `Spatie\Permission\Contracts\Role` contract.
*/
'role' => Spatie\Permission\Models\Role::class,
],
'table_names' => [
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'roles' => 'roles',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your permissions. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'permissions' => 'permissions',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your models permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_permissions' => 'model_has_permissions',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your models roles. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_roles' => 'model_has_roles',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'role_has_permissions' => 'role_has_permissions',
],
'column_names' => [
/*
* Change this if you want to name the related pivots other than defaults
*/
'role_pivot_key' => null, // default 'role_id',
'permission_pivot_key' => null, // default 'permission_id',
/*
* Change this if you want to name the related model primary key other than
* `model_id`.
*
* For example, this would be nice if your primary keys are all UUIDs. In
* that case, name this `model_uuid`.
*/
'model_morph_key' => 'model_id',
/*
* Change this if you want to use the teams feature and your related model's
* foreign key is other than `team_id`.
*/
'team_foreign_key' => 'team_id',
],
/*
* When set to true, the method for checking permissions will be registered on the gate.
* Set this to false if you want to implement custom logic for checking permissions.
*/
'register_permission_check_method' => true,
/*
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
*/
'register_octane_reset_listener' => false,
/*
* Events will fire when a role or permission is assigned/unassigned:
* \Spatie\Permission\Events\RoleAttachedEvent
* \Spatie\Permission\Events\RoleDetachedEvent
* \Spatie\Permission\Events\PermissionAttachedEvent
* \Spatie\Permission\Events\PermissionDetachedEvent
*
* To enable, set to true, and then create listeners to watch these events.
*/
'events_enabled' => false,
/*
* Teams Feature.
* When set to true the package implements teams using the 'team_foreign_key'.
* If you want the migrations to register the 'team_foreign_key', you must
* set this to true before doing the migration.
* If you already did the migration then you must make a new migration to also
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
* (view the latest version of this package's migration file)
*/
'teams' => false,
/*
* The class to use to resolve the permissions team id
*/
'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class,
/*
* Passport Client Credentials Grant
* When set to true the package will use Passports Client to check permissions
*/
'use_passport_client_credentials' => false,
/*
* When set to true, the required permission names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_permission_in_exception' => false,
/*
* When set to true, the required role names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_role_in_exception' => false,
/*
* By default wildcard permission lookups are disabled.
* See documentation to understand supported syntax.
*/
'enable_wildcard_permission' => false,
/*
* The class to use for interpreting wildcard permissions.
* If you need to modify delimiters, override the class and specify its name here.
*/
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
/* Cache-specific settings */
'cache' => [
/*
* By default all permissions are cached for 24 hours to speed up performance.
* When permissions or roles are updated the cache is flushed automatically.
*/
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
/*
* The cache key used to store all permissions.
*/
'key' => 'spatie.permission.cache',
/*
* You may optionally indicate a specific cache driver to use for permission and
* role caching using any of the `store` drivers listed in the cache.php config
* file. Using 'default' here means to use the `default` set in cache.php.
*/
'store' => 'default',
],
];

View File

@@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Models\Feedback;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Feedback>
*/
class FeedbackFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
//
];
}
}

View File

@@ -0,0 +1,26 @@
<?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::create('customer_product', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->date('purchase_date')->nullable();
$table->timestamps();
$table->unique(['customer_id', 'product_id']);
});
}
public function down(): void
{
Schema::dropIfExists('customer_product');
}
};

View File

@@ -0,0 +1,26 @@
<?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::create('customers', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->nullable()->unique();
$table->string('phone')->nullable();
$table->text('address')->nullable();
$table->string('status')->default('active');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('customers');
}
};

View File

@@ -0,0 +1,24 @@
<?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::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->string('status')->default('active');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};

View File

@@ -0,0 +1,25 @@
<?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::create('feedback_channels', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('icon')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('feedback_channels');
}
};

View File

@@ -0,0 +1,28 @@
<?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::create('feedback', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->foreignId('customer_product_id')->nullable()->constrained('customer_product')->nullOnDelete();
$table->foreignId('feedback_channel_id')->constrained('feedback_channels');
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
$table->string('title');
$table->text('content');
$table->string('status')->default('pending');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('feedback');
}
};

View File

@@ -0,0 +1,22 @@
<?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->string('role')->default('staff')->after('password');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};

View File

@@ -0,0 +1,26 @@
<?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::create('feedback_interactions', function (Blueprint $table) {
$table->id();
$table->foreignId('feedback_id')->constrained('feedback')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users');
$table->string('type')->default('note'); // note, status_change, assignment, forward
$table->text('content')->nullable();
$table->json('changes')->nullable(); // tracks what changed (old_status => new_status, old_assignee => new_assignee)
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('feedback_interactions');
}
};

View File

@@ -0,0 +1,28 @@
<?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::create('feedback_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('feedback_id')->constrained('feedback')->cascadeOnDelete();
$table->foreignId('feedback_interaction_id')->nullable()->constrained('feedback_interactions')->nullOnDelete();
$table->foreignId('user_id')->constrained('users');
$table->string('name');
$table->string('path');
$table->string('mime_type')->nullable();
$table->unsignedBigInteger('size')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('feedback_attachments');
}
};

View File

@@ -0,0 +1,31 @@
<?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::create('feedback_tags', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('color')->nullable();
$table->timestamps();
});
Schema::create('feedback_feedback_tag', function (Blueprint $table) {
$table->foreignId('feedback_id')->constrained('feedback')->cascadeOnDelete();
$table->foreignId('feedback_tag_id')->constrained('feedback_tags')->cascadeOnDelete();
$table->primary(['feedback_id', 'feedback_tag_id']);
});
}
public function down(): void
{
Schema::dropIfExists('feedback_feedback_tag');
Schema::dropIfExists('feedback_tags');
}
};

View File

@@ -0,0 +1,137 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id
$table->string('name');
$table->string('guard_name');
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name');
$table->string('guard_name');
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
Schema::dropIfExists($tableNames['role_has_permissions']);
Schema::dropIfExists($tableNames['model_has_roles']);
Schema::dropIfExists($tableNames['model_has_permissions']);
Schema::dropIfExists($tableNames['roles']);
Schema::dropIfExists($tableNames['permissions']);
}
};

View File

@@ -0,0 +1,23 @@
<?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::create('departments', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->foreignId('manager_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('departments');
}
};

View File

@@ -0,0 +1,24 @@
<?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('feedback', function (Blueprint $table) {
$table->foreignId('current_department_id')->nullable()->after('assigned_to')->constrained('departments')->nullOnDelete();
$table->boolean('is_escalated')->default(false)->after('current_department_id');
});
}
public function down(): void
{
Schema::table('feedback', function (Blueprint $table) {
$table->dropForeign(['current_department_id']);
$table->dropColumn(['current_department_id', 'is_escalated']);
});
}
};

View File

@@ -0,0 +1,26 @@
<?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::create('ticket_transfer_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('feedback_id')->constrained('feedback')->cascadeOnDelete();
$table->foreignId('from_department_id')->nullable()->constrained('departments')->nullOnDelete();
$table->foreignId('to_department_id')->nullable()->constrained('departments')->nullOnDelete();
$table->foreignId('sender_id')->constrained('users');
$table->text('reason');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ticket_transfer_logs');
}
};

View File

@@ -0,0 +1,24 @@
<?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('feedback_attachments', function (Blueprint $table) {
$table->uuid('uuid')->nullable()->after('id')->unique();
$table->string('disk')->default('local')->after('path');
$table->string('collection')->default('general')->after('disk');
});
}
public function down(): void
{
Schema::table('feedback_attachments', function (Blueprint $table) {
$table->dropColumn(['uuid', 'disk', 'collection']);
});
}
};

View File

@@ -0,0 +1,22 @@
<?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('feedback_attachments', function (Blueprint $table) {
$table->string('disk')->default('local')->change();
});
}
public function down(): void
{
Schema::table('feedback_attachments', function (Blueprint $table) {
$table->string('disk')->default('private')->change();
});
}
};

View File

@@ -0,0 +1,25 @@
<?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::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@@ -0,0 +1,261 @@
<?php
namespace Database\Seeders;
use App\Models\Customer;
use App\Models\Department;
use App\Models\Feedback;
use App\Models\FeedbackChannel;
use App\Models\Product;
use App\Models\User;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
class AfterSalesSeeder extends Seeder
{
public function run(): void
{
Role::create(['name' => 'admin']);
Role::create(['name' => 'manager']);
Role::create(['name' => 'staff']);
$admin = User::create([
'name' => 'Admin',
'email' => 'admin@minicrm.local',
'password' => bcrypt('password'),
'role' => 'admin',
]);
$admin->assignRole('admin');
$manager = User::create([
'name' => 'Manager',
'email' => 'manager@minicrm.local',
'password' => bcrypt('password'),
'role' => 'manager',
]);
$manager->assignRole('manager');
$staff = User::create([
'name' => 'Staff',
'email' => 'staff@minicrm.local',
'password' => bcrypt('password'),
'role' => 'staff',
]);
$staff->assignRole('staff');
$depCskh = Department::create(['name' => 'Phòng CSKH', 'manager_id' => $manager->id]);
$depKythuat = Department::create(['name' => 'Phòng Kỹ thuật', 'manager_id' => $admin->id]);
$depKetoan = Department::create(['name' => 'Phòng Kế toán', 'manager_id' => null]);
$depPhaply = Department::create(['name' => 'Phòng Pháp lý', 'manager_id' => null]);
$channels = [
['name' => 'Email', 'slug' => 'email', 'icon' => 'heroicon-o-envelope'],
['name' => 'Zalo', 'slug' => 'zalo', 'icon' => 'heroicon-o-chat-bubble-left'],
['name' => 'Phone', 'slug' => 'phone', 'icon' => 'heroicon-o-phone'],
['name' => 'Document', 'slug' => 'document', 'icon' => 'heroicon-o-document-text'],
['name' => 'In Person', 'slug' => 'in-person', 'icon' => 'heroicon-o-user'],
];
foreach ($channels as $channel) {
FeedbackChannel::create($channel);
}
$products = [
['name' => 'Sunrise Apartment A1', 'description' => 'Luxury apartment in District 1, 3 bedrooms, river view', 'status' => 'active'],
['name' => 'Sunrise Apartment A2', 'description' => 'Premium apartment in District 1, 2 bedrooms, city view', 'status' => 'active'],
['name' => 'Green Valley Villa', 'description' => 'Standalone villa with private garden and pool', 'status' => 'active'],
['name' => 'Ocean Tower Office', 'description' => 'High-end office space in central business district', 'status' => 'sold_out'],
['name' => 'Park Hill Townhouse', 'description' => 'Modern townhouse near the park, 4 bedrooms', 'status' => 'inactive'],
];
foreach ($products as $product) {
Product::create($product);
}
$customers = [
['name' => 'Nguyen Van A', 'email' => 'nguyenvana@example.com', 'phone' => '0909123456', 'address' => 'Hanoi'],
['name' => 'Tran Thi B', 'email' => 'tranthib@example.com', 'phone' => '0918234567', 'address' => 'Ho Chi Minh City'],
['name' => 'Le Van C', 'email' => 'levanc@example.com', 'phone' => '0927345678', 'address' => 'Da Nang'],
['name' => 'Pham Thi D', 'email' => 'phamthid@example.com', 'phone' => '0936456789', 'address' => 'Can Tho'],
];
foreach ($customers as $customerData) {
Customer::create($customerData);
}
$customers = Customer::all();
$products = Product::where('status', 'active')->get();
$channels = FeedbackChannel::all();
$customers[0]->products()->attach($products[0]->id, ['purchase_date' => '2024-01-15']);
$customers[0]->products()->attach($products[1]->id, ['purchase_date' => '2024-06-01']);
$customers[1]->products()->attach($products[0]->id, ['purchase_date' => '2024-03-20']);
$customers[2]->products()->attach($products[2]->id, ['purchase_date' => '2024-09-10']);
$customers[3]->products()->attach($products[1]->id, ['purchase_date' => '2025-01-05']);
$customers[3]->products()->attach($products[2]->id, ['purchase_date' => '2025-02-15']);
$customerProduct1 = \App\Models\CustomerProduct::where('customer_id', $customers[0]->id)->where('product_id', $products[0]->id)->first();
$customerProduct2 = \App\Models\CustomerProduct::where('customer_id', $customers[0]->id)->where('product_id', $products[1]->id)->first();
$customerProduct3 = \App\Models\CustomerProduct::where('customer_id', $customers[1]->id)->where('product_id', $products[0]->id)->first();
$feedbacks = [
[
'customer_id' => $customers[0]->id,
'customer_product_id' => $customerProduct1->id,
'feedback_channel_id' => $channels->firstWhere('slug', 'email')->id,
'title' => 'Leaking ceiling in living room',
'content' => 'I noticed water stains on the ceiling of the living room after heavy rain. Please send someone to inspect and fix.',
'status' => 'pending',
'assigned_to' => $staff->id,
'current_department_id' => $depCskh->id,
],
[
'customer_id' => $customers[0]->id,
'customer_product_id' => $customerProduct2->id,
'feedback_channel_id' => $channels->firstWhere('slug', 'phone')->id,
'title' => 'Request for parking slot upgrade',
'content' => 'I would like to upgrade from one parking slot to two. Please advise on availability and pricing.',
'status' => 'processing',
'assigned_to' => $manager->id,
'current_department_id' => $depCskh->id,
],
[
'customer_id' => $customers[1]->id,
'customer_product_id' => $customerProduct3->id,
'feedback_channel_id' => $channels->firstWhere('slug', 'zalo')->id,
'title' => 'AC not cooling properly',
'content' => 'The air conditioner in the master bedroom is making loud noises and not cooling effectively.',
'status' => 'resolved',
'assigned_to' => $staff->id,
'current_department_id' => $depKythuat->id,
],
[
'customer_id' => $customers[2]->id,
'customer_product_id' => null,
'feedback_channel_id' => $channels->firstWhere('slug', 'document')->id,
'title' => 'General feedback on community services',
'content' => 'I suggest improving the garbage collection schedule to twice a day instead of once. Also, the security guard shift changes could be more organized.',
'status' => 'pending',
'assigned_to' => null,
'current_department_id' => $depCskh->id,
],
[
'customer_id' => $customers[3]->id,
'customer_product_id' => \App\Models\CustomerProduct::where('customer_id', $customers[3]->id)->where('product_id', $products[1]->id)->first()->id,
'feedback_channel_id' => $channels->firstWhere('slug', 'email')->id,
'title' => 'Kitchen cabinet hinge broken',
'content' => 'One of the kitchen cabinet hinges is broken. It is still under warranty, please arrange replacement.',
'status' => 'closed',
'assigned_to' => $staff->id,
'current_department_id' => $depKythuat->id,
],
];
foreach ($feedbacks as $feedback) {
Feedback::create($feedback);
}
$tags = [
['name' => 'Leaking', 'slug' => 'leaking', 'color' => '#3b82f6'],
['name' => 'Electrical', 'slug' => 'electrical', 'color' => '#f59e0b'],
['name' => 'Warranty', 'slug' => 'warranty', 'color' => '#10b981'],
['name' => 'Maintenance', 'slug' => 'maintenance', 'color' => '#8b5cf6'],
['name' => 'Service Request', 'slug' => 'service-request', 'color' => '#ec4899'],
];
foreach ($tags as $tag) {
\App\Models\FeedbackTag::create($tag);
}
$allFeedback = Feedback::all();
$allTags = \App\Models\FeedbackTag::all();
$allFeedback[0]->tags()->attach([$allTags[0]->id, $allTags[3]->id]);
$allFeedback[1]->tags()->attach([$allTags[4]->id]);
$allFeedback[2]->tags()->attach([$allTags[1]->id, $allTags[2]->id]);
$allFeedback[3]->tags()->attach([$allTags[3]->id]);
$allFeedback[4]->tags()->attach([$allTags[2]->id, $allTags[3]->id]);
\App\Models\FeedbackInteraction::create([
'feedback_id' => $allFeedback[0]->id,
'user_id' => $admin->id,
'type' => 'note',
'content' => 'Feedback received via Email. Assigned to Staff for initial handling.',
'changes' => ['status' => ['old' => null, 'new' => 'pending']],
]);
\App\Models\FeedbackInteraction::create([
'feedback_id' => $allFeedback[0]->id,
'user_id' => $staff->id,
'type' => 'note',
'content' => 'Contacted customer via phone. Scheduled inspection for next Tuesday. Customer agreed.',
]);
\App\Models\FeedbackInteraction::create([
'feedback_id' => $allFeedback[2]->id,
'user_id' => $staff->id,
'type' => 'status_change',
'content' => 'Technician visited site on 2024-06-10. AC repaired. Customer confirmed cooling is now normal.',
'changes' => ['status' => ['old' => 'processing', 'new' => 'resolved']],
]);
\App\Models\FeedbackInteraction::create([
'feedback_id' => $allFeedback[1]->id,
'user_id' => $manager->id,
'type' => 'note',
'content' => 'Parking upgrade available. Sent quotation via email. Waiting for customer confirmation.',
]);
\App\Models\FeedbackInteraction::create([
'feedback_id' => $allFeedback[4]->id,
'user_id' => $staff->id,
'type' => 'status_change',
'content' => 'Replacement hinge installed on 2025-03-01. All kitchen cabinets now working properly.',
'changes' => ['status' => ['old' => 'processing', 'new' => 'closed']],
]);
\App\Models\TicketTransferLog::create([
'feedback_id' => $allFeedback[2]->id,
'from_department_id' => $depCskh->id,
'to_department_id' => $depKythuat->id,
'sender_id' => $manager->id,
'reason' => 'Vấn đề kỹ thuật về điều hòa, cần Phòng Kỹ thuật kiểm tra và xử lý.',
]);
\App\Models\FeedbackAttachment::create([
'uuid' => \Illuminate\Support\Str::uuid(),
'feedback_id' => $allFeedback[2]->id,
'user_id' => $staff->id,
'name' => 'ac_repair_report.pdf',
'path' => 'feedback-attachments/ac_repair_report.pdf',
'disk' => 'local',
'collection' => 'proof_of_resolution',
'mime_type' => 'application/pdf',
'size' => 204800,
]);
\App\Models\FeedbackAttachment::create([
'uuid' => \Illuminate\Support\Str::uuid(),
'feedback_id' => $allFeedback[0]->id,
'user_id' => $staff->id,
'name' => 'ceiling_photo.jpg',
'path' => 'feedback-attachments/ceiling_photo.jpg',
'disk' => 'local',
'collection' => 'customer_evidence',
'mime_type' => 'image/jpeg',
'size' => 512000,
]);
\App\Models\FeedbackAttachment::create([
'uuid' => \Illuminate\Support\Str::uuid(),
'feedback_id' => $allFeedback[4]->id,
'user_id' => $staff->id,
'name' => 'replaced_hinge_photo.png',
'path' => 'feedback-attachments/replaced_hinge_photo.png',
'disk' => 'local',
'collection' => 'proof_of_resolution',
'mime_type' => 'image/png',
'size' => 307200,
]);
}
}

View File

@@ -2,7 +2,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -10,16 +9,10 @@ class DatabaseSeeder extends Seeder
{ {
use WithoutModelEvents; use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); $this->call([
AfterSalesSeeder::class,
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]); ]);
} }
} }

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "${APP_PORT:-8080}:80"
env_file:
- .env
volumes:
- ./database/database.sqlite:/var/www/html/database/database.sqlite
- ./storage/app:/var/www/html/storage/app
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/admin/login"]
interval: 30s
timeout: 5s
retries: 3

View File

@@ -0,0 +1,19 @@
#!/bin/sh
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
if [ -z "$APP_KEY" ]; then
php artisan key:generate --force --no-interaction
fi
php artisan migrate --force --no-interaction
php artisan storage:link --force --no-interaction 2>/dev/null || true
chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
exec /usr/bin/supervisord -c /etc/supervisord.conf

24
docker/nginx.conf Normal file
View File

@@ -0,0 +1,24 @@
server {
listen 80 default_server;
root /var/www/html/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}

12
docker/php.ini Normal file
View File

@@ -0,0 +1,12 @@
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.revalidate_freq=0
memory_limit=256M
upload_max_filesize=20M
post_max_size=21M
max_execution_time=60
file_uploads=On

15
docker/supervisord.conf Normal file
View File

@@ -0,0 +1,15 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/stdout
logfile_maxbytes=0
[program:php-fpm]
command=php-fpm
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
[program:nginx]
command=nginx -g 'daemon off;'
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0

0
mota.md Normal file
View File

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-ext-wght-normal-IYF56FF6.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-wght-normal-JEOLYBOO.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-ext-wght-normal-EOVOK2B5.woff2") format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-wght-normal-IRE366VL.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-vietnamese-wght-normal-CE5GGD3W.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-ext-wght-normal-HA22NDSG.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-wght-normal-NRMW37G5.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}

Some files were not shown because too many files have changed in this diff Show More