Chinh sua giao dien phu hop tailwind css va upload
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled

This commit is contained in:
2026-05-05 09:25:28 +00:00
parent 8c6b71cb8a
commit fb1f82e1a0
12 changed files with 834 additions and 466 deletions

View File

@@ -424,3 +424,78 @@ $user->assignRole('manager');
13. **i18n - ChartWidget heading**: `$heading`/`$description` are raw properties — must override `getHeading()`/`getDescription()`. 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'))`. 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`. 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., `<svg width="14" height="14">`) 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:
<svg width="14" height="14" class="text-blue-600">...</svg>
// NOT: <svg class="w-3.5 h-3.5 text-blue-600">...</svg>

View File

@@ -10,12 +10,19 @@ class CreateFeedback extends CreateRecord
{ {
protected static string $resource = FeedbackResource::class; protected static string $resource = FeedbackResource::class;
protected array $pendingAttachments = [];
protected string $pendingCollection = 'general';
protected function mutateFormDataBeforeCreate(array $data): array protected function mutateFormDataBeforeCreate(array $data): array
{ {
if (($data['is_general'] ?? false) === true) { if (($data['is_general'] ?? false) === true) {
$data['customer_product_id'] = null; $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; return $data;
} }
@@ -23,8 +30,6 @@ class CreateFeedback extends CreateRecord
protected function afterCreate(): void protected function afterCreate(): void
{ {
$feedback = $this->getRecord(); $feedback = $this->getRecord();
$attachments = $this->data['attachments'] ?? [];
$collection = $this->data['attachment_collection'] ?? 'general';
$feedback->interactions()->create([ $feedback->interactions()->create([
'user_id' => auth()->id(), 'user_id' => auth()->id(),
@@ -33,20 +38,27 @@ class CreateFeedback extends CreateRecord
'changes' => ['status' => ['old' => null, 'new' => $feedback->status]], 'changes' => ['status' => ['old' => null, 'new' => $feedback->status]],
]); ]);
if (! empty($attachments)) { if (empty($this->pendingAttachments)) {
$disk = Storage::disk('local'); return;
foreach ($attachments as $path) { }
$disk = Storage::disk('public');
foreach ($this->pendingAttachments as $path) {
if (! $disk->exists($path)) {
continue;
}
$feedback->attachments()->create([ $feedback->attachments()->create([
'uuid' => (string) \Illuminate\Support\Str::uuid(), 'uuid' => (string) \Illuminate\Support\Str::uuid(),
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'name' => basename($path), 'name' => basename($path),
'path' => $path, 'path' => $path,
'disk' => 'local', 'disk' => 'public',
'collection' => $collection, 'collection' => $this->pendingCollection,
'mime_type' => $disk->mimeType($path), 'mime_type' => $disk->mimeType($path),
'size' => $disk->size($path), 'size' => $disk->size($path),
]); ]);
} }
} }
}
} }

View File

@@ -63,7 +63,7 @@ class EditFeedback extends EditRecord
FileUpload::make('transfer_attachments') FileUpload::make('transfer_attachments')
->label(__('app.attachments')) ->label(__('app.attachments'))
->multiple() ->multiple()
->disk('local') ->disk('public')
->directory('feedback-attachments') ->directory('feedback-attachments')
->columnSpanFull(), ->columnSpanFull(),
]) ])
@@ -82,13 +82,23 @@ class EditFeedback extends EditRecord
); );
if (! empty($data['transfer_attachments'])) { if (! empty($data['transfer_attachments'])) {
$fileService = App::make(FileService::class); $disk = Storage::disk('public');
$fileService->upload( foreach ($data['transfer_attachments'] as $path) {
$data['transfer_attachments'], if (! $disk->exists($path)) {
'general', continue;
$feedback, }
$sender->id,
); $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() Notification::make()
@@ -145,12 +155,19 @@ class EditFeedback extends EditRecord
return view('filament.resources.feedback.edit-footer'); return view('filament.resources.feedback.edit-footer');
} }
protected array $pendingAttachments = [];
protected string $pendingCollection = 'general';
protected function mutateFormDataBeforeSave(array $data): array protected function mutateFormDataBeforeSave(array $data): array
{ {
if (($data['is_general'] ?? false) === true) { if (($data['is_general'] ?? false) === true) {
$data['customer_product_id'] = null; $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; $newStatus = $data['status'] ?? null;
if ($newStatus === 'closed' && $this->getRecord()->status !== 'closed') { if ($newStatus === 'closed' && $this->getRecord()->status !== 'closed') {
@@ -163,26 +180,30 @@ class EditFeedback extends EditRecord
protected function afterSave(): void protected function afterSave(): void
{ {
$attachments = $this->data['attachments'] ?? []; if (empty($this->pendingAttachments)) {
$collection = $this->data['attachment_collection'] ?? 'general'; return;
}
if (! empty($attachments)) { $disk = Storage::disk('public');
$disk = Storage::disk('local');
$feedback = $this->getRecord(); $feedback = $this->getRecord();
foreach ($attachments as $path) {
foreach ($this->pendingAttachments as $path) {
if (! $disk->exists($path)) {
continue;
}
$feedback->attachments()->create([ $feedback->attachments()->create([
'uuid' => (string) \Illuminate\Support\Str::uuid(), 'uuid' => (string) \Illuminate\Support\Str::uuid(),
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'name' => basename($path), 'name' => basename($path),
'path' => $path, 'path' => $path,
'disk' => 'local', 'disk' => 'public',
'collection' => $collection, 'collection' => $this->pendingCollection,
'mime_type' => $disk->mimeType($path), 'mime_type' => $disk->mimeType($path),
'size' => $disk->size($path), 'size' => $disk->size($path),
]); ]);
} }
} }
}
protected function mutateFormDataBeforeFill(array $data): array protected function mutateFormDataBeforeFill(array $data): array
{ {

View File

@@ -75,7 +75,7 @@ class InteractionsRelationManager extends RelationManager
FileUpload::make('attachments') FileUpload::make('attachments')
->label(__('app.attachments')) ->label(__('app.attachments'))
->multiple() ->multiple()
->disk('local') ->disk('public')
->directory('feedback-attachments') ->directory('feedback-attachments')
->columnSpanFull(), ->columnSpanFull(),
]); ]);
@@ -203,15 +203,19 @@ class InteractionsRelationManager extends RelationManager
$attachments = $data['_attachments'] ?? []; $attachments = $data['_attachments'] ?? [];
if (! empty($attachments)) { if (! empty($attachments)) {
$disk = Storage::disk('local'); $disk = Storage::disk('public');
foreach ($attachments as $path) { foreach ($attachments as $path) {
if (! $disk->exists($path)) {
continue;
}
$record->attachments()->create([ $record->attachments()->create([
'uuid' => (string) \Illuminate\Support\Str::uuid(), 'uuid' => (string) \Illuminate\Support\Str::uuid(),
'feedback_id' => $record->feedback_id, 'feedback_id' => $record->feedback_id,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'name' => basename($path), 'name' => basename($path),
'path' => $path, 'path' => $path,
'disk' => 'local', 'disk' => 'public',
'collection' => 'general', 'collection' => 'general',
'mime_type' => $disk->mimeType($path), 'mime_type' => $disk->mimeType($path),
'size' => $disk->size($path), 'size' => $disk->size($path),

View File

@@ -195,10 +195,9 @@ class FeedbackForm
FileUpload::make('attachments') FileUpload::make('attachments')
->label(__('app.files')) ->label(__('app.files'))
->multiple() ->multiple()
->disk('local') ->disk('public')
->directory('feedback-attachments') ->directory('feedback-attachments')
->columnSpan(1) ->columnSpan(1),
->dehydrated(false),
]) ])
->columns(2), ->columns(2),
]); ]);

View File

@@ -24,7 +24,7 @@ class FileService
protected int $maxSize = 20480; protected int $maxSize = 20480;
protected string $defaultDisk = 'local'; protected string $defaultDisk = 'public';
/** /**
* Upload files and create FeedbackAttachment records. * Upload files and create FeedbackAttachment records.
@@ -35,7 +35,7 @@ class FileService
Model $model, Model $model,
int $userId, int $userId,
?int $interactionId = null, ?int $interactionId = null,
string $disk = 'local', string $disk = 'public',
): array { ): array {
$records = []; $records = [];
@@ -71,7 +71,7 @@ class FileService
public function getTemporaryUrl(FeedbackAttachment $attachment, int $minutes = 10): string public function getTemporaryUrl(FeedbackAttachment $attachment, int $minutes = 10): string
{ {
if ($attachment->disk === 'public') { if ($attachment->disk === 'public') {
return Storage::disk('public')->url($attachment->path); return asset('storage/' . $attachment->path);
} }
return Storage::disk($attachment->disk)->temporaryUrl( return Storage::disk($attachment->disk)->temporaryUrl(

View File

@@ -2,396 +2,453 @@
namespace Database\Seeders; 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\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\FeedbackChannel; use App\Models\FeedbackChannel;
use App\Models\FeedbackInteraction;
use App\Models\FeedbackTag;
use App\Models\Handover;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductService;
use App\Models\TicketTransferLog;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
class AfterSalesSeeder extends Seeder class AfterSalesSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
// ============================================================
// 1. ROLES & PERMISSIONS
// ============================================================
Role::create(['name' => 'admin']); Role::create(['name' => 'admin']);
Role::create(['name' => 'manager']); Role::create(['name' => 'manager']);
Role::create(['name' => 'staff']); Role::create(['name' => 'staff']);
$this->call(PermissionSeeder::class); $this->call(PermissionSeeder::class);
// ============================================================
// 2. USERS (5 users)
// ============================================================
$admin = User::create([ $admin = User::create([
'name' => 'Admin', 'name' => 'Nguyễn Quản Trị',
'email' => 'admin@minicrm.local', 'email' => 'admin@minicrm.local',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'role' => 'admin', 'role' => 'admin',
]); ]);
$admin->assignRole('admin'); $admin->assignRole('admin');
$manager = User::create([ $managerCskh = User::create([
'name' => 'Manager', 'name' => 'Trần Minh Manager',
'email' => 'manager@minicrm.local', 'email' => 'manager@minicrm.local',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'role' => 'manager', 'role' => 'manager',
]); ]);
$manager->assignRole('manager'); $managerCskh->assignRole('manager');
$staff = User::create([ $managerKt = User::create([
'name' => 'Staff', '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', 'email' => 'staff@minicrm.local',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'role' => 'staff', 'role' => 'staff',
]); ]);
$staff->assignRole('staff'); $staffA->assignRole('staff');
$depCskh = Department::create(['name' => 'Phòng CSKH', 'manager_id' => $manager->id]); $staffB = User::create([
$depKythuat = Department::create(['name' => 'Phòng Kỹ thuật', 'manager_id' => $admin->id]); 'name' => 'Hoàng Văn Phụ',
$depKetoan = Department::create(['name' => 'Phòng Kế toán', 'manager_id' => null]); 'email' => 'staff.b@minicrm.local',
$depPhaply = Department::create(['name' => 'Phòng Pháp lý', 'manager_id' => null]); 'password' => bcrypt('password'),
'role' => 'staff',
]);
$staffB->assignRole('staff');
$channels = [ // ============================================================
['name' => 'Email', 'slug' => 'email', 'icon' => 'heroicon-o-envelope', 'color' => '#3b82f6'], // 3. DEPARTMENTS (4 departments)
['name' => 'Zalo', 'slug' => 'zalo', 'icon' => 'heroicon-o-chat-bubble-left', 'color' => '#10b981'], // ============================================================
['name' => 'Phone', 'slug' => 'phone', 'icon' => 'heroicon-o-phone', 'color' => '#f59e0b'], $deptCskh = Department::create(['name' => 'Phòng Chăm sóc Khách hàng', 'manager_id' => $managerCskh->id]);
['name' => 'Document', 'slug' => 'document', 'icon' => 'heroicon-o-document-text', 'color' => '#8b5cf6'], $deptKt = Department::create(['name' => 'Phòng Kỹ thuật', 'manager_id' => $managerKt->id]);
['name' => 'In Person', 'slug' => 'in-person', 'icon' => 'heroicon-o-user', 'color' => '#ec4899'], $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'], // 5. PRODUCTS (6 products)
['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'], $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']);
['name' => 'Ocean Tower Office', 'description' => 'High-end office space in central business district', 'status' => 'sold_out'], $p2 = Product::create(['name' => 'Sunrise Apartment A202', 'description' => 'Căn hộ 2PN, tầng 20, view thành phố', 'status' => 'active']);
['name' => 'Park Hill Townhouse', 'description' => 'Modern townhouse near the park, 4 bedrooms', 'status' => 'inactive'], $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'], // 7. CUSTOMER-PRODUCT pivots
['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'], $c1->products()->attach($p1->id, ['purchase_date' => '2024-01-15']);
['name' => 'Pham Thi D', 'email' => 'phamthid@example.com', 'phone' => '0936456789', 'address' => 'Can Tho'], $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); // 8. CONTRACTS (6 contracts)
} // ============================================================
$contract1 = Contract::create([
$customers = Customer::all(); 'product_id' => $p1->id, 'customer_id' => $c1->id,
$products = Product::where('status', 'active')->get(); 'type' => ContractType::SALE->value,
$channels = FeedbackChannel::all(); 'start_date' => '2024-01-15', 'end_date' => null,
'status' => ContractStatus::ACTIVE->value,
$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,
'signed_status' => 'Đã ký', 'signed_status' => 'Đã ký',
]); ]);
$contract2 = Contract::create([
$contract2 = \App\Models\Contract::create([ 'product_id' => $p2->id, 'customer_id' => $c1->id,
'product_id' => $products[1]->id, 'type' => ContractType::LEASE->value,
'customer_id' => $customers[0]->id, 'start_date' => '2024-06-01', 'end_date' => '2026-06-01',
'type' => \App\Enums\ContractType::LEASE->value, 'status' => ContractStatus::ACTIVE->value,
'start_date' => '2024-06-01',
'end_date' => '2026-06-01',
'status' => \App\Enums\ContractStatus::ACTIVE->value,
'signed_status' => 'Đã ký', 'signed_status' => 'Đã ký',
]); ]);
$contract3 = Contract::create([
$contract3 = \App\Models\Contract::create([ 'product_id' => $p1->id, 'customer_id' => $c2->id,
'product_id' => $products[0]->id, 'type' => ContractType::SALE->value,
'customer_id' => $customers[1]->id, 'start_date' => '2024-03-20', 'end_date' => null,
'type' => \App\Enums\ContractType::SALE->value, 'status' => ContractStatus::ACTIVE->value,
'start_date' => '2024-03-20',
'status' => \App\Enums\ContractStatus::ACTIVE->value,
'signed_status' => 'Đã ký', 'signed_status' => 'Đã ký',
]); ]);
$contract4 = Contract::create([
$contract4 = \App\Models\Contract::create([ 'product_id' => $p3->id, 'customer_id' => $c3->id,
'product_id' => $products[2]->id, 'type' => ContractType::BCC->value,
'customer_id' => $customers[2]->id, 'start_date' => '2024-09-10', 'end_date' => '2029-09-10',
'type' => \App\Enums\ContractType::BCC->value, 'status' => ContractStatus::ACTIVE->value,
'start_date' => '2024-09-10',
'end_date' => '2029-09-10',
'status' => \App\Enums\ContractStatus::ACTIVE->value,
'signed_status' => 'Đang chờ ký', 'signed_status' => 'Đang chờ ký',
]); ]);
$contract5 = Contract::create([
$contract5 = \App\Models\Contract::create([ 'product_id' => $p2->id, 'customer_id' => $c4->id,
'product_id' => $products[1]->id, 'type' => ContractType::LEASE->value,
'customer_id' => $customers[3]->id, 'start_date' => '2025-01-05', 'end_date' => '2026-01-05',
'type' => \App\Enums\ContractType::LEASE->value, 'status' => ContractStatus::EXPIRED->value,
'start_date' => '2025-01-05', 'signed_status' => 'Đã ký',
'end_date' => '2026-01-05', ]);
'status' => \App\Enums\ContractStatus::EXPIRED->value, $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ý', '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(); // 9. HANDOVERS (6 handovers)
$customerProduct3 = \App\Models\CustomerProduct::where('customer_id', $customers[1]->id)->where('product_id', $products[0]->id)->first(); // ============================================================
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, // 10. PRODUCT SERVICES (6 services)
'customer_id' => $customers[0]->id, // ============================================================
'handover_date' => '2024-02-15', 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.']);
'status' => \App\Enums\HandoverStatus::HANDED_OVER->value, 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.']);
'remark' => 'Đã bàn giao đầy đủ chìa khóa và thẻ căn hộ.', 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, // 11. FEEDBACK TAGS (6 tags)
'customer_id' => $customers[1]->id, // ============================================================
'handover_date' => '2024-04-20', $tagLeaking = FeedbackTag::create(['name' => 'Rò rỉ', 'slug' => 'leaking', 'color' => '#3b82f6']);
'status' => \App\Enums\HandoverStatus::HANDED_OVER->value, $tagElectrical = FeedbackTag::create(['name' => 'Điện nước', 'slug' => 'electrical', 'color' => '#f59e0b']);
'remark' => 'Đã bàn giao, còn thiếu sổ bảo hành nội thất.', $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, // 12. FEEDBACKS (8 feedbacks - various statuses)
'customer_id' => $customers[0]->id, // ============================================================
'status' => \App\Enums\HandoverStatus::READY->value,
'remark' => 'Căn hộ đã hoàn thiện, chờ khách xác nhận lịch bàn giao.',
]);
\App\Models\Handover::create([ // Feedback 1: Pending - Trần rò rỉ
'product_id' => $products[2]->id, $cp1 = CustomerProduct::where('customer_id', $c1->id)->where('product_id', $p1->id)->first();
'customer_id' => $customers[2]->id, $fb1 = Feedback::create([
'handover_date' => '2024-10-01', 'customer_id' => $c1->id, 'customer_product_id' => $cp1->id, 'contract_id' => $contract1->id,
'status' => \App\Enums\HandoverStatus::SCHEDULED->value, 'feedback_channel_id' => $chEmail->id, 'assigned_to' => $staffA->id,
'remark' => 'Đã hẹn lịch 10h sáng ngày 01/10/2024.', '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.',
\App\Models\Handover::create([
'product_id' => $products[1]->id,
'customer_id' => $customers[3]->id,
'status' => \App\Enums\HandoverStatus::NOT_READY->value,
'remark' => 'Đang hoàn thiện nội thất, dự kiến đủ điều kiện sau 2 tuần.',
]);
\App\Models\ProductService::create([
'product_id' => $products[0]->id,
'service_type' => \App\Enums\ServiceType::MANAGEMENT_FEE->value,
'status' => 'active',
'actual_collection_date' => '2024-03-01',
'remark' => 'Phí quản lý hàng tháng 5.000.000 VND.',
]);
\App\Models\ProductService::create([
'product_id' => $products[0]->id,
'service_type' => \App\Enums\ServiceType::GRATITUDE->value,
'status' => 'completed',
'actual_collection_date' => '2024-06-15',
'remark' => 'Quà tri ân khách hàng dịp sinh nhật BQL.',
]);
\App\Models\ProductService::create([
'product_id' => $products[1]->id,
'service_type' => \App\Enums\ServiceType::MANAGEMENT_FEE->value,
'status' => 'pending', 'status' => 'pending',
'remark' => 'Chưa thu phí quản lý quý 1/2025.',
]); ]);
\App\Models\ProductService::create([ // Feedback 2: Processing - Nâng cấp chỗ đậu xe
'product_id' => $products[2]->id, $cp2 = CustomerProduct::where('customer_id', $c1->id)->where('product_id', $p2->id)->first();
'service_type' => \App\Enums\ServiceType::SELF_BUSINESS->value, $fb2 = Feedback::create([
'status' => 'active', 'customer_id' => $c1->id, 'customer_product_id' => $cp2->id, 'contract_id' => $contract2->id,
'actual_collection_date' => '2024-10-01', 'feedback_channel_id' => $chPhone->id, 'assigned_to' => $managerCskh->id,
'remark' => 'Dịch vụ cho thuê hồ bơi và BBQ khu Villa.', '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.',
\App\Models\ProductService::create([
'product_id' => $products[1]->id,
'service_type' => \App\Enums\ServiceType::GRATITUDE->value,
'status' => 'cancelled',
'remark' => 'KH từ chối nhận quà, chuyển sang đợt sau.',
]);
$feedbacks = [
[
'customer_id' => $customers[0]->id,
'customer_product_id' => $customerProduct1->id,
'contract_id' => $contract1->id,
'feedback_channel_id' => $channels->firstWhere('slug', 'email')->id,
'title' => 'Leaking ceiling in living room',
'content' => 'I noticed water stains on the ceiling of the living room after heavy rain. Please send someone to inspect and fix.',
'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', 'status' => 'processing',
'assigned_to' => $manager->id, ]);
'current_department_id' => $depCskh->id,
], // Feedback 3: Resolved - Điều hòa hỏng
[ $cp3 = CustomerProduct::where('customer_id', $c2->id)->where('product_id', $p1->id)->first();
'customer_id' => $customers[1]->id, $fb3 = Feedback::create([
'customer_product_id' => $customerProduct3->id, 'customer_id' => $c2->id, 'customer_product_id' => $cp3->id, 'contract_id' => $contract3->id,
'contract_id' => $contract3->id, 'feedback_channel_id' => $chZalo->id, 'assigned_to' => $staffA->id,
'feedback_channel_id' => $channels->firstWhere('slug', 'zalo')->id, 'current_department_id' => $deptKt->id, 'is_escalated' => false,
'title' => 'AC not cooling properly', 'title' => 'Điều hòa phòng ngủ chính không mát',
'content' => 'The air conditioner in the master bedroom is making loud noises and not cooling effectively.', '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', 'status' => 'resolved',
'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' => $customers[2]->id, 'customer_id' => $c3->id, 'customer_product_id' => null, 'contract_id' => null,
'customer_product_id' => null, 'feedback_channel_id' => $chApp->id, 'assigned_to' => null,
'contract_id' => null, 'current_department_id' => $deptCskh->id,
'feedback_channel_id' => $channels->firstWhere('slug', 'document')->id, 'title' => 'Đề xuất cải thiện dịch vụ vệ sinh',
'title' => 'General feedback on community services', '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.',
'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', 'status' => 'pending',
'assigned_to' => null, ]);
'current_department_id' => $depCskh->id,
], // Feedback 5: Closed - Bản lề tủ bếp
[ $cp5 = CustomerProduct::where('customer_id', $c4->id)->where('product_id', $p2->id)->first();
'customer_id' => $customers[3]->id, $fb5 = Feedback::create([
'customer_product_id' => \App\Models\CustomerProduct::where('customer_id', $customers[3]->id)->where('product_id', $products[1]->id)->first()->id, 'customer_id' => $c4->id, 'customer_product_id' => $cp5->id, 'contract_id' => $contract5->id,
'contract_id' => $contract5->id, 'feedback_channel_id' => $chEmail->id, 'assigned_to' => $staffB->id,
'feedback_channel_id' => $channels->firstWhere('slug', 'email')->id, 'current_department_id' => $deptKt->id,
'title' => 'Kitchen cabinet hinge broken', 'title' => 'Bản lề tủ bếp bị gãy',
'content' => 'One of the kitchen cabinet hinges is broken. It is still under warranty, please arrange replacement.', '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', 'status' => 'closed',
'assigned_to' => $staff->id, ]);
'current_department_id' => $depKythuat->id,
],
];
foreach ($feedbacks as $feedback) { // Feedback 6: Processing - Ổ cắm điện hư
Feedback::create($feedback); $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',
]);
$tags = [ // Feedback 7: Resolved - Hợp đồng thuê
['name' => 'Leaking', 'slug' => 'leaking', 'color' => '#3b82f6'], $fb7 = Feedback::create([
['name' => 'Electrical', 'slug' => 'electrical', 'color' => '#f59e0b'], 'customer_id' => $c4->id, 'customer_product_id' => null, 'contract_id' => $contract5->id,
['name' => 'Warranty', 'slug' => 'warranty', 'color' => '#10b981'], 'feedback_channel_id' => $chWalkin->id, 'assigned_to' => $staffB->id,
['name' => 'Maintenance', 'slug' => 'maintenance', 'color' => '#8b5cf6'], 'current_department_id' => $deptPhaply->id,
['name' => 'Service Request', 'slug' => 'service-request', 'color' => '#ec4899'], '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',
]);
foreach ($tags as $tag) { // Feedback 8: Pending - Phàn nàn về tiếng ồn
\App\Models\FeedbackTag::create($tag); $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 = Feedback::all(); // ============================================================
$allTags = \App\Models\FeedbackTag::all(); // 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]);
$allFeedback[0]->tags()->attach([$allTags[0]->id, $allTags[3]->id]); // ============================================================
$allFeedback[1]->tags()->attach([$allTags[4]->id]); // 14. FEEDBACK INTERACTIONS (12 interactions)
$allFeedback[2]->tags()->attach([$allTags[1]->id, $allTags[2]->id]); // ============================================================
$allFeedback[3]->tags()->attach([$allTags[3]->id]);
$allFeedback[4]->tags()->attach([$allTags[2]->id, $allTags[3]->id]);
\App\Models\FeedbackInteraction::create([ // FB1: Tạo mới + ghi chú
'feedback_id' => $allFeedback[0]->id, FeedbackInteraction::create([
'user_id' => $admin->id, 'feedback_id' => $fb1->id, 'user_id' => $admin->id, 'type' => 'note',
'type' => 'note', 'content' => 'Tiếp nhận phản ánh qua Email. Giao cho nhân viên Phạm Thị xử lý.',
'content' => 'Feedback received via Email. Assigned to Staff for initial handling.',
'changes' => ['status' => ['old' => null, 'new' => 'pending']], 'changes' => ['status' => ['old' => null, 'new' => 'pending']],
]); ]);
FeedbackInteraction::create([
\App\Models\FeedbackInteraction::create([ 'feedback_id' => $fb1->id, 'user_id' => $staffA->id, 'type' => 'note',
'feedback_id' => $allFeedback[0]->id, '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.',
'user_id' => $staff->id,
'type' => 'note',
'content' => 'Contacted customer via phone. Scheduled inspection for next Tuesday. Customer agreed.',
]); ]);
\App\Models\FeedbackInteraction::create([ // FB2: Phân công
'feedback_id' => $allFeedback[2]->id, FeedbackInteraction::create([
'user_id' => $staff->id, 'feedback_id' => $fb2->id, 'user_id' => $managerCskh->id, 'type' => 'note',
'type' => 'status_change', 'content' => 'Đã gửi báo giá nâng cấp chỗ đậu xe qua email. Chờ khách xác nhận.',
'content' => 'Technician visited site on 2024-06-10. AC repaired. Customer confirmed cooling is now normal.', ]);
// 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']], 'changes' => ['status' => ['old' => 'processing', 'new' => 'resolved']],
]); ]);
\App\Models\FeedbackInteraction::create([ // FB5: Đóng phiếu
'feedback_id' => $allFeedback[1]->id, FeedbackInteraction::create([
'user_id' => $manager->id, 'feedback_id' => $fb5->id, 'user_id' => $staffB->id, 'type' => 'status_change',
'type' => 'note', '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' => 'Parking upgrade available. Sent quotation via email. Waiting for customer confirmation.',
]);
\App\Models\FeedbackInteraction::create([
'feedback_id' => $allFeedback[4]->id,
'user_id' => $staff->id,
'type' => 'status_change',
'content' => 'Replacement hinge installed on 2025-03-01. All kitchen cabinets now working properly.',
'changes' => ['status' => ['old' => 'processing', 'new' => 'closed']], 'changes' => ['status' => ['old' => 'processing', 'new' => 'closed']],
]); ]);
\App\Models\TicketTransferLog::create([ // FB6: Đang xử lý + ghi chú khẩn cấp
'feedback_id' => $allFeedback[2]->id, FeedbackInteraction::create([
'from_department_id' => $depCskh->id, 'feedback_id' => $fb6->id, 'user_id' => $staffA->id, 'type' => 'note',
'to_department_id' => $depKythuat->id, '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.',
'sender_id' => $manager->id, ]);
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ý.', 'reason' => 'Vấn đề kỹ thuật về điều hòa, cần Phòng Kỹ thuật kiểm tra và xử lý.',
]); ]);
TicketTransferLog::create([
$disk = \Illuminate\Support\Facades\Storage::disk('local'); 'feedback_id' => $fb7->id,
'from_department_id' => $deptCskh->id,
$disk->put('feedback-attachments/ac_repair_report.pdf', '%PDF-1.4 Demo report content'); 'to_department_id' => $deptPhaply->id,
\App\Models\FeedbackAttachment::create([ 'sender_id' => $managerCskh->id,
'uuid' => \Illuminate\Support\Str::uuid(), 'reason' => 'Yêu cầu gia hạn hợp đồng thuộc thẩm quyền Phòng Pháp lý.',
'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'),
]); ]);
$disk->put('feedback-attachments/ceiling_photo.jpg', 'dummy-jpeg-content'); // ============================================================
\App\Models\FeedbackAttachment::create([ // 16. FEEDBACK ATTACHMENTS (6 attachments - public disk)
'uuid' => \Illuminate\Support\Str::uuid(), // ============================================================
'feedback_id' => $allFeedback[0]->id, $disk = Storage::disk('public');
'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'),
]);
$disk->put('feedback-attachments/replaced_hinge_photo.png', 'dummy-png-content'); // Tạo file mẫu trên public disk
\App\Models\FeedbackAttachment::create([ $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');
'uuid' => \Illuminate\Support\Str::uuid(), $disk->put('feedback-attachments/anh-tran-ro-ri.jpg', str_repeat('x', 1000)); // dummy image
'feedback_id' => $allFeedback[4]->id, $disk->put('feedback-attachments/anh-ban-le-hong.png', str_repeat('y', 1200)); // dummy image
'user_id' => $staff->id, $disk->put('feedback-attachments/bao-gia-nang-cap.pdf', '%PDF-1.4 Báo giá nâng cấp chỗ đậu xe - Demo');
'name' => 'replaced_hinge_photo.png', $disk->put('feedback-attachments/phu-luc-hop-dong.pdf', '%PDF-1.4 Phụ lục hợp đồng gia hạn - Demo');
'path' => 'feedback-attachments/replaced_hinge_photo.png', $disk->put('feedback-attachments/anh-o-cam-hu.jpg', str_repeat('z', 800)); // dummy image
'disk' => 'local',
'collection' => 'proof_of_resolution', FeedbackAttachment::create([
'mime_type' => 'image/png', 'uuid' => (string) Str::uuid(),
'size' => $disk->size('feedback-attachments/replaced_hinge_photo.png'), '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'),
]); ]);
} }
} }

View File

@@ -714,3 +714,208 @@ svg[class*="fi-icon"] {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.5; } 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; }
}

View File

@@ -1,61 +1,50 @@
<div class="p-4"> <div class="p-3" style="max-width: 100%;">
@if(empty($links) || count($links) === 0) @if(empty($links) || count($links) === 0)
<p class="text-sm text-gray-500 py-8 text-center">{{ __('app.blade_no_attachments') }}</p> <p class="text-gray-500 text-center" style="font-size: 11px; padding: 16px 0;">{{ __('app.blade_no_attachments') }}</p>
@else @else
<div class="space-y-3"> <div class="divide-y divide-gray-100 border border-gray-200 rounded-lg overflow-hidden">
@foreach($links as $link) @foreach($links as $link)
@php @php
$isImage = in_array($link['mime_type'] ?? '', ['image/jpeg', 'image/jpg', 'image/png']); $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 @endphp
<div class="flex items-start gap-4 p-3 border border-gray-200 rounded-lg bg-gray-50/30 hover:border-blue-300 transition-colors group" <a href="{{ $link['url'] }}" target="_blank" class="flex items-center gap-2 px-3 py-1.5 hover:bg-blue-50/50 transition-colors group no-underline" style="text-decoration: none;">
onclick="window.open('{{ $link['url'] }}', '_blank')" {{-- Icon --}}
style="cursor: pointer;"> <div class="flex-shrink-0 rounded {{ $bgColor }} flex items-center justify-center" style="width: 20px; height: 20px;">
{{-- File icon or image preview --}}
<div class="flex-shrink-0">
@if($isImage) @if($isImage)
<img src="{{ $link['url'] }}" alt="{{ $link['name'] }}" <svg width="12" height="12" class="{{ $iconColor }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
class="w-16 h-16 rounded object-cover border border-gray-200" @elseif($ext === 'pdf')
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"> <svg width="12" height="12" class="{{ $iconColor }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
<div class="w-16 h-16 rounded border border-gray-200 bg-gray-100 items-center justify-center" style="display: none;">
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
</div>
@elseif(($link['mime_type'] ?? '') === 'application/pdf')
<div class="w-16 h-16 rounded border border-red-200 bg-red-50 flex items-center justify-center">
<svg class="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
</div>
@else @else
<div class="w-16 h-16 rounded border border-blue-200 bg-blue-50 flex items-center justify-center"> <svg width="12" height="12" class="{{ $iconColor }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg>
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg>
</div>
@endif @endif
</div> </div>
{{-- File info --}} {{-- Tên --}}
<div class="flex-1 min-w-0"> <span class="flex-1 text-gray-700 group-hover:text-blue-600 truncate block" style="font-size: 11px; min-width: 0;">{{ $link['name'] }}</span>
<p class="text-sm font-medium text-gray-900 truncate group-hover:text-blue-600 transition-colors">
{{ $link['name'] }} {{-- Badge --}}
</p>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs text-gray-500">{{ $link['size'] }}</span>
@if(($link['collection'] ?? '') === 'proof_of_resolution') @if(($link['collection'] ?? '') === 'proof_of_resolution')
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold bg-green-100 text-green-700 border border-green-200">{{ __('app.blade_proof_of_resolution') }}</span> <span class="inline-flex items-center px-1 py-0.5 rounded bg-green-100 text-green-700 flex-shrink-0" style="font-size: 9px;">{{ __('app.blade_proof_of_resolution') }}</span>
@elseif(($link['collection'] ?? '') === 'customer_evidence') @elseif(($link['collection'] ?? '') === 'customer_evidence')
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold bg-blue-100 text-blue-700 border border-blue-200">{{ __('app.blade_customer_evidence') }}</span> <span class="inline-flex items-center px-1 py-0.5 rounded bg-blue-100 text-blue-700 flex-shrink-0" style="font-size: 9px;">{{ __('app.blade_customer_evidence') }}</span>
@else
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold bg-gray-100 text-gray-600 border border-gray-200">{{ $link['collection'] ?? __('app.collection_general') }}</span>
@endif @endif
</div>
</div>
{{-- Download button --}} {{-- Size --}}
<a href="{{ $link['url'] }}" target="_blank" <span class="text-gray-400 flex-shrink-0" style="font-size: 10px;">{{ $link['size'] }}</span>
onclick="event.stopPropagation();"
class="flex-shrink-0 inline-flex items-center px-3 py-1.5 rounded text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 transition-colors shadow-sm no-underline">
{{ __('app.blade_open') }}
</a> </a>
</div>
@endforeach @endforeach
</div> </div>
<p class="mt-4 text-xs text-gray-500">{{ __('app.blade_links_expire') }}</p> <p class="text-gray-400 text-center" style="font-size: 10px; margin-top: 6px;">{{ __('app.blade_links_expire') }}</p>
@endif @endif
</div> </div>

View File

@@ -1,4 +1,4 @@
<div> <div class="max-w-full overflow-hidden">
@include('filament.resources.feedback.feedback-attachments') @include('filament.resources.feedback.feedback-attachments')
@include('filament.resources.feedback.similar-cases') @include('filament.resources.feedback.similar-cases')
</div> </div>

View File

@@ -6,56 +6,60 @@
@endphp @endphp
@if($attachments->isNotEmpty()) @if($attachments->isNotEmpty())
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-8"> <div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mt-6" style="max-width: 100%;">
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex justify-between items-center"> <div class="px-4 py-2 border-b border-gray-100 bg-gray-50/50 flex justify-between items-center">
<h3 class="font-semibold text-lg text-gray-900 flex items-center gap-2" style="font-family: 'Manrope', sans-serif;"> <h3 class="font-semibold text-xs text-gray-900 flex items-center gap-1.5" style="font-family: 'Manrope', sans-serif;">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg> <svg width="14" height="14" class="text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg>
{{ __('app.attachments') }} {{ __('app.attachments') }}
<span class="text-[10px] font-normal text-gray-400">({{ $attachments->count() }})</span>
</h3> </h3>
<span class="text-xs text-gray-500">{{ __('app.blade_file_count', ['count' => $attachments->count()]) }}</span>
</div> </div>
<div class="p-4"> <div class="divide-y divide-gray-100">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
@foreach($attachments as $attachment) @foreach($attachments as $attachment)
@php @php
$url = $fileService->getTemporaryUrl($attachment, 60); $url = $fileService->getTemporaryUrl($attachment, 60);
$isImage = $fileService->isImage($attachment); $isImage = $fileService->isImage($attachment);
$sizeKb = $attachment->size ? round($attachment->size / 1024, 1) . ' KB' : 'N/A'; $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 @endphp
<div class="border border-gray-200 rounded-lg p-3 flex items-start gap-3 bg-gray-50/30 hover:border-blue-300 transition-colors group cursor-pointer" <a href="{{ $url }}" target="_blank" class="flex items-center gap-2 px-4 py-1.5 hover:bg-blue-50/50 transition-colors group no-underline" style="text-decoration: none;">
onclick="window.open('{{ $url }}', '_blank')"> {{-- Icon 20x20 --}}
<div class="flex-shrink-0 rounded {{ $bgColor }} flex items-center justify-center" style="width: 20px; height: 20px;">
@if($isImage) @if($isImage)
<div class="w-12 h-12 rounded bg-gray-200 overflow-hidden flex-shrink-0"> <svg width="12" height="12" class="{{ $iconColor }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<img src="{{ $url }}" alt="{{ $attachment->name }}" class="w-full h-full object-cover" @elseif($ext === 'pdf')
onerror="this.parentElement.innerHTML='<span class=\'flex items-center justify-center h-full text-gray-400\'><svg class=\'w-6 h-6\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\'/></svg></span>'"> <svg width="12" height="12" class="{{ $iconColor }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
</div>
@elseif($attachment->mime_type === 'application/pdf')
<div class="w-12 h-12 rounded bg-red-100 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
</div>
@else @else
<div class="w-12 h-12 rounded bg-blue-100 flex items-center justify-center flex-shrink-0"> <svg width="12" height="12" class="{{ $iconColor }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg>
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg>
</div>
@endif @endif
<div class="flex-1 min-w-0"> </div>
<p class="text-sm font-medium text-gray-900 truncate group-hover:text-blue-600 transition-colors">
{{ $attachment->name }} {{-- Tên file --}}
</p> <span class="flex-1 text-gray-700 group-hover:text-blue-600 transition-colors truncate" style="font-size: 11px; min-width: 0;">{{ $attachment->name }}</span>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs text-gray-500">{{ $sizeKb }}</span> {{-- Badge collection --}}
@if($attachment->collection === 'proof_of_resolution') @if($attachment->collection === 'proof_of_resolution')
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold bg-green-100 text-green-700 border border-green-200">{{ __('app.blade_proof') }}</span> <span class="inline-flex items-center px-1 py-0.5 rounded bg-green-100 text-green-700 flex-shrink-0" style="font-size: 9px;">{{ __('app.blade_proof') }}</span>
@elseif($attachment->collection === 'customer_evidence') @elseif($attachment->collection === 'customer_evidence')
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold bg-blue-100 text-blue-700 border border-blue-200">{{ __('app.blade_evidence') }}</span> <span class="inline-flex items-center px-1 py-0.5 rounded bg-blue-100 text-blue-700 flex-shrink-0" style="font-size: 9px;">{{ __('app.blade_evidence') }}</span>
@else
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold bg-gray-100 text-gray-600 border border-gray-200">{{ __('app.collection_general') }}</span>
@endif @endif
</div>
</div> {{-- Size --}}
</div> <span class="text-gray-400 flex-shrink-0" style="font-size: 10px;">{{ $sizeKb }}</span>
</a>
@endforeach @endforeach
</div> </div>
</div>
</div> </div>
@endif @endif

View File

@@ -15,55 +15,57 @@
@endphp @endphp
@if($similarCases->isNotEmpty()) @if($similarCases->isNotEmpty())
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-8"> <div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mt-6" style="max-width: 100%;">
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex justify-between items-center"> <div class="px-4 py-2 border-b border-gray-100 bg-gray-50/50 flex justify-between items-center">
<h3 class="font-semibold text-lg text-gray-900 flex items-center gap-2" style="font-family: 'Manrope', sans-serif;"> <h3 class="font-semibold text-xs text-gray-900 flex items-center gap-1.5" style="font-family: 'Manrope', sans-serif;">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> <svg width="14" height="14" class="text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
{{ __('app.blade_similar_cases') }} {{ __('app.blade_similar_cases') }}
</h3> </h3>
<span class="text-xs text-gray-500">{{ __('app.blade_matching_tags') }} {{ $record->tags->pluck('name')->implode(', ') }}</span> <span class="text-[10px] text-gray-400">{{ __('app.blade_matching_tags') }} {{ $record->tags->pluck('name')->implode(', ') }}</span>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-left border-collapse"> <table class="w-full text-left border-collapse">
<thead> <thead>
<tr class="bg-gray-50 border-b border-gray-200"> <tr class="bg-gray-50/50 border-b border-gray-100">
<th class="px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{{ __('app.blade_title') }}</th> <th class="px-4 py-2 text-[10px] font-semibold text-gray-500 uppercase tracking-wider">{{ __('app.blade_title') }}</th>
<th class="px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{{ __('app.blade_customer') }}</th> <th class="px-4 py-2 text-[10px] font-semibold text-gray-500 uppercase tracking-wider">{{ __('app.blade_customer') }}</th>
<th class="px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{{ __('app.blade_status') }}</th> <th class="px-4 py-2 text-[10px] font-semibold text-gray-500 uppercase tracking-wider">{{ __('app.blade_status') }}</th>
<th class="px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{{ __('app.blade_handler') }}</th> <th class="px-4 py-2 text-[10px] font-semibold text-gray-500 uppercase tracking-wider">{{ __('app.blade_handler') }}</th>
<th class="px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{{ __('app.blade_date') }}</th> <th class="px-4 py-2 text-[10px] font-semibold text-gray-500 uppercase tracking-wider">{{ __('app.blade_date') }}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-gray-100">
@foreach($similarCases as $case) @foreach($similarCases as $case)
<tr class="hover:bg-gray-50/50 transition-colors cursor-pointer" <tr class="hover:bg-blue-50/30 transition-colors cursor-pointer"
onclick="window.location='{{ \App\Filament\Resources\Feedback\FeedbackResource::getUrl('edit', ['record' => $case]) }}'"> onclick="window.location='{{ \App\Filament\Resources\Feedback\FeedbackResource::getUrl('edit', ['record' => $case]) }}'">
<td class="px-6 py-4 text-sm text-gray-900 hover:text-blue-600 transition-colors font-medium"> <td class="px-4 py-2">
<span class="text-blue-600 hover:text-blue-800 hover:underline transition-colors font-medium" style="font-size: 12px;">
{{ $case->title }} {{ $case->title }}
</span>
</td> </td>
<td class="px-6 py-4 text-sm text-gray-600"> <td class="px-4 py-2 text-gray-600" style="font-size: 12px;">
{{ $case->customer?->name }} {{ $case->customer?->name }}
</td> </td>
<td class="px-6 py-4"> <td class="px-4 py-2">
@switch($case->status) @switch($case->status)
@case('pending') @case('pending')
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-amber-50 text-amber-700 border border-amber-200">{{ __('app.status_pending') }}</span> <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-medium bg-amber-50 text-amber-700 border border-amber-200">{{ __('app.status_pending') }}</span>
@break @break
@case('processing') @case('processing')
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-blue-50 text-blue-700 border border-blue-200">{{ __('app.status_processing') }}</span> <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-medium bg-blue-50 text-blue-700 border border-blue-200">{{ __('app.status_processing') }}</span>
@break @break
@case('resolved') @case('resolved')
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-green-50 text-green-700 border border-green-200">{{ __('app.status_resolved') }}</span> <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-medium bg-green-50 text-green-700 border border-green-200">{{ __('app.status_resolved') }}</span>
@break @break
@case('closed') @case('closed')
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-gray-100 text-gray-600 border border-gray-200">{{ __('app.status_closed') }}</span> <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-medium bg-gray-100 text-gray-600 border border-gray-200">{{ __('app.status_closed') }}</span>
@break @break
@endswitch @endswitch
</td> </td>
<td class="px-6 py-4 text-sm text-gray-600"> <td class="px-4 py-2 text-gray-600" style="font-size: 12px;">
{{ $case->assignedTo?->name ?? __('app.unassigned') }} {{ $case->assignedTo?->name ?? __('app.unassigned') }}
</td> </td>
<td class="px-6 py-4 text-sm text-gray-500"> <td class="px-4 py-2 text-gray-500" style="font-size: 12px;">
{{ $case->created_at->format('d/m/Y') }} {{ $case->created_at->format('d/m/Y') }}
</td> </td>
</tr> </tr>