Version 2.0
This commit is contained in:
36
AGENTS.md
36
AGENTS.md
@@ -114,6 +114,7 @@ minicrm/
|
|||||||
│ │ ├── ImportCskh.php # app:import-cskh
|
│ │ ├── ImportCskh.php # app:import-cskh
|
||||||
│ │ └── ExportBackup.php # app:export-backup
|
│ │ └── ExportBackup.php # app:export-backup
|
||||||
│ ├── Enums/
|
│ ├── Enums/
|
||||||
|
│ │ ├── ContractCategory.php # V2: ownership/business
|
||||||
│ │ ├── ContractType.php
|
│ │ ├── ContractType.php
|
||||||
│ │ ├── ContractStatus.php
|
│ │ ├── ContractStatus.php
|
||||||
│ │ ├── HandoverStatus.php
|
│ │ ├── HandoverStatus.php
|
||||||
@@ -123,11 +124,10 @@ minicrm/
|
|||||||
│ │ └── Middleware/HandleUploadErrors.php
|
│ │ └── Middleware/HandleUploadErrors.php
|
||||||
│ ├── Models/
|
│ ├── Models/
|
||||||
│ │ ├── ActivityLog.php # NEW: Audit trail model
|
│ │ ├── ActivityLog.php # NEW: Audit trail model
|
||||||
│ │ ├── Contract.php
|
│ │ ├── Contract.php # V2: +category, +representative_*
|
||||||
│ │ ├── Customer.php
|
│ │ ├── Customer.php # V2: ownedProducts() qua contracts
|
||||||
│ │ ├── CustomerProduct.php
|
|
||||||
│ │ ├── Department.php
|
│ │ ├── Department.php
|
||||||
│ │ ├── Feedback.php
|
│ │ ├── Feedback.php # V2: bỏ customer_product_id
|
||||||
│ │ ├── FeedbackAttachment.php
|
│ │ ├── FeedbackAttachment.php
|
||||||
│ │ ├── FeedbackChannel.php
|
│ │ ├── FeedbackChannel.php
|
||||||
│ │ ├── FeedbackInteraction.php
|
│ │ ├── FeedbackInteraction.php
|
||||||
@@ -136,7 +136,7 @@ minicrm/
|
|||||||
│ │ ├── Product.php
|
│ │ ├── Product.php
|
||||||
│ │ ├── ProductService.php
|
│ │ ├── ProductService.php
|
||||||
│ │ ├── TicketTransferLog.php
|
│ │ ├── TicketTransferLog.php
|
||||||
│ │ └── User.php # HasRoles (Spatie), isAdmin(), isManager()
|
│ │ └── User.php
|
||||||
│ ├── Policies/
|
│ ├── Policies/
|
||||||
│ │ ├── ActivityLogPolicy.php # NEW
|
│ │ ├── ActivityLogPolicy.php # NEW
|
||||||
│ │ ├── ContractPolicy.php
|
│ │ ├── ContractPolicy.php
|
||||||
@@ -192,7 +192,26 @@ minicrm/
|
|||||||
| metadata | json nullable | Extra data |
|
| metadata | json nullable | Extra data |
|
||||||
| created_at | timestamp | |
|
| created_at | timestamp | |
|
||||||
|
|
||||||
### departments
|
### contracts (V2: category + representative)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint PK | |
|
||||||
|
| product_id | fk -> products | cascadeOnDelete |
|
||||||
|
| customer_id | fk -> customers | cascadeOnDelete |
|
||||||
|
| category | string | **(V2)** ownership \| business |
|
||||||
|
| type | string | **(V2)** deposit, purchase, transfer, rental, self_business, bcc |
|
||||||
|
| start_date, end_date | date nullable | |
|
||||||
|
| status | string | active, expired, liquidated |
|
||||||
|
| printed_at | date nullable | |
|
||||||
|
| signed_status | string nullable | |
|
||||||
|
| representative_name | string nullable | **(V2)** |
|
||||||
|
| representative_phone | string nullable | **(V2)** |
|
||||||
|
| representative_email | string nullable | **(V2)** |
|
||||||
|
| created_at, updated_at | timestamp | |
|
||||||
|
|
||||||
|
> **V2 Constraint:** Mỗi product chỉ có tối đa 1 contract active mỗi category. Validation ở cả Model level (`booted::saving`) và UI.
|
||||||
|
|
||||||
|
### departments (NEW)
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
|--------|------|-------|
|
|--------|------|-------|
|
||||||
| id | bigint PK | |
|
| id | bigint PK | |
|
||||||
@@ -216,8 +235,7 @@ minicrm/
|
|||||||
|--------|------|-------|
|
|--------|------|-------|
|
||||||
| id | bigint PK | |
|
| id | bigint PK | |
|
||||||
| customer_id | fk -> customers | cascadeOnDelete |
|
| customer_id | fk -> customers | cascadeOnDelete |
|
||||||
| customer_product_id | fk -> customer_product nullable | null = general feedback |
|
| contract_id | fk -> contracts nullable | **(V2)** Snapshot contract (thay thế customer_product_id) |
|
||||||
| contract_id | fk -> contracts nullable | **(NEW)** Snapshot contract |
|
|
||||||
| feedback_channel_id | fk -> feedback_channels | |
|
| feedback_channel_id | fk -> feedback_channels | |
|
||||||
| assigned_to | fk -> users nullable | Current handler |
|
| assigned_to | fk -> users nullable | Current handler |
|
||||||
| current_department_id | fk -> departments nullable | Current department |
|
| current_department_id | fk -> departments nullable | Current department |
|
||||||
@@ -244,7 +262,7 @@ minicrm/
|
|||||||
| created_at, updated_at | timestamp | |
|
| created_at, updated_at | timestamp | |
|
||||||
|
|
||||||
### Other tables
|
### Other tables
|
||||||
feedback_interactions, feedback_tags, feedback_feedback_tag, products, customers, customer_product, feedback_channels, users, contracts, handovers, product_services, roles, permissions, model_has_roles (Spatie), notifications, sessions, cache
|
feedback_interactions, feedback_tags, feedback_feedback_tag, products, customers, feedback_channels, users, contracts, handovers, product_services, roles, permissions, model_has_roles (Spatie), notifications, sessions, cache
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ app/
|
|||||||
│ ├── ImportCskh.php # app:import-cskh command
|
│ ├── ImportCskh.php # app:import-cskh command
|
||||||
│ └── ExportBackup.php # app:export-backup command (JSON backup)
|
│ └── ExportBackup.php # app:export-backup command (JSON backup)
|
||||||
├── Enums/
|
├── Enums/
|
||||||
│ ├── ContractType.php # SALE/LEASE/BCC
|
│ ├── ContractCategory.php # V2: ownership/business
|
||||||
|
│ ├── ContractType.php # V2: deposit/purchase/transfer/rental/self_business/bcc
|
||||||
│ ├── ContractStatus.php # ACTIVE/EXPIRED/LIQUIDATED
|
│ ├── ContractStatus.php # ACTIVE/EXPIRED/LIQUIDATED
|
||||||
│ ├── HandoverStatus.php # NOT_READY/READY/SCHEDULED/HANDED_OVER
|
│ ├── HandoverStatus.php # NOT_READY/READY/SCHEDULED/HANDED_OVER
|
||||||
│ └── ServiceType.php # MANAGEMENT_FEE/GRATITUDE/SELF_BUSINESS
|
│ └── ServiceType.php # MANAGEMENT_FEE/GRATITUDE/SELF_BUSINESS
|
||||||
@@ -143,9 +144,8 @@ app/
|
|||||||
│ └── HandleUploadErrors.php
|
│ └── HandleUploadErrors.php
|
||||||
├── Models/
|
├── Models/
|
||||||
│ ├── ActivityLog.php # NEW: Audit trail (user_id, action, description, metadata)
|
│ ├── ActivityLog.php # NEW: Audit trail (user_id, action, description, metadata)
|
||||||
│ ├── Contract.php
|
│ ├── Contract.php # V2: +category, +representative_*
|
||||||
│ ├── Customer.php
|
│ ├── Customer.php # V2: ownedProducts() qua contracts
|
||||||
│ ├── CustomerProduct.php # pivot model (customer_product table)
|
|
||||||
│ ├── Department.php
|
│ ├── Department.php
|
||||||
│ ├── Feedback.php
|
│ ├── Feedback.php
|
||||||
│ ├── FeedbackAttachment.php
|
│ ├── FeedbackAttachment.php
|
||||||
@@ -224,7 +224,7 @@ Relationships: `user()` BelongsTo
|
|||||||
| description | text nullable |
|
| description | text nullable |
|
||||||
| status | string (active/inactive/sold_out) |
|
| status | string (active/inactive/sold_out) |
|
||||||
|
|
||||||
Relationships: `customers()` BelongsToMany (pivot: customer_product, withPivot: purchase_date), `contracts()` HasMany, `handovers()` HasMany, `productServices()` HasMany
|
Relationships: `contracts()` HasMany, `handovers()` HasMany, `productServices()` HasMany
|
||||||
|
|
||||||
### ProductService (`product_services`)
|
### ProductService (`product_services`)
|
||||||
| Cột | Kiểu |
|
| Cột | Kiểu |
|
||||||
@@ -250,22 +250,28 @@ Relationships: `product()` BelongsTo
|
|||||||
|
|
||||||
Relationships: `product()` BelongsTo, `customer()` BelongsTo
|
Relationships: `product()` BelongsTo, `customer()` BelongsTo
|
||||||
|
|
||||||
### Contract (`contracts`)
|
### Contract (`contracts`) — V2
|
||||||
| Cột | Kiểu |
|
| Cột | Kiểu |
|
||||||
|-----|------|
|
|-----|------|
|
||||||
| id | bigint PK |
|
| id | bigint PK |
|
||||||
| product_id | FK->products cascadeOnDelete |
|
| product_id | FK->products cascadeOnDelete |
|
||||||
| customer_id | FK->customers cascadeOnDelete |
|
| customer_id | FK->customers cascadeOnDelete |
|
||||||
| type | string (sale/lease/bcc → ContractType enum) |
|
| category | string (ownership/business → ContractCategory enum) |
|
||||||
|
| type | string (deposit/purchase/transfer/rental/self_business/bcc → ContractType enum) |
|
||||||
| start_date | date nullable |
|
| start_date | date nullable |
|
||||||
| end_date | date nullable |
|
| end_date | date nullable |
|
||||||
| status | string (active/expired/liquidated → ContractStatus enum) |
|
| status | string (active/expired/liquidated → ContractStatus enum) |
|
||||||
| printed_at | date nullable |
|
| printed_at | date nullable |
|
||||||
| signed_status | string nullable |
|
| signed_status | string nullable |
|
||||||
|
| representative_name | string nullable |
|
||||||
|
| representative_phone | string nullable |
|
||||||
|
| representative_email | string nullable |
|
||||||
|
|
||||||
Relationships: `product()` BelongsTo, `customer()` BelongsTo, `feedbacks()` HasMany
|
Relationships: `product()` BelongsTo, `customer()` BelongsTo, `feedbacks()` HasMany
|
||||||
|
|
||||||
### Customer (`customers`)
|
> **Constraint:** Mỗi product tối đa 1 active contract mỗi category. Validation ở Model `booted::saving`.
|
||||||
|
|
||||||
|
### Customer (`customers`) — V2
|
||||||
| Cột | Kiểu |
|
| Cột | Kiểu |
|
||||||
|-----|------|
|
|-----|------|
|
||||||
| id | bigint PK |
|
| id | bigint PK |
|
||||||
@@ -275,18 +281,7 @@ Relationships: `product()` BelongsTo, `customer()` BelongsTo, `feedbacks()` HasM
|
|||||||
| address | text nullable |
|
| address | text nullable |
|
||||||
| status | string (active) |
|
| status | string (active) |
|
||||||
|
|
||||||
Relationships: `products()` BelongsToMany, `feedbacks()` HasMany
|
Relationships: `ownedProducts()` BelongsToMany qua contracts (ownership + active), `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`)
|
### Department (`departments`)
|
||||||
| Cột | Kiểu |
|
| Cột | Kiểu |
|
||||||
@@ -308,12 +303,11 @@ Relationships: `manager()` BelongsTo User, `feedbacks()` HasMany, `transferLogsF
|
|||||||
|
|
||||||
Uses Spatie `HasRoles`. Relationships: `assignedFeedbacks()` HasMany Feedback, `activityLogs()` HasMany.
|
Uses Spatie `HasRoles`. Relationships: `assignedFeedbacks()` HasMany Feedback, `activityLogs()` HasMany.
|
||||||
|
|
||||||
### Feedback (`feedback`)
|
### Feedback (`feedback`) — V2
|
||||||
| Cột | Kiểu |
|
| Cột | Kiểu |
|
||||||
|-----|------|
|
|-----|------|
|
||||||
| id | bigint PK |
|
| id | bigint PK |
|
||||||
| customer_id | FK->customers cascadeOnDelete |
|
| customer_id | FK->customers cascadeOnDelete |
|
||||||
| customer_product_id | FK->customer_product nullable nullOnDelete |
|
|
||||||
| contract_id | FK->contracts nullable nullOnDelete |
|
| contract_id | FK->contracts nullable nullOnDelete |
|
||||||
| feedback_channel_id | FK->feedback_channels |
|
| feedback_channel_id | FK->feedback_channels |
|
||||||
| assigned_to | FK->users nullable nullOnDelete |
|
| assigned_to | FK->users nullable nullOnDelete |
|
||||||
@@ -323,9 +317,9 @@ Uses Spatie `HasRoles`. Relationships: `assignedFeedbacks()` HasMany Feedback, `
|
|||||||
| content | text |
|
| content | text |
|
||||||
| status | string (pending/processing/resolved/closed) |
|
| 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']`
|
Fillable: `['customer_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
|
Relationships: `customer()`, `contract()`, `feedbackChannel()`, `assignedTo()`, `currentDepartment()`, `transferLogs()`, `interactions()` ordered latest, `attachments()`, `tags()` BelongsToMany
|
||||||
|
|
||||||
### FeedbackInteraction (`feedback_interactions`)
|
### FeedbackInteraction (`feedback_interactions`)
|
||||||
| Cột | Kiểu |
|
| Cột | Kiểu |
|
||||||
@@ -498,6 +492,7 @@ Dashboard (3 custom widgets)
|
|||||||
| 23 | `2026_05_01_000003_create_product_services_table` | product_services |
|
| 23 | `2026_05_01_000003_create_product_services_table` | product_services |
|
||||||
| 24 | `2026_05_01_000004_add_color_to_feedback_channels_table` | (modify feedback_channels) add color |
|
| 24 | `2026_05_01_000004_add_color_to_feedback_channels_table` | (modify feedback_channels) add color |
|
||||||
| 25 | `2026_05_09_104519_create_activity_logs_table` | activity_logs |
|
| 25 | `2026_05_09_104519_create_activity_logs_table` | activity_logs |
|
||||||
|
| 26 | `2026_05_12_075014_v2_restructure_contracts` | **(V2)** contracts +category, +representative; drop customer_product; drop feedback.customer_product_id |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
- **SOP Phase 1-8:** 100% hoàn thành
|
- **SOP Phase 1-8:** 100% hoàn thành
|
||||||
- **AI Instructions Core:** 85% (6/7, còn auto-suggest transfer)
|
- **AI Instructions Core:** 85% (6/7, còn auto-suggest transfer)
|
||||||
|
- **V2 Restructure:** 100% hoàn thành (Contract category, bỏ pivot, đại diện)
|
||||||
- **Test:** Pest 8/8 tests (21 assertions) pass
|
- **Test:** Pest 8/8 tests (21 assertions) pass
|
||||||
- **Data import:** 3658 rows từ `ai_instructions/CSKH.xlsx` imported successfully
|
- **Data import:** 3658 rows từ `ai_instructions/CSKH.xlsx` imported successfully
|
||||||
- **Dual Panel:** Admin (đầy đủ) + Support (rút gọn, tập trung feedback)
|
- **Dual Panel:** Admin (đầy đủ) + Support (rút gọn, tập trung feedback)
|
||||||
@@ -146,6 +147,20 @@
|
|||||||
- [x] Resources trong Support: Feedback, Customer, Product, FeedbackChannel, FeedbackTag, ActivityLog
|
- [x] Resources trong Support: Feedback, Customer, Product, FeedbackChannel, FeedbackTag, ActivityLog
|
||||||
- [x] Widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget, HandlerPerformanceWidget
|
- [x] Widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget, HandlerPerformanceWidget
|
||||||
|
|
||||||
|
### V2: Contract Restructure — Category + Representative + Bỏ Pivot
|
||||||
|
- [x] Migration: thêm `category` (ownership/business), `representative_*` vào contracts, xóa `customer_product`
|
||||||
|
- [x] Enum: `ContractCategory` (ownership, business) + cập nhật `ContractType` (deposit, purchase, transfer, rental, self_business, bcc)
|
||||||
|
- [x] Model: Contract (+category, +representative), Customer (ownedProducts qua contracts), Feedback (-customer_product_id)
|
||||||
|
- [x] Xóa `CustomerProduct` model/pivot
|
||||||
|
- [x] Validation: ràng buộc 1 active/category/product (CreateContract/EditContract)
|
||||||
|
- [x] FeedbackForm: filter mới (Customer → Product qua Contract ownership + đại diện), case-insensitive search
|
||||||
|
- [x] ContractForm: select category → dynamic type options, representative form section
|
||||||
|
- [x] FeedbackTable: `contract.product.name` thay `customerProduct.product.name`
|
||||||
|
- [x] Seeder: 6 contracts V2 (ownership+business, có representative), feedback no customer_product_id
|
||||||
|
- [x] ImportCskh: cập nhật pipeline, không còn customer_product
|
||||||
|
- [x] i18n: vi/en cho category, type mới, representative, validation
|
||||||
|
- [x] Tests: 8/8 pass (21 assertions)
|
||||||
|
|
||||||
### ActivityLog — Audit Trail
|
### ActivityLog — Audit Trail
|
||||||
- [x] Migration `activity_logs`: user_id, action, description, subject_type/id, metadata
|
- [x] Migration `activity_logs`: user_id, action, description, subject_type/id, metadata
|
||||||
- [x] Model `ActivityLog` + Service `ActivityLogger` (static log method)
|
- [x] Model `ActivityLog` + Service `ActivityLogger` (static log method)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace App\Console\Commands;
|
|||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
use App\Models\Contract;
|
use App\Models\Contract;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\CustomerProduct;
|
|
||||||
use App\Models\Feedback;
|
use App\Models\Feedback;
|
||||||
use App\Models\FeedbackInteraction;
|
use App\Models\FeedbackInteraction;
|
||||||
use App\Models\Handover;
|
use App\Models\Handover;
|
||||||
@@ -22,7 +21,7 @@ class ImportCskh extends Command
|
|||||||
{file_path : Path to CSV/Excel file}
|
{file_path : Path to CSV/Excel file}
|
||||||
{--dry-run : Analyze only, do not write to database}';
|
{--dry-run : Analyze only, do not write to database}';
|
||||||
|
|
||||||
protected $description = 'Import dữ liệu CSKH từ file Excel/CSV vào database relational';
|
protected $description = 'Import dữ liệu CSKH từ file Excel/CSV vào database relational (V2)';
|
||||||
|
|
||||||
private int $statsProducts = 0;
|
private int $statsProducts = 0;
|
||||||
private int $statsCustomers = 0;
|
private int $statsCustomers = 0;
|
||||||
@@ -123,11 +122,6 @@ class ImportCskh extends Command
|
|||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isMetaRow(array $row): bool
|
|
||||||
{
|
|
||||||
return $this->getMetaRowReason($row) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getMetaRowReason(array $row): ?string
|
private function getMetaRowReason(array $row): ?string
|
||||||
{
|
{
|
||||||
$code = $row['Mã căn hộ'] ?? '';
|
$code = $row['Mã căn hộ'] ?? '';
|
||||||
@@ -210,8 +204,6 @@ class ImportCskh extends Command
|
|||||||
$product = $this->findOrCreateProduct($productCode);
|
$product = $this->findOrCreateProduct($productCode);
|
||||||
$customer = $this->findOrCreateCustomer($customerName);
|
$customer = $this->findOrCreateCustomer($customerName);
|
||||||
|
|
||||||
$this->ensureCustomerProduct($product, $customer);
|
|
||||||
|
|
||||||
$this->processContracts($row, $product, $customer);
|
$this->processContracts($row, $product, $customer);
|
||||||
$this->processHandover($row, $product, $customer);
|
$this->processHandover($row, $product, $customer);
|
||||||
$this->processServices($row, $product);
|
$this->processServices($row, $product);
|
||||||
@@ -250,14 +242,6 @@ class ImportCskh extends Command
|
|||||||
return $c;
|
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
|
private function processContracts(array $row, Product $product, Customer $customer): void
|
||||||
{
|
{
|
||||||
$leaseValue = $row['HỢP ĐỒNG THUÊ'] ?? null;
|
$leaseValue = $row['HỢP ĐỒNG THUÊ'] ?? null;
|
||||||
@@ -265,14 +249,16 @@ class ImportCskh extends Command
|
|||||||
if (! empty($leaseStr) && ! is_numeric($leaseStr) && $leaseStr !== 'Tình trạng') {
|
if (! empty($leaseStr) && ! is_numeric($leaseStr) && $leaseStr !== 'Tình trạng') {
|
||||||
$exists = Contract::where('product_id', $product->id)
|
$exists = Contract::where('product_id', $product->id)
|
||||||
->where('customer_id', $customer->id)
|
->where('customer_id', $customer->id)
|
||||||
->where('type', 'lease')
|
->where('category', 'business')
|
||||||
|
->where('type', 'rental')
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if (! $exists) {
|
if (! $exists) {
|
||||||
Contract::create([
|
Contract::create([
|
||||||
'product_id' => $product->id,
|
'product_id' => $product->id,
|
||||||
'customer_id' => $customer->id,
|
'customer_id' => $customer->id,
|
||||||
'type' => 'lease',
|
'category' => 'business',
|
||||||
|
'type' => 'rental',
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
$this->statsContracts++;
|
$this->statsContracts++;
|
||||||
@@ -284,6 +270,7 @@ class ImportCskh extends Command
|
|||||||
if (! empty($bccStr) && ! is_numeric($bccStr) && $bccStr !== 'Tình trạng HĐ') {
|
if (! empty($bccStr) && ! is_numeric($bccStr) && $bccStr !== 'Tình trạng HĐ') {
|
||||||
$exists = Contract::where('product_id', $product->id)
|
$exists = Contract::where('product_id', $product->id)
|
||||||
->where('customer_id', $customer->id)
|
->where('customer_id', $customer->id)
|
||||||
|
->where('category', 'business')
|
||||||
->where('type', 'bcc')
|
->where('type', 'bcc')
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
@@ -291,6 +278,7 @@ class ImportCskh extends Command
|
|||||||
Contract::create([
|
Contract::create([
|
||||||
'product_id' => $product->id,
|
'product_id' => $product->id,
|
||||||
'customer_id' => $customer->id,
|
'customer_id' => $customer->id,
|
||||||
|
'category' => 'business',
|
||||||
'type' => 'bcc',
|
'type' => 'bcc',
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'signed_status' => $bccStr,
|
'signed_status' => $bccStr,
|
||||||
@@ -386,20 +374,15 @@ class ImportCskh extends Command
|
|||||||
|
|
||||||
$title = 'Ticket từ dữ liệu lịch sử';
|
$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(
|
$feedback = Feedback::firstOrCreate(
|
||||||
[
|
[
|
||||||
'customer_id' => $customer->id,
|
'customer_id' => $customer->id,
|
||||||
'customer_product_id' => $customerProduct?->id,
|
'contract_id' => $activeContract?->id,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
'contract_id' => $activeContract?->id,
|
|
||||||
'feedback_channel_id' => 1,
|
'feedback_channel_id' => 1,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -429,9 +412,6 @@ class ImportCskh extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the PHP DateTime JSON format: {"date":"2021-06-09 00:00:00.000000","timezone_type":3,"timezone":"UTC"}
|
|
||||||
*/
|
|
||||||
private function parseHandoverDate(mixed $value): ?string
|
private function parseHandoverDate(mixed $value): ?string
|
||||||
{
|
{
|
||||||
if (empty($value)) {
|
if (empty($value)) {
|
||||||
|
|||||||
39
app/Enums/ContractCategory.php
Normal file
39
app/Enums/ContractCategory.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract Category Enum — Phân loại hợp đồng cấp cao
|
||||||
|
*
|
||||||
|
* Mỗi căn hộ chỉ có tối đa 1 HĐ active mỗi category tại cùng thời điểm.
|
||||||
|
* - ownership: Sở hữu (đặt cọc, mua bán, chuyển nhượng...)
|
||||||
|
* - business: Kinh doanh (cho thuê, tự doanh, hợp tác KD...)
|
||||||
|
*/
|
||||||
|
enum ContractCategory: string
|
||||||
|
{
|
||||||
|
case OWNERSHIP = 'ownership';
|
||||||
|
case BUSINESS = 'business';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::OWNERSHIP => __('enums.contract_category.ownership'),
|
||||||
|
self::BUSINESS => __('enums.contract_category.business'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function color(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::OWNERSHIP => 'success',
|
||||||
|
self::BUSINESS => 'info',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function options(): array
|
||||||
|
{
|
||||||
|
return collect(self::cases())->mapWithKeys(fn ($case) => [
|
||||||
|
$case->value => $case->label(),
|
||||||
|
])->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,24 +3,33 @@
|
|||||||
namespace App\Enums;
|
namespace App\Enums;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contract Type Enum
|
* Contract Type Enum — Loại hợp đồng chi tiết (subtype của category)
|
||||||
*
|
*
|
||||||
* Để thêm loại hợp đồng mới:
|
* Ownership: deposit (đặt cọc), purchase (mua bán), transfer (chuyển nhượng)
|
||||||
* 1. Thêm case mới vào enum
|
* Business: rental (cho thuê), self_business (tự doanh), bcc (hợp tác KD)
|
||||||
|
*
|
||||||
|
* Để thêm loại mới:
|
||||||
|
* 1. Thêm case mới vào enum + gán category phù hợp trong category()
|
||||||
* 2. Thêm label trong lang/vi/enums.php và lang/en/enums.php
|
* 2. Thêm label trong lang/vi/enums.php và lang/en/enums.php
|
||||||
* 3. Reload browser
|
* 3. Reload browser
|
||||||
*/
|
*/
|
||||||
enum ContractType: string
|
enum ContractType: string
|
||||||
{
|
{
|
||||||
case SALE = 'sale';
|
case DEPOSIT = 'deposit';
|
||||||
case LEASE = 'lease';
|
case PURCHASE = 'purchase';
|
||||||
|
case TRANSFER = 'transfer';
|
||||||
|
case RENTAL = 'rental';
|
||||||
|
case SELF_BUSINESS = 'self_business';
|
||||||
case BCC = 'bcc';
|
case BCC = 'bcc';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::SALE => __('enums.contract_type.sale'),
|
self::DEPOSIT => __('enums.contract_type.deposit'),
|
||||||
self::LEASE => __('enums.contract_type.lease'),
|
self::PURCHASE => __('enums.contract_type.purchase'),
|
||||||
|
self::TRANSFER => __('enums.contract_type.transfer'),
|
||||||
|
self::RENTAL => __('enums.contract_type.rental'),
|
||||||
|
self::SELF_BUSINESS => __('enums.contract_type.self_business'),
|
||||||
self::BCC => __('enums.contract_type.bcc'),
|
self::BCC => __('enums.contract_type.bcc'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -28,9 +37,20 @@ enum ContractType: string
|
|||||||
public function color(): string
|
public function color(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::SALE => 'success',
|
self::DEPOSIT => 'warning',
|
||||||
self::LEASE => 'info',
|
self::PURCHASE => 'success',
|
||||||
self::BCC => 'warning',
|
self::TRANSFER => 'info',
|
||||||
|
self::RENTAL => 'info',
|
||||||
|
self::SELF_BUSINESS => 'warning',
|
||||||
|
self::BCC => 'danger',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function category(): ContractCategory
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DEPOSIT, self::PURCHASE, self::TRANSFER => ContractCategory::OWNERSHIP,
|
||||||
|
self::RENTAL, self::SELF_BUSINESS, self::BCC => ContractCategory::BUSINESS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,4 +60,14 @@ enum ContractType: string
|
|||||||
$case->value => $case->label(),
|
$case->value => $case->label(),
|
||||||
])->toArray();
|
])->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function ownershipCases(): array
|
||||||
|
{
|
||||||
|
return [self::DEPOSIT, self::PURCHASE, self::TRANSFER];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function businessCases(): array
|
||||||
|
{
|
||||||
|
return [self::RENTAL, self::SELF_BUSINESS, self::BCC];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\Contracts;
|
namespace App\Filament\Resources\Contracts;
|
||||||
|
|
||||||
|
use App\Enums\ContractCategory;
|
||||||
use App\Filament\Resources\Contracts\Pages\CreateContract;
|
use App\Filament\Resources\Contracts\Pages\CreateContract;
|
||||||
use App\Filament\Resources\Contracts\Pages\EditContract;
|
use App\Filament\Resources\Contracts\Pages\EditContract;
|
||||||
use App\Filament\Resources\Contracts\Pages\ListContracts;
|
use App\Filament\Resources\Contracts\Pages\ListContracts;
|
||||||
@@ -64,7 +65,6 @@ class ContractResource extends Resource
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// GlobalSearch customization
|
|
||||||
public static function getGlobalSearchEloquentQuery(): Builder
|
public static function getGlobalSearchEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return parent::getGlobalSearchEloquentQuery()
|
return parent::getGlobalSearchEloquentQuery()
|
||||||
@@ -89,6 +89,7 @@ class ContractResource extends Resource
|
|||||||
return [
|
return [
|
||||||
__('app.product') => $record->product?->name ?? '-',
|
__('app.product') => $record->product?->name ?? '-',
|
||||||
__('app.widget_customer') => $record->customer?->name ?? '-',
|
__('app.widget_customer') => $record->customer?->name ?? '-',
|
||||||
|
__('enums.contract_category_label') => ContractCategory::from($record->category)->label(),
|
||||||
__('app.blade_status') => \App\Enums\ContractStatus::from($record->status)->label(),
|
__('app.blade_status') => \App\Enums\ContractStatus::from($record->status)->label(),
|
||||||
__('app.signed_status') => $record->signed_status ?? '-',
|
__('app.signed_status') => $record->signed_status ?? '-',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,9 +3,38 @@
|
|||||||
namespace App\Filament\Resources\Contracts\Pages;
|
namespace App\Filament\Resources\Contracts\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Contracts\ContractResource;
|
use App\Filament\Resources\Contracts\ContractResource;
|
||||||
|
use App\Models\Contract;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateContract extends CreateRecord
|
class CreateContract extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = ContractResource::class;
|
protected static string $resource = ContractResource::class;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
if (($data['status'] ?? '') === 'active') {
|
||||||
|
$existing = Contract::where('product_id', $data['product_id'])
|
||||||
|
->where('category', $data['category'])
|
||||||
|
->where('status', 'active')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('feedback.contract_duplicate_title', [
|
||||||
|
'category' => \App\Enums\ContractCategory::from($data['category'])->label(),
|
||||||
|
]))
|
||||||
|
->body(__('feedback.contract_duplicate_body', [
|
||||||
|
'product' => $existing->product?->name ?? '?',
|
||||||
|
'customer' => $existing->customer?->name ?? '?',
|
||||||
|
]))
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->halt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace App\Filament\Resources\Contracts\Pages;
|
namespace App\Filament\Resources\Contracts\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Contracts\ContractResource;
|
use App\Filament\Resources\Contracts\ContractResource;
|
||||||
|
use App\Models\Contract;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
class EditContract extends EditRecord
|
class EditContract extends EditRecord
|
||||||
@@ -16,4 +18,32 @@ class EditContract extends EditRecord
|
|||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
if (($data['status'] ?? '') === 'active') {
|
||||||
|
$existing = Contract::where('product_id', $data['product_id'])
|
||||||
|
->where('category', $data['category'])
|
||||||
|
->where('status', 'active')
|
||||||
|
->where('id', '!=', $this->getRecord()->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('feedback.contract_duplicate_title', [
|
||||||
|
'category' => \App\Enums\ContractCategory::from($data['category'])->label(),
|
||||||
|
]))
|
||||||
|
->body(__('feedback.contract_duplicate_body', [
|
||||||
|
'product' => $existing->product?->name ?? '?',
|
||||||
|
'customer' => $existing->customer?->name ?? '?',
|
||||||
|
]))
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->halt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\Contracts\Schemas;
|
namespace App\Filament\Resources\Contracts\Schemas;
|
||||||
|
|
||||||
|
use App\Enums\ContractCategory;
|
||||||
use App\Enums\ContractStatus;
|
use App\Enums\ContractStatus;
|
||||||
use App\Enums\ContractType;
|
use App\Enums\ContractType;
|
||||||
use Filament\Forms\Components\DatePicker;
|
use Filament\Forms\Components\DatePicker;
|
||||||
@@ -16,6 +17,36 @@ class ContractForm
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->components([
|
->components([
|
||||||
|
Section::make(__('app.contract_category_section'))
|
||||||
|
->schema([
|
||||||
|
Select::make('category')
|
||||||
|
->label(__('enums.contract_category_label'))
|
||||||
|
->options(ContractCategory::options())
|
||||||
|
->required()
|
||||||
|
->native(false)
|
||||||
|
->live(),
|
||||||
|
Select::make('type')
|
||||||
|
->label(__('enums.contract_type_label'))
|
||||||
|
->options(function ($get): array {
|
||||||
|
$category = $get('category');
|
||||||
|
if ($category === 'ownership') {
|
||||||
|
return collect(ContractType::ownershipCases())->mapWithKeys(fn ($c) => [
|
||||||
|
$c->value => $c->label(),
|
||||||
|
])->toArray();
|
||||||
|
}
|
||||||
|
if ($category === 'business') {
|
||||||
|
return collect(ContractType::businessCases())->mapWithKeys(fn ($c) => [
|
||||||
|
$c->value => $c->label(),
|
||||||
|
])->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContractType::options();
|
||||||
|
})
|
||||||
|
->required()
|
||||||
|
->native(false),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
Section::make(__('app.contract_info'))
|
Section::make(__('app.contract_info'))
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('product_id')
|
Select::make('product_id')
|
||||||
@@ -28,11 +59,6 @@ class ContractForm
|
|||||||
->relationship('customer', 'name')
|
->relationship('customer', 'name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->required(),
|
->required(),
|
||||||
Select::make('type')
|
|
||||||
->label(__('app.contract'))
|
|
||||||
->options(ContractType::options())
|
|
||||||
->required()
|
|
||||||
->native(false),
|
|
||||||
Select::make('status')
|
Select::make('status')
|
||||||
->label(__('app.status'))
|
->label(__('app.status'))
|
||||||
->options(ContractStatus::ACTIVE->options())
|
->options(ContractStatus::ACTIVE->options())
|
||||||
@@ -50,6 +76,22 @@ class ContractForm
|
|||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make(__('app.representative_info'))
|
||||||
|
->description(__('app.representative_desc'))
|
||||||
|
->schema([
|
||||||
|
TextInput::make('representative_name')
|
||||||
|
->label(__('app.name'))
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('representative_phone')
|
||||||
|
->label(__('app.phone'))
|
||||||
|
->maxLength(20),
|
||||||
|
TextInput::make('representative_email')
|
||||||
|
->label(__('app.email'))
|
||||||
|
->email()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->columns(3),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\Contracts\Tables;
|
namespace App\Filament\Resources\Contracts\Tables;
|
||||||
|
|
||||||
|
use App\Enums\ContractCategory;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
class ContractsTable
|
class ContractsTable
|
||||||
@@ -17,15 +19,25 @@ class ContractsTable
|
|||||||
TextColumn::make('id')
|
TextColumn::make('id')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('product.name')
|
TextColumn::make('product.name')
|
||||||
|
->label(__('app.product'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('customer.name')
|
TextColumn::make('customer.name')
|
||||||
|
->label(__('app.widget_customer'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('type')
|
TextColumn::make('category')
|
||||||
|
->label(__('enums.contract_category_label'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (string $state): string => \App\Enums\ContractType::from($state)->label()),
|
->formatStateUsing(fn (string $state): string => ContractCategory::from($state)->label())
|
||||||
|
->color(fn (string $state): string => ContractCategory::from($state)->color()),
|
||||||
|
TextColumn::make('type')
|
||||||
|
->label(__('enums.contract_type_label'))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (string $state): string => \App\Enums\ContractType::from($state)->label())
|
||||||
|
->color(fn (string $state): string => \App\Enums\ContractType::from($state)->color()),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
|
->label(__('app.status'))
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (string $state): string => match ($state) {
|
->color(fn (string $state): string => match ($state) {
|
||||||
'active' => 'success',
|
'active' => 'success',
|
||||||
@@ -34,15 +46,22 @@ class ContractsTable
|
|||||||
default => 'gray',
|
default => 'gray',
|
||||||
})
|
})
|
||||||
->formatStateUsing(fn (string $state): string => \App\Enums\ContractStatus::from($state)->label()),
|
->formatStateUsing(fn (string $state): string => \App\Enums\ContractStatus::from($state)->label()),
|
||||||
|
TextColumn::make('representative_name')
|
||||||
|
->label(__('app.representative'))
|
||||||
|
->placeholder('-')
|
||||||
|
->toggleable(),
|
||||||
TextColumn::make('start_date')
|
TextColumn::make('start_date')
|
||||||
|
->label(__('app.start_date'))
|
||||||
->date()
|
->date()
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
TextColumn::make('end_date')
|
TextColumn::make('end_date')
|
||||||
|
->label(__('app.end_date'))
|
||||||
->date()
|
->date()
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
TextColumn::make('signed_status')
|
TextColumn::make('signed_status')
|
||||||
|
->label(__('app.signed_status'))
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
TextColumn::make('feedbacks_count')
|
TextColumn::make('feedbacks_count')
|
||||||
->counts('feedbacks')
|
->counts('feedbacks')
|
||||||
@@ -53,7 +72,12 @@ class ContractsTable
|
|||||||
->toggleable(),
|
->toggleable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
//
|
SelectFilter::make('category')
|
||||||
|
->label(__('enums.contract_category_label'))
|
||||||
|
->options(ContractCategory::options()),
|
||||||
|
SelectFilter::make('status')
|
||||||
|
->label(__('app.status'))
|
||||||
|
->options(\App\Enums\ContractStatus::ACTIVE->options()),
|
||||||
])
|
])
|
||||||
->recordActions([
|
->recordActions([
|
||||||
EditAction::make(),
|
EditAction::make(),
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ class FeedbacksRelationManager extends RelationManager
|
|||||||
->recordTitleAttribute('title')
|
->recordTitleAttribute('title')
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('title')
|
TextColumn::make('title')
|
||||||
|
->label(__('app.blade_title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->url(fn ($record) => FeedbackResource::getUrl('edit', ['record' => $record])),
|
->url(fn ($record) => FeedbackResource::getUrl('edit', ['record' => $record])),
|
||||||
TextColumn::make('customerProduct.product.name')
|
TextColumn::make('contract.product.name')
|
||||||
->label(__('app.product'))
|
->label(__('app.product'))
|
||||||
->placeholder(__('app.general')),
|
->placeholder(__('app.general')),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
|
->label(__('app.status'))
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (string $state): string => match ($state) {
|
->color(fn (string $state): string => match ($state) {
|
||||||
'pending' => 'warning',
|
'pending' => 'warning',
|
||||||
|
|||||||
@@ -41,15 +41,6 @@ class CustomerForm
|
|||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
|
|
||||||
Section::make(__('app.owned_products'))
|
|
||||||
->schema([
|
|
||||||
Select::make('products')
|
|
||||||
->label(__('app.resource_products'))
|
|
||||||
->relationship('products', 'name')
|
|
||||||
->multiple()
|
|
||||||
->preload()
|
|
||||||
->columnSpanFull(),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,6 @@ class CustomersTable
|
|||||||
'inactive' => 'gray',
|
'inactive' => 'gray',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
}),
|
}),
|
||||||
TextColumn::make('products_count')
|
|
||||||
->counts('products')
|
|
||||||
->label(__('app.resource_products')),
|
|
||||||
TextColumn::make('feedbacks_count')
|
TextColumn::make('feedbacks_count')
|
||||||
->counts('feedbacks')
|
->counts('feedbacks')
|
||||||
->label(__('app.feedbacks')),
|
->label(__('app.feedbacks')),
|
||||||
|
|||||||
@@ -65,11 +65,10 @@ class FeedbackResource extends Resource
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// GlobalSearch customization
|
|
||||||
public static function getGlobalSearchEloquentQuery(): Builder
|
public static function getGlobalSearchEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return parent::getGlobalSearchEloquentQuery()
|
return parent::getGlobalSearchEloquentQuery()
|
||||||
->with(['customer', 'customerProduct.product', 'currentDepartment']);
|
->with(['customer', 'contract.product', 'currentDepartment']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function modifyGlobalSearchQuery(Builder $query, string $search): void
|
public static function modifyGlobalSearchQuery(Builder $query, string $search): void
|
||||||
@@ -83,7 +82,7 @@ class FeedbackResource extends Resource
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
__('app.widget_customer') => $record->customer?->name ?? '-',
|
__('app.widget_customer') => $record->customer?->name ?? '-',
|
||||||
__('app.product') => $record->customerProduct?->product?->name ?? __('app.general'),
|
__('app.product') => $record->contract?->product?->name ?? __('app.general'),
|
||||||
__('app.blade_status') => match ($record->status) {
|
__('app.blade_status') => match ($record->status) {
|
||||||
'pending' => __('app.status_pending'),
|
'pending' => __('app.status_pending'),
|
||||||
'processing' => __('app.status_processing'),
|
'processing' => __('app.status_processing'),
|
||||||
|
|||||||
@@ -15,14 +15,10 @@ class CreateFeedback extends CreateRecord
|
|||||||
|
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
{
|
{
|
||||||
if (($data['is_general'] ?? false) === true) {
|
|
||||||
$data['customer_product_id'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->pendingAttachments = $data['attachments'] ?? [];
|
$this->pendingAttachments = $data['attachments'] ?? [];
|
||||||
$this->pendingCollection = $data['attachment_collection'] ?? 'general';
|
$this->pendingCollection = $data['attachment_collection'] ?? 'general';
|
||||||
|
|
||||||
unset($data['is_general'], $data['attachment_collection'], $data['attachments']);
|
unset($data['is_general'], $data['attachment_collection'], $data['attachments'], $data['product_id_virtual']);
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class EditFeedback extends EditRecord
|
|||||||
->disk('public')
|
->disk('public')
|
||||||
->directory('feedback-attachments')
|
->directory('feedback-attachments')
|
||||||
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'video/quicktime'])
|
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'video/quicktime'])
|
||||||
->maxSize(51200) // 50MB in KB
|
->maxSize(51200)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
@@ -166,14 +166,10 @@ class EditFeedback extends EditRecord
|
|||||||
|
|
||||||
protected function mutateFormDataBeforeSave(array $data): array
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
{
|
{
|
||||||
if (($data['is_general'] ?? false) === true) {
|
|
||||||
$data['customer_product_id'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->pendingAttachments = $data['attachments'] ?? [];
|
$this->pendingAttachments = $data['attachments'] ?? [];
|
||||||
$this->pendingCollection = $data['attachment_collection'] ?? 'general';
|
$this->pendingCollection = $data['attachment_collection'] ?? 'general';
|
||||||
|
|
||||||
unset($data['is_general'], $data['attachment_collection'], $data['attachments']);
|
unset($data['is_general'], $data['attachment_collection'], $data['attachments'], $data['product_id_virtual']);
|
||||||
|
|
||||||
$newStatus = $data['status'] ?? null;
|
$newStatus = $data['status'] ?? null;
|
||||||
if ($newStatus === 'closed' && $this->getRecord()->status !== 'closed') {
|
if ($newStatus === 'closed' && $this->getRecord()->status !== 'closed') {
|
||||||
@@ -235,7 +231,7 @@ class EditFeedback extends EditRecord
|
|||||||
|
|
||||||
protected function mutateFormDataBeforeFill(array $data): array
|
protected function mutateFormDataBeforeFill(array $data): array
|
||||||
{
|
{
|
||||||
$data['is_general'] = $data['customer_product_id'] === null;
|
$data['is_general'] = $data['contract_id'] === null;
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\Feedback\Schemas;
|
namespace App\Filament\Resources\Feedback\Schemas;
|
||||||
|
|
||||||
use App\Models\CustomerProduct;
|
use App\Models\Contract;
|
||||||
use App\Models\Department;
|
use App\Models\Department;
|
||||||
use Filament\Forms\Components\ColorPicker;
|
use Filament\Forms\Components\ColorPicker;
|
||||||
use Filament\Forms\Components\FileUpload;
|
use Filament\Forms\Components\FileUpload;
|
||||||
@@ -25,6 +25,7 @@ class FeedbackForm
|
|||||||
->icon('heroicon-o-user-group')
|
->icon('heroicon-o-user-group')
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('customer_id')
|
Select::make('customer_id')
|
||||||
|
->label(__('app.widget_customer'))
|
||||||
->relationship('customer', 'name')
|
->relationship('customer', 'name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->required()
|
->required()
|
||||||
@@ -37,48 +38,51 @@ class FeedbackForm
|
|||||||
->live()
|
->live()
|
||||||
->columnSpan(1),
|
->columnSpan(1),
|
||||||
|
|
||||||
Select::make('customer_product_id')
|
Select::make('product_id_virtual')
|
||||||
->label(__('app.product'))
|
->label(__('app.product'))
|
||||||
->searchable()
|
->searchable()
|
||||||
|
->options(fn ($get): array => self::getProductOptionsForCustomer($get('customer_id')))
|
||||||
->getSearchResultsUsing(function (string $search, $get): array {
|
->getSearchResultsUsing(function (string $search, $get): array {
|
||||||
$customerId = $get('customer_id');
|
$customerId = $get('customer_id');
|
||||||
if (! $customerId) {
|
if (! $customerId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$keyword = mb_strtolower($search);
|
$keyword = mb_strtolower($search);
|
||||||
return CustomerProduct::where('customer_id', $customerId)
|
$allIds = self::getProductIdsForCustomer($customerId);
|
||||||
->whereHas('product', fn ($q) => $q->whereRaw('LOWER(name) LIKE ?', ["%{$keyword}%"]))
|
|
||||||
->with('product')
|
if ($allIds->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return \App\Models\Product::whereIn('id', $allIds)
|
||||||
|
->whereRaw('LOWER(name) LIKE ?', ["%{$keyword}%"])
|
||||||
->limit(50)
|
->limit(50)
|
||||||
->get()
|
->pluck('name', 'id')
|
||||||
->pluck('product.name', 'id')
|
|
||||||
->toArray();
|
->toArray();
|
||||||
})
|
})
|
||||||
->getOptionLabelUsing(fn ($value): ?string =>
|
->getOptionLabelUsing(fn ($value): ?string =>
|
||||||
CustomerProduct::find($value)?->product?->name
|
\App\Models\Product::find($value)?->name
|
||||||
)
|
)
|
||||||
->visible(fn ($get): bool => ! $get('is_general'))
|
->visible(fn ($get): bool => ! $get('is_general'))
|
||||||
->nullable()
|
->nullable()
|
||||||
->live(),
|
->live()
|
||||||
|
->columnSpan(1),
|
||||||
|
|
||||||
Select::make('contract_id')
|
Select::make('contract_id')
|
||||||
->label(__('app.contract'))
|
->label(__('app.contract'))
|
||||||
->visible(fn ($get): bool => ! $get('is_general') && filled($get('customer_product_id')))
|
->visible(fn ($get): bool => ! $get('is_general') && filled($get('product_id_virtual')))
|
||||||
->options(function ($get): array {
|
->options(function ($get): array {
|
||||||
$customerProductId = $get('customer_product_id');
|
$productId = $get('product_id_virtual');
|
||||||
if (! $customerProductId) {
|
if (! $productId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$cp = \App\Models\CustomerProduct::find($customerProductId);
|
|
||||||
if (! $cp) {
|
return Contract::where('product_id', $productId)
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return \App\Models\Contract::where('product_id', $cp->product_id)
|
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->with('customer')
|
->with('customer')
|
||||||
->get()
|
->get()
|
||||||
->mapWithKeys(fn ($c) => [
|
->mapWithKeys(fn ($c) => [
|
||||||
$c->id => sprintf('%s (%s) - %s',
|
$c->id => sprintf('%s — %s (%s)',
|
||||||
\App\Enums\ContractType::from($c->type)->label(),
|
\App\Enums\ContractType::from($c->type)->label(),
|
||||||
$c->customer->name,
|
$c->customer->name,
|
||||||
$c->start_date?->format('d/m/Y') ?? 'N/A',
|
$c->start_date?->format('d/m/Y') ?? 'N/A',
|
||||||
@@ -90,15 +94,11 @@ class FeedbackForm
|
|||||||
if (filled($state)) {
|
if (filled($state)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$customerProductId = $get('customer_product_id');
|
$productId = $get('product_id_virtual');
|
||||||
if (! $customerProductId) {
|
if (! $productId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$cp = \App\Models\CustomerProduct::find($customerProductId);
|
$activeContract = Contract::where('product_id', $productId)
|
||||||
if (! $cp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$activeContract = \App\Models\Contract::where('product_id', $cp->product_id)
|
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->first();
|
->first();
|
||||||
if ($activeContract) {
|
if ($activeContract) {
|
||||||
@@ -115,17 +115,20 @@ class FeedbackForm
|
|||||||
->icon('heroicon-o-chat-bubble-left-right')
|
->icon('heroicon-o-chat-bubble-left-right')
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('feedback_channel_id')
|
Select::make('feedback_channel_id')
|
||||||
|
->label(__('app.channel'))
|
||||||
->relationship('feedbackChannel', 'name')
|
->relationship('feedbackChannel', 'name')
|
||||||
->required()
|
->required()
|
||||||
->preload()
|
->preload()
|
||||||
->columnSpan(1),
|
->columnSpan(1),
|
||||||
|
|
||||||
TextInput::make('title')
|
TextInput::make('title')
|
||||||
|
->label(__('app.blade_title'))
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->columnSpan(1),
|
->columnSpan(1),
|
||||||
|
|
||||||
Textarea::make('content')
|
Textarea::make('content')
|
||||||
|
->label(__('app.description'))
|
||||||
->required()
|
->required()
|
||||||
->rows(6)
|
->rows(6)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
@@ -136,9 +139,9 @@ class FeedbackForm
|
|||||||
->preload()
|
->preload()
|
||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
->createOptionForm([
|
->createOptionForm([
|
||||||
TextInput::make('name')->required(),
|
TextInput::make('name')->label(__('app.name'))->required(),
|
||||||
TextInput::make('slug')->required(),
|
TextInput::make('slug')->label(__('app.slug'))->required(),
|
||||||
ColorPicker::make('color')->default('#3b82f6'),
|
ColorPicker::make('color')->label(__('app.color'))->default('#3b82f6'),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
@@ -148,6 +151,7 @@ class FeedbackForm
|
|||||||
->icon('heroicon-o-arrow-path-rounded-square')
|
->icon('heroicon-o-arrow-path-rounded-square')
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('status')
|
Select::make('status')
|
||||||
|
->label(__('app.status'))
|
||||||
->options([
|
->options([
|
||||||
'pending' => __('app.status_pending'),
|
'pending' => __('app.status_pending'),
|
||||||
'processing' => __('app.status_processing'),
|
'processing' => __('app.status_processing'),
|
||||||
@@ -159,6 +163,7 @@ class FeedbackForm
|
|||||||
->columnSpan(1),
|
->columnSpan(1),
|
||||||
|
|
||||||
Select::make('assigned_to')
|
Select::make('assigned_to')
|
||||||
|
->label(__('app.assigned_to'))
|
||||||
->relationship('assignedTo', 'name')
|
->relationship('assignedTo', 'name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->nullable()
|
->nullable()
|
||||||
@@ -198,10 +203,42 @@ class FeedbackForm
|
|||||||
->disk('public')
|
->disk('public')
|
||||||
->directory('feedback-attachments')
|
->directory('feedback-attachments')
|
||||||
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'video/quicktime'])
|
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'video/quicktime'])
|
||||||
->maxSize(51200) // 50MB in KB
|
->maxSize(51200)
|
||||||
->columnSpan(1),
|
->columnSpan(1),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function getProductOptionsForCustomer(?int $customerId): array
|
||||||
|
{
|
||||||
|
$ids = self::getProductIdsForCustomer($customerId);
|
||||||
|
|
||||||
|
return \App\Models\Product::whereIn('id', $ids)
|
||||||
|
->limit(100)
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getProductIdsForCustomer(?int $customerId): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
if (! $customerId) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$ownedIds = Contract::where('customer_id', $customerId)
|
||||||
|
->where('category', 'ownership')
|
||||||
|
->where('status', 'active')
|
||||||
|
->pluck('product_id');
|
||||||
|
|
||||||
|
$customer = \App\Models\Customer::find($customerId);
|
||||||
|
$repIds = collect();
|
||||||
|
if ($customer?->email) {
|
||||||
|
$repIds = Contract::where('representative_email', $customer->email)
|
||||||
|
->where('status', 'active')
|
||||||
|
->pluck('product_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ownedIds->merge($repIds)->unique();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,16 +23,18 @@ class FeedbackTable
|
|||||||
->color('primary'),
|
->color('primary'),
|
||||||
|
|
||||||
TextColumn::make('title')
|
TextColumn::make('title')
|
||||||
|
->label(__('app.blade_title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable()
|
->sortable()
|
||||||
->weight('semibold')
|
->weight('semibold')
|
||||||
->limit(40),
|
->limit(40),
|
||||||
|
|
||||||
TextColumn::make('customer.name')
|
TextColumn::make('customer.name')
|
||||||
|
->label(__('app.widget_customer'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
|
||||||
TextColumn::make('customerProduct.product.name')
|
TextColumn::make('contract.product.name')
|
||||||
->label(__('app.product'))
|
->label(__('app.product'))
|
||||||
->placeholder(__('app.general'))
|
->placeholder(__('app.general'))
|
||||||
->sortable()
|
->sortable()
|
||||||
@@ -43,9 +45,12 @@ class FeedbackTable
|
|||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (?string $state): string => $state ? \App\Enums\ContractType::from($state)->label() : '-')
|
->formatStateUsing(fn (?string $state): string => $state ? \App\Enums\ContractType::from($state)->label() : '-')
|
||||||
->color(fn (?string $state): string => match ($state) {
|
->color(fn (?string $state): string => match ($state) {
|
||||||
'sale' => 'success',
|
'deposit' => 'warning',
|
||||||
'lease' => 'info',
|
'purchase' => 'success',
|
||||||
'bcc' => 'warning',
|
'transfer' => 'info',
|
||||||
|
'rental' => 'info',
|
||||||
|
'self_business' => 'warning',
|
||||||
|
'bcc' => 'danger',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
})
|
})
|
||||||
->placeholder('-')
|
->placeholder('-')
|
||||||
@@ -62,6 +67,7 @@ class FeedbackTable
|
|||||||
->toggleable(),
|
->toggleable(),
|
||||||
|
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
|
->label(__('app.status'))
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn(string $state): string => match ($state) {
|
->color(fn(string $state): string => match ($state) {
|
||||||
'pending' => 'warning',
|
'pending' => 'warning',
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ class ProductsTable
|
|||||||
TextColumn::make('description')
|
TextColumn::make('description')
|
||||||
->limit(50)
|
->limit(50)
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('customers_count')
|
TextColumn::make('contracts_count')
|
||||||
->counts('customers')
|
->counts('contracts')
|
||||||
->label(__('app.owners')),
|
->label(__('app.contracts')),
|
||||||
TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class ResolvedTicketsWidget extends BaseWidget
|
|||||||
->label(__('app.widget_customer'))
|
->label(__('app.widget_customer'))
|
||||||
->searchable(),
|
->searchable(),
|
||||||
|
|
||||||
TextColumn::make('customerProduct.product.name')
|
TextColumn::make('contract.product.name')
|
||||||
->label(__('app.product'))
|
->label(__('app.product'))
|
||||||
->placeholder(__('app.general')),
|
->placeholder(__('app.general')),
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,30 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['product_id', 'customer_id', 'type', 'start_date', 'end_date', 'status', 'printed_at', 'signed_status'])]
|
#[Fillable(['product_id', 'customer_id', 'type', 'category', 'start_date', 'end_date', 'status', 'printed_at', 'signed_status', 'representative_name', 'representative_phone', 'representative_email'])]
|
||||||
class Contract extends Model
|
class Contract extends Model
|
||||||
{
|
{
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $contract) {
|
||||||
|
if ($contract->status === 'active') {
|
||||||
|
$exists = self::where('product_id', $contract->product_id)
|
||||||
|
->where('category', $contract->category)
|
||||||
|
->where('status', 'active')
|
||||||
|
->when($contract->exists, fn ($q) => $q->where('id', '!=', $contract->id))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
__('feedback.contract_duplicate_body', [
|
||||||
|
'product' => $contract->product?->name ?? $contract->product_id,
|
||||||
|
'customer' => $contract->customer?->name ?? '?',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'phone', 'address', 'status'])]
|
#[Fillable(['name', 'email', 'phone', 'address', 'status'])]
|
||||||
class Customer extends Model
|
class Customer extends Model
|
||||||
{
|
{
|
||||||
public function products(): BelongsToMany
|
/**
|
||||||
|
* Các căn hộ khách hàng sở hữu (qua HĐ Ownership active)
|
||||||
|
*/
|
||||||
|
public function ownedProducts(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Product::class, 'customer_product')
|
return $this->belongsToMany(Product::class, 'contracts')
|
||||||
->withPivot('purchase_date')
|
->wherePivot('category', 'ownership')
|
||||||
|
->wherePivot('status', 'active')
|
||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class CustomerProduct extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'customer_product';
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'customer_id',
|
|
||||||
'product_id',
|
|
||||||
'purchase_date',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'purchase_date' => 'date',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function customer(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Customer::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function product(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Product::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function feedbacks(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Feedback::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['customer_id', 'customer_product_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])]
|
#[Fillable(['customer_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])]
|
||||||
class Feedback extends Model
|
class Feedback extends Model
|
||||||
{
|
{
|
||||||
public function customer(): BelongsTo
|
public function customer(): BelongsTo
|
||||||
@@ -16,11 +16,6 @@ class Feedback extends Model
|
|||||||
return $this->belongsTo(Customer::class);
|
return $this->belongsTo(Customer::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function customerProduct(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(CustomerProduct::class, 'customer_product_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function contract(): BelongsTo
|
public function contract(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Contract::class);
|
return $this->belongsTo(Contract::class);
|
||||||
|
|||||||
@@ -4,19 +4,11 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['name', 'description', 'status'])]
|
#[Fillable(['name', 'description', 'status'])]
|
||||||
class Product extends Model
|
class Product extends Model
|
||||||
{
|
{
|
||||||
public function customers(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Customer::class, 'customer_product')
|
|
||||||
->withPivot('purchase_date')
|
|
||||||
->withTimestamps();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function contracts(): HasMany
|
public function contracts(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Contract::class);
|
return $this->hasMany(Contract::class);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// 1. Add category + representative fields to contracts
|
||||||
|
Schema::table('contracts', function (Blueprint $table) {
|
||||||
|
$table->string('category')->after('type')->default('ownership');
|
||||||
|
$table->string('representative_name')->nullable()->after('signed_status');
|
||||||
|
$table->string('representative_phone')->nullable()->after('representative_name');
|
||||||
|
$table->string('representative_email')->nullable()->after('representative_phone');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Drop customer_product_id FK and column from feedback
|
||||||
|
if (DB::getDriverName() === 'sqlite') {
|
||||||
|
// SQLite doesn't support dropping foreign keys cleanly, recreate table approach
|
||||||
|
Schema::table('feedback', function (Blueprint $table) {
|
||||||
|
// Drop the column directly (SQLite will handle)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Schema::table('feedback', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['customer_product_id']);
|
||||||
|
});
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// FK may not exist or already dropped
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('feedback', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('feedback', 'customer_product_id')) {
|
||||||
|
$table->dropColumn('customer_product_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Drop customer_product pivot table
|
||||||
|
Schema::dropIfExists('customer_product');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Restore customer_product table
|
||||||
|
Schema::create('customer_product', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete();
|
||||||
|
$table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
|
||||||
|
$table->date('purchase_date')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->unique(['customer_id', 'product_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore customer_product_id on feedback
|
||||||
|
Schema::table('feedback', function (Blueprint $table) {
|
||||||
|
$table->foreignId('customer_product_id')->nullable()->after('customer_id');
|
||||||
|
$table->foreign('customer_product_id')->references('id')->on('customer_product')->nullOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove V2 columns from contracts
|
||||||
|
Schema::table('contracts', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['category', 'representative_name', 'representative_phone', 'representative_email']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Enums\ContractCategory;
|
||||||
use App\Enums\ContractStatus;
|
use App\Enums\ContractStatus;
|
||||||
use App\Enums\ContractType;
|
use App\Enums\ContractType;
|
||||||
use App\Enums\HandoverStatus;
|
use App\Enums\HandoverStatus;
|
||||||
use App\Enums\ServiceType;
|
use App\Enums\ServiceType;
|
||||||
use App\Models\Contract;
|
use App\Models\Contract;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\CustomerProduct;
|
|
||||||
use App\Models\Department;
|
use App\Models\Department;
|
||||||
use App\Models\Feedback;
|
use App\Models\Feedback;
|
||||||
use App\Models\FeedbackAttachment;
|
use App\Models\FeedbackAttachment;
|
||||||
@@ -119,65 +119,62 @@ class AfterSalesSeeder extends Seeder
|
|||||||
$c6 = Customer::create(['name' => 'Đỗ Văn Phúc', 'email' => 'dovanphuc@gmail.com', 'phone' => '0954678901', 'address' => 'Thủ Đức, TP.HCM', 'status' => 'inactive']);
|
$c6 = Customer::create(['name' => 'Đỗ Văn Phúc', 'email' => 'dovanphuc@gmail.com', 'phone' => '0954678901', 'address' => 'Thủ Đức, TP.HCM', 'status' => 'inactive']);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 7. CUSTOMER-PRODUCT pivots
|
// 7. CONTRACTS (V2: category + representative)
|
||||||
// ============================================================
|
|
||||||
$c1->products()->attach($p1->id, ['purchase_date' => '2024-01-15']);
|
|
||||||
$c1->products()->attach($p2->id, ['purchase_date' => '2024-06-01']);
|
|
||||||
$c2->products()->attach($p1->id, ['purchase_date' => '2024-03-20']);
|
|
||||||
$c3->products()->attach($p3->id, ['purchase_date' => '2024-09-10']);
|
|
||||||
$c4->products()->attach($p2->id, ['purchase_date' => '2025-01-05']);
|
|
||||||
$c4->products()->attach($p5->id, ['purchase_date' => '2025-02-15']);
|
|
||||||
$c5->products()->attach($p5->id, ['purchase_date' => '2024-11-01']);
|
|
||||||
$c6->products()->attach($p6->id, ['purchase_date' => '2023-06-15']);
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 8. CONTRACTS (6 contracts)
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
$contract1 = Contract::create([
|
$contract1 = Contract::create([
|
||||||
'product_id' => $p1->id, 'customer_id' => $c1->id,
|
'product_id' => $p1->id, 'customer_id' => $c1->id,
|
||||||
'type' => ContractType::SALE->value,
|
'category' => ContractCategory::OWNERSHIP->value,
|
||||||
|
'type' => ContractType::PURCHASE->value,
|
||||||
'start_date' => '2024-01-15', 'end_date' => null,
|
'start_date' => '2024-01-15', 'end_date' => null,
|
||||||
'status' => ContractStatus::ACTIVE->value,
|
'status' => ContractStatus::ACTIVE->value,
|
||||||
'signed_status' => 'Đã ký',
|
'signed_status' => 'Đã ký',
|
||||||
|
'representative_name' => 'Nguyễn Thị Hoa',
|
||||||
|
'representative_phone' => '0901000111',
|
||||||
|
'representative_email' => 'hoant@example.com',
|
||||||
]);
|
]);
|
||||||
$contract2 = Contract::create([
|
$contract2 = Contract::create([
|
||||||
'product_id' => $p2->id, 'customer_id' => $c1->id,
|
'product_id' => $p2->id, 'customer_id' => $c1->id,
|
||||||
'type' => ContractType::LEASE->value,
|
'category' => ContractCategory::BUSINESS->value,
|
||||||
|
'type' => ContractType::RENTAL->value,
|
||||||
'start_date' => '2024-06-01', 'end_date' => '2026-06-01',
|
'start_date' => '2024-06-01', 'end_date' => '2026-06-01',
|
||||||
'status' => ContractStatus::ACTIVE->value,
|
'status' => ContractStatus::ACTIVE->value,
|
||||||
'signed_status' => 'Đã ký',
|
'signed_status' => 'Đã ký',
|
||||||
]);
|
]);
|
||||||
$contract3 = Contract::create([
|
$contract3 = Contract::create([
|
||||||
'product_id' => $p1->id, 'customer_id' => $c2->id,
|
'product_id' => $p2->id, 'customer_id' => $c4->id,
|
||||||
'type' => ContractType::SALE->value,
|
'category' => ContractCategory::BUSINESS->value,
|
||||||
'start_date' => '2024-03-20', 'end_date' => null,
|
'type' => ContractType::RENTAL->value,
|
||||||
'status' => ContractStatus::ACTIVE->value,
|
'start_date' => '2025-01-05', 'end_date' => '2026-01-05',
|
||||||
|
'status' => ContractStatus::EXPIRED->value,
|
||||||
'signed_status' => 'Đã ký',
|
'signed_status' => 'Đã ký',
|
||||||
]);
|
]);
|
||||||
$contract4 = Contract::create([
|
$contract4 = Contract::create([
|
||||||
'product_id' => $p3->id, 'customer_id' => $c3->id,
|
'product_id' => $p3->id, 'customer_id' => $c3->id,
|
||||||
|
'category' => ContractCategory::BUSINESS->value,
|
||||||
'type' => ContractType::BCC->value,
|
'type' => ContractType::BCC->value,
|
||||||
'start_date' => '2024-09-10', 'end_date' => '2029-09-10',
|
'start_date' => '2024-09-10', 'end_date' => '2029-09-10',
|
||||||
'status' => ContractStatus::ACTIVE->value,
|
'status' => ContractStatus::ACTIVE->value,
|
||||||
'signed_status' => 'Đang chờ ký',
|
'signed_status' => 'Đang chờ ký',
|
||||||
]);
|
]);
|
||||||
$contract5 = Contract::create([
|
$contract5 = Contract::create([
|
||||||
'product_id' => $p2->id, 'customer_id' => $c4->id,
|
|
||||||
'type' => ContractType::LEASE->value,
|
|
||||||
'start_date' => '2025-01-05', 'end_date' => '2026-01-05',
|
|
||||||
'status' => ContractStatus::EXPIRED->value,
|
|
||||||
'signed_status' => 'Đã ký',
|
|
||||||
]);
|
|
||||||
$contract6 = Contract::create([
|
|
||||||
'product_id' => $p5->id, 'customer_id' => $c5->id,
|
'product_id' => $p5->id, 'customer_id' => $c5->id,
|
||||||
'type' => ContractType::SALE->value,
|
'category' => ContractCategory::OWNERSHIP->value,
|
||||||
|
'type' => ContractType::PURCHASE->value,
|
||||||
'start_date' => '2024-11-01', 'end_date' => null,
|
'start_date' => '2024-11-01', 'end_date' => null,
|
||||||
'status' => ContractStatus::ACTIVE->value,
|
'status' => ContractStatus::ACTIVE->value,
|
||||||
'signed_status' => 'Đã ký',
|
'signed_status' => 'Đã ký',
|
||||||
]);
|
]);
|
||||||
|
$contract6 = Contract::create([
|
||||||
|
'product_id' => $p1->id, 'customer_id' => $c2->id,
|
||||||
|
'category' => ContractCategory::OWNERSHIP->value,
|
||||||
|
'type' => ContractType::PURCHASE->value,
|
||||||
|
'start_date' => '2024-03-20', 'end_date' => '2024-12-31',
|
||||||
|
'status' => ContractStatus::LIQUIDATED->value,
|
||||||
|
'signed_status' => 'Đã ký',
|
||||||
|
]);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 9. HANDOVERS (6 handovers)
|
// 8. HANDOVERS (6 handovers)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
Handover::create(['product_id' => $p1->id, 'customer_id' => $c1->id, 'handover_date' => '2024-02-15', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Đã bàn giao đầy đủ chìa khóa, thẻ từ và sổ bảo hành.']);
|
Handover::create(['product_id' => $p1->id, 'customer_id' => $c1->id, 'handover_date' => '2024-02-15', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Đã bàn giao đầy đủ chìa khóa, thẻ từ và sổ bảo hành.']);
|
||||||
Handover::create(['product_id' => $p1->id, 'customer_id' => $c2->id, 'handover_date' => '2024-04-20', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Đã bàn giao, khách yêu cầu kiểm tra lại hệ thống nước nóng.']);
|
Handover::create(['product_id' => $p1->id, 'customer_id' => $c2->id, 'handover_date' => '2024-04-20', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Đã bàn giao, khách yêu cầu kiểm tra lại hệ thống nước nóng.']);
|
||||||
@@ -187,7 +184,7 @@ class AfterSalesSeeder extends Seeder
|
|||||||
Handover::create(['product_id' => $p5->id, 'customer_id' => $c5->id, 'handover_date' => '2025-01-15', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Bàn giao thành công, khách hài lòng với chất lượng.']);
|
Handover::create(['product_id' => $p5->id, 'customer_id' => $c5->id, 'handover_date' => '2025-01-15', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Bàn giao thành công, khách hài lòng với chất lượng.']);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 10. PRODUCT SERVICES (6 services)
|
// 9. PRODUCT SERVICES (6 services)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
ProductService::create(['product_id' => $p1->id, 'service_type' => ServiceType::MANAGEMENT_FEE->value, 'status' => 'active', 'actual_collection_date' => '2024-03-01', 'remark' => 'Phí quản lý hàng tháng 5.000.000 VND/căn.']);
|
ProductService::create(['product_id' => $p1->id, 'service_type' => ServiceType::MANAGEMENT_FEE->value, 'status' => 'active', 'actual_collection_date' => '2024-03-01', 'remark' => 'Phí quản lý hàng tháng 5.000.000 VND/căn.']);
|
||||||
ProductService::create(['product_id' => $p1->id, 'service_type' => ServiceType::GRATITUDE->value, 'status' => 'completed', 'actual_collection_date' => '2024-06-15', 'remark' => 'Quà tri ân khách hàng nhân dịp khai trương tiện ích mới.']);
|
ProductService::create(['product_id' => $p1->id, 'service_type' => ServiceType::GRATITUDE->value, 'status' => 'completed', 'actual_collection_date' => '2024-06-15', 'remark' => 'Quà tri ân khách hàng nhân dịp khai trương tiện ích mới.']);
|
||||||
@@ -197,7 +194,7 @@ class AfterSalesSeeder extends Seeder
|
|||||||
ProductService::create(['product_id' => $p5->id, 'service_type' => ServiceType::MANAGEMENT_FEE->value, 'status' => 'active', 'actual_collection_date' => '2025-02-01', 'remark' => 'Phí quản lý hàng tháng 3.500.000 VND/căn.']);
|
ProductService::create(['product_id' => $p5->id, 'service_type' => ServiceType::MANAGEMENT_FEE->value, 'status' => 'active', 'actual_collection_date' => '2025-02-01', 'remark' => 'Phí quản lý hàng tháng 3.500.000 VND/căn.']);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 11. FEEDBACK TAGS (6 tags)
|
// 10. FEEDBACK TAGS (6 tags)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
$tagLeaking = FeedbackTag::create(['name' => 'Rò rỉ', 'slug' => 'leaking', 'color' => '#3b82f6']);
|
$tagLeaking = FeedbackTag::create(['name' => 'Rò rỉ', 'slug' => 'leaking', 'color' => '#3b82f6']);
|
||||||
$tagElectrical = FeedbackTag::create(['name' => 'Điện nước', 'slug' => 'electrical', 'color' => '#f59e0b']);
|
$tagElectrical = FeedbackTag::create(['name' => 'Điện nước', 'slug' => 'electrical', 'color' => '#f59e0b']);
|
||||||
@@ -207,13 +204,12 @@ class AfterSalesSeeder extends Seeder
|
|||||||
$tagSuggestion = FeedbackTag::create(['name' => 'Đề xuất', 'slug' => 'suggestion', 'color' => '#06b6d4']);
|
$tagSuggestion = FeedbackTag::create(['name' => 'Đề xuất', 'slug' => 'suggestion', 'color' => '#06b6d4']);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 12. FEEDBACKS (8 feedbacks - various statuses)
|
// 11. FEEDBACKS (8 feedbacks - V2: no customer_product_id, use contract_id)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// Feedback 1: Pending - Trần rò rỉ
|
// Feedback 1: Pending - Trần rò rỉ
|
||||||
$cp1 = CustomerProduct::where('customer_id', $c1->id)->where('product_id', $p1->id)->first();
|
|
||||||
$fb1 = Feedback::create([
|
$fb1 = Feedback::create([
|
||||||
'customer_id' => $c1->id, 'customer_product_id' => $cp1->id, 'contract_id' => $contract1->id,
|
'customer_id' => $c1->id, 'contract_id' => $contract1->id,
|
||||||
'feedback_channel_id' => $chEmail->id, 'assigned_to' => $staffA->id,
|
'feedback_channel_id' => $chEmail->id, 'assigned_to' => $staffA->id,
|
||||||
'current_department_id' => $deptCskh->id,
|
'current_department_id' => $deptCskh->id,
|
||||||
'title' => 'Trần nhà bị rò rỉ nước mưa',
|
'title' => 'Trần nhà bị rò rỉ nước mưa',
|
||||||
@@ -222,9 +218,8 @@ class AfterSalesSeeder extends Seeder
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Feedback 2: Processing - Nâng cấp chỗ đậu xe
|
// Feedback 2: Processing - Nâng cấp chỗ đậu xe
|
||||||
$cp2 = CustomerProduct::where('customer_id', $c1->id)->where('product_id', $p2->id)->first();
|
|
||||||
$fb2 = Feedback::create([
|
$fb2 = Feedback::create([
|
||||||
'customer_id' => $c1->id, 'customer_product_id' => $cp2->id, 'contract_id' => $contract2->id,
|
'customer_id' => $c1->id, 'contract_id' => $contract2->id,
|
||||||
'feedback_channel_id' => $chPhone->id, 'assigned_to' => $managerCskh->id,
|
'feedback_channel_id' => $chPhone->id, 'assigned_to' => $managerCskh->id,
|
||||||
'current_department_id' => $deptCskh->id,
|
'current_department_id' => $deptCskh->id,
|
||||||
'title' => 'Yêu cầu nâng cấp chỗ đậu xe',
|
'title' => 'Yêu cầu nâng cấp chỗ đậu xe',
|
||||||
@@ -233,9 +228,8 @@ class AfterSalesSeeder extends Seeder
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Feedback 3: Resolved - Điều hòa hỏng
|
// Feedback 3: Resolved - Điều hòa hỏng
|
||||||
$cp3 = CustomerProduct::where('customer_id', $c2->id)->where('product_id', $p1->id)->first();
|
|
||||||
$fb3 = Feedback::create([
|
$fb3 = Feedback::create([
|
||||||
'customer_id' => $c2->id, 'customer_product_id' => $cp3->id, 'contract_id' => $contract3->id,
|
'customer_id' => $c2->id, 'contract_id' => $contract6->id,
|
||||||
'feedback_channel_id' => $chZalo->id, 'assigned_to' => $staffA->id,
|
'feedback_channel_id' => $chZalo->id, 'assigned_to' => $staffA->id,
|
||||||
'current_department_id' => $deptKt->id, 'is_escalated' => false,
|
'current_department_id' => $deptKt->id, 'is_escalated' => false,
|
||||||
'title' => 'Điều hòa phòng ngủ chính không mát',
|
'title' => 'Điều hòa phòng ngủ chính không mát',
|
||||||
@@ -243,9 +237,9 @@ class AfterSalesSeeder extends Seeder
|
|||||||
'status' => 'resolved',
|
'status' => 'resolved',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Feedback 4: Pending - Góp ý cải thiện dịch vụ
|
// Feedback 4: Pending - Góp ý cải thiện dịch vụ (general)
|
||||||
$fb4 = Feedback::create([
|
$fb4 = Feedback::create([
|
||||||
'customer_id' => $c3->id, 'customer_product_id' => null, 'contract_id' => null,
|
'customer_id' => $c3->id, 'contract_id' => null,
|
||||||
'feedback_channel_id' => $chApp->id, 'assigned_to' => null,
|
'feedback_channel_id' => $chApp->id, 'assigned_to' => null,
|
||||||
'current_department_id' => $deptCskh->id,
|
'current_department_id' => $deptCskh->id,
|
||||||
'title' => 'Đề xuất cải thiện dịch vụ vệ sinh',
|
'title' => 'Đề xuất cải thiện dịch vụ vệ sinh',
|
||||||
@@ -253,10 +247,9 @@ class AfterSalesSeeder extends Seeder
|
|||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Feedback 5: Closed - Bản lề tủ bếp
|
// Feedback 5: Closed - Bản lề tủ bếp (vẫn ref contract đã expired)
|
||||||
$cp5 = CustomerProduct::where('customer_id', $c4->id)->where('product_id', $p2->id)->first();
|
|
||||||
$fb5 = Feedback::create([
|
$fb5 = Feedback::create([
|
||||||
'customer_id' => $c4->id, 'customer_product_id' => $cp5->id, 'contract_id' => $contract5->id,
|
'customer_id' => $c4->id, 'contract_id' => $contract3->id,
|
||||||
'feedback_channel_id' => $chEmail->id, 'assigned_to' => $staffB->id,
|
'feedback_channel_id' => $chEmail->id, 'assigned_to' => $staffB->id,
|
||||||
'current_department_id' => $deptKt->id,
|
'current_department_id' => $deptKt->id,
|
||||||
'title' => 'Bản lề tủ bếp bị gãy',
|
'title' => 'Bản lề tủ bếp bị gãy',
|
||||||
@@ -265,19 +258,18 @@ class AfterSalesSeeder extends Seeder
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Feedback 6: Processing - Ổ cắm điện hư
|
// Feedback 6: Processing - Ổ cắm điện hư
|
||||||
$cp6 = CustomerProduct::where('customer_id', $c5->id)->where('product_id', $p5->id)->first();
|
|
||||||
$fb6 = Feedback::create([
|
$fb6 = Feedback::create([
|
||||||
'customer_id' => $c5->id, 'customer_product_id' => $cp6->id, 'contract_id' => $contract6->id,
|
'customer_id' => $c5->id, 'contract_id' => $contract5->id,
|
||||||
'feedback_channel_id' => $chPhone->id, 'assigned_to' => $staffA->id,
|
'feedback_channel_id' => $chPhone->id, 'assigned_to' => $staffA->id,
|
||||||
'current_department_id' => $deptKt->id, 'is_escalated' => true,
|
'current_department_id' => $deptKt->id, 'is_escalated' => true,
|
||||||
'title' => 'Ổ c插座 điện phòng bếp bị hư, có mùi khét',
|
'title' => 'Ổ cắm điện phòng bếp bị hư, có mùi khét',
|
||||||
'content' => 'Ổ cắm điện cạnh bồn rửa chén bị hư, phát ra mùi khét khi cắm thiết bị. Đây là vấn đề an toàn, cần xử lý gấp.',
|
'content' => 'Ổ cắm điện cạnh bồn rửa chén bị hư, phát ra mùi khét khi cắm thiết bị. Đây là vấn đề an toàn, cần xử lý gấp.',
|
||||||
'status' => 'processing',
|
'status' => 'processing',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Feedback 7: Resolved - Hợp đồng thuê
|
// Feedback 7: Resolved - Hợp đồng thuê
|
||||||
$fb7 = Feedback::create([
|
$fb7 = Feedback::create([
|
||||||
'customer_id' => $c4->id, 'customer_product_id' => null, 'contract_id' => $contract5->id,
|
'customer_id' => $c4->id, 'contract_id' => $contract3->id,
|
||||||
'feedback_channel_id' => $chWalkin->id, 'assigned_to' => $staffB->id,
|
'feedback_channel_id' => $chWalkin->id, 'assigned_to' => $staffB->id,
|
||||||
'current_department_id' => $deptPhaply->id,
|
'current_department_id' => $deptPhaply->id,
|
||||||
'title' => 'Hỏi về gia hạn hợp đồng thuê',
|
'title' => 'Hỏi về gia hạn hợp đồng thuê',
|
||||||
@@ -285,9 +277,9 @@ class AfterSalesSeeder extends Seeder
|
|||||||
'status' => 'resolved',
|
'status' => 'resolved',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Feedback 8: Pending - Phàn nàn về tiếng ồn
|
// Feedback 8: Pending - Phàn nàn tiếng ồn
|
||||||
$fb8 = Feedback::create([
|
$fb8 = Feedback::create([
|
||||||
'customer_id' => $c2->id, 'customer_product_id' => $cp3->id, 'contract_id' => $contract3->id,
|
'customer_id' => $c2->id, 'contract_id' => $contract6->id,
|
||||||
'feedback_channel_id' => $chZalo->id, 'assigned_to' => $staffB->id,
|
'feedback_channel_id' => $chZalo->id, 'assigned_to' => $staffB->id,
|
||||||
'current_department_id' => $deptCskh->id,
|
'current_department_id' => $deptCskh->id,
|
||||||
'title' => 'Phàn nàn về tiếng ồn từ tầng trên',
|
'title' => 'Phàn nàn về tiếng ồn từ tầng trên',
|
||||||
@@ -296,7 +288,7 @@ class AfterSalesSeeder extends Seeder
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 13. TAG ATTACHMENTS
|
// 12. TAG ATTACHMENTS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
$fb1->tags()->attach([$tagLeaking->id, $tagMaintenance->id]);
|
$fb1->tags()->attach([$tagLeaking->id, $tagMaintenance->id]);
|
||||||
$fb2->tags()->attach([$tagSuggestion->id]);
|
$fb2->tags()->attach([$tagSuggestion->id]);
|
||||||
@@ -308,10 +300,8 @@ class AfterSalesSeeder extends Seeder
|
|||||||
$fb8->tags()->attach([$tagComplaint->id]);
|
$fb8->tags()->attach([$tagComplaint->id]);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 14. FEEDBACK INTERACTIONS (12 interactions)
|
// 13. FEEDBACK INTERACTIONS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// FB1: Tạo mới + ghi chú
|
|
||||||
FeedbackInteraction::create([
|
FeedbackInteraction::create([
|
||||||
'feedback_id' => $fb1->id, 'user_id' => $admin->id, 'type' => 'note',
|
'feedback_id' => $fb1->id, 'user_id' => $admin->id, 'type' => 'note',
|
||||||
'content' => 'Tiếp nhận phản ánh qua Email. Giao cho nhân viên Phạm Thị xử lý.',
|
'content' => 'Tiếp nhận phản ánh qua Email. Giao cho nhân viên Phạm Thị xử lý.',
|
||||||
@@ -321,28 +311,20 @@ class AfterSalesSeeder extends Seeder
|
|||||||
'feedback_id' => $fb1->id, 'user_id' => $staffA->id, 'type' => 'note',
|
'feedback_id' => $fb1->id, 'user_id' => $staffA->id, 'type' => 'note',
|
||||||
'content' => 'Đã liên hệ khách hàng qua điện thoại. Lên lịch kiểm tra vào thứ Ba tuần sau.',
|
'content' => 'Đã liên hệ khách hàng qua điện thoại. Lên lịch kiểm tra vào thứ Ba tuần sau.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// FB2: Phân công
|
|
||||||
FeedbackInteraction::create([
|
FeedbackInteraction::create([
|
||||||
'feedback_id' => $fb2->id, 'user_id' => $managerCskh->id, 'type' => 'note',
|
'feedback_id' => $fb2->id, 'user_id' => $managerCskh->id, 'type' => 'note',
|
||||||
'content' => 'Đã gửi báo giá nâng cấp chỗ đậu xe qua email. Chờ khách xác nhận.',
|
'content' => 'Đã gửi báo giá nâng cấp chỗ đậu xe qua email. Chờ khách xác nhận.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// FB3: Xử lý xong
|
|
||||||
FeedbackInteraction::create([
|
FeedbackInteraction::create([
|
||||||
'feedback_id' => $fb3->id, 'user_id' => $staffA->id, 'type' => 'status_change',
|
'feedback_id' => $fb3->id, 'user_id' => $staffA->id, 'type' => 'status_change',
|
||||||
'content' => 'Kỹ thuật viên đã kiểm tra và thay thế block điều hòa ngày 10/06. Khách hàng xác nhận hoạt động bình thường.',
|
'content' => 'Kỹ thuật viên đã kiểm tra và thay thế block điều hòa ngày 10/06. Khách hàng xác nhận hoạt động bình thường.',
|
||||||
'changes' => ['status' => ['old' => 'processing', 'new' => 'resolved']],
|
'changes' => ['status' => ['old' => 'processing', 'new' => 'resolved']],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// FB5: Đóng phiếu
|
|
||||||
FeedbackInteraction::create([
|
FeedbackInteraction::create([
|
||||||
'feedback_id' => $fb5->id, 'user_id' => $staffB->id, 'type' => 'status_change',
|
'feedback_id' => $fb5->id, 'user_id' => $staffB->id, 'type' => 'status_change',
|
||||||
'content' => 'Đã thay thế bản lề mới ngày 01/03. Tất cả tủ bếp hoạt động bình thường.',
|
'content' => 'Đã thay thế bản lề mới ngày 01/03. Tất cả tủ bếp hoạt động bình thường.',
|
||||||
'changes' => ['status' => ['old' => 'processing', 'new' => 'closed']],
|
'changes' => ['status' => ['old' => 'processing', 'new' => 'closed']],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// FB6: Đang xử lý + ghi chú khẩn cấp
|
|
||||||
FeedbackInteraction::create([
|
FeedbackInteraction::create([
|
||||||
'feedback_id' => $fb6->id, 'user_id' => $staffA->id, 'type' => 'note',
|
'feedback_id' => $fb6->id, 'user_id' => $staffA->id, 'type' => 'note',
|
||||||
'content' => 'Đã cử kỹ thuật viên khẩn cấp đến kiểm tra. Yêu cầu khách không sử dụng ổ cắm cho đến khi sửa xong.',
|
'content' => 'Đã cử kỹ thuật viên khẩn cấp đến kiểm tra. Yêu cầu khách không sử dụng ổ cắm cho đến khi sửa xong.',
|
||||||
@@ -352,8 +334,6 @@ class AfterSalesSeeder extends Seeder
|
|||||||
'content' => 'Kỹ thuật viên đã kiểm tra, phát hiện dây điện bị hở. Đang chờ vật tư thay thế.',
|
'content' => 'Kỹ thuật viên đã kiểm tra, phát hiện dây điện bị hở. Đang chờ vật tư thay thế.',
|
||||||
'changes' => ['status' => ['old' => 'pending', 'new' => 'processing']],
|
'changes' => ['status' => ['old' => 'pending', 'new' => 'processing']],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// FB7: Phân công + chuyển phòng ban
|
|
||||||
FeedbackInteraction::create([
|
FeedbackInteraction::create([
|
||||||
'feedback_id' => $fb7->id, 'user_id' => $managerCskh->id, 'type' => 'assignment',
|
'feedback_id' => $fb7->id, 'user_id' => $managerCskh->id, 'type' => 'assignment',
|
||||||
'content' => 'Chuyển yêu cầu gia hạn hợp đồng sang Phòng Pháp lý.',
|
'content' => 'Chuyển yêu cầu gia hạn hợp đồng sang Phòng Pháp lý.',
|
||||||
@@ -364,15 +344,13 @@ class AfterSalesSeeder extends Seeder
|
|||||||
'content' => 'Đã soạn thảo phụ lục hợp đồng gia hạn. Đang chờ khách hàng ký.',
|
'content' => 'Đã soạn thảo phụ lục hợp đồng gia hạn. Đang chờ khách hàng ký.',
|
||||||
'changes' => ['status' => ['old' => 'processing', 'new' => 'resolved']],
|
'changes' => ['status' => ['old' => 'processing', 'new' => 'resolved']],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// FB8: Ghi chú
|
|
||||||
FeedbackInteraction::create([
|
FeedbackInteraction::create([
|
||||||
'feedback_id' => $fb8->id, 'user_id' => $staffB->id, 'type' => 'note',
|
'feedback_id' => $fb8->id, 'user_id' => $staffB->id, 'type' => 'note',
|
||||||
'content' => 'Đã liên hệ ban quản lý tòa nhà để xác minh thông tin. BQL cho biết sẽ nhắc nhở cư dân tầng trên.',
|
'content' => 'Đã liên hệ ban quản lý tòa nhà để xác minh thông tin. BQL cho biết sẽ nhắc nhở cư dân tầng trên.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 15. TICKET TRANSFER LOGS (2 transfers)
|
// 14. TICKET TRANSFER LOGS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
TicketTransferLog::create([
|
TicketTransferLog::create([
|
||||||
'feedback_id' => $fb3->id,
|
'feedback_id' => $fb3->id,
|
||||||
@@ -390,17 +368,15 @@ class AfterSalesSeeder extends Seeder
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 16. FEEDBACK ATTACHMENTS (6 attachments - public disk)
|
// 15. FEEDBACK ATTACHMENTS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
$disk = Storage::disk('public');
|
$disk = Storage::disk('public');
|
||||||
|
|
||||||
// Tạo file mẫu trên public disk
|
|
||||||
$disk->put('feedback-attachments/bao-cao-kiem-tra-dieu-hoa.pdf', '%PDF-1.4 Mẫu báo cáo kiểm tra điều hòa - Demo');
|
$disk->put('feedback-attachments/bao-cao-kiem-tra-dieu-hoa.pdf', '%PDF-1.4 Mẫu báo cáo kiểm tra điều hòa - Demo');
|
||||||
$disk->put('feedback-attachments/anh-tran-ro-ri.jpg', str_repeat('x', 1000)); // dummy image
|
$disk->put('feedback-attachments/anh-tran-ro-ri.jpg', str_repeat('x', 1000));
|
||||||
$disk->put('feedback-attachments/anh-ban-le-hong.png', str_repeat('y', 1200)); // dummy image
|
$disk->put('feedback-attachments/anh-ban-le-hong.png', str_repeat('y', 1200));
|
||||||
$disk->put('feedback-attachments/bao-gia-nang-cap.pdf', '%PDF-1.4 Báo giá nâng cấp chỗ đậu xe - Demo');
|
$disk->put('feedback-attachments/bao-gia-nang-cap.pdf', '%PDF-1.4 Báo giá nâng cấp chỗ đậu xe - Demo');
|
||||||
$disk->put('feedback-attachments/phu-luc-hop-dong.pdf', '%PDF-1.4 Phụ lục hợp đồng gia hạn - Demo');
|
$disk->put('feedback-attachments/phu-luc-hop-dong.pdf', '%PDF-1.4 Phụ lục hợp đồng gia hạn - Demo');
|
||||||
$disk->put('feedback-attachments/anh-o-cam-hu.jpg', str_repeat('z', 800)); // dummy image
|
$disk->put('feedback-attachments/anh-o-cam-hu.jpg', str_repeat('z', 800));
|
||||||
|
|
||||||
FeedbackAttachment::create([
|
FeedbackAttachment::create([
|
||||||
'uuid' => (string) Str::uuid(),
|
'uuid' => (string) Str::uuid(),
|
||||||
|
|||||||
@@ -88,7 +88,11 @@ return [
|
|||||||
'end_date' => 'End Date',
|
'end_date' => 'End Date',
|
||||||
'printed_at' => 'Print Date',
|
'printed_at' => 'Print Date',
|
||||||
'signed_status' => 'Signed Status',
|
'signed_status' => 'Signed Status',
|
||||||
|
'contract_category_section' => 'Contract Classification',
|
||||||
'contract_info' => 'Contract Information',
|
'contract_info' => 'Contract Information',
|
||||||
|
'representative_info' => 'Representative Information',
|
||||||
|
'representative_desc' => 'Person representing the owner (if any)',
|
||||||
|
'representative' => 'Representative',
|
||||||
'feedback_history' => 'Feedback History',
|
'feedback_history' => 'Feedback History',
|
||||||
'service_type' => 'Service Type',
|
'service_type' => 'Service Type',
|
||||||
'tab_all' => 'All',
|
'tab_all' => 'All',
|
||||||
|
|||||||
@@ -32,11 +32,21 @@ return [
|
|||||||
'liquidated' => 'Liquidated',
|
'liquidated' => 'Liquidated',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Contract Type
|
// Contract Category
|
||||||
|
'contract_category' => [
|
||||||
|
'ownership' => 'Ownership',
|
||||||
|
'business' => 'Business',
|
||||||
|
],
|
||||||
|
'contract_category_label' => 'Contract Category',
|
||||||
|
|
||||||
|
// Contract Type (V2)
|
||||||
'contract_type' => [
|
'contract_type' => [
|
||||||
'sale' => 'Sale contract',
|
'deposit' => 'Deposit',
|
||||||
'lease' => 'Lease contract',
|
'purchase' => 'Purchase',
|
||||||
'bcc' => 'Business cooperation contract (BCC)',
|
'transfer' => 'Transfer',
|
||||||
|
'rental' => 'Rental',
|
||||||
|
'self_business' => 'Self Business',
|
||||||
|
'bcc' => 'Business Cooperation (BCC)',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Labels for Select filters
|
// Labels for Select filters
|
||||||
|
|||||||
@@ -60,4 +60,8 @@ return [
|
|||||||
'log_export_done' => 'Exported :tables tables, :rows rows',
|
'log_export_done' => 'Exported :tables tables, :rows rows',
|
||||||
'log_transfer' => ':title transferred from :from to :to',
|
'log_transfer' => ':title transferred from :from to :to',
|
||||||
'log_close' => 'Ticket closed: :title',
|
'log_close' => 'Ticket closed: :title',
|
||||||
|
|
||||||
|
// Contract validation
|
||||||
|
'contract_duplicate_title' => 'Product already has active :category contract',
|
||||||
|
'contract_duplicate_body' => 'Product :product already has an active contract of this category with :customer. Please liquidate the existing contract before creating a new one.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -88,7 +88,11 @@ return [
|
|||||||
'end_date' => 'Ngày kết thúc',
|
'end_date' => 'Ngày kết thúc',
|
||||||
'printed_at' => 'Ngày in HĐ',
|
'printed_at' => 'Ngày in HĐ',
|
||||||
'signed_status' => 'Tình trạng ký',
|
'signed_status' => 'Tình trạng ký',
|
||||||
|
'contract_category_section' => 'Phân loại hợp đồng',
|
||||||
'contract_info' => 'Thông tin hợp đồng',
|
'contract_info' => 'Thông tin hợp đồng',
|
||||||
|
'representative_info' => 'Thông tin người đại diện',
|
||||||
|
'representative_desc' => 'Người đại diện thay mặt chủ sở hữu (nếu có)',
|
||||||
|
'representative' => 'Người đại diện',
|
||||||
'feedback_history' => 'Lịch sử phản ánh',
|
'feedback_history' => 'Lịch sử phản ánh',
|
||||||
'service_type' => 'Loại dịch vụ',
|
'service_type' => 'Loại dịch vụ',
|
||||||
'tab_all' => 'Tất cả',
|
'tab_all' => 'Tất cả',
|
||||||
|
|||||||
@@ -32,11 +32,21 @@ return [
|
|||||||
'liquidated' => 'Đã thanh lý',
|
'liquidated' => 'Đã thanh lý',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Contract Type
|
// Contract Category
|
||||||
|
'contract_category' => [
|
||||||
|
'ownership' => 'Sở hữu',
|
||||||
|
'business' => 'Kinh doanh',
|
||||||
|
],
|
||||||
|
'contract_category_label' => 'Phân loại HĐ',
|
||||||
|
|
||||||
|
// Contract Type (V2)
|
||||||
'contract_type' => [
|
'contract_type' => [
|
||||||
'sale' => 'HĐ Mua bán',
|
'deposit' => 'Đặt cọc',
|
||||||
'lease' => 'HĐ Thuê',
|
'purchase' => 'Mua bán',
|
||||||
'bcc' => 'HĐ Hợp tác kinh doanh (BCC)',
|
'transfer' => 'Chuyển nhượng',
|
||||||
|
'rental' => 'Cho thuê',
|
||||||
|
'self_business' => 'Tự doanh',
|
||||||
|
'bcc' => 'Hợp tác KD (BCC)',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Labels cho Select filters
|
// Labels cho Select filters
|
||||||
|
|||||||
@@ -60,4 +60,8 @@ return [
|
|||||||
'log_export_done' => 'Xuất :tables bảng, :rows dòng',
|
'log_export_done' => 'Xuất :tables bảng, :rows dòng',
|
||||||
'log_transfer' => ':title: chuyển từ :from sang :to',
|
'log_transfer' => ':title: chuyển từ :from sang :to',
|
||||||
'log_close' => 'Đóng ticket: :title',
|
'log_close' => 'Đóng ticket: :title',
|
||||||
|
|
||||||
|
// Contract validation
|
||||||
|
'contract_duplicate_title' => 'Căn hộ đã có HĐ :category active',
|
||||||
|
'contract_duplicate_body' => 'Căn hộ :product đã có HĐ thuộc nhóm này với khách :customer. Vui lòng thanh lý HĐ cũ trước khi tạo mới.',
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user