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\Helpers\AiTokenHelper;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Juil\ConstructionSitePhoto;
|
use App\Models\Juil\ConstructionSitePhoto;
|
||||||
|
use App\Models\Juil\ConstructionSitePhotoRow;
|
||||||
use App\Services\ConstructionSitePhotoService;
|
use App\Services\ConstructionSitePhotoService;
|
||||||
use App\Services\GoogleCloudService;
|
use App\Services\GoogleCloudService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -40,7 +41,7 @@ public function list(Request $request): JsonResponse
|
|||||||
|
|
||||||
public function show(int $id): JsonResponse
|
public function show(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$photo = ConstructionSitePhoto::with('user')->find($id);
|
$photo = ConstructionSitePhoto::with(['user', 'rows'])->find($id);
|
||||||
|
|
||||||
if (!$photo) {
|
if (!$photo) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -72,7 +73,7 @@ public function store(Request $request): JsonResponse
|
|||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function uploadPhoto(Request $request, int $id): JsonResponse
|
public function uploadPhoto(Request $request, int $id, int $rowId): JsonResponse
|
||||||
{
|
{
|
||||||
$photo = ConstructionSitePhoto::find($id);
|
$photo = ConstructionSitePhoto::find($id);
|
||||||
|
|
||||||
@@ -83,12 +84,23 @@ public function uploadPhoto(Request $request, int $id): JsonResponse
|
|||||||
], 404);
|
], 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([
|
$validated = $request->validate([
|
||||||
'type' => 'required|in:before,during,after',
|
'type' => 'required|in:before,during,after',
|
||||||
'photo' => 'required|image|mimes:jpeg,jpg,png,webp|max:10240',
|
'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) {
|
if (!$result) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -100,7 +112,7 @@ public function uploadPhoto(Request $request, int $id): JsonResponse
|
|||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => '사진이 업로드되었습니다.',
|
'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
|
public function destroy(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$photo = ConstructionSitePhoto::find($id);
|
$photo = ConstructionSitePhoto::with('rows')->find($id);
|
||||||
|
|
||||||
if (!$photo) {
|
if (!$photo) {
|
||||||
return response()->json([
|
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);
|
$photo = ConstructionSitePhoto::find($id);
|
||||||
|
|
||||||
@@ -160,6 +172,17 @@ public function deletePhoto(int $id, string $type): JsonResponse
|
|||||||
], 404);
|
], 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'])) {
|
if (!in_array($type, ['before', 'during', 'after'])) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
@@ -167,16 +190,16 @@ public function deletePhoto(int $id, string $type): JsonResponse
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->service->deletePhotoByType($photo, $type);
|
$this->service->deletePhotoByType($row, $type);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => '사진이 삭제되었습니다.',
|
'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);
|
$photo = ConstructionSitePhoto::find($id);
|
||||||
|
|
||||||
@@ -187,6 +210,17 @@ public function downloadPhoto(Request $request, int $id, string $type): Response
|
|||||||
], 404);
|
], 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'])) {
|
if (!in_array($type, ['before', 'during', 'after'])) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
@@ -194,7 +228,7 @@ public function downloadPhoto(Request $request, int $id, string $type): Response
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = $photo->{$type . '_photo_path'};
|
$path = $row->{$type . '_photo_path'};
|
||||||
|
|
||||||
if (!$path) {
|
if (!$path) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -235,6 +269,62 @@ public function downloadPhoto(Request $request, int $id, string $type): Response
|
|||||||
->header('Cache-Control', 'private, max-age=3600');
|
->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
|
public function logSttUsage(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
use App\Traits\BelongsToTenant;
|
use App\Traits\BelongsToTenant;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class ConstructionSitePhoto extends Model
|
class ConstructionSitePhoto extends Model
|
||||||
@@ -20,22 +21,10 @@ class ConstructionSitePhoto extends Model
|
|||||||
'site_name',
|
'site_name',
|
||||||
'work_date',
|
'work_date',
|
||||||
'description',
|
'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 = [
|
protected $casts = [
|
||||||
'work_date' => 'date',
|
'work_date' => 'date',
|
||||||
'before_photo_size' => 'integer',
|
|
||||||
'during_photo_size' => 'integer',
|
|
||||||
'after_photo_size' => 'integer',
|
|
||||||
'created_at' => 'datetime',
|
'created_at' => 'datetime',
|
||||||
'updated_at' => 'datetime',
|
'updated_at' => 'datetime',
|
||||||
'deleted_at' => 'datetime',
|
'deleted_at' => 'datetime',
|
||||||
@@ -46,18 +35,16 @@ public function user(): BelongsTo
|
|||||||
return $this->belongsTo(User::class);
|
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
|
public function getPhotoCount(): int
|
||||||
{
|
{
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach (['before', 'during', 'after'] as $type) {
|
foreach ($this->rows as $row) {
|
||||||
if ($this->hasPhoto($type)) {
|
$count += $row->getPhotoCount();
|
||||||
$count++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $count;
|
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\Helpers\AiTokenHelper;
|
||||||
use App\Models\Juil\ConstructionSitePhoto;
|
use App\Models\Juil\ConstructionSitePhoto;
|
||||||
|
use App\Models\Juil\ConstructionSitePhotoRow;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -16,7 +17,7 @@ public function __construct(
|
|||||||
|
|
||||||
public function getList(array $params): LengthAwarePaginator
|
public function getList(array $params): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
$query = ConstructionSitePhoto::with('user')
|
$query = ConstructionSitePhoto::with(['user', 'rows'])
|
||||||
->orderBy('work_date', 'desc')
|
->orderBy('work_date', 'desc')
|
||||||
->orderBy('id', 'desc');
|
->orderBy('id', 'desc');
|
||||||
|
|
||||||
@@ -43,27 +44,33 @@ public function getList(array $params): LengthAwarePaginator
|
|||||||
|
|
||||||
public function create(array $data): ConstructionSitePhoto
|
public function create(array $data): ConstructionSitePhoto
|
||||||
{
|
{
|
||||||
return ConstructionSitePhoto::create([
|
$photo = ConstructionSitePhoto::create([
|
||||||
'tenant_id' => session('selected_tenant_id'),
|
'tenant_id' => session('selected_tenant_id'),
|
||||||
'user_id' => Auth::id(),
|
'user_id' => Auth::id(),
|
||||||
'site_name' => $data['site_name'],
|
'site_name' => $data['site_name'],
|
||||||
'work_date' => $data['work_date'],
|
'work_date' => $data['work_date'],
|
||||||
'description' => $data['description'] ?? null,
|
'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'])) {
|
if (!in_array($type, ['before', 'during', 'after'])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$photo = $row->constructionSitePhoto;
|
||||||
$extension = $file->getClientOriginalExtension();
|
$extension = $file->getClientOriginalExtension();
|
||||||
$timestamp = now()->format('Ymd_His');
|
$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에서 삭제
|
// 기존 사진이 있으면 GCS에서 삭제
|
||||||
$oldPath = $photo->{$type . '_photo_path'};
|
$oldPath = $row->{$type . '_photo_path'};
|
||||||
if ($oldPath) {
|
if ($oldPath) {
|
||||||
$this->googleCloudService->deleteFromStorage($oldPath);
|
$this->googleCloudService->deleteFromStorage($oldPath);
|
||||||
}
|
}
|
||||||
@@ -75,13 +82,14 @@ public function uploadPhoto(ConstructionSitePhoto $photo, $file, string $type):
|
|||||||
if (!$result) {
|
if (!$result) {
|
||||||
Log::error('ConstructionSitePhoto: GCS 업로드 실패', [
|
Log::error('ConstructionSitePhoto: GCS 업로드 실패', [
|
||||||
'photo_id' => $photo->id,
|
'photo_id' => $photo->id,
|
||||||
|
'row_id' => $row->id,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$photo->update([
|
$row->update([
|
||||||
$type . '_photo_path' => $objectName,
|
$type . '_photo_path' => $objectName,
|
||||||
$type . '_photo_gcs_uri' => $result['uri'],
|
$type . '_photo_gcs_uri' => $result['uri'],
|
||||||
$type . '_photo_size' => $result['size'],
|
$type . '_photo_size' => $result['size'],
|
||||||
@@ -100,34 +108,36 @@ public function update(ConstructionSitePhoto $photo, array $data): ConstructionS
|
|||||||
'description' => $data['description'] ?? null,
|
'description' => $data['description'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $photo->fresh();
|
return $photo->fresh()->load('rows');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(ConstructionSitePhoto $photo): bool
|
public function delete(ConstructionSitePhoto $photo): bool
|
||||||
{
|
{
|
||||||
// GCS에서 모든 사진 삭제
|
// rows 순회하여 모든 GCS 파일 삭제
|
||||||
foreach (['before', 'during', 'after'] as $type) {
|
foreach ($photo->rows as $row) {
|
||||||
$path = $photo->{$type . '_photo_path'};
|
foreach (['before', 'during', 'after'] as $type) {
|
||||||
if ($path) {
|
$path = $row->{$type . '_photo_path'};
|
||||||
$this->googleCloudService->deleteFromStorage($path);
|
if ($path) {
|
||||||
|
$this->googleCloudService->deleteFromStorage($path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $photo->delete();
|
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'])) {
|
if (!in_array($type, ['before', 'during', 'after'])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = $photo->{$type . '_photo_path'};
|
$path = $row->{$type . '_photo_path'};
|
||||||
if ($path) {
|
if ($path) {
|
||||||
$this->googleCloudService->deleteFromStorage($path);
|
$this->googleCloudService->deleteFromStorage($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
$photo->update([
|
$row->update([
|
||||||
$type . '_photo_path' => null,
|
$type . '_photo_path' => null,
|
||||||
$type . '_photo_gcs_uri' => null,
|
$type . '_photo_gcs_uri' => null,
|
||||||
$type . '_photo_size' => null,
|
$type . '_photo_size' => null,
|
||||||
@@ -135,4 +145,38 @@ public function deletePhotoByType(ConstructionSitePhoto $photo, string $type): b
|
|||||||
|
|
||||||
return true;
|
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',
|
list: '/juil/construction-photos/list',
|
||||||
store: '/juil/construction-photos',
|
store: '/juil/construction-photos',
|
||||||
show: (id) => `/juil/construction-photos/${id}`,
|
show: (id) => `/juil/construction-photos/${id}`,
|
||||||
upload: (id) => `/juil/construction-photos/${id}/upload`,
|
|
||||||
update: (id) => `/juil/construction-photos/${id}`,
|
update: (id) => `/juil/construction-photos/${id}`,
|
||||||
destroy: (id) => `/juil/construction-photos/${id}`,
|
destroy: (id) => `/juil/construction-photos/${id}`,
|
||||||
deletePhoto: (id, type) => `/juil/construction-photos/${id}/photo/${type}`,
|
upload: (id, rowId) => `/juil/construction-photos/${id}/rows/${rowId}/upload`,
|
||||||
downloadPhoto: (id, type) => `/juil/construction-photos/${id}/download/${type}`,
|
deletePhoto: (id, rowId, type) => `/juil/construction-photos/${id}/rows/${rowId}/photo/${type}`,
|
||||||
photoUrl: (id, type) => `/juil/construction-photos/${id}/download/${type}?inline=1`,
|
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',
|
logSttUsage: '/juil/construction-photos/log-stt-usage',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,7 +284,7 @@ function ToastNotification({ message, type, onClose }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- PhotoUploadBox ---
|
// --- PhotoUploadBox ---
|
||||||
function PhotoUploadBox({ type, photoPath, photoId, onUpload, onDelete, disabled }) {
|
function PhotoUploadBox({ type, photoPath, photoId, rowId, onUpload, onDelete, disabled }) {
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
@@ -290,7 +292,7 @@ function PhotoUploadBox({ type, photoPath, photoId, onUpload, onDelete, disabled
|
|||||||
const hasPhoto = !!photoPath;
|
const hasPhoto = !!photoPath;
|
||||||
|
|
||||||
const handleFiles = async (files) => {
|
const handleFiles = async (files) => {
|
||||||
if (!files || files.length === 0 || !photoId) return;
|
if (!files || files.length === 0 || !photoId || !rowId) return;
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
alert('이미지 파일만 업로드할 수 있습니다.');
|
alert('이미지 파일만 업로드할 수 있습니다.');
|
||||||
@@ -299,7 +301,7 @@ function PhotoUploadBox({ type, photoPath, photoId, onUpload, onDelete, disabled
|
|||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const compressed = await compressImage(file);
|
const compressed = await compressImage(file);
|
||||||
await onUpload(photoId, type, compressed);
|
await onUpload(photoId, rowId, type, compressed);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -333,14 +335,14 @@ className={`relative border-2 border-dashed rounded-lg overflow-hidden transitio
|
|||||||
{hasPhoto ? (
|
{hasPhoto ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
src={API.photoUrl(photoId, type)}
|
src={API.photoUrl(photoId, rowId, type)}
|
||||||
alt={TYPE_LABELS[type]}
|
alt={TYPE_LABELS[type]}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<button
|
<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"
|
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="사진 삭제"
|
title="사진 삭제"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -367,21 +369,28 @@ className="hidden"
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- PhotoCard ---
|
// --- PhotoCard ---
|
||||||
function PhotoCard({ item, onSelect, onUpload, onDeletePhoto }) {
|
function PhotoCard({ item, onSelect }) {
|
||||||
const photoCount = ['before', 'during', 'after'].filter(t => item[`${t}_photo_path`]).length;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
|
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
|
||||||
onClick={() => onSelect(item)}
|
onClick={() => onSelect(item)}
|
||||||
>
|
>
|
||||||
{/* 사진 썸네일 3칸 */}
|
{/* 사진 썸네일 3칸 (첫 행 기준) */}
|
||||||
<div className="grid grid-cols-3 gap-0.5 bg-gray-100 p-0.5">
|
<div className="grid grid-cols-3 gap-0.5 bg-gray-100 p-0.5">
|
||||||
{['before', 'during', 'after'].map(type => (
|
{['before', 'during', 'after'].map(type => (
|
||||||
<div key={type} className="aspect-square bg-gray-200 overflow-hidden relative">
|
<div key={type} className="aspect-square bg-gray-200 overflow-hidden relative">
|
||||||
{item[`${type}_photo_path`] ? (
|
{firstRow && firstRow[`${type}_photo_path`] ? (
|
||||||
<img
|
<img
|
||||||
src={API.photoUrl(item.id, type)}
|
src={API.photoUrl(item.id, firstRow.id, type)}
|
||||||
alt={TYPE_LABELS[type]}
|
alt={TYPE_LABELS[type]}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
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>
|
<h3 className="font-semibold text-sm text-gray-900 truncate">{item.site_name}</h3>
|
||||||
<div className="flex items-center justify-between mt-1.5">
|
<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-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>
|
</div>
|
||||||
{item.user && (
|
{item.user && (
|
||||||
<span className="text-xs text-gray-400 mt-1 block">{item.user.name}</span>
|
<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 ---
|
// --- 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 [editing, setEditing] = useState(false);
|
||||||
const [siteName, setSiteName] = useState('');
|
const [siteName, setSiteName] = useState('');
|
||||||
const [workDate, setWorkDate] = useState('');
|
const [workDate, setWorkDate] = useState('');
|
||||||
@@ -517,6 +529,8 @@ function DetailModal({ item, onClose, onUpload, onDeletePhoto, onUpdate, onDelet
|
|||||||
|
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
|
|
||||||
|
const rows = item.rows || [];
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!siteName.trim()) return alert('현장명을 입력해주세요.');
|
if (!siteName.trim()) return alert('현장명을 입력해주세요.');
|
||||||
setSaving(true);
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
<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()}>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Photos */}
|
{/* Photo Rows */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
<div className="space-y-4 mb-6">
|
||||||
{['before', 'during', 'after'].map(type => (
|
{rows.map((row, idx) => (
|
||||||
<PhotoUploadBox
|
<div key={row.id} className="border border-gray-200 rounded-xl p-4 bg-gray-50/50">
|
||||||
key={type}
|
<div className="flex items-center justify-between mb-3">
|
||||||
type={type}
|
<span className="text-sm font-medium text-gray-700">행 {idx + 1}</span>
|
||||||
photoPath={item[`${type}_photo_path`]}
|
{rows.length > 1 && (
|
||||||
photoId={item.id}
|
<button
|
||||||
onUpload={onUpload}
|
onClick={() => handleDeleteRow(row.id)}
|
||||||
onDelete={onDeletePhoto}
|
className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded hover:bg-red-50 transition-colors"
|
||||||
disabled={false}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -712,26 +773,25 @@ function App() {
|
|||||||
setSelectedItem(res.data);
|
setSelectedItem(res.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = async (id, type, file) => {
|
const handleUpload = async (id, rowId, type, file) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('type', type);
|
formData.append('type', type);
|
||||||
formData.append('photo', file);
|
formData.append('photo', file);
|
||||||
|
|
||||||
const res = await apiFetch(API.upload(id), {
|
const res = await apiFetch(API.upload(id, rowId), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
showToast('사진이 업로드되었습니다.');
|
showToast('사진이 업로드되었습니다.');
|
||||||
modalDirtyRef.current = true;
|
modalDirtyRef.current = true;
|
||||||
// 모달 데이터만 갱신 (배경 리스트는 모달 닫힐 때)
|
|
||||||
if (selectedItem?.id === id) {
|
if (selectedItem?.id === id) {
|
||||||
setSelectedItem(res.data);
|
setSelectedItem(res.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletePhoto = async (id, type) => {
|
const handleDeletePhoto = async (id, rowId, type) => {
|
||||||
if (!confirm(`${TYPE_LABELS[type]} 사진을 삭제하시겠습니까?`)) return;
|
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('사진이 삭제되었습니다.');
|
showToast('사진이 삭제되었습니다.');
|
||||||
modalDirtyRef.current = true;
|
modalDirtyRef.current = true;
|
||||||
if (selectedItem?.id === id) {
|
if (selectedItem?.id === id) {
|
||||||
@@ -755,6 +815,24 @@ function App() {
|
|||||||
modalDirtyRef.current = true;
|
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) => {
|
const handleSelectItem = async (item) => {
|
||||||
modalDirtyRef.current = false;
|
modalDirtyRef.current = false;
|
||||||
try {
|
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}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
onSelect={handleSelectItem}
|
onSelect={handleSelectItem}
|
||||||
onUpload={handleUpload}
|
|
||||||
onDeletePhoto={handleDeletePhoto}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -904,6 +980,8 @@ className="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50
|
|||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onRefresh={refreshSelected}
|
onRefresh={refreshSelected}
|
||||||
|
onAddRow={handleAddRow}
|
||||||
|
onDeleteRow={handleDeleteRow}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1357,14 +1357,18 @@
|
|||||||
Route::prefix('construction-photos')->name('construction-photos.')->group(function () {
|
Route::prefix('construction-photos')->name('construction-photos.')->group(function () {
|
||||||
Route::get('/', [ConstructionSitePhotoController::class, 'index'])->name('index');
|
Route::get('/', [ConstructionSitePhotoController::class, 'index'])->name('index');
|
||||||
Route::get('/list', [ConstructionSitePhotoController::class, 'list'])->name('list');
|
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('/', [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::put('/{id}', [ConstructionSitePhotoController::class, 'update'])->name('update');
|
||||||
Route::delete('/{id}', [ConstructionSitePhotoController::class, 'destroy'])->name('destroy');
|
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('/{id}/rows', [ConstructionSitePhotoController::class, 'addRow'])->name('add-row');
|
||||||
Route::post('/log-stt-usage', [ConstructionSitePhotoController::class, 'logSttUsage'])->name('log-stt-usage');
|
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