diff --git a/app/Http/Controllers/Juil/ConstructionSitePhotoController.php b/app/Http/Controllers/Juil/ConstructionSitePhotoController.php index 5a2a05a8..c6cb5f25 100644 --- a/app/Http/Controllers/Juil/ConstructionSitePhotoController.php +++ b/app/Http/Controllers/Juil/ConstructionSitePhotoController.php @@ -5,6 +5,7 @@ use App\Helpers\AiTokenHelper; use App\Http\Controllers\Controller; use App\Models\Juil\ConstructionSitePhoto; +use App\Models\Juil\ConstructionSitePhotoRow; use App\Services\ConstructionSitePhotoService; use App\Services\GoogleCloudService; use Illuminate\Http\JsonResponse; @@ -40,7 +41,7 @@ public function list(Request $request): JsonResponse public function show(int $id): JsonResponse { - $photo = ConstructionSitePhoto::with('user')->find($id); + $photo = ConstructionSitePhoto::with(['user', 'rows'])->find($id); if (!$photo) { return response()->json([ @@ -72,7 +73,7 @@ public function store(Request $request): JsonResponse ], 201); } - public function uploadPhoto(Request $request, int $id): JsonResponse + public function uploadPhoto(Request $request, int $id, int $rowId): JsonResponse { $photo = ConstructionSitePhoto::find($id); @@ -83,12 +84,23 @@ public function uploadPhoto(Request $request, int $id): JsonResponse ], 404); } + $row = ConstructionSitePhotoRow::where('id', $rowId) + ->where('construction_site_photo_id', $id) + ->first(); + + if (!$row) { + 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']); + $result = $this->service->uploadPhoto($row, $request->file('photo'), $validated['type']); if (!$result) { return response()->json([ @@ -100,7 +112,7 @@ public function uploadPhoto(Request $request, int $id): JsonResponse return response()->json([ 'success' => true, 'message' => '사진이 업로드되었습니다.', - 'data' => $photo->fresh(), + 'data' => $photo->fresh()->load('rows'), ]); } @@ -132,7 +144,7 @@ public function update(Request $request, int $id): JsonResponse public function destroy(int $id): JsonResponse { - $photo = ConstructionSitePhoto::find($id); + $photo = ConstructionSitePhoto::with('rows')->find($id); if (!$photo) { return response()->json([ @@ -149,7 +161,7 @@ public function destroy(int $id): JsonResponse ]); } - public function deletePhoto(int $id, string $type): JsonResponse + public function deletePhoto(int $id, int $rowId, string $type): JsonResponse { $photo = ConstructionSitePhoto::find($id); @@ -160,6 +172,17 @@ public function deletePhoto(int $id, string $type): JsonResponse ], 404); } + $row = ConstructionSitePhotoRow::where('id', $rowId) + ->where('construction_site_photo_id', $id) + ->first(); + + if (!$row) { + return response()->json([ + 'success' => false, + 'message' => '사진 행을 찾을 수 없습니다.', + ], 404); + } + if (!in_array($type, ['before', 'during', 'after'])) { return response()->json([ 'success' => false, @@ -167,16 +190,16 @@ public function deletePhoto(int $id, string $type): JsonResponse ], 422); } - $this->service->deletePhotoByType($photo, $type); + $this->service->deletePhotoByType($row, $type); return response()->json([ 'success' => true, 'message' => '사진이 삭제되었습니다.', - 'data' => $photo->fresh(), + 'data' => $photo->fresh()->load('rows'), ]); } - public function downloadPhoto(Request $request, int $id, string $type): Response|JsonResponse + public function downloadPhoto(Request $request, int $id, int $rowId, string $type): Response|JsonResponse { $photo = ConstructionSitePhoto::find($id); @@ -187,6 +210,17 @@ public function downloadPhoto(Request $request, int $id, string $type): Response ], 404); } + $row = ConstructionSitePhotoRow::where('id', $rowId) + ->where('construction_site_photo_id', $id) + ->first(); + + if (!$row) { + return response()->json([ + 'success' => false, + 'message' => '사진 행을 찾을 수 없습니다.', + ], 404); + } + if (!in_array($type, ['before', 'during', 'after'])) { return response()->json([ 'success' => false, @@ -194,7 +228,7 @@ public function downloadPhoto(Request $request, int $id, string $type): Response ], 422); } - $path = $photo->{$type . '_photo_path'}; + $path = $row->{$type . '_photo_path'}; if (!$path) { return response()->json([ @@ -235,6 +269,62 @@ public function downloadPhoto(Request $request, int $id, string $type): Response ->header('Cache-Control', 'private, max-age=3600'); } + public function addRow(int $id): JsonResponse + { + $photo = ConstructionSitePhoto::find($id); + + if (!$photo) { + return response()->json([ + 'success' => false, + 'message' => '사진대지를 찾을 수 없습니다.', + ], 404); + } + + $this->service->addRow($photo); + + return response()->json([ + 'success' => true, + 'message' => '사진 행이 추가되었습니다.', + 'data' => $photo->fresh()->load('rows'), + ]); + } + + public function deleteRow(int $id, int $rowId): JsonResponse + { + $photo = ConstructionSitePhoto::with('rows')->find($id); + + if (!$photo) { + return response()->json([ + 'success' => false, + 'message' => '사진대지를 찾을 수 없습니다.', + ], 404); + } + + if ($photo->rows->count() <= 1) { + return response()->json([ + 'success' => false, + 'message' => '최소 1개의 사진 행은 유지해야 합니다.', + ], 422); + } + + $row = $photo->rows->firstWhere('id', $rowId); + + if (!$row) { + return response()->json([ + 'success' => false, + 'message' => '사진 행을 찾을 수 없습니다.', + ], 404); + } + + $this->service->deleteRow($row); + + return response()->json([ + 'success' => true, + 'message' => '사진 행이 삭제되었습니다.', + 'data' => $photo->fresh()->load('rows'), + ]); + } + public function logSttUsage(Request $request): JsonResponse { $validated = $request->validate([ diff --git a/app/Models/Juil/ConstructionSitePhoto.php b/app/Models/Juil/ConstructionSitePhoto.php index 91533743..f33f964a 100644 --- a/app/Models/Juil/ConstructionSitePhoto.php +++ b/app/Models/Juil/ConstructionSitePhoto.php @@ -6,6 +6,7 @@ use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; class ConstructionSitePhoto extends Model @@ -20,22 +21,10 @@ class ConstructionSitePhoto extends Model 'site_name', 'work_date', 'description', - 'before_photo_path', - 'before_photo_gcs_uri', - 'before_photo_size', - 'during_photo_path', - 'during_photo_gcs_uri', - 'during_photo_size', - 'after_photo_path', - 'after_photo_gcs_uri', - 'after_photo_size', ]; protected $casts = [ 'work_date' => 'date', - 'before_photo_size' => 'integer', - 'during_photo_size' => 'integer', - 'after_photo_size' => 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', @@ -46,18 +35,16 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } - public function hasPhoto(string $type): bool + public function rows(): HasMany { - return !empty($this->{$type . '_photo_path'}); + return $this->hasMany(ConstructionSitePhotoRow::class)->orderBy('sort_order'); } public function getPhotoCount(): int { $count = 0; - foreach (['before', 'during', 'after'] as $type) { - if ($this->hasPhoto($type)) { - $count++; - } + foreach ($this->rows as $row) { + $count += $row->getPhotoCount(); } return $count; diff --git a/app/Models/Juil/ConstructionSitePhotoRow.php b/app/Models/Juil/ConstructionSitePhotoRow.php new file mode 100644 index 00000000..d553dc9a --- /dev/null +++ b/app/Models/Juil/ConstructionSitePhotoRow.php @@ -0,0 +1,54 @@ + 'integer', + 'before_photo_size' => 'integer', + 'during_photo_size' => 'integer', + 'after_photo_size' => 'integer', + ]; + + public function constructionSitePhoto(): BelongsTo + { + return $this->belongsTo(ConstructionSitePhoto::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 index ac4aaf52..c9cf8fc5 100644 --- a/app/Services/ConstructionSitePhotoService.php +++ b/app/Services/ConstructionSitePhotoService.php @@ -4,6 +4,7 @@ use App\Helpers\AiTokenHelper; use App\Models\Juil\ConstructionSitePhoto; +use App\Models\Juil\ConstructionSitePhotoRow; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; @@ -16,7 +17,7 @@ public function __construct( public function getList(array $params): LengthAwarePaginator { - $query = ConstructionSitePhoto::with('user') + $query = ConstructionSitePhoto::with(['user', 'rows']) ->orderBy('work_date', 'desc') ->orderBy('id', 'desc'); @@ -43,27 +44,33 @@ public function getList(array $params): LengthAwarePaginator public function create(array $data): ConstructionSitePhoto { - return ConstructionSitePhoto::create([ + $photo = ConstructionSitePhoto::create([ 'tenant_id' => session('selected_tenant_id'), 'user_id' => Auth::id(), 'site_name' => $data['site_name'], 'work_date' => $data['work_date'], 'description' => $data['description'] ?? null, ]); + + // 기본 빈 행 1개 자동 생성 + $photo->rows()->create(['sort_order' => 0]); + + return $photo->load('rows'); } - public function uploadPhoto(ConstructionSitePhoto $photo, $file, string $type): bool + public function uploadPhoto(ConstructionSitePhotoRow $row, $file, string $type): bool { if (!in_array($type, ['before', 'during', 'after'])) { return false; } + $photo = $row->constructionSitePhoto; $extension = $file->getClientOriginalExtension(); $timestamp = now()->format('Ymd_His'); - $objectName = "construction-site-photos/{$photo->tenant_id}/{$photo->id}/{$timestamp}_{$type}.{$extension}"; + $objectName = "construction-site-photos/{$photo->tenant_id}/{$photo->id}/{$row->id}_{$timestamp}_{$type}.{$extension}"; // 기존 사진이 있으면 GCS에서 삭제 - $oldPath = $photo->{$type . '_photo_path'}; + $oldPath = $row->{$type . '_photo_path'}; if ($oldPath) { $this->googleCloudService->deleteFromStorage($oldPath); } @@ -75,13 +82,14 @@ public function uploadPhoto(ConstructionSitePhoto $photo, $file, string $type): if (!$result) { Log::error('ConstructionSitePhoto: GCS 업로드 실패', [ 'photo_id' => $photo->id, + 'row_id' => $row->id, 'type' => $type, ]); return false; } - $photo->update([ + $row->update([ $type . '_photo_path' => $objectName, $type . '_photo_gcs_uri' => $result['uri'], $type . '_photo_size' => $result['size'], @@ -100,34 +108,36 @@ public function update(ConstructionSitePhoto $photo, array $data): ConstructionS 'description' => $data['description'] ?? null, ]); - return $photo->fresh(); + return $photo->fresh()->load('rows'); } public function delete(ConstructionSitePhoto $photo): bool { - // GCS에서 모든 사진 삭제 - foreach (['before', 'during', 'after'] as $type) { - $path = $photo->{$type . '_photo_path'}; - if ($path) { - $this->googleCloudService->deleteFromStorage($path); + // rows 순회하여 모든 GCS 파일 삭제 + foreach ($photo->rows as $row) { + foreach (['before', 'during', 'after'] as $type) { + $path = $row->{$type . '_photo_path'}; + if ($path) { + $this->googleCloudService->deleteFromStorage($path); + } } } return $photo->delete(); } - public function deletePhotoByType(ConstructionSitePhoto $photo, string $type): bool + public function deletePhotoByType(ConstructionSitePhotoRow $row, string $type): bool { if (!in_array($type, ['before', 'during', 'after'])) { return false; } - $path = $photo->{$type . '_photo_path'}; + $path = $row->{$type . '_photo_path'}; if ($path) { $this->googleCloudService->deleteFromStorage($path); } - $photo->update([ + $row->update([ $type . '_photo_path' => null, $type . '_photo_gcs_uri' => null, $type . '_photo_size' => null, @@ -135,4 +145,38 @@ public function deletePhotoByType(ConstructionSitePhoto $photo, string $type): b return true; } + + public function addRow(ConstructionSitePhoto $photo): ConstructionSitePhotoRow + { + $nextOrder = ($photo->rows()->max('sort_order') ?? -1) + 1; + + return $photo->rows()->create(['sort_order' => $nextOrder]); + } + + public function deleteRow(ConstructionSitePhotoRow $row): bool + { + // 행의 GCS 파일 삭제 + foreach (['before', 'during', 'after'] as $type) { + $path = $row->{$type . '_photo_path'}; + if ($path) { + $this->googleCloudService->deleteFromStorage($path); + } + } + + $photoId = $row->construction_site_photo_id; + $row->delete(); + + // 나머지 행 순서 재정렬 + $remainingRows = ConstructionSitePhotoRow::where('construction_site_photo_id', $photoId) + ->orderBy('sort_order') + ->get(); + + foreach ($remainingRows as $i => $r) { + if ($r->sort_order !== $i) { + $r->update(['sort_order' => $i]); + } + } + + return true; + } } diff --git a/resources/views/juil/construction-photos.blade.php b/resources/views/juil/construction-photos.blade.php index 7f97b6cf..746d52b8 100644 --- a/resources/views/juil/construction-photos.blade.php +++ b/resources/views/juil/construction-photos.blade.php @@ -19,12 +19,14 @@ list: '/juil/construction-photos/list', store: '/juil/construction-photos', show: (id) => `/juil/construction-photos/${id}`, - upload: (id) => `/juil/construction-photos/${id}/upload`, update: (id) => `/juil/construction-photos/${id}`, destroy: (id) => `/juil/construction-photos/${id}`, - deletePhoto: (id, type) => `/juil/construction-photos/${id}/photo/${type}`, - downloadPhoto: (id, type) => `/juil/construction-photos/${id}/download/${type}`, - photoUrl: (id, type) => `/juil/construction-photos/${id}/download/${type}?inline=1`, + upload: (id, rowId) => `/juil/construction-photos/${id}/rows/${rowId}/upload`, + deletePhoto: (id, rowId, type) => `/juil/construction-photos/${id}/rows/${rowId}/photo/${type}`, + downloadPhoto: (id, rowId, type) => `/juil/construction-photos/${id}/rows/${rowId}/download/${type}`, + photoUrl: (id, rowId, type) => `/juil/construction-photos/${id}/rows/${rowId}/download/${type}?inline=1`, + addRow: (id) => `/juil/construction-photos/${id}/rows`, + deleteRow: (id, rowId) => `/juil/construction-photos/${id}/rows/${rowId}`, logSttUsage: '/juil/construction-photos/log-stt-usage', }; @@ -282,7 +284,7 @@ function ToastNotification({ message, type, onClose }) { } // --- PhotoUploadBox --- -function PhotoUploadBox({ type, photoPath, photoId, onUpload, onDelete, disabled }) { +function PhotoUploadBox({ type, photoPath, photoId, rowId, onUpload, onDelete, disabled }) { const [dragOver, setDragOver] = useState(false); const [uploading, setUploading] = useState(false); const inputRef = useRef(null); @@ -290,7 +292,7 @@ function PhotoUploadBox({ type, photoPath, photoId, onUpload, onDelete, disabled const hasPhoto = !!photoPath; const handleFiles = async (files) => { - if (!files || files.length === 0 || !photoId) return; + if (!files || files.length === 0 || !photoId || !rowId) return; const file = files[0]; if (!file.type.startsWith('image/')) { alert('이미지 파일만 업로드할 수 있습니다.'); @@ -299,7 +301,7 @@ function PhotoUploadBox({ type, photoPath, photoId, onUpload, onDelete, disabled setUploading(true); try { const compressed = await compressImage(file); - await onUpload(photoId, type, compressed); + await onUpload(photoId, rowId, type, compressed); } finally { setUploading(false); } @@ -333,14 +335,14 @@ className={`relative border-2 border-dashed rounded-lg overflow-hidden transitio {hasPhoto ? ( <> {TYPE_LABELS[type]} + )} + +
+ {['before', 'during', 'after'].map(type => ( + + ))} +
+ ))} + + {/* 행 추가 버튼 */} + {/* Actions */} @@ -712,26 +773,25 @@ function App() { setSelectedItem(res.data); }; - const handleUpload = async (id, type, file) => { + const handleUpload = async (id, rowId, type, file) => { const formData = new FormData(); formData.append('type', type); formData.append('photo', file); - const res = await apiFetch(API.upload(id), { + const res = await apiFetch(API.upload(id, rowId), { method: 'POST', body: formData, }); showToast('사진이 업로드되었습니다.'); modalDirtyRef.current = true; - // 모달 데이터만 갱신 (배경 리스트는 모달 닫힐 때) if (selectedItem?.id === id) { setSelectedItem(res.data); } }; - const handleDeletePhoto = async (id, type) => { + const handleDeletePhoto = async (id, rowId, type) => { if (!confirm(`${TYPE_LABELS[type]} 사진을 삭제하시겠습니까?`)) return; - const res = await apiFetch(API.deletePhoto(id, type), { method: 'DELETE' }); + const res = await apiFetch(API.deletePhoto(id, rowId, type), { method: 'DELETE' }); showToast('사진이 삭제되었습니다.'); modalDirtyRef.current = true; if (selectedItem?.id === id) { @@ -755,6 +815,24 @@ function App() { modalDirtyRef.current = true; }; + const handleAddRow = async (id) => { + const res = await apiFetch(API.addRow(id), { method: 'POST' }); + showToast('사진 행이 추가되었습니다.'); + modalDirtyRef.current = true; + if (selectedItem?.id === id) { + setSelectedItem(res.data); + } + }; + + const handleDeleteRow = async (id, rowId) => { + const res = await apiFetch(API.deleteRow(id, rowId), { method: 'DELETE' }); + showToast('사진 행이 삭제되었습니다.'); + modalDirtyRef.current = true; + if (selectedItem?.id === id) { + setSelectedItem(res.data); + } + }; + const handleSelectItem = async (item) => { modalDirtyRef.current = false; try { @@ -861,8 +939,6 @@ className="px-3 py-2 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray- key={item.id} item={item} onSelect={handleSelectItem} - onUpload={handleUpload} - onDeletePhoto={handleDeletePhoto} /> ))} @@ -904,6 +980,8 @@ className="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 onUpdate={handleUpdate} onDelete={handleDelete} onRefresh={refreshSelected} + onAddRow={handleAddRow} + onDeleteRow={handleDeleteRow} /> ); diff --git a/routes/web.php b/routes/web.php index 93e69079..359f039a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1357,14 +1357,18 @@ 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::post('/log-stt-usage', [ConstructionSitePhotoController::class, 'logSttUsage'])->name('log-stt-usage'); + Route::get('/{id}', [ConstructionSitePhotoController::class, 'show'])->name('show'); 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'); - Route::post('/log-stt-usage', [ConstructionSitePhotoController::class, 'logSttUsage'])->name('log-stt-usage'); + // 행 관리 + Route::post('/{id}/rows', [ConstructionSitePhotoController::class, 'addRow'])->name('add-row'); + Route::delete('/{id}/rows/{rowId}', [ConstructionSitePhotoController::class, 'deleteRow'])->name('delete-row'); + // 행별 사진 관리 + Route::post('/{id}/rows/{rowId}/upload', [ConstructionSitePhotoController::class, 'uploadPhoto'])->name('upload'); + Route::delete('/{id}/rows/{rowId}/photo/{type}', [ConstructionSitePhotoController::class, 'deletePhoto'])->name('delete-photo'); + Route::get('/{id}/rows/{rowId}/download/{type}', [ConstructionSitePhotoController::class, 'downloadPhoto'])->name('download'); }); // 회의록 작성