diff --git a/app/Http/Controllers/Forms/FormVehicleController.php b/app/Http/Controllers/Forms/FormVehicleController.php index a29c5e5..b7d1b36 100644 --- a/app/Http/Controllers/Forms/FormVehicleController.php +++ b/app/Http/Controllers/Forms/FormVehicleController.php @@ -30,9 +30,38 @@ use App\Helpers\OutstandingHelper; use Illuminate\Support\Facades\Log; use App\Services\BrevoService; use Carbon\Carbon; +use Illuminate\Validation\ValidationException; +use App\Services\AttachmentService; +use App\Enums\AttachmentTableName; +use App\Models\AttachmentForm; +use Illuminate\Support\Facades\Validator; class FormVehicleController extends Controller { + protected AttachmentService $attachmentService; + + /** + * Standardized attachment categories for Vehicle Running Cost. + */ + protected array $attachmentCategories = [ + 'bukti_total', + 'bukti_invoice', + 'bukti_bbm', + 'bukti_service', + 'bukti_lainnya', + ]; + + /** + * Blocked file extensions (case insensitive) to guard against dangerous uploads. + */ + protected array $blockedExtensions = ['exe', 'bat', 'sh', 'cmd', 'dll', 'msi']; + + public function __construct(AttachmentService $attachmentService) + { + $this->attachmentService = $attachmentService; + view()->share('vehicleAttachmentCategories', $this->attachmentCategories); + } + public function index() { $this->checkAuthorization(auth()->user(), ['forms.vehicle.view']); @@ -120,21 +149,27 @@ class FormVehicleController extends Controller { $this->checkAuthorization(auth()->user(), ['forms.vehicle.view']); - $form = FormVehicleRunningCost::with('user')->findOrFail($id); + $form = FormVehicleRunningCost::with(['user', 'attachments'])->findOrFail($id); + $attachments = $this->formatAttachmentCollection($form); + $form->bukti_total = $form->bukti_total ? NextCloudHelper::getFileUrl($form->bukti_total) : null; - return response()->json($form); + return response()->json(array_merge($form->toArray(), [ + 'attachments' => $attachments, + ])); } public function view($id) { $this->checkAuthorization(auth()->user(), ['forms.vehicle.view']); - $form = FormVehicleRunningCost::with('user')->findOrFail($id); + $form = FormVehicleRunningCost::with(['user', 'attachments'])->findOrFail($id); + $attachments = $this->formatAttachmentCollection($form); $form->bukti_total = $form->bukti_total ? NextCloudHelper::getFileUrl($form->bukti_total) : null; return view('backend.pages.forms.vehicle.view', [ 'form' => $form, + 'attachments' => $attachments, ]); } @@ -142,13 +177,15 @@ class FormVehicleController extends Controller { $this->checkAuthorization(auth()->user(), ['forms.vehicle.create']); - return view('backend.pages.forms.vehicle.create'); + return view('backend.pages.forms.vehicle.create', [ + 'attachmentCategories' => $this->attachmentCategories, + ]); } public function store(Request $request) { $this->checkAuthorization(auth()->user(), ['forms.vehicle.create']); - $request->validate([ + $validator = Validator::make($request->all(), [ 'tanggal' => 'required', 'type' => 'required', 'liter' => 'nullable|numeric|min:0', @@ -157,9 +194,37 @@ class FormVehicleController extends Controller 'tipe_bensin' => 'nullable|in:pertalite,pertamax', 'nopol' => 'nullable', 'keterangan' => 'required', - 'bukti_total' => ['nullable', 'file', 'max:51200'], + 'attachments' => 'nullable|array', + 'attachments.*.file_category' => 'required|string|in:' . implode(',', $this->attachmentCategories), + 'attachments.*.file_path' => [ + 'nullable', + 'file', + 'max:10240', // 10 MB + 'mimetypes:image/jpeg,image/png,image/webp,application/pdf,text/plain,text/csv,application/zip,application/x-zip-compressed,application/x-7z-compressed,application/vnd.rar,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/octet-stream' + ], ]); + $validator->after(function ($validator) use ($request) { + $inputAttachments = $request->input('attachments', []); + $fileAttachments = $request->file('attachments', []); + + foreach ($inputAttachments as $index => $input) { + $category = $input['file_category'] ?? null; + $file = $fileAttachments[$index]['file_path'] ?? null; + + if ($category && !$file) { + $validator->errors()->add("attachments.{$index}.file_path", 'Attachment file is required.'); + continue; + } + + if ($file && $this->isBlockedExtension($file->getClientOriginalExtension())) { + $validator->errors()->add("attachments.{$index}.file_path", 'File type is not allowed.'); + } + } + }); + + $validator->validate(); + $tanggal = Carbon::parse($request->tanggal); $startDay = (int) env('STARTING_DATE', 11); $closingDay = (int) env('CLOSING_DATE', 10); @@ -207,43 +272,54 @@ class FormVehicleController extends Controller $region = Region::where('id', $cabang->region_id)->first(); $kategori = Kategori::where('name', 'Vehicle Running Cost')->first(); - $filePaths = []; $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/vehicle'; - if ($request->hasFile('bukti_total')) { - $bukti_total = $request->file('bukti_total'); - $filename = Str::uuid() . '.' . $bukti_total->extension(); - - $filePaths['bukti_total'] = $folderPath . '/' . $filename; - - NextCloudHelper::uploadFile( - $folderPath, - $bukti_total->getContent(), - $filename - ); - } + $attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath); // Generate sequence number and expense number $sequence_number = FormVehicleRunningCost::withTrashed()->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-VHC-' . date('ym') . '%')->count() + 1; $formatted_sequence_number = str_pad($sequence_number, 4, '0', STR_PAD_LEFT); $expense_number = $region->code . '-' . $cabang->code . '-VHC-' . date('ym') . $formatted_sequence_number; - // Save the form data - $form = FormVehicleRunningCost::create([ - 'expense_number' => $expense_number, - 'user_id' => auth()->user()->id, - 'tanggal' => $request->tanggal, - 'type' => $request->type, - 'liter' => $request->liter ?? 0, - 'total' => $request->total, - 'jarak' => $request->jarak ?? 0, - 'tipe_bensin' => $request->tipe_bensin ?? '-', - 'nopol' => $request->nopol ?? '-', - 'keterangan' => $request->keterangan, - 'bukti_total' => $filePaths['bukti_total'] ?? null, - 'account_number' => $kategori->account_number, - 'status' => 'On Progress', - ]); + DB::beginTransaction(); + try { + $form = FormVehicleRunningCost::create([ + 'expense_number' => $expense_number, + 'user_id' => auth()->user()->id, + 'tanggal' => $request->tanggal, + 'type' => $request->type, + 'liter' => $request->liter ?? 0, + 'total' => $request->total, + 'jarak' => $request->jarak ?? 0, + 'tipe_bensin' => $request->tipe_bensin ?? '-', + 'nopol' => $request->nopol ?? '-', + 'keterangan' => $request->keterangan, + 'bukti_total' => null, + 'account_number' => $kategori->account_number, + 'status' => 'On Progress', + ]); + + if (!empty($attachmentsPayload)) { + $this->attachmentService->addMultipleAttachments( + $form->id, + AttachmentTableName::FORM_VEHICLE_RUNNING_COST, + $attachmentsPayload + ); + } + + DB::commit(); + } catch (\Throwable $th) { + DB::rollBack(); + Log::error('Failed to store Vehicle Running Cost form', [ + 'message' => $th->getMessage(), + 'trace' => $th->getTraceAsString(), + ]); + + session()->flash('error', 'Terjadi kesalahan saat menyimpan data, silakan coba lagi.'); + return redirect()->back()->withInput(); + } + + $form->load('user'); $name = auth()->user()->name; $tanggal = $form->tanggal; @@ -330,14 +406,16 @@ class FormVehicleController extends Controller $this->checkAuthorization(auth()->user(), ['forms.vehicle.edit']); $role = auth()->user()->getRoleNames()[0]; - $form = FormVehicleRunningCost::findOrfail($id); + $form = FormVehicleRunningCost::with('attachments')->findOrfail($id); if($form->status != 'On Progress' && $role == 'Medical Representatif') { session()->flash('error', 'You cannot edit this form because it has been approved or rejected.'); return redirect()->back(); } return view('backend.pages.forms.vehicle.edit', [ - 'form' => $form + 'form' => $form, + 'attachments' => $this->formatAttachmentCollection($form), + 'attachmentCategories' => $this->attachmentCategories, ]); } @@ -345,7 +423,7 @@ class FormVehicleController extends Controller { $this->checkAuthorization(auth()->user(), ['forms.vehicle.edit']); $role = auth()->user()->getRoleNames()[0]; - $form = FormVehicleRunningCost::findOrFail($id); + $form = FormVehicleRunningCost::with('attachments')->findOrFail($id); // Ambil cabang ID dari user yang mengisi form $cabang_id = UserHasCabang::where('user_id', $form->user_id)->first()?->cabang_id; @@ -371,7 +449,7 @@ class FormVehicleController extends Controller $availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear); // Validasi input - $request->validate([ + $validator = Validator::make($request->all(), [ 'tanggal' => 'required', 'type' => 'required', 'liter' => 'nullable|numeric|min:0', @@ -380,9 +458,37 @@ class FormVehicleController extends Controller 'tipe_bensin' => 'nullable|in:pertalite,pertamax', 'nopol' => 'nullable', 'keterangan' => 'required', - 'bukti_total' => ['nullable', 'file', 'max:51200'], + 'attachments' => 'nullable|array', + 'attachments.*.file_category' => 'required|string|in:' . implode(',', $this->attachmentCategories), + 'attachments.*.file_path' => [ + 'nullable', + 'file', + 'max:10240', + 'mimetypes:image/jpeg,image/png,image/webp,application/pdf,text/plain,text/csv,application/zip,application/x-zip-compressed,application/x-7z-compressed,application/vnd.rar,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/octet-stream' + ], ]); + $validator->after(function ($validator) use ($request) { + $inputAttachments = $request->input('attachments', []); + $fileAttachments = $request->file('attachments', []); + + foreach ($inputAttachments as $index => $input) { + $category = $input['file_category'] ?? null; + $file = $fileAttachments[$index]['file_path'] ?? null; + + if ($category && !$file) { + $validator->errors()->add("attachments.{$index}.file_path", 'Attachment file is required.'); + continue; + } + + if ($file && $this->isBlockedExtension($file->getClientOriginalExtension())) { + $validator->errors()->add("attachments.{$index}.file_path", 'File type is not allowed.'); + } + } + }); + + $validator->validate(); + if($form->status != 'On Progress' && $role == 'Medical Representatif') { session()->flash('error', 'You cannot edit this form because it has been approved or rejected.'); return redirect()->back(); @@ -392,42 +498,79 @@ class FormVehicleController extends Controller $region = Region::where('id', $cabang->region_id)->first(); $kategori = Kategori::where('name', 'Vehicle Running Cost')->first(); - $filePaths = []; $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/vehicle'; - if ($request->hasFile('bukti_total')) { - $bukti_total = $request->file('bukti_total'); - $filename = Str::uuid() . '.' . $bukti_total->extension(); + $attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath); - $filePaths['bukti_total'] = $folderPath . '/' . $filename; + DB::beginTransaction(); + try { + $form->update([ + 'tanggal' => $request->tanggal, + 'type' => $request->type, + 'liter' => $request->liter ?? 0, + 'total' => $request->total, + 'jarak' => $request->jarak ?? 0, + 'tipe_bensin' => $request->tipe_bensin ?? '-', + 'nopol' => $request->nopol ?? '-', + 'keterangan' => $request->keterangan, + 'bukti_total' => $form->bukti_total, + 'account_number' => $kategori->account_number, + 'status' => 'On Progress', + ]); - NextCloudHelper::uploadFile( - $folderPath, - $bukti_total->getContent(), - $filename - ); + if (!empty($attachmentsPayload)) { + $this->attachmentService->addMultipleAttachments( + $form->id, + AttachmentTableName::FORM_VEHICLE_RUNNING_COST, + $attachmentsPayload + ); + } + + DB::commit(); + } catch (\Throwable $th) { + DB::rollBack(); + Log::error('Failed to update Vehicle Running Cost form', [ + 'form_id' => $form->id, + 'message' => $th->getMessage(), + 'trace' => $th->getTraceAsString(), + ]); + + session()->flash('error', 'Terjadi kesalahan saat memperbarui data, silakan coba lagi.'); + return redirect()->back()->withInput(); } - // Save the form data - $form->update([ - 'tanggal' => $request->tanggal, - 'type' => $request->type, - 'liter' => $request->liter ?? 0, - 'total' => $request->total, - 'jarak' => $request->jarak ?? 0, - 'tipe_bensin' => $request->tipe_bensin ?? '-', - 'nopol' => $request->nopol ?? '-', - 'keterangan' => $request->keterangan, - 'bukti_total' => $filePaths['bukti_total'] ?? $form->bukti_total, - 'account_number' => $kategori->account_number, - 'status' => 'On Progress', - ]); - AuditTrailHelper::AddAuditTrail('Update', 'Update Record at Form Vehicle Running Cost (' . $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.vehicle.edit']); + + $form = FormVehicleRunningCost::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_VEHICLE_RUNNING_COST) + ->first(); + + if (!$attachment) { + return response()->json(['message' => 'Attachment not found.'], 404); + } + + $this->attachmentService->deleteAttachment($attachment->id); + + AuditTrailHelper::AddAuditTrail('Delete Attachment', 'Delete Attachment at Vehicle Running Cost (' . $form->expense_number . ')'); + + return response()->json(['message' => 'Attachment deleted successfully.']); + } + public function approve($id) { $this->checkAuthorization(auth()->user(), ['approval.approve']); @@ -820,7 +963,7 @@ class FormVehicleController extends Controller { $this->checkAuthorization(auth()->user(), ['forms.vehicle.delete']); $role = auth()->user()->getRoleNames()[0]; - + $form = FormVehicleRunningCost::findOrfail($id); if($form->status != 'On Progress' && $role == 'Medical Representatif') { session()->flash('error', 'You cannot edit this form because it has been approved or rejected.'); @@ -834,4 +977,106 @@ class FormVehicleController extends Controller session()->flash('success', 'Form has been deleted.'); return redirect()->back(); } + + protected function formatAttachmentCollection(FormVehicleRunningCost $form): array + { + $attachments = $form->attachments->map(function ($attachment) { + return $this->formatAttachmentData( + $attachment->id, + $attachment->file_category, + $attachment->file_path + ); + }); + + if ($attachments->isEmpty() && $form->bukti_total) { + $attachments->push($this->formatAttachmentData(null, 'bukti_total', $form->bukti_total)); + } + + return $attachments->values()->all(); + } + + protected function formatAttachmentData(?int $id, ?string $category, string $filePath): array + { + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + $previewType = $this->determinePreviewType($extension); + + return [ + 'id' => $id, + 'file_category' => $category, + 'file_path' => $filePath, + 'download_url' => NextCloudHelper::getFileUrl($filePath), + 'preview_url' => in_array($previewType, ['image', 'pdf'], true) ? NextCloudHelper::getPreviewUrl($filePath) : null, + 'preview_type' => $previewType, + 'filename' => basename($filePath), + 'extension' => $extension, + 'can_delete' => !is_null($id), + ]; + } + + protected function determinePreviewType(?string $extension): string + { + $extension = strtolower((string) $extension); + $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; + + if (in_array($extension, $imageExtensions, true)) { + return 'image'; + } + + if ($extension === 'pdf') { + return 'pdf'; + } + + return 'other'; + } + + protected function isBlockedExtension(?string $extension): bool + { + return in_array(strtolower((string) $extension), $this->blockedExtensions, true); + } + + protected function buildAttachmentPayload(Request $request, string $folderPath): array + { + $payload = []; + $inputs = $request->input('attachments', []); + $files = $request->file('attachments', []); + + foreach ($inputs as $index => $input) { + $category = $input['file_category'] ?? null; + $file = $files[$index]['file_path'] ?? null; + + if (!$category || !$file) { + continue; + } + + $extension = strtolower($file->getClientOriginalExtension()); + + if ($this->isBlockedExtension($extension)) { + throw ValidationException::withMessages([ + "attachments.{$index}.file_path" => 'File type is not allowed.', + ]); + } + + $filename = Str::uuid() . '.' . $extension; + $filePath = $folderPath . '/' . $filename; + + $uploadResponse = NextCloudHelper::uploadFile( + $folderPath, + $file->getContent(), + $filename + ); + + if (is_array($uploadResponse) && isset($uploadResponse['success']) && $uploadResponse['success'] === false) { + throw ValidationException::withMessages([ + "attachments.{$index}.file_path" => $uploadResponse['message'] ?? 'Failed to upload attachment.', + ]); + } + + $payload[] = [ + 'file_category' => $category, + 'file_path' => $filePath, + ]; + } + + return $payload; + } } diff --git a/resources/views/backend/pages/forms/upcountry/create.blade.php b/resources/views/backend/pages/forms/upcountry/create.blade.php index e7e95f2..18867ba 100644 --- a/resources/views/backend/pages/forms/upcountry/create.blade.php +++ b/resources/views/backend/pages/forms/upcountry/create.blade.php @@ -247,7 +247,7 @@ const row = $(`