Files
sam-manage/resources/views/juil/pmis-archive-notice.blade.php
김보곤 2102f4a398 feat: [pmis] PMIS 자료실/안전관리/품질관리 기능 추가 및 개선
- 자료실 하위 3개 메뉴: 자료보관함, 매뉴얼, 공지사항
- 자료보관함: 폴더 트리 + 파일 업로드/다운로드/삭제
- 매뉴얼/공지사항: 게시판형 CRUD + 첨부파일
- 안전관리: 안전보건교육, TBM현황, 위험성평가, 재해예방조치
- 품질관리: 시정조치 UI 페이지
- 대시보드: 슈퍼관리자 전용 레거시 사이트 참고 카드
- 작업일보/출면일보 오류 수정 및 기능 개선
- 설비 사진 업로드, 근로계약서 종료일 수정
2026-03-12 21:11:21 +09:00

421 lines
25 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, useEffect, useCallback, useRef } = React;
const API = '/juil/construction-pmis/api/notices';
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 NoticeForm({ notice, isAdmin, onSaved, onClose }) {
const isNew = !notice;
const fileRef = useRef(null);
const [title, setTitle] = useState(notice?.title || '');
const [content, setContent] = useState(notice?.content || '');
const [files, setFiles] = useState([]);
const [existingAttachments, setExistingAttachments] = useState(notice?.attachments || []);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(isNew);
const formatSize = (bytes) => {
if (bytes >= 1048576) return (bytes/1048576).toFixed(1)+' M';
if (bytes >= 1024) return (bytes/1024).toFixed(1)+' K';
return bytes+' B';
};
const handleSave = async () => {
if (!title.trim()) return alert('제목을 입력해주세요.');
setSaving(true);
try {
const fd = new FormData();
fd.append('title', title);
fd.append('content', content);
files.forEach(f => fd.append('files[]', f));
const url = notice?.id ? `${API}/${notice.id}` : API;
const res = await fetch(url, {
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);
onSaved();
} catch (e) { alert(e.message); }
finally { setSaving(false); }
};
const handleDeleteAttachment = async (attId) => {
if (!confirm('첨부파일을 삭제하시겠습니까?')) return;
await fetch(`${API}/attachments/${attId}`, { method: 'DELETE', headers: jsonHeaders });
setExistingAttachments(prev => prev.filter(a => a.id !== attId));
};
const labelCls = 'text-sm font-medium text-gray-600 whitespace-nowrap pt-2';
return (
<div className="bg-white border border-gray-200 rounded-lg mb-4">
<div className="border-b border-gray-200 px-6 py-4">
<h2 className="text-base font-bold text-gray-800">공지사항</h2>
</div>
<div className="px-6 py-4 space-y-4">
{/* 제목 */}
<div className="flex gap-4">
<div className={labelCls} style={{width:80}}>제목 <span className="text-red-500">*</span></div>
<div className="flex-1">
{editing ? (
<input type="text" value={title} onChange={e => setTitle(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2 text-sm" placeholder="제목을 입력하세요" />
) : (
<div className="text-sm text-gray-800 py-2">{title}</div>
)}
</div>
</div>
{/* 내용 */}
<div className="flex gap-4">
<div className={labelCls} style={{width:80}}>내용 <span className="text-red-500">*</span></div>
<div className="flex-1">
{editing ? (
<textarea value={content} onChange={e => setContent(e.target.value)} rows={10}
className="w-full border border-gray-300 rounded px-3 py-2 text-sm resize-y" placeholder="내용을 입력하세요" />
) : (
<div className="text-sm text-gray-800 py-2 whitespace-pre-wrap min-h-[120px] border border-gray-200 rounded px-3 py-2 bg-gray-50">{content || '-'}</div>
)}
</div>
</div>
{/* 첨부파일 */}
<div className="flex gap-4">
<div className={labelCls} style={{width:80}}>첨부파일</div>
<div className="flex-1">
{(existingAttachments.length > 0 || files.length > 0) && (
<table className="w-full text-sm border border-gray-200 rounded mb-2">
<thead className="bg-gray-50">
<tr>
{editing && <th className="px-2 py-2 text-center" style={{width:30}}></th>}
<th className="px-3 py-2 text-left text-gray-600 font-medium">파일명</th>
<th className="px-3 py-2 text-center text-gray-600 font-medium" style={{width:70}}>크기</th>
{!editing && <th className="px-3 py-2 text-center text-gray-600 font-medium" style={{width:50}}>받기</th>}
{!editing && <th className="px-3 py-2 text-center text-gray-600 font-medium" style={{width:50}}>보기</th>}
</tr>
</thead>
<tbody>
{existingAttachments.map(a => (
<tr key={'ex-'+a.id} className="border-t border-gray-100">
{editing && (
<td className="px-2 py-2 text-center">
<button onClick={() => handleDeleteAttachment(a.id)} className="text-red-400 hover:text-red-600"><i className="ri-close-line"></i></button>
</td>
)}
<td className="px-3 py-2 text-gray-700">{a.fileName}</td>
<td className="px-3 py-2 text-center text-gray-500">{a.size}</td>
{!editing && (
<td className="px-3 py-2 text-center">
<a href={a.downloadUrl} className="text-blue-500 hover:text-blue-700"><i className="ri-download-2-line"></i></a>
</td>
)}
{!editing && (
<td className="px-3 py-2 text-center">
<a href={a.downloadUrl} target="_blank" className="text-blue-500 hover:text-blue-700"><i className="ri-eye-line"></i></a>
</td>
)}
</tr>
))}
{files.map((f, i) => (
<tr key={'new-'+i} className="border-t border-gray-100 bg-blue-50/30">
{editing && (
<td className="px-2 py-2 text-center">
<button onClick={() => setFiles(prev => prev.filter((_, idx) => idx !== i))} className="text-red-400 hover:text-red-600"><i className="ri-close-line"></i></button>
</td>
)}
<td className="px-3 py-2 text-gray-700"><i className="ri-add-line text-blue-500 mr-1"></i>{f.name}</td>
<td className="px-3 py-2 text-center text-gray-500">{formatSize(f.size)}</td>
</tr>
))}
</tbody>
</table>
)}
{editing && (
<div>
<button onClick={() => fileRef.current?.click()}
className="border border-gray-300 px-3 py-1.5 rounded text-sm text-gray-600 hover:bg-gray-50 inline-flex items-center gap-1">
<i className="ri-attachment-2"></i> 파일 추가
</button>
<input ref={fileRef} type="file" multiple className="hidden" onChange={e => { if(e.target.files.length) setFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value=''; }} />
</div>
)}
{!editing && existingAttachments.length === 0 && (
<div className="text-sm text-gray-400 py-2">첨부파일 없음</div>
)}
</div>
</div>
</div>
{/* 하단 버튼 */}
<div className="flex items-center gap-2 px-6 py-3 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>
{isAdmin && !editing && notice?.id && (
<>
<button onClick={() => setEditing(true)} className="bg-yellow-500 text-white px-4 py-2 rounded text-sm font-semibold hover:bg-yellow-600">수정</button>
<button onClick={async () => {
if (!confirm('삭제하시겠습니까?')) return;
await fetch(`${API}/${notice.id}`, { method: 'DELETE', headers: jsonHeaders });
onSaved();
}} className="bg-red-500 text-white px-4 py-2 rounded text-sm font-semibold hover:bg-red-600">삭제</button>
</>
)}
{editing && (
<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 ? '저장 중...' : (notice?.id ? '수정 저장' : '등록')}
</button>
)}
</div>
</div>
);
}
/* ═══════ 메인 ═══════ */
function App(){
const [isAdmin, setIsAdmin] = useState(false);
const [searchText, setSearchText] = useState('');
const [showDeleted, setShowDeleted] = useState(false);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState('list');
const [selectedNotice, setSelectedNotice] = useState(null);
useEffect(() => {
fetch('/juil/construction-pmis/profile', { headers: { Accept: 'application/json' } })
.then(r => r.json())
.then(d => setIsAdmin(d.worker?.is_admin || false))
.catch(() => {});
}, []);
const loadList = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (searchText) params.set('search', searchText);
if (showDeleted) params.set('with_trashed', '1');
const res = await fetch(API + '?' + params.toString(), { headers: { Accept: 'application/json' } });
const d = await res.json();
setData(d.notices || []);
} catch {}
finally { setLoading(false); }
}, [searchText, showDeleted]);
useEffect(() => { loadList(); }, [showDeleted]);
const handleSearch = () => { setPage(0); loadList(); };
const handleView = async (id) => {
try {
const res = await fetch(`${API}/${id}`, { headers: { Accept: 'application/json' } });
const d = await res.json();
setSelectedNotice(d.notice);
setMode('view');
} catch {}
};
const handleSaved = () => {
setMode('list');
setSelectedNotice(null);
loadList();
};
const handleBackToList = () => {
setMode('list');
setSelectedNotice(null);
};
const totalPages = Math.max(1, Math.ceil(data.length / pageSize));
const paged = data.slice(page * pageSize, (page + 1) * pageSize);
const thCls = 'px-4 py-3 text-center font-semibold text-gray-600 text-sm whitespace-nowrap bg-gray-100';
return(
<div className="flex bg-gray-100" style={{height:'calc(100vh - 56px)'}}>
<PmisSidebar activePage="archive-notice"/>
<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> &gt; <span>자료실</span> &gt; <span className="text-gray-600">공지사항</span>
</div>
<h1 className="text-lg font-bold text-gray-800">공지사항</h1>
</div>
{/* 컨텐츠 */}
<div className="flex-1 overflow-auto p-6">
{/* 등록/상세 폼 */}
{(mode === 'new' || mode === 'view') && (
<NoticeForm
notice={mode === 'new' ? null : selectedNotice}
isAdmin={isAdmin}
onSaved={handleSaved}
onClose={handleBackToList}
/>
)}
{/* 목록 */}
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
{/* 검색 바 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<input type="text" value={searchText} onChange={e => setSearchText(e.target.value)}
placeholder="Search..." className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width:200}}
onKeyDown={e => e.key === 'Enter' && handleSearch()} />
<button onClick={handleSearch} className="w-8 h-8 flex items-center justify-center text-gray-500 hover:text-gray-700">
<i className="ri-search-line"></i>
</button>
</div>
{isAdmin && (
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" checked={showDeleted} onChange={e => setShowDeleted(e.target.checked)} />
삭제항목 보기
</label>
)}
</div>
{isAdmin && mode === 'list' && (
<button onClick={() => { setMode('new'); setSelectedNotice(null); }}
className="bg-blue-700 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-800">등록</button>
)}
</div>
{/* 테이블 */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr>
<th className={thCls} style={{width:80}}>No</th>
<th className={thCls + ' text-left'}>제목</th>
<th className={thCls} style={{width:160}}>작성일시</th>
<th className={thCls} style={{width:100}}>작성자</th>
<th className={thCls} style={{width:80}}>조회수</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={5} className="text-center py-16 text-gray-400"><i className="ri-loader-4-line animate-spin text-xl"></i></td></tr>
) : paged.length > 0 ? paged.map((d, i) => (
<tr key={d.id} onClick={() => !d.isDeleted && handleView(d.id)}
className={`border-b border-gray-100 transition ${d.isDeleted ? 'bg-red-50/50 opacity-50' : 'hover:bg-blue-50/30 cursor-pointer'} ${selectedNotice?.id === d.id ? 'bg-blue-50' : ''}`}>
<td className="px-4 py-3 text-center text-gray-500">{data.length - (page * pageSize + i)}</td>
<td className="px-4 py-3 text-gray-800">
{d.isDeleted && <span className="text-xs text-red-400 mr-1">[삭제됨]</span>}
{d.title}
{d.hasAttachment && <i className="ri-attachment-2 text-gray-400 ml-1"></i>}
</td>
<td className="px-4 py-3 text-center text-gray-600">{d.createdAt}</td>
<td className="px-4 py-3 text-center text-gray-700">{d.author}</td>
<td className="px-4 py-3 text-center text-gray-500">{d.views}</td>
</tr>
)) : (
<tr>
<td colSpan={5} className="text-center py-16 text-gray-400">조회된 데이터가 없습니다.</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<div className="flex items-center gap-2 px-4 py-3 border-t border-gray-200 bg-gray-50">
<button onClick={() => setPage(0)} disabled={page === 0}
className="px-2 py-1 text-sm text-gray-500 hover:text-gray-700 disabled:opacity-30"><i className="ri-skip-back-mini-line"></i></button>
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}
className="px-2 py-1 text-sm text-gray-500 hover:text-gray-700 disabled:opacity-30"><i className="ri-arrow-left-s-line"></i></button>
<span className="px-3 py-1 text-sm text-gray-700 border border-gray-300 rounded bg-white min-w-[32px] text-center">{page + 1}</span>
<button onClick={() => setPage(Math.min(totalPages - 1, page + 1))} disabled={page >= totalPages - 1}
className="px-2 py-1 text-sm text-gray-500 hover:text-gray-700 disabled:opacity-30"><i className="ri-arrow-right-s-line"></i></button>
<button onClick={() => setPage(totalPages - 1)} disabled={page >= totalPages - 1}
className="px-2 py-1 text-sm text-gray-500 hover:text-gray-700 disabled:opacity-30"><i className="ri-skip-forward-mini-line"></i></button>
<select value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(0); }}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white ml-2">
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
</div>
</div>
</div>
</div>);
}
ReactDOM.render(<App/>, document.getElementById('root'));
@endverbatim
</script>
@endpush