From beff95b4e169332a0bcdb598f456ec2a5d216030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Mon, 9 Feb 2026 21:25:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EA=B3=B5=EC=82=AC=ED=98=84=EC=9E=A5=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=EB=8C=80=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모델, 서비스, 컨트롤러, React SPA 뷰, 라우트 추가 GCS 업로드/다운로드, 드래그앤드롭 사진 관리 Co-Authored-By: Claude Opus 4.6 --- .../Juil/ConstructionSitePhotoController.php | 236 ++++++ app/Models/Juil/ConstructionSitePhoto.php | 65 ++ app/Services/ConstructionSitePhotoService.php | 135 ++++ .../views/juil/construction-photos.blade.php | 678 ++++++++++++++++++ routes/web.php | 14 + 5 files changed, 1128 insertions(+) create mode 100644 app/Http/Controllers/Juil/ConstructionSitePhotoController.php create mode 100644 app/Models/Juil/ConstructionSitePhoto.php create mode 100644 app/Services/ConstructionSitePhotoService.php create mode 100644 resources/views/juil/construction-photos.blade.php diff --git a/app/Http/Controllers/Juil/ConstructionSitePhotoController.php b/app/Http/Controllers/Juil/ConstructionSitePhotoController.php new file mode 100644 index 00000000..f48a99b0 --- /dev/null +++ b/app/Http/Controllers/Juil/ConstructionSitePhotoController.php @@ -0,0 +1,236 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('juil.construction-photos.index')); + } + + return view('juil.construction-photos'); + } + + public function list(Request $request): JsonResponse + { + $params = $request->only(['search', 'date_from', 'date_to', 'per_page']); + $photos = $this->service->getList($params); + + return response()->json([ + 'success' => true, + 'data' => $photos, + ]); + } + + public function show(int $id): JsonResponse + { + $photo = ConstructionSitePhoto::with('user')->find($id); + + if (!$photo) { + return response()->json([ + 'success' => false, + 'message' => '사진대지를 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $photo, + ]); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'site_name' => 'required|string|max:200', + 'work_date' => 'required|date', + 'description' => 'nullable|string|max:2000', + ]); + + $photo = $this->service->create($validated); + + return response()->json([ + 'success' => true, + 'message' => '사진대지가 등록되었습니다.', + 'data' => $photo, + ], 201); + } + + public function uploadPhoto(Request $request, int $id): JsonResponse + { + $photo = ConstructionSitePhoto::find($id); + + if (!$photo) { + return response()->json([ + 'success' => false, + 'message' => '사진대지를 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'type' => 'required|in:before,during,after', + 'photo' => 'required|image|mimes:jpeg,jpg,png,webp|max:10240', + ]); + + $result = $this->service->uploadPhoto($photo, $request->file('photo'), $validated['type']); + + if (!$result) { + return response()->json([ + 'success' => false, + 'message' => '사진 업로드에 실패했습니다.', + ], 500); + } + + return response()->json([ + 'success' => true, + 'message' => '사진이 업로드되었습니다.', + 'data' => $photo->fresh(), + ]); + } + + public function update(Request $request, int $id): JsonResponse + { + $photo = ConstructionSitePhoto::find($id); + + if (!$photo) { + return response()->json([ + 'success' => false, + 'message' => '사진대지를 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'site_name' => 'required|string|max:200', + 'work_date' => 'required|date', + 'description' => 'nullable|string|max:2000', + ]); + + $photo = $this->service->update($photo, $validated); + + return response()->json([ + 'success' => true, + 'message' => '사진대지가 수정되었습니다.', + 'data' => $photo, + ]); + } + + public function destroy(int $id): JsonResponse + { + $photo = ConstructionSitePhoto::find($id); + + if (!$photo) { + return response()->json([ + 'success' => false, + 'message' => '사진대지를 찾을 수 없습니다.', + ], 404); + } + + $this->service->delete($photo); + + return response()->json([ + 'success' => true, + 'message' => '사진대지가 삭제되었습니다.', + ]); + } + + public function deletePhoto(int $id, string $type): JsonResponse + { + $photo = ConstructionSitePhoto::find($id); + + if (!$photo) { + return response()->json([ + 'success' => false, + 'message' => '사진대지를 찾을 수 없습니다.', + ], 404); + } + + if (!in_array($type, ['before', 'during', 'after'])) { + return response()->json([ + 'success' => false, + 'message' => '올바르지 않은 사진 유형입니다.', + ], 422); + } + + $this->service->deletePhotoByType($photo, $type); + + return response()->json([ + 'success' => true, + 'message' => '사진이 삭제되었습니다.', + 'data' => $photo->fresh(), + ]); + } + + public function downloadPhoto(Request $request, int $id, string $type): Response|JsonResponse + { + $photo = ConstructionSitePhoto::find($id); + + if (!$photo) { + return response()->json([ + 'success' => false, + 'message' => '사진대지를 찾을 수 없습니다.', + ], 404); + } + + if (!in_array($type, ['before', 'during', 'after'])) { + return response()->json([ + 'success' => false, + 'message' => '올바르지 않은 사진 유형입니다.', + ], 422); + } + + $path = $photo->{$type . '_photo_path'}; + + if (!$path) { + return response()->json([ + 'success' => false, + 'message' => '파일을 찾을 수 없습니다.', + ], 404); + } + + $googleCloudService = app(GoogleCloudService::class); + $content = $googleCloudService->downloadFromStorage($path); + + if (!$content) { + return response()->json([ + 'success' => false, + 'message' => '파일 다운로드에 실패했습니다.', + ], 500); + } + + $extension = pathinfo($path, PATHINFO_EXTENSION) ?: 'jpg'; + $mimeType = 'image/' . ($extension === 'jpg' ? 'jpeg' : $extension); + + $typeLabel = match ($type) { + 'before' => '작업전', + 'during' => '작업중', + 'after' => '작업후', + }; + $safeTitle = preg_replace('/[\/\\\\:*?"<>|]/', '_', $photo->site_name); + $filename = "{$safeTitle}_{$typeLabel}.{$extension}"; + $encodedFilename = rawurlencode($filename); + + $disposition = $request->query('inline') ? 'inline' : 'attachment'; + + return response($content) + ->header('Content-Type', $mimeType) + ->header('Content-Length', strlen($content)) + ->header('Accept-Ranges', 'bytes') + ->header('Content-Disposition', "{$disposition}; filename=\"{$encodedFilename}\"; filename*=UTF-8''{$encodedFilename}") + ->header('Cache-Control', 'private, max-age=3600'); + } +} diff --git a/app/Models/Juil/ConstructionSitePhoto.php b/app/Models/Juil/ConstructionSitePhoto.php new file mode 100644 index 00000000..91533743 --- /dev/null +++ b/app/Models/Juil/ConstructionSitePhoto.php @@ -0,0 +1,65 @@ + 'date', + 'before_photo_size' => 'integer', + 'during_photo_size' => 'integer', + 'after_photo_size' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function hasPhoto(string $type): bool + { + return !empty($this->{$type . '_photo_path'}); + } + + public function getPhotoCount(): int + { + $count = 0; + foreach (['before', 'during', 'after'] as $type) { + if ($this->hasPhoto($type)) { + $count++; + } + } + + return $count; + } +} diff --git a/app/Services/ConstructionSitePhotoService.php b/app/Services/ConstructionSitePhotoService.php new file mode 100644 index 00000000..7f8be564 --- /dev/null +++ b/app/Services/ConstructionSitePhotoService.php @@ -0,0 +1,135 @@ +orderBy('work_date', 'desc') + ->orderBy('id', 'desc'); + + if (!empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('site_name', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + if (!empty($params['date_from'])) { + $query->where('work_date', '>=', $params['date_from']); + } + + if (!empty($params['date_to'])) { + $query->where('work_date', '<=', $params['date_to']); + } + + $perPage = (int) ($params['per_page'] ?? 12); + + return $query->paginate($perPage); + } + + public function create(array $data): ConstructionSitePhoto + { + return ConstructionSitePhoto::create([ + 'tenant_id' => Auth::user()->tenant_id, + 'user_id' => Auth::id(), + 'site_name' => $data['site_name'], + 'work_date' => $data['work_date'], + 'description' => $data['description'] ?? null, + ]); + } + + public function uploadPhoto(ConstructionSitePhoto $photo, $file, string $type): bool + { + if (!in_array($type, ['before', 'during', 'after'])) { + return false; + } + + $extension = $file->getClientOriginalExtension(); + $timestamp = now()->format('Ymd_His'); + $objectName = "construction-site-photos/{$photo->tenant_id}/{$photo->id}/{$timestamp}_{$type}.{$extension}"; + + // 기존 사진이 있으면 GCS에서 삭제 + $oldPath = $photo->{$type . '_photo_path'}; + if ($oldPath) { + $this->googleCloudService->deleteFromStorage($oldPath); + } + + // 임시 파일로 저장 후 GCS 업로드 + $tempPath = $file->getRealPath(); + $result = $this->googleCloudService->uploadToStorage($tempPath, $objectName); + + if (!$result) { + Log::error('ConstructionSitePhoto: GCS 업로드 실패', [ + 'photo_id' => $photo->id, + 'type' => $type, + ]); + + return false; + } + + $photo->update([ + $type . '_photo_path' => $objectName, + $type . '_photo_gcs_uri' => $result['uri'], + $type . '_photo_size' => $result['size'], + ]); + + return true; + } + + public function update(ConstructionSitePhoto $photo, array $data): ConstructionSitePhoto + { + $photo->update([ + 'site_name' => $data['site_name'], + 'work_date' => $data['work_date'], + 'description' => $data['description'] ?? null, + ]); + + return $photo->fresh(); + } + + public function delete(ConstructionSitePhoto $photo): bool + { + // GCS에서 모든 사진 삭제 + foreach (['before', 'during', 'after'] as $type) { + $path = $photo->{$type . '_photo_path'}; + if ($path) { + $this->googleCloudService->deleteFromStorage($path); + } + } + + return $photo->delete(); + } + + public function deletePhotoByType(ConstructionSitePhoto $photo, string $type): bool + { + if (!in_array($type, ['before', 'during', 'after'])) { + return false; + } + + $path = $photo->{$type . '_photo_path'}; + if ($path) { + $this->googleCloudService->deleteFromStorage($path); + } + + $photo->update([ + $type . '_photo_path' => null, + $type . '_photo_gcs_uri' => null, + $type . '_photo_size' => null, + ]); + + return true; + } +} diff --git a/resources/views/juil/construction-photos.blade.php b/resources/views/juil/construction-photos.blade.php new file mode 100644 index 00000000..ecfc4150 --- /dev/null +++ b/resources/views/juil/construction-photos.blade.php @@ -0,0 +1,678 @@ +@extends('layouts.app') + +@section('title', '공사현장 사진대지') + +@section('content') +
+@endsection + +@push('scripts') + + + + + +@endpush diff --git a/routes/web.php b/routes/web.php index ed861728..fc5d4d07 100644 --- a/routes/web.php +++ b/routes/web.php @@ -31,6 +31,7 @@ use App\Http\Controllers\RoleController; use App\Http\Controllers\RolePermissionController; use App\Http\Controllers\Sales\SalesProductController; +use App\Http\Controllers\Juil\ConstructionSitePhotoController; use App\Http\Controllers\Juil\PlanningController; use App\Http\Controllers\Stats\StatDashboardController; use App\Http\Controllers\System\AiConfigController; @@ -1300,4 +1301,17 @@ Route::middleware('auth')->prefix('juil')->name('juil.')->group(function () { Route::get('/estimate', [PlanningController::class, 'estimate'])->name('estimate'); Route::get('/project', [PlanningController::class, 'project'])->name('project'); + + // 공사현장 사진대지 + Route::prefix('construction-photos')->name('construction-photos.')->group(function () { + Route::get('/', [ConstructionSitePhotoController::class, 'index'])->name('index'); + Route::get('/list', [ConstructionSitePhotoController::class, 'list'])->name('list'); + Route::get('/{id}', [ConstructionSitePhotoController::class, 'show'])->name('show'); + Route::post('/', [ConstructionSitePhotoController::class, 'store'])->name('store'); + Route::post('/{id}/upload', [ConstructionSitePhotoController::class, 'uploadPhoto'])->name('upload'); + Route::put('/{id}', [ConstructionSitePhotoController::class, 'update'])->name('update'); + Route::delete('/{id}', [ConstructionSitePhotoController::class, 'destroy'])->name('destroy'); + Route::delete('/{id}/photo/{type}', [ConstructionSitePhotoController::class, 'deletePhoto'])->name('delete-photo'); + Route::get('/{id}/download/{type}', [ConstructionSitePhotoController::class, 'downloadPhoto'])->name('download'); + }); });