- 자료실 하위 3개 메뉴: 자료보관함, 매뉴얼, 공지사항 - 자료보관함: 폴더 트리 + 파일 업로드/다운로드/삭제 - 매뉴얼/공지사항: 게시판형 CRUD + 첨부파일 - 안전관리: 안전보건교육, TBM현황, 위험성평가, 재해예방조치 - 품질관리: 시정조치 UI 페이지 - 대시보드: 슈퍼관리자 전용 레거시 사이트 참고 카드 - 작업일보/출면일보 오류 수정 및 기능 개선 - 설비 사진 업로드, 근로계약서 종료일 수정
800 lines
44 KiB
PHP
800 lines
44 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, useRef, useCallback, useMemo } = React;
|
|
|
|
const API_BASE = '/juil/construction-pmis/api';
|
|
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
|
|
async function api(path, opts = {}) {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': CSRF,
|
|
...opts.headers,
|
|
},
|
|
...opts,
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.message || `HTTP ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════
|
|
PMIS 사이드바
|
|
════════════════════════════════════════════════ */
|
|
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}
|
|
<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 WorkerModal({ open, onClose, onSaved, worker, jobTypes }) {
|
|
const isEdit = !!worker?.id;
|
|
const [form, setForm] = useState({
|
|
company_name: '', trade_name: '', job_type_id: '', name: '',
|
|
phone: '', birth_date: '', ssn_gender: '', wage: '', blood_type: '', remark: '',
|
|
});
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [showJobTypeInput, setShowJobTypeInput] = useState(false);
|
|
const [newJobTypeName, setNewJobTypeName] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
if (worker) {
|
|
setForm({
|
|
company_name: worker.company_name || '',
|
|
trade_name: worker.trade_name || '',
|
|
job_type_id: worker.job_type_id || '',
|
|
name: worker.name || '',
|
|
phone: worker.phone || '',
|
|
birth_date: worker.birth_date || '',
|
|
ssn_gender: worker.ssn_gender || '',
|
|
wage: worker.wage || '',
|
|
blood_type: worker.blood_type || '',
|
|
remark: worker.remark || '',
|
|
});
|
|
} else {
|
|
setForm({
|
|
company_name: '', trade_name: '', job_type_id: '', name: '',
|
|
phone: '', birth_date: '', ssn_gender: '', wage: '', blood_type: '', remark: '',
|
|
});
|
|
}
|
|
setError('');
|
|
setShowJobTypeInput(false);
|
|
setNewJobTypeName('');
|
|
}
|
|
}, [open, worker]);
|
|
|
|
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
|
|
|
async function handleSubmit(e) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
setError('');
|
|
try {
|
|
const body = { ...form, wage: form.wage ? parseInt(form.wage) : 0 };
|
|
if (!body.job_type_id) delete body.job_type_id;
|
|
if (isEdit) {
|
|
await api(`/workers/${worker.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
|
} else {
|
|
await api('/workers', { method: 'POST', body: JSON.stringify(body) });
|
|
}
|
|
onSaved();
|
|
onClose();
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleAddJobType() {
|
|
if (!newJobTypeName.trim()) return;
|
|
try {
|
|
const created = await api('/job-types', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name: newJobTypeName.trim() }),
|
|
});
|
|
set('job_type_id', created.id);
|
|
setShowJobTypeInput(false);
|
|
setNewJobTypeName('');
|
|
onSaved(); // refresh job types
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
}
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
|
<h2 className="text-lg font-bold text-gray-800">
|
|
{isEdit ? '인원 수정' : '인원 등록'}
|
|
</h2>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
|
<i className="ri-close-line text-xl"></i>
|
|
</button>
|
|
</div>
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm">{error}</div>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">업체명 <span className="text-red-500">*</span></label>
|
|
<input type="text" value={form.company_name} onChange={e => set('company_name', e.target.value)}
|
|
required className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">공종 <span className="text-red-500">*</span></label>
|
|
<input type="text" value={form.trade_name} onChange={e => set('trade_name', e.target.value)}
|
|
required className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">직종 <span className="text-red-500">*</span></label>
|
|
<div className="flex items-center gap-2">
|
|
<select value={form.job_type_id} onChange={e => set('job_type_id', e.target.value)}
|
|
required className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
<option value="">직종 선택</option>
|
|
{jobTypes.map(jt => (
|
|
<option key={jt.id} value={jt.id}>{jt.name}</option>
|
|
))}
|
|
</select>
|
|
<button type="button" onClick={() => setShowJobTypeInput(!showJobTypeInput)}
|
|
className="shrink-0 bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-2 rounded-lg text-sm border border-gray-300">
|
|
<i className="ri-add-line"></i> 직종추가
|
|
</button>
|
|
</div>
|
|
{showJobTypeInput && (
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<input type="text" value={newJobTypeName} onChange={e => setNewJobTypeName(e.target.value)}
|
|
placeholder="새 직종명 입력" className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
|
<button type="button" onClick={handleAddJobType}
|
|
className="bg-blue-600 text-white px-3 py-2 rounded-lg text-sm hover:bg-blue-700">추가</button>
|
|
<button type="button" onClick={() => { setShowJobTypeInput(false); setNewJobTypeName(''); }}
|
|
className="text-gray-500 hover:text-gray-700 text-sm">취소</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">성명 <span className="text-red-500">*</span></label>
|
|
<input type="text" value={form.name} onChange={e => set('name', e.target.value)}
|
|
required className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">전화번호</label>
|
|
<input type="text" value={form.phone} onChange={e => set('phone', e.target.value)}
|
|
placeholder="010-0000-0000"
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">생년월일 (YYMMDD)</label>
|
|
<input type="text" value={form.birth_date} onChange={e => set('birth_date', e.target.value)}
|
|
maxLength={6} placeholder="YYMMDD"
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">성별구분</label>
|
|
<select value={form.ssn_gender} onChange={e => set('ssn_gender', e.target.value)}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
<option value="">선택</option>
|
|
<option value="1">1 (남, ~1999)</option>
|
|
<option value="2">2 (여, ~1999)</option>
|
|
<option value="3">3 (남, 2000~)</option>
|
|
<option value="4">4 (여, 2000~)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">혈액형</label>
|
|
<select value={form.blood_type} onChange={e => set('blood_type', e.target.value)}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
<option value="">선택</option>
|
|
<option value="A">A</option>
|
|
<option value="B">B</option>
|
|
<option value="O">O</option>
|
|
<option value="AB">AB</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">노임단가 (원)</label>
|
|
<input type="number" value={form.wage} onChange={e => set('wage', e.target.value)}
|
|
min="0" step="1000" placeholder="0"
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">비고</label>
|
|
<textarea value={form.remark} onChange={e => set('remark', e.target.value)} rows={2}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none" />
|
|
</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 hover:bg-gray-200 rounded-lg">취소</button>
|
|
<button type="submit" disabled={saving}
|
|
className="px-6 py-2 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50">
|
|
{saving ? '저장 중...' : (isEdit ? '수정' : '등록')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════
|
|
탭 1: 인원등록 (실제 CRUD)
|
|
════════════════════════════════════════════════ */
|
|
function TabRegister() {
|
|
const [workers, setWorkers] = useState([]);
|
|
const [jobTypes, setJobTypes] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [pagination, setPagination] = useState({ current_page: 1, last_page: 1, total: 0, per_page: 15 });
|
|
|
|
// 필터
|
|
const [search, setSearch] = useState('');
|
|
const [filterCompany, setFilterCompany] = useState('');
|
|
const [filterJobType, setFilterJobType] = useState('');
|
|
const [perPage, setPerPage] = useState(15);
|
|
const [page, setPage] = useState(1);
|
|
|
|
// 모달
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editWorker, setEditWorker] = useState(null);
|
|
|
|
// 선택
|
|
const [selected, setSelected] = useState(new Set());
|
|
|
|
const fetchJobTypes = useCallback(async () => {
|
|
try {
|
|
const data = await api('/job-types');
|
|
setJobTypes(data);
|
|
} catch {}
|
|
}, []);
|
|
|
|
const fetchWorkers = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.set('page', page);
|
|
params.set('per_page', perPage);
|
|
if (search) params.set('search', search);
|
|
if (filterCompany) params.set('company', filterCompany);
|
|
if (filterJobType) params.set('job_type_id', filterJobType);
|
|
const data = await api(`/workers?${params}`);
|
|
setWorkers(data.data);
|
|
setPagination({
|
|
current_page: data.current_page,
|
|
last_page: data.last_page,
|
|
total: data.total,
|
|
per_page: data.per_page,
|
|
});
|
|
} catch {}
|
|
setLoading(false);
|
|
}, [page, perPage, search, filterCompany, filterJobType]);
|
|
|
|
useEffect(() => { fetchJobTypes(); }, []);
|
|
useEffect(() => { fetchWorkers(); }, [fetchWorkers]);
|
|
|
|
function handleSearch() {
|
|
setPage(1);
|
|
fetchWorkers();
|
|
}
|
|
|
|
function handleAdd() {
|
|
setEditWorker(null);
|
|
setModalOpen(true);
|
|
}
|
|
|
|
function randomWorkerData() {
|
|
const lastNames = ['김','이','박','최','정','강','조','윤','장','임','한','오','서','신','권','황','안','송','류','홍'];
|
|
const firstNames = ['민수','영호','재영','성민','동환','상준','현석','종선','상우','대호','준호','철수','영식','길동','강사'];
|
|
const companies = ['(주)대림건설','(주)현대건설','(주)삼성물산','(주)GS건설','(주)포스코건설','(주)대우건설'];
|
|
const trades = ['방화셔터공사','철근콘크리트공사','전기공사','설비공사','도장공사','미장공사','타일공사','배관공사'];
|
|
const bloods = ['A','B','O','AB'];
|
|
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
|
|
const pad = n => String(n).padStart(2, '0');
|
|
const y = 60 + Math.floor(Math.random() * 40);
|
|
const m = 1 + Math.floor(Math.random() * 12);
|
|
const d = 1 + Math.floor(Math.random() * 28);
|
|
const jt = jobTypes.length > 0 ? pick(jobTypes) : null;
|
|
return {
|
|
company_name: pick(companies),
|
|
trade_name: pick(trades),
|
|
job_type_id: jt ? jt.id : '',
|
|
name: pick(lastNames) + pick(firstNames),
|
|
phone: '010-' + String(1000 + Math.floor(Math.random() * 9000)) + '-' + String(1000 + Math.floor(Math.random() * 9000)),
|
|
birth_date: pad(y) + pad(m) + pad(d),
|
|
ssn_gender: y >= 0 && y <= 99 ? (y >= 0 ? String(Math.random() > 0.5 ? 1 : 2) : String(Math.random() > 0.5 ? 3 : 4)) : '1',
|
|
wage: String((15 + Math.floor(Math.random() * 16)) * 10000),
|
|
blood_type: pick(bloods),
|
|
remark: '',
|
|
};
|
|
}
|
|
|
|
function handleQuickAdd() {
|
|
setEditWorker(randomWorkerData());
|
|
setModalOpen(true);
|
|
}
|
|
|
|
function handleEdit(w) {
|
|
setEditWorker(w);
|
|
setModalOpen(true);
|
|
}
|
|
|
|
async function handleDelete(id) {
|
|
if (!confirm('정말 삭제하시겠습니까?')) return;
|
|
try {
|
|
await api(`/workers/${id}`, { method: 'DELETE' });
|
|
fetchWorkers();
|
|
} catch (err) {
|
|
alert(err.message);
|
|
}
|
|
}
|
|
|
|
async function handleBulkDelete() {
|
|
if (selected.size === 0) return;
|
|
if (!confirm(`선택한 ${selected.size}명을 삭제하시겠습니까?`)) return;
|
|
for (const id of selected) {
|
|
try { await api(`/workers/${id}`, { method: 'DELETE' }); } catch {}
|
|
}
|
|
setSelected(new Set());
|
|
fetchWorkers();
|
|
}
|
|
|
|
function handleSaved() {
|
|
fetchWorkers();
|
|
fetchJobTypes();
|
|
}
|
|
|
|
function toggleSelect(id) {
|
|
setSelected(prev => {
|
|
const s = new Set(prev);
|
|
s.has(id) ? s.delete(id) : s.add(id);
|
|
return s;
|
|
});
|
|
}
|
|
|
|
function toggleAll() {
|
|
if (selected.size === workers.length) {
|
|
setSelected(new Set());
|
|
} else {
|
|
setSelected(new Set(workers.map(w => w.id)));
|
|
}
|
|
}
|
|
|
|
// 업체 목록 (현재 페이지 근로자 기준 + 빈 값)
|
|
const companies = useMemo(() => [...new Set(workers.map(w => w.company_name).filter(Boolean))], [workers]);
|
|
|
|
function formatSsn(birthDate, gender) {
|
|
if (!birthDate) return '-';
|
|
return `${birthDate}-${gender || 'X'}XXXXXX`;
|
|
}
|
|
|
|
const pageNumbers = useMemo(() => {
|
|
const pages = [];
|
|
const start = Math.max(1, pagination.current_page - 2);
|
|
const end = Math.min(pagination.last_page, pagination.current_page + 2);
|
|
for (let i = start; i <= end; i++) pages.push(i);
|
|
return pages;
|
|
}, [pagination]);
|
|
|
|
return (
|
|
<div>
|
|
<WorkerModal
|
|
open={modalOpen}
|
|
onClose={() => setModalOpen(false)}
|
|
onSaved={handleSaved}
|
|
worker={editWorker}
|
|
jobTypes={jobTypes}
|
|
/>
|
|
|
|
{/* 필터 */}
|
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
|
<select value={filterCompany} onChange={e => { setFilterCompany(e.target.value); setPage(1); }}
|
|
className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
|
<option value="">전체 업체</option>
|
|
{companies.map(c => <option key={c} value={c}>{c}</option>)}
|
|
</select>
|
|
<select value={filterJobType} onChange={e => { setFilterJobType(e.target.value); setPage(1); }}
|
|
className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
|
<option value="">직종선택</option>
|
|
{jobTypes.map(jt => <option key={jt.id} value={jt.id}>{jt.name}</option>)}
|
|
</select>
|
|
<input type="text" placeholder="이름 또는 연락처 검색" value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
|
className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width: 180}} />
|
|
<button onClick={handleSearch} className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
|
<button onClick={handleAdd} className="bg-green-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-green-700">
|
|
<i className="ri-add-line mr-1"></i>추가
|
|
</button>
|
|
<button onClick={handleQuickAdd} className="bg-amber-500 text-white px-3 py-1.5 rounded text-sm font-semibold hover:bg-amber-600" title="랜덤 데이터로 추가">
|
|
<i className="ri-flashlight-line"></i>
|
|
</button>
|
|
{selected.size > 0 && (
|
|
<button onClick={handleBulkDelete} className="bg-red-500 text-white px-3 py-1.5 rounded text-sm hover:bg-red-600">
|
|
<i className="ri-delete-bin-line mr-1"></i>선택 삭제 ({selected.size})
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<div className="overflow-x-auto border border-gray-200 rounded-lg">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">
|
|
<input type="checkbox" checked={workers.length > 0 && selected.size === workers.length} onChange={toggleAll} />
|
|
</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">순번</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">업체</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">공종</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">직종</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">이름</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">연락처</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">주민등록번호</th>
|
|
<th className="px-3 py-2.5 text-center font-semibold text-gray-600 whitespace-nowrap">혈액형</th>
|
|
<th className="px-3 py-2.5 text-right font-semibold text-gray-600 whitespace-nowrap">노임단가</th>
|
|
<th className="px-3 py-2.5 text-center font-semibold text-gray-600 whitespace-nowrap">관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={11} className="text-center py-8 text-gray-400">불러오는 중...</td></tr>
|
|
) : workers.length === 0 ? (
|
|
<tr><td colSpan={11} className="text-center py-8 text-gray-400">등록된 인원이 없습니다.</td></tr>
|
|
) : workers.map((w, idx) => (
|
|
<tr key={w.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition">
|
|
<td className="px-3 py-2">
|
|
<input type="checkbox" checked={selected.has(w.id)} onChange={() => toggleSelect(w.id)} />
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-500">
|
|
{(pagination.current_page - 1) * pagination.per_page + idx + 1}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-700">{w.company_name}</td>
|
|
<td className="px-3 py-2 text-gray-700">{w.trade_name}</td>
|
|
<td className="px-3 py-2 text-gray-700">{w.job_type?.name || '-'}</td>
|
|
<td className="px-3 py-2 font-medium text-gray-800 cursor-pointer hover:text-blue-600" onClick={() => handleEdit(w)}>
|
|
{w.name}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-600">{w.phone || '-'}</td>
|
|
<td className="px-3 py-2 text-gray-600">{formatSsn(w.birth_date, w.ssn_gender)}</td>
|
|
<td className="px-3 py-2 text-center text-gray-600">{w.blood_type || '-'}</td>
|
|
<td className="px-3 py-2 text-right font-mono text-gray-800">{(w.wage || 0).toLocaleString()}</td>
|
|
<td className="px-3 py-2 text-center">
|
|
<button onClick={() => handleEdit(w)} className="text-blue-600 hover:text-blue-800 mr-2" title="수정">
|
|
<i className="ri-edit-line"></i>
|
|
</button>
|
|
<button onClick={() => handleDelete(w.id)} className="text-red-500 hover:text-red-700" title="삭제">
|
|
<i className="ri-delete-bin-line"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* 페이지네이션 */}
|
|
<div className="flex items-center justify-between mt-3 text-sm text-gray-500">
|
|
<div>총 {pagination.total}명</div>
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={() => setPage(Math.max(1, page-1))} disabled={page <= 1}
|
|
className="px-2 py-1 rounded hover:bg-gray-100 disabled:opacity-30"><</button>
|
|
{pageNumbers.map(p => (
|
|
<button key={p} onClick={() => setPage(p)}
|
|
className={`px-2 py-1 rounded text-xs ${p === pagination.current_page ? 'bg-blue-600 text-white' : 'hover:bg-gray-100'}`}>{p}</button>
|
|
))}
|
|
<button onClick={() => setPage(Math.min(pagination.last_page, page+1))} disabled={page >= pagination.last_page}
|
|
className="px-2 py-1 rounded hover:bg-gray-100 disabled:opacity-30">></button>
|
|
<select value={perPage} onChange={e => { setPerPage(+e.target.value); setPage(1); }}
|
|
className="border border-gray-300 rounded px-2 py-1 text-xs ml-2">
|
|
<option value={15}>15</option>
|
|
<option value={30}>30</option>
|
|
<option value={50}>50</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════
|
|
탭 2: 출역현황 (준비 중)
|
|
════════════════════════════════════════════════ */
|
|
function TabAttendance() {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const [date, setDate] = useState(today);
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
|
<select className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
|
<option>전체 업체</option>
|
|
</select>
|
|
<input type="date" value={date} onChange={e => setDate(e.target.value)}
|
|
className="border border-gray-300 rounded px-3 py-1.5 text-sm" />
|
|
<select className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
|
<option>직종선택</option>
|
|
</select>
|
|
<input type="text" placeholder="Search..." className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width: 160}} />
|
|
<button className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
|
<div className="ml-auto">
|
|
<button className="flex items-center gap-1 border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
|
<i className="ri-file-excel-2-line text-green-600"></i> Excel 다운로드
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center py-16 text-gray-400">
|
|
<div className="text-center">
|
|
<i className="ri-calendar-check-line text-4xl mb-2 block"></i>
|
|
<p className="text-sm">출역 데이터 테이블 구현 예정</p>
|
|
<p className="text-xs mt-1 text-gray-300">인원등록 후 출역 기록을 관리할 수 있습니다.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════
|
|
탭 3: 투입현황(업체별) (준비 중)
|
|
════════════════════════════════════════════════ */
|
|
function TabDeployCompany() {
|
|
const [viewBy, setViewBy] = useState('day');
|
|
const [period, setPeriod] = useState('1w');
|
|
const [startDate, setStartDate] = useState('2026-03-05');
|
|
const [endDate, setEndDate] = useState('2026-03-12');
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
|
<select className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
|
<option>전체 업체</option>
|
|
</select>
|
|
<div className="ml-auto">
|
|
<button className="flex items-center gap-1 border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
|
<i className="ri-file-excel-2-line text-green-600"></i> Excel 다운로드
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3 mb-4 text-sm">
|
|
<span className="text-gray-600 font-semibold">보기기준</span>
|
|
{[['day','일'],['week','주'],['month','월']].map(([v,l]) => (
|
|
<label key={v} className="flex items-center gap-1 cursor-pointer">
|
|
<input type="radio" name="viewBy" checked={viewBy===v} onChange={()=>setViewBy(v)} />
|
|
<span className={viewBy===v?'text-gray-800 font-medium':'text-gray-500'}>{l}</span>
|
|
</label>
|
|
))}
|
|
<span className="text-gray-600 font-semibold ml-4">조회기간</span>
|
|
{[['1w','1주'],['1m','1개월'],['3m','3개월']].map(([v,l]) => (
|
|
<label key={v} className="flex items-center gap-1 cursor-pointer">
|
|
<input type="radio" name="period" checked={period===v} onChange={()=>setPeriod(v)} />
|
|
<span className={period===v?'text-gray-800 font-medium':'text-gray-500'}>{l}</span>
|
|
</label>
|
|
))}
|
|
<span className="text-gray-600 font-semibold ml-4">날짜</span>
|
|
<input type="date" value={startDate} onChange={e=>setStartDate(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
|
|
<span className="text-gray-400">~</span>
|
|
<input type="date" value={endDate} onChange={e=>setEndDate(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
|
|
<button className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center py-16 text-gray-400">
|
|
<div className="text-center">
|
|
<i className="ri-team-line text-4xl mb-2 block"></i>
|
|
<p className="text-sm">업체별 투입현황 구현 예정</p>
|
|
<p className="text-xs mt-1 text-gray-300">출역 데이터 기반으로 업체별 투입 현황을 집계합니다.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════
|
|
탭 4: 투입현황(근로자별) (준비 중)
|
|
════════════════════════════════════════════════ */
|
|
function TabDeployWorker() {
|
|
const [viewBy, setViewBy] = useState('day');
|
|
const [period, setPeriod] = useState('1w');
|
|
const [startDate, setStartDate] = useState('2026-03-05');
|
|
const [endDate, setEndDate] = useState('2026-03-12');
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
|
<select className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
|
<option>전체 업체</option>
|
|
</select>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button className="flex items-center gap-1 border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
|
<i className="ri-file-excel-2-line text-green-600"></i> Excel 다운로드
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3 mb-4 text-sm">
|
|
<span className="text-gray-600 font-semibold">보기기준</span>
|
|
{[['day','일'],['week','주'],['month','월']].map(([v,l]) => (
|
|
<label key={v} className="flex items-center gap-1 cursor-pointer">
|
|
<input type="radio" name="vb2" checked={viewBy===v} onChange={()=>setViewBy(v)} />
|
|
<span className={viewBy===v?'text-gray-800 font-medium':'text-gray-500'}>{l}</span>
|
|
</label>
|
|
))}
|
|
<span className="text-gray-600 font-semibold ml-4">조회기간</span>
|
|
{[['1w','1주'],['1m','1개월'],['3m','3개월']].map(([v,l]) => (
|
|
<label key={v} className="flex items-center gap-1 cursor-pointer">
|
|
<input type="radio" name="pd2" checked={period===v} onChange={()=>setPeriod(v)} />
|
|
<span className={period===v?'text-gray-800 font-medium':'text-gray-500'}>{l}</span>
|
|
</label>
|
|
))}
|
|
<span className="text-gray-600 font-semibold ml-4">날짜</span>
|
|
<input type="date" value={startDate} onChange={e=>setStartDate(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
|
|
<span className="text-gray-400">~</span>
|
|
<input type="date" value={endDate} onChange={e=>setEndDate(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
|
|
<button className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center py-16 text-gray-400">
|
|
<div className="text-center">
|
|
<i className="ri-user-follow-line text-4xl mb-2 block"></i>
|
|
<p className="text-sm">근로자별 투입현황 구현 예정</p>
|
|
<p className="text-xs mt-1 text-gray-300">출역 데이터 기반으로 근로자별 투입 현황을 집계합니다.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════
|
|
메인 컴포넌트
|
|
════════════════════════════════════════════════ */
|
|
const TABS = [
|
|
{ id: 'register', label: '인원등록' },
|
|
{ id: 'attendance', label: '출역현황' },
|
|
{ id: 'deploy-company', label: '투입현황(업체별)' },
|
|
{ id: 'deploy-worker', label: '투입현황(근로자별)' },
|
|
];
|
|
|
|
function WorkforceApp() {
|
|
const [activeTab, setActiveTab] = useState('register');
|
|
|
|
return (
|
|
<div className="flex bg-gray-100" style={{ height: 'calc(100vh - 56px)' }}>
|
|
<PmisSidebar activePage="workforce" />
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
{/* 헤더 */}
|
|
<div className="bg-white border-b border-gray-200 px-6 pt-4 pb-0">
|
|
<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 mb-3">인원관리</h1>
|
|
{/* 탭 */}
|
|
<div className="flex gap-0 border-b-0">
|
|
{TABS.map(tab => (
|
|
<button key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition whitespace-nowrap ${
|
|
activeTab === tab.id
|
|
? 'border-blue-600 text-blue-700 bg-blue-50/50'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* 탭 컨텐츠 */}
|
|
<div className="flex-1 overflow-auto p-6">
|
|
{activeTab === 'register' && <TabRegister />}
|
|
{activeTab === 'attendance' && <TabAttendance />}
|
|
{activeTab === 'deploy-company' && <TabDeployCompany />}
|
|
{activeTab === 'deploy-worker' && <TabDeployWorker />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ReactDOM.render(<WorkforceApp />, document.getElementById('root'));
|
|
@endverbatim
|
|
</script>
|
|
@endpush
|