feat:공사현장 사진대지 멀티행(N행) 사진 지원
- ConstructionSitePhotoRow 모델 추가 - 부모 모델에서 사진 컬럼 제거, rows() 관계 추가 - 서비스/컨트롤러에 행 추가/삭제 기능 추가 - 라우트를 행 기반 URL 구조로 변경 - 프론트엔드 멀티행 UI 전면 개편 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
54
app/Models/Juil/ConstructionSitePhotoRow.php
Normal file
54
app/Models/Juil/ConstructionSitePhotoRow.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="사진 삭제"
|
||||
>
|
||||
×
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
// 회의록 작성
|
||||
|
||||
Reference in New Issue
Block a user