- 자료실 하위 3개 메뉴: 자료보관함, 매뉴얼, 공지사항 - 자료보관함: 폴더 트리 + 파일 업로드/다운로드/삭제 - 매뉴얼/공지사항: 게시판형 CRUD + 첨부파일 - 안전관리: 안전보건교육, TBM현황, 위험성평가, 재해예방조치 - 품질관리: 시정조치 UI 페이지 - 대시보드: 슈퍼관리자 전용 레거시 사이트 참고 카드 - 작업일보/출면일보 오류 수정 및 기능 개선 - 설비 사진 업로드, 근로계약서 종료일 수정
592 lines
33 KiB
PHP
592 lines
33 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '자료보관함 - 건설PMIS')
|
|
|
|
@section('content')
|
|
<div id="root"></div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.2.0/fonts/remixicon.css" rel="stylesheet" />
|
|
@include('partials.react-cdn')
|
|
<script type="text/babel">
|
|
@verbatim
|
|
const { useState, useMemo, useCallback, useEffect, useRef } = React;
|
|
|
|
const API = '/juil/construction-pmis/api/archive';
|
|
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
const jsonHeaders = { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF };
|
|
|
|
/* ═══════ 사이드바 ═══════ */
|
|
const PMIS_MENUS=[
|
|
{icon:'ri-building-2-line',label:'BIM 관리',id:'bim',children:[{label:'BIM 뷰어',id:'bim-viewer',url:'/juil/construction-pmis/bim-viewer'}]},
|
|
{icon:'ri-line-chart-line',label:'시공관리',id:'construction',children:[
|
|
{label:'인원관리',id:'workforce',url:'/juil/construction-pmis/workforce'},
|
|
{label:'장비관리',id:'equipment',url:'/juil/construction-pmis/equipment'},
|
|
{label:'자재관리',id:'materials',url:'/juil/construction-pmis/materials'},
|
|
{label:'공사량관리',id:'work-volume',url:'/juil/construction-pmis/work-volume'},
|
|
{label:'출면일보',id:'daily-attendance',url:'/juil/construction-pmis/daily-attendance'},
|
|
{label:'작업일보',id:'daily-report',url:'/juil/construction-pmis/daily-report'},
|
|
]},
|
|
{icon:'ri-file-list-3-line',label:'품질관리',id:'quality',children:[
|
|
{label:'시정조치',id:'corrective-action',url:'/juil/construction-pmis/corrective-action'},
|
|
]},
|
|
{icon:'ri-shield-check-line',label:'안전관리',id:'safety',children:[
|
|
{label:'안전보건교육',id:'safety-education',url:'/juil/construction-pmis/safety-education'},
|
|
{label:'TBM현장',id:'tbm',url:'/juil/construction-pmis/tbm'},
|
|
{label:'위험성 평가',id:'risk-assessment',url:'/juil/construction-pmis/risk-assessment'},
|
|
{label:'재해예방조치',id:'disaster-prevention',url:'/juil/construction-pmis/disaster-prevention'},
|
|
]},
|
|
{icon:'ri-folder-line',label:'자료실',id:'archive',children:[
|
|
{label:'자료보관함',id:'archive-files',url:'/juil/construction-pmis/archive-files'},
|
|
{label:'매뉴얼',id:'archive-manual',url:'/juil/construction-pmis/archive-manual'},
|
|
{label:'공지사항',id:'archive-notice',url:'/juil/construction-pmis/archive-notice'},
|
|
]},
|
|
];
|
|
|
|
function PmisSidebar({activePage}){
|
|
const[profile,setProfile]=useState(null);
|
|
const[expanded,setExpanded]=useState(()=>{for(const m of PMIS_MENUS){if(m.children?.some(c=>c.id===activePage))return m.id}return null});
|
|
useEffect(()=>{fetch('/juil/construction-pmis/profile',{headers:{Accept:'application/json'}}).then(r=>r.json()).then(d=>setProfile(d.worker)).catch(()=>{})},[]);
|
|
return(
|
|
<div className="bg-white border-r border-gray-200 shadow-sm flex flex-col shrink-0" style={{width:200}}>
|
|
<a href="/juil/construction-pmis" className="flex items-center gap-2 px-4 py-3 text-sm text-blue-600 hover:bg-blue-50 border-b border-gray-100 transition"><i className="ri-arrow-left-s-line text-lg"></i> PMIS 대시보드</a>
|
|
<div className="p-3 border-b border-gray-100 text-center">
|
|
<div className="w-12 h-12 mx-auto mb-1 rounded-full bg-gray-100 border-2 border-gray-200 flex items-center justify-center">
|
|
{profile?.profile_photo_path?<img src={profile.profile_photo_path} className="w-full h-full rounded-full object-cover"/>:<i className="ri-user-3-line text-xl text-gray-300"></i>}
|
|
</div>
|
|
<div className="text-sm font-bold text-gray-800">{profile?.name||'...'}</div>
|
|
<div className="text-xs text-gray-500 mt-0.5">{profile?.department||''}</div>
|
|
</div>
|
|
<div className="flex-1 overflow-auto py-1">
|
|
{PMIS_MENUS.map(m=>(
|
|
<div key={m.id}>
|
|
<div onClick={()=>setExpanded(expanded===m.id?null:m.id)} className={`flex items-center gap-2 px-4 py-2.5 text-sm cursor-pointer transition ${expanded===m.id?'bg-blue-50 text-blue-700 font-semibold':'text-gray-600 hover:bg-gray-50'}`}>
|
|
<i className={`${m.icon} text-base`}></i>{m.label}
|
|
{m.children&&<i className={`ml-auto ${expanded===m.id?'ri-arrow-down-s-line':'ri-arrow-right-s-line'} text-gray-400 text-xs`}></i>}
|
|
</div>
|
|
{expanded===m.id&&m.children?.map(c=>(<a key={c.id} href={c.url} className={`block pl-10 pr-4 py-2 text-sm transition ${c.id===activePage?'bg-blue-100 text-blue-800 font-semibold border-l-2 border-blue-600':'text-gray-500 hover:text-blue-600 hover:bg-gray-50'}`}>{c.label}</a>))}
|
|
</div>))}
|
|
</div>
|
|
</div>);
|
|
}
|
|
|
|
/* ═══════ 폴더 트리 노드 ═══════ */
|
|
function FolderNode({ node, depth, selectedId, onSelect, onContextMenu }) {
|
|
const [open, setOpen] = useState(true);
|
|
const hasChildren = node.children && node.children.length > 0;
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
className={`flex items-center gap-1 py-1 cursor-pointer hover:bg-blue-50 rounded transition ${selectedId === node.id ? 'bg-blue-100 text-blue-700 font-semibold' : 'text-gray-700'}`}
|
|
style={{ paddingLeft: depth * 20 + 8 }}
|
|
onClick={() => { onSelect(node.id); if (hasChildren) setOpen(!open); }}
|
|
onContextMenu={e => { e.preventDefault(); onContextMenu(e, node); }}
|
|
>
|
|
{hasChildren ? (
|
|
<i className={`text-xs text-gray-400 ${open ? 'ri-arrow-down-s-fill' : 'ri-arrow-right-s-fill'}`} style={{width:14}}></i>
|
|
) : (
|
|
<span style={{width:14}}></span>
|
|
)}
|
|
<i className="ri-folder-fill" style={{color:'#60a5fa', fontSize:16}}></i>
|
|
<span className="text-sm truncate">{node.name}</span>
|
|
</div>
|
|
{open && hasChildren && node.children.map(child => (
|
|
<FolderNode key={child.id} node={child} depth={depth + 1} selectedId={selectedId} onSelect={onSelect} onContextMenu={onContextMenu} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ═══════ 업로드 모달 ═══════ */
|
|
function UploadModal({ folderId, folderName, onClose, onUploaded }) {
|
|
const fileRef = useRef(null);
|
|
const [files, setFiles] = useState([]);
|
|
const [title, setTitle] = useState('');
|
|
const [siteName, setSiteName] = useState('');
|
|
const [uploading, setUploading] = useState(false);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
|
|
const addFiles = (newFiles) => {
|
|
setFiles(prev => [...prev, ...Array.from(newFiles)]);
|
|
};
|
|
|
|
const removeFile = (idx) => {
|
|
setFiles(prev => prev.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const handleDrop = (e) => {
|
|
e.preventDefault(); setDragOver(false);
|
|
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
|
|
};
|
|
|
|
const handleUpload = async () => {
|
|
if (!files.length) return alert('파일을 선택해주세요.');
|
|
if (!folderId) return alert('폴더를 선택해주세요.');
|
|
setUploading(true);
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('folder_id', folderId);
|
|
fd.append('title', title);
|
|
fd.append('site_name', siteName);
|
|
files.forEach(f => fd.append('files[]', f));
|
|
|
|
const res = await fetch(API + '/files', {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-TOKEN': CSRF, Accept: 'application/json' },
|
|
body: fd,
|
|
});
|
|
if (!res.ok) { const err = await res.json(); throw new Error(err.message || '업로드 실패'); }
|
|
const data = await res.json();
|
|
alert(data.message);
|
|
onUploaded();
|
|
onClose();
|
|
} catch (e) {
|
|
alert(e.message);
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
const formatSize = (bytes) => {
|
|
if (bytes >= 1048576) return (bytes/1048576).toFixed(1)+'MB';
|
|
if (bytes >= 1024) return (bytes/1024).toFixed(1)+'KB';
|
|
return bytes+'B';
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
|
<h3 className="text-base font-bold text-gray-800">파일 업로드</h3>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><i className="ri-close-line text-xl"></i></button>
|
|
</div>
|
|
<div className="p-5 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">업로드 폴더</label>
|
|
<div className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 rounded px-3 py-2 border border-gray-200">
|
|
<i className="ri-folder-fill text-blue-400"></i>
|
|
{folderName || '폴더를 선택해주세요'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">제목 (선택)</label>
|
|
<input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="제목을 입력하세요"
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm" />
|
|
</div>
|
|
<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)} placeholder="현장명을 입력하세요"
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">파일 선택</label>
|
|
<div
|
|
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition ${dragOver ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400'}`}
|
|
onClick={() => fileRef.current?.click()}
|
|
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleDrop}
|
|
>
|
|
<i className="ri-upload-cloud-2-line text-3xl text-gray-400 mb-2"></i>
|
|
<div className="text-sm text-gray-500">클릭하거나 파일을 드래그해서 업로드</div>
|
|
<div className="text-xs text-gray-400 mt-1">최대 50MB / 파일</div>
|
|
<input ref={fileRef} type="file" multiple className="hidden" onChange={e => { if(e.target.files.length) addFiles(e.target.files); e.target.value=''; }} />
|
|
</div>
|
|
</div>
|
|
{files.length > 0 && (
|
|
<div className="max-h-36 overflow-auto border border-gray-200 rounded">
|
|
{files.map((f, i) => (
|
|
<div key={i} className="flex items-center gap-2 px-3 py-2 text-sm border-b border-gray-100 last:border-0">
|
|
<i className="ri-file-line text-gray-400"></i>
|
|
<span className="flex-1 truncate text-gray-700">{f.name}</span>
|
|
<span className="text-xs text-gray-400 shrink-0">{formatSize(f.size)}</span>
|
|
<button onClick={() => removeFile(i)} className="text-red-400 hover:text-red-600"><i className="ri-close-line"></i></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
|
|
<button onClick={onClose} className="border border-gray-300 px-4 py-2 rounded text-sm hover:bg-gray-100">취소</button>
|
|
<button onClick={handleUpload} disabled={uploading || !files.length}
|
|
className="bg-blue-700 text-white px-5 py-2 rounded text-sm font-semibold hover:bg-blue-800 disabled:opacity-50">
|
|
{uploading ? '업로드 중...' : `업로드 (${files.length}개)`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ═══════ 폴더 추가 모달 ═══════ */
|
|
function FolderModal({ parentId, parentName, onClose, onCreated }) {
|
|
const [name, setName] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const handleSave = async () => {
|
|
if (!name.trim()) return alert('폴더명을 입력해주세요.');
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch(API + '/folders', {
|
|
method: 'POST', headers: jsonHeaders,
|
|
body: JSON.stringify({ name: name.trim(), parent_id: parentId || null }),
|
|
});
|
|
if (!res.ok) { const err = await res.json(); throw new Error(err.message || '생성 실패'); }
|
|
onCreated();
|
|
onClose();
|
|
} catch (e) { alert(e.message); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-sm" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
|
<h3 className="text-base font-bold text-gray-800">새 폴더</h3>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><i className="ri-close-line text-xl"></i></button>
|
|
</div>
|
|
<div className="p-5 space-y-3">
|
|
{parentName && (
|
|
<div className="text-sm text-gray-500">상위 폴더: <span className="font-medium text-gray-700">{parentName}</span></div>
|
|
)}
|
|
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="폴더명 입력"
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm" autoFocus
|
|
onKeyDown={e => e.key === 'Enter' && handleSave()} />
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
|
|
<button onClick={onClose} className="border border-gray-300 px-4 py-2 rounded text-sm hover:bg-gray-100">취소</button>
|
|
<button onClick={handleSave} disabled={saving}
|
|
className="bg-blue-700 text-white px-5 py-2 rounded text-sm font-semibold hover:bg-blue-800 disabled:opacity-50">
|
|
{saving ? '생성 중...' : '생성'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ═══════ 탭 ═══════ */
|
|
const FILE_TABS = ['전체','사진','동영상','문서'];
|
|
|
|
/* ═══════ 헬퍼: 폴더트리에서 이름 찾기 ═══════ */
|
|
function findFolderName(folders, id) {
|
|
for (const f of folders) {
|
|
if (f.id === id) return f.name;
|
|
if (f.children?.length) {
|
|
const found = findFolderName(f.children, id);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/* ═══════ 메인 ═══════ */
|
|
function App(){
|
|
const now = new Date();
|
|
const pad = (n) => String(n).padStart(2,'0');
|
|
const todayStr = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}`;
|
|
const yearAgo = new Date(now); yearAgo.setFullYear(yearAgo.getFullYear()-1);
|
|
const yearAgoStr = `${yearAgo.getFullYear()}-${pad(yearAgo.getMonth()+1)}-${pad(yearAgo.getDate())}`;
|
|
|
|
const [folders, setFolders] = useState([]);
|
|
const [selectedFolder, setSelectedFolder] = useState(null);
|
|
const [folderSearch, setFolderSearch] = useState('');
|
|
const [activeTab, setActiveTab] = useState('전체');
|
|
const [searchText, setSearchText] = useState('');
|
|
const [dateFrom, setDateFrom] = useState(yearAgoStr);
|
|
const [dateTo, setDateTo] = useState(todayStr);
|
|
const [includeSubfolder, setIncludeSubfolder] = useState(true);
|
|
const [checked, setChecked] = useState({});
|
|
const [data, setData] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const [showUpload, setShowUpload] = useState(false);
|
|
const [showNewFolder, setShowNewFolder] = useState(false);
|
|
const [ctxMenu, setCtxMenu] = useState(null);
|
|
|
|
/* 폴더 트리 로드 */
|
|
const loadFolders = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(API + '/folders', { headers: { Accept: 'application/json' } });
|
|
const d = await res.json();
|
|
setFolders(d.folders || []);
|
|
} catch {}
|
|
}, []);
|
|
|
|
/* 파일 목록 로드 */
|
|
const loadFiles = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (selectedFolder) params.set('folder_id', selectedFolder);
|
|
params.set('tab', activeTab);
|
|
params.set('search', searchText);
|
|
params.set('date_from', dateFrom);
|
|
params.set('date_to', dateTo);
|
|
params.set('include_subfolder', includeSubfolder ? '1' : '0');
|
|
|
|
const res = await fetch(API + '/files?' + params.toString(), { headers: { Accept: 'application/json' } });
|
|
const d = await res.json();
|
|
setData(d.files || []);
|
|
setChecked({});
|
|
} catch {}
|
|
finally { setLoading(false); }
|
|
}, [selectedFolder, activeTab, searchText, dateFrom, dateTo, includeSubfolder]);
|
|
|
|
useEffect(() => { loadFolders(); }, [loadFolders]);
|
|
useEffect(() => { loadFiles(); }, [selectedFolder, activeTab]);
|
|
|
|
const handleSearch = () => loadFiles();
|
|
|
|
/* 파일 삭제 */
|
|
const handleDeleteFiles = async () => {
|
|
const ids = Object.keys(checked).filter(k => checked[k]);
|
|
if (!ids.length) return alert('삭제할 파일을 선택해주세요.');
|
|
if (!confirm(`${ids.length}개 파일을 삭제하시겠습니까?`)) return;
|
|
for (const id of ids) {
|
|
await fetch(API + '/files/' + id, { method: 'DELETE', headers: jsonHeaders });
|
|
}
|
|
loadFiles();
|
|
};
|
|
|
|
/* 파일 다운로드 */
|
|
const handleDownload = () => {
|
|
const ids = Object.keys(checked).filter(k => checked[k]);
|
|
if (!ids.length) return alert('다운로드할 파일을 선택해주세요.');
|
|
ids.forEach(id => {
|
|
window.open(API + '/files/' + id + '/download', '_blank');
|
|
});
|
|
};
|
|
|
|
/* 폴더 삭제 */
|
|
const handleDeleteFolder = async (id) => {
|
|
if (!confirm('이 폴더와 하위 파일이 모두 삭제됩니다. 계속하시겠습니까?')) return;
|
|
await fetch(API + '/folders/' + id, { method: 'DELETE', headers: jsonHeaders });
|
|
if (selectedFolder === id) setSelectedFolder(null);
|
|
loadFolders();
|
|
loadFiles();
|
|
};
|
|
|
|
/* 폴더 이름 변경 */
|
|
const handleRenameFolder = async (id) => {
|
|
const currentName = findFolderName(folders, id) || '';
|
|
const newName = prompt('새 폴더명을 입력하세요:', currentName);
|
|
if (!newName || newName === currentName) return;
|
|
await fetch(API + '/folders/' + id, {
|
|
method: 'PUT', headers: jsonHeaders,
|
|
body: JSON.stringify({ name: newName }),
|
|
});
|
|
loadFolders();
|
|
};
|
|
|
|
/* 폴더 우클릭 메뉴 */
|
|
const handleFolderCtx = (e, node) => {
|
|
setCtxMenu({ x: e.clientX, y: e.clientY, node });
|
|
};
|
|
|
|
useEffect(() => {
|
|
const close = () => setCtxMenu(null);
|
|
document.addEventListener('click', close);
|
|
return () => document.removeEventListener('click', close);
|
|
}, []);
|
|
|
|
/* 폴더 검색 필터 */
|
|
const filterFolders = useCallback((nodes, query) => {
|
|
if (!query) return nodes;
|
|
return nodes.reduce((acc, node) => {
|
|
const childMatches = filterFolders(node.children || [], query);
|
|
if (node.name.includes(query) || childMatches.length > 0) {
|
|
acc.push({ ...node, children: childMatches.length > 0 ? childMatches : node.children });
|
|
}
|
|
return acc;
|
|
}, []);
|
|
}, []);
|
|
|
|
const filteredFolders = useMemo(() => filterFolders(folders, folderSearch), [folders, folderSearch, filterFolders]);
|
|
const selectedFolderName = selectedFolder ? findFolderName(folders, selectedFolder) : '전체';
|
|
const allChecked = data.length > 0 && data.every(d => checked[d.id]);
|
|
const thCls = 'px-3 py-2.5 text-center font-semibold text-gray-600 text-sm whitespace-nowrap';
|
|
|
|
return(
|
|
<div className="flex bg-gray-100" style={{height:'calc(100vh - 56px)'}}>
|
|
<PmisSidebar activePage="archive-files"/>
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
{/* 헤더 */}
|
|
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
|
<div className="flex items-center gap-2 text-xs text-gray-400 mb-2">
|
|
<i className="ri-home-4-line"></i><span>Home</span> > <span>자료실</span> > <span className="text-gray-600">자료보관함</span>
|
|
</div>
|
|
<h1 className="text-lg font-bold text-gray-800">자료보관함</h1>
|
|
</div>
|
|
|
|
{/* 컨텐츠 영역 */}
|
|
<div className="flex-1 flex overflow-hidden p-6 gap-4">
|
|
{/* 왼쪽: 폴더 트리 */}
|
|
<div className="bg-white border border-gray-200 rounded-lg flex flex-col shrink-0" style={{width:280}}>
|
|
<div className="flex items-center gap-1 p-3 border-b border-gray-200">
|
|
<input type="text" value={folderSearch} onChange={e => setFolderSearch(e.target.value)}
|
|
placeholder="폴더명 검색..." className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm" />
|
|
<button onClick={() => setShowNewFolder(true)} title="새 폴더"
|
|
className="w-7 h-7 flex items-center justify-center border border-gray-300 rounded text-gray-500 hover:bg-blue-50 hover:text-blue-600 text-sm">+</button>
|
|
</div>
|
|
<div className="flex-1 overflow-auto p-2">
|
|
<div
|
|
className={`flex items-center gap-1 py-1 px-2 text-sm cursor-pointer rounded transition ${selectedFolder === null ? 'bg-blue-100 text-blue-700 font-semibold' : 'text-gray-500 hover:bg-gray-50'}`}
|
|
onClick={() => setSelectedFolder(null)}
|
|
>
|
|
<i className="ri-folder-3-line text-gray-400" style={{fontSize:16}}></i>
|
|
<span>자료보관함 (전체)</span>
|
|
</div>
|
|
{filteredFolders.map(node => (
|
|
<FolderNode key={node.id} node={node} depth={1} selectedId={selectedFolder} onSelect={setSelectedFolder} onContextMenu={handleFolderCtx} />
|
|
))}
|
|
{folders.length === 0 && (
|
|
<div className="text-center py-8 text-gray-400 text-sm">
|
|
<i className="ri-folder-add-line text-2xl mb-2 block"></i>
|
|
+ 버튼으로 폴더를 추가하세요
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 오른쪽: 파일 목록 */}
|
|
<div className="flex-1 flex flex-col bg-white border border-gray-200 rounded-lg overflow-hidden">
|
|
{/* 탭 */}
|
|
<div className="flex items-center border-b border-gray-200 px-4">
|
|
{FILE_TABS.map(tab => (
|
|
<button key={tab} onClick={() => setActiveTab(tab)}
|
|
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition ${activeTab === tab ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
|
{tab === activeTab && <span className="inline-block w-2 h-2 rounded-full bg-red-500 mr-1.5" style={{verticalAlign:'middle'}}></span>}
|
|
{tab}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 필터 바 */}
|
|
<div className="flex items-center gap-3 flex-wrap px-4 py-3 border-b border-gray-200">
|
|
<input type="text" value={searchText} onChange={e => setSearchText(e.target.value)}
|
|
placeholder="제목, 파일명, 등록자 검색..." className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width:200}}
|
|
onKeyDown={e => e.key === 'Enter' && handleSearch()} />
|
|
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
|
|
className="border border-gray-300 rounded px-2 py-1.5 text-sm" />
|
|
<span className="text-gray-400">~</span>
|
|
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
|
|
className="border border-gray-300 rounded px-2 py-1.5 text-sm" />
|
|
<button onClick={handleSearch} className="bg-blue-700 text-white px-5 py-1.5 rounded text-sm font-semibold hover:bg-blue-800">검색</button>
|
|
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer">
|
|
<input type="checkbox" checked={includeSubfolder} onChange={e => setIncludeSubfolder(e.target.checked)} className="accent-red-500" />
|
|
하위폴더 포함 검색
|
|
</label>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button onClick={() => { if(!selectedFolder) return alert('업로드할 폴더를 선택해주세요.'); setShowUpload(true); }}
|
|
className="bg-blue-700 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-800">업로드</button>
|
|
<button onClick={handleDownload} className="border border-gray-300 px-4 py-1.5 rounded text-sm hover:bg-gray-50">다운로드</button>
|
|
<button onClick={handleDeleteFiles} className="border border-red-300 text-red-600 px-4 py-1.5 rounded text-sm hover:bg-red-50">삭제</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<div className="flex-1 overflow-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0">
|
|
<tr>
|
|
<th className={thCls} style={{width:60}}>번호</th>
|
|
<th className={thCls} style={{width:35}}>
|
|
<input type="checkbox" checked={allChecked} onChange={() => {
|
|
const c = {}; if (!allChecked) data.forEach(d => c[d.id] = true);
|
|
setChecked(c);
|
|
}} />
|
|
</th>
|
|
<th className={thCls}>제목</th>
|
|
<th className={thCls}>파일명</th>
|
|
<th className={thCls} style={{width:100}}>현장명</th>
|
|
<th className={thCls} style={{width:70}}>크기</th>
|
|
<th className={thCls} style={{width:80}}>등록자</th>
|
|
<th className={thCls} style={{width:100}}>등록일</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={8} className="text-center py-16 text-gray-400"><i className="ri-loader-4-line animate-spin text-xl"></i></td></tr>
|
|
) : data.length > 0 ? data.map((d, i) => (
|
|
<tr key={d.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition">
|
|
<td className="px-3 py-2.5 text-center text-gray-500">{i + 1}</td>
|
|
<td className="px-3 py-2.5 text-center">
|
|
<input type="checkbox" checked={!!checked[d.id]} onChange={() => setChecked(p => ({...p, [d.id]: !p[d.id]}))} />
|
|
</td>
|
|
<td className="px-3 py-2.5 text-gray-700">
|
|
<a href={d.filePath} target="_blank" className="hover:text-blue-600 hover:underline">{d.title}</a>
|
|
</td>
|
|
<td className="px-3 py-2.5 text-gray-700">{d.fileName}</td>
|
|
<td className="px-3 py-2.5 text-center text-gray-700">{d.siteName || '-'}</td>
|
|
<td className="px-3 py-2.5 text-center text-gray-600">{d.size}</td>
|
|
<td className="px-3 py-2.5 text-center text-gray-700">{d.registrant}</td>
|
|
<td className="px-3 py-2.5 text-center text-gray-600">{d.registeredAt}</td>
|
|
</tr>
|
|
)) : (
|
|
<tr>
|
|
<td colSpan={8} className="text-center py-16 text-blue-500">조회된 데이터가 없습니다.</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* 하단 정보 바 */}
|
|
{data.length > 0 && (
|
|
<div className="flex items-center gap-2 px-4 py-2 border-t border-gray-200 bg-gray-50">
|
|
<span className="text-sm text-gray-500">총 {data.length}건</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 업로드 모달 */}
|
|
{showUpload && (
|
|
<UploadModal
|
|
folderId={selectedFolder}
|
|
folderName={selectedFolderName}
|
|
onClose={() => setShowUpload(false)}
|
|
onUploaded={loadFiles}
|
|
/>
|
|
)}
|
|
|
|
{/* 폴더 추가 모달 */}
|
|
{showNewFolder && (
|
|
<FolderModal
|
|
parentId={selectedFolder}
|
|
parentName={selectedFolderName !== '전체' ? selectedFolderName : null}
|
|
onClose={() => setShowNewFolder(false)}
|
|
onCreated={loadFolders}
|
|
/>
|
|
)}
|
|
|
|
{/* 폴더 우클릭 메뉴 */}
|
|
{ctxMenu && (
|
|
<div className="fixed z-50 bg-white border border-gray-200 rounded-lg shadow-lg py-1" style={{left: ctxMenu.x, top: ctxMenu.y, minWidth:140}}>
|
|
<button onClick={() => { setSelectedFolder(ctxMenu.node.id); setShowNewFolder(true); setCtxMenu(null); }}
|
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2">
|
|
<i className="ri-folder-add-line text-blue-500"></i> 하위폴더 추가
|
|
</button>
|
|
<button onClick={() => { handleRenameFolder(ctxMenu.node.id); setCtxMenu(null); }}
|
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2">
|
|
<i className="ri-edit-line text-yellow-500"></i> 이름 변경
|
|
</button>
|
|
<button onClick={() => { handleDeleteFolder(ctxMenu.node.id); setCtxMenu(null); }}
|
|
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2">
|
|
<i className="ri-delete-bin-line"></i> 폴더 삭제
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>);
|
|
}
|
|
|
|
ReactDOM.render(<App/>, document.getElementById('root'));
|
|
@endverbatim
|
|
</script>
|
|
@endpush
|