feat:공사현장 사진대지 멀티행(N행) 사진 지원

- ConstructionSitePhotoRow 모델 추가
- 부모 모델에서 사진 컬럼 제거, rows() 관계 추가
- 서비스/컨트롤러에 행 추가/삭제 기능 추가
- 라우트를 행 기반 URL 구조로 변경
- 프론트엔드 멀티행 UI 전면 개편

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-11 18:04:09 +09:00
parent b0c4f18c2e
commit 9d314a62fa
6 changed files with 340 additions and 83 deletions

View File

@@ -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([

View File

@@ -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;

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Models\Juil;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ConstructionSitePhotoRow extends Model
{
protected $table = 'construction_site_photo_rows';
protected $fillable = [
'construction_site_photo_id',
'sort_order',
'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 = [
'sort_order' => '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;
}
}

View File

@@ -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;
}
}

View File

@@ -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 ? (
<>
<img
src={API.photoUrl(photoId, type)}
src={API.photoUrl(photoId, rowId, type)}
alt={TYPE_LABELS[type]}
className="w-full h-full object-cover"
loading="lazy"
/>
<button
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-red-600 z-10 shadow"
onClick={(e) => { e.stopPropagation(); onDelete(photoId, type); }}
onClick={(e) => { e.stopPropagation(); onDelete(photoId, rowId, type); }}
title="사진 삭제"
>
&times;
@@ -367,21 +369,28 @@ className="hidden"
}
// --- PhotoCard ---
function PhotoCard({ item, onSelect, onUpload, onDeletePhoto }) {
const photoCount = ['before', 'during', 'after'].filter(t => item[`${t}_photo_path`]).length;
function PhotoCard({ item, onSelect }) {
const rows = item.rows || [];
const firstRow = rows[0];
const totalPhotos = rows.reduce((sum, row) => {
let cnt = 0;
['before', 'during', 'after'].forEach(t => { if (row[`${t}_photo_path`]) cnt++; });
return sum + cnt;
}, 0);
const totalSlots = rows.length * 3;
return (
<div
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
onClick={() => onSelect(item)}
>
{/* 사진 썸네일 3칸 */}
{/* 사진 썸네일 3칸 (첫 행 기준) */}
<div className="grid grid-cols-3 gap-0.5 bg-gray-100 p-0.5">
{['before', 'during', 'after'].map(type => (
<div key={type} className="aspect-square bg-gray-200 overflow-hidden relative">
{item[`${type}_photo_path`] ? (
{firstRow && firstRow[`${type}_photo_path`] ? (
<img
src={API.photoUrl(item.id, type)}
src={API.photoUrl(item.id, firstRow.id, type)}
alt={TYPE_LABELS[type]}
className="w-full h-full object-cover"
loading="lazy"
@@ -405,7 +414,10 @@ className="w-full h-full object-cover"
<h3 className="font-semibold text-sm text-gray-900 truncate">{item.site_name}</h3>
<div className="flex items-center justify-between mt-1.5">
<span className="text-xs text-gray-500">{item.work_date}</span>
<span className="text-xs text-gray-400">{photoCount}/3</span>
<span className="text-xs text-gray-400">
{totalPhotos}/{totalSlots}
{rows.length > 1 && ` (${rows.length}행)`}
</span>
</div>
{item.user && (
<span className="text-xs text-gray-400 mt-1 block">{item.user.name}</span>
@@ -498,7 +510,7 @@ className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus
}
// --- DetailModal ---
function DetailModal({ item, onClose, onUpload, onDeletePhoto, onUpdate, onDelete, onRefresh }) {
function DetailModal({ item, onClose, onUpload, onDeletePhoto, onUpdate, onDelete, onRefresh, onAddRow, onDeleteRow }) {
const [editing, setEditing] = useState(false);
const [siteName, setSiteName] = useState('');
const [workDate, setWorkDate] = useState('');
@@ -517,6 +529,8 @@ function DetailModal({ item, onClose, onUpload, onDeletePhoto, onUpdate, onDelet
if (!item) return null;
const rows = item.rows || [];
const handleSave = async () => {
if (!siteName.trim()) return alert('현장명을 입력해주세요.');
setSaving(true);
@@ -541,6 +555,23 @@ function DetailModal({ item, onClose, onUpload, onDeletePhoto, onUpdate, onDelet
}
};
const handleAddRow = async () => {
try {
await onAddRow(item.id);
} catch (err) {
alert(err.message);
}
};
const handleDeleteRow = async (rowId) => {
if (!confirm('이 사진 행을 삭제하시겠습니까? 해당 행의 모든 사진이 삭제됩니다.')) return;
try {
await onDeleteRow(item.id, rowId);
} catch (err) {
alert(err.message);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
@@ -604,19 +635,49 @@ className="flex-1 px-3 py-1.5 border border-gray-300 rounded-lg text-sm" rows={2
)}
</div>
{/* Photos */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
{['before', 'during', 'after'].map(type => (
<PhotoUploadBox
key={type}
type={type}
photoPath={item[`${type}_photo_path`]}
photoId={item.id}
onUpload={onUpload}
onDelete={onDeletePhoto}
disabled={false}
/>
{/* Photo Rows */}
<div className="space-y-4 mb-6">
{rows.map((row, idx) => (
<div key={row.id} className="border border-gray-200 rounded-xl p-4 bg-gray-50/50">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-700"> {idx + 1}</span>
{rows.length > 1 && (
<button
onClick={() => handleDeleteRow(row.id)}
className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded hover:bg-red-50 transition-colors"
title="이 행 삭제"
>
삭제
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{['before', 'during', 'after'].map(type => (
<PhotoUploadBox
key={type}
type={type}
photoPath={row[`${type}_photo_path`]}
photoId={item.id}
rowId={row.id}
onUpload={onUpload}
onDelete={onDeletePhoto}
disabled={false}
/>
))}
</div>
</div>
))}
{/* 행 추가 버튼 */}
<button
onClick={handleAddRow}
className="w-full border-2 border-dashed border-gray-300 rounded-xl py-4 text-gray-400 hover:border-blue-400 hover:text-blue-500 hover:bg-blue-50/50 transition-all flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
<span className="text-sm font-medium">사진 추가</span>
</button>
</div>
{/* 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}
/>
))}
</div>
@@ -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}
/>
</div>
);

View File

@@ -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');
});
// 회의록 작성