feat:공사현장 사진대지 기능 추가

모델, 서비스, 컨트롤러, React SPA 뷰, 라우트 추가
GCS 업로드/다운로드, 드래그앤드롭 사진 관리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-09 21:25:07 +09:00
parent 64ac667cfd
commit beff95b4e1
5 changed files with 1128 additions and 0 deletions

View File

@@ -0,0 +1,236 @@
<?php
namespace App\Http\Controllers\Juil;
use App\Http\Controllers\Controller;
use App\Models\Juil\ConstructionSitePhoto;
use App\Services\ConstructionSitePhotoService;
use App\Services\GoogleCloudService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response;
class ConstructionSitePhotoController extends Controller
{
public function __construct(
private readonly ConstructionSitePhotoService $service
) {}
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('juil.construction-photos.index'));
}
return view('juil.construction-photos');
}
public function list(Request $request): JsonResponse
{
$params = $request->only(['search', 'date_from', 'date_to', 'per_page']);
$photos = $this->service->getList($params);
return response()->json([
'success' => true,
'data' => $photos,
]);
}
public function show(int $id): JsonResponse
{
$photo = ConstructionSitePhoto::with('user')->find($id);
if (!$photo) {
return response()->json([
'success' => false,
'message' => '사진대지를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'data' => $photo,
]);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'site_name' => 'required|string|max:200',
'work_date' => 'required|date',
'description' => 'nullable|string|max:2000',
]);
$photo = $this->service->create($validated);
return response()->json([
'success' => true,
'message' => '사진대지가 등록되었습니다.',
'data' => $photo,
], 201);
}
public function uploadPhoto(Request $request, int $id): JsonResponse
{
$photo = ConstructionSitePhoto::find($id);
if (!$photo) {
return response()->json([
'success' => false,
'message' => '사진대지를 찾을 수 없습니다.',
], 404);
}
$validated = $request->validate([
'type' => 'required|in:before,during,after',
'photo' => 'required|image|mimes:jpeg,jpg,png,webp|max:10240',
]);
$result = $this->service->uploadPhoto($photo, $request->file('photo'), $validated['type']);
if (!$result) {
return response()->json([
'success' => false,
'message' => '사진 업로드에 실패했습니다.',
], 500);
}
return response()->json([
'success' => true,
'message' => '사진이 업로드되었습니다.',
'data' => $photo->fresh(),
]);
}
public function update(Request $request, int $id): JsonResponse
{
$photo = ConstructionSitePhoto::find($id);
if (!$photo) {
return response()->json([
'success' => false,
'message' => '사진대지를 찾을 수 없습니다.',
], 404);
}
$validated = $request->validate([
'site_name' => 'required|string|max:200',
'work_date' => 'required|date',
'description' => 'nullable|string|max:2000',
]);
$photo = $this->service->update($photo, $validated);
return response()->json([
'success' => true,
'message' => '사진대지가 수정되었습니다.',
'data' => $photo,
]);
}
public function destroy(int $id): JsonResponse
{
$photo = ConstructionSitePhoto::find($id);
if (!$photo) {
return response()->json([
'success' => false,
'message' => '사진대지를 찾을 수 없습니다.',
], 404);
}
$this->service->delete($photo);
return response()->json([
'success' => true,
'message' => '사진대지가 삭제되었습니다.',
]);
}
public function deletePhoto(int $id, string $type): JsonResponse
{
$photo = ConstructionSitePhoto::find($id);
if (!$photo) {
return response()->json([
'success' => false,
'message' => '사진대지를 찾을 수 없습니다.',
], 404);
}
if (!in_array($type, ['before', 'during', 'after'])) {
return response()->json([
'success' => false,
'message' => '올바르지 않은 사진 유형입니다.',
], 422);
}
$this->service->deletePhotoByType($photo, $type);
return response()->json([
'success' => true,
'message' => '사진이 삭제되었습니다.',
'data' => $photo->fresh(),
]);
}
public function downloadPhoto(Request $request, int $id, string $type): Response|JsonResponse
{
$photo = ConstructionSitePhoto::find($id);
if (!$photo) {
return response()->json([
'success' => false,
'message' => '사진대지를 찾을 수 없습니다.',
], 404);
}
if (!in_array($type, ['before', 'during', 'after'])) {
return response()->json([
'success' => false,
'message' => '올바르지 않은 사진 유형입니다.',
], 422);
}
$path = $photo->{$type . '_photo_path'};
if (!$path) {
return response()->json([
'success' => false,
'message' => '파일을 찾을 수 없습니다.',
], 404);
}
$googleCloudService = app(GoogleCloudService::class);
$content = $googleCloudService->downloadFromStorage($path);
if (!$content) {
return response()->json([
'success' => false,
'message' => '파일 다운로드에 실패했습니다.',
], 500);
}
$extension = pathinfo($path, PATHINFO_EXTENSION) ?: 'jpg';
$mimeType = 'image/' . ($extension === 'jpg' ? 'jpeg' : $extension);
$typeLabel = match ($type) {
'before' => '작업전',
'during' => '작업중',
'after' => '작업후',
};
$safeTitle = preg_replace('/[\/\\\\:*?"<>|]/', '_', $photo->site_name);
$filename = "{$safeTitle}_{$typeLabel}.{$extension}";
$encodedFilename = rawurlencode($filename);
$disposition = $request->query('inline') ? 'inline' : 'attachment';
return response($content)
->header('Content-Type', $mimeType)
->header('Content-Length', strlen($content))
->header('Accept-Ranges', 'bytes')
->header('Content-Disposition', "{$disposition}; filename=\"{$encodedFilename}\"; filename*=UTF-8''{$encodedFilename}")
->header('Cache-Control', 'private, max-age=3600');
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Models\Juil;
use App\Models\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ConstructionSitePhoto extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'construction_site_photos';
protected $fillable = [
'tenant_id',
'user_id',
'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',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function hasPhoto(string $type): bool
{
return !empty($this->{$type . '_photo_path'});
}
public function getPhotoCount(): int
{
$count = 0;
foreach (['before', 'during', 'after'] as $type) {
if ($this->hasPhoto($type)) {
$count++;
}
}
return $count;
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Services;
use App\Models\Juil\ConstructionSitePhoto;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class ConstructionSitePhotoService
{
public function __construct(
private readonly GoogleCloudService $googleCloudService
) {}
public function getList(array $params): LengthAwarePaginator
{
$query = ConstructionSitePhoto::with('user')
->orderBy('work_date', 'desc')
->orderBy('id', 'desc');
if (!empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('site_name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
if (!empty($params['date_from'])) {
$query->where('work_date', '>=', $params['date_from']);
}
if (!empty($params['date_to'])) {
$query->where('work_date', '<=', $params['date_to']);
}
$perPage = (int) ($params['per_page'] ?? 12);
return $query->paginate($perPage);
}
public function create(array $data): ConstructionSitePhoto
{
return ConstructionSitePhoto::create([
'tenant_id' => Auth::user()->tenant_id,
'user_id' => Auth::id(),
'site_name' => $data['site_name'],
'work_date' => $data['work_date'],
'description' => $data['description'] ?? null,
]);
}
public function uploadPhoto(ConstructionSitePhoto $photo, $file, string $type): bool
{
if (!in_array($type, ['before', 'during', 'after'])) {
return false;
}
$extension = $file->getClientOriginalExtension();
$timestamp = now()->format('Ymd_His');
$objectName = "construction-site-photos/{$photo->tenant_id}/{$photo->id}/{$timestamp}_{$type}.{$extension}";
// 기존 사진이 있으면 GCS에서 삭제
$oldPath = $photo->{$type . '_photo_path'};
if ($oldPath) {
$this->googleCloudService->deleteFromStorage($oldPath);
}
// 임시 파일로 저장 후 GCS 업로드
$tempPath = $file->getRealPath();
$result = $this->googleCloudService->uploadToStorage($tempPath, $objectName);
if (!$result) {
Log::error('ConstructionSitePhoto: GCS 업로드 실패', [
'photo_id' => $photo->id,
'type' => $type,
]);
return false;
}
$photo->update([
$type . '_photo_path' => $objectName,
$type . '_photo_gcs_uri' => $result['uri'],
$type . '_photo_size' => $result['size'],
]);
return true;
}
public function update(ConstructionSitePhoto $photo, array $data): ConstructionSitePhoto
{
$photo->update([
'site_name' => $data['site_name'],
'work_date' => $data['work_date'],
'description' => $data['description'] ?? null,
]);
return $photo->fresh();
}
public function delete(ConstructionSitePhoto $photo): bool
{
// GCS에서 모든 사진 삭제
foreach (['before', 'during', 'after'] as $type) {
$path = $photo->{$type . '_photo_path'};
if ($path) {
$this->googleCloudService->deleteFromStorage($path);
}
}
return $photo->delete();
}
public function deletePhotoByType(ConstructionSitePhoto $photo, string $type): bool
{
if (!in_array($type, ['before', 'during', 'after'])) {
return false;
}
$path = $photo->{$type . '_photo_path'};
if ($path) {
$this->googleCloudService->deleteFromStorage($path);
}
$photo->update([
$type . '_photo_path' => null,
$type . '_photo_gcs_uri' => null,
$type . '_photo_size' => null,
]);
return true;
}
}

View File

@@ -0,0 +1,678 @@
@extends('layouts.app')
@section('title', '공사현장 사진대지')
@section('content')
<div id="root"></div>
@endsection
@push('scripts')
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
@verbatim
const { useState, useEffect, useCallback, useRef } = React;
const API = {
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`,
};
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content || '';
const TYPE_LABELS = { before: '작업전', during: '작업중', after: '작업후' };
const TYPE_COLORS = {
before: { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', badge: 'bg-blue-100 text-blue-800' },
during: { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-700', badge: 'bg-yellow-100 text-yellow-800' },
after: { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', badge: 'bg-green-100 text-green-800' },
};
async function apiFetch(url, options = {}) {
const res = await fetch(url, {
headers: {
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json',
...options.headers,
},
...options,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: '요청 처리 중 오류가 발생했습니다.' }));
throw new Error(err.message || `HTTP ${res.status}`);
}
return res.json();
}
// --- Toast ---
function Toast({ message, type, onClose }) {
useEffect(() => {
const t = setTimeout(onClose, 3000);
return () => clearTimeout(t);
}, [onClose]);
const colors = type === 'error' ? 'bg-red-500' : 'bg-green-500';
return (
<div className={`fixed top-4 right-4 z-[9999] px-6 py-3 rounded-lg shadow-lg text-white ${colors} transition-all`}>
{message}
</div>
);
}
// --- PhotoUploadBox ---
function PhotoUploadBox({ type, photoPath, photoId, onUpload, onDelete, disabled }) {
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const inputRef = useRef(null);
const colors = TYPE_COLORS[type];
const hasPhoto = !!photoPath;
const handleFiles = async (files) => {
if (!files || files.length === 0 || !photoId) return;
const file = files[0];
if (!file.type.startsWith('image/')) {
alert('이미지 파일만 업로드할 수 있습니다.');
return;
}
setUploading(true);
try {
await onUpload(photoId, type, file);
} finally {
setUploading(false);
}
};
const onDrop = (e) => {
e.preventDefault();
setDragOver(false);
handleFiles(e.dataTransfer.files);
};
return (
<div className="flex flex-col">
<span className={`inline-block text-xs font-semibold mb-1.5 px-2 py-0.5 rounded ${colors.badge} w-fit`}>
{TYPE_LABELS[type]}
</span>
<div
className={`relative border-2 border-dashed rounded-lg overflow-hidden transition-all cursor-pointer aspect-[4/3]
${dragOver ? 'border-blue-400 bg-blue-50' : hasPhoto ? `${colors.border} ${colors.bg}` : 'border-gray-300 bg-gray-50'}
${disabled ? 'opacity-50 pointer-events-none' : 'hover:border-blue-400'}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
onClick={() => !uploading && inputRef.current?.click()}
>
{uploading && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10">
<div className="w-8 h-8 border-3 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
)}
{hasPhoto ? (
<>
<img
src={API.photoUrl(photoId, 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); }}
title="사진 삭제"
>
&times;
</button>
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-gray-400 p-3">
<svg className="w-8 h-8 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="text-xs text-center">클릭 또는 드래그</span>
</div>
)}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
</div>
</div>
);
}
// --- PhotoCard ---
function PhotoCard({ item, onSelect, onUpload, onDeletePhoto }) {
const photoCount = ['before', 'during', 'after'].filter(t => item[`${t}_photo_path`]).length;
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칸 */}
<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`] ? (
<img
src={API.photoUrl(item.id, type)}
alt={TYPE_LABELS[type]}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
<span className={`absolute bottom-0 left-0 right-0 text-center text-[9px] py-0.5 font-medium ${TYPE_COLORS[type].badge}`}>
{TYPE_LABELS[type]}
</span>
</div>
))}
</div>
{/* 정보 */}
<div className="p-3">
<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>
</div>
{item.user && (
<span className="text-xs text-gray-400 mt-1 block">{item.user.name}</span>
)}
</div>
</div>
);
}
// --- CreateModal ---
function CreateModal({ show, onClose, onCreate }) {
const [siteName, setSiteName] = useState('');
const [workDate, setWorkDate] = useState(new Date().toISOString().split('T')[0]);
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
if (!show) return null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!siteName.trim()) return alert('현장명을 입력해주세요.');
setSaving(true);
try {
await onCreate({ site_name: siteName.trim(), work_date: workDate, description: description.trim() || null });
setSiteName('');
setDescription('');
onClose();
} catch (err) {
alert(err.message);
} finally {
setSaving(false);
}
};
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-md" onClick={e => e.stopPropagation()}>
<div className="p-6">
<h2 className="text-lg font-bold text-gray-900 mb-4"> 사진대지 등록</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">현장명 *</label>
<input
type="text"
value={siteName}
onChange={e => setSiteName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="공사 현장명을 입력하세요"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">작업일자 *</label>
<input
type="date"
value={workDate}
onChange={e => setWorkDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">설명</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
rows={3}
placeholder="작업 내용을 간단히 기록하세요"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200">
취소
</button>
<button type="submit" disabled={saving} className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
{saving ? '등록 중...' : '등록'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}
// --- DetailModal ---
function DetailModal({ item, onClose, onUpload, onDeletePhoto, onUpdate, onDelete, onRefresh }) {
const [editing, setEditing] = useState(false);
const [siteName, setSiteName] = useState('');
const [workDate, setWorkDate] = useState('');
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
const [lightbox, setLightbox] = useState(null);
useEffect(() => {
if (item) {
setSiteName(item.site_name);
setWorkDate(item.work_date);
setDescription(item.description || '');
setEditing(false);
}
}, [item]);
if (!item) return null;
const handleSave = async () => {
if (!siteName.trim()) return alert('현장명을 입력해주세요.');
setSaving(true);
try {
await onUpdate(item.id, { site_name: siteName.trim(), work_date: workDate, description: description.trim() || null });
setEditing(false);
onRefresh();
} catch (err) {
alert(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm('이 사진대지를 삭제하시겠습니까? 모든 사진이 함께 삭제됩니다.')) return;
try {
await onDelete(item.id);
onClose();
} 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()}>
{/* Header */}
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-2xl z-10">
{editing ? (
<input
type="text"
value={siteName}
onChange={e => setSiteName(e.target.value)}
className="text-lg font-bold text-gray-900 border border-gray-300 rounded-lg px-3 py-1 flex-1 mr-3"
/>
) : (
<h2 className="text-lg font-bold text-gray-900">{item.site_name}</h2>
)}
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<div className="p-6">
{/* Info */}
<div className="flex flex-wrap gap-4 mb-6">
{editing ? (
<>
<div>
<label className="block text-xs text-gray-500 mb-1">작업일자</label>
<input type="date" value={workDate} onChange={e => setWorkDate(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm" />
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-xs text-gray-500 mb-1">설명</label>
<textarea value={description} onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm" rows={2} />
</div>
</>
) : (
<>
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{item.work_date}
</div>
{item.user && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{item.user.name}
</div>
)}
{item.description && (
<p className="text-sm text-gray-600 w-full">{item.description}</p>
)}
</>
)}
</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}
/>
))}
</div>
{/* Actions */}
<div className="flex justify-between pt-4 border-t border-gray-200">
<button onClick={handleDelete}
className="px-4 py-2 text-sm text-red-600 bg-red-50 rounded-lg hover:bg-red-100">
삭제
</button>
<div className="flex gap-2">
{editing ? (
<>
<button onClick={() => setEditing(false)}
className="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200">
취소
</button>
<button onClick={handleSave} disabled={saving}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
{saving ? '저장 중...' : '저장'}
</button>
</>
) : (
<button onClick={() => setEditing(true)}
className="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200">
수정
</button>
)}
</div>
</div>
</div>
</div>
{/* Lightbox */}
{lightbox && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[60]"
onClick={() => setLightbox(null)}>
<img src={lightbox} className="max-w-[90vw] max-h-[90vh] object-contain" />
</div>
)}
</div>
);
}
// --- App ---
function App() {
const [items, setItems] = useState([]);
const [pagination, setPagination] = useState({ current_page: 1, last_page: 1 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [selectedItem, setSelectedItem] = useState(null);
const [toast, setToast] = useState(null);
const showToast = (message, type = 'success') => setToast({ message, type });
const fetchList = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ per_page: '12', page: String(page) });
if (search) params.set('search', search);
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
const res = await apiFetch(`${API.list}?${params}`);
setItems(res.data.data || []);
setPagination({
current_page: res.data.current_page,
last_page: res.data.last_page,
total: res.data.total,
});
} catch (err) {
showToast(err.message, 'error');
} finally {
setLoading(false);
}
}, [search, dateFrom, dateTo]);
useEffect(() => {
fetchList();
}, [fetchList]);
const handleCreate = async (data) => {
const res = await apiFetch(API.store, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
showToast('사진대지가 등록되었습니다.');
fetchList();
// 생성 후 바로 상세 열기
setSelectedItem(res.data);
};
const handleUpload = async (id, type, file) => {
const formData = new FormData();
formData.append('type', type);
formData.append('photo', file);
const res = await apiFetch(API.upload(id), {
method: 'POST',
body: formData,
});
showToast('사진이 업로드되었습니다.');
// 상세 모달 데이터 갱신
if (selectedItem?.id === id) {
setSelectedItem(res.data);
}
fetchList();
};
const handleDeletePhoto = async (id, type) => {
if (!confirm(`${TYPE_LABELS[type]} 사진을 삭제하시겠습니까?`)) return;
const res = await apiFetch(API.deletePhoto(id, type), { method: 'DELETE' });
showToast('사진이 삭제되었습니다.');
if (selectedItem?.id === id) {
setSelectedItem(res.data);
}
fetchList();
};
const handleUpdate = async (id, data) => {
await apiFetch(API.update(id), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
showToast('사진대지가 수정되었습니다.');
fetchList();
};
const handleDelete = async (id) => {
await apiFetch(API.destroy(id), { method: 'DELETE' });
showToast('사진대지가 삭제되었습니다.');
fetchList();
};
const handleSelectItem = async (item) => {
try {
const res = await apiFetch(API.show(item.id));
setSelectedItem(res.data);
} catch {
setSelectedItem(item);
}
};
const refreshSelected = async () => {
if (!selectedItem) return;
try {
const res = await apiFetch(API.show(selectedItem.id));
setSelectedItem(res.data);
} catch {}
};
return (
<div className="min-h-screen bg-gray-50">
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
{/* Header */}
<div className="bg-white border-b border-gray-200 px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-bold text-gray-900">공사현장 사진대지</h1>
<p className="text-sm text-gray-500 mt-0.5">작업전/작업중/작업후 현장 사진을 관리합니다</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 shadow-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
사진대지
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mt-4">
<input
type="text"
placeholder="현장명, 설명 검색..."
value={search}
onChange={e => setSearch(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm w-64 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<div className="flex items-center gap-2">
<input
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<span className="text-gray-400">~</span>
<input
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{(search || dateFrom || dateTo) && (
<button
onClick={() => { setSearch(''); setDateFrom(''); setDateTo(''); }}
className="px-3 py-2 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200"
>
초기화
</button>
)}
</div>
</div>
{/* Content */}
<div className="p-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-3 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
) : items.length === 0 ? (
<div className="text-center py-20">
<svg className="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-gray-500 text-lg">등록된 사진대지가 없습니다</p>
<p className="text-gray-400 text-sm mt-1"> 사진대지를 등록해보세요</p>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{items.map(item => (
<PhotoCard
key={item.id}
item={item}
onSelect={handleSelectItem}
onUpload={handleUpload}
onDeletePhoto={handleDeletePhoto}
/>
))}
</div>
{/* Pagination */}
{pagination.last_page > 1 && (
<div className="flex items-center justify-center gap-2 mt-8">
<button
disabled={pagination.current_page === 1}
onClick={() => fetchList(pagination.current_page - 1)}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
이전
</button>
<span className="text-sm text-gray-600">
{pagination.current_page} / {pagination.last_page}
{pagination.total !== undefined && ` (총 ${pagination.total}건)`}
</span>
<button
disabled={pagination.current_page === pagination.last_page}
onClick={() => fetchList(pagination.current_page + 1)}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
다음
</button>
</div>
)}
</>
)}
</div>
{/* Modals */}
<CreateModal show={showCreate} onClose={() => setShowCreate(false)} onCreate={handleCreate} />
<DetailModal
item={selectedItem}
onClose={() => setSelectedItem(null)}
onUpload={handleUpload}
onDeletePhoto={handleDeletePhoto}
onUpdate={handleUpdate}
onDelete={handleDelete}
onRefresh={refreshSelected}
/>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
@endverbatim
</script>
@endpush

View File

@@ -31,6 +31,7 @@
use App\Http\Controllers\RoleController;
use App\Http\Controllers\RolePermissionController;
use App\Http\Controllers\Sales\SalesProductController;
use App\Http\Controllers\Juil\ConstructionSitePhotoController;
use App\Http\Controllers\Juil\PlanningController;
use App\Http\Controllers\Stats\StatDashboardController;
use App\Http\Controllers\System\AiConfigController;
@@ -1300,4 +1301,17 @@
Route::middleware('auth')->prefix('juil')->name('juil.')->group(function () {
Route::get('/estimate', [PlanningController::class, 'estimate'])->name('estimate');
Route::get('/project', [PlanningController::class, 'project'])->name('project');
// 공사현장 사진대지
Route::prefix('construction-photos')->name('construction-photos.')->group(function () {
Route::get('/', [ConstructionSitePhotoController::class, 'index'])->name('index');
Route::get('/list', [ConstructionSitePhotoController::class, 'list'])->name('list');
Route::get('/{id}', [ConstructionSitePhotoController::class, 'show'])->name('show');
Route::post('/', [ConstructionSitePhotoController::class, 'store'])->name('store');
Route::post('/{id}/upload', [ConstructionSitePhotoController::class, 'uploadPhoto'])->name('upload');
Route::put('/{id}', [ConstructionSitePhotoController::class, 'update'])->name('update');
Route::delete('/{id}', [ConstructionSitePhotoController::class, 'destroy'])->name('destroy');
Route::delete('/{id}/photo/{type}', [ConstructionSitePhotoController::class, 'deletePhoto'])->name('delete-photo');
Route::get('/{id}/download/{type}', [ConstructionSitePhotoController::class, 'downloadPhoto'])->name('download');
});
});