- 모달에서 사진 업로드/삭제/수정 시 배경 리스트 fetchList() 제거 - modalDirtyRef로 변경 여부 추적 - 모달 닫힐 때만 dirty 상태면 리스트 한 번 갱신 - 카드 많을 때 불필요한 리렌더링으로 인한 성능 저하 방지 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
885 lines
40 KiB
PHP
885 lines
40 KiB
PHP
@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`,
|
|
logSttUsage: '/juil/construction-photos/log-stt-usage',
|
|
};
|
|
|
|
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 { headers: optHeaders, ...restOptions } = options;
|
|
const res = await fetch(url, {
|
|
...restOptions,
|
|
headers: {
|
|
'X-CSRF-TOKEN': CSRF_TOKEN,
|
|
'Accept': 'application/json',
|
|
...optHeaders,
|
|
},
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ message: '요청 처리 중 오류가 발생했습니다.' }));
|
|
throw new Error(err.message || `HTTP ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
// --- VoiceInputButton (Web Speech API STT) ---
|
|
// 규칙: 미확정=이탤릭+회색, 확정=일반체+진한색, 삭제금지, 교정허용, 부드러운 전환
|
|
function VoiceInputButton({ onResult, disabled }) {
|
|
const [recording, setRecording] = useState(false);
|
|
const [finalizedSegments, setFinalizedSegments] = useState([]);
|
|
const [interimText, setInterimText] = useState('');
|
|
const recognitionRef = useRef(null);
|
|
const startTimeRef = useRef(null);
|
|
const dismissTimerRef = useRef(null);
|
|
const previewRef = useRef(null);
|
|
|
|
const isSupported = typeof window !== 'undefined' &&
|
|
(window.SpeechRecognition || window.webkitSpeechRecognition);
|
|
|
|
const logUsage = useCallback((startTime) => {
|
|
const duration = Math.max(1, Math.round((Date.now() - startTime) / 1000));
|
|
apiFetch(API.logSttUsage, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ duration_seconds: duration }),
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
// 프리뷰 자동 스크롤
|
|
useEffect(() => {
|
|
if (previewRef.current) {
|
|
previewRef.current.scrollTop = previewRef.current.scrollHeight;
|
|
}
|
|
}, [finalizedSegments, interimText]);
|
|
|
|
const stopRecording = useCallback(() => {
|
|
recognitionRef.current?.stop();
|
|
recognitionRef.current = null;
|
|
if (startTimeRef.current) {
|
|
logUsage(startTimeRef.current);
|
|
startTimeRef.current = null;
|
|
}
|
|
setRecording(false);
|
|
setInterimText('');
|
|
// 녹음 종료 후 2초 뒤 프리뷰 닫기
|
|
dismissTimerRef.current = setTimeout(() => {
|
|
setFinalizedSegments([]);
|
|
}, 2000);
|
|
}, [logUsage]);
|
|
|
|
const startRecording = useCallback(() => {
|
|
// 이전 타이머 정리
|
|
if (dismissTimerRef.current) { clearTimeout(dismissTimerRef.current); dismissTimerRef.current = null; }
|
|
|
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
const recognition = new SR();
|
|
recognition.lang = 'ko-KR';
|
|
recognition.continuous = true;
|
|
recognition.interimResults = true;
|
|
recognition.maxAlternatives = 1;
|
|
|
|
recognition.onresult = (event) => {
|
|
if (dismissTimerRef.current) { clearTimeout(dismissTimerRef.current); dismissTimerRef.current = null; }
|
|
|
|
let currentInterim = '';
|
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
const transcript = event.results[i][0].transcript;
|
|
if (event.results[i].isFinal) {
|
|
// 확정: input에 반영 + 로그에 영구 저장
|
|
onResult(transcript);
|
|
setFinalizedSegments(prev => [...prev, transcript]);
|
|
currentInterim = '';
|
|
} else {
|
|
// 미확정: 교정은 허용하되 이전 확정분은 보존
|
|
currentInterim = transcript;
|
|
}
|
|
}
|
|
setInterimText(currentInterim);
|
|
};
|
|
recognition.onerror = () => stopRecording();
|
|
recognition.onend = () => {
|
|
if (startTimeRef.current) {
|
|
logUsage(startTimeRef.current);
|
|
startTimeRef.current = null;
|
|
}
|
|
setRecording(false);
|
|
setInterimText('');
|
|
recognitionRef.current = null;
|
|
dismissTimerRef.current = setTimeout(() => {
|
|
setFinalizedSegments([]);
|
|
}, 2000);
|
|
};
|
|
|
|
recognitionRef.current = recognition;
|
|
startTimeRef.current = Date.now();
|
|
setFinalizedSegments([]);
|
|
setInterimText('');
|
|
recognition.start();
|
|
setRecording(true);
|
|
}, [onResult, stopRecording, logUsage]);
|
|
|
|
const toggle = useCallback((e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (disabled || !isSupported) return;
|
|
recording ? stopRecording() : startRecording();
|
|
}, [disabled, isSupported, recording, stopRecording, startRecording]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
recognitionRef.current?.stop();
|
|
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
if (!isSupported) return null;
|
|
|
|
const hasContent = finalizedSegments.length > 0 || interimText;
|
|
|
|
return (
|
|
<div className="relative flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={toggle}
|
|
disabled={disabled}
|
|
title={recording ? '녹음 중지 (클릭)' : '음성으로 입력'}
|
|
className={`inline-flex items-center justify-center w-8 h-8 rounded-full transition-all
|
|
${recording
|
|
? 'bg-red-500 text-white shadow-lg shadow-red-200'
|
|
: 'bg-gray-100 text-gray-500 hover:bg-blue-100 hover:text-blue-600'}
|
|
${disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
>
|
|
{recording ? (
|
|
<span className="relative flex items-center justify-center w-4 h-4">
|
|
<span className="absolute inset-0 rounded-full bg-white/30 animate-ping"></span>
|
|
<svg className="w-3.5 h-3.5 relative" fill="currentColor" viewBox="0 0 24 24">
|
|
<rect x="6" y="6" width="12" height="12" rx="2" />
|
|
</svg>
|
|
</span>
|
|
) : (
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
|
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
{/* 스트리밍 프리뷰 패널 */}
|
|
{(recording || hasContent) && (
|
|
<div
|
|
ref={previewRef}
|
|
className="absolute bottom-full mb-2 right-0 bg-gray-900 rounded-lg shadow-xl z-50
|
|
w-[300px] max-h-[120px] overflow-y-auto px-3 py-2"
|
|
style={{ lineHeight: '1.6' }}
|
|
>
|
|
{/* 확정 텍스트: 일반체 + 흰색 - 삭제되지 않음 */}
|
|
{finalizedSegments.map((seg, i) => (
|
|
<span key={i} className="text-white text-xs font-normal transition-colors duration-300">
|
|
{seg}
|
|
</span>
|
|
))}
|
|
|
|
{/* 미확정 텍스트: 이탤릭 + 연한 회색 - 교정 가능 */}
|
|
{interimText && (
|
|
<span className="text-gray-400 text-xs italic transition-colors duration-200">
|
|
{interimText}
|
|
</span>
|
|
)}
|
|
|
|
{/* 녹음 중 + 텍스트 없음: 대기 표시 */}
|
|
{recording && !hasContent && (
|
|
<span className="text-gray-500 text-xs flex items-center gap-1.5">
|
|
<span className="inline-block w-1.5 h-1.5 bg-red-400 rounded-full animate-pulse"></span>
|
|
말씀하세요...
|
|
</span>
|
|
)}
|
|
|
|
{/* 녹음 종료 후 확정 텍스트만 남아있을 때 */}
|
|
{!recording && finalizedSegments.length > 0 && !interimText && (
|
|
<span className="text-green-400 text-xs ml-1">✓</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- ToastNotification ---
|
|
function ToastNotification({ 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="사진 삭제"
|
|
>
|
|
×
|
|
</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>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={siteName}
|
|
onChange={e => setSiteName(e.target.value)}
|
|
className="flex-1 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
|
|
/>
|
|
<VoiceInputButton onResult={(text) => setSiteName(prev => prev ? prev + ' ' + text : text)} />
|
|
</div>
|
|
</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>
|
|
<div className="flex items-start gap-2">
|
|
<textarea
|
|
value={description}
|
|
onChange={e => setDescription(e.target.value)}
|
|
className="flex-1 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="작업 내용을 간단히 기록하세요"
|
|
/>
|
|
<VoiceInputButton onResult={(text) => setDescription(prev => prev ? prev + ' ' + text : text)} />
|
|
</div>
|
|
</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 ? (
|
|
<div className="flex items-center gap-2 flex-1 mr-3">
|
|
<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"
|
|
/>
|
|
<VoiceInputButton onResult={(text) => setSiteName(prev => prev ? prev + ' ' + text : text)} />
|
|
</div>
|
|
) : (
|
|
<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">×</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>
|
|
<div className="flex items-start gap-2">
|
|
<textarea value={description} onChange={e => setDescription(e.target.value)}
|
|
className="flex-1 px-3 py-1.5 border border-gray-300 rounded-lg text-sm" rows={2} />
|
|
<VoiceInputButton onResult={(text) => setDescription(prev => prev ? prev + ' ' + text : text)} />
|
|
</div>
|
|
</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 modalDirtyRef = useRef(false);
|
|
|
|
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('사진대지가 등록되었습니다.');
|
|
modalDirtyRef.current = true;
|
|
// 생성 후 바로 상세 열기
|
|
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('사진이 업로드되었습니다.');
|
|
modalDirtyRef.current = true;
|
|
// 모달 데이터만 갱신 (배경 리스트는 모달 닫힐 때)
|
|
if (selectedItem?.id === id) {
|
|
setSelectedItem(res.data);
|
|
}
|
|
};
|
|
|
|
const handleDeletePhoto = async (id, type) => {
|
|
if (!confirm(`${TYPE_LABELS[type]} 사진을 삭제하시겠습니까?`)) return;
|
|
const res = await apiFetch(API.deletePhoto(id, type), { method: 'DELETE' });
|
|
showToast('사진이 삭제되었습니다.');
|
|
modalDirtyRef.current = true;
|
|
if (selectedItem?.id === id) {
|
|
setSelectedItem(res.data);
|
|
}
|
|
};
|
|
|
|
const handleUpdate = async (id, data) => {
|
|
await apiFetch(API.update(id), {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
showToast('사진대지가 수정되었습니다.');
|
|
modalDirtyRef.current = true;
|
|
};
|
|
|
|
const handleDelete = async (id) => {
|
|
await apiFetch(API.destroy(id), { method: 'DELETE' });
|
|
showToast('사진대지가 삭제되었습니다.');
|
|
modalDirtyRef.current = true;
|
|
};
|
|
|
|
const handleSelectItem = async (item) => {
|
|
modalDirtyRef.current = false;
|
|
try {
|
|
const res = await apiFetch(API.show(item.id));
|
|
setSelectedItem(res.data);
|
|
} catch {
|
|
setSelectedItem(item);
|
|
}
|
|
};
|
|
|
|
const handleCloseDetail = useCallback(() => {
|
|
setSelectedItem(null);
|
|
if (modalDirtyRef.current) {
|
|
modalDirtyRef.current = false;
|
|
fetchList();
|
|
}
|
|
}, [fetchList]);
|
|
|
|
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 && <ToastNotification 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={handleCloseDetail}
|
|
onUpload={handleUpload}
|
|
onDeletePhoto={handleDeletePhoto}
|
|
onUpdate={handleUpdate}
|
|
onDelete={handleDelete}
|
|
onRefresh={refreshSelected}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
|
@endverbatim
|
|
</script>
|
|
@endpush
|