diff --git a/AGENTS.md b/AGENTS.md index f19de2c0..4da94f1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -424,3 +424,78 @@ $user->assignRole('manager'); 13. **i18n - ChartWidget heading**: `$heading`/`$description` are raw properties — must override `getHeading()`/`getDescription()`. 14. **i18n - form labels**: Form fields without `->label()` use Filament's `filament-panels::` namespace — always add explicit `->label(__('key'))`. 15. **i18n - Filament vendor**: Filament ships 57 Vietnamese translation files — auto-activated when `APP_LOCALE=vi`. +16. **FileUpload disk**: Always use `disk('public')` for all FileUpload components. NEVER use `disk('local')` — causes file not found errors. +17. **FileUpload dehydrated**: Do NOT use `dehydrated(false)` — prevents file paths from being available in afterSave()/afterCreate(). Use property to store paths in mutateFormDataBeforeSave(). +18. **File metadata safety**: Always check `$disk->exists($path)` before calling `$disk->mimeType()` or `$disk->size()` — prevents UnableToRetrieveMetadata error. +19. **FileUpload in Actions**: When handling file uploads in Action callbacks (e.g., transfer attachments), file paths are strings (not UploadedFile). Create records directly instead of using FileService::upload(). +20. **Tailwind CSS in Filament**: Custom blade views render inside Filament layout which does NOT include full Tailwind CSS build. Must add needed utilities to `public/css/filament-custom.css` with `!important`. +21. **SVG icon sizing**: Use inline `width`/`height` attributes on SVG icons (e.g., ``) instead of Tailwind classes (`w-3.5 h-3.5`) — prevents Filament CSS from overriding sizes. + +## File Upload Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FileUpload Component (disk='public') │ +│ → Filament uploads via AJAX to storage/app/public/ │ +│ → File path stored in Livewire state │ +│ │ +│ Form Submit │ +│ → mutateFormDataBeforeSave() stores paths in property │ +│ → Removes 'attachments' from $data before saving model │ +│ │ +│ afterSave()/afterCreate() │ +│ → Uses stored paths to create FeedbackAttachment records │ +│ → Checks $disk->exists($path) before getting metadata │ +│ → Uses asset('storage/' . $path) for URLs │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### FileUpload Pattern (correct): +```php +// In Form +FileUpload::make('attachments') + ->disk('public') // ALWAYS public + ->directory('feedback-attachments') + // Do NOT use ->dehydrated(false) + +// In Page class +protected array $pendingAttachments = []; + +protected function mutateFormDataBeforeSave(array $data): array +{ + $this->pendingAttachments = $data['attachments'] ?? []; + unset($data['attachments']); // Remove before saving model + return $data; +} + +protected function afterSave(): void +{ + $disk = Storage::disk('public'); + foreach ($this->pendingAttachments as $path) { + if (! $disk->exists($path)) continue; // Safety check + + $this->getRecord()->attachments()->create([ + 'path' => $path, + 'disk' => 'public', + 'mime_type' => $disk->mimeType($path), + 'size' => $disk->size($path), + // ... + ]); + } +} +``` + +### Tailwind CSS in Filament (correct): +```php +// Filament uses its own CSS build, NOT the project's Tailwind build +// Custom blade views need utilities added to public/css/filament-custom.css + +// In filament-custom.css: +.flex { display: flex !important; } +.items-center { align-items: center !important; } +.gap-2 { gap: 0.5rem !important; } +// ... add all needed utilities with !important + +// For SVG icons, use inline attributes: +... +// NOT: ... diff --git a/app/Filament/Resources/Feedback/Pages/CreateFeedback.php b/app/Filament/Resources/Feedback/Pages/CreateFeedback.php index 795516a8..cddf3974 100644 --- a/app/Filament/Resources/Feedback/Pages/CreateFeedback.php +++ b/app/Filament/Resources/Feedback/Pages/CreateFeedback.php @@ -10,12 +10,19 @@ class CreateFeedback extends CreateRecord { protected static string $resource = FeedbackResource::class; + protected array $pendingAttachments = []; + protected string $pendingCollection = 'general'; + protected function mutateFormDataBeforeCreate(array $data): array { if (($data['is_general'] ?? false) === true) { $data['customer_product_id'] = null; } - unset($data['is_general'], $data['attachment_collection']); + + $this->pendingAttachments = $data['attachments'] ?? []; + $this->pendingCollection = $data['attachment_collection'] ?? 'general'; + + unset($data['is_general'], $data['attachment_collection'], $data['attachments']); return $data; } @@ -23,8 +30,6 @@ class CreateFeedback extends CreateRecord protected function afterCreate(): void { $feedback = $this->getRecord(); - $attachments = $this->data['attachments'] ?? []; - $collection = $this->data['attachment_collection'] ?? 'general'; $feedback->interactions()->create([ 'user_id' => auth()->id(), @@ -33,20 +38,27 @@ class CreateFeedback extends CreateRecord 'changes' => ['status' => ['old' => null, 'new' => $feedback->status]], ]); - if (! empty($attachments)) { - $disk = Storage::disk('local'); - foreach ($attachments as $path) { - $feedback->attachments()->create([ - 'uuid' => (string) \Illuminate\Support\Str::uuid(), - 'user_id' => auth()->id(), - 'name' => basename($path), - 'path' => $path, - 'disk' => 'local', - 'collection' => $collection, - 'mime_type' => $disk->mimeType($path), - 'size' => $disk->size($path), - ]); + if (empty($this->pendingAttachments)) { + return; + } + + $disk = Storage::disk('public'); + + foreach ($this->pendingAttachments as $path) { + if (! $disk->exists($path)) { + continue; } + + $feedback->attachments()->create([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + 'user_id' => auth()->id(), + 'name' => basename($path), + 'path' => $path, + 'disk' => 'public', + 'collection' => $this->pendingCollection, + 'mime_type' => $disk->mimeType($path), + 'size' => $disk->size($path), + ]); } } } diff --git a/app/Filament/Resources/Feedback/Pages/EditFeedback.php b/app/Filament/Resources/Feedback/Pages/EditFeedback.php index 68ee9d99..0d053847 100644 --- a/app/Filament/Resources/Feedback/Pages/EditFeedback.php +++ b/app/Filament/Resources/Feedback/Pages/EditFeedback.php @@ -63,7 +63,7 @@ class EditFeedback extends EditRecord FileUpload::make('transfer_attachments') ->label(__('app.attachments')) ->multiple() - ->disk('local') + ->disk('public') ->directory('feedback-attachments') ->columnSpanFull(), ]) @@ -82,13 +82,23 @@ class EditFeedback extends EditRecord ); if (! empty($data['transfer_attachments'])) { - $fileService = App::make(FileService::class); - $fileService->upload( - $data['transfer_attachments'], - 'general', - $feedback, - $sender->id, - ); + $disk = Storage::disk('public'); + foreach ($data['transfer_attachments'] as $path) { + if (! $disk->exists($path)) { + continue; + } + + $feedback->attachments()->create([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + 'user_id' => $sender->id, + 'name' => basename($path), + 'path' => $path, + 'disk' => 'public', + 'collection' => 'general', + 'mime_type' => $disk->mimeType($path), + 'size' => $disk->size($path), + ]); + } } Notification::make() @@ -145,12 +155,19 @@ class EditFeedback extends EditRecord return view('filament.resources.feedback.edit-footer'); } + protected array $pendingAttachments = []; + protected string $pendingCollection = 'general'; + protected function mutateFormDataBeforeSave(array $data): array { if (($data['is_general'] ?? false) === true) { $data['customer_product_id'] = null; } - unset($data['is_general'], $data['attachment_collection']); + + $this->pendingAttachments = $data['attachments'] ?? []; + $this->pendingCollection = $data['attachment_collection'] ?? 'general'; + + unset($data['is_general'], $data['attachment_collection'], $data['attachments']); $newStatus = $data['status'] ?? null; if ($newStatus === 'closed' && $this->getRecord()->status !== 'closed') { @@ -163,24 +180,28 @@ class EditFeedback extends EditRecord protected function afterSave(): void { - $attachments = $this->data['attachments'] ?? []; - $collection = $this->data['attachment_collection'] ?? 'general'; + if (empty($this->pendingAttachments)) { + return; + } - if (! empty($attachments)) { - $disk = Storage::disk('local'); - $feedback = $this->getRecord(); - foreach ($attachments as $path) { - $feedback->attachments()->create([ - 'uuid' => (string) \Illuminate\Support\Str::uuid(), - 'user_id' => auth()->id(), - 'name' => basename($path), - 'path' => $path, - 'disk' => 'local', - 'collection' => $collection, - 'mime_type' => $disk->mimeType($path), - 'size' => $disk->size($path), - ]); + $disk = Storage::disk('public'); + $feedback = $this->getRecord(); + + foreach ($this->pendingAttachments as $path) { + if (! $disk->exists($path)) { + continue; } + + $feedback->attachments()->create([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + 'user_id' => auth()->id(), + 'name' => basename($path), + 'path' => $path, + 'disk' => 'public', + 'collection' => $this->pendingCollection, + 'mime_type' => $disk->mimeType($path), + 'size' => $disk->size($path), + ]); } } diff --git a/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php b/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php index dfe76316..7405d1b5 100644 --- a/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php +++ b/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php @@ -75,7 +75,7 @@ class InteractionsRelationManager extends RelationManager FileUpload::make('attachments') ->label(__('app.attachments')) ->multiple() - ->disk('local') + ->disk('public') ->directory('feedback-attachments') ->columnSpanFull(), ]); @@ -203,15 +203,19 @@ class InteractionsRelationManager extends RelationManager $attachments = $data['_attachments'] ?? []; if (! empty($attachments)) { - $disk = Storage::disk('local'); + $disk = Storage::disk('public'); foreach ($attachments as $path) { + if (! $disk->exists($path)) { + continue; + } + $record->attachments()->create([ 'uuid' => (string) \Illuminate\Support\Str::uuid(), 'feedback_id' => $record->feedback_id, 'user_id' => auth()->id(), 'name' => basename($path), 'path' => $path, - 'disk' => 'local', + 'disk' => 'public', 'collection' => 'general', 'mime_type' => $disk->mimeType($path), 'size' => $disk->size($path), diff --git a/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php b/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php index 7c1bb1a6..b74413b3 100644 --- a/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php +++ b/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php @@ -195,10 +195,9 @@ class FeedbackForm FileUpload::make('attachments') ->label(__('app.files')) ->multiple() - ->disk('local') + ->disk('public') ->directory('feedback-attachments') - ->columnSpan(1) - ->dehydrated(false), + ->columnSpan(1), ]) ->columns(2), ]); diff --git a/app/Services/FileService.php b/app/Services/FileService.php index 1456edfd..95e53830 100644 --- a/app/Services/FileService.php +++ b/app/Services/FileService.php @@ -24,7 +24,7 @@ class FileService protected int $maxSize = 20480; - protected string $defaultDisk = 'local'; + protected string $defaultDisk = 'public'; /** * Upload files and create FeedbackAttachment records. @@ -35,7 +35,7 @@ class FileService Model $model, int $userId, ?int $interactionId = null, - string $disk = 'local', + string $disk = 'public', ): array { $records = []; @@ -71,7 +71,7 @@ class FileService public function getTemporaryUrl(FeedbackAttachment $attachment, int $minutes = 10): string { if ($attachment->disk === 'public') { - return Storage::disk('public')->url($attachment->path); + return asset('storage/' . $attachment->path); } return Storage::disk($attachment->disk)->temporaryUrl( diff --git a/database/seeders/AfterSalesSeeder.php b/database/seeders/AfterSalesSeeder.php index 1c4d8b51..bda67894 100644 --- a/database/seeders/AfterSalesSeeder.php +++ b/database/seeders/AfterSalesSeeder.php @@ -2,396 +2,453 @@ namespace Database\Seeders; +use App\Enums\ContractStatus; +use App\Enums\ContractType; +use App\Enums\HandoverStatus; +use App\Enums\ServiceType; +use App\Models\Contract; use App\Models\Customer; +use App\Models\CustomerProduct; use App\Models\Department; use App\Models\Feedback; +use App\Models\FeedbackAttachment; use App\Models\FeedbackChannel; +use App\Models\FeedbackInteraction; +use App\Models\FeedbackTag; +use App\Models\Handover; use App\Models\Product; +use App\Models\ProductService; +use App\Models\TicketTransferLog; use App\Models\User; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Spatie\Permission\Models\Role; class AfterSalesSeeder extends Seeder { public function run(): void { + // ============================================================ + // 1. ROLES & PERMISSIONS + // ============================================================ Role::create(['name' => 'admin']); Role::create(['name' => 'manager']); Role::create(['name' => 'staff']); $this->call(PermissionSeeder::class); + // ============================================================ + // 2. USERS (5 users) + // ============================================================ $admin = User::create([ - 'name' => 'Admin', + 'name' => 'Nguyễn Quản Trị', 'email' => 'admin@minicrm.local', 'password' => bcrypt('password'), 'role' => 'admin', ]); $admin->assignRole('admin'); - $manager = User::create([ - 'name' => 'Manager', + $managerCskh = User::create([ + 'name' => 'Trần Minh Manager', 'email' => 'manager@minicrm.local', 'password' => bcrypt('password'), 'role' => 'manager', ]); - $manager->assignRole('manager'); + $managerCskh->assignRole('manager'); - $staff = User::create([ - 'name' => 'Staff', + $managerKt = User::create([ + 'name' => 'Lê Văn Kỹ Thuật', + 'email' => 'manager.kt@minicrm.local', + 'password' => bcrypt('password'), + 'role' => 'manager', + ]); + $managerKt->assignRole('manager'); + + $staffA = User::create([ + 'name' => 'Phạm Thị Nhân Viên', 'email' => 'staff@minicrm.local', 'password' => bcrypt('password'), 'role' => 'staff', ]); - $staff->assignRole('staff'); + $staffA->assignRole('staff'); - $depCskh = Department::create(['name' => 'Phòng CSKH', 'manager_id' => $manager->id]); - $depKythuat = Department::create(['name' => 'Phòng Kỹ thuật', 'manager_id' => $admin->id]); - $depKetoan = Department::create(['name' => 'Phòng Kế toán', 'manager_id' => null]); - $depPhaply = Department::create(['name' => 'Phòng Pháp lý', 'manager_id' => null]); + $staffB = User::create([ + 'name' => 'Hoàng Văn Phụ', + 'email' => 'staff.b@minicrm.local', + 'password' => bcrypt('password'), + 'role' => 'staff', + ]); + $staffB->assignRole('staff'); - $channels = [ - ['name' => 'Email', 'slug' => 'email', 'icon' => 'heroicon-o-envelope', 'color' => '#3b82f6'], - ['name' => 'Zalo', 'slug' => 'zalo', 'icon' => 'heroicon-o-chat-bubble-left', 'color' => '#10b981'], - ['name' => 'Phone', 'slug' => 'phone', 'icon' => 'heroicon-o-phone', 'color' => '#f59e0b'], - ['name' => 'Document', 'slug' => 'document', 'icon' => 'heroicon-o-document-text', 'color' => '#8b5cf6'], - ['name' => 'In Person', 'slug' => 'in-person', 'icon' => 'heroicon-o-user', 'color' => '#ec4899'], - ]; + // ============================================================ + // 3. DEPARTMENTS (4 departments) + // ============================================================ + $deptCskh = Department::create(['name' => 'Phòng Chăm sóc Khách hàng', 'manager_id' => $managerCskh->id]); + $deptKt = Department::create(['name' => 'Phòng Kỹ thuật', 'manager_id' => $managerKt->id]); + $deptKtoan = Department::create(['name' => 'Phòng Kế toán', 'manager_id' => null]); + $deptPhaply = Department::create(['name' => 'Phòng Pháp lý', 'manager_id' => null]); - foreach ($channels as $channel) { - FeedbackChannel::create($channel); - } + // ============================================================ + // 4. FEEDBACK CHANNELS (5 channels) + // ============================================================ + $chEmail = FeedbackChannel::create(['name' => 'Email', 'slug' => 'email', 'icon' => 'heroicon-o-envelope', 'color' => '#3b82f6']); + $chZalo = FeedbackChannel::create(['name' => 'Zalo', 'slug' => 'zalo', 'icon' => 'heroicon-o-chat-bubble-left', 'color' => '#10b981']); + $chPhone = FeedbackChannel::create(['name' => 'Điện thoại', 'slug' => 'phone', 'icon' => 'heroicon-o-phone', 'color' => '#f59e0b']); + $chWalkin = FeedbackChannel::create(['name' => 'Trực tiếp', 'slug' => 'walk-in', 'icon' => 'heroicon-o-user', 'color' => '#ec4899']); + $chApp = FeedbackChannel::create(['name' => 'Ứng dụng', 'slug' => 'app', 'icon' => 'heroicon-o-device-phone-mobile', 'color' => '#8b5cf6']); - $products = [ - ['name' => 'Sunrise Apartment A1', 'description' => 'Luxury apartment in District 1, 3 bedrooms, river view', 'status' => 'active'], - ['name' => 'Sunrise Apartment A2', 'description' => 'Premium apartment in District 1, 2 bedrooms, city view', 'status' => 'active'], - ['name' => 'Green Valley Villa', 'description' => 'Standalone villa with private garden and pool', 'status' => 'active'], - ['name' => 'Ocean Tower Office', 'description' => 'High-end office space in central business district', 'status' => 'sold_out'], - ['name' => 'Park Hill Townhouse', 'description' => 'Modern townhouse near the park, 4 bedrooms', 'status' => 'inactive'], - ]; + // ============================================================ + // 5. PRODUCTS (6 products) + // ============================================================ + $p1 = Product::create(['name' => 'Sunrise Apartment A101', 'description' => 'Căn hộ 3PN, tầng 10, view sông, nội thất cao cấp', 'status' => 'active']); + $p2 = Product::create(['name' => 'Sunrise Apartment A202', 'description' => 'Căn hộ 2PN, tầng 20, view thành phố', 'status' => 'active']); + $p3 = Product::create(['name' => 'Green Valley Villa V05', 'description' => 'Biệt thự đơn lập, hồ bơi riêng, sân vườn 200m²', 'status' => 'active']); + $p4 = Product::create(['name' => 'Ocean Tower Office O12', 'description' => 'Văn phòng hạng A, tầng 12, trung tâm Q1', 'status' => 'sold_out']); + $p5 = Product::create(['name' => 'Park Hill Townhouse T08', 'description' => 'Nhà phố liền kề, 4PN, gần công viên', 'status' => 'active']); + $p6 = Product::create(['name' => 'Sunrise Apartment B305', 'description' => 'Căn hộ Studio, tầng 3, phù hợp đầu tư cho thuê', 'status' => 'inactive']); - foreach ($products as $product) { - Product::create($product); - } + // ============================================================ + // 6. CUSTOMERS (6 customers) + // ============================================================ + $c1 = Customer::create(['name' => 'Nguyễn Văn An', 'email' => 'nguyenvanan@gmail.com', 'phone' => '0909123456', 'address' => 'Quận 1, TP.HCM', 'status' => 'active']); + $c2 = Customer::create(['name' => 'Trần Thị Bình', 'email' => 'tranthibinh@gmail.com', 'phone' => '0918234567', 'address' => 'Quận 7, TP.HCM', 'status' => 'active']); + $c3 = Customer::create(['name' => 'Lê Hoàng Dũng', 'email' => 'lehoangdung@gmail.com', 'phone' => '0927345678', 'address' => 'Quận 2, TP.HCM', 'status' => 'active']); + $c4 = Customer::create(['name' => 'Phạm Minh Châu', 'email' => 'phamminhchau@gmail.com', 'phone' => '0936456789', 'address' => 'Bình Thạnh, TP.HCM', 'status' => 'active']); + $c5 = Customer::create(['name' => 'Hoàng Thị Mai', 'email' => 'hoangthimai@gmail.com', 'phone' => '0945567890', 'address' => 'Quận 9, TP.HCM', 'status' => 'active']); + $c6 = Customer::create(['name' => 'Đỗ Văn Phúc', 'email' => 'dovanphuc@gmail.com', 'phone' => '0954678901', 'address' => 'Thủ Đức, TP.HCM', 'status' => 'inactive']); - $customers = [ - ['name' => 'Nguyen Van A', 'email' => 'nguyenvana@example.com', 'phone' => '0909123456', 'address' => 'Hanoi'], - ['name' => 'Tran Thi B', 'email' => 'tranthib@example.com', 'phone' => '0918234567', 'address' => 'Ho Chi Minh City'], - ['name' => 'Le Van C', 'email' => 'levanc@example.com', 'phone' => '0927345678', 'address' => 'Da Nang'], - ['name' => 'Pham Thi D', 'email' => 'phamthid@example.com', 'phone' => '0936456789', 'address' => 'Can Tho'], - ]; + // ============================================================ + // 7. CUSTOMER-PRODUCT pivots + // ============================================================ + $c1->products()->attach($p1->id, ['purchase_date' => '2024-01-15']); + $c1->products()->attach($p2->id, ['purchase_date' => '2024-06-01']); + $c2->products()->attach($p1->id, ['purchase_date' => '2024-03-20']); + $c3->products()->attach($p3->id, ['purchase_date' => '2024-09-10']); + $c4->products()->attach($p2->id, ['purchase_date' => '2025-01-05']); + $c4->products()->attach($p5->id, ['purchase_date' => '2025-02-15']); + $c5->products()->attach($p5->id, ['purchase_date' => '2024-11-01']); + $c6->products()->attach($p6->id, ['purchase_date' => '2023-06-15']); - foreach ($customers as $customerData) { - Customer::create($customerData); - } - - $customers = Customer::all(); - $products = Product::where('status', 'active')->get(); - $channels = FeedbackChannel::all(); - - $customers[0]->products()->attach($products[0]->id, ['purchase_date' => '2024-01-15']); - $customers[0]->products()->attach($products[1]->id, ['purchase_date' => '2024-06-01']); - $customers[1]->products()->attach($products[0]->id, ['purchase_date' => '2024-03-20']); - $customers[2]->products()->attach($products[2]->id, ['purchase_date' => '2024-09-10']); - $customers[3]->products()->attach($products[1]->id, ['purchase_date' => '2025-01-05']); - $customers[3]->products()->attach($products[2]->id, ['purchase_date' => '2025-02-15']); - - $contract1 = \App\Models\Contract::create([ - 'product_id' => $products[0]->id, - 'customer_id' => $customers[0]->id, - 'type' => \App\Enums\ContractType::SALE->value, - 'start_date' => '2024-01-15', - 'status' => \App\Enums\ContractStatus::ACTIVE->value, + // ============================================================ + // 8. CONTRACTS (6 contracts) + // ============================================================ + $contract1 = Contract::create([ + 'product_id' => $p1->id, 'customer_id' => $c1->id, + 'type' => ContractType::SALE->value, + 'start_date' => '2024-01-15', 'end_date' => null, + 'status' => ContractStatus::ACTIVE->value, 'signed_status' => 'Đã ký', ]); - - $contract2 = \App\Models\Contract::create([ - 'product_id' => $products[1]->id, - 'customer_id' => $customers[0]->id, - 'type' => \App\Enums\ContractType::LEASE->value, - 'start_date' => '2024-06-01', - 'end_date' => '2026-06-01', - 'status' => \App\Enums\ContractStatus::ACTIVE->value, + $contract2 = Contract::create([ + 'product_id' => $p2->id, 'customer_id' => $c1->id, + 'type' => ContractType::LEASE->value, + 'start_date' => '2024-06-01', 'end_date' => '2026-06-01', + 'status' => ContractStatus::ACTIVE->value, 'signed_status' => 'Đã ký', ]); - - $contract3 = \App\Models\Contract::create([ - 'product_id' => $products[0]->id, - 'customer_id' => $customers[1]->id, - 'type' => \App\Enums\ContractType::SALE->value, - 'start_date' => '2024-03-20', - 'status' => \App\Enums\ContractStatus::ACTIVE->value, + $contract3 = Contract::create([ + 'product_id' => $p1->id, 'customer_id' => $c2->id, + 'type' => ContractType::SALE->value, + 'start_date' => '2024-03-20', 'end_date' => null, + 'status' => ContractStatus::ACTIVE->value, 'signed_status' => 'Đã ký', ]); - - $contract4 = \App\Models\Contract::create([ - 'product_id' => $products[2]->id, - 'customer_id' => $customers[2]->id, - 'type' => \App\Enums\ContractType::BCC->value, - 'start_date' => '2024-09-10', - 'end_date' => '2029-09-10', - 'status' => \App\Enums\ContractStatus::ACTIVE->value, + $contract4 = Contract::create([ + 'product_id' => $p3->id, 'customer_id' => $c3->id, + 'type' => ContractType::BCC->value, + 'start_date' => '2024-09-10', 'end_date' => '2029-09-10', + 'status' => ContractStatus::ACTIVE->value, 'signed_status' => 'Đang chờ ký', ]); - - $contract5 = \App\Models\Contract::create([ - 'product_id' => $products[1]->id, - 'customer_id' => $customers[3]->id, - 'type' => \App\Enums\ContractType::LEASE->value, - 'start_date' => '2025-01-05', - 'end_date' => '2026-01-05', - 'status' => \App\Enums\ContractStatus::EXPIRED->value, + $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, + 'type' => ContractType::SALE->value, + 'start_date' => '2024-11-01', 'end_date' => null, + 'status' => ContractStatus::ACTIVE->value, 'signed_status' => 'Đã ký', ]); - $customerProduct1 = \App\Models\CustomerProduct::where('customer_id', $customers[0]->id)->where('product_id', $products[0]->id)->first(); - $customerProduct2 = \App\Models\CustomerProduct::where('customer_id', $customers[0]->id)->where('product_id', $products[1]->id)->first(); - $customerProduct3 = \App\Models\CustomerProduct::where('customer_id', $customers[1]->id)->where('product_id', $products[0]->id)->first(); + // ============================================================ + // 9. HANDOVERS (6 handovers) + // ============================================================ + Handover::create(['product_id' => $p1->id, 'customer_id' => $c1->id, 'handover_date' => '2024-02-15', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Đã bàn giao đầy đủ chìa khóa, thẻ từ và sổ bảo hành.']); + Handover::create(['product_id' => $p1->id, 'customer_id' => $c2->id, 'handover_date' => '2024-04-20', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Đã bàn giao, khách yêu cầu kiểm tra lại hệ thống nước nóng.']); + Handover::create(['product_id' => $p2->id, 'customer_id' => $c1->id, 'handover_date' => null, 'status' => HandoverStatus::READY->value, 'remark' => 'Căn hộ đã hoàn thiện, chờ khách xác nhận lịch bàn giao.']); + Handover::create(['product_id' => $p3->id, 'customer_id' => $c3->id, 'handover_date' => '2024-10-01', 'status' => HandoverStatus::SCHEDULED->value, 'remark' => 'Đã hẹn lịch 10h sáng ngày 01/10/2024.']); + Handover::create(['product_id' => $p5->id, 'customer_id' => $c4->id, 'handover_date' => null, 'status' => HandoverStatus::NOT_READY->value, 'remark' => 'Đang hoàn thiện nội thất, dự kiến đủ điều kiện sau 2 tuần.']); + 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.']); - \App\Models\Handover::create([ - 'product_id' => $products[0]->id, - 'customer_id' => $customers[0]->id, - 'handover_date' => '2024-02-15', - 'status' => \App\Enums\HandoverStatus::HANDED_OVER->value, - 'remark' => 'Đã bàn giao đầy đủ chìa khóa và thẻ căn hộ.', - ]); + // ============================================================ + // 10. PRODUCT SERVICES (6 services) + // ============================================================ + ProductService::create(['product_id' => $p1->id, 'service_type' => ServiceType::MANAGEMENT_FEE->value, 'status' => 'active', 'actual_collection_date' => '2024-03-01', 'remark' => 'Phí quản lý hàng tháng 5.000.000 VND/căn.']); + ProductService::create(['product_id' => $p1->id, 'service_type' => ServiceType::GRATITUDE->value, 'status' => 'completed', 'actual_collection_date' => '2024-06-15', 'remark' => 'Quà tri ân khách hàng nhân dịp khai trương tiện ích mới.']); + ProductService::create(['product_id' => $p2->id, 'service_type' => ServiceType::MANAGEMENT_FEE->value, 'status' => 'pending', 'actual_collection_date' => null, 'remark' => 'Chưa thu phí quản lý quý 1/2025.']); + ProductService::create(['product_id' => $p3->id, 'service_type' => ServiceType::SELF_BUSINESS->value, 'status' => 'active', 'actual_collection_date' => '2024-10-01', 'remark' => 'Dịch vụ cho thuê hồ bơi và BBQ khu Villa.']); + ProductService::create(['product_id' => $p5->id, 'service_type' => ServiceType::GRATITUDE->value, 'status' => 'cancelled', 'actual_collection_date' => null, 'remark' => 'KH từ chối nhận quà, chuyển sang đợt sau.']); + 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.']); - \App\Models\Handover::create([ - 'product_id' => $products[0]->id, - 'customer_id' => $customers[1]->id, - 'handover_date' => '2024-04-20', - 'status' => \App\Enums\HandoverStatus::HANDED_OVER->value, - 'remark' => 'Đã bàn giao, còn thiếu sổ bảo hành nội thất.', - ]); + // ============================================================ + // 11. FEEDBACK TAGS (6 tags) + // ============================================================ + $tagLeaking = FeedbackTag::create(['name' => 'Rò rỉ', 'slug' => 'leaking', 'color' => '#3b82f6']); + $tagElectrical = FeedbackTag::create(['name' => 'Điện nước', 'slug' => 'electrical', 'color' => '#f59e0b']); + $tagWarranty = FeedbackTag::create(['name' => 'Bảo hành', 'slug' => 'warranty', 'color' => '#10b981']); + $tagMaintenance = FeedbackTag::create(['name' => 'Bảo trì', 'slug' => 'maintenance', 'color' => '#8b5cf6']); + $tagComplaint = FeedbackTag::create(['name' => 'Phàn nàn', 'slug' => 'complaint', 'color' => '#ef4444']); + $tagSuggestion = FeedbackTag::create(['name' => 'Đề xuất', 'slug' => 'suggestion', 'color' => '#06b6d4']); - \App\Models\Handover::create([ - 'product_id' => $products[1]->id, - 'customer_id' => $customers[0]->id, - 'status' => \App\Enums\HandoverStatus::READY->value, - 'remark' => 'Căn hộ đã hoàn thiện, chờ khách xác nhận lịch bàn giao.', - ]); + // ============================================================ + // 12. FEEDBACKS (8 feedbacks - various statuses) + // ============================================================ - \App\Models\Handover::create([ - 'product_id' => $products[2]->id, - 'customer_id' => $customers[2]->id, - 'handover_date' => '2024-10-01', - 'status' => \App\Enums\HandoverStatus::SCHEDULED->value, - 'remark' => 'Đã hẹn lịch 10h sáng ngày 01/10/2024.', - ]); - - \App\Models\Handover::create([ - 'product_id' => $products[1]->id, - 'customer_id' => $customers[3]->id, - 'status' => \App\Enums\HandoverStatus::NOT_READY->value, - 'remark' => 'Đang hoàn thiện nội thất, dự kiến đủ điều kiện sau 2 tuần.', - ]); - - \App\Models\ProductService::create([ - 'product_id' => $products[0]->id, - 'service_type' => \App\Enums\ServiceType::MANAGEMENT_FEE->value, - 'status' => 'active', - 'actual_collection_date' => '2024-03-01', - 'remark' => 'Phí quản lý hàng tháng 5.000.000 VND.', - ]); - - \App\Models\ProductService::create([ - 'product_id' => $products[0]->id, - 'service_type' => \App\Enums\ServiceType::GRATITUDE->value, - 'status' => 'completed', - 'actual_collection_date' => '2024-06-15', - 'remark' => 'Quà tri ân khách hàng dịp sinh nhật BQL.', - ]); - - \App\Models\ProductService::create([ - 'product_id' => $products[1]->id, - 'service_type' => \App\Enums\ServiceType::MANAGEMENT_FEE->value, + // Feedback 1: Pending - Trần rò rỉ + $cp1 = CustomerProduct::where('customer_id', $c1->id)->where('product_id', $p1->id)->first(); + $fb1 = Feedback::create([ + 'customer_id' => $c1->id, 'customer_product_id' => $cp1->id, 'contract_id' => $contract1->id, + 'feedback_channel_id' => $chEmail->id, 'assigned_to' => $staffA->id, + 'current_department_id' => $deptCskh->id, + 'title' => 'Trần nhà bị rò rỉ nước mưa', + 'content' => 'Sau trận mưa lớn ngày 25/04, trần phòng khách xuất hiện vết nước và nhỏ giọt. Đề nghị kiểm tra và sửa chữa khẩn cấp.', 'status' => 'pending', - 'remark' => 'Chưa thu phí quản lý quý 1/2025.', ]); - \App\Models\ProductService::create([ - 'product_id' => $products[2]->id, - 'service_type' => \App\Enums\ServiceType::SELF_BUSINESS->value, - 'status' => 'active', - 'actual_collection_date' => '2024-10-01', - 'remark' => 'Dịch vụ cho thuê hồ bơi và BBQ khu Villa.', + // Feedback 2: Processing - Nâng cấp chỗ đậu xe + $cp2 = CustomerProduct::where('customer_id', $c1->id)->where('product_id', $p2->id)->first(); + $fb2 = Feedback::create([ + 'customer_id' => $c1->id, 'customer_product_id' => $cp2->id, 'contract_id' => $contract2->id, + 'feedback_channel_id' => $chPhone->id, 'assigned_to' => $managerCskh->id, + 'current_department_id' => $deptCskh->id, + 'title' => 'Yêu cầu nâng cấp chỗ đậu xe', + 'content' => 'Tôi muốn nâng cấp từ 1 chỗ đậu xe lên 2 chỗ. Vui lòng tư vấn giá cả và thủ tục.', + 'status' => 'processing', ]); - \App\Models\ProductService::create([ - 'product_id' => $products[1]->id, - 'service_type' => \App\Enums\ServiceType::GRATITUDE->value, - 'status' => 'cancelled', - 'remark' => 'KH từ chối nhận quà, chuyển sang đợt sau.', + // Feedback 3: Resolved - Điều hòa hỏng + $cp3 = CustomerProduct::where('customer_id', $c2->id)->where('product_id', $p1->id)->first(); + $fb3 = Feedback::create([ + 'customer_id' => $c2->id, 'customer_product_id' => $cp3->id, 'contract_id' => $contract3->id, + 'feedback_channel_id' => $chZalo->id, 'assigned_to' => $staffA->id, + 'current_department_id' => $deptKt->id, 'is_escalated' => false, + 'title' => 'Điều hòa phòng ngủ chính không mát', + 'content' => 'Điều hòa Panasonic Inverter 1.5HP trong phòng ngủ chính phát ra tiếng ồn lớn và không làm mát hiệu quả. Đã vệ sinh lưới lọc nhưng không cải thiện.', + 'status' => 'resolved', ]); - $feedbacks = [ - [ - 'customer_id' => $customers[0]->id, - 'customer_product_id' => $customerProduct1->id, - 'contract_id' => $contract1->id, - 'feedback_channel_id' => $channels->firstWhere('slug', 'email')->id, - 'title' => 'Leaking ceiling in living room', - 'content' => 'I noticed water stains on the ceiling of the living room after heavy rain. Please send someone to inspect and fix.', - 'status' => 'pending', - 'assigned_to' => $staff->id, - 'current_department_id' => $depCskh->id, - ], - [ - 'customer_id' => $customers[0]->id, - 'customer_product_id' => $customerProduct2->id, - 'contract_id' => $contract2->id, - 'feedback_channel_id' => $channels->firstWhere('slug', 'phone')->id, - 'title' => 'Request for parking slot upgrade', - 'content' => 'I would like to upgrade from one parking slot to two. Please advise on availability and pricing.', - 'status' => 'processing', - 'assigned_to' => $manager->id, - 'current_department_id' => $depCskh->id, - ], - [ - 'customer_id' => $customers[1]->id, - 'customer_product_id' => $customerProduct3->id, - 'contract_id' => $contract3->id, - 'feedback_channel_id' => $channels->firstWhere('slug', 'zalo')->id, - 'title' => 'AC not cooling properly', - 'content' => 'The air conditioner in the master bedroom is making loud noises and not cooling effectively.', - 'status' => 'resolved', - 'assigned_to' => $staff->id, - 'current_department_id' => $depKythuat->id, - ], - [ - 'customer_id' => $customers[2]->id, - 'customer_product_id' => null, - 'contract_id' => null, - 'feedback_channel_id' => $channels->firstWhere('slug', 'document')->id, - 'title' => 'General feedback on community services', - 'content' => 'I suggest improving the garbage collection schedule to twice a day instead of once. Also, the security guard shift changes could be more organized.', - 'status' => 'pending', - 'assigned_to' => null, - 'current_department_id' => $depCskh->id, - ], - [ - 'customer_id' => $customers[3]->id, - 'customer_product_id' => \App\Models\CustomerProduct::where('customer_id', $customers[3]->id)->where('product_id', $products[1]->id)->first()->id, - 'contract_id' => $contract5->id, - 'feedback_channel_id' => $channels->firstWhere('slug', 'email')->id, - 'title' => 'Kitchen cabinet hinge broken', - 'content' => 'One of the kitchen cabinet hinges is broken. It is still under warranty, please arrange replacement.', - 'status' => 'closed', - 'assigned_to' => $staff->id, - 'current_department_id' => $depKythuat->id, - ], - ]; + // Feedback 4: Pending - Góp ý cải thiện dịch vụ + $fb4 = Feedback::create([ + 'customer_id' => $c3->id, 'customer_product_id' => null, 'contract_id' => null, + 'feedback_channel_id' => $chApp->id, 'assigned_to' => null, + 'current_department_id' => $deptCskh->id, + 'title' => 'Đề xuất cải thiện dịch vụ vệ sinh', + 'content' => 'Tôi đề xuất tăng tần suất thu gom rác lên 2 lần/ngày (hiện tại 1 lần). Khu vực hành lang tầng 3 thường xuyên có mùi khó chịu vào buổi chiều.', + 'status' => 'pending', + ]); - foreach ($feedbacks as $feedback) { - Feedback::create($feedback); - } + // Feedback 5: Closed - Bản lề tủ bếp + $cp5 = CustomerProduct::where('customer_id', $c4->id)->where('product_id', $p2->id)->first(); + $fb5 = Feedback::create([ + 'customer_id' => $c4->id, 'customer_product_id' => $cp5->id, 'contract_id' => $contract5->id, + 'feedback_channel_id' => $chEmail->id, 'assigned_to' => $staffB->id, + 'current_department_id' => $deptKt->id, + 'title' => 'Bản lề tủ bếp bị gãy', + 'content' => 'Bản lề cánh tủ bếp trên bị gãy, không đóng được. Còn trong thời gian bảo hành, đề nghị thay thế.', + 'status' => 'closed', + ]); - $tags = [ - ['name' => 'Leaking', 'slug' => 'leaking', 'color' => '#3b82f6'], - ['name' => 'Electrical', 'slug' => 'electrical', 'color' => '#f59e0b'], - ['name' => 'Warranty', 'slug' => 'warranty', 'color' => '#10b981'], - ['name' => 'Maintenance', 'slug' => 'maintenance', 'color' => '#8b5cf6'], - ['name' => 'Service Request', 'slug' => 'service-request', 'color' => '#ec4899'], - ]; + // Feedback 6: Processing - Ổ cắm điện hư + $cp6 = CustomerProduct::where('customer_id', $c5->id)->where('product_id', $p5->id)->first(); + $fb6 = Feedback::create([ + 'customer_id' => $c5->id, 'customer_product_id' => $cp6->id, 'contract_id' => $contract6->id, + 'feedback_channel_id' => $chPhone->id, 'assigned_to' => $staffA->id, + 'current_department_id' => $deptKt->id, 'is_escalated' => true, + 'title' => 'Ổ c插座 điện phòng bếp bị hư, có mùi khét', + 'content' => 'Ổ cắm điện cạnh bồn rửa chén bị hư, phát ra mùi khét khi cắm thiết bị. Đây là vấn đề an toàn, cần xử lý gấp.', + 'status' => 'processing', + ]); - foreach ($tags as $tag) { - \App\Models\FeedbackTag::create($tag); - } + // Feedback 7: Resolved - Hợp đồng thuê + $fb7 = Feedback::create([ + 'customer_id' => $c4->id, 'customer_product_id' => null, 'contract_id' => $contract5->id, + 'feedback_channel_id' => $chWalkin->id, 'assigned_to' => $staffB->id, + 'current_department_id' => $deptPhaply->id, + 'title' => 'Hỏi về gia hạn hợp đồng thuê', + 'content' => 'Hợp đồng thuê căn hộ A202 sẽ hết hạn vào 01/2026. Tôi muốn gia hạn thêm 2 năm, vui lòng tư vấn thủ tục và giá thuê mới.', + 'status' => 'resolved', + ]); - $allFeedback = Feedback::all(); - $allTags = \App\Models\FeedbackTag::all(); + // Feedback 8: Pending - Phàn nàn về tiếng ồn + $fb8 = Feedback::create([ + 'customer_id' => $c2->id, 'customer_product_id' => $cp3->id, 'contract_id' => $contract3->id, + 'feedback_channel_id' => $chZalo->id, 'assigned_to' => $staffB->id, + 'current_department_id' => $deptCskh->id, + 'title' => 'Phàn nàn về tiếng ồn từ tầng trên', + 'content' => 'Căn hộ tầng trên thường xuyên gây tiếng ồn sau 22h, ảnh hưởng đến giấc ngủ gia đình tôi. Đã phản ánh 2 lần nhưng chưa được giải quyết.', + 'status' => 'pending', + ]); - $allFeedback[0]->tags()->attach([$allTags[0]->id, $allTags[3]->id]); - $allFeedback[1]->tags()->attach([$allTags[4]->id]); - $allFeedback[2]->tags()->attach([$allTags[1]->id, $allTags[2]->id]); - $allFeedback[3]->tags()->attach([$allTags[3]->id]); - $allFeedback[4]->tags()->attach([$allTags[2]->id, $allTags[3]->id]); + // ============================================================ + // 13. TAG ATTACHMENTS + // ============================================================ + $fb1->tags()->attach([$tagLeaking->id, $tagMaintenance->id]); + $fb2->tags()->attach([$tagSuggestion->id]); + $fb3->tags()->attach([$tagElectrical->id, $tagWarranty->id]); + $fb4->tags()->attach([$tagSuggestion->id]); + $fb5->tags()->attach([$tagWarranty->id, $tagMaintenance->id]); + $fb6->tags()->attach([$tagElectrical->id, $tagComplaint->id]); + $fb7->tags()->attach([$tagSuggestion->id]); + $fb8->tags()->attach([$tagComplaint->id]); - \App\Models\FeedbackInteraction::create([ - 'feedback_id' => $allFeedback[0]->id, - 'user_id' => $admin->id, - 'type' => 'note', - 'content' => 'Feedback received via Email. Assigned to Staff for initial handling.', + // ============================================================ + // 14. FEEDBACK INTERACTIONS (12 interactions) + // ============================================================ + + // FB1: Tạo mới + ghi chú + FeedbackInteraction::create([ + 'feedback_id' => $fb1->id, 'user_id' => $admin->id, 'type' => 'note', + 'content' => 'Tiếp nhận phản ánh qua Email. Giao cho nhân viên Phạm Thị xử lý.', 'changes' => ['status' => ['old' => null, 'new' => 'pending']], ]); - - \App\Models\FeedbackInteraction::create([ - 'feedback_id' => $allFeedback[0]->id, - 'user_id' => $staff->id, - 'type' => 'note', - 'content' => 'Contacted customer via phone. Scheduled inspection for next Tuesday. Customer agreed.', + FeedbackInteraction::create([ + '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.', ]); - \App\Models\FeedbackInteraction::create([ - 'feedback_id' => $allFeedback[2]->id, - 'user_id' => $staff->id, - 'type' => 'status_change', - 'content' => 'Technician visited site on 2024-06-10. AC repaired. Customer confirmed cooling is now normal.', + // FB2: Phân công + FeedbackInteraction::create([ + 'feedback_id' => $fb2->id, 'user_id' => $managerCskh->id, 'type' => 'note', + 'content' => 'Đã gửi báo giá nâng cấp chỗ đậu xe qua email. Chờ khách xác nhận.', + ]); + + // FB3: Xử lý xong + FeedbackInteraction::create([ + 'feedback_id' => $fb3->id, 'user_id' => $staffA->id, 'type' => 'status_change', + 'content' => 'Kỹ thuật viên đã kiểm tra và thay thế block điều hòa ngày 10/06. Khách hàng xác nhận hoạt động bình thường.', 'changes' => ['status' => ['old' => 'processing', 'new' => 'resolved']], ]); - \App\Models\FeedbackInteraction::create([ - 'feedback_id' => $allFeedback[1]->id, - 'user_id' => $manager->id, - 'type' => 'note', - 'content' => 'Parking upgrade available. Sent quotation via email. Waiting for customer confirmation.', - ]); - - \App\Models\FeedbackInteraction::create([ - 'feedback_id' => $allFeedback[4]->id, - 'user_id' => $staff->id, - 'type' => 'status_change', - 'content' => 'Replacement hinge installed on 2025-03-01. All kitchen cabinets now working properly.', + // FB5: Đóng phiếu + FeedbackInteraction::create([ + 'feedback_id' => $fb5->id, 'user_id' => $staffB->id, 'type' => 'status_change', + 'content' => 'Đã thay thế bản lề mới ngày 01/03. Tất cả tủ bếp hoạt động bình thường.', 'changes' => ['status' => ['old' => 'processing', 'new' => 'closed']], ]); - \App\Models\TicketTransferLog::create([ - 'feedback_id' => $allFeedback[2]->id, - 'from_department_id' => $depCskh->id, - 'to_department_id' => $depKythuat->id, - 'sender_id' => $manager->id, + // FB6: Đang xử lý + ghi chú khẩn cấp + FeedbackInteraction::create([ + 'feedback_id' => $fb6->id, 'user_id' => $staffA->id, 'type' => 'note', + 'content' => 'Đã cử kỹ thuật viên khẩn cấp đến kiểm tra. Yêu cầu khách không sử dụng ổ cắm cho đến khi sửa xong.', + ]); + FeedbackInteraction::create([ + 'feedback_id' => $fb6->id, 'user_id' => $staffA->id, 'type' => 'status_change', + 'content' => 'Kỹ thuật viên đã kiểm tra, phát hiện dây điện bị hở. Đang chờ vật tư thay thế.', + 'changes' => ['status' => ['old' => 'pending', 'new' => 'processing']], + ]); + + // FB7: Phân công + chuyển phòng ban + FeedbackInteraction::create([ + 'feedback_id' => $fb7->id, 'user_id' => $managerCskh->id, 'type' => 'assignment', + 'content' => 'Chuyển yêu cầu gia hạn hợp đồng sang Phòng Pháp lý.', + 'changes' => ['assignment' => ['old' => 'Chưa phân công', 'new' => 'Hoàng Văn Phụ']], + ]); + FeedbackInteraction::create([ + 'feedback_id' => $fb7->id, 'user_id' => $staffB->id, 'type' => 'status_change', + 'content' => 'Đã soạn thảo phụ lục hợp đồng gia hạn. Đang chờ khách hàng ký.', + 'changes' => ['status' => ['old' => 'processing', 'new' => 'resolved']], + ]); + + // FB8: Ghi chú + FeedbackInteraction::create([ + 'feedback_id' => $fb8->id, 'user_id' => $staffB->id, 'type' => 'note', + 'content' => 'Đã liên hệ ban quản lý tòa nhà để xác minh thông tin. BQL cho biết sẽ nhắc nhở cư dân tầng trên.', + ]); + + // ============================================================ + // 15. TICKET TRANSFER LOGS (2 transfers) + // ============================================================ + TicketTransferLog::create([ + 'feedback_id' => $fb3->id, + 'from_department_id' => $deptCskh->id, + 'to_department_id' => $deptKt->id, + 'sender_id' => $managerCskh->id, 'reason' => 'Vấn đề kỹ thuật về điều hòa, cần Phòng Kỹ thuật kiểm tra và xử lý.', ]); - - $disk = \Illuminate\Support\Facades\Storage::disk('local'); - - $disk->put('feedback-attachments/ac_repair_report.pdf', '%PDF-1.4 Demo report content'); - \App\Models\FeedbackAttachment::create([ - 'uuid' => \Illuminate\Support\Str::uuid(), - 'feedback_id' => $allFeedback[2]->id, - 'user_id' => $staff->id, - 'name' => 'ac_repair_report.pdf', - 'path' => 'feedback-attachments/ac_repair_report.pdf', - 'disk' => 'local', - 'collection' => 'proof_of_resolution', - 'mime_type' => 'application/pdf', - 'size' => $disk->size('feedback-attachments/ac_repair_report.pdf'), + TicketTransferLog::create([ + 'feedback_id' => $fb7->id, + 'from_department_id' => $deptCskh->id, + 'to_department_id' => $deptPhaply->id, + 'sender_id' => $managerCskh->id, + 'reason' => 'Yêu cầu gia hạn hợp đồng thuộc thẩm quyền Phòng Pháp lý.', ]); - $disk->put('feedback-attachments/ceiling_photo.jpg', 'dummy-jpeg-content'); - \App\Models\FeedbackAttachment::create([ - 'uuid' => \Illuminate\Support\Str::uuid(), - 'feedback_id' => $allFeedback[0]->id, - 'user_id' => $staff->id, - 'name' => 'ceiling_photo.jpg', - 'path' => 'feedback-attachments/ceiling_photo.jpg', - 'disk' => 'local', - 'collection' => 'customer_evidence', - 'mime_type' => 'image/jpeg', - 'size' => $disk->size('feedback-attachments/ceiling_photo.jpg'), - ]); + // ============================================================ + // 16. FEEDBACK ATTACHMENTS (6 attachments - public disk) + // ============================================================ + $disk = Storage::disk('public'); - $disk->put('feedback-attachments/replaced_hinge_photo.png', 'dummy-png-content'); - \App\Models\FeedbackAttachment::create([ - 'uuid' => \Illuminate\Support\Str::uuid(), - 'feedback_id' => $allFeedback[4]->id, - 'user_id' => $staff->id, - 'name' => 'replaced_hinge_photo.png', - 'path' => 'feedback-attachments/replaced_hinge_photo.png', - 'disk' => 'local', - 'collection' => 'proof_of_resolution', - 'mime_type' => 'image/png', - 'size' => $disk->size('feedback-attachments/replaced_hinge_photo.png'), + // Tạo file mẫu trên public disk + $disk->put('feedback-attachments/bao-cao-kiem-tra-dieu-hoa.pdf', '%PDF-1.4 Mẫu báo cáo kiểm tra điều hòa - Demo'); + $disk->put('feedback-attachments/anh-tran-ro-ri.jpg', str_repeat('x', 1000)); // dummy image + $disk->put('feedback-attachments/anh-ban-le-hong.png', str_repeat('y', 1200)); // dummy image + $disk->put('feedback-attachments/bao-gia-nang-cap.pdf', '%PDF-1.4 Báo giá nâng cấp chỗ đậu xe - Demo'); + $disk->put('feedback-attachments/phu-luc-hop-dong.pdf', '%PDF-1.4 Phụ lục hợp đồng gia hạn - Demo'); + $disk->put('feedback-attachments/anh-o-cam-hu.jpg', str_repeat('z', 800)); // dummy image + + FeedbackAttachment::create([ + 'uuid' => (string) Str::uuid(), + 'feedback_id' => $fb3->id, 'user_id' => $staffA->id, + 'name' => 'bao-cao-kiem-tra-dieu-hoa.pdf', + 'path' => 'feedback-attachments/bao-cao-kiem-tra-dieu-hoa.pdf', + 'disk' => 'public', 'collection' => 'proof_of_resolution', + 'mime_type' => 'application/pdf', 'size' => $disk->size('feedback-attachments/bao-cao-kiem-tra-dieu-hoa.pdf'), + ]); + FeedbackAttachment::create([ + 'uuid' => (string) Str::uuid(), + 'feedback_id' => $fb1->id, 'user_id' => $staffA->id, + 'name' => 'anh-tran-ro-ri.jpg', + 'path' => 'feedback-attachments/anh-tran-ro-ri.jpg', + 'disk' => 'public', 'collection' => 'customer_evidence', + 'mime_type' => 'image/jpeg', 'size' => $disk->size('feedback-attachments/anh-tran-ro-ri.jpg'), + ]); + FeedbackAttachment::create([ + 'uuid' => (string) Str::uuid(), + 'feedback_id' => $fb5->id, 'user_id' => $staffB->id, + 'name' => 'anh-ban-le-hong.png', + 'path' => 'feedback-attachments/anh-ban-le-hong.png', + 'disk' => 'public', 'collection' => 'proof_of_resolution', + 'mime_type' => 'image/png', 'size' => $disk->size('feedback-attachments/anh-ban-le-hong.png'), + ]); + FeedbackAttachment::create([ + 'uuid' => (string) Str::uuid(), + 'feedback_id' => $fb2->id, 'user_id' => $managerCskh->id, + 'name' => 'bao-gia-nang-cap.pdf', + 'path' => 'feedback-attachments/bao-gia-nang-cap.pdf', + 'disk' => 'public', 'collection' => 'general', + 'mime_type' => 'application/pdf', 'size' => $disk->size('feedback-attachments/bao-gia-nang-cap.pdf'), + ]); + FeedbackAttachment::create([ + 'uuid' => (string) Str::uuid(), + 'feedback_id' => $fb7->id, 'user_id' => $staffB->id, + 'name' => 'phu-luc-hop-dong.pdf', + 'path' => 'feedback-attachments/phu-luc-hop-dong.pdf', + 'disk' => 'public', 'collection' => 'proof_of_resolution', + 'mime_type' => 'application/pdf', 'size' => $disk->size('feedback-attachments/phu-luc-hop-dong.pdf'), + ]); + FeedbackAttachment::create([ + 'uuid' => (string) Str::uuid(), + 'feedback_id' => $fb6->id, 'user_id' => $staffA->id, + 'name' => 'anh-o-cam-hu.jpg', + 'path' => 'feedback-attachments/anh-o-cam-hu.jpg', + 'disk' => 'public', 'collection' => 'customer_evidence', + 'mime_type' => 'image/jpeg', 'size' => $disk->size('feedback-attachments/anh-o-cam-hu.jpg'), ]); } } diff --git a/public/css/filament-custom.css b/public/css/filament-custom.css index d6eb87f4..a4f55815 100644 --- a/public/css/filament-custom.css +++ b/public/css/filament-custom.css @@ -714,3 +714,208 @@ svg[class*="fi-icon"] { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } + +/* ---------- TAILWIND UTILITIES (for custom blade views) ---------- */ +/* These are needed because custom blade views render inside Filament layout */ +/* which doesn't include full Tailwind CSS build */ + +/* Display */ +.inline-flex { display: inline-flex !important; } +.inline-block { display: inline-block !important; } +.block { display: block !important; } + +/* Flexbox */ +.flex { display: flex !important; } +.flex-1 { flex: 1 1 0% !important; } +.flex-shrink-0 { flex-shrink: 0 !important; } +.items-center { align-items: center !important; } +.items-start { align-items: flex-start !important; } +.justify-center { justify-content: center !important; } +.justify-between { justify-content: space-between !important; } +.flex-col { flex-direction: column !important; } + +/* Gap */ +.gap-1 { gap: 0.25rem !important; } +.gap-1\.5 { gap: 0.375rem !important; } +.gap-2 { gap: 0.5rem !important; } +.gap-3 { gap: 0.75rem !important; } +.gap-4 { gap: 1rem !important; } + +/* Width/Height */ +.w-3 { width: 0.75rem !important; } +.w-3\.5 { width: 0.875rem !important; } +.w-4 { width: 1rem !important; } +.w-5 { width: 1.25rem !important; } +.w-6 { width: 1.5rem !important; } +.w-7 { width: 1.75rem !important; } +.w-8 { width: 2rem !important; } +.w-10 { width: 2.5rem !important; } +.w-12 { width: 3rem !important; } +.w-full { width: 100% !important; } +.h-3 { height: 0.75rem !important; } +.h-3\.5 { height: 0.875rem !important; } +.h-4 { height: 1rem !important; } +.h-5 { height: 1.25rem !important; } +.h-6 { height: 1.5rem !important; } +.h-7 { height: 1.75rem !important; } +.h-8 { height: 2rem !important; } +.h-10 { height: 2.5rem !important; } +.h-12 { height: 3rem !important; } +.h-full { height: 100% !important; } +.min-w-0 { min-width: 0 !important; } +.max-w-full { max-width: 100% !important; } + +/* Padding */ +.p-3 { padding: 0.75rem !important; } +.p-4 { padding: 1rem !important; } +.px-1 { padding-left: 0.25rem !important; padding-right: 0.25rem !important; } +.px-1\.5 { padding-left: 0.375rem !important; padding-right: 0.375rem !important; } +.px-2 { padding-left: 0.5rem !important; padding-right: 0.5rem !important; } +.px-2\.5 { padding-left: 0.625rem !important; padding-right: 0.625rem !important; } +.px-3 { padding-left: 0.75rem !important; padding-right: 0.75rem !important; } +.px-4 { padding-left: 1rem !important; padding-right: 1rem !important; } +.px-5 { padding-left: 1.25rem !important; padding-right: 1.25rem !important; } +.py-0\.5 { padding-top: 0.125rem !important; padding-bottom: 0.125rem !important; } +.py-1 { padding-top: 0.25rem !important; padding-bottom: 0.25rem !important; } +.py-1\.5 { padding-top: 0.375rem !important; padding-bottom: 0.375rem !important; } +.py-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; } +.py-2\.5 { padding-top: 0.625rem !important; padding-bottom: 0.625rem !important; } +.py-3 { padding-top: 0.75rem !important; padding-bottom: 0.75rem !important; } +.py-4 { padding-top: 1rem !important; padding-bottom: 1rem !important; } +.py-6 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; } +.py-8 { padding-top: 2rem !important; padding-bottom: 2rem !important; } + +/* Margin */ +.mt-1 { margin-top: 0.25rem !important; } +.mt-1\.5 { margin-top: 0.375rem !important; } +.mt-2 { margin-top: 0.5rem !important; } +.mt-3 { margin-top: 0.75rem !important; } +.mt-4 { margin-top: 1rem !important; } +.mt-6 { margin-top: 1.5rem !important; } +.mt-8 { margin-top: 2rem !important; } + +/* Border */ +.rounded { border-radius: 0.25rem !important; } +.rounded-md { border-radius: 0.375rem !important; } +.rounded-lg { border-radius: 0.5rem !important; } +.rounded-xl { border-radius: 0.75rem !important; } +.rounded-full { border-radius: 9999px !important; } +.border { border-width: 1px !important; border-style: solid !important; } +.border-t { border-top-width: 1px !important; border-top-style: solid !important; } +.border-b { border-bottom-width: 1px !important; border-bottom-style: solid !important; } +.divide-y > * + * { border-top-width: 1px !important; border-top-style: solid !important; } +.divide-gray-100 > * + * { border-color: #f3f4f6 !important; } +.divide-gray-200 > * + * { border-color: #e5e7eb !important; } + +/* Border colors */ +.border-gray-100 { border-color: #f3f4f6 !important; } +.border-gray-200 { border-color: #e5e7eb !important; } +.border-gray-300 { border-color: #d1d5db !important; } +.border-amber-200 { border-color: #fde68a !important; } +.border-blue-100 { border-color: #dbeafe !important; } +.border-blue-200 { border-color: #bfdbfe !important; } +.border-green-100 { border-color: #dcfce7 !important; } +.border-green-200 { border-color: #bbf7d0 !important; } +.border-red-100 { border-color: #fee2e2 !important; } +.border-red-200 { border-color: #fecaca !important; } + +/* Background colors */ +.bg-white { background-color: #ffffff !important; } +.bg-gray-50 { background-color: #f9fafb !important; } +.bg-gray-100 { background-color: #f3f4f6 !important; } +.bg-gray-200 { background-color: #e5e7eb !important; } +.bg-amber-50 { background-color: #fffbeb !important; } +.bg-blue-50 { background-color: #eff6ff !important; } +.bg-blue-100 { background-color: #dbeafe !important; } +.bg-green-50 { background-color: #f0fdf4 !important; } +.bg-green-100 { background-color: #dcfce7 !important; } +.bg-red-50 { background-color: #fef2f2 !important; } +.bg-red-100 { background-color: #fee2e2 !important; } +.bg-purple-50 { background-color: #faf5ff !important; } +.bg-purple-100 { background-color: #f3e8ff !important; } + +/* Text colors */ +.text-gray-300 { color: #d1d5db !important; } +.text-gray-400 { color: #9ca3af !important; } +.text-gray-500 { color: #6b7280 !important; } +.text-gray-600 { color: #4b5563 !important; } +.text-gray-700 { color: #374151 !important; } +.text-gray-900 { color: #111827 !important; } +.text-amber-500 { color: #f59e0b !important; } +.text-amber-700 { color: #b45309 !important; } +.text-blue-400 { color: #60a5fa !important; } +.text-blue-500 { color: #3b82f6 !important; } +.text-blue-600 { color: #2563eb !important; } +.text-blue-700 { color: #1d4ed8 !important; } +.text-blue-800 { color: #1e40af !important; } +.text-green-500 { color: #22c55e !important; } +.text-green-700 { color: #15803d !important; } +.text-red-400 { color: #f87171 !important; } +.text-red-500 { color: #ef4444 !important; } +.text-red-700 { color: #b91c1c !important; } +.text-purple-500 { color: #a855f7 !important; } +.text-white { color: #ffffff !important; } + +/* Font sizes */ +.text-\[9px\] { font-size: 9px !important; } +.text-\[10px\] { font-size: 10px !important; } +.text-\[11px\] { font-size: 11px !important; } +.text-xs { font-size: 0.75rem !important; line-height: 1rem !important; } +.text-sm { font-size: 0.875rem !important; line-height: 1.25rem !important; } +.text-base { font-size: 1rem !important; line-height: 1.5rem !important; } +.text-lg { font-size: 1.125rem !important; line-height: 1.75rem !important; } + +/* Font weight */ +.font-normal { font-weight: 400 !important; } +.font-medium { font-weight: 500 !important; } +.font-semibold { font-weight: 600 !important; } +.font-bold { font-weight: 700 !important; } + +/* Text decoration */ +.no-underline { text-decoration: none !important; } +.underline { text-decoration: underline !important; } +.truncate { overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; } +.uppercase { text-transform: uppercase !important; } +.tracking-wider { letter-spacing: 0.05em !important; } + +/* Overflow */ +.overflow-hidden { overflow: hidden !important; } +.overflow-x-auto { overflow-x: auto !important; } + +/* Shadow */ +.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.05) !important; } +.shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1) !important; } + +/* Transition */ +.transition-colors { transition-property: color, background-color, border-color !important; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important; transition-duration: 150ms !important; } + +/* Cursor */ +.cursor-pointer { cursor: pointer !important; } + +/* Table */ +.w-full { width: 100% !important; } +.text-left { text-align: left !important; } +.text-center { text-align: center !important; } +.border-collapse { border-collapse: collapse !important; } + +/* Hover states */ +.hover\:bg-blue-50\/50:hover { background-color: rgba(239, 246, 255, 0.5) !important; } +.hover\:bg-gray-50\/50:hover { background-color: rgba(249, 250, 251, 0.5) !important; } +.hover\:text-blue-600:hover { color: #2563eb !important; } +.hover\:text-blue-800:hover { color: #1e40af !important; } +.hover\:underline:hover { text-decoration: underline !important; } +.hover\:border-blue-300:hover { border-color: #93c5fd !important; } +.group:hover .group-hover\:text-blue-500 { color: #3b82f6 !important; } +.group:hover .group-hover\:text-blue-600 { color: #2563eb !important; } + +/* Grid */ +.grid { display: grid !important; } +.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)) !important; } + +@media (min-width: 768px) { + .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } +} + +@media (min-width: 1024px) { + .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; } +} diff --git a/resources/views/filament/resources/feedback/attachment-links.blade.php b/resources/views/filament/resources/feedback/attachment-links.blade.php index 64530575..94145fab 100644 --- a/resources/views/filament/resources/feedback/attachment-links.blade.php +++ b/resources/views/filament/resources/feedback/attachment-links.blade.php @@ -1,61 +1,50 @@ -
+
@if(empty($links) || count($links) === 0) -

{{ __('app.blade_no_attachments') }}

+

{{ __('app.blade_no_attachments') }}

@else -
+
@foreach($links as $link) @php $isImage = in_array($link['mime_type'] ?? '', ['image/jpeg', 'image/jpg', 'image/png']); + $ext = pathinfo($link['name'] ?? '', PATHINFO_EXTENSION); + $iconColor = match($ext) { + 'pdf' => 'text-red-500', + 'jpg', 'jpeg', 'png' => 'text-green-500', + default => 'text-gray-400', + }; + $bgColor = match($ext) { + 'pdf' => 'bg-red-50', + 'jpg', 'jpeg', 'png' => 'bg-green-50', + default => 'bg-gray-50', + }; @endphp - -

{{ __('app.blade_links_expire') }}

+

{{ __('app.blade_links_expire') }}

@endif
diff --git a/resources/views/filament/resources/feedback/edit-footer.blade.php b/resources/views/filament/resources/feedback/edit-footer.blade.php index abf81935..2f2466e5 100644 --- a/resources/views/filament/resources/feedback/edit-footer.blade.php +++ b/resources/views/filament/resources/feedback/edit-footer.blade.php @@ -1,4 +1,4 @@ -
+
@include('filament.resources.feedback.feedback-attachments') @include('filament.resources.feedback.similar-cases')
diff --git a/resources/views/filament/resources/feedback/feedback-attachments.blade.php b/resources/views/filament/resources/feedback/feedback-attachments.blade.php index 0e702ff2..8fdcec27 100644 --- a/resources/views/filament/resources/feedback/feedback-attachments.blade.php +++ b/resources/views/filament/resources/feedback/feedback-attachments.blade.php @@ -6,56 +6,60 @@ @endphp @if($attachments->isNotEmpty()) -
-
-

- +
+
+

+ {{ __('app.attachments') }} + ({{ $attachments->count() }})

- {{ __('app.blade_file_count', ['count' => $attachments->count()]) }}
-
-
- @foreach($attachments as $attachment) - @php - $url = $fileService->getTemporaryUrl($attachment, 60); - $isImage = $fileService->isImage($attachment); - $sizeKb = $attachment->size ? round($attachment->size / 1024, 1) . ' KB' : 'N/A'; - @endphp -
+
+ @foreach($attachments as $attachment) + @php + $url = $fileService->getTemporaryUrl($attachment, 60); + $isImage = $fileService->isImage($attachment); + $sizeKb = $attachment->size ? round($attachment->size / 1024, 1) . ' KB' : 'N/A'; + $ext = pathinfo($attachment->name, PATHINFO_EXTENSION); + $iconColor = match($ext) { + 'pdf' => 'text-red-500', + 'jpg', 'jpeg', 'png' => 'text-green-500', + 'mp4', 'mov' => 'text-purple-500', + default => 'text-gray-400', + }; + $bgColor = match($ext) { + 'pdf' => 'bg-red-50', + 'jpg', 'jpeg', 'png' => 'bg-green-50', + 'mp4', 'mov' => 'bg-purple-50', + default => 'bg-gray-50', + }; + @endphp + + {{-- Icon 20x20 --}} +
@if($isImage) -
- {{ $attachment->name }} -
- @elseif($attachment->mime_type === 'application/pdf') -
- -
+ + @elseif($ext === 'pdf') + @else -
- -
+ @endif -
-

- {{ $attachment->name }} -

-
- {{ $sizeKb }} - @if($attachment->collection === 'proof_of_resolution') - {{ __('app.blade_proof') }} - @elseif($attachment->collection === 'customer_evidence') - {{ __('app.blade_evidence') }} - @else - {{ __('app.collection_general') }} - @endif -
-
- @endforeach -
+ + {{-- Tên file --}} + {{ $attachment->name }} + + {{-- Badge collection --}} + @if($attachment->collection === 'proof_of_resolution') + {{ __('app.blade_proof') }} + @elseif($attachment->collection === 'customer_evidence') + {{ __('app.blade_evidence') }} + @endif + + {{-- Size --}} + {{ $sizeKb }} + + @endforeach
@endif diff --git a/resources/views/filament/resources/feedback/similar-cases.blade.php b/resources/views/filament/resources/feedback/similar-cases.blade.php index cf5378d2..0534e502 100644 --- a/resources/views/filament/resources/feedback/similar-cases.blade.php +++ b/resources/views/filament/resources/feedback/similar-cases.blade.php @@ -15,55 +15,57 @@ @endphp @if($similarCases->isNotEmpty()) -
-
-

- +
+
+

+ {{ __('app.blade_similar_cases') }}

- {{ __('app.blade_matching_tags') }} {{ $record->tags->pluck('name')->implode(', ') }} + {{ __('app.blade_matching_tags') }} {{ $record->tags->pluck('name')->implode(', ') }}
- - - - - - + + + + + + @foreach($similarCases as $case) - - - - - -
{{ __('app.blade_title') }}{{ __('app.blade_customer') }}{{ __('app.blade_status') }}{{ __('app.blade_handler') }}{{ __('app.blade_date') }}
{{ __('app.blade_title') }}{{ __('app.blade_customer') }}{{ __('app.blade_status') }}{{ __('app.blade_handler') }}{{ __('app.blade_date') }}
- {{ $case->title }} + + + {{ $case->title }} + + {{ $case->customer?->name }} + @switch($case->status) @case('pending') - {{ __('app.status_pending') }} + {{ __('app.status_pending') }} @break @case('processing') - {{ __('app.status_processing') }} + {{ __('app.status_processing') }} @break @case('resolved') - {{ __('app.status_resolved') }} + {{ __('app.status_resolved') }} @break @case('closed') - {{ __('app.status_closed') }} + {{ __('app.status_closed') }} @break @endswitch + {{ $case->assignedTo?->name ?? __('app.unassigned') }} + {{ $case->created_at->format('d/m/Y') }}