fix: attachment file handling - real files, inline preview, click to open
- FileService: use Storage::temporaryUrl() standard API instead of manual signed route - FileService: added isImage() and getPreviewUrl() helpers - Seeder: create actual files on disk instead of fake paths - Edit Feedback: new 'Attachments' section showing all feedback files with thumbnails - Interactions modal: image preview thumbnails + inline click-to-open - New footer layout: edit-footer.blade.php wrapper for attachments + similar cases
This commit is contained in:
@@ -142,7 +142,7 @@ class EditFeedback extends EditRecord
|
|||||||
|
|
||||||
public function getFooter(): ?View
|
public function getFooter(): ?View
|
||||||
{
|
{
|
||||||
return view('filament.resources.feedback.similar-cases');
|
return view('filament.resources.feedback.edit-footer');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function mutateFormDataBeforeSave(array $data): array
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ class InteractionsRelationManager extends RelationManager
|
|||||||
'url' => $fileService->getTemporaryUrl($a, 60),
|
'url' => $fileService->getTemporaryUrl($a, 60),
|
||||||
'size' => $a->size ? round($a->size / 1024, 1) . ' KB' : 'N/A',
|
'size' => $a->size ? round($a->size / 1024, 1) . ' KB' : 'N/A',
|
||||||
'collection' => $a->collection,
|
'collection' => $a->collection,
|
||||||
|
'mime_type' => $a->mime_type,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -74,13 +74,28 @@ class FileService
|
|||||||
return Storage::disk('public')->url($attachment->path);
|
return Storage::disk('public')->url($attachment->path);
|
||||||
}
|
}
|
||||||
|
|
||||||
return URL::temporarySignedRoute(
|
return Storage::disk($attachment->disk)->temporaryUrl(
|
||||||
'storage.local',
|
$attachment->path,
|
||||||
now()->addMinutes($minutes),
|
now()->addMinutes($minutes),
|
||||||
['path' => $attachment->path]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a preview (inline) URL vs download URL.
|
||||||
|
*/
|
||||||
|
public function getPreviewUrl(FeedbackAttachment $attachment, int $minutes = 60): string
|
||||||
|
{
|
||||||
|
return $this->getTemporaryUrl($attachment, $minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if attachment is an image (for inline preview).
|
||||||
|
*/
|
||||||
|
public function isImage(FeedbackAttachment $attachment): bool
|
||||||
|
{
|
||||||
|
return in_array($attachment->mime_type, ['image/jpeg', 'image/jpg', 'image/png']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all attachments of a specific collection for a model.
|
* Get all attachments of a specific collection for a model.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -222,6 +222,9 @@ class AfterSalesSeeder extends Seeder
|
|||||||
'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ý.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$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([
|
\App\Models\FeedbackAttachment::create([
|
||||||
'uuid' => \Illuminate\Support\Str::uuid(),
|
'uuid' => \Illuminate\Support\Str::uuid(),
|
||||||
'feedback_id' => $allFeedback[2]->id,
|
'feedback_id' => $allFeedback[2]->id,
|
||||||
@@ -231,9 +234,10 @@ class AfterSalesSeeder extends Seeder
|
|||||||
'disk' => 'local',
|
'disk' => 'local',
|
||||||
'collection' => 'proof_of_resolution',
|
'collection' => 'proof_of_resolution',
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'size' => 204800,
|
'size' => $disk->size('feedback-attachments/ac_repair_report.pdf'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$disk->put('feedback-attachments/ceiling_photo.jpg', 'dummy-jpeg-content');
|
||||||
\App\Models\FeedbackAttachment::create([
|
\App\Models\FeedbackAttachment::create([
|
||||||
'uuid' => \Illuminate\Support\Str::uuid(),
|
'uuid' => \Illuminate\Support\Str::uuid(),
|
||||||
'feedback_id' => $allFeedback[0]->id,
|
'feedback_id' => $allFeedback[0]->id,
|
||||||
@@ -243,9 +247,10 @@ class AfterSalesSeeder extends Seeder
|
|||||||
'disk' => 'local',
|
'disk' => 'local',
|
||||||
'collection' => 'customer_evidence',
|
'collection' => 'customer_evidence',
|
||||||
'mime_type' => 'image/jpeg',
|
'mime_type' => 'image/jpeg',
|
||||||
'size' => 512000,
|
'size' => $disk->size('feedback-attachments/ceiling_photo.jpg'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$disk->put('feedback-attachments/replaced_hinge_photo.png', 'dummy-png-content');
|
||||||
\App\Models\FeedbackAttachment::create([
|
\App\Models\FeedbackAttachment::create([
|
||||||
'uuid' => \Illuminate\Support\Str::uuid(),
|
'uuid' => \Illuminate\Support\Str::uuid(),
|
||||||
'feedback_id' => $allFeedback[4]->id,
|
'feedback_id' => $allFeedback[4]->id,
|
||||||
@@ -255,7 +260,7 @@ class AfterSalesSeeder extends Seeder
|
|||||||
'disk' => 'local',
|
'disk' => 'local',
|
||||||
'collection' => 'proof_of_resolution',
|
'collection' => 'proof_of_resolution',
|
||||||
'mime_type' => 'image/png',
|
'mime_type' => 'image/png',
|
||||||
'size' => 307200,
|
'size' => $disk->size('feedback-attachments/replaced_hinge_photo.png'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,60 @@
|
|||||||
@if(empty($links) || count($links) === 0)
|
@if(empty($links) || count($links) === 0)
|
||||||
<p class="text-sm text-gray-500 py-8 text-center">No attachments.</p>
|
<p class="text-sm text-gray-500 py-8 text-center">No attachments.</p>
|
||||||
@else
|
@else
|
||||||
<table class="w-full text-left border-collapse">
|
<div class="space-y-3">
|
||||||
<thead>
|
@foreach($links as $link)
|
||||||
<tr class="border-b-2 border-gray-200">
|
@php
|
||||||
<th class="text-left py-2 px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">File</th>
|
$isImage = in_array($link['mime_type'] ?? '', ['image/jpeg', 'image/jpg', 'image/png']);
|
||||||
<th class="text-left py-2 px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Collection</th>
|
@endphp
|
||||||
<th class="text-left py-2 px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Size</th>
|
<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"
|
||||||
<th class="text-right py-2 px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Action</th>
|
onclick="window.open('{{ $link['url'] }}', '_blank')"
|
||||||
</tr>
|
style="cursor: pointer;">
|
||||||
</thead>
|
{{-- File icon or image preview --}}
|
||||||
<tbody>
|
<div class="flex-shrink-0">
|
||||||
@foreach($links as $link)
|
@if($isImage)
|
||||||
<tr class="border-b border-gray-100 hover:bg-gray-50/50 transition-colors">
|
<img src="{{ $link['url'] }}" alt="{{ $link['name'] }}"
|
||||||
<td class="py-3 px-3 text-sm text-gray-900 font-medium">{{ $link['name'] }}</td>
|
class="w-16 h-16 rounded object-cover border border-gray-200"
|
||||||
<td class="py-3 px-3">
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
@if($link['collection'] === 'proof_of_resolution')
|
<div class="w-16 h-16 rounded border border-gray-200 bg-gray-100 items-center justify-center" style="display: none;">
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-green-100 text-green-700 border border-green-200">proof_of_resolution</span>
|
<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>
|
||||||
@elseif($link['collection'] === 'customer_evidence')
|
</div>
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-blue-100 text-blue-700 border border-blue-200">customer_evidence</span>
|
@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
|
||||||
<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">{{ $link['collection'] }}</span>
|
<div class="w-16 h-16 rounded border border-blue-200 bg-blue-50 flex items-center justify-center">
|
||||||
|
<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
|
||||||
</td>
|
</div>
|
||||||
<td class="py-3 px-3 text-sm text-gray-500">{{ $link['size'] }}</td>
|
|
||||||
<td class="py-3 px-3 text-right">
|
{{-- File info --}}
|
||||||
<a href="{{ $link['url'] }}" target="_blank"
|
<div class="flex-1 min-w-0">
|
||||||
class="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">
|
<p class="text-sm font-medium text-gray-900 truncate group-hover:text-blue-600 transition-colors">
|
||||||
Download
|
{{ $link['name'] }}
|
||||||
</a>
|
</p>
|
||||||
</td>
|
<div class="flex items-center gap-2 mt-1">
|
||||||
</tr>
|
<span class="text-xs text-gray-500">{{ $link['size'] }}</span>
|
||||||
@endforeach
|
@if(($link['collection'] ?? '') === 'proof_of_resolution')
|
||||||
</tbody>
|
<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">Proof of Resolution</span>
|
||||||
</table>
|
@elseif(($link['collection'] ?? '') === 'customer_evidence')
|
||||||
<p class="mt-4 text-xs text-gray-500">Links expire after 60 minutes.</p>
|
<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">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'] ?? 'General' }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Download button --}}
|
||||||
|
<a href="{{ $link['url'] }}" target="_blank"
|
||||||
|
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">
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-xs text-gray-500">Click to open. Links expire after 60 minutes.</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div>
|
||||||
|
@include('filament.resources.feedback.feedback-attachments')
|
||||||
|
@include('filament.resources.feedback.similar-cases')
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
@php
|
||||||
|
use App\Services\FileService;
|
||||||
|
$record = $this->getRecord();
|
||||||
|
$fileService = app(FileService::class);
|
||||||
|
$attachments = $record->attachments()->orderBy('created_at', 'desc')->get();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if($attachments->isNotEmpty())
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-8">
|
||||||
|
<div class="px-6 py-4 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;">
|
||||||
|
<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>
|
||||||
|
Attachments
|
||||||
|
</h3>
|
||||||
|
<span class="text-xs text-gray-500">{{ $attachments->count() }} file(s)</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
@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
|
||||||
|
<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"
|
||||||
|
onclick="window.open('{{ $url }}', '_blank')">
|
||||||
|
@if($isImage)
|
||||||
|
<div class="w-12 h-12 rounded bg-gray-200 overflow-hidden flex-shrink-0">
|
||||||
|
<img src="{{ $url }}" alt="{{ $attachment->name }}" class="w-full h-full object-cover"
|
||||||
|
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>'">
|
||||||
|
</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
|
||||||
|
<div class="w-12 h-12 rounded bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<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
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate group-hover:text-blue-600 transition-colors">
|
||||||
|
{{ $attachment->name }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<span class="text-xs text-gray-500">{{ $sizeKb }}</span>
|
||||||
|
@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">Proof</span>
|
||||||
|
@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">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">General</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
Reference in New Issue
Block a user