diff --git a/app/Helpers/NextCloudHelper.php b/app/Helpers/NextCloudHelper.php index 08f100b..eb8f571 100644 --- a/app/Helpers/NextCloudHelper.php +++ b/app/Helpers/NextCloudHelper.php @@ -135,6 +135,11 @@ class NextCloudHelper return route('admin.nextcloud.download', ['file' => base64_encode($filePath)]); } + public static function getPreviewUrl($filePath) + { + return route('admin.nextcloud.preview', ['file' => base64_encode($filePath)]); + } + public static function getNextcloudFile($filePath) { // Check if the file exists diff --git a/app/Http/Controllers/Backend/NextCloudController.php b/app/Http/Controllers/Backend/NextCloudController.php index 9bd1220..d462555 100644 --- a/app/Http/Controllers/Backend/NextCloudController.php +++ b/app/Http/Controllers/Backend/NextCloudController.php @@ -68,4 +68,60 @@ class NextCloudController extends Controller ->header('Pragma', 'public') ->header('Content-Length', strlen($response)); } + + public function preview($file) + { + $filePath = base64_decode($file); + + $baseUrl = rtrim(env('NEXT_CLOUD_URL'), '/'); + $username = env('NEXT_CLOUD_USERNAME'); + $password = env('NEXT_CLOUD_PASSWORD'); + + $filePath = trim((string) $filePath); + $segments = explode('/', ltrim($filePath, '/')); + $encodedPath = implode('/', array_map('rawurlencode', $segments)); + + $relativePath = "remote.php/dav/files/{$username}/" . $encodedPath; + $url = $baseUrl . '/' . $relativePath; + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERPWD, "{$username}:{$password}"); + curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + + $response = curl_exec($ch); + + if ($response === false) { + curl_close($ch); + abort(404); + } + + $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + curl_close($ch); + + if ($statusCode >= 400 || !$response) { + abort(404); + } + + if (!$contentType) { + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + $contentType = match ($extension) { + 'jpg', 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'pdf' => 'application/pdf', + default => 'application/octet-stream', + }; + } + + $filename = basename($filePath); + + return response($response, 200) + ->header('Content-Type', $contentType) + ->header('Content-Disposition', "inline; filename=\"{$filename}\"") + ->header('Content-Length', strlen($response)); + } } diff --git a/app/Http/Controllers/Forms/FormUpCountryController.php b/app/Http/Controllers/Forms/FormUpCountryController.php index 53e6bfd..fe3af60 100644 --- a/app/Http/Controllers/Forms/FormUpCountryController.php +++ b/app/Http/Controllers/Forms/FormUpCountryController.php @@ -24,16 +24,35 @@ use App\Helpers\AuditTrailHelper; use App\Helpers\NextCloudHelper; use App\Rules\FileType; use App\Models\Admin; +use App\Models\AttachmentForm; use App\Helpers\BudgetHelper; use App\Helpers\NotificationHelper; use App\Helpers\OutstandingHelper; use Illuminate\Support\Facades\Log; use App\Services\BrevoService; +use App\Services\AttachmentService; +use App\Enums\AttachmentTableName; use Carbon\Carbon; use Illuminate\Container\Attributes\Auth; class FormUpCountryController extends Controller { + protected AttachmentService $attachmentService; + protected array $attachmentCategories = [ + 'bukti_allowance', + 'bukti_transport_dalkot', + 'bukti_transport_ankot', + 'bukti_hotel', + 'bukti_total', + 'bukti_lainnya', + ]; + + public function __construct(AttachmentService $attachmentService) + { + $this->attachmentService = $attachmentService; + view()->share('upCountryAttachmentCategories', $this->attachmentCategories); + } + public function index() { $this->checkAuthorization(auth()->user(), ['forms.country.view']); @@ -134,16 +153,33 @@ class FormUpCountryController extends Controller { $this->checkAuthorization(auth()->user(), ['forms.country.view']); - $form = FormUpCountry::with(['rayon', 'user'])->findOrFail($id); + $form = FormUpCountry::with(['rayon', 'user', 'attachments'])->findOrFail($id); $form->bukti_allowance = $form->bukti_allowance ? NextCloudHelper::getFileUrl($form->bukti_allowance) : null; $form->bukti_transport_dalkot = $form->bukti_transport_dalkot ? NextCloudHelper::getFileUrl($form->bukti_transport_dalkot) : null; $form->bukti_transport_ankot = $form->bukti_transport_ankot ? NextCloudHelper::getFileUrl($form->bukti_transport_ankot) : null; $form->bukti_hotel = $form->bukti_hotel ? NextCloudHelper::getFileUrl($form->bukti_hotel) : null; $form->uc_plan = $form->uc_plan ? NextCloudHelper::getFileUrl($form->uc_plan) : null; + $attachments = $form->attachments->map(function ($attachment) { + $filePath = $attachment->file_path; + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + + return [ + 'id' => $attachment->id, + 'file_category' => $attachment->file_category, + 'file_path' => $filePath, + 'download_url' => $filePath ? NextCloudHelper::getFileUrl($filePath) : null, + 'preview_url' => $filePath ? NextCloudHelper::getPreviewUrl($filePath) : null, + 'preview_type' => in_array($extension, ['jpg', 'jpeg', 'png']) ? 'image' : 'pdf', + 'filename' => $filePath ? basename($filePath) : null, + 'extension' => $extension, + ]; + })->values(); + return view('backend.pages.forms.upcountry.view', [ 'form' => $form, 'rayons' => Rayon::all(), + 'attachments' => $attachments, ]); } @@ -153,7 +189,8 @@ class FormUpCountryController extends Controller $cabang = UserHasCabang::with('cabang')->where('user_id', auth()->user()->id)->first()->cabang; return view('backend.pages.forms.upcountry.create', [ - 'rayons' => Rayon::where('cabang_id', $cabang->id)->get() + 'rayons' => Rayon::where('cabang_id', $cabang->id)->get(), + 'attachmentCategories' => $this->attachmentCategories, ]); } @@ -169,10 +206,9 @@ class FormUpCountryController extends Controller 'transport_dalkot' => 'nullable|numeric', 'transport_ankot' => 'nullable|numeric', 'hotel' => 'nullable|numeric', - 'bukti_allowance' => ['nullable', 'file', 'max:51200'], - 'bukti_transport_dalkot' => ['nullable', 'file', 'max:51200'], - 'bukti_transport_ankot' => ['nullable', 'file', 'max:51200'], - 'bukti_hotel' => ['nullable', 'file', 'max:51200'], + 'attachments' => 'nullable|array', + 'attachments.*.file_category' => 'required|string|in:' . implode(',', $this->attachmentCategories), + 'attachments.*.file_path' => ['nullable', 'file', 'mimes:jpg,jpeg,png,pdf', 'max:5120'], 'uc_plan' => 'nullable|file|max:51200', ]); @@ -215,54 +251,6 @@ class FormUpCountryController extends Controller $periodeMonth = strtolower($periodeDate->format('F')); $periodeYear = (int) $periodeDate->format('Y'); - // Upload file - $filePaths = []; - $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/upcountry'; - - if ($request->hasFile('bukti_allowance')) { - $bukti = $request->file('bukti_allowance'); - $filename = Str::uuid() . '.' . $bukti->extension(); - $filePaths['bukti_allowance'] = $folderPath . '/' . $filename; - NextCloudHelper::uploadFile($folderPath, $bukti->getContent(), $filename); - } - - if ($request->hasFile('bukti_transport_dalkot')) { - $bukti = $request->file('bukti_transport_dalkot'); - $filename = Str::uuid() . '.' . $bukti->extension(); - $filePaths['bukti_transport_dalkot'] = $folderPath . '/' . $filename; - NextCloudHelper::uploadFile($folderPath, $bukti->getContent(), $filename); - } - - if ($request->hasFile('bukti_transport_ankot')) { - $bukti = $request->file('bukti_transport_ankot'); - $filename = Str::uuid() . '.' . $bukti->extension(); - $filePaths['bukti_transport_ankot'] = $folderPath . '/' . $filename; - NextCloudHelper::uploadFile($folderPath, $bukti->getContent(), $filename); - } - - if ($request->hasFile('bukti_hotel')) { - $bukti = $request->file('bukti_hotel'); - $filename = Str::uuid() . '.' . $bukti->extension(); - $filePaths['bukti_hotel'] = $folderPath . '/' . $filename; - NextCloudHelper::uploadFile($folderPath, $bukti->getContent(), $filename); - } - - if ($request->hasFile('uc_plan')) { - $ucPlan = $request->file('uc_plan'); - $filename = Str::uuid() . '.' . $ucPlan->extension(); - $filePaths['uc_plan'] = $folderPath . '/' . $filename; - NextCloudHelper::uploadFile($folderPath, $ucPlan->getContent(), $filename); - } - - // Generate expense number - $sequence_number = FormUpCountry::withTrashed() - ->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-UPC-' . date('ym') . '%') - ->count() + 1; - - $formatted_sequence_number = str_pad($sequence_number, 4, '0', STR_PAD_LEFT); - $expense_number = $region->code . '-' . $cabang->code . '-UPC-' . date('ym') . $formatted_sequence_number; - - // Hitung total nominal $data_nominal = [ 'allowance' => $request->allowance ?? 0, 'transport_dalkot' => $request->transport_dalkot ?? 0, @@ -274,32 +262,74 @@ class FormUpCountryController extends Controller // Validasi budget berdasarkan periode $availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear); - if(array_sum($data_nominal) > $availableBudget) { + if($totalNominal > $availableBudget) { session()->flash('error', 'Alokasi budget bulanan cabang tidak mencukupi,silahkan ajukan pada periode expense berikutnya'); return redirect()->back(); } - // Save the form data - $form = FormUpCountry::create([ - 'expense_number' => $expense_number, - 'user_id' => auth()->user()->id, - 'rayon_id' => $request->rayon_id, - 'tanggal' => $request->tanggal, - 'tujuan' => $request->tujuan, - 'jarak' => $request->jarak, - 'allowance' => $data_nominal['allowance'], - 'transport_dalkot' => $data_nominal['transport_dalkot'], - 'transport_ankot' => $data_nominal['transport_ankot'], - 'hotel' => $data_nominal['hotel'], - 'total' => array_sum($data_nominal), - 'bukti_allowance' => $filePaths['bukti_allowance'] ?? null, - 'bukti_transport_dalkot' => $filePaths['bukti_transport_dalkot'] ?? null, - 'bukti_transport_ankot' => $filePaths['bukti_transport_ankot'] ?? null, - 'bukti_hotel' => $filePaths['bukti_hotel'] ?? null, - 'uc_plan' => $filePaths['uc_plan'] ?? null, - 'account_number' => $kategori->account_number, - 'status' => 'On Progress', - ]); + $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/upcountry'; + + $ucPlanPath = null; + + if ($request->hasFile('uc_plan')) { + $ucPlan = $request->file('uc_plan'); + $filename = Str::uuid() . '.' . $ucPlan->extension(); + $ucPlanPath = $folderPath . '/' . $filename; + NextCloudHelper::uploadFile($folderPath, $ucPlan->getContent(), $filename); + } + + $attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath); + + // Generate expense number + DB::beginTransaction(); + try { + $sequence_number = FormUpCountry::withTrashed() + ->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-UPC-' . date('ym') . '%') + ->count() + 1; + + $formatted_sequence_number = str_pad($sequence_number, 4, '0', STR_PAD_LEFT); + $expense_number = $region->code . '-' . $cabang->code . '-UPC-' . date('ym') . $formatted_sequence_number; + + $form = FormUpCountry::create([ + 'expense_number' => $expense_number, + 'user_id' => auth()->user()->id, + 'rayon_id' => $request->rayon_id, + 'tanggal' => $request->tanggal, + 'tujuan' => $request->tujuan, + 'jarak' => $request->jarak, + 'allowance' => $data_nominal['allowance'], + 'transport_dalkot' => $data_nominal['transport_dalkot'], + 'transport_ankot' => $data_nominal['transport_ankot'], + 'hotel' => $data_nominal['hotel'], + 'total' => $totalNominal, + 'bukti_allowance' => null, + 'bukti_transport_dalkot' => null, + 'bukti_transport_ankot' => null, + 'bukti_hotel' => null, + 'uc_plan' => $ucPlanPath, + 'account_number' => $kategori->account_number, + 'status' => 'On Progress', + ]); + + if (!empty($attachmentsPayload)) { + $this->attachmentService->addMultipleAttachments( + $form->id, + AttachmentTableName::FORM_UP_COUNTRY, + $attachmentsPayload + ); + } + + DB::commit(); + } catch (\Throwable $th) { + DB::rollBack(); + Log::error('Failed to store Form Up Country', [ + 'message' => $th->getMessage(), + 'trace' => $th->getTraceAsString(), + ]); + + session()->flash('error', 'Terjadi kesalahan saat menyimpan data, silakan coba lagi.'); + return redirect()->back()->withInput(); + } // Send notification to MIS and Admin Region and Area Manager Cabang $roles = ['Marketing Information System', 'Admin Region', 'Area Manager Cabang']; @@ -406,19 +436,37 @@ class FormUpCountryController extends Controller ->get(); } - $form = FormUpCountry::with('rayon')->findOrfail($id); + $form = FormUpCountry::with(['rayon', 'attachments'])->findOrfail($id); if (($form->status != 'On Progress' && $form->status != 'Rejected') && $role == 'Medical Representatif') { session()->flash('error', 'You cannot edit this form because it has been approved or closed.'); return redirect()->back(); } - + + $existingAttachments = $form->attachments->map(function ($attachment) { + $filePath = $attachment->file_path; + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + + return [ + 'id' => $attachment->id, + 'file_category' => $attachment->file_category, + 'file_path' => $filePath, + 'download_url' => $filePath ? NextCloudHelper::getFileUrl($filePath) : null, + 'preview_url' => $filePath ? NextCloudHelper::getPreviewUrl($filePath) : null, + 'preview_type' => in_array($extension, ['jpg', 'jpeg', 'png']) ? 'image' : 'pdf', + 'filename' => $filePath ? basename($filePath) : null, + 'extension' => $extension, + ]; + })->values(); + return view('backend.pages.forms.upcountry.edit', [ 'rayons' => $rayonData, - 'form' => $form + 'form' => $form, + 'attachments' => $existingAttachments, + 'attachmentCategories' => $this->attachmentCategories, ]); } - public function update(Request $request, $id) + public function update(Request $request, $id) { $this->checkAuthorization(auth()->user(), ['forms.country.edit']); $role = auth()->user()->getRoleNames()[0]; @@ -432,10 +480,9 @@ class FormUpCountryController extends Controller 'transport_dalkot' => 'nullable|numeric', 'transport_ankot' => 'nullable|numeric', 'hotel' => 'nullable|numeric', - 'bukti_allowance' => ['nullable', 'file', 'max:51200'], - 'bukti_transport_dalkot' => ['nullable', 'file', 'max:51200'], - 'bukti_transport_ankot' => ['nullable', 'file', 'max:51200'], - 'bukti_hotel' => ['nullable', 'file', 'max:51200'], + 'attachments' => 'nullable|array', + 'attachments.*.file_category' => 'required|string|in:' . implode(',', $this->attachmentCategories), + 'attachments.*.file_path' => ['nullable', 'file', 'mimes:jpg,jpeg,png,pdf', 'max:5120'], 'uc_plan' => 'nullable|file|max:51200', ]); @@ -466,45 +513,19 @@ class FormUpCountryController extends Controller $periodeMonth = strtolower($periodeDate->format('F')); $periodeYear = (int) $periodeDate->format('Y'); - // Upload bukti file - $filePaths = []; $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/upcountry'; - if ($request->hasFile('bukti_allowance')) { - $file = $request->file('bukti_allowance'); - $filename = Str::uuid() . '.' . $file->extension(); - $filePaths['bukti_allowance'] = $folderPath . '/' . $filename; - NextCloudHelper::uploadFile($folderPath, $file->getContent(), $filename); - } - - if ($request->hasFile('bukti_transport_dalkot')) { - $file = $request->file('bukti_transport_dalkot'); - $filename = Str::uuid() . '.' . $file->extension(); - $filePaths['bukti_transport_dalkot'] = $folderPath . '/' . $filename; - NextCloudHelper::uploadFile($folderPath, $file->getContent(), $filename); - } - - if ($request->hasFile('bukti_transport_ankot')) { - $file = $request->file('bukti_transport_ankot'); - $filename = Str::uuid() . '.' . $file->extension(); - $filePaths['bukti_transport_ankot'] = $folderPath . '/' . $filename; - NextCloudHelper::uploadFile($folderPath, $file->getContent(), $filename); - } - - if ($request->hasFile('bukti_hotel')) { - $file = $request->file('bukti_hotel'); - $filename = Str::uuid() . '.' . $file->extension(); - $filePaths['bukti_hotel'] = $folderPath . '/' . $filename; - NextCloudHelper::uploadFile($folderPath, $file->getContent(), $filename); - } + $ucPlanPath = null; if ($request->hasFile('uc_plan')) { $file = $request->file('uc_plan'); $filename = Str::uuid() . '.' . $file->extension(); - $filePaths['uc_plan'] = $folderPath . '/' . $filename; + $ucPlanPath = $folderPath . '/' . $filename; NextCloudHelper::uploadFile($folderPath, $file->getContent(), $filename); } + $attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath); + // Hitung total nominal $data_nominal = [ 'allowance' => $request->allowance ?? 0, @@ -522,29 +543,107 @@ class FormUpCountryController extends Controller } // Save the form data - $form->update([ - 'rayon_id' => $request->rayon_id, - 'tanggal' => $request->tanggal, - 'tujuan' => $request->tujuan, - 'jarak' => $request->jarak, - 'allowance' => $data_nominal['allowance'], - 'transport_dalkot' => $data_nominal['transport_dalkot'], - 'transport_ankot' => $data_nominal['transport_ankot'], - 'hotel' => $data_nominal['hotel'], - 'total' => array_sum($data_nominal), - 'bukti_allowance' => $filePaths['bukti_allowance'] ?? $form->bukti_allowance, - 'bukti_transport_dalkot' => $filePaths['bukti_transport_dalkot'] ?? $form->bukti_transport_dalkot, - 'bukti_transport_ankot' => $filePaths['bukti_transport_ankot'] ?? $form->bukti_transport_ankot, - 'bukti_hotel' => $filePaths['bukti_hotel'] ?? $form->bukti_hotel, - 'uc_plan' => $filePaths['uc_plan'] ?? $form->uc_plan, - 'account_number' => $kategori->account_number, - ]); + DB::beginTransaction(); + try { + $form->update([ + 'rayon_id' => $request->rayon_id, + 'tanggal' => $request->tanggal, + 'tujuan' => $request->tujuan, + 'jarak' => $request->jarak, + 'allowance' => $data_nominal['allowance'], + 'transport_dalkot' => $data_nominal['transport_dalkot'], + 'transport_ankot' => $data_nominal['transport_ankot'], + 'hotel' => $data_nominal['hotel'], + 'total' => $totalNominal, + 'uc_plan' => $ucPlanPath ?? $form->uc_plan, + 'account_number' => $kategori->account_number, + ]); + + if (!empty($attachmentsPayload)) { + $this->attachmentService->addMultipleAttachments( + $form->id, + AttachmentTableName::FORM_UP_COUNTRY, + $attachmentsPayload + ); + } + + DB::commit(); + } catch (\Throwable $th) { + DB::rollBack(); + Log::error('Failed to update Form Up Country', [ + 'message' => $th->getMessage(), + 'trace' => $th->getTraceAsString(), + ]); + + session()->flash('error', 'Terjadi kesalahan saat memperbarui data, silakan coba lagi.'); + return redirect()->back()->withInput(); + } AuditTrailHelper::AddAuditTrail('Update', 'Update Record at Form Up Country (' . $form->expense_number . ')'); session()->flash('success', 'Form has been updated.'); return redirect()->back(); } + public function deleteAttachment(Request $request, $formId, $attachmentId) + { + $this->checkAuthorization(auth()->user(), ['forms.country.edit']); + + $form = FormUpCountry::findOrFail($formId); + $role = auth()->user()->getRoleNames()[0]; + + if (($form->status !== 'On Progress' && $form->status !== 'Rejected') && $role === 'Medical Representatif') { + return response()->json(['message' => 'You cannot modify attachments once the form is approved or closed.'], 403); + } + + $attachment = AttachmentForm::where('id', $attachmentId) + ->where('form_id', $form->id) + ->where('table_name', AttachmentTableName::FORM_UP_COUNTRY) + ->first(); + + if (!$attachment) { + return response()->json(['message' => 'Attachment not found.'], 404); + } + + $this->attachmentService->deleteAttachment($attachment->id); + + AuditTrailHelper::AddAuditTrail('Delete Attachment', 'Delete Attachment at Form Up Country (' . $form->expense_number . ')'); + + return response()->json(['message' => 'Attachment deleted successfully.']); + } + + protected function buildAttachmentPayload(Request $request, string $folderPath): array + { + $payload = []; + $attachmentInputs = $request->input('attachments', []); + $attachmentFiles = $request->file('attachments', []); + + foreach ($attachmentFiles as $index => $fileGroup) { + $uploadedFile = $fileGroup['file_path'] ?? null; + + if (!$uploadedFile) { + continue; + } + + $category = $attachmentInputs[$index]['file_category'] ?? null; + + if (!$category) { + continue; + } + + $filename = Str::uuid() . '.' . $uploadedFile->extension(); + $filePath = $folderPath . '/' . $filename; + + NextCloudHelper::uploadFile($folderPath, $uploadedFile->getContent(), $filename); + + $payload[] = [ + 'file_category' => $category, + 'file_path' => $filePath, + ]; + } + + return $payload; + } + public function approve(Request $request, $id) { $this->checkAuthorization(auth()->user(), ['approval.approve']); diff --git a/resources/views/backend/components/attachment-preview-modal.blade.php b/resources/views/backend/components/attachment-preview-modal.blade.php new file mode 100644 index 0000000..9c35f6f --- /dev/null +++ b/resources/views/backend/components/attachment-preview-modal.blade.php @@ -0,0 +1,20 @@ +@props([ + 'modalId', + 'title' => 'Attachment Preview', +]) + +
diff --git a/resources/views/backend/pages/forms/upcountry/create.blade.php b/resources/views/backend/pages/forms/upcountry/create.blade.php index 8bc0616..e7e95f2 100644 --- a/resources/views/backend/pages/forms/upcountry/create.blade.php +++ b/resources/views/backend/pages/forms/upcountry/create.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.app') +@extends('layouts.app') @section('title') Dashboard @@ -74,10 +74,6 @@ -+ Allowed files: JPG, JPEG, PNG, PDF. Max size 5 MB per file. +
+| File Category | +Attachment | +Preview | +Action | +
|---|---|---|---|
| Belum ada lampiran ditambahkan. | +|||
Preview, download, or delete attachments that were previously uploaded.
+| File Category | +Filename | +Preview | +Download | +Action | +
|---|---|---|---|---|
| {{ $categoryLabel }} | +{{ $attachment['filename'] ?? basename($attachment['file_path']) }} | ++ + | ++ + Download + + | ++ + | +
| Belum ada lampiran tersimpan. | +||||
+ Allowed files: JPG, JPEG, PNG, PDF. Max size 5 MB per file. +
+| File Category | +Attachment | +Preview | +Action | +
|---|---|---|---|
| Belum ada lampiran baru. | +|||
| Kategori | +Nama File | +Preview | +Download | +
|---|---|---|---|
| {{ $categoryLabel }} | +{{ $attachment['filename'] ?? basename($attachment['file_path']) }} | ++ + | ++ + Download + + | +
| Tidak ada lampiran. | +|||