From 087ad1c7b9033e56ce1de493ac82af946e6c7e33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Mar 2026 20:07:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[approval]=20=EC=A7=80=EC=B6=9C?= =?UTF-8?q?=EA=B2=B0=EC=9D=98=EC=84=9C=20=EC=B2=A8=EB=B6=80=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C/=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첨부파일 업로드 API (GCS 연동, 20MB 제한) - 첨부파일 삭제/다운로드 API 추가 - 지출결의서 폼에 드래그&드롭 멀티 파일 업로드 UI 추가 - ApprovalService에 linkAttachments 메서드 추가 (is_temp 플래그 관리) - show 페이지에 첨부파일 목록 표시 및 다운로드 링크 - 지출부서 기본값 '본사', 로그인 사용자 이름 자동입력, 제목 필드 제거 --- .../Api/Admin/ApprovalApiController.php | 98 ++++++++++ app/Services/ApprovalService.php | 42 +++++ resources/views/approvals/create.blade.php | 3 + resources/views/approvals/edit.blade.php | 20 ++ .../partials/_expense-form.blade.php | 173 ++++++++++++++++-- .../partials/_expense-show.blade.php | 22 +++ routes/api.php | 3 + 7 files changed, 348 insertions(+), 13 deletions(-) diff --git a/app/Http/Controllers/Api/Admin/ApprovalApiController.php b/app/Http/Controllers/Api/Admin/ApprovalApiController.php index 06713e75..26ba48d7 100644 --- a/app/Http/Controllers/Api/Admin/ApprovalApiController.php +++ b/app/Http/Controllers/Api/Admin/ApprovalApiController.php @@ -3,9 +3,13 @@ namespace App\Http\Controllers\Api\Admin; use App\Http\Controllers\Controller; +use App\Models\Boards\File; use App\Services\ApprovalService; +use App\Services\GoogleCloudStorageService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class ApprovalApiController extends Controller { @@ -100,6 +104,8 @@ public function store(Request $request): JsonResponse 'steps' => 'nullable|array', 'steps.*.user_id' => 'required_with:steps|exists:users,id', 'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference', + 'attachment_file_ids' => 'nullable|array', + 'attachment_file_ids.*' => 'integer', ]); $approval = $this->service->createApproval($request->all()); @@ -124,6 +130,8 @@ public function update(Request $request, int $id): JsonResponse 'steps' => 'nullable|array', 'steps.*.user_id' => 'required_with:steps|exists:users,id', 'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference', + 'attachment_file_ids' => 'nullable|array', + 'attachment_file_ids.*' => 'integer', ]); try { @@ -455,4 +463,94 @@ public function badgeCounts(): JsonResponse return response()->json(['success' => true, 'data' => $counts]); } + + // ========================================================================= + // 첨부파일 + // ========================================================================= + + /** + * 첨부파일 업로드 + */ + public function uploadFile(Request $request, GoogleCloudStorageService $gcs): JsonResponse + { + $request->validate([ + 'file' => 'required|file|max:20480', + ]); + + $file = $request->file('file'); + $tenantId = session('selected_tenant_id'); + $storedName = Str::random(40).'.'.$file->getClientOriginalExtension(); + $storagePath = "approvals/{$tenantId}/{$storedName}"; + + Storage::disk('tenant')->put($storagePath, file_get_contents($file)); + + $gcsUri = null; + $gcsObjectName = null; + if ($gcs->isAvailable()) { + $gcsObjectName = $storagePath; + $gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName); + } + + $fileRecord = File::create([ + 'tenant_id' => $tenantId, + 'document_type' => 'approval_attachment', + 'original_name' => $file->getClientOriginalName(), + 'stored_name' => $storedName, + 'file_path' => $storagePath, + 'mime_type' => $file->getMimeType(), + 'file_size' => $file->getSize(), + 'file_type' => strtolower($file->getClientOriginalExtension()), + 'gcs_object_name' => $gcsObjectName, + 'gcs_uri' => $gcsUri, + 'is_temp' => true, + 'uploaded_by' => auth()->id(), + 'created_by' => auth()->id(), + ]); + + return response()->json([ + 'success' => true, + 'data' => [ + 'id' => $fileRecord->id, + 'name' => $fileRecord->original_name, + 'size' => $fileRecord->file_size, + 'mime_type' => $fileRecord->mime_type, + ], + ]); + } + + /** + * 첨부파일 삭제 + */ + public function deleteFile(int $fileId): JsonResponse + { + $file = File::where('id', $fileId) + ->where('uploaded_by', auth()->id()) + ->first(); + + if (! $file) { + return response()->json(['success' => false, 'message' => '파일을 찾을 수 없습니다.'], 404); + } + + if ($file->existsInStorage()) { + Storage::disk('tenant')->delete($file->file_path); + } + + $file->forceDelete(); + + return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']); + } + + /** + * 첨부파일 다운로드 + */ + public function downloadFile(int $fileId) + { + $file = File::findOrFail($fileId); + + if (Storage::disk('tenant')->exists($file->file_path)) { + return Storage::disk('tenant')->download($file->file_path, $file->original_name); + } + + abort(404, '파일을 찾을 수 없습니다.'); + } } diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php index 8dda23a0..9274a388 100644 --- a/app/Services/ApprovalService.php +++ b/app/Services/ApprovalService.php @@ -6,6 +6,7 @@ use App\Models\Approvals\ApprovalForm; use App\Models\Approvals\ApprovalLine; use App\Models\Approvals\ApprovalStep; +use App\Models\Boards\File; use App\Models\User; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; @@ -138,6 +139,11 @@ public function createApproval(array $data): Approval $this->saveApprovalSteps($approval, $data['steps']); } + // 첨부파일 연결 + if (! empty($data['attachment_file_ids'])) { + $this->linkAttachments($approval, $data['attachment_file_ids']); + } + return $approval->load(['form', 'drafter', 'steps.approver']); }); } @@ -170,6 +176,11 @@ public function updateApproval(int $id, array $data): Approval $this->saveApprovalSteps($approval, $data['steps']); } + // 첨부파일 갱신 + if (array_key_exists('attachment_file_ids', $data)) { + $this->linkAttachments($approval, $data['attachment_file_ids'] ?? []); + } + return $approval->load(['form', 'drafter', 'steps.approver']); }); } @@ -669,6 +680,37 @@ public function saveApprovalSteps(Approval $approval, array $steps): void } } + /** + * 첨부파일 연결 + */ + public function linkAttachments(Approval $approval, array $fileIds): void + { + $attachments = []; + + if (! empty($fileIds)) { + $files = File::whereIn('id', $fileIds) + ->where('uploaded_by', auth()->id()) + ->get(); + + foreach ($files as $file) { + $attachments[] = [ + 'id' => $file->id, + 'name' => $file->original_name, + 'size' => $file->file_size, + 'mime_type' => $file->mime_type, + ]; + + $file->update([ + 'is_temp' => false, + 'document_id' => $approval->id, + 'document_type' => 'approval', + ]); + } + } + + $approval->update(['attachments' => $attachments]); + } + /** * 미처리 건수 (뱃지용) */ diff --git a/resources/views/approvals/create.blade.php b/resources/views/approvals/create.blade.php index a774d23a..e2e70d0c 100644 --- a/resources/views/approvals/create.blade.php +++ b/resources/views/approvals/create.blade.php @@ -432,10 +432,12 @@ function applyBodyTemplate(formId) { } let expenseContent = {}; + let attachmentFileIds = []; if (isExpenseForm) { const expenseEl = document.getElementById('expense-form-container'); if (expenseEl && expenseEl._x_dataStack) { expenseContent = expenseEl._x_dataStack[0].getFormData(); + attachmentFileIds = expenseEl._x_dataStack[0].getFileIds(); } } @@ -444,6 +446,7 @@ function applyBodyTemplate(formId) { title: title, body: isExpenseForm ? null : getBodyContent(), content: isExpenseForm ? expenseContent : {}, + attachment_file_ids: attachmentFileIds, is_urgent: document.getElementById('is_urgent').checked, steps: steps, }; diff --git a/resources/views/approvals/edit.blade.php b/resources/views/approvals/edit.blade.php index e5308edb..5ed73930 100644 --- a/resources/views/approvals/edit.blade.php +++ b/resources/views/approvals/edit.blade.php @@ -105,8 +105,25 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline- {{-- 지출결의서 전용 폼 --}} + @php + $existingFiles = []; + if (!empty($approval->attachments)) { + $fileIds = collect($approval->attachments)->pluck('id')->filter(); + if ($fileIds->isNotEmpty()) { + $existingFiles = \App\Models\Boards\File::whereIn('id', $fileIds) + ->get() + ->map(fn($f) => [ + 'id' => $f->id, + 'name' => $f->original_name, + 'size' => $f->file_size, + 'mime_type' => $f->mime_type, + ])->toArray(); + } + } + @endphp @include('approvals.partials._expense-form', [ 'initialData' => $approval->content ?? [], + 'initialFiles' => $existingFiles, ]) {{-- 액션 버튼 --}} @@ -480,10 +497,12 @@ function applyBodyTemplate(formId) { } let expenseContent = {}; + let attachmentFileIds = []; if (isExpenseForm) { const expenseEl = document.getElementById('expense-form-container'); if (expenseEl && expenseEl._x_dataStack) { expenseContent = expenseEl._x_dataStack[0].getFormData(); + attachmentFileIds = expenseEl._x_dataStack[0].getFileIds(); } } @@ -492,6 +511,7 @@ function applyBodyTemplate(formId) { title: title, body: isExpenseForm ? null : getBodyContent(), content: isExpenseForm ? expenseContent : {}, + attachment_file_ids: attachmentFileIds, is_urgent: document.getElementById('is_urgent').checked, steps: steps, }; diff --git a/resources/views/approvals/partials/_expense-form.blade.php b/resources/views/approvals/partials/_expense-form.blade.php index caafdbdf..0bad50d7 100644 --- a/resources/views/approvals/partials/_expense-form.blade.php +++ b/resources/views/approvals/partials/_expense-form.blade.php @@ -2,13 +2,16 @@ 지출결의서 전용 폼 (Alpine.js) Props: $initialData (array|null) - 기존 content JSON (edit 시) + $initialFiles (array|null) - 기존 첨부파일 [{id, name, size, mime_type}] (edit 시) --}} @php $initialData = $initialData ?? []; + $initialFiles = $initialFiles ?? []; + $userName = auth()->user()->name ?? ''; @endphp -
- - -
- {{-- 내역 테이블 --}}
@@ -152,15 +149,65 @@ class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">
{{-- 첨부서류 메모 --}} -
+
+ + {{-- 첨부파일 업로드 --}} +
+ + + {{-- 드래그 앤 드롭 영역 --}} +
+ +
+ + + + 파일을 드래그하거나 클릭하여 선택 (최대 20MB) +
+
+ + {{-- 업로드 중 프로그레스 --}} +
+
+
+
+
+ +
+
+ + {{-- 업로드된 파일 목록 --}} +
+ +
+
diff --git a/resources/views/approvals/partials/_expense-show.blade.php b/resources/views/approvals/partials/_expense-show.blade.php index 9f686f79..11c3afd7 100644 --- a/resources/views/approvals/partials/_expense-show.blade.php +++ b/resources/views/approvals/partials/_expense-show.blade.php @@ -96,4 +96,26 @@
{{ $content['attachment_memo'] }}
@endif + + {{-- 첨부파일 --}} + @if(!empty($approval->attachments)) +
+ 첨부파일 +
+ @foreach($approval->attachments as $file) +
+ + + + + {{ $file['name'] ?? '파일' }} + + + {{ isset($file['size']) ? number_format($file['size'] / 1024, 1) . 'KB' : '' }} + +
+ @endforeach +
+
+ @endif diff --git a/routes/api.php b/routes/api.php index 87af0760..38476164 100644 --- a/routes/api.php +++ b/routes/api.php @@ -959,6 +959,9 @@ Route::delete('/lines/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'destroyLine'])->name('lines.destroy'); Route::get('/forms', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'forms'])->name('forms'); Route::get('/badge-counts', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'badgeCounts'])->name('badge-counts'); + Route::post('/upload-file', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'uploadFile'])->name('upload-file'); + Route::delete('/files/{fileId}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'deleteFile'])->name('delete-file'); + Route::get('/files/{fileId}/download', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'downloadFile'])->name('download-file'); // CRUD Route::post('/', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'store'])->name('store');