diff --git a/AGENTS.md b/AGENTS.md index a370858e..1c3ed547 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,10 @@ # AfterSales CRM - AI Agent Instructions +> **Trước khi làm việc, đọc các file quan trọng:** +> - `CODEBASE_SNAPSHOT.md` — Toàn bộ cấu trúc codebase (thay vì scan lại code) +> - `TASKS_ROADMAP.md` — Roadmap: đã làm, đang làm, sẽ làm +> - `PROGRESS.md` — Lịch sử tiến độ hoàn thành + ## Overview AfterSales CRM is a real-estate after-sales customer care system built with **Laravel 13.6** + **Filament 5.6**. diff --git a/CODEBASE_SNAPSHOT.md b/CODEBASE_SNAPSHOT.md new file mode 100644 index 00000000..ad259bee --- /dev/null +++ b/CODEBASE_SNAPSHOT.md @@ -0,0 +1,484 @@ +# CODEBASE_SNAPSHOT.md — AfterSales CRM + +> **Mục đích:** File snapshot toàn bộ cấu trúc codebase hiện tại. Dành cho AI Agent đọc thay vì scan toàn bộ code. +> **Cập nhật:** Mỗi khi có thay đổi lớn về cấu trúc (thêm/xóa Model, Migration, Resource, Service, Policy...). + +--- + +## Thông tin chung + +| Mục | Giá trị | +|------|--------| +| Framework | Laravel 13.6.0 | +| Admin Panel | Filament 5.6.1 | +| Auth/RBAC | Spatie laravel-permission (3 roles: admin, manager, staff) | +| Database | SQLite (`database/database.sqlite`) | +| Testing | Pest PHP | +| PHP | 8.3 | +| Composer | `/home/phuongtc/composer` | + +**Test Accounts (seeded):** +| Role | Email | Password | +|------|-------|----------| +| Admin | admin@minicrm.local | password | +| Manager | manager@minicrm.local | password | +| Staff | staff@minicrm.local | password | + +--- + +## Cây thư mục `app/` + +``` +app/ +├── Filament/ +│ ├── Resources/ +│ │ ├── Customers/ +│ │ │ ├── CustomerResource.php +│ │ │ ├── Pages/ +│ │ │ │ ├── CreateCustomer.php +│ │ │ │ ├── EditCustomer.php +│ │ │ │ └── ListCustomers.php +│ │ │ ├── RelationManagers/ +│ │ │ │ └── FeedbacksRelationManager.php # read-only table +│ │ │ ├── Schemas/ +│ │ │ │ └── CustomerForm.php +│ │ │ └── Tables/ +│ │ │ └── CustomersTable.php +│ │ ├── Feedback/ +│ │ │ ├── FeedbackResource.php +│ │ │ ├── Pages/ +│ │ │ │ ├── CreateFeedback.php +│ │ │ │ ├── EditFeedback.php # TransferDepartment + CloseTicket actions +│ │ │ │ └── ListFeedback.php +│ │ │ ├── RelationManagers/ +│ │ │ │ └── InteractionsRelationManager.php # status_change/note/assignment +│ │ │ ├── Schemas/ +│ │ │ │ └── FeedbackForm.php +│ │ │ └── Tables/ +│ │ │ └── FeedbackTable.php +│ │ ├── Contracts/ # NEW +│ │ │ ├── ContractResource.php +│ │ │ ├── Pages/ +│ │ │ │ ├── CreateContract.php +│ │ │ │ ├── EditContract.php +│ │ │ │ └── ListContracts.php +│ │ │ ├── Schemas/ +│ │ │ │ └── ContractForm.php +│ │ │ └── Tables/ +│ │ │ └── ContractsTable.php +│ │ ├── FeedbackChannels/ +│ │ │ ├── FeedbackChannelResource.php +│ │ │ ├── Pages/ +│ │ │ │ ├── CreateFeedbackChannel.php +│ │ │ │ ├── EditFeedbackChannel.php +│ │ │ │ └── ListFeedbackChannels.php +│ │ │ ├── Schemas/ +│ │ │ │ └── FeedbackChannelForm.php +│ │ │ └── Tables/ +│ │ │ └── FeedbackChannelsTable.php +│ │ ├── FeedbackTags/ +│ │ │ ├── FeedbackTagResource.php +│ │ │ ├── Pages/ +│ │ │ │ ├── CreateFeedbackTag.php +│ │ │ │ ├── EditFeedbackTag.php +│ │ │ │ └── ListFeedbackTags.php +│ │ │ ├── Schemas/ +│ │ │ │ └── FeedbackTagForm.php +│ │ │ └── Tables/ +│ │ │ └── FeedbackTagsTable.php +│ │ └── Products/ +│ │ ├── ProductResource.php +│ │ ├── Pages/ +│ │ │ ├── CreateProduct.php +│ │ │ ├── EditProduct.php +│ │ │ └── ListProducts.php +│ │ ├── RelationManagers/ +│ │ │ ├── ContractsRelationManager.php # NEW +│ │ │ ├── HandoversRelationManager.php # NEW +│ │ │ └── ProductServicesRelationManager.php # NEW +│ │ ├── Schemas/ +│ │ │ └── ProductForm.php +│ │ └── Tables/ +│ │ └── ProductsTable.php +│ └── Widgets/ +│ ├── AvgProcessingTimeWidget.php # 4 stats: resolved count, avg time, pending, escalated +│ ├── HandlerPerformanceWidget.php # bar chart: avg time per handler +│ └── ResolvedTicketsWidget.php # table: resolved tickets awaiting closure +├── Console/ # NEW +│ └── Commands/ +│ └── ImportCskh.php # app:import-cskh command +├── Enums/ # NEW +│ ├── ContractType.php # SALE/LEASE/BCC +│ ├── ContractStatus.php # ACTIVE/EXPIRED/LIQUIDATED +│ ├── HandoverStatus.php # NOT_READY/READY/SCHEDULED/HANDED_OVER +│ └── ServiceType.php # MANAGEMENT_FEE/GRATITUDE/SELF_BUSINESS +├── Http/ +│ └── Controllers/ +│ └── Controller.php +├── Models/ +│ ├── Contract.php # NEW +│ ├── Customer.php +│ ├── CustomerProduct.php # pivot model (customer_product table) +│ ├── Department.php +│ ├── Feedback.php +│ ├── FeedbackAttachment.php +│ ├── FeedbackChannel.php +│ ├── FeedbackInteraction.php +│ ├── FeedbackTag.php +│ ├── Handover.php # NEW +│ ├── Product.php +│ ├── ProductService.php # NEW +│ ├── TicketTransferLog.php +│ └── User.php +├── Notifications/ +│ ├── TicketClosed.php +│ └── TicketTransferred.php +├── Policies/ +│ ├── ContractPolicy.php # NEW +│ ├── CustomerPolicy.php +│ ├── FeedbackChannelPolicy.php +│ ├── FeedbackPolicy.php +│ └── ProductPolicy.php +├── Providers/ +│ ├── AppServiceProvider.php +│ └── Filament/ +│ └── AdminPanelProvider.php +├── Rules/ +│ └── RequireProofOfResolution.php +└── Services/ + ├── ClosingService.php + ├── FileService.php + └── TransferService.php +``` + +--- + +## Models — Chi tiết + +### Product (`products`) +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| name | string | +| description | text nullable | +| status | string (active/inactive/sold_out) | + +Relationships: `customers()` BelongsToMany (pivot: customer_product, withPivot: purchase_date), `contracts()` HasMany, `handovers()` HasMany, `productServices()` HasMany + +### ProductService (`product_services`) — NEW +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| product_id | FK->products cascadeOnDelete | +| service_type | string (management_fee/gratitude/self_business → ServiceType enum) | +| status | string (active/pending/completed/cancelled) | +| actual_collection_date | date nullable | +| remark | text nullable | + +Relationships: `product()` BelongsTo + +### Handover (`handovers`) — NEW +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| product_id | FK->products cascadeOnDelete | +| customer_id | FK->customers cascadeOnDelete | +| handover_date | date nullable | +| status | string (chua_du_dieu_kien/du_dieu_kien/da_len_lich/da_ban_giao → HandoverStatus enum) | +| remark | text nullable | + +Relationships: `product()` BelongsTo, `customer()` BelongsTo + +### Contract (`contracts`) — NEW +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| product_id | FK->products cascadeOnDelete | +| customer_id | FK->customers cascadeOnDelete | +| type | string (sale/lease/bcc → ContractType enum) | +| start_date | date nullable | +| end_date | date nullable | +| status | string (active/expired/liquidated → ContractStatus enum) | +| printed_at | date nullable | +| signed_status | string nullable | + +Relationships: `product()` BelongsTo, `customer()` BelongsTo, `feedbacks()` HasMany + +### Customer (`customers`) +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| name | string | +| email | string nullable unique | +| phone | string nullable | +| address | text nullable | +| status | string (active) | + +Relationships: `products()` BelongsToMany, `feedbacks()` HasMany + +### CustomerProduct (`customer_product` — pivot model) +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| customer_id | FK->customers cascadeOnDelete | +| product_id | FK->products cascadeOnDelete | +| purchase_date | date nullable | +UNIQUE(customer_id, product_id) + +Relationships: `customer()` BelongsTo, `product()` BelongsTo, `feedbacks()` HasMany + +### Department (`departments`) +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| name | string | +| manager_id | FK->users nullable nullOnDelete | + +Relationships: `manager()` BelongsTo User, `feedbacks()` HasMany, `transferLogsFrom()` HasMany, `transferLogsTo()` HasMany + +### User (`users`) +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| name | string | +| email | string unique | +| password | string | +| role | string (admin/manager/staff) | + +Uses Spatie `HasRoles`. Relationships: `assignedFeedbacks()` HasMany Feedback. + +### Feedback (`feedback`) +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| customer_id | FK->customers cascadeOnDelete | +| customer_product_id | FK->customer_product nullable nullOnDelete | +| contract_id | FK->contracts nullable nullOnDelete | +| feedback_channel_id | FK->feedback_channels | +| assigned_to | FK->users nullable nullOnDelete | +| current_department_id | FK->departments nullable nullOnDelete | +| is_escalated | boolean default false | +| title | string | +| content | text | +| status | string (pending/processing/resolved/closed) | + +Fillable: `['customer_id', 'customer_product_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated']` + +Relationships (10): `customer()`, `customerProduct()`, `contract()`, `feedbackChannel()`, `assignedTo()`, `currentDepartment()`, `transferLogs()`, `interactions()` ordered latest, `attachments()`, `tags()` BelongsToMany + +### FeedbackInteraction (`feedback_interactions`) +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| feedback_id | FK->feedback cascadeOnDelete | +| user_id | FK->users | +| type | string (note/status_change/assignment) | +| content | text nullable | +| changes | json nullable | + +Relationships: `feedback()` BelongsTo, `user()` BelongsTo, `attachments()` HasMany + +### FeedbackAttachment (`feedback_attachments`) +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| uuid | uuid nullable unique | +| feedback_id | FK->feedback cascadeOnDelete | +| feedback_interaction_id | FK->feedback_interactions nullable nullOnDelete | +| user_id | FK->users | +| name | string | +| path | string | +| disk | string default 'local' | +| collection | string default 'general' (proof_of_resolution/customer_evidence/general) | +| mime_type | string nullable | +| size | bigint nullable | + +Relationships: `feedback()`, `interaction()`, `user()`. Scopes: `byCollection()`, `byDisk()`. + +### FeedbackChannel (`feedback_channels`) +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| name | string | +| slug | string unique | +| icon | string nullable | +| is_active | boolean default true | + +Relationships: `feedbacks()` HasMany + +### FeedbackTag (`feedback_tags`) +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| name | string | +| slug | string unique | +| color | string nullable | + +Relationships: `feedbacks()` BelongsToMany (pivot: feedback_feedback_tag) + +### TicketTransferLog (`ticket_transfer_logs`) +| Cột | Kiểu | +|-----|------| +| id | bigint PK | +| feedback_id | FK->feedback cascadeOnDelete | +| from_department_id | FK->departments nullable nullOnDelete | +| to_department_id | FK->departments nullable nullOnDelete | +| sender_id | FK->users | +| reason | text | + +Relationships: `feedback()`, `fromDepartment()`, `toDepartment()`, `sender()` + +--- + +## Services — Tóm tắt + +### TransferService (`app/Services/TransferService.php`) +- `transfer(Feedback, Department, reason, User, ?int $newHandlerId)`: Tạo TicketTransferLog, cập nhật current_department_id + assigned_to, gửi TicketTransferred notification cho manager đích. +- Validate reason không được rỗng (ValidationException). + +### ClosingService (`app/Services/ClosingService.php`) +- `close(Feedback, User)`: 4 guard: status ≠ closed, staff → 403, manager chỉ đóng ticket phòng mình, phải có proof_of_resolution attachment (FileService::hasCollection). Gửi TicketClosed notification. +- `resolve(Feedback, User)`: Đánh dấu resolved (không role gate). + +### FileService (`app/Services/FileService.php`) +- `upload()`: Validate (jpg/png/pdf/mp4/mov, ≤20MB), store vào private local disk (`feedback-attachments/`), tạo FeedbackAttachment record. +- `getTemporaryUrl()` / `getPreviewUrl()`: Signed URL cho private file. +- `getByCollection()` / `hasCollection()`: Query collection-based attachments. + +--- + +## Policies — Tóm tắt + +| Policy | viewAny/view | create | update | delete | restore/forceDelete | +|--------|-------------|--------|--------|--------|---------------------| +| ContractPolicy | all roles | admin+manager | admin+manager | admin | admin only | +| FeedbackPolicy | all roles | all roles | all roles | admin+manager | admin only | +| ProductPolicy | all roles | admin+manager | admin+manager | admin | admin only | +| CustomerPolicy | all roles | admin+manager | admin+manager | admin | admin only | +| FeedbackChannelPolicy | all roles | admin+manager | admin+manager | admin | admin only | + +--- + +## Filament Navigation Structure + +``` +Dashboard (AccountWidget + 3 custom widgets) +├── Customer Care (group) +│ └── Feedbacks (heroicon-o-chat-bubble-left-right, sort=1) +└── Management (group) + ├── Products (heroicon-o-building-storefront, sort=1) + ├── Customers (heroicon-o-user-group, sort=2) + ├── Channels (heroicon-o-inbox-stack, sort=3) + ├── Contracts (heroicon-o-document-text, sort=4) # NEW + └── Tags (heroicon-o-tag, default sort) +``` + +--- + +## Key Filament Actions trên EditFeedback + +1. **TransferDepartment** — Modal: Chọn phòng ban đích + reason (required) + attachments (optional). Visibility: admin/manager. +2. **CloseTicket** — Confirm modal. Visibility: admin/manager, status ≠ closed. + +--- + +## Database Migration files (23 files) + +| # | File | Tables | +|---|------|--------| +| 1 | `0001_01_01_000000` | users, password_reset_tokens, sessions | +| 2 | `0001_01_01_000001` | cache, cache_locks | +| 3 | `0001_01_01_000002` | jobs, job_batches, failed_jobs | +| 4 | `2026_04_26_052423_create_products_table` | products | +| 5 | `2026_04_26_052423_create_customers_table` | customers | +| 6 | `2026_04_26_052423_create_customer_product_table` | customer_product (pivot) | +| 7 | `2026_04_26_052424_create_feedback_channels_table` | feedback_channels | +| 8 | `2026_04_26_052424_create_feedbacks_table` | feedback (base) | +| 9 | `2026_04_26_052528_add_role_to_users_table` | (modify users) add role | +| 10 | `2026_04_26_063709_create_feedback_interactions_table` | feedback_interactions | +| 11 | `2026_04_26_063710_create_feedback_attachments_table` | feedback_attachments (base) | +| 12 | `2026_04_26_070225_create_feedback_tags_table` | feedback_tags, feedback_feedback_tag | +| 13 | `2026_04_27_030125_create_permission_tables` | Spatie: permissions, roles, model_has_roles, etc. | +| 14 | `2026_04_27_080000_create_departments_table` | departments | +| 15 | `2026_04_27_081000_add_department_to_feedback_table` | (modify feedback) add current_department_id, is_escalated | +| 16 | `2026_04_27_082000_create_ticket_transfer_logs_table` | ticket_transfer_logs | +| 17 | `2026_04_27_083000_add_collection_to_feedback_attachments_table` | (modify feedback_attachments) add uuid, disk, collection | +| 18 | `2026_04_27_140000_fix_attachment_disk_default` | (modify feedback_attachments) fix disk default private→local | +| 19 | `2026_04_27_150000_create_notifications_table` | notifications | +| 20 | `2026_05_01_000000_create_contracts_table` | **(NEW)** contracts | +| 21 | `2026_05_01_000001_add_contract_id_to_feedback_table` | **(NEW)** add contract_id FK to feedback | +| 22 | `2026_05_01_000002_create_handovers_table` | **(NEW)** handovers | +| 23 | `2026_05_01_000003_create_product_services_table` | **(NEW)** product_services | + +--- + +## Seeder (AfterSalesSeeder) — Data mẫu + +- 3 users (admin, manager, staff) + Spatie roles +- 4 departments (CSKH: manager quản lý, Kỹ thuật: admin quản lý, Kế toán, Pháp lý) +- 5 feedback channels (Email, Zalo, Phone, Document, In Person) +- 5 products (Sunrise A1, A2, Green Valley Villa, Ocean Tower, Park Hill) +- 4 customers +- 5 customer-product assignments +- **5 contracts** (2 Sale, 2 Lease, 1 BCC) — NEW +- **5 handovers** (2 Đã BG, 1 Đủ ĐK, 1 Đã lên lịch, 1 Chưa đủ ĐK) — NEW +- **5 product_services** (2 Management Fee, 2 Gratitude, 1 Self Business) — NEW +- 5 feedbacks (pending: 2, processing: 1, resolved: 1, closed: 1) +- 5 feedback tags +- 5 feedback interactions +- 1 ticket_transfer_log +- 3 feedback_attachments (2 proof_of_resolution, 1 customer_evidence) + +---|--- +| 22 | `2026_05_01_000003_create_product_services_table` | **(NEW)** product_services | + +--- + +## Data Import (`app:import-cskh`) — NEW + +Command: `php artisan app:import-cskh {file_path}` +- Sử dụng `spatie/simple-excel` với Lazy Collection (chống OOM) +- ProgressBar + chunk 100 rows/transaction +- Pipeline mỗi dòng: + 1. `firstOrCreate` Product (Mã căn hộ) + Customer (Họ tên KH) + 2. Insert Contract, Handover, ProductService nếu có dữ liệu + 3. Tách `Lịch Sử khách hàng` → Feedback + FeedbackInteraction + 4. Auto-gắn active contract vào Feedback +- Hỗ trợ cột: Mã căn hộ, Họ tên KH, BÀN GIÀO CĂN HỘ, TÌNH TRẠNG, HỢP ĐỒNG THUÊ, HĐ HỢP TÁC KINH DOANH, TỰ DOANH, Phí QL, Lịch Sử khách hàng + +--- + +**Test files:** `tests/Feature/ClosingPermissionTest.php`, `tests/Feature/TransferFlowTest.php` + +| Test | Coverage | +|------|----------| +| ClosingPermissionTest (4 tests) | staff close → 403, close without evidence → 422, close with evidence → OK, cross-department manager → 403 | +| TransferFlowTest (2 tests) | successful transfer, missing reason | + +Tổng: 8 tests, 21 assertions. + +--- + +## Các Pattern cần biết khi code + +1. **Form components:** `Filament\Forms\Components\*` (Select, TextInput, Textarea, Toggle, FileUpload, RichEditor) +2. **Schema/layout:** `Filament\Schemas\Components\*` (Section, Schema) +3. **Service access:** `App::make(FileService::class)` hoặc inject constructor +4. **Role check:** `$user->hasRole('admin')` / `$user->hasRole(['admin', 'manager'])` +5. **Dynamic form field:** dùng `$get` function injection, KHÔNG dùng `$component->getLivewire()->data` +6. **FileUpload với dehydrated(false):** File được Filament lưu trước, chỉ cần tạo FeedbackAttachment record trong after()/afterSave() — KHÔNG re-upload qua FileService +7. **Private disk:** files lưu ở `storage/app/private/feedback-attachments/`, truy cập qua signed URL từ FileService +8. **Navigation group icons:** Filament 5 báo lỗi nếu cả navigation group VÀ child items đều có icons + +--- + +## File hướng dẫn liên quan + +| File | Nội dung | +|------|----------| +| `AGENTS.md` | Hướng dẫn cho AI Agent: cách chạy, cấu trúc, workflow, RBAC, gotchas | +| `PROGRESS.md` | Lịch sử tiến độ hoàn thành (8 giai đoạn + bug fix) | +| `ai_instructions/AI_01_*.md` | Business Logic & Policies (hợp đồng snapshot, transfer policy, handover) | +| `ai_instructions/AI_02_*.md` | Database Schema Upgrade (contracts, handovers, product_services) | +| `ai_instructions/AI_03_*.md` | Data Import Pipeline (spatie/simple-excel, lazy collection) | +| `TASKS_ROADMAP.md` | Roadmap: việc đã làm, đang làm, sẽ làm | diff --git a/PROGRESS.md b/PROGRESS.md index 05e0266e..6a968a38 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,9 +1,10 @@ # AfterSales CRM - Tiến độ -## Trạng thái: Hoàn thành SOP — 8 giai đoạn + bug fix + polish +## Trạng thái: Hoàn thành SOP + 6/7 AI Instructions (85%) Đã test: Tất cả routes HTTP 200, Pest 8/8 tests (21 assertions) pass ✓ Tham chiếu: `SOP_des.md` — Bản đặc tả hệ thống After-Sale CRM +**Data import:** `php artisan app:import-cskh ai_instructions/CSKH.xlsx` — 3658 rows imported successfully ## Công nghệ - Laravel 13.6.0 + Filament 5.6.1 + SQLite @@ -80,9 +81,54 @@ Tham chiếu: `SOP_des.md` — Bản đặc tả hệ thống After-Sale CRM - [x] Docker sẵn sàng production: Dockerfile, docker-compose, entrypoint - [x] Migration `notifications` table +### AI Instructions — Phase A: Module Hợp đồng (Contract) +- [x] Migration `contracts`: product_id, customer_id, type (sale/lease/bcc), dates, status, signed_status +- [x] Enum `ContractType` (SALE/LEASE/BCC) + `ContractStatus` (ACTIVE/EXPIRED/LIQUIDATED) +- [x] Model `Contract`: relationships product(), customer(), feedbacks() +- [x] Filament Resource `Contracts` (CRUD) + `ContractPolicy` +- [x] RelationManager `ContractsRelationManager` trên ProductResource +- [x] Model `Product` thêm `contracts()` HasMany +- [x] Seeder: 5 contracts mẫu + +### AI Instructions — Phase B: Snapshot Hợp đồng +- [x] Migration thêm `contract_id` FK nullable vào `feedback` +- [x] Model `Feedback`: thêm `contract_id` fillable + `contract()` BelongsTo +- [x] FeedbackForm: auto-select active contract khi chọn sản phẩm, cho phép chọn lại thủ công +- [x] FeedbackTable: cột "Contract" hiển thị loại HĐ dạng badge +- [x] Seeder: gán contract_id cho 5 feedback mẫu + +### AI Instructions — Phase C: Module Bàn giao (Handover) +- [x] Migration `handovers`: product_id, customer_id, handover_date, status, remark +- [x] Enum `HandoverStatus` (NOT_READY/READY/SCHEDULED/HANDED_OVER) +- [x] Model `Handover`: relationships product(), customer() +- [x] RelationManager `HandoversRelationManager` trên ProductResource +- [x] Model `Product` thêm `handovers()` HasMany +- [x] Seeder: 5 handovers mẫu + +### AI Instructions — Phase D: Module Dịch vụ (ProductService) +- [x] Migration `product_services`: product_id, service_type, status, actual_collection_date, remark +- [x] Enum `ServiceType` (MANAGEMENT_FEE/GRATITUDE/SELF_BUSINESS) +- [x] Model `ProductService`: relationship product() +- [x] RelationManager `ProductServicesRelationManager` trên ProductResource +- [x] Model `Product` thêm `productServices()` HasMany +- [x] Seeder: 5 services mẫu + +### AI Instructions — Phase E: Enhancement ProductResource +- [x] ProductResource có 3 tabs: Contracts + Handovers + Services + +### AI Instructions — Phase G: Data Import Pipeline +- [x] Cài `spatie/simple-excel` (Lazy Collection, chống OOM) +- [x] Command `app:import-cskh {file_path} --dry-run` với ProgressBar +- [x] Pipeline: Product/Customer firstOrCreate → customer_product pivot → Contract → Handover → Service → Feedback + Interactions +- [x] Xử lý DateTimeImmutable object từ Excel, skip meta rows +- [x] Đã test thành công với CSKH.xlsx: 3658 rows → 3299 products, 2089 customers, 2637 contracts, 2077 handovers, 159 services, 71 feedbacks, 226 interactions + +### Còn lại (Optional) +- [ ] Auto-suggest chuyển phòng ban dựa trên loại hợp đồng (Ticket BCC → gợi ý Pháp lý) + --- -## Database Schema (final) +## Database Schema (final — 23 migrations) ``` products customers customer_product (pivot) feedback_channels feedback feedback_interactions @@ -90,14 +136,16 @@ feedback_attachments feedback_tags feedback_feedback_tag (pivot) users departments ticket_transfer_logs permissions roles model_has_roles (spatie) notifications sessions cache +contracts handovers product_services ``` -## Docker Deployment +## Data Import ```bash -cp .env.example .env -# Edit .env: set APP_URL, optional: switch to MySQL -docker compose up -d --build -# Open http://localhost:8080/admin +# Dry-run (phân tích trước, an toàn) +php artisan app:import-cskh ai_instructions/CSKH.xlsx --dry-run + +# Import thật +php artisan app:import-cskh ai_instructions/CSKH.xlsx ``` ## Local Dev @@ -110,4 +158,6 @@ 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. +- `CODEBASE_SNAPSHOT.md` — Toàn bộ cấu trúc codebase (đọc thay vì scan code) +- `TASKS_ROADMAP.md` — Roadmap: đã làm, đang làm, sẽ làm +- `AGENTS.md` — Hướng dẫn chạy, workflow, RBAC, gotchas diff --git a/TASKS_ROADMAP.md b/TASKS_ROADMAP.md new file mode 100644 index 00000000..2ed1b184 --- /dev/null +++ b/TASKS_ROADMAP.md @@ -0,0 +1,145 @@ +# TASKS_ROADMAP.md — AfterSales CRM + +> **Mục đích:** Theo dõi tất cả các công việc: đã hoàn thành, đang làm, sẽ làm. +> **Cập nhật:** Mỗi khi hoàn thành hoặc thêm task mới. + +--- + +## ✅ ĐÃ HOÀN THÀNH (SOP Phase 1–8) + +### 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` +- [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 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 EditFeedback +- [x] `CloseTicket` Action +- [x] InteractionsRelationManager: role-aware status options +- [x] FeedbackForm: current_department_id, is_escalated + +### Giai đoạn 5: Dashboard Widgets +- [x] `ResolvedTicketsWidget` +- [x] `AvgProcessingTimeWidget` +- [x] `HandlerPerformanceWidget` + +### Giai đoạn 6: Testing +- [x] Pest: 8 tests (21 assertions) + +### Giai đoạn 7: Seeder +- [x] 4 departments, 5 channels, 5 products, 4 customers, 5 feedbacks, 5 tags, 5 interactions, 1 transfer log, 3 attachments + +### Giai đoạn 8: Polish & Bug Fix +- [x] Dashboard widget filter Manager theo department +- [x] Migration disk default fix +- [x] File upload pattern fix +- [x] Department + Escalated columns trên FeedbackTable +- [x] Validation proof_of_resolution khi close từ form +- [x] Manager chỉ đóng ticket phòng mình +- [x] View Attachments trong Interaction History +- [x] Docker deployment +- [x] Migration notifications table + +--- + +## 🔴 CHƯA LÀM — Theo AI Instructions + +### A: Module Hợp đồng (Contract) — từ `AI_01`, `AI_02` +- [x] Tạo migration bảng `contracts`: + - `product_id` FK, `customer_id` FK + - `type` (LEASE/BCC/SALE) + - `start_date`, `end_date` + - `status` (Active/Expired/Liquidated) + - `printed_at`, `signed_status` +- [x] Tạo Model `Contract` + Enum `ContractType` +- [x] Relationships: `product()`, `customer()`, `feedbacks()` +- [x] Tạo ContractResource Filament + ContractPolicy +- [x] Tạo ContractsRelationManager trên ProductResource + +### B: Cơ chế Snapshot Hợp đồng — từ `AI_01` +- [x] Tạo migration thêm `contract_id` (FK->contracts, nullable) vào bảng `feedback` +- [x] Cập nhật Model `Feedback`: fillable + relationship `contract()` +- [x] Logic auto-select active contract khi tạo Feedback mới (FeedbackForm) +- [x] Hiển thị contract info trên Feedback list (FeedbackTable column) +- [x] Gán contract_id cho feedback mẫu trong Seeder + +### C: Module Bàn giao (Handover) — từ `AI_01`, `AI_02` +- [x] Tạo migration bảng `handovers`: + - `product_id` FK, `customer_id` FK + - `handover_date` (date) + - `status` (Chưa đủ điều kiện/Đủ ĐK/Đã lên lịch/Đã BG) + - `remark` (text nullable) +- [x] Tạo Model `Handover` + Enum `HandoverStatus` +- [x] Relationships: `product()`, `customer()` +- [x] Tạo `HandoversRelationManager` gắn vào `ProductResource` +- [x] Cập nhật Product model: thêm `handovers()` HasMany + +### D: Module Dịch vụ phụ trợ (ProductService) — từ `AI_02` +- [x] Tạo migration bảng `product_services`: + - `product_id` FK + - `service_type` (MANAGEMENT_FEE/GRATITUDE/SELF_BUSINESS) + - `status` + - `actual_collection_date` (date nullable) + - `remark` (text nullable) +- [x] Tạo Model `ProductService` + Enum `ServiceType` +- [x] Relationship: `product()` +- [x] Tạo `ProductServicesRelationManager` gắn vào `ProductResource` +- [x] Cập nhật Product model: thêm `productServices()` HasMany + +### E: Enhancement ProductResource — từ `AI_02` +- [x] Thêm RelationManagers vào `ProductResource::getRelations()` + - ContractsRelationManager ✅ + - HandoversRelationManager ✅ + - ProductServicesRelationManager ✅ +- [x] Product detail page có 3 tabs: Contracts + Handovers + Services + +### F: Tự động gợi ý transfer — từ `AI_01` (optional) +- [ ] Hiển thị badge gợi ý: "Ticket BCC → khuyến nghị chuyển Pháp lý" +- [ ] Logic detect contract type → suggest department + +### G: Data Import Pipeline — từ `AI_03` +- [x] Cài package `spatie/simple-excel` +- [x] Tạo Command `app:import-cskh {file_path} --dry-run` +- [x] Logic: Lazy Collection, chunk 100 rows, DB::transaction, ProgressBar +- [x] Pipeline per row: Product/Customer firstOrCreate → Contract/Handover/Service → Feedback + Interactions +- [x] Tạo customer_product pivot record +- [x] Xử lý DateTimeImmutable từ Excel +- [x] Skip header/meta rows +- [x] Đã test thành công với CSKH.xlsx (3658 rows → 3299 products, 2089 customers, 2637 contracts, 2077 handovers, 159 services, 71 feedbacks, 226 interactions) + +--- + +## 📋 Ưu tiên phát triển + +| Ưu tiên | Task | Phụ thuộc | +|---------|------|-----------| +| 1 | ~~A: Module Hợp đồng (Migration + Model)~~ ✅ | Không | +| 2 | ~~B: contract_id trên feedback (Snapshot)~~ ✅ | A | +| 3 | ~~C: Module Bàn giao (Migration + Model + RelationManager)~~ ✅ | Không | +| 4 | ~~D: Module Dịch vụ (Migration + Model + RelationManager)~~ ✅ | Không | +| 5 | ~~E: Enhancement ProductResource~~ ✅ | A, C, D | +| 6 | ~~G: Data Import Pipeline~~ ✅ | A, B, C, D | +| 7 | F: Tự động gợi ý transfer (nice-to-have) | A | + +--- + +## 📊 Tiến độ tổng quan + +``` +SOP Phase 1-8: ████████████████████████ 100% (Hoàn thành) +AI Instructions Core: ███████████████████████░ 85% (6/7 — Còn: auto-suggest transfer) +Data Import Tested: ████████████████████████ 100% (3658 rows real data imported successfully) +``` diff --git a/ai_instructions/AI_01_BUSINESS_LOGIC_AND_POLICIES.md b/ai_instructions/AI_01_BUSINESS_LOGIC_AND_POLICIES.md new file mode 100644 index 00000000..686c770d --- /dev/null +++ b/ai_instructions/AI_01_BUSINESS_LOGIC_AND_POLICIES.md @@ -0,0 +1,19 @@ +# Kiến trúc Nghiệp vụ & Chính sách hệ thống AfterSales CRM + +# Tham chiếu: AGENTS.md, PROGRESS.md + +## 1. Cơ chế Snapshot Hợp đồng (Neo dữ liệu) + +- **Bối cảnh:** Một sản phẩm (Căn hộ) sẽ trải qua nhiều loại hợp đồng theo thời gian (HĐ Mua bán -> HĐ Thuê -> HĐ Hợp tác kinh doanh BCC). +- **Quy tắc:** Mọi tương tác/khiếu nại (`Feedback`) phải được gắn cứng với hợp đồng đang có hiệu lực tại thời điểm đó. +- **Thực thi:** Bổ sung trường `contract_id` (nullable) vào bảng `feedback`. Khi tạo mới một Ticket, hệ thống ưu tiên tự động chọn hợp đồng đang Active của căn hộ đó (có thể cho phép nhân viên chọn lại thủ công). Lịch sử của Ticket sẽ vĩnh viễn thuộc về hợp đồng này, không bị thay đổi ngay cả khi căn hộ chuyển sang hợp đồng khác trong tương lai. + +## 2. Chính sách Luân chuyển (Ticket Transfer Policy) + +- **Quy tắc Tối thượng:** KHÔNG CÓ BẤT KỲ CƠ CHẾ TỰ ĐỘNG TRANSFER NÀO. +- **Thực thi:** Việc chuyển Ticket (Feedback) giữa các phòng ban (ví dụ: từ CSKH sang Kỹ thuật, Pháp lý) phải được thực hiện hoàn toàn thủ công bởi nhân viên hoặc quản lý (thông qua `TransferService` đã có). +- **Tính năng mở rộng (Tùy chọn):** Trên giao diện Filament, có thể hiển thị Text/Badge gợi ý: "Ticket này thuộc mảng BCC, khuyến nghị chuyển cho phòng Pháp lý" nhưng hành động chuyển (submit) là do con người quyết định. + +## 3. Tách bạch Module Bàn giao (Handover) + +- Việc bàn giao không phải là một "Feedback", mà là một mốc trạng thái cốt lõi của sản phẩm. Nó cần một bảng riêng (`handovers`) và quản lý bằng một Tab riêng biệt trên giao diện chi tiết Căn hộ. diff --git a/ai_instructions/AI_02_DATABASE_SCHEMA_UPGRADE.md b/ai_instructions/AI_02_DATABASE_SCHEMA_UPGRADE.md new file mode 100644 index 00000000..f486aec0 --- /dev/null +++ b/ai_instructions/AI_02_DATABASE_SCHEMA_UPGRADE.md @@ -0,0 +1,41 @@ +# TASK: Nâng cấp Database Schema từ Dữ liệu Excel + +# Context: Đọc quy tắc tại `AI_01_BUSINESS_LOGIC_AND_POLICIES.md` + +Hãy tạo các file Migration và cập nhật các Model tương ứng trong Laravel 13 cho các thực thể sau: + +## 1. Bảng `handovers` (Module Bàn giao) + +- `product_id` (foreignId, constrained) +- `customer_id` (foreignId, constrained - người nhận bàn giao) +- `handover_date` (date - Ngày nhận BG) +- `status` (string - Tình trạng BG: Chưa đủ điều kiện, Đủ ĐK, Đã BG trực tiếp...) +- `remark` (text, nullable - Ghi chú hồ sơ BG) + +## 2. Bảng `contracts` (Module Hợp đồng) + +- `product_id` (foreignId, constrained) +- `customer_id` (foreignId, constrained) +- `type` (string - Enum/Const: LEASE, BCC, SALE) +- `start_date` (date, nullable - Ngày bắt đầu) +- `end_date` (date, nullable - Ngày kết thúc/Thời hạn thanh lý) +- `status` (string - Tình trạng HĐ: Active, Expired, Liquidated...) +- `printed_at` (date, nullable - Ngày in HĐ) +- `signed_status` (string - Tình trạng ký) + +## 3. Bảng `product_services` (Dịch vụ phụ trợ / Tự doanh / Phí QL / Tri ân) + +- `product_id` (foreignId, constrained) +- `service_type` (string - Enum/Const: MANAGEMENT_FEE, GRATITUDE, SELF_BUSINESS) +- `status` (string - Tình trạng) +- `actual_collection_date` (date, nullable - Thời hạn thu thực tế) +- `remark` (text, nullable) + +## 4. Cập nhật Bảng `feedback` (Cốt lõi) + +- Tạo migration bổ sung cột: `table->foreignId('contract_id')->nullable()->constrained('contracts')->nullOnDelete();` + +## Yêu cầu cho AI: + +- Khai báo đầy đủ các Relationships (`hasMany`, `belongsTo`) trong tất cả các Models. +- Chuẩn bị sẵn các `RelationManager` cho Filament 5.6 để gắn vào `ProductResource`. diff --git a/ai_instructions/AI_03_DATA_IMPORT_PIPELINE.md b/ai_instructions/AI_03_DATA_IMPORT_PIPELINE.md new file mode 100644 index 00000000..190254e0 --- /dev/null +++ b/ai_instructions/AI_03_DATA_IMPORT_PIPELINE.md @@ -0,0 +1,36 @@ +# TASK: Xây dựng Command Import Dữ liệu lớn (CSV/Excel) + +# Context: Import file CSKH "phẳng" vào cấu trúc Database Relational mới. + +## Yêu cầu Kỹ thuật + +- Sử dụng package `spatie/simple-excel` với cơ chế **Lazy Collection** (Generator) để chống tràn bộ nhớ (OOM). +- Tạo command: `php artisan app:import-cskh {file_path}`. +- Bọc logic xử lý mỗi chunk (ví dụ 100 dòng) bằng `DB::transaction()`. +- Có `ProgressBar` của Symfony Console để theo dõi. + +## Luồng xử lý Data (Per Row) + +Mỗi `$row` từ file CSV sẽ chạy qua Pipeline sau: + +1. **Định danh Base Entities:** + + - Tìm hoặc tạo mới `Product` bằng `$row['Mã căn hộ']`. + - Tìm hoặc tạo mới `Customer` bằng `$row['Họ tên KH']`. + +2. **Khởi tạo Hợp đồng (Contracts) & Bàn giao (Handovers):** + + - Đọc các cột liên quan đến Bàn Giao, nếu có -> Insert/Update `handovers`. + - Đọc các cột HĐ BCC, HĐ THUÊ, nếu có -> Insert `contracts`. + +3. **Xử lý Feedback & Interactions (Tách dòng lịch sử):** + + - Lấy chuỗi text tại cột `Nội dung` (hoặc `Lịch Sử khách hàng`). + - Sử dụng `explode("\n", $text)` để tách thành mảng các interactions. Loại bỏ các chuỗi rỗng. + - Tìm hợp đồng `Active` gần nhất của Product này để lấy `$contract_id` (nếu có). + - `firstOrCreate` một `Feedback` (Ticket) gắn với `Product`, `Customer`, và `Contract` vừa tìm được. (Tiêu đề mặc định: "Ticket từ dữ liệu lịch sử"). + - Lặp qua mảng interactions vừa tách, tạo các record trong `feedback_interactions` nối với Ticket trên. + +## Lưu ý cho AI: + +- Viết code có cấu trúc rõ ràng, tách các khối logic (Process Product, Process Contract, Process Interactions) thành các private methods trong Command class để dễ bảo trì. diff --git a/ai_instructions/CSKH.xlsx b/ai_instructions/CSKH.xlsx new file mode 100644 index 00000000..fba4c465 Binary files /dev/null and b/ai_instructions/CSKH.xlsx differ diff --git a/ai_instructions/CSKH.xlsx:Zone.Identifier b/ai_instructions/CSKH.xlsx:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/ai_instructions/CSKH.xlsx:Zone.Identifier differ diff --git a/app/Console/Commands/ImportCskh.php b/app/Console/Commands/ImportCskh.php new file mode 100644 index 00000000..d7b5a1ac --- /dev/null +++ b/app/Console/Commands/ImportCskh.php @@ -0,0 +1,446 @@ +argument('file_path'); + $dryRun = $this->option('dry-run'); + + if (! file_exists($filePath)) { + $this->error("File not found: {$filePath}"); + + return self::FAILURE; + } + + if ($dryRun) { + $this->warn('=== DRY RUN MODE — No data will be written ==='); + } + + $rows = SimpleExcelReader::create($filePath)->getRows(); + + $total = iterator_count($rows); + $rows = SimpleExcelReader::create($filePath)->getRows(); + + $this->info("Total rows in file: {$total}"); + $bar = $this->output->createProgressBar($total); + $bar->start(); + + $chunkSize = 100; + $chunk = []; + $rowCount = 0; + + foreach ($rows as $row) { + $rowCount++; + + if ($this->isMetaRow($row)) { + $this->statsSkipped++; + $bar->advance(); + + continue; + } + + $chunk[] = $row; + + if (count($chunk) >= $chunkSize) { + $this->processChunk($chunk, $dryRun); + $chunk = []; + } + + $bar->advance(); + } + + if (! empty($chunk)) { + $this->processChunk($chunk, $dryRun); + } + + $bar->finish(); + $this->newLine(2); + $this->printSummary($rowCount, $dryRun); + + return self::SUCCESS; + } + + private function isMetaRow(array $row): bool + { + $code = $row['Mã căn hộ'] ?? ''; + + if (empty($code) || is_numeric($code)) { + return true; + } + + if ($code === 'Ngày TT' || $code === 'Tình trạng' || $code === 'Tên DN') { + return true; + } + + return false; + } + + private function processChunk(array $rows, bool $dryRun): void + { + if ($dryRun) { + foreach ($rows as $row) { + $this->processRow($row, true); + } + + return; + } + + DB::transaction(function () use ($rows): void { + foreach ($rows as $row) { + $this->processRow($row, false); + } + }); + } + + private function processRow(array $row, bool $dryRun): void + { + $productCode = trim($row['Mã căn hộ'] ?? ''); + $customerName = trim($row['Họ tên KH'] ?? ''); + + if (empty($productCode) || empty($customerName)) { + $this->statsSkipped++; + + return; + } + + if ($dryRun) { + $this->statsProducts++; + $this->statsCustomers++; + + return; + } + + $product = $this->findOrCreateProduct($productCode); + $customer = $this->findOrCreateCustomer($customerName); + + $this->ensureCustomerProduct($product, $customer); + + $this->processContracts($row, $product, $customer); + $this->processHandover($row, $product, $customer); + $this->processServices($row, $product); + + $content = $this->extractContent($row); + if (! empty($content)) { + $this->processFeedback($content, $product, $customer); + } + } + + private function findOrCreateProduct(string $code): Product + { + $p = Product::firstOrCreate( + ['name' => $code], + ['status' => 'active'] + ); + + if ($p->wasRecentlyCreated) { + $this->statsProducts++; + } + + return $p; + } + + private function findOrCreateCustomer(string $name): Customer + { + $c = Customer::firstOrCreate( + ['name' => $name], + ['status' => 'active'] + ); + + if ($c->wasRecentlyCreated) { + $this->statsCustomers++; + } + + return $c; + } + + private function ensureCustomerProduct(Product $product, Customer $customer): void + { + CustomerProduct::firstOrCreate([ + 'customer_id' => $customer->id, + 'product_id' => $product->id, + ]); + } + + private function processContracts(array $row, Product $product, Customer $customer): void + { + $leaseValue = $row['HỢP ĐỒNG THUÊ'] ?? null; + $leaseStr = is_object($leaseValue) ? null : (string) ($leaseValue ?? ''); + if (! empty($leaseStr) && ! is_numeric($leaseStr) && $leaseStr !== 'Tình trạng') { + $exists = Contract::where('product_id', $product->id) + ->where('customer_id', $customer->id) + ->where('type', 'lease') + ->exists(); + + if (! $exists) { + Contract::create([ + 'product_id' => $product->id, + 'customer_id' => $customer->id, + 'type' => 'lease', + 'status' => 'active', + ]); + $this->statsContracts++; + } + } + + $bccValue = $row['HĐ HỢP TÁC KINH DOANH'] ?? null; + $bccStr = is_object($bccValue) ? null : (string) ($bccValue ?? ''); + if (! empty($bccStr) && ! is_numeric($bccStr) && $bccStr !== 'Tình trạng HĐ') { + $exists = Contract::where('product_id', $product->id) + ->where('customer_id', $customer->id) + ->where('type', 'bcc') + ->exists(); + + if (! $exists) { + Contract::create([ + 'product_id' => $product->id, + 'customer_id' => $customer->id, + 'type' => 'bcc', + 'status' => 'active', + 'signed_status' => $bccStr, + ]); + $this->statsContracts++; + } + } + } + + private function processHandover(array $row, Product $product, Customer $customer): void + { + $handoverDate = $this->parseHandoverDate($row['BÀN GIÀO CĂN HỘ'] ?? null); + $statusNote = $row['TÌNH TRẠNG'] ?? null; + $statusStr = is_object($statusNote) ? null : $statusNote; + + if (empty($handoverDate) && empty($statusStr)) { + return; + } + + $exists = Handover::where('product_id', $product->id) + ->where('customer_id', $customer->id) + ->exists(); + + if ($exists) { + return; + } + + Handover::create([ + 'product_id' => $product->id, + 'customer_id' => $customer->id, + 'handover_date' => $handoverDate, + 'status' => $handoverDate ? 'da_ban_giao' : 'chua_du_dieu_kien', + 'remark' => $this->truncateRemark($statusStr), + ]); + $this->statsHandovers++; + } + + private function processServices(array $row, Product $product): void + { + $selfBiz = $row['TỰ DOANH'] ?? null; + $selfBizStr = is_object($selfBiz) ? null : (string) ($selfBiz ?? ''); + if (! empty($selfBizStr) && ! is_numeric($selfBizStr) && $selfBizStr !== 'Tên DN') { + $exists = ProductService::where('product_id', $product->id) + ->where('service_type', 'self_business') + ->exists(); + + if (! $exists) { + ProductService::create([ + 'product_id' => $product->id, + 'service_type' => 'self_business', + 'status' => 'active', + 'remark' => $selfBizStr, + ]); + $this->statsServices++; + } + } + } + + private function extractContent(array $row): ?string + { + $sources = [ + $row['Lịch Sử khách hàng'] ?? null, + $row['Nội dung'] ?? null, + $row['TÌNH TRẠNG'] ?? null, + ]; + + foreach ($sources as $src) { + if (is_object($src)) { + continue; + } + if (! empty($src) && ! is_numeric($src)) { + return (string) $src; + } + } + + return null; + } + + private function processFeedback(string $content, Product $product, Customer $customer): void + { + $activeContract = Contract::where('product_id', $product->id) + ->where('status', 'active') + ->first(); + + $lines = array_values(array_filter( + explode("\n", str_replace("\r\n", "\n", $content)), + fn ($l) => trim($l) !== '' + )); + + if (empty($lines)) { + return; + } + + $title = 'Ticket từ dữ liệu lịch sử'; + + $customerProduct = CustomerProduct::where('customer_id', $customer->id) + ->where('product_id', $product->id) + ->first(); + + $feedback = Feedback::firstOrCreate( + [ + 'customer_id' => $customer->id, + 'customer_product_id' => $customerProduct?->id, + 'title' => $title, + ], + [ + 'content' => $content, + 'status' => 'pending', + 'contract_id' => $activeContract?->id, + 'feedback_channel_id' => 1, + ] + ); + + if ($feedback->wasRecentlyCreated) { + $this->statsFeedbacks++; + } + + $defaultUser = User::first(); + + foreach ($lines as $line) { + $exists = FeedbackInteraction::where('feedback_id', $feedback->id) + ->where('content', $line) + ->exists(); + + if ($exists) { + continue; + } + + FeedbackInteraction::create([ + 'feedback_id' => $feedback->id, + 'user_id' => $defaultUser?->id, + 'type' => 'note', + 'content' => mb_strlen($line) > 500 ? mb_substr($line, 0, 500) : $line, + ]); + $this->statsInteractions++; + } + } + + /** + * Parse the PHP DateTime JSON format: {"date":"2021-06-09 00:00:00.000000","timezone_type":3,"timezone":"UTC"} + */ + private function parseHandoverDate(mixed $value): ?string + { + if (empty($value)) { + return null; + } + + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d'); + } + + if (is_object($value)) { + return null; + } + + if (is_string($value) && str_starts_with($value, '{')) { + $decoded = json_decode($value, true); + + if (is_array($decoded) && ! empty($decoded['date'])) { + $timestamp = strtotime($decoded['date']); + + return $timestamp ? date('Y-m-d', $timestamp) : null; + } + + return null; + } + + return $this->parseDateString((string) $value); + } + + private function parseDateString(string $value): ?string + { + $value = trim($value); + $patterns = [ + '/^\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4}$/', + '/^\d{4}[\/\-\.]\d{1,2}[\/\-\.]\d{1,2}$/', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $value)) { + $cleaned = str_replace('/', '-', $value); + $timestamp = strtotime($cleaned); + + return $timestamp ? date('Y-m-d', $timestamp) : null; + } + } + + return null; + } + + private function truncateRemark(?string $value): ?string + { + if (empty($value) || is_numeric($value)) { + return null; + } + + return mb_strlen($value) > 500 ? mb_substr($value, 0, 500) : $value; + } + + private function printSummary(int $totalRows, bool $dryRun): void + { + $mode = $dryRun ? '[DRY RUN] ' : ''; + + $this->table( + ['Metric', 'Count'], + [ + ['Total rows in file', $totalRows], + ['Skipped (meta/empty)', $this->statsSkipped], + [$mode . 'New Products', $this->statsProducts], + [$mode . 'New Customers', $this->statsCustomers], + [$mode . 'New Contracts', $this->statsContracts], + [$mode . 'New Handovers', $this->statsHandovers], + [$mode . 'New Services', $this->statsServices], + [$mode . 'New Feedbacks', $this->statsFeedbacks], + [$mode . 'New Interactions', $this->statsInteractions], + ] + ); + } +} diff --git a/app/Enums/ContractStatus.php b/app/Enums/ContractStatus.php new file mode 100644 index 00000000..2e22f886 --- /dev/null +++ b/app/Enums/ContractStatus.php @@ -0,0 +1,28 @@ + 'Đang hiệu lực', + self::EXPIRED => 'Hết hạn', + self::LIQUIDATED => 'Đã thanh lý', + }; + } + + public function options(): array + { + return [ + self::ACTIVE->value => self::ACTIVE->label(), + self::EXPIRED->value => self::EXPIRED->label(), + self::LIQUIDATED->value => self::LIQUIDATED->label(), + ]; + } +} diff --git a/app/Enums/ContractType.php b/app/Enums/ContractType.php new file mode 100644 index 00000000..5fbc5e9f --- /dev/null +++ b/app/Enums/ContractType.php @@ -0,0 +1,28 @@ + 'HĐ Mua bán', + self::LEASE => 'HĐ Thuê', + self::BCC => 'HĐ Hợp tác kinh doanh (BCC)', + }; + } + + public static function options(): array + { + return [ + self::SALE->value => self::SALE->label(), + self::LEASE->value => self::LEASE->label(), + self::BCC->value => self::BCC->label(), + ]; + } +} diff --git a/app/Enums/HandoverStatus.php b/app/Enums/HandoverStatus.php new file mode 100644 index 00000000..03884d3b --- /dev/null +++ b/app/Enums/HandoverStatus.php @@ -0,0 +1,31 @@ + 'Chưa đủ điều kiện', + self::READY => 'Đủ điều kiện', + self::HANDED_OVER => 'Đã bàn giao', + self::SCHEDULED => 'Đã lên lịch', + }; + } + + public function options(): array + { + return [ + self::NOT_READY->value => self::NOT_READY->label(), + self::READY->value => self::READY->label(), + self::SCHEDULED->value => self::SCHEDULED->label(), + self::HANDED_OVER->value => self::HANDED_OVER->label(), + ]; + } +} diff --git a/app/Enums/ServiceType.php b/app/Enums/ServiceType.php new file mode 100644 index 00000000..d6f4add0 --- /dev/null +++ b/app/Enums/ServiceType.php @@ -0,0 +1,28 @@ + 'Phí quản lý', + self::GRATITUDE => 'Tri ân', + self::SELF_BUSINESS => 'Tự doanh', + }; + } + + public function options(): array + { + return [ + self::MANAGEMENT_FEE->value => self::MANAGEMENT_FEE->label(), + self::GRATITUDE->value => self::GRATITUDE->label(), + self::SELF_BUSINESS->value => self::SELF_BUSINESS->label(), + ]; + } +} diff --git a/app/Filament/Resources/Contracts/ContractResource.php b/app/Filament/Resources/Contracts/ContractResource.php new file mode 100644 index 00000000..a63bb254 --- /dev/null +++ b/app/Filament/Resources/Contracts/ContractResource.php @@ -0,0 +1,57 @@ + ListContracts::route('/'), + 'create' => CreateContract::route('/create'), + 'edit' => EditContract::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Contracts/Pages/CreateContract.php b/app/Filament/Resources/Contracts/Pages/CreateContract.php new file mode 100644 index 00000000..98522045 --- /dev/null +++ b/app/Filament/Resources/Contracts/Pages/CreateContract.php @@ -0,0 +1,11 @@ +components([ + Section::make('Thông tin hợp đồng') + ->schema([ + Select::make('product_id') + ->relationship('product', 'name') + ->searchable() + ->preload() + ->required(), + Select::make('customer_id') + ->relationship('customer', 'name') + ->searchable() + ->preload() + ->required(), + Select::make('type') + ->options(ContractType::options()) + ->required() + ->native(false), + Select::make('status') + ->options(ContractStatus::ACTIVE->options()) + ->default('active') + ->required() + ->native(false), + DatePicker::make('start_date') + ->label('Ngày bắt đầu'), + DatePicker::make('end_date') + ->label('Ngày kết thúc'), + DatePicker::make('printed_at') + ->label('Ngày in HĐ'), + TextInput::make('signed_status') + ->label('Tình trạng ký') + ->maxLength(255), + ]) + ->columns(2), + ]); + } +} diff --git a/app/Filament/Resources/Contracts/Tables/ContractsTable.php b/app/Filament/Resources/Contracts/Tables/ContractsTable.php new file mode 100644 index 00000000..a260975b --- /dev/null +++ b/app/Filament/Resources/Contracts/Tables/ContractsTable.php @@ -0,0 +1,67 @@ +columns([ + TextColumn::make('id') + ->sortable(), + TextColumn::make('product.name') + ->searchable() + ->sortable(), + TextColumn::make('customer.name') + ->searchable() + ->sortable(), + TextColumn::make('type') + ->badge() + ->formatStateUsing(fn (string $state): string => \App\Enums\ContractType::from($state)->label()), + TextColumn::make('status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'active' => 'success', + 'expired' => 'warning', + 'liquidated' => 'danger', + default => 'gray', + }) + ->formatStateUsing(fn (string $state): string => \App\Enums\ContractStatus::from($state)->label()), + TextColumn::make('start_date') + ->date() + ->sortable() + ->toggleable(), + TextColumn::make('end_date') + ->date() + ->sortable() + ->toggleable(), + TextColumn::make('signed_status') + ->toggleable(), + TextColumn::make('feedbacks_count') + ->counts('feedbacks') + ->label('Tickets'), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php b/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php index 07a8f97e..f110c671 100644 --- a/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php +++ b/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php @@ -47,6 +47,54 @@ class FeedbackForm }) ->visible(fn ($get): bool => ! $get('is_general')) ->nullable() + ->searchable() + ->live(), + + Select::make('contract_id') + ->label('Hợp đồng') + ->visible(fn ($get): bool => ! $get('is_general') && filled($get('customer_product_id'))) + ->options(function ($get): array { + $customerProductId = $get('customer_product_id'); + if (! $customerProductId) { + return []; + } + $cp = \App\Models\CustomerProduct::find($customerProductId); + if (! $cp) { + return []; + } + return \App\Models\Contract::where('product_id', $cp->product_id) + ->where('status', 'active') + ->with('customer') + ->get() + ->mapWithKeys(fn ($c) => [ + $c->id => sprintf('%s (%s) - %s', + \App\Enums\ContractType::from($c->type)->label(), + $c->customer->name, + $c->start_date?->format('d/m/Y') ?? 'N/A', + ), + ]) + ->toArray(); + }) + ->afterStateHydrated(function ($component, $state, $get): void { + if (filled($state)) { + return; + } + $customerProductId = $get('customer_product_id'); + if (! $customerProductId) { + return; + } + $cp = \App\Models\CustomerProduct::find($customerProductId); + if (! $cp) { + return; + } + $activeContract = \App\Models\Contract::where('product_id', $cp->product_id) + ->where('status', 'active') + ->first(); + if ($activeContract) { + $component->state($activeContract->id); + } + }) + ->nullable() ->searchable(), Select::make('feedback_channel_id') diff --git a/app/Filament/Resources/Feedback/Tables/FeedbackTable.php b/app/Filament/Resources/Feedback/Tables/FeedbackTable.php index 3d5ba3b8..02cc6e74 100644 --- a/app/Filament/Resources/Feedback/Tables/FeedbackTable.php +++ b/app/Filament/Resources/Feedback/Tables/FeedbackTable.php @@ -26,6 +26,18 @@ class FeedbackTable ->label('Product') ->placeholder('General') ->sortable(), + TextColumn::make('contract.type') + ->label('Contract') + ->badge() + ->formatStateUsing(fn (?string $state): string => $state ? \App\Enums\ContractType::from($state)->label() : '-') + ->color(fn (?string $state): string => match ($state) { + 'sale' => 'success', + 'lease' => 'info', + 'bcc' => 'warning', + default => 'gray', + }) + ->placeholder('-') + ->toggleable(), TextColumn::make('feedbackChannel.name') ->label('Channel'), TextColumn::make('tags.name') diff --git a/app/Filament/Resources/Products/ProductResource.php b/app/Filament/Resources/Products/ProductResource.php index 0473f0c3..37e52c45 100644 --- a/app/Filament/Resources/Products/ProductResource.php +++ b/app/Filament/Resources/Products/ProductResource.php @@ -5,6 +5,9 @@ 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\RelationManagers\ContractsRelationManager; +use App\Filament\Resources\Products\RelationManagers\HandoversRelationManager; +use App\Filament\Resources\Products\RelationManagers\ProductServicesRelationManager; use App\Filament\Resources\Products\Schemas\ProductForm; use App\Filament\Resources\Products\Tables\ProductsTable; use App\Models\Product; @@ -42,7 +45,9 @@ class ProductResource extends Resource public static function getRelations(): array { return [ - // + ContractsRelationManager::class, + HandoversRelationManager::class, + ProductServicesRelationManager::class, ]; } diff --git a/app/Filament/Resources/Products/RelationManagers/ContractsRelationManager.php b/app/Filament/Resources/Products/RelationManagers/ContractsRelationManager.php new file mode 100644 index 00000000..96d6fc80 --- /dev/null +++ b/app/Filament/Resources/Products/RelationManagers/ContractsRelationManager.php @@ -0,0 +1,62 @@ +recordTitleAttribute('type') + ->columns([ + TextColumn::make('type') + ->badge() + ->formatStateUsing(fn (string $state): string => ContractType::from($state)->label()), + TextColumn::make('customer.name') + ->label('Customer') + ->searchable(), + TextColumn::make('status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'active' => 'success', + 'expired' => 'warning', + 'liquidated' => 'danger', + default => 'gray', + }) + ->formatStateUsing(fn (string $state): string => ContractStatus::from($state)->label()), + TextColumn::make('start_date') + ->date('d/m/Y') + ->sortable(), + TextColumn::make('end_date') + ->date('d/m/Y') + ->sortable(), + TextColumn::make('signed_status') + ->label('Signed'), + TextColumn::make('feedbacks_count') + ->counts('feedbacks') + ->label('Tickets'), + ]) + ->filters([ + SelectFilter::make('type') + ->options(ContractType::options()), + SelectFilter::make('status') + ->options(ContractStatus::ACTIVE->options()), + ]) + ->defaultSort('created_at', 'desc') + ->headerActions([]) + ->recordActions([]); + } +} diff --git a/app/Filament/Resources/Products/RelationManagers/HandoversRelationManager.php b/app/Filament/Resources/Products/RelationManagers/HandoversRelationManager.php new file mode 100644 index 00000000..cd6aad9b --- /dev/null +++ b/app/Filament/Resources/Products/RelationManagers/HandoversRelationManager.php @@ -0,0 +1,57 @@ +recordTitleAttribute('status') + ->columns([ + TextColumn::make('customer.name') + ->label('Customer') + ->searchable() + ->sortable(), + TextColumn::make('handover_date') + ->label('Ngày BG') + ->date('d/m/Y') + ->sortable(), + TextColumn::make('status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'da_ban_giao' => 'success', + 'da_len_lich' => 'info', + 'du_dieu_kien' => 'warning', + 'chua_du_dieu_kien' => 'danger', + default => 'gray', + }) + ->formatStateUsing(fn (string $state): string => HandoverStatus::from($state)->label()), + TextColumn::make('remark') + ->label('Ghi chú') + ->limit(50) + ->toggleable(), + TextColumn::make('created_at') + ->dateTime('d/m/Y') + ->sortable() + ->toggleable(), + ]) + ->filters([ + SelectFilter::make('status') + ->options(HandoverStatus::NOT_READY->options()), + ]) + ->defaultSort('handover_date', 'desc') + ->headerActions([]) + ->recordActions([]); + } +} diff --git a/app/Filament/Resources/Products/RelationManagers/ProductServicesRelationManager.php b/app/Filament/Resources/Products/RelationManagers/ProductServicesRelationManager.php new file mode 100644 index 00000000..4a25d7ea --- /dev/null +++ b/app/Filament/Resources/Products/RelationManagers/ProductServicesRelationManager.php @@ -0,0 +1,68 @@ +recordTitleAttribute('service_type') + ->columns([ + TextColumn::make('service_type') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'management_fee' => 'info', + 'gratitude' => 'success', + 'self_business' => 'warning', + default => 'gray', + }) + ->formatStateUsing(fn (string $state): string => ServiceType::from($state)->label()), + TextColumn::make('status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'active' => 'success', + 'pending' => 'warning', + 'completed' => 'info', + 'cancelled' => 'danger', + default => 'gray', + }), + TextColumn::make('actual_collection_date') + ->label('Ngày thu') + ->date('d/m/Y') + ->sortable(), + TextColumn::make('remark') + ->label('Ghi chú') + ->limit(50) + ->toggleable(), + TextColumn::make('created_at') + ->dateTime('d/m/Y') + ->sortable() + ->toggleable(), + ]) + ->filters([ + SelectFilter::make('service_type') + ->options(ServiceType::MANAGEMENT_FEE->options()), + SelectFilter::make('status') + ->options([ + 'active' => 'Active', + 'pending' => 'Pending', + 'completed' => 'Completed', + 'cancelled' => 'Cancelled', + ]), + ]) + ->defaultSort('created_at', 'desc') + ->headerActions([]) + ->recordActions([]); + } +} diff --git a/app/Models/Contract.php b/app/Models/Contract.php new file mode 100644 index 00000000..f504447c --- /dev/null +++ b/app/Models/Contract.php @@ -0,0 +1,27 @@ +belongsTo(Product::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function feedbacks(): HasMany + { + return $this->hasMany(Feedback::class); + } +} diff --git a/app/Models/Feedback.php b/app/Models/Feedback.php index 73d5af7f..a83d4ed4 100644 --- a/app/Models/Feedback.php +++ b/app/Models/Feedback.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['customer_id', 'customer_product_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])] +#[Fillable(['customer_id', 'customer_product_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])] class Feedback extends Model { public function customer(): BelongsTo @@ -21,6 +21,11 @@ class Feedback extends Model return $this->belongsTo(CustomerProduct::class, 'customer_product_id'); } + public function contract(): BelongsTo + { + return $this->belongsTo(Contract::class); + } + public function feedbackChannel(): BelongsTo { return $this->belongsTo(FeedbackChannel::class); diff --git a/app/Models/Handover.php b/app/Models/Handover.php new file mode 100644 index 00000000..2b58bab3 --- /dev/null +++ b/app/Models/Handover.php @@ -0,0 +1,21 @@ +belongsTo(Product::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index 6523839d..faf7bf8b 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable(['name', 'description', 'status'])] class Product extends Model @@ -15,4 +16,19 @@ class Product extends Model ->withPivot('purchase_date') ->withTimestamps(); } + + public function contracts(): HasMany + { + return $this->hasMany(Contract::class); + } + + public function handovers(): HasMany + { + return $this->hasMany(Handover::class); + } + + public function productServices(): HasMany + { + return $this->hasMany(ProductService::class); + } } diff --git a/app/Models/ProductService.php b/app/Models/ProductService.php new file mode 100644 index 00000000..5b00d560 --- /dev/null +++ b/app/Models/ProductService.php @@ -0,0 +1,18 @@ +belongsTo(Product::class); + } +} diff --git a/app/Policies/ContractPolicy.php b/app/Policies/ContractPolicy.php new file mode 100644 index 00000000..1567f455 --- /dev/null +++ b/app/Policies/ContractPolicy.php @@ -0,0 +1,44 @@ +hasRole(['admin', 'manager', 'staff']); + } + + public function view(User $user, Contract $contract): bool + { + return $user->hasRole(['admin', 'manager', 'staff']); + } + + public function create(User $user): bool + { + return $user->hasRole(['admin', 'manager']); + } + + public function update(User $user, Contract $contract): bool + { + return $user->hasRole(['admin', 'manager']); + } + + public function delete(User $user, Contract $contract): bool + { + return $user->hasRole('admin'); + } + + public function restore(User $user, Contract $contract): bool + { + return $user->hasRole('admin'); + } + + public function forceDelete(User $user, Contract $contract): bool + { + return $user->hasRole('admin'); + } +} diff --git a/composer.json b/composer.json index 01f21268..7574d46a 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ "filament/filament": "^5.5", "laravel/framework": "^13.0", "laravel/tinker": "^3.0", - "spatie/laravel-permission": "*" + "spatie/laravel-permission": "*", + "spatie/simple-excel": "^3.9" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 4718bdc0..602e1505 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f3b7430c94705bbff603985939216158", + "content-hash": "a70c58ea25e9af9dc009c1f80e2316df", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -5366,6 +5366,66 @@ ], "time": "2026-02-01T09:30:04+00:00" }, + { + "name": "spatie/simple-excel", + "version": "3.9.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/simple-excel.git", + "reference": "67095053ff6037284fd213abd84259ea4faca7aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/simple-excel/zipball/67095053ff6037284fd213abd84259ea4faca7aa", + "reference": "67095053ff6037284fd213abd84259ea4faca7aa", + "shasum": "" + }, + "require": { + "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", + "openspout/openspout": "^4.30", + "php": "^8.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.4|^10.5|^11.0|^12.0", + "spatie/pest-plugin-snapshots": "^1.1|^2.1", + "spatie/phpunit-snapshot-assertions": "^4.0|^5.1", + "spatie/temporary-directory": "^1.2|^2.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\SimpleExcel\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Read and write simple Excel and CSV files", + "homepage": "https://github.com/spatie/simple-excel", + "keywords": [ + "simple-excel", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/simple-excel/tree/3.9.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-22T08:49:24+00:00" + }, { "name": "symfony/clock", "version": "v7.4.8", diff --git a/database/migrations/2026_05_01_000000_create_contracts_table.php b/database/migrations/2026_05_01_000000_create_contracts_table.php new file mode 100644 index 00000000..1fce172d --- /dev/null +++ b/database/migrations/2026_05_01_000000_create_contracts_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete(); + $table->string('type'); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->string('status')->default('active'); + $table->date('printed_at')->nullable(); + $table->string('signed_status')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('contracts'); + } +}; diff --git a/database/migrations/2026_05_01_000001_add_contract_id_to_feedback_table.php b/database/migrations/2026_05_01_000001_add_contract_id_to_feedback_table.php new file mode 100644 index 00000000..8dd7bf1f --- /dev/null +++ b/database/migrations/2026_05_01_000001_add_contract_id_to_feedback_table.php @@ -0,0 +1,23 @@ +foreignId('contract_id')->nullable()->after('customer_product_id')->constrained('contracts')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('feedback', function (Blueprint $table) { + $table->dropForeign(['contract_id']); + $table->dropColumn('contract_id'); + }); + } +}; diff --git a/database/migrations/2026_05_01_000002_create_handovers_table.php b/database/migrations/2026_05_01_000002_create_handovers_table.php new file mode 100644 index 00000000..5eeb72e5 --- /dev/null +++ b/database/migrations/2026_05_01_000002_create_handovers_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete(); + $table->date('handover_date')->nullable(); + $table->string('status')->default('chua_du_dieu_kien'); + $table->text('remark')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('handovers'); + } +}; diff --git a/database/migrations/2026_05_01_000003_create_product_services_table.php b/database/migrations/2026_05_01_000003_create_product_services_table.php new file mode 100644 index 00000000..3e2cfd15 --- /dev/null +++ b/database/migrations/2026_05_01_000003_create_product_services_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->string('service_type'); + $table->string('status')->default('active'); + $table->date('actual_collection_date')->nullable(); + $table->text('remark')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_services'); + } +}; diff --git a/database/seeders/AfterSalesSeeder.php b/database/seeders/AfterSalesSeeder.php index 6fa7289c..e1504638 100644 --- a/database/seeders/AfterSalesSeeder.php +++ b/database/seeders/AfterSalesSeeder.php @@ -94,14 +94,139 @@ class AfterSalesSeeder extends Seeder $customers[3]->products()->attach($products[1]->id, ['purchase_date' => '2025-01-05']); $customers[3]->products()->attach($products[2]->id, ['purchase_date' => '2025-02-15']); + $contract1 = \App\Models\Contract::create([ + 'product_id' => $products[0]->id, + 'customer_id' => $customers[0]->id, + 'type' => \App\Enums\ContractType::SALE->value, + 'start_date' => '2024-01-15', + 'status' => \App\Enums\ContractStatus::ACTIVE->value, + 'signed_status' => 'Đã ký', + ]); + + $contract2 = \App\Models\Contract::create([ + 'product_id' => $products[1]->id, + 'customer_id' => $customers[0]->id, + 'type' => \App\Enums\ContractType::LEASE->value, + 'start_date' => '2024-06-01', + 'end_date' => '2026-06-01', + 'status' => \App\Enums\ContractStatus::ACTIVE->value, + 'signed_status' => 'Đã ký', + ]); + + $contract3 = \App\Models\Contract::create([ + 'product_id' => $products[0]->id, + 'customer_id' => $customers[1]->id, + 'type' => \App\Enums\ContractType::SALE->value, + 'start_date' => '2024-03-20', + 'status' => \App\Enums\ContractStatus::ACTIVE->value, + 'signed_status' => 'Đã ký', + ]); + + $contract4 = \App\Models\Contract::create([ + 'product_id' => $products[2]->id, + 'customer_id' => $customers[2]->id, + 'type' => \App\Enums\ContractType::BCC->value, + 'start_date' => '2024-09-10', + 'end_date' => '2029-09-10', + 'status' => \App\Enums\ContractStatus::ACTIVE->value, + 'signed_status' => 'Đang chờ ký', + ]); + + $contract5 = \App\Models\Contract::create([ + 'product_id' => $products[1]->id, + 'customer_id' => $customers[3]->id, + 'type' => \App\Enums\ContractType::LEASE->value, + 'start_date' => '2025-01-05', + 'end_date' => '2026-01-05', + 'status' => \App\Enums\ContractStatus::EXPIRED->value, + 'signed_status' => 'Đã ký', + ]); + $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(); + \App\Models\Handover::create([ + 'product_id' => $products[0]->id, + 'customer_id' => $customers[0]->id, + 'handover_date' => '2024-02-15', + 'status' => \App\Enums\HandoverStatus::HANDED_OVER->value, + 'remark' => 'Đã bàn giao đầy đủ chìa khóa và thẻ căn hộ.', + ]); + + \App\Models\Handover::create([ + 'product_id' => $products[0]->id, + 'customer_id' => $customers[1]->id, + 'handover_date' => '2024-04-20', + 'status' => \App\Enums\HandoverStatus::HANDED_OVER->value, + 'remark' => 'Đã bàn giao, còn thiếu sổ bảo hành nội thất.', + ]); + + \App\Models\Handover::create([ + 'product_id' => $products[1]->id, + 'customer_id' => $customers[0]->id, + 'status' => \App\Enums\HandoverStatus::READY->value, + 'remark' => 'Căn hộ đã hoàn thiện, chờ khách xác nhận lịch bàn giao.', + ]); + + \App\Models\Handover::create([ + 'product_id' => $products[2]->id, + 'customer_id' => $customers[2]->id, + 'handover_date' => '2024-10-01', + 'status' => \App\Enums\HandoverStatus::SCHEDULED->value, + 'remark' => 'Đã hẹn lịch 10h sáng ngày 01/10/2024.', + ]); + + \App\Models\Handover::create([ + 'product_id' => $products[1]->id, + 'customer_id' => $customers[3]->id, + 'status' => \App\Enums\HandoverStatus::NOT_READY->value, + 'remark' => 'Đang hoàn thiện nội thất, dự kiến đủ điều kiện sau 2 tuần.', + ]); + + \App\Models\ProductService::create([ + 'product_id' => $products[0]->id, + 'service_type' => \App\Enums\ServiceType::MANAGEMENT_FEE->value, + 'status' => 'active', + 'actual_collection_date' => '2024-03-01', + 'remark' => 'Phí quản lý hàng tháng 5.000.000 VND.', + ]); + + \App\Models\ProductService::create([ + 'product_id' => $products[0]->id, + 'service_type' => \App\Enums\ServiceType::GRATITUDE->value, + 'status' => 'completed', + 'actual_collection_date' => '2024-06-15', + 'remark' => 'Quà tri ân khách hàng dịp sinh nhật BQL.', + ]); + + \App\Models\ProductService::create([ + 'product_id' => $products[1]->id, + 'service_type' => \App\Enums\ServiceType::MANAGEMENT_FEE->value, + 'status' => 'pending', + 'remark' => 'Chưa thu phí quản lý quý 1/2025.', + ]); + + \App\Models\ProductService::create([ + 'product_id' => $products[2]->id, + 'service_type' => \App\Enums\ServiceType::SELF_BUSINESS->value, + 'status' => 'active', + 'actual_collection_date' => '2024-10-01', + 'remark' => 'Dịch vụ cho thuê hồ bơi và BBQ khu Villa.', + ]); + + \App\Models\ProductService::create([ + 'product_id' => $products[1]->id, + 'service_type' => \App\Enums\ServiceType::GRATITUDE->value, + 'status' => 'cancelled', + 'remark' => 'KH từ chối nhận quà, chuyển sang đợt sau.', + ]); + $feedbacks = [ [ 'customer_id' => $customers[0]->id, 'customer_product_id' => $customerProduct1->id, + 'contract_id' => $contract1->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.', @@ -112,6 +237,7 @@ class AfterSalesSeeder extends Seeder [ 'customer_id' => $customers[0]->id, 'customer_product_id' => $customerProduct2->id, + 'contract_id' => $contract2->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.', @@ -122,6 +248,7 @@ class AfterSalesSeeder extends Seeder [ 'customer_id' => $customers[1]->id, 'customer_product_id' => $customerProduct3->id, + 'contract_id' => $contract3->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.', @@ -132,6 +259,7 @@ class AfterSalesSeeder extends Seeder [ 'customer_id' => $customers[2]->id, 'customer_product_id' => null, + 'contract_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.', @@ -142,6 +270,7 @@ class AfterSalesSeeder extends Seeder [ '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, + 'contract_id' => $contract5->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.', diff --git a/webappUI/aftersales_crm_core/DESIGN.md b/webappUI/aftersales_crm_core/DESIGN.md new file mode 100644 index 00000000..7723cf2c --- /dev/null +++ b/webappUI/aftersales_crm_core/DESIGN.md @@ -0,0 +1,178 @@ +--- +name: AfterSales CRM Core +colors: + surface: '#f9f9ff' + surface-dim: '#d8d9e3' + surface-bright: '#f9f9ff' + surface-container-lowest: '#ffffff' + surface-container-low: '#f2f3fd' + surface-container: '#ecedf7' + surface-container-high: '#e6e7f2' + surface-container-highest: '#e1e2ec' + on-surface: '#191b23' + on-surface-variant: '#424754' + inverse-surface: '#2e3038' + inverse-on-surface: '#eff0fa' + outline: '#727785' + outline-variant: '#c2c6d6' + surface-tint: '#005ac2' + primary: '#0058be' + on-primary: '#ffffff' + primary-container: '#2170e4' + on-primary-container: '#fefcff' + inverse-primary: '#adc6ff' + secondary: '#585f6c' + on-secondary: '#ffffff' + secondary-container: '#dce2f3' + on-secondary-container: '#5e6572' + tertiary: '#924700' + on-tertiary: '#ffffff' + tertiary-container: '#b75b00' + on-tertiary-container: '#fffbff' + error: '#ba1a1a' + on-error: '#ffffff' + error-container: '#ffdad6' + on-error-container: '#93000a' + primary-fixed: '#d8e2ff' + primary-fixed-dim: '#adc6ff' + on-primary-fixed: '#001a42' + on-primary-fixed-variant: '#004395' + secondary-fixed: '#dce2f3' + secondary-fixed-dim: '#c0c7d6' + on-secondary-fixed: '#151c27' + on-secondary-fixed-variant: '#404754' + tertiary-fixed: '#ffdcc6' + tertiary-fixed-dim: '#ffb786' + on-tertiary-fixed: '#311400' + on-tertiary-fixed-variant: '#723600' + background: '#f9f9ff' + on-background: '#191b23' + surface-variant: '#e1e2ec' +typography: + h1: + fontFamily: Manrope + fontSize: 24px + fontWeight: '600' + lineHeight: 32px + h2: + fontFamily: Manrope + fontSize: 20px + fontWeight: '600' + lineHeight: 28px + h3: + fontFamily: Manrope + fontSize: 18px + fontWeight: '600' + lineHeight: 24px + body-base: + fontFamily: Inter + fontSize: 14px + fontWeight: '400' + lineHeight: 20px + body-sm: + fontFamily: Inter + fontSize: 12px + fontWeight: '400' + lineHeight: 16px + label-semibold: + fontFamily: Inter + fontSize: 14px + fontWeight: '600' + lineHeight: 20px + button: + fontFamily: Inter + fontSize: 14px + fontWeight: '500' + lineHeight: 20px +rounded: + sm: 0.125rem + DEFAULT: 0.25rem + md: 0.375rem + lg: 0.5rem + xl: 0.75rem + full: 9999px +spacing: + container-max: 1280px + nav-height: 56px + gutter: 1rem + margin-x: 1.5rem + stack-gap: 1.5rem + grid-columns-desktop: '12' + form-gap: 1.25rem +--- + +## Brand & Style + +This design system is built on a **Corporate / Modern** aesthetic, prioritizing utility, clarity, and reliability. It is designed for high-density information environments where user trust is paramount. The visual language utilizes a refined "Filament-inspired" approach, characterized by clean lines, subtle depth, and a structured hierarchy that minimizes cognitive load during complex CRM tasks. + +The emotional response should be one of "effortless control"—where the interface recedes to let the data lead, but remains aesthetically polished and professional. It balances the rigidity of enterprise software with the fluidity of modern web applications. + +## Colors + +The palette is rooted in a functional spectrum. The **Primary Blue** acts as the main driver for interaction and focus, while the **Neutral Gray** scale provides the structural scaffolding. + +In **Light Mode**, we use a soft gray background (`#f9fafb`) to reduce glare, paired with pure white surfaces for cards. +In **Dark Mode**, the depth is established using a deep navy-black (`#020617`) for the foundation and a slightly lighter charcoal (`#111827`) for elevated components. + +Semantic colors (Success, Warning, Danger) are used sparingly to highlight status changes, ensuring they command attention without overwhelming the user. + +## Typography + +The typography system uses **Manrope** for headings to inject a modern, refined character into the CRM’s data-heavy views. For body text and functional UI elements, **Inter** is utilized for its exceptional legibility at small sizes. + +- **Headings:** Always semibold to provide clear entry points for the eye. +- **Body:** Standardized at 14px (text-sm) to optimize information density without sacrificing readability. +- **Labels:** Use semibold Inter for form labels and table headers to distinguish them from data values. + +## Layout & Spacing + +The system follows a **Fluid Grid** philosophy with fixed breakpoints. +- **Top Navigation:** A sticky 56px bar provides constant access to global controls. +- **Page Layout:** Content is centered in a max-width container (1280px) with 24px (1.5rem) side margins. +- **Forms:** Employ a responsive 2-column layout on desktop, collapsing to a single column on mobile devices. +- **Tables:** Designed for horizontal scrolling on smaller viewports to preserve data integrity. +- **Rhythm:** A 4px/8px baseline grid maintains consistent vertical rhythm across all component stacks. + +## Elevation & Depth + +Visual hierarchy is established through **Tonal Layers** and **Ambient Shadows**. + +- **Level 0 (Background):** Flat, using the base background token. +- **Level 1 (Cards/Tables):** Pure white (light mode) or #111827 (dark mode) with a subtle `shadow-sm` and a 1px border. +- **Level 2 (Dropdowns/Modals):** Increased shadow depth (`shadow-lg`) and a distinct border to separate floating elements from the surface. + +In dark mode, shadows are deepened and opacity is reduced, relying more on subtle border highlights (1px white/10%) to define edges. + +## Shapes + +The shape language is **Soft** and approachable. +- **Components:** Standard buttons, input fields, and cards use a 0.25rem (4px) corner radius. +- **Large Elements:** Modals and large cards use 0.5rem (8px) for a slightly more modern feel. +- **Avatars/Badges:** Notification badges and user avatars use a full pill/circular shape to contrast against the predominantly rectangular UI. + +## Components + +### Buttons +- **Primary:** Solid #3b82f6 background, white text, subtle hover state brightness increase. +- **Secondary:** White background with a 1px gray-300 border and gray-700 text. +- **Tertiary:** Ghost style, no background/border until hover. + +### Inputs & Forms +- Inputs use a consistent 14px text size with a 1px border. +- Focus state: 2px ring of primary color with 20% opacity. +- Form groups: Label on top, followed by input, followed by optional hint text. + +### Cards +- Clean white surfaces with `shadow-sm`. +- Header section separated by a thin light-gray divider. +- Padding should be a consistent 1.5rem (24px) for desktop. + +### Navigation (Top Bar) +- Sticky positioning with a subtle bottom border. +- Nav items use a 14px medium weight; active states are indicated by a primary color shift or subtle bottom indicator. +- Notifications: A 20px circular badge in Danger Red sits at the top right of the bell icon. + +### Tables +- Headers are sticky where possible. +- Row hover states use a very faint gray (gray-50) to assist with line tracking. +- Status indicators (Chips) should use a "Soft" style: light background tint with high-contrast text of the same hue (e.g., Success green text on a light green background). \ No newline at end of file diff --git a/webappUI/aftersales_crm_core/DESIGN.md:Zone.Identifier b/webappUI/aftersales_crm_core/DESIGN.md:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/aftersales_crm_core/DESIGN.md:Zone.Identifier differ diff --git a/webappUI/channel_list_desktop/code.html b/webappUI/channel_list_desktop/code.html new file mode 100644 index 00000000..17db877b --- /dev/null +++ b/webappUI/channel_list_desktop/code.html @@ -0,0 +1,365 @@ + + + + + +Channels - AfterSales CRM + + + + + + + + + + + + + + +
+
+ +
+AfterSales CRM + + +
+ +
+ + +
+User profile +
+
+
+
+ +
+ +
+
+

Channels

+

Manage intake sources and communication pathways.

+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameSlugStatusFeedbacksCreatedActions
+
+
+language +
+Web Portal +
+
web-portal + +check_circle + Active + +12,450Oct 12, 2023 +
+ +
+
+
+
+smartphone +
+iOS App +
+
ios-app + +check_circle + Active + +8,204Nov 05, 2023 +
+ +
+
+
+
+android +
+Android App +
+
android-app + +cancel + Inactive + +542Jan 22, 2024 +
+ +
+
+
+
+mail +
+Email Parsing +
+
email-parsing + +check_circle + Active + +45,102Sep 01, 2023 +
+ +
+
+
+ +
+Showing 1 to 4 of 4 results +
+ + +
+
+
+
+ + + +
+ +
+ +
+

Edit Channel

+ +
+ +
+ +
+ + +
+ +
+ + +Used for API routing. Must be unique. +
+ +
+ +
+ +smartphone + + +
+Use Material Symbol names (e.g., 'mail', 'android'). +
+ +
+
+ +Allow new intake from this channel. +
+ + +
+
+ +
+ + +
+
+
+ \ No newline at end of file diff --git a/webappUI/channel_list_desktop/code.html:Zone.Identifier b/webappUI/channel_list_desktop/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/channel_list_desktop/code.html:Zone.Identifier differ diff --git a/webappUI/channel_list_desktop/screen.png b/webappUI/channel_list_desktop/screen.png new file mode 100644 index 00000000..2cb3b00e Binary files /dev/null and b/webappUI/channel_list_desktop/screen.png differ diff --git a/webappUI/channel_list_desktop/screen.png:Zone.Identifier b/webappUI/channel_list_desktop/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/channel_list_desktop/screen.png:Zone.Identifier differ diff --git a/webappUI/create_edit_channel_desktop/code.html b/webappUI/create_edit_channel_desktop/code.html new file mode 100644 index 00000000..a8932f0f --- /dev/null +++ b/webappUI/create_edit_channel_desktop/code.html @@ -0,0 +1,198 @@ + + + + + +Create Channel - AfterSales CRM + + + + + + + + + +
+ +
+ + +
+

Create New Channel

+
+

+ Define a new distribution or communication channel for your products. +

+
+ +
+
+
+

Channel Details

+ +
+ +
+ + +
+ +
+ +
+ + ch_ + + +
+

Used for API integration.

+
+ +
+ +
+ +search + + + +storefront + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ \ No newline at end of file diff --git a/webappUI/create_edit_channel_desktop/code.html:Zone.Identifier b/webappUI/create_edit_channel_desktop/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/create_edit_channel_desktop/code.html:Zone.Identifier differ diff --git a/webappUI/create_edit_channel_desktop/screen.png b/webappUI/create_edit_channel_desktop/screen.png new file mode 100644 index 00000000..544d3aad Binary files /dev/null and b/webappUI/create_edit_channel_desktop/screen.png differ diff --git a/webappUI/create_edit_channel_desktop/screen.png:Zone.Identifier b/webappUI/create_edit_channel_desktop/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/create_edit_channel_desktop/screen.png:Zone.Identifier differ diff --git a/webappUI/create_edit_channel_desktop_horizontal_menu/code.html b/webappUI/create_edit_channel_desktop_horizontal_menu/code.html new file mode 100644 index 00000000..264463ca --- /dev/null +++ b/webappUI/create_edit_channel_desktop_horizontal_menu/code.html @@ -0,0 +1,228 @@ + + + + + +Create Channel - AfterSales CRM + + + + + + + + + + +
+ +
+ + +
+

Create New Channel

+
+

+ Define a new distribution or communication channel for your products. +

+
+ +
+
+
+

Channel Details

+ +
+ +
+ + +
+ +
+ +
+ + ch_ + + +
+

Used for API integration.

+
+ +
+ +
+ +search + + + +storefront + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ \ No newline at end of file diff --git a/webappUI/create_edit_channel_desktop_horizontal_menu/code.html:Zone.Identifier b/webappUI/create_edit_channel_desktop_horizontal_menu/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/create_edit_channel_desktop_horizontal_menu/code.html:Zone.Identifier differ diff --git a/webappUI/create_edit_channel_desktop_horizontal_menu/screen.png b/webappUI/create_edit_channel_desktop_horizontal_menu/screen.png new file mode 100644 index 00000000..5aa16b31 Binary files /dev/null and b/webappUI/create_edit_channel_desktop_horizontal_menu/screen.png differ diff --git a/webappUI/create_edit_channel_desktop_horizontal_menu/screen.png:Zone.Identifier b/webappUI/create_edit_channel_desktop_horizontal_menu/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/create_edit_channel_desktop_horizontal_menu/screen.png:Zone.Identifier differ diff --git a/webappUI/create_edit_product_desktop_horizontal_menu/code.html b/webappUI/create_edit_product_desktop_horizontal_menu/code.html new file mode 100644 index 00000000..17b6aada --- /dev/null +++ b/webappUI/create_edit_product_desktop_horizontal_menu/code.html @@ -0,0 +1,225 @@ + + + + + +AfterSales CRM - New Product + + + + + + + +
+
+ +
+

AfterSales CRM

+
+ + + +
+ + +
+User Avatar +
+
+
+
+ +
+ +
+
+ + + +
+

New Product

+

Enter the details below to add a new item to the inventory catalog.

+
+ +
+
+ +
+ +
+ + +
+ +
+ +
+ +
+expand_more +
+
+
+ +
+ + +
+
+ +
+ + +
+
+
+
+
+
+ \ No newline at end of file diff --git a/webappUI/create_edit_product_desktop_horizontal_menu/code.html:Zone.Identifier b/webappUI/create_edit_product_desktop_horizontal_menu/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/create_edit_product_desktop_horizontal_menu/code.html:Zone.Identifier differ diff --git a/webappUI/create_edit_product_desktop_horizontal_menu/screen.png b/webappUI/create_edit_product_desktop_horizontal_menu/screen.png new file mode 100644 index 00000000..ca569ac2 Binary files /dev/null and b/webappUI/create_edit_product_desktop_horizontal_menu/screen.png differ diff --git a/webappUI/create_edit_product_desktop_horizontal_menu/screen.png:Zone.Identifier b/webappUI/create_edit_product_desktop_horizontal_menu/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/create_edit_product_desktop_horizontal_menu/screen.png:Zone.Identifier differ diff --git a/webappUI/create_edit_tag_desktop_horizontal_menu/code.html b/webappUI/create_edit_tag_desktop_horizontal_menu/code.html new file mode 100644 index 00000000..3570690d --- /dev/null +++ b/webappUI/create_edit_tag_desktop_horizontal_menu/code.html @@ -0,0 +1,207 @@ + + + + + +Create Tag - AfterSales CRM + + + + + + + + + +
+ +
+
+ +
+ +
+

Create New Tag

+
+
+ +
+
+

Tag Details

+

Configure the identity and appearance of your tag.

+
+
+
+ +
+ + +

The display name of the tag used across the CRM.

+
+ +
+ + +

A unique identifier used in URLs and API responses (auto-generated if left blank).

+
+ +
+ +
+ +
+
#
+ +
+ +
+ + + + + + +
+
+

Select a color to visually distinguish this tag.

+
+
+
+ +
+ + +
+
+
+
+
+ \ No newline at end of file diff --git a/webappUI/create_edit_tag_desktop_horizontal_menu/code.html:Zone.Identifier b/webappUI/create_edit_tag_desktop_horizontal_menu/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/create_edit_tag_desktop_horizontal_menu/code.html:Zone.Identifier differ diff --git a/webappUI/create_edit_tag_desktop_horizontal_menu/screen.png b/webappUI/create_edit_tag_desktop_horizontal_menu/screen.png new file mode 100644 index 00000000..09201dfd Binary files /dev/null and b/webappUI/create_edit_tag_desktop_horizontal_menu/screen.png differ diff --git a/webappUI/create_edit_tag_desktop_horizontal_menu/screen.png:Zone.Identifier b/webappUI/create_edit_tag_desktop_horizontal_menu/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/create_edit_tag_desktop_horizontal_menu/screen.png:Zone.Identifier differ diff --git a/webappUI/create_feedback_desktop/code.html b/webappUI/create_feedback_desktop/code.html new file mode 100644 index 00000000..a442f868 --- /dev/null +++ b/webappUI/create_feedback_desktop/code.html @@ -0,0 +1,293 @@ + + + + + +Feedback - AfterSales CRM + + + + + + + + + + + +
+
+

Create Feedback

+
+ + +
+
+
+ +
+ +
+
+
+ +
+search + +
+
+
+ + +arrow_drop_down +
+
+
+ + +
+
+ + +arrow_drop_down +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+

Attachments

+General, Proof, Evidence +
+
+
+cloud_upload +
+

Click to upload or drag and drop

+

SVG, PNG, JPG or PDF (max. 10MB)

+
+
+
+ +
+ +
+
+ +
+Pending +pending +
+
+
+ +
+
+ Feature Request + close +
+ +
+
+
+ +
+
+ + +arrow_drop_down +
+
+ + +arrow_drop_down +
+
+ +
+
+

+admin_panel_settings + Admin Controls +

+
+
+
+ +Flag for immediate manager review +
+ +
+
+
+
+
+ + + \ No newline at end of file diff --git a/webappUI/create_feedback_desktop/code.html:Zone.Identifier b/webappUI/create_feedback_desktop/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/create_feedback_desktop/code.html:Zone.Identifier differ diff --git a/webappUI/create_feedback_desktop/screen.png b/webappUI/create_feedback_desktop/screen.png new file mode 100644 index 00000000..12ce468d Binary files /dev/null and b/webappUI/create_feedback_desktop/screen.png differ diff --git a/webappUI/create_feedback_desktop/screen.png:Zone.Identifier b/webappUI/create_feedback_desktop/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/create_feedback_desktop/screen.png:Zone.Identifier differ diff --git a/webappUI/customer_list_desktop/code.html b/webappUI/customer_list_desktop/code.html new file mode 100644 index 00000000..4d2e64b1 --- /dev/null +++ b/webappUI/customer_list_desktop/code.html @@ -0,0 +1,310 @@ + + + + + +Customers - AfterSales CRM + + + + + + + + + + +
+ +
+
+
+Home +chevron_right +Customers +
+

Customers

+
+ +
+ +
+ +
+
+search + +
+
+Status: + + +
+expand_more +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameEmailPhoneStatusProductsFeedbacksCreated
+
+Avatar +Alex Mercer +
+
alex.m@example.com+1 (555) 123-4567 +Active +312Oct 12, 2023 + +
+
+
SJ
+Sarah Jenkins +
+
s.jenkins@corp.com+44 20 7123 4567 +Inactive +10Nov 05, 2023 + +
+
+Avatar +Elena Rodriguez +
+
elena.r@startup.io+1 (555) 987-6543 +Pending +524Jan 18, 2024 + +
+
+
DT
+David Thompson +
+
david.t@globaltech.net+61 4 1234 5678 +Active +25Feb 02, 2024 + +
+
+ +
+Showing 1 to 4 of 24 entries +
+ + + + + +
+
+
+
+ + + \ No newline at end of file diff --git a/webappUI/customer_list_desktop/code.html:Zone.Identifier b/webappUI/customer_list_desktop/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/customer_list_desktop/code.html:Zone.Identifier differ diff --git a/webappUI/customer_list_desktop/screen.png b/webappUI/customer_list_desktop/screen.png new file mode 100644 index 00000000..f3b25c1c Binary files /dev/null and b/webappUI/customer_list_desktop/screen.png differ diff --git a/webappUI/customer_list_desktop/screen.png:Zone.Identifier b/webappUI/customer_list_desktop/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/customer_list_desktop/screen.png:Zone.Identifier differ diff --git a/webappUI/dashboard_desktop/code.html b/webappUI/dashboard_desktop/code.html new file mode 100644 index 00000000..527bf7e0 --- /dev/null +++ b/webappUI/dashboard_desktop/code.html @@ -0,0 +1,420 @@ + + + + + +AfterSales CRM - Dashboard + + + + + + + + + + +
+
+
+ +
+ AfterSales CRM +
+ + +
+ +
+ +
+ +User avatar +
+
+
+
+ +
+ +
+
+

Dashboard Overview

+

Real-time metrics and ticket tracking.

+
+
+ + +
+
+ +
+ +
+
+
+

Resolved Tickets

+
+check_circle +
+
+
+128 + +trending_up 12% + +
+

vs last month

+
+ +
+
+
+

Avg Resolution Time

+
+timer +
+
+
+45 min + +trending_down 5% + +
+

vs last month

+
+ +
+
+
+

Pending Tickets

+
+pending_actions +
+
+
+12 + +trending_up 2 + +
+

requires immediate action

+
+ +
+
+
+

Escalated

+
+assignment_late +
+
+
+5 +
+

assigned to management

+
+
+ +
+ +
+
+

Avg Processing Time by Handler

+ +
+
+ +
+60m +45m +30m +15m +0m +
+ +
+ +
+
+
+
+ +
+
+Alice W. +
+ +
+
+Bob M. +
+ +
+
+Charlie D. +
+ +
+
+Diana S. +
+ +
+
+Evan T. +
+
+
+
+ +
+ +
+
+

System Insights

+
+
+warning +
+

High Volume Alert

+

Returns department seeing 20% higher volume today.

+
+
+
+check_circle +
+

SLA Target Met

+

Global resolution time is under 48 hours target.

+
+
+
+
+ +
+
+ +
+
+

Tickets Awaiting Closure

+
+
+search + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#TicketCustomerHandlerDepartmentResolved DateActions
1TKT-8902TechCorp Inc. +
+
AW
+ Alice W. +
+
Hardware SupportOct 24, 2023 + +
2TKT-8905Jane Smith +
+
BM
+ Bob M. +
+
BillingOct 24, 2023 + +
3TKT-8911Acme Logistics +
+
CD
+ Charlie D. +
+
Software ConfigOct 23, 2023 + +
4TKT-8915Global Retail +
+
DS
+ Diana S. +
+
ReturnsOct 22, 2023 + +
+
+ +
+Showing 1 to 4 of 12 entries +
+ + + + + +
+
+
+
+ \ No newline at end of file diff --git a/webappUI/dashboard_desktop/code.html:Zone.Identifier b/webappUI/dashboard_desktop/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/dashboard_desktop/code.html:Zone.Identifier differ diff --git a/webappUI/dashboard_desktop/screen.png b/webappUI/dashboard_desktop/screen.png new file mode 100644 index 00000000..e17dae9a Binary files /dev/null and b/webappUI/dashboard_desktop/screen.png differ diff --git a/webappUI/dashboard_desktop/screen.png:Zone.Identifier b/webappUI/dashboard_desktop/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/dashboard_desktop/screen.png:Zone.Identifier differ diff --git a/webappUI/edit_customer_desktop/code.html b/webappUI/edit_customer_desktop/code.html new file mode 100644 index 00000000..f361344d --- /dev/null +++ b/webappUI/edit_customer_desktop/code.html @@ -0,0 +1,355 @@ + + + + + +Edit Customer - AfterSales CRM + + + + + + + + + +
+
+
AfterSales CRM
+ +
+
+ + +
+User profile +
+
+
+ +
+ +
+
+
+Customers +chevron_right +Alex Mercer +
+

+
AM
+ Edit Customer Profile +

+
+
+ + +
+
+
+ +
+ +
+
+

Contact Information

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ +
+
+

Owned Products

+ +
+
+
+
+Pro Series Router AC3200 + +
+
+Mesh Wi-Fi Node (Gen 2) + +
+
+
+ +search +
+
+
+
+ +
+ +
+
+

Billing Address

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TitleProductStatusTagsChannelInteractionsCreated
+Router keeps dropping connection on 5GHz +Pro Series Router AC3200 +Open + +
+Connectivity +Urgent +
+
+mail Email + 4Oct 12, 2024
+Feature Request: Guest Network Scheduling +Pro Series Router AC3200 +Under Review + +
+Enhancement +
+
+forum Portal + 1Sep 28, 2024
+Setup was confusing, took 2 hours +Mesh Wi-Fi Node (Gen 2) +Closed + +
+UX +Docs +
+
+phone_in_talk Phone + 2Aug 05, 2024
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/webappUI/edit_customer_desktop/code.html:Zone.Identifier b/webappUI/edit_customer_desktop/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/edit_customer_desktop/code.html:Zone.Identifier differ diff --git a/webappUI/edit_customer_desktop/screen.png b/webappUI/edit_customer_desktop/screen.png new file mode 100644 index 00000000..e4b1d35e Binary files /dev/null and b/webappUI/edit_customer_desktop/screen.png differ diff --git a/webappUI/edit_customer_desktop/screen.png:Zone.Identifier b/webappUI/edit_customer_desktop/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/edit_customer_desktop/screen.png:Zone.Identifier differ diff --git a/webappUI/edit_feedback_desktop/code.html b/webappUI/edit_feedback_desktop/code.html new file mode 100644 index 00000000..79e05946 --- /dev/null +++ b/webappUI/edit_feedback_desktop/code.html @@ -0,0 +1,404 @@ + + + + + +Edit Feedback - AfterSales CRM + + + + + + + + + + +
+ +
+
+ + +
+ +

+ Edit Feedback + In Progress +Technical Support +

+
+
+ +
+ + + + + +
+
+ +
+ + +
+ +
+ +
+ +
+
+

Core Information

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +

Provide a detailed description of the customer's issue or feedback.

+
+
+
+ +
+
+

Attachments & Evidence

+2 Files +
+
+
+
+
+image +
+
+

IMG_8492.jpg

+

2.4 MB • Evidence

+
+ +
+
+
+description +
+
+

invoice_copy.pdf

+

450 KB • Proof

+
+ +
+
+
+
+cloud_upload +
+

Click to upload or drag and drop

+

SVG, PNG, JPG or PDF (max. 10MB)

+
+ +
+
+
+
+
+ +
+ +
+
+

Routing & Status

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ +
+
+

Classification

+
+
+
+ +
+ + Hardware Fault + + + + Display + + +
+
+ +sell +
+
+
+
+
+
+ +
+
+

+search + Similar Cases +

+Matching tags: Hardware Fault, Display +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
TitleCustomerStatusHandlerDate
Screen bleeding on edgeDesignWorks Studio +Resolved +Sarah JenkinsOct 12, 2023
Flickering when using HDMI 2.0Alex Mercer +Closed +Mike RossSep 28, 2023
+
+
+
+ \ No newline at end of file diff --git a/webappUI/edit_feedback_desktop/code.html:Zone.Identifier b/webappUI/edit_feedback_desktop/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/edit_feedback_desktop/code.html:Zone.Identifier differ diff --git a/webappUI/edit_feedback_desktop/screen.png b/webappUI/edit_feedback_desktop/screen.png new file mode 100644 index 00000000..b81a3771 Binary files /dev/null and b/webappUI/edit_feedback_desktop/screen.png differ diff --git a/webappUI/edit_feedback_desktop/screen.png:Zone.Identifier b/webappUI/edit_feedback_desktop/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/edit_feedback_desktop/screen.png:Zone.Identifier differ diff --git a/webappUI/feedback_list_desktop/code.html b/webappUI/feedback_list_desktop/code.html new file mode 100644 index 00000000..357f8de3 --- /dev/null +++ b/webappUI/feedback_list_desktop/code.html @@ -0,0 +1,457 @@ + + + + + +AfterSales CRM - Feedbacks + + + + + + + + +
+
+ +
+AfterSales CRM + + +
+ +
+ +
+User avatar + +
+
+
+
+ +
+ +
+
+ + +

Feedbacks

+
+
+ +
+
+ +
+
+ +
+ +
+ +arrow_drop_down +
+ +
+ +arrow_drop_down +
+ +
+ +arrow_drop_down +
+ +
+ +arrow_drop_down +
+ +
+ +
+
+ + +
+
+search + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +# ID + Title + +CustomerProductChannelTagsStatusDeptHandlerEscCreated
+ +FB-1042 +Device won't power on after update + +
Sarah Jenkins
+
Corp Solutions Inc.
+
ProScanner 500Email +
+Hardware +Urgent +
+
+Pending +TechnicalUnassigned +warning +Oct 24, 09:15
+ +FB-1041 +Billing discrepancy for Q3 + +
Marcus Vance
+
Global Logistics
+
Enterprise SubPortal +
+Finance +
+
+Processing +Billing +
+
JD
+Jane Doe +
+
+remove +Oct 23, 14:30
+ +FB-1040 +Feature request: dark mode + +
Elena Rostova
+
Web AppPortal +
+UI/UX +Feature +
+
+Resolved +Support +
+
TR
+Tom Riddle +
+
+check_circle +Oct 22, 11:05
+ +FB-1039 +Cannot reset password + +
Anonymous
+
GeneralPhone +
+Access +
+
+Closed +Support +
+
JD
+Jane Doe +
+
+check_circle +Oct 20, 16:45
+
+ +
+Showing 1 to 4 of 42 entries +
+ + + + +... + + +
+
+
+
+ \ No newline at end of file diff --git a/webappUI/feedback_list_desktop/code.html:Zone.Identifier b/webappUI/feedback_list_desktop/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/feedback_list_desktop/code.html:Zone.Identifier differ diff --git a/webappUI/feedback_list_desktop/screen.png b/webappUI/feedback_list_desktop/screen.png new file mode 100644 index 00000000..e81da83b Binary files /dev/null and b/webappUI/feedback_list_desktop/screen.png differ diff --git a/webappUI/feedback_list_desktop/screen.png:Zone.Identifier b/webappUI/feedback_list_desktop/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/feedback_list_desktop/screen.png:Zone.Identifier differ diff --git a/webappUI/product_list_desktop/code.html b/webappUI/product_list_desktop/code.html new file mode 100644 index 00000000..778689b3 --- /dev/null +++ b/webappUI/product_list_desktop/code.html @@ -0,0 +1,290 @@ + + + + + +Products - AfterSales CRM + + + + + + + + +
+
+
+AfterSales CRM + +
+
+ + + +User profile +
+
+
+
+
+ +
+

Products

+
+
+search + +
+ +
+
+
+
+
+
+ +filter_list Filter: + +
+ +expand_more +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameStatusDescriptionOwnersCreated
Enterprise Server Rack Q4 + + Active + + + High-density server rack designed for optimal ai... + 1,402Oct 12, 2023
Cloud Gateway Pro + + Active + + + Secure Edge routing gateway with integrated thre... + 845Nov 05, 2023
Legacy Switch Module X1 + + Inactive + + + Deprecated 24-port switch module. Scheduled for ... + 312Jan 18, 2021
Quantum Encryption Key V2 + + Sold Out + + + Hardware security module for post-quantum crypto... + 2,055Feb 22, 2024
Optical Transceiver 100G + + Active + + + High-speed optical module for long-haul fiber co... + 4,190Mar 10, 2024
+
+
+Showing 1 to 5 of 124 entries +
+ + + + +... + + +
+
+
+
+ + \ No newline at end of file diff --git a/webappUI/product_list_desktop/code.html:Zone.Identifier b/webappUI/product_list_desktop/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/product_list_desktop/code.html:Zone.Identifier differ diff --git a/webappUI/product_list_desktop/screen.png b/webappUI/product_list_desktop/screen.png new file mode 100644 index 00000000..7d1cbebe Binary files /dev/null and b/webappUI/product_list_desktop/screen.png differ diff --git a/webappUI/product_list_desktop/screen.png:Zone.Identifier b/webappUI/product_list_desktop/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/product_list_desktop/screen.png:Zone.Identifier differ diff --git a/webappUI/tag_list_desktop/code.html b/webappUI/tag_list_desktop/code.html new file mode 100644 index 00000000..47cd38ed --- /dev/null +++ b/webappUI/tag_list_desktop/code.html @@ -0,0 +1,356 @@ + + + + + +AfterSales CRM - Tags + + + + + + + + + + + + + +
+
+ +
+AfterSales CRM + +
+ + + +
+ + +
+User profile +
+
+
+
+ +
+ +
+
+

Tag Management

+

Organize and categorize your CRM data with descriptive tags.

+
+
+ + +
+
+ +
+ +
+
+ +
+
+search + +
+Showing 5 of 24 tags +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameSlugColorUpdatedActions
VIP Customervip-customer +
+
+Gold +
+
2 hours ago + + +
High Priorityhigh-priority +
+
+Red +
+
Yesterday + + +
Feature Requestfeature-request +
+
+Blue +
+
Oct 24, 2023 + + +
Bug Reportbug-report +
+
+Orange +
+
Oct 20, 2023 + + +
Documentationdocumentation +
+
+Gray +
+
Sep 15, 2023 + + +
+
+ +
+ +
+ + + +
+ +
+
+
+ +
+
+
+

Edit Tag

+Editing +
+ +
+ + +

The display name used in the UI.

+
+ +
+ + +

Auto-generated identifier. Use lowercase and hyphens.

+
+ +
+ +
+
+ + + + + + + + + + + + + +
+
+
+ +
+ + +
+
+
+
+
+ + + \ No newline at end of file diff --git a/webappUI/tag_list_desktop/code.html:Zone.Identifier b/webappUI/tag_list_desktop/code.html:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/tag_list_desktop/code.html:Zone.Identifier differ diff --git a/webappUI/tag_list_desktop/screen.png b/webappUI/tag_list_desktop/screen.png new file mode 100644 index 00000000..1499a15b Binary files /dev/null and b/webappUI/tag_list_desktop/screen.png differ diff --git a/webappUI/tag_list_desktop/screen.png:Zone.Identifier b/webappUI/tag_list_desktop/screen.png:Zone.Identifier new file mode 100644 index 00000000..d6c1ec68 Binary files /dev/null and b/webappUI/tag_list_desktop/screen.png:Zone.Identifier differ