- 시공관리 하위메뉴: 인원관리, 장비관리, 자재관리, 공사량관리, 출면일보, 작업일보 - 인원관리 4개 탭 구현: 인원등록, 출역현황, 투입현황(업체별), 투입현황(근로자별) - PMIS 사이드바에 시공관리 children 메뉴 추가 (대시보드, BIM 뷰어 포함) - 나머지 5개 메뉴 placeholder 페이지 생성
578 lines
40 KiB
PHP
578 lines
40 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;
|
|
|
|
/* ════════════════════════════════════════════════
|
|
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' },
|
|
{ icon: 'ri-shield-check-line', label: '안전관리', id: 'safety' },
|
|
{ icon: 'ri-folder-line', label: '자료실', id: 'archive' },
|
|
];
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════
|
|
샘플 데이터
|
|
════════════════════════════════════════════════ */
|
|
const SAMPLE_WORKERS = [
|
|
{ id: 18, company: '(주)주일기업', trade: '방화셔터공사', jobType: '현장소장', name: '김철수', phone: '010-5292-3623', ssn: '630205-1XXXXXX', blood: 'M', remark: 'N', wage: 200000 },
|
|
{ id: 17, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '김영식', phone: '010-6766-4600', ssn: '630212-1XXXXXX', blood: 'M', remark: 'N', wage: 200000 },
|
|
{ id: 16, company: '(주)주일기업', trade: '방화셔터공사', jobType: '철거/잡사', name: '김강사', phone: '010-5557-9522', ssn: '940713-1XXXXXX', blood: 'M', remark: 'Y', wage: 180000 },
|
|
{ id: 15, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '홍길동', phone: '010-4209-3618', ssn: '630320-1XXXXXX', blood: 'M', remark: 'N', wage: 170000 },
|
|
{ id: 14, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '박민수', phone: '010-6396-8603', ssn: '860902-2XXXXXX', blood: 'M', remark: 'N', wage: 200000 },
|
|
{ id: 13, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '이준호', phone: '010-6364-8466', ssn: '730426-1XXXXXX', blood: 'M', remark: 'N', wage: 180000 },
|
|
{ id: 12, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '정대호', phone: '010-8559-0517', ssn: '720219-1XXXXXX', blood: 'M', remark: 'N', wage: 180000 },
|
|
{ id: 11, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '오상호', phone: '010-8971-8806', ssn: '651111-1XXXXXX', blood: 'M', remark: 'N', wage: 200000 },
|
|
{ id: 10, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '한재영', phone: '010-6261-9738', ssn: '630522-1XXXXXX', blood: 'M', remark: 'N', wage: 200000 },
|
|
{ id: 9, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '윤성민', phone: '010-6261-8745', ssn: '681216-1XXXXXX', blood: 'M', remark: 'N', wage: 200000 },
|
|
{ id: 8, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '최동환', phone: '010-3630-1779', ssn: '721212-1XXXXXX', blood: 'M', remark: 'N', wage: 200000 },
|
|
{ id: 7, company: '(주)주일기업', trade: '방화셔터공사', jobType: '철거/잡사', name: '김상준', phone: '010-9272-2342', ssn: '700626-1XXXXXX', blood: 'M', remark: 'N', wage: 180000 },
|
|
{ id: 6, company: '(주)주일기업', trade: '방화셔터공사', jobType: '취기검사시', name: '박현석', phone: '010-1321-1779', ssn: '010413-3XXXXXX', blood: 'M', remark: 'N', wage: 200000 },
|
|
{ id: 5, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '이종선', phone: '010-4560-5697', ssn: '681029-1XXXXXX', blood: 'M', remark: 'N', wage: 180000 },
|
|
{ id: 4, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '김상우', phone: '010-5330-0941', ssn: '680609-1XXXXXX', blood: 'M', remark: 'N', wage: 180000 },
|
|
];
|
|
|
|
const SAMPLE_ATTENDANCE = [
|
|
{ id: 1, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '김철수', status: '현장소장', task: 'A동 1층 셔터시공', hours: '', wage: 180000 },
|
|
{ id: 2, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '김영식', status: '현장소장', task: 'A동 3층 셔터시공', hours: '', wage: 200000 },
|
|
{ id: 3, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '홍길동', status: '현장소장', task: '', hours: '', wage: 170000 },
|
|
{ id: 4, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '박민수', status: '현장소장', task: '취기검사시', hours: '', wage: 200000 },
|
|
{ id: 5, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '이준호', status: '현장소장', task: '', hours: '', wage: 170000 },
|
|
{ id: 6, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '정대호', status: '현장소장', task: '', hours: '', wage: 200000 },
|
|
{ id: 7, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '오상호', status: '현장소장', task: '', hours: '', wage: 170000 },
|
|
{ id: 8, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '한재영', status: '현장소장', task: '취기검사시', hours: '', wage: 200000 },
|
|
{ id: 9, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '윤성민', status: '현장소장', task: 'A동 1층', hours: '', wage: 175000 },
|
|
{ id: 10, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '최동환', status: '현장소장', task: '', hours: '', wage: 180000 },
|
|
];
|
|
|
|
/* ════════════════════════════════════════════════
|
|
탭 1: 인원등록
|
|
════════════════════════════════════════════════ */
|
|
function TabRegister() {
|
|
const [search, setSearch] = useState('');
|
|
const [company, setCompany] = useState('');
|
|
const [trade, setTrade] = useState('');
|
|
const [jobType, setJobType] = useState('');
|
|
|
|
const filtered = useMemo(() => {
|
|
return SAMPLE_WORKERS.filter(w => {
|
|
if (search && !w.name.includes(search) && !w.jobType.includes(search)) return false;
|
|
if (company && w.company !== company) return false;
|
|
if (trade && w.trade !== trade) return false;
|
|
if (jobType && w.jobType !== jobType) return false;
|
|
return true;
|
|
});
|
|
}, [search, company, trade, jobType]);
|
|
|
|
const companies = [...new Set(SAMPLE_WORKERS.map(w => w.company))];
|
|
const trades = [...new Set(SAMPLE_WORKERS.map(w => w.trade))];
|
|
const jobTypes = [...new Set(SAMPLE_WORKERS.map(w => w.jobType))];
|
|
|
|
return (
|
|
<div>
|
|
{/* 필터 */}
|
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
|
<select value={company} onChange={e => setCompany(e.target.value)}
|
|
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={trade} onChange={e => setTrade(e.target.value)}
|
|
className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
|
<option value="">직종선택</option>
|
|
{trades.map(t => <option key={t} value={t}>{t}</option>)}
|
|
</select>
|
|
<input type="text" placeholder="근로자 또는 현장소" value={search} onChange={e => setSearch(e.target.value)}
|
|
className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width: 180}} />
|
|
<button className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
|
<button className="bg-green-600 text-white px-3 py-1.5 rounded text-sm hover:bg-green-700">부재자기입</button>
|
|
<button className="bg-gray-200 text-gray-700 px-3 py-1.5 rounded text-sm hover:bg-gray-300">출력</button>
|
|
<button className="bg-gray-200 text-gray-700 px-3 py-1.5 rounded text-sm hover:bg-gray-300">자재등</button>
|
|
<button className="bg-gray-200 text-gray-700 px-3 py-1.5 rounded text-sm hover:bg-gray-300">다운로드</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" /></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-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>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map(w => (
|
|
<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" /></td>
|
|
<td className="px-3 py-2 text-gray-500">{w.id}</td>
|
|
<td className="px-3 py-2 text-gray-700">{w.company}</td>
|
|
<td className="px-3 py-2 text-gray-700">{w.trade}</td>
|
|
<td className="px-3 py-2 text-gray-700">{w.jobType}</td>
|
|
<td className="px-3 py-2 font-medium text-gray-800">{w.name}</td>
|
|
<td className="px-3 py-2 text-gray-600">{w.phone}</td>
|
|
<td className="px-3 py-2 text-gray-600">{w.ssn}</td>
|
|
<td className="px-3 py-2 text-center text-gray-600">{w.blood}</td>
|
|
<td className="px-3 py-2 text-center">
|
|
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${w.remark === 'Y' ? 'bg-red-100 text-red-700' : 'text-gray-400'}`}>{w.remark}</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-right font-mono text-gray-800">{w.wage.toLocaleString()}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-3 text-sm text-gray-500">
|
|
<div>총 {filtered.length}명</div>
|
|
<div className="flex items-center gap-1">
|
|
<button className="px-2 py-1 rounded hover:bg-gray-100"><</button>
|
|
<button className="px-2 py-1 rounded bg-blue-600 text-white text-xs">1</button>
|
|
<button className="px-2 py-1 rounded hover:bg-gray-100">2</button>
|
|
<button className="px-2 py-1 rounded hover:bg-gray-100">></button>
|
|
<select className="border border-gray-300 rounded px-2 py-1 text-xs ml-2">
|
|
<option>15</option><option>30</option><option>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="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">순번</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">업체명</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">공종</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">직종</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">이름</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">출역구분</th>
|
|
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">작업내용</th>
|
|
<th className="px-3 py-2.5 text-center font-semibold text-gray-600">수우</th>
|
|
<th className="px-3 py-2.5 text-right font-semibold text-gray-600">노임단가</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{SAMPLE_ATTENDANCE.map(a => (
|
|
<tr key={a.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition">
|
|
<td className="px-3 py-2 text-gray-500">{a.id}</td>
|
|
<td className="px-3 py-2 text-gray-700">{a.company}</td>
|
|
<td className="px-3 py-2 text-gray-700">{a.trade}</td>
|
|
<td className="px-3 py-2 text-gray-700">{a.jobType}</td>
|
|
<td className="px-3 py-2 font-medium text-gray-800">{a.name}</td>
|
|
<td className="px-3 py-2 text-gray-600">{a.status}</td>
|
|
<td className="px-3 py-2 text-gray-600">{a.task || '-'}</td>
|
|
<td className="px-3 py-2 text-center text-gray-600">{a.hours || '-'}</td>
|
|
<td className="px-3 py-2 text-right font-mono text-gray-800">{a.wage.toLocaleString()}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</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');
|
|
|
|
const dates = [];
|
|
const s = new Date(startDate), e = new Date(endDate);
|
|
for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) {
|
|
dates.push(new Date(d));
|
|
}
|
|
|
|
const data = [
|
|
{ trade: '방화셔터공사', company: '(주)주일기업', firstDate: '2025-12-22', lastDate: '2026-03-12', daily: [10, 7, 10, 0, 12, 10, 10, 10] },
|
|
];
|
|
|
|
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)} className="text-red-500" />
|
|
<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)} className="text-red-500" />
|
|
<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>
|
|
<button className="bg-gray-200 text-gray-700 px-3 py-1.5 rounded text-sm hover:bg-gray-300">검색초기화</button>
|
|
</div>
|
|
|
|
<div className="text-sm font-bold text-gray-800 mb-2">
|
|
{startDate.replace(/-/g,'년 ').replace(/년 (\d+)$/,'월 $1일')} ~ {endDate.replace(/-/g,'년 ').replace(/년 (\d+)$/,'월 $1일')}
|
|
</div>
|
|
|
|
<div className="overflow-x-auto border border-gray-200 rounded-lg">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-gray-50 border-b border-gray-200">
|
|
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>공종</th>
|
|
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>업체명</th>
|
|
<th className="px-3 py-2 text-center font-semibold text-gray-600" rowSpan={2}>최초투입</th>
|
|
<th className="px-3 py-2 text-center font-semibold text-gray-600" rowSpan={2}>최종투입</th>
|
|
<th className="px-2 py-1 text-center font-semibold text-blue-600 border-b border-gray-200" colSpan={dates.length}>
|
|
{startDate.slice(0,7)}
|
|
</th>
|
|
<th className="px-3 py-2 text-center font-semibold text-gray-600" rowSpan={2}>합계</th>
|
|
</tr>
|
|
<tr className="bg-gray-50 border-b border-gray-200">
|
|
{dates.map(d => (
|
|
<th key={d.toISOString()} className="px-2 py-1.5 text-center font-medium text-gray-500 text-xs min-w-[36px]">
|
|
{d.getDate()}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.map((row, i) => (
|
|
<tr key={i} className="border-b border-gray-100 hover:bg-blue-50/30">
|
|
<td className="px-3 py-2 text-gray-700">{row.trade}</td>
|
|
<td className="px-3 py-2 text-gray-700">{row.company}</td>
|
|
<td className="px-3 py-2 text-center text-gray-600">{row.firstDate}</td>
|
|
<td className="px-3 py-2 text-center text-gray-600">{row.lastDate}</td>
|
|
{row.daily.map((v, j) => (
|
|
<td key={j} className={`px-2 py-2 text-center font-mono ${v > 0 ? 'text-gray-800' : 'text-gray-300'}`}>{v || ''}</td>
|
|
))}
|
|
<td className="px-3 py-2 text-center font-mono font-bold text-blue-700">{row.daily.reduce((a,b)=>a+b,0)}</td>
|
|
</tr>
|
|
))}
|
|
<tr className="bg-gray-50 font-bold border-t border-gray-300">
|
|
<td className="px-3 py-2" colSpan={4}>합계</td>
|
|
{data[0].daily.map((_, j) => {
|
|
const sum = data.reduce((s, r) => s + (r.daily[j]||0), 0);
|
|
return <td key={j} className={`px-2 py-2 text-center font-mono ${sum > 0 ? 'text-gray-800' : 'text-gray-300'}`}>{sum || ''}</td>;
|
|
})}
|
|
<td className="px-3 py-2 text-center font-mono text-blue-700">
|
|
{data.reduce((s, r) => s + r.daily.reduce((a,b)=>a+b,0), 0)}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</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');
|
|
|
|
const dates = [];
|
|
const s = new Date(startDate), e = new Date(endDate);
|
|
for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) {
|
|
dates.push(new Date(d));
|
|
}
|
|
|
|
const workers = [
|
|
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'현장소장', name:'김철수', daily:[1,1,1,0,1,1,1,1], wage:170000, remark:'' },
|
|
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'김영식', daily:[1,1,1,0,1,1,1,1], wage:200000, remark:'' },
|
|
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'홍길동', daily:[1,0,1,0,1,1,1,1], wage:170000, remark:'' },
|
|
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'취기검사시', name:'박현석', daily:[1,1,1,0,1,1,1,1], wage:200000, remark:'' },
|
|
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'이준호', daily:[1,1,1,0,1,1,1,1], wage:200000, remark:'' },
|
|
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'정대호', daily:[1,0,0,0,1,1,1,1], wage:180000, remark:'' },
|
|
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'철거/잡사', name:'김강사', daily:[0,1,1,0,1,1,1,1], wage:180000, remark:'' },
|
|
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'오상호', daily:[1,0,1,0,1,0,1,1], wage:180000, remark:'' },
|
|
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'한재영', daily:[1,1,0,0,1,1,0,1], wage:180000, remark:'작업정상' },
|
|
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'윤성민', daily:[1,0,1,0,1,0,1,0], wage:170000, remark:'' },
|
|
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'최동환', daily:[0,1,1,0,0,1,0,1], wage:170000, remark:'' },
|
|
];
|
|
|
|
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>
|
|
<span className="ml-2 text-gray-500">근로자별</span>
|
|
</div>
|
|
|
|
<div className="text-sm font-bold text-gray-800 mb-2">
|
|
{startDate.replace(/-/g,'년 ').replace(/년 (\d+)$/,'월 $1일')} ~ {endDate.replace(/-/g,'년 ').replace(/년 (\d+)$/,'월 $1일')}
|
|
</div>
|
|
|
|
<div className="overflow-x-auto border border-gray-200 rounded-lg">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-gray-50 border-b border-gray-200">
|
|
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>업체</th>
|
|
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>공종</th>
|
|
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>직종</th>
|
|
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>이름</th>
|
|
<th className="px-2 py-1 text-center font-semibold text-blue-600 border-b border-gray-200" colSpan={dates.length}>
|
|
{startDate.slice(0,7)}
|
|
</th>
|
|
<th className="px-3 py-2 text-right font-semibold text-gray-600" rowSpan={2}>노임단가</th>
|
|
<th className="px-3 py-2 text-right font-semibold text-gray-600" rowSpan={2}>합계</th>
|
|
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>비고</th>
|
|
</tr>
|
|
<tr className="bg-gray-50 border-b border-gray-200">
|
|
{dates.map(d => (
|
|
<th key={d.toISOString()} className="px-2 py-1.5 text-center font-medium text-gray-500 text-xs min-w-[36px]">
|
|
{d.getDate()}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{workers.map((w, i) => {
|
|
const total = w.daily.reduce((a,b) => a+b, 0);
|
|
const totalWage = total * w.wage;
|
|
return (
|
|
<tr key={i} className="border-b border-gray-100 hover:bg-blue-50/30">
|
|
<td className="px-3 py-2 text-gray-700">{w.company}</td>
|
|
<td className="px-3 py-2 text-gray-700">{w.trade}</td>
|
|
<td className="px-3 py-2 text-gray-700">{w.jobType}</td>
|
|
<td className="px-3 py-2 font-medium text-gray-800">{w.name}</td>
|
|
{w.daily.map((v, j) => (
|
|
<td key={j} className={`px-2 py-2 text-center font-mono ${v ? 'text-gray-800' : 'text-gray-300'}`}>{v || ''}</td>
|
|
))}
|
|
<td className="px-3 py-2 text-right font-mono text-gray-700">{w.wage.toLocaleString()}</td>
|
|
<td className="px-3 py-2 text-right font-mono font-bold text-blue-700">{totalWage.toLocaleString()}</td>
|
|
<td className="px-3 py-2 text-gray-500 text-xs">{w.remark}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
<tr className="bg-gray-50 font-bold border-t border-gray-300">
|
|
<td className="px-3 py-2" colSpan={4}>합계</td>
|
|
{dates.map((_, j) => {
|
|
const sum = workers.reduce((s, w) => s + (w.daily[j]||0), 0);
|
|
return <td key={j} className={`px-2 py-2 text-center font-mono ${sum ? 'text-gray-800' : 'text-gray-300'}`}>{sum || ''}</td>;
|
|
})}
|
|
<td className="px-3 py-2"></td>
|
|
<td className="px-3 py-2 text-right font-mono text-blue-700">
|
|
{workers.reduce((s, w) => s + w.daily.reduce((a,b)=>a+b,0) * w.wage, 0).toLocaleString()}
|
|
</td>
|
|
<td></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</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
|