Files
JH/people-unified.html

1147 lines
53 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>dailyproject 사람별 근무내역</title>
<style>
body { margin: 0; font-family: "Noto Sans KR", sans-serif; background: #f4f7fb; color: #1f2937; }
.wrap { max-width: 1480px; margin: 16px auto; padding: 0 12px; }
.card { background: #fff; border: 1px solid #dbe4f0; border-radius: 12px; padding: 12px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
input, select, button { font-size: 14px; padding: 7px 9px; border: 1px solid #ccd7e6; border-radius: 8px; }
button { background: #0f766e; color: #fff; border: 0; cursor: pointer; }
.tabs { display:flex; gap:8px; margin: 0 0 10px; }
.tab-btn { background:#e6f2f0; color:#0f766e; border:1px solid #b8d7d2; }
.tab-btn.active { background:#0f766e; color:#fff; border-color:#0f766e; }
.sync-status { font-size:12px; color:#475569; }
.sync-status.error { color:#dc2626; font-weight:700; }
.grid { display: grid; grid-template-columns: minmax(280px, 29%) minmax(0, 71%); gap: 10px; }
.meta { font-size: 13px; color: #475569; margin-bottom: 8px; }
.table-wrap { overflow: auto; border: 1px solid #dbe4f0; border-radius: 10px; height: 1080px; }
table { border-collapse: collapse; width: 100%; min-width: 0; table-layout: fixed; }
th, td { font-size: 12px; padding: 5px 4px; border-bottom: 1px solid #edf2f9; white-space: nowrap; text-align: left; overflow: hidden; text-overflow: ellipsis; }
th { position: sticky; top: 0; background: #f8fbff; }
th:nth-child(1), td:nth-child(1) { width: 44%; }
th:nth-child(2), td:nth-child(2) { width: 34%; }
th:nth-child(3), td:nth-child(3) { width: 22%; }
.num { text-align: right; }
tr.active { background: #eefbf8; }
.retired-badge { font-size: 10px; color: #dc2626; margin-left: 4px; font-weight: 600; }
.no-record-badge { display:inline-block; width:7px; height:7px; border-radius:50%; background:#2563eb; margin-left:4px; vertical-align:middle; }
.calendar-head { display:flex; gap:8px; align-items:center; margin: 4px 0 8px; }
.selected-person-banner { margin-left:auto; padding:7px 12px; border-radius:10px; background:#eaf6ff; border:1px solid #bfdbfe; color:#0f172a; font-size:13px; font-weight:800; min-width:220px; text-align:right; }
.selected-person-banner .sub { color:#475569; font-size:11px; font-weight:600; margin-left:6px; }
.month-summary { margin-top:10px; }
.summary-title { font-size: 13px; font-weight: 700; color: #0f172a; margin-bottom: 8px; }
.summary-metrics { display:flex; flex-wrap:wrap; gap:8px 14px; margin-bottom:8px; font-size:12px; color:#334155; }
.metric { padding:0; border:0; background:transparent; }
.metric-label { font-size:11px; color:#64748b; margin-right:4px; }
.metric-value { font-size:12px; font-weight:700; color:#0f172a; display:inline; }
.summary-sub { font-size:11px; color:#64748b; margin:6px 0 8px; }
.summary-table-wrap { max-height:none; overflow:visible; border:1px solid #dbe4f0; border-radius:8px; background:#fff; }
.project-list-wrap { max-height:none; overflow:visible; }
.summary-table { width:100%; border-collapse:collapse; table-layout:fixed; }
.summary-table th, .summary-table td { font-size:12px; padding:6px 7px; border-bottom:1px solid #edf2f9; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.summary-table th { background:#f8fbff; }
.summary-table th:nth-child(1), .summary-table td:nth-child(1) { width: 32%; }
.summary-table th:nth-child(2), .summary-table td:nth-child(2),
.summary-table th:nth-child(3), .summary-table td:nth-child(3),
.summary-table th:nth-child(4), .summary-table td:nth-child(4),
.summary-table th:nth-child(5), .summary-table td:nth-child(5) { width: 17%; text-align:right; }
.summary-section td { background:#eef6ff; color:#0f4c81; font-weight:800; text-align:left !important; }
.summary-note-row td { color:#475569; font-size:11px; white-space:normal; text-align:left !important; }
.summary-site td:first-child { color:#047857; font-weight:700; }
.summary-both td:first-child { color:#b45309; font-weight:700; }
.month-subtotal td { background:#f8fbff; font-weight:700; border-top:1px solid #dbe4f0; border-bottom:1px solid #dbe4f0; }
.month-gap td { border-top: 2px solid #e6edf7; }
.view-panel { display:none; }
#peopleView.active, #projectsView.active { display:grid; }
.calendar-grid {
display:grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-rows: 18px;
grid-auto-rows: 124px;
column-gap: 8px;
row-gap: 8px;
align-items: stretch;
}
.dow, .cell {
box-sizing: border-box;
border:1px solid #dbe4f0;
border-radius:8px;
background:#fff;
}
.dow { padding:1px 4px; font-size:12px; text-align:center; background:#f8fbff; font-weight:600; min-height: 14px; line-height: 1; display:flex; align-items:center; justify-content:center; }
.cell { height:124px; padding:8px; overflow:hidden; display:flex; flex-direction:column; }
.cell.empty { background:#f8fafc; }
.cell.weekend { background:#f8fbff; }
.cell.holiday { background:#fff7ed; border-color:#fed7aa; }
.d { font-size:12px; font-weight:700; color:#0f172a; }
.dline { display:flex; align-items:center; justify-content:space-between; gap:6px; }
.d.sun { color:#dc2626; }
.d.sat { color:#2563eb; }
.day-flag { font-size: 10px; color: #dc2626; font-weight: 600; flex: 0 0 auto; }
.h { font-size:12px; color:#0f766e; margin-top:2px; }
.h .ot-inline { color:#dc2626; font-weight:700; }
.h .shift-inline { color:#7c3aed; font-weight:700; }
.ot { font-size:11px; color:#b45309; margin-top:1px; }
.hl { font-size:11px; color:#c2410c; margin-top:1px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:700; }
.hl:not(:empty) { display:inline-block; align-self:flex-start; padding:1px 4px; border-radius:4px; background:#ffedd5; }
.st { font-size:11px; color:#7c3aed; margin-top:1px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.site { font-size:11px; color:#0369a1; margin-top:1px; overflow:hidden; font-weight:700; line-height:1.25; }
.site-code, .site-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.site-name { font-weight:600; }
.sql-work { font-size:11px; color:#334155; margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:700; }
.pname { font-size:11px; color:#475569; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.project-code { color:#0f172a; font-weight:700; }
.project-name { color:#64748b; font-size:11px; margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
@media (max-width: 980px) {
.grid { grid-template-columns: 1fr; }
.summary-metrics { gap:6px 10px; }
}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h2 style="margin:0 0 10px;">dailyproject 사람별 근무내역</h2>
<div class="tabs">
<button id="tabPeople" type="button" class="tab-btn active">사람별</button>
<button id="tabProjects" type="button" class="tab-btn">프로젝트별</button>
</div>
<div class="row">
<label>시작일 <input id="start" type="date" value="2020-01-01"></label>
<label>종료일 <input id="end" type="date"></label>
<select id="teamFilter"><option value="">전체 팀</option></select>
<select id="rankFilter"><option value="">전체 직급</option></select>
<select id="projectGroupFilter"><option value="">전체 분류</option></select>
<label id="retiredWrap"><input id="excludeRetired" type="checkbox"> 퇴사자 제외</label>
<label id="noRecordWrap"><input id="excludeNoRecord" type="checkbox"> 기록없음 제외</label>
<input id="keyword" type="text" placeholder="이름/사번 검색">
<button id="btnLoad">조회</button>
<button id="btnLoadSiteWorksheet" type="button">사업관리 전체 가져오기</button>
<span id="siteSyncStatus" class="sync-status"></span>
</div>
<div id="peopleView" class="grid view-panel active">
<div>
<div id="peopleMeta" class="meta">사람 목록 로딩 전</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>이름(사번)</th><th></th><th>직급</th></tr>
</thead>
<tbody id="peopleBody"></tbody>
</table>
</div>
</div>
<div>
<div class="calendar-head">
<button id="prevMonth" type="button">이전</button>
<input id="monthPicker" type="month" style="padding:6px 8px;">
<button id="goMonth" type="button">이동</button>
<button id="nextMonth" type="button">다음</button>
<div id="selectedPersonBanner" class="selected-person-banner">사람을 선택하세요</div>
</div>
<div class="calendar-grid" id="calendar"></div>
<div class="month-summary">
<div id="summaryTitle" class="summary-title">월 요약</div>
<div id="summaryMetrics" class="summary-metrics"></div>
<div class="summary-table-wrap">
<table class="summary-table">
<thead>
<tr><th>프로젝트/날짜</th><th>총시간</th><th>정규/공수</th><th>OT/일수</th></tr>
</thead>
<tbody id="summaryProjectBody"></tbody>
</table>
</div>
<div id="summarySub" class="summary-sub"></div>
</div>
</div>
</div>
<div id="projectsView" class="grid view-panel">
<div>
<div id="projectMeta" class="meta">프로젝트 목록 로딩 전</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>프로젝트</th><th class="num">총시간</th><th class="num">인원</th></tr>
</thead>
<tbody id="projectBody"></tbody>
</table>
</div>
</div>
<div>
<div class="calendar-head">
<button id="prevYearProject" type="button">이전</button>
<input id="yearPickerProject" type="number" min="2000" max="2100" step="1" style="width:86px; padding:6px 8px;">
<button id="goMonthProject" type="button">이동</button>
<button id="nextYearProject" type="button">다음</button>
<div id="selectedProjectBanner" class="selected-person-banner">프로젝트를 선택하세요</div>
</div>
<div class="month-summary">
<div id="projectSummaryTitle" class="summary-title">프로젝트 요약</div>
<div id="projectSummaryMetrics" class="summary-metrics"></div>
<div class="summary-table-wrap project-list-wrap">
<table class="summary-table">
<thead>
<tr><th></th><th>사람</th><th>총시간</th><th>정규</th><th>OT</th></tr>
</thead>
<tbody id="projectMemberBody"></tbody>
</table>
</div>
<div id="projectSummarySub" class="summary-sub"></div>
</div>
</div>
</div>
</div>
</div>
<script>
const el = (id) => document.getElementById(id);
let people = [];
let filteredPeople = [];
let selectedMemberNo = '';
let selectedName = '';
let currentMonth = '';
let currentProjectYear = '';
let dayRows = [];
let dayStates = {};
let dayShiftHours = {};
let siteRows = [];
let aliasMap = {};
let activeTab = 'people';
let projects = [];
let filteredProjects = [];
let selectedProjectCode = '';
let projectDashboard = null;
let projectMonthlyRows = [];
let projectSiteMonthlyRows = [];
const HOLIDAYS = {
'2020-01-01': '신정',
'2020-01-24': '설날연휴',
'2020-01-25': '설날',
'2020-01-26': '설날연휴',
'2020-01-27': '대체공휴일',
'2020-03-01': '삼일절',
'2020-04-15': '국회의원선거',
'2020-04-30': '부처님오신날',
'2020-05-05': '어린이날',
'2020-06-06': '현충일',
'2020-08-15': '광복절',
'2020-08-17': '임시공휴일',
'2020-09-30': '추석연휴',
'2020-10-01': '추석',
'2020-10-02': '추석연휴',
'2020-10-03': '개천절',
'2020-10-09': '한글날',
'2020-12-25': '성탄절',
'2025-01-01': '신정',
'2025-01-27': '임시공휴일',
'2025-01-28': '설날연휴',
'2025-01-29': '설날',
'2025-01-30': '설날연휴',
'2025-03-01': '삼일절',
'2025-03-03': '대체공휴일',
'2025-05-01': '근로자의날',
'2025-05-05': '어린이날/부처님오신날',
'2025-05-06': '대체공휴일',
'2025-06-03': '대통령선거일',
'2025-06-06': '현충일',
'2025-08-15': '광복절',
'2025-10-03': '개천절',
'2025-10-05': '추석연휴',
'2025-10-06': '추석',
'2025-10-07': '추석연휴',
'2025-10-08': '대체공휴일',
'2025-10-09': '한글날',
'2025-12-25': '성탄절',
'2026-01-01': '신정',
'2026-02-16': '설날연휴',
'2026-02-17': '설날',
'2026-02-18': '설날연휴',
'2026-03-02': '삼일절 대체공휴일',
'2026-05-01': '근로자의날',
'2026-05-05': '어린이날',
'2026-05-25': '부처님오신날 대체공휴일',
'2026-06-03': '선거일',
'2026-07-17': '제헌절',
'2026-08-17': '광복절 대체공휴일',
'2026-09-24': '추석연휴',
'2026-09-25': '추석',
'2026-09-26': '추석연휴',
'2026-10-05': '개천절 대체공휴일',
'2026-10-09': '한글날',
'2026-12-25': '성탄절'
};
function n(v) { return Number(v || 0).toLocaleString(); }
function todayYmd() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function todayYm() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function projectLabel(code) {
const c = String(code || '').trim();
if (!c) return { code: '', name: '' };
const a = aliasMap[c] || '';
return { code: c, name: a ? `(${a})` : '' };
}
function projectGroupFromCode(code) {
const c = String(code || '').trim();
const parts = c.split('-');
if (parts.length >= 2) return parts[1].trim();
return '';
}
function fillFilters(rows) {
const teams = [...new Set(rows.map(r => (r.teamName || '').trim()).filter(Boolean))].sort();
const ranks = [...new Set(rows.map(r => (r.rankName || '').trim()).filter(Boolean))].sort();
el('teamFilter').innerHTML = '<option value="">전체 팀</option>';
el('rankFilter').innerHTML = '<option value="">전체 직급</option>';
teams.forEach(v => { const o=document.createElement('option'); o.value=v; o.textContent=v; el('teamFilter').appendChild(o); });
ranks.forEach(v => { const o=document.createElement('option'); o.value=v; o.textContent=v; el('rankFilter').appendChild(o); });
}
function fillProjectGroupFilter(rows) {
const projectGroups = [...new Set(rows.map(r => projectGroupFromCode(r.projectCode || '')).filter(Boolean))].sort();
el('projectGroupFilter').innerHTML = '<option value="">전체 분류</option>';
projectGroups.forEach(v => { const o=document.createElement('option'); o.value=v; o.textContent=v; el('projectGroupFilter').appendChild(o); });
}
function rankSortOrder(rankName) {
const ranks = ['부회장', '사장', '부사장', '전무이사', '상무이사', '이사', '부장', '차장', '과장', '대리', '사원'];
const idx = ranks.indexOf(String(rankName || '').trim());
return idx >= 0 ? idx : ranks.length;
}
function applyFilters() {
const kw = (el('keyword').value || '').trim().toLowerCase();
const team = el('teamFilter').value;
const rank = el('rankFilter').value;
const excludeRetired = el('excludeRetired').checked;
const excludeNoRecord = el('excludeNoRecord').checked;
if (activeTab === 'people') {
filteredPeople = people.filter(r => {
if (excludeRetired && Number(r.isRetired || 0)) return false;
if (team && (r.teamName || '') !== team) return false;
if (rank && (r.rankName || '') !== rank) return false;
if (excludeNoRecord && Number(r.totalRows || 0) === 0 && Number(r.siteRows || 0) === 0) return false;
if (!kw) return true;
return (r.MemberNo || '').toLowerCase().includes(kw) || (r.korName || '').toLowerCase().includes(kw);
}).sort((a, b) =>
rankSortOrder(a.rankName) - rankSortOrder(b.rankName) ||
String(a.korName || '').localeCompare(String(b.korName || ''), 'ko-KR') ||
String(a.MemberNo || '').localeCompare(String(b.MemberNo || ''))
);
renderPeople();
} else {
filteredProjects = projects.filter(r => {
const proj = projectLabel(r.projectCode || '');
const selectedGroup = el('projectGroupFilter').value;
if (selectedGroup && projectGroupFromCode(r.projectCode || '') !== selectedGroup) return false;
if (!kw) return true;
return (r.projectCode || '').toLowerCase().includes(kw) || (proj.name || '').toLowerCase().includes(kw);
});
renderProjects();
}
}
function renderPeople() {
const tbody = el('peopleBody');
tbody.innerHTML = '';
filteredPeople.forEach(r => {
const tr = document.createElement('tr');
if (selectedMemberNo === r.MemberNo) tr.className = 'active';
const noRecordBadge = (Number(r.totalRows || 0) === 0 && Number(r.siteRows || 0) === 0)
? '<span class="no-record-badge" title="기록없음"></span>'
: '';
tr.innerHTML = `
<td>${r.korName || ''} (${r.MemberNo || ''})${Number(r.isRetired || 0) ? '<span class="retired-badge">퇴사</span>' : ''}${noRecordBadge}</td>
<td>${r.teamName || ''}</td>
<td>${r.rankName || ''}</td>`;
tr.style.cursor = 'pointer';
tr.onclick = () => selectPerson(r.MemberNo, r.korName || '');
tbody.appendChild(tr);
});
el('peopleMeta').textContent = `사람 ${filteredPeople.length}명 (member 기준)`;
}
function renderSelectedPersonBanner() {
const box = el('selectedPersonBanner');
if (!box) return;
if (!selectedMemberNo) {
box.textContent = '사람을 선택하세요';
return;
}
const person = people.find(r => r.MemberNo === selectedMemberNo) || {};
const team = person.teamName || '';
const rank = person.rankName || '';
const sub = [team, rank].filter(Boolean).join(' / ');
box.innerHTML = `${selectedName || person.korName || ''} (${selectedMemberNo})${sub ? `<span class="sub">${sub}</span>` : ''}`;
}
function renderProjects() {
const tbody = el('projectBody');
tbody.innerHTML = '';
filteredProjects.forEach(r => {
const proj = projectLabel(r.projectCode || '');
const tr = document.createElement('tr');
if (selectedProjectCode === r.projectCode) tr.className = 'active';
tr.innerHTML = `
<td><div class="project-code">${proj.code || r.projectCode || ''}</div><div class="project-name">${proj.name || ''}</div></td>
<td class="num">${n(r.totalHours)}h</td>
<td class="num">${n(r.peopleCount)}</td>`;
tr.style.cursor = 'pointer';
tr.onclick = () => selectProject(r.projectCode || '');
tbody.appendChild(tr);
});
el('projectMeta').textContent = `프로젝트 ${filteredProjects.length}`;
}
function renderSelectedProjectBanner() {
const box = el('selectedProjectBanner');
if (!box) return;
if (!selectedProjectCode) {
box.textContent = '프로젝트를 선택하세요';
return;
}
const proj = projectLabel(selectedProjectCode);
box.innerHTML = `${proj.code || selectedProjectCode}${proj.name ? `<span class="sub">${proj.name}</span>` : ''}`;
}
function toYm(dateStr) { return dateStr.slice(0, 7); }
function isHolidayDate(dateStr) { return Boolean(HOLIDAYS[dateStr]); }
function dayMapForMonth() {
const map = {};
dayRows.filter(r => toYm(r.workDate) === currentMonth).forEach(r => {
const d = Number(r.workDate.slice(8, 10));
if (!map[d]) map[d] = { hours: 0, regularHours: 0, overtimeHours: 0, projects: {} };
map[d].hours += Number(r.hours || 0);
map[d].regularHours += Number(r.regularHours || 0);
map[d].overtimeHours += Number(r.overtimeHours || 0);
const p = r.projectCode || '-';
if (!map[d].projects[p]) map[d].projects[p] = { hours: 0, regularHours: 0, overtimeHours: 0 };
map[d].projects[p].hours += Number(r.hours || 0);
map[d].projects[p].regularHours += Number(r.regularHours || 0);
map[d].projects[p].overtimeHours += Number(r.overtimeHours || 0);
});
return map;
}
function siteMapForMonth() {
const map = {};
siteRows.filter(r => toYm(r.workDate || '') === currentMonth).forEach(r => {
const day = Number((r.workDate || '').slice(8, 10));
if (!map[day]) map[day] = [];
map[day].push(r);
});
return map;
}
function renderMonthSummary(dayMap, siteMap) {
const body = el('summaryProjectBody');
const metrics = el('summaryMetrics');
const title = el('summaryTitle');
const sub = el('summarySub');
body.innerHTML = '';
metrics.innerHTML = '';
title.textContent = selectedMemberNo ? `${selectedName} (${selectedMemberNo}) ${currentMonth} 요약` : `${currentMonth} 요약`;
if (!currentMonth) {
sub.textContent = '';
return;
}
const [y, m] = currentMonth.split('-').map(Number);
const lastDay = new Date(y, m, 0).getDate();
const projectMap = {};
const siteProjectMap = {};
const bothDayRows = [];
let totalHours = 0;
let totalRegular = 0;
let totalOt = 0;
let siteTotalHours = 0;
let siteTotalCount = 0;
let commonHours = 0;
let workedDays = 0;
let bothDays = 0;
let weekdayNoRecord = 0;
let annualDays = 0;
let halfDays = 0;
let tripDays = 0;
let shiftDays = 0;
let lateDays = 0;
for (let day = 1; day <= lastDay; day++) {
const dayKey = `${currentMonth}-${String(day).padStart(2, '0')}`;
const info = dayMap[day];
const siteInfo = siteMap[day] || [];
const states = dayStates[dayKey] || [];
const holiday = isHolidayDate(dayKey);
const dow = new Date(y, m - 1, day).getDay();
const isWeekday = dow >= 1 && dow <= 5 && !holiday;
const hasSql = info && (Number(info.hours || 0) > 0 || Number(info.regularHours || 0) > 0 || Number(info.overtimeHours || 0) > 0);
const hasSite = siteInfo.length > 0;
if (hasSql) {
workedDays += 1;
totalHours += Number(info.hours || 0);
totalRegular += Number(info.regularHours || 0);
totalOt += Number(info.overtimeHours || 0);
Object.entries(info.projects).forEach(([code, vals]) => {
if (!projectMap[code]) projectMap[code] = { hours: 0, regularHours: 0, overtimeHours: 0 };
projectMap[code].hours += Number(vals.hours || 0);
projectMap[code].regularHours += Number(vals.regularHours || 0);
projectMap[code].overtimeHours += Number(vals.overtimeHours || 0);
});
} else if (isWeekday) {
weekdayNoRecord += 1;
}
if (hasSite) {
let daySiteHours = 0;
const daySiteCodes = [];
siteInfo.forEach((r) => {
const code = r.projectCode || '-';
const count = Number(r.personCount || 0);
const hours = count * 8;
const proj = projectLabel(code);
const bridge = (proj.name || r.workText || '').replace(/^\((.*)\)$/, '$1');
if (!siteProjectMap[code]) siteProjectMap[code] = { hours: 0, personCount: 0, days: new Set(), name: bridge };
siteProjectMap[code].hours += hours;
siteProjectMap[code].personCount += count;
siteProjectMap[code].days.add(dayKey);
siteTotalHours += hours;
siteTotalCount += count;
daySiteHours += hours;
daySiteCodes.push(`${code}${bridge ? `(${bridge})` : ''}`);
});
if (hasSql) {
bothDays += 1;
// 업무 기준: 같은 날짜에 인트라넷과 사업관리 기록이 모두 있으면 사업관리 기록을 우선한다.
// 현재 commonHours는 중복 확인/요약용으로 계산하며, 실제 판정 기준은 사업관리 쪽이다.
commonHours += Math.min(Number(info.regularHours || info.hours || 0), daySiteHours);
bothDayRows.push({
date: dayKey,
sqlHours: Number(info.hours || 0),
siteHours: daySiteHours,
sqlProjects: Object.keys(info.projects || {}).join(', '),
siteProjects: [...new Set(daySiteCodes)].join(', '),
});
}
}
if (states.includes('연차')) annualDays += 1;
if (states.includes('반차')) halfDays += 1;
if (states.includes('출장')) tripDays += 1;
if (states.includes('시차')) shiftDays += 1;
if (states.includes('지각')) lateDays += 1;
}
const metricItems = [
['총근무', `${n(totalRegular + siteTotalHours - commonHours)}h`],
['정규/OT', `${n(totalRegular)}h / ${n(totalOt)}h`],
['근무일수', `${workedDays}`],
['사업관리', `${n(siteTotalHours)}h / ${n(siteTotalCount)}공수`],
['공통', `${n(commonHours)}h`],
['같은날', `${bothDays}`],
['평일 무기록', `${weekdayNoRecord}`],
['연차/반차', `${annualDays}일 / ${halfDays}`],
['출장/시차', `${tripDays}일 / ${shiftDays}`],
['지각', `${lateDays}`],
];
metricItems.forEach(([label, value]) => {
const item = document.createElement('div');
item.className = 'metric';
item.innerHTML = `<span class="metric-label">${label}</span><span class="metric-value">${value}</span>`;
metrics.appendChild(item);
});
function addSection(label) {
const tr = document.createElement('tr');
tr.className = 'summary-section';
tr.innerHTML = `<td colspan="4">${label}</td>`;
body.appendChild(tr);
}
function addNote(text) {
const tr = document.createElement('tr');
tr.className = 'summary-note-row';
tr.innerHTML = `<td colspan="4">${text}</td>`;
body.appendChild(tr);
}
const sortedProjects = Object.entries(projectMap)
.sort((a, b) => (b[1].hours - a[1].hours) || a[0].localeCompare(b[0]))
.slice(0, 20);
addSection('인트라넷 근무');
if (!sortedProjects.length) {
addNote('해당 월 인트라넷 프로젝트 근무기록이 없습니다.');
} else {
sortedProjects.forEach(([code, vals]) => {
const proj = projectLabel(code);
const tr = document.createElement('tr');
tr.innerHTML = `
<td title="${proj.code} ${proj.name}">${proj.code}${proj.name ? `<br>${proj.name}` : ''}</td>
<td>${n(vals.hours)}h</td>
<td>${n(vals.regularHours)}h</td>
<td>${n(vals.overtimeHours)}h</td>
`;
body.appendChild(tr);
});
}
const sortedSiteProjects = Object.entries(siteProjectMap)
.sort((a, b) => (b[1].hours - a[1].hours) || a[0].localeCompare(b[0]))
.slice(0, 20);
addSection('사업관리 근무');
if (!sortedSiteProjects.length) {
addNote('해당 월 사업관리 근무기록이 없습니다.');
} else {
sortedSiteProjects.forEach(([code, vals]) => {
const proj = projectLabel(code);
const name = vals.name || proj.name || '';
const tr = document.createElement('tr');
tr.className = 'summary-site';
tr.innerHTML = `
<td title="${code} ${name}">${code}${name ? `<br>${name}` : ''}</td>
<td>${n(vals.hours)}h</td>
<td>${n(vals.personCount)}공수</td>
<td>${vals.days.size}일</td>
`;
body.appendChild(tr);
});
}
addSection('인트라넷 + 사업관리 같이 있는 날짜');
if (!bothDayRows.length) {
addNote('같은 날짜에 인트라넷과 사업관리 기록이 함께 있는 날이 없습니다.');
} else {
bothDayRows.slice(0, 20).forEach((r) => {
const tr = document.createElement('tr');
tr.className = 'summary-both';
tr.innerHTML = `
<td title="인트라넷: ${r.sqlProjects} / 사업관리: ${r.siteProjects}">${r.date}</td>
<td>${n(r.sqlHours)}h</td>
<td>${n(r.siteHours)}h</td>
<td>같이 있음</td>
`;
body.appendChild(tr);
});
}
sub.textContent = `인트라넷은 SQL dailyproject 기준, 사업관리는 작업일보 공수*8h 기준입니다. 같은날은 두 기록이 같은 날짜에 모두 있는 경우입니다.`;
}
function renderProjectMonthlyList() {
const title = el('projectSummaryTitle');
const metrics = el('projectSummaryMetrics');
const body = el('projectMemberBody');
const sub = el('projectSummarySub');
metrics.innerHTML = '';
body.innerHTML = '';
const proj = projectLabel(selectedProjectCode || '');
if (!selectedProjectCode) {
title.textContent = '프로젝트 요약';
sub.textContent = '';
return;
}
const selectedYear = currentProjectYear || String(new Date().getFullYear());
el('yearPickerProject').value = selectedYear;
title.textContent = `${proj.code}${proj.name ? ' ' + proj.name : ''} ${selectedYear}년 참여자 근무`;
const yearRows = projectMonthlyRows.filter(r => !selectedYear || String(r.yearCode || '').startsWith(selectedYear));
const siteYearRows = projectSiteMonthlyRows.filter(r => !selectedYear || String(r.yearCode || '').startsWith(selectedYear));
const regularHours = yearRows.reduce((s, r) => s + Number(r.regularHours || 0), 0);
const overtimeHours = yearRows.reduce((s, r) => s + Number(r.overtimeHours || 0), 0);
const siteHours = siteYearRows.reduce((s, r) => s + Number(r.hours || 0), 0);
const siteCount = siteYearRows.reduce((s, r) => s + Number(r.personCount || 0), 0);
const sqlByPersonMonth = {};
yearRows.forEach((r) => {
const key = `${r.yearMonth || ''}|${r.MemberNo || r.korName || ''}`;
sqlByPersonMonth[key] = (sqlByPersonMonth[key] || 0) + Number(r.regularHours || r.hours || 0);
});
let commonHours = 0;
const bothRows = [];
siteYearRows.forEach((r) => {
const key = `${r.yearMonth || ''}|${r.MemberNo || r.korName || ''}`;
const sqlHours = Number(sqlByPersonMonth[key] || 0);
const currentSiteHours = Number(r.hours || 0);
if (sqlHours > 0 && currentSiteHours > 0) {
// 업무 기준: 프로젝트별에서도 인트라넷과 사업관리가 같이 잡히면 사업관리 기록을 우선한다.
// 같은 월/사람의 commonHours는 중복 확인용 집계다.
commonHours += Math.min(sqlHours, currentSiteHours);
bothRows.push({...r, sqlHours, siteHours: currentSiteHours});
}
});
const totalHours = regularHours + siteHours - commonHours;
const peopleCount = new Set([...yearRows, ...siteYearRows].map(r => r.MemberNo || r.korName || '').filter(Boolean)).size;
const metricItems = [
['총근무', `${n(totalHours)}h`],
['정규/OT', `${n(regularHours)}h / ${n(overtimeHours)}h`],
['사업관리', `${n(siteHours)}h / ${n(siteCount)}공수`],
['공통', `${n(commonHours)}h`],
['같은월/사람', `${bothRows.length}`],
['참여인원', `${peopleCount}`],
['기간 누적', `${n(projectDashboard?.totalHours || 0)}h`],
];
metricItems.forEach(([label, value]) => {
const item = document.createElement('div');
item.className = 'metric';
item.innerHTML = `<span class="metric-label">${label}</span><span class="metric-value">${value}</span>`;
metrics.appendChild(item);
});
function addProjectSection(label) {
const tr = document.createElement('tr');
tr.className = 'summary-section';
tr.innerHTML = `<td colspan="5">${label}</td>`;
body.appendChild(tr);
}
function addProjectNote(text) {
const tr = document.createElement('tr');
tr.className = 'summary-note-row';
tr.innerHTML = `<td colspan="5">${text}</td>`;
body.appendChild(tr);
}
function renderRowsByMonth(rows, mode) {
if (!rows.length) {
addProjectNote(`${selectedYear}${mode} 데이터가 없습니다.`);
return;
}
const sortedRows = rows
.slice()
.sort((a, b) => String(a.yearMonth || '').localeCompare(String(b.yearMonth || '')) || Number(b.hours || 0) - Number(a.hours || 0));
let activeMonth = '';
let monthTotal = { hours: 0, regularHours: 0, overtimeHours: 0, personCount: 0 };
const appendSubtotal = () => {
if (!activeMonth) return;
const totalRow = document.createElement('tr');
totalRow.className = 'month-subtotal';
totalRow.innerHTML = `
<td>${activeMonth}</td>
<td>소계</td>
<td>${n(monthTotal.hours)}h</td>
<td>${mode === '사업관리' ? `${n(monthTotal.personCount)}공수` : `${n(monthTotal.regularHours)}h`}</td>
<td>${mode === '사업관리' ? '' : `${n(monthTotal.overtimeHours)}h`}</td>`;
body.appendChild(totalRow);
};
sortedRows.forEach(r => {
const rowMonth = r.yearMonth || '';
if (activeMonth && activeMonth !== rowMonth) {
appendSubtotal();
monthTotal = { hours: 0, regularHours: 0, overtimeHours: 0, personCount: 0 };
}
const isNewMonth = activeMonth !== rowMonth;
if (isNewMonth) activeMonth = rowMonth;
monthTotal.hours += Number(r.hours || 0);
monthTotal.regularHours += Number(r.regularHours || 0);
monthTotal.overtimeHours += Number(r.overtimeHours || 0);
monthTotal.personCount += Number(r.personCount || 0);
const tr = document.createElement('tr');
if (isNewMonth) tr.className = 'month-gap';
if (mode === '사업관리') tr.className = `${tr.className || ''} summary-site`.trim();
tr.innerHTML = `
<td>${isNewMonth ? rowMonth : ''}</td>
<td title="${r.korName || ''} (${r.MemberNo || ''})">${r.korName || ''} (${r.MemberNo || ''})</td>
<td>${n(r.hours)}h</td>
<td>${mode === '사업관리' ? `${n(r.personCount || 0)}공수` : `${n(r.regularHours)}h`}</td>
<td>${mode === '사업관리' ? `${Number(r.workDays || 0)}` : `${n(r.overtimeHours)}h`}</td>`;
body.appendChild(tr);
});
appendSubtotal();
}
addProjectSection('인트라넷 근무');
renderRowsByMonth(yearRows, '인트라넷');
addProjectSection('사업관리 근무');
renderRowsByMonth(siteYearRows, '사업관리');
addProjectSection('인트라넷 + 사업관리 같이 있는 월/사람');
if (!bothRows.length) {
addProjectNote('같은 월에 같은 사람이 인트라넷과 사업관리 기록을 모두 가진 데이터가 없습니다.');
} else {
renderRowsByMonth(bothRows.map(r => ({
...r,
hours: Math.min(Number(r.sqlHours || 0), Number(r.siteHours || 0)),
regularHours: Number(r.sqlHours || 0),
overtimeHours: 0,
personCount: Number(r.personCount || 0),
})), '사업관리');
}
sub.textContent = `프로젝트별 총근무는 인트라넷 정규 + 사업관리 공수*8h - 공통 기준입니다. 공통은 같은 월/같은 사람의 중복분입니다.`;
}
function renderCalendar() {
const cal = el('calendar');
cal.innerHTML = '';
if (!currentMonth) return;
el('monthPicker').value = currentMonth;
const dows = ['일','월','화','수','목','금','토'];
dows.forEach(d => {
const h = document.createElement('div'); h.className = 'dow'; h.textContent = d; cal.appendChild(h);
});
const [y, m] = currentMonth.split('-').map(Number);
const first = new Date(y, m - 1, 1);
const startDow = first.getDay();
const lastDay = new Date(y, m, 0).getDate();
const dayMap = dayMapForMonth();
const siteMap = siteMapForMonth();
renderMonthSummary(dayMap, siteMap);
for (let i = 0; i < startDow; i++) {
const c = document.createElement('div'); c.className = 'cell empty'; cal.appendChild(c);
}
for (let day = 1; day <= lastDay; day++) {
const info = dayMap[day] || { hours: 0, regularHours: 0, overtimeHours: 0, projects: {} };
const siteInfo = siteMap[day] || [];
const dayKey = `${currentMonth}-${String(day).padStart(2, '0')}`;
const states = dayStates[dayKey] || [];
const shiftHours = Number(dayShiftHours[dayKey] || 0);
const holidayName = HOLIDAYS[dayKey] || '';
const dow = new Date(y, m - 1, day).getDay();
const isWeekend = (dow === 0 || dow === 6);
const sqlProjects = Object.entries(info.projects)
.sort((a, b) => Number(b[1].hours || 0) - Number(a[1].hours || 0))
.map(([code]) => code)
.filter(Boolean);
const c = document.createElement('div');
c.className = `cell${isWeekend ? ' weekend' : ''}${holidayName ? ' holiday' : ''}`;
const dayClass = dow === 0 ? 'sun' : (dow === 6 ? 'sat' : '');
const overtimeDisplayHours = Number(info.overtimeHours || 0);
let shownHours = Number(info.regularHours || 0);
// Fallback for older API responses that only include total hours.
if (!shownHours && Number(info.hours || 0)) {
const totalHours = Number(info.hours || 0);
shownHours = Math.max(0, totalHours - overtimeDisplayHours);
}
// 상태 보정 표시: 연차=0h, 반차=최대 4h 차감
if (states.includes('연차')) {
shownHours = 0;
} else if (states.includes('반차')) {
shownHours = Math.min(shownHours, 4);
}
if (shiftHours > 0) {
shownHours = Math.min(shownHours, Math.max(0, 8 - shiftHours));
}
const topDayFlags = [];
['연차', '반차', '출장', '지각'].forEach((s) => {
if (states.includes(s)) topDayFlags.push(s);
});
const topDayFlag = topDayFlags.join('/');
const shownStates = states
.filter((s) => !['연차', '반차', '출장', '지각', '시차'].includes(s))
.map((s) => (s === '시차' && shiftHours > 0 ? `시차(${n(shiftHours)}h)` : s));
const mainSqlProject = sqlProjects.length ? projectLabel(sqlProjects[0]) : { code: '', name: '' };
const sqlText = sqlProjects.length ? sqlProjects.join(', ') : '';
const timeParts = [];
if (shownHours) timeParts.push(`${n(shownHours)}h`);
if (overtimeDisplayHours) timeParts.push(`<span class="ot-inline">(OT ${n(overtimeDisplayHours)}h)</span>`);
if (shiftHours > 0) timeParts.push(`<span class="shift-inline">(시차 ${n(shiftHours)}h)</span>`);
const timeText = timeParts.join(' ');
const siteItems = siteInfo.map((r) => {
const proj = projectLabel(r.projectCode || '');
const code = proj.code || r.projectCode || '';
const bridge = (proj.name || r.workText || '').replace(/^\((.*)\)$/, '$1');
const count = Number(r.personCount || 0);
const countText = count ? ` ${n(count)} (${n(count * 8)}hr)` : '';
return `<div class="site-code">${code}${countText}</div><div class="site-name">(${bridge || code})</div>`;
}).filter(Boolean);
const siteTitle = siteInfo.map((r) => {
const proj = projectLabel(r.projectCode || '');
const code = proj.code || r.projectCode || '';
const name = proj.name ? ` ${proj.name}` : '';
const work = r.workText ? ` - ${r.workText}` : '';
const count = Number(r.personCount || 0) ? ` ${n(r.personCount)}` : '';
return `${name || code}${work}${count}`.trim();
}).filter(Boolean).join(', ');
const siteText = siteItems.join('');
c.innerHTML = `
<div class="dline"><div class="d ${dayClass}">${day}</div><div class="day-flag">${topDayFlag}</div></div>
<div class="hl">${holidayName}</div>
<div class="h">${timeText}</div>
<div class="st">${shownStates.length ? shownStates.join(', ') : ''}</div>
<div class="sql-work" title="${sqlText}">${sqlText}</div>
<div class="pname">${mainSqlProject.name}</div>
<div class="site" title="${siteTitle}">${siteText}</div>
`;
cal.appendChild(c);
}
}
function shiftMonth(delta) {
if (!currentMonth) return;
const [y, m] = currentMonth.split('-').map(Number);
const d = new Date(y, m - 1 + delta, 1);
currentMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (activeTab === 'people') {
renderCalendar();
} else {
shiftProjectYear(delta);
}
}
function goToPickedMonth() {
const v = el('monthPicker').value;
if (!v) return;
currentMonth = v;
renderCalendar();
}
function goToPickedProjectMonth() {
const v = el('yearPickerProject').value;
if (!v) return;
currentProjectYear = String(v).slice(0, 4);
renderProjectMonthlyList();
}
function shiftProjectYear(delta) {
const y = Number(currentProjectYear || el('yearPickerProject').value || new Date().getFullYear());
currentProjectYear = String(y + delta);
renderProjectMonthlyList();
}
function monthRange(ym) {
const [y, m] = String(ym || todayYm()).split('-').map(Number);
const last = new Date(y, m, 0).getDate();
return {
start: `${y}-${String(m).padStart(2, '0')}-01`,
end: `${y}-${String(m).padStart(2, '0')}-${String(last).padStart(2, '0')}`
};
}
async function loadPeople() {
const start = el('start').value;
const end = el('end').value;
try {
const ar = await fetch('/api/project-aliases');
const aj = await ar.json();
aliasMap = aj.aliases || {};
} catch (_) {
aliasMap = {};
}
const res = await fetch(`/api/people-summary?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`);
const data = await res.json();
people = (data.people || []).filter(r => (r.korName || '').trim());
fillFilters(people);
selectedMemberNo = '';
selectedName = '';
siteRows = [];
renderSelectedPersonBanner();
applyFilters();
el('calendar').innerHTML = '';
el('summaryTitle').textContent = `${currentMonth} 요약`;
el('summaryMetrics').innerHTML = '';
el('summarySub').textContent = '';
el('summaryProjectBody').innerHTML = '';
}
async function loadProjects() {
const start = el('start').value;
const end = el('end').value;
const res = await fetch(`/api/project-summary?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`);
const data = await res.json();
projects = (data.projects || []).filter(r => Number(r.totalHours || 0) > 0);
fillProjectGroupFilter(projects);
selectedProjectCode = '';
projectDashboard = null;
projectMonthlyRows = [];
projectSiteMonthlyRows = [];
renderSelectedProjectBanner();
applyFilters();
el('projectSummaryTitle').textContent = '프로젝트 요약';
el('projectSummaryMetrics').innerHTML = '';
el('projectSummarySub').textContent = '';
el('projectMemberBody').innerHTML = '';
}
async function selectPerson(memberNo, name) {
selectedMemberNo = memberNo;
selectedName = name;
renderPeople();
renderSelectedPersonBanner();
const start = el('start').value;
const end = el('end').value;
const [dashRes, calRes] = await Promise.all([
fetch(`/api/member-dashboard?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&memberNo=${encodeURIComponent(memberNo)}`),
fetch(`/api/member-daily-calendar?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&memberNo=${encodeURIComponent(memberNo)}`)
]);
const d = await dashRes.json();
const cal = await calRes.json();
dayRows = cal.rows || [];
const siteRes = await fetch(`/api/member-site-worksheet-records?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&memberNo=${encodeURIComponent(memberNo)}&cacheOnly=1`);
const site = await siteRes.json();
siteRows = site.rows || [];
dayStates = {};
(cal.dayStates || []).forEach((r) => { dayStates[r.workDate] = r.states || []; });
dayShiftHours = {};
(cal.dayShiftHours || []).forEach((r) => { dayShiftHours[r.workDate] = Number(r.shiftHours || 0); });
if (!currentMonth) {
currentMonth = el('monthPicker').value || start.slice(0,7);
}
renderCalendar();
}
async function loadSiteWorksheetForSelectedPerson() {
const status = el('siteSyncStatus');
status.classList.remove('error');
const targetMembers = filteredPeople.map(r => r.MemberNo).filter(Boolean);
if (!targetMembers.length) {
status.textContent = '가져올 사람 목록이 없습니다.';
return;
}
const btn = el('btnLoadSiteWorksheet');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '가져오는 중';
let lastSiteRefreshAt = 0;
const refreshSelectedSiteRows = async (force = false) => {
if (!selectedMemberNo) return;
const now = Date.now();
if (!force && now - lastSiteRefreshAt < 5000) return;
lastSiteRefreshAt = now;
const siteRes = await fetch(`/api/member-site-worksheet-records?start=${encodeURIComponent(el('start').value)}&end=${encodeURIComponent(el('end').value || todayYmd())}&memberNo=${encodeURIComponent(selectedMemberNo)}&cacheOnly=1`);
const site = await siteRes.json();
siteRows = site.rows || [];
renderCalendar();
};
const updateStatus = (job) => {
const total = Number(job.totalTargets || 0);
const done = Number(job.processedTargets || 0);
const progress = total ? ` 프로젝트/월 ${done}/${total}` : '';
const totalDays = Number(job.totalDays || 0);
const doneDays = Number(job.processedDays || 0);
const dayProgress = totalDays ? ` 작업일보 ${doneDays}/${totalDays}` : '';
const current = job.currentProjectCode
? ` (${job.currentProjectCode}${job.currentYearMonth ? ` ${job.currentYearMonth}` : ''}${job.currentWorkDate ? ` ${job.currentWorkDate}` : ''})`
: '';
const found = Number(job.foundWorkers || 0) ? `, 발견 ${n(job.foundWorkers)}` : '';
const matched = Number(job.matchedWorkers || 0) ? `, 매칭 ${n(job.matchedWorkers)}` : '';
const added = Number(job.added || 0) ? `, 저장 ${n(job.added)}` : '';
const cached = Number(job.cachedRows || 0) ? `, 기존 ${n(job.cachedRows)}` : '';
status.textContent = `${job.phase || '진행 중'}${progress}${dayProgress}${current}${found}${matched}${added}${cached}`;
};
try {
const start = '2020-01-01';
const end = el('end').value || todayYmd();
const startRes = await fetch(`/api/start-site-worksheet-sync?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&members=${encodeURIComponent(targetMembers.join(','))}`);
let data = await startRes.json();
if (!startRes.ok || !data.jobId) {
throw new Error(data.error || '사업관리 가져오기를 시작하지 못했습니다.');
}
updateStatus(data);
if (data.reused) {
status.textContent = `이미 실행 중인 작업 확인 중: ${status.textContent}`;
}
while (data.status === 'running') {
await new Promise(resolve => setTimeout(resolve, 1200));
const statusRes = await fetch(`/api/site-worksheet-sync-status?jobId=${encodeURIComponent(data.jobId)}`);
data = await statusRes.json();
if (!statusRes.ok) {
throw new Error(data.error || '진행 상태를 확인하지 못했습니다.');
}
updateStatus(data);
await refreshSelectedSiteRows(false);
}
if (data.status === 'error') {
throw new Error(data.error || '사업관리 가져오기 중 오류가 발생했습니다.');
}
await refreshSelectedSiteRows(true);
status.textContent = `완료: 작업일보 기준 수집 완료, 발견 ${n(data.foundWorkers || 0)}명, 매칭 ${n(data.matchedWorkers || data.added || 0)}`;
} catch (e) {
status.classList.add('error');
status.textContent = e.message || '사업관리 가져오기 오류';
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
async function selectProject(projectCode) {
selectedProjectCode = projectCode;
renderProjects();
renderSelectedProjectBanner();
const start = el('start').value;
const end = el('end').value;
const [dashRes, monthRes, siteMonthRes] = await Promise.all([
fetch(`/api/project-dashboard?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&projectCode=${encodeURIComponent(projectCode)}`),
fetch(`/api/project-monthly-detail?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&projectCode=${encodeURIComponent(projectCode)}`),
fetch(`/api/project-site-monthly-detail?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&projectCode=${encodeURIComponent(projectCode)}`)
]);
projectDashboard = await dashRes.json();
const monthly = await monthRes.json();
const siteMonthly = await siteMonthRes.json();
projectMonthlyRows = monthly.rows || [];
projectSiteMonthlyRows = siteMonthly.rows || [];
if (!currentProjectYear) {
currentProjectYear = el('yearPickerProject').value || start.slice(0,4);
}
renderProjectMonthlyList();
}
function setActiveTab(tab) {
activeTab = tab;
el('tabPeople').classList.toggle('active', tab === 'people');
el('tabProjects').classList.toggle('active', tab === 'projects');
el('peopleView').classList.toggle('active', tab === 'people');
el('projectsView').classList.toggle('active', tab === 'projects');
el('keyword').placeholder = tab === 'people' ? '이름/사번 검색' : '프로젝트코드/명 검색';
el('teamFilter').style.display = tab === 'people' ? '' : 'none';
el('rankFilter').style.display = tab === 'people' ? '' : 'none';
el('retiredWrap').style.display = tab === 'people' ? '' : 'none';
el('noRecordWrap').style.display = tab === 'people' ? '' : 'none';
el('projectGroupFilter').style.display = tab === 'projects' ? '' : 'none';
applyFilters();
if (tab === 'projects' && !projects.length) {
loadProjects();
}
if (tab === 'projects') {
renderProjectMonthlyList();
}
}
el('btnLoad').addEventListener('click', () => {
if (activeTab === 'people') {
loadPeople();
} else {
loadProjects();
}
});
el('keyword').addEventListener('input', applyFilters);
el('teamFilter').addEventListener('change', applyFilters);
el('rankFilter').addEventListener('change', applyFilters);
el('projectGroupFilter').addEventListener('change', applyFilters);
el('excludeRetired').addEventListener('change', applyFilters);
el('excludeNoRecord').addEventListener('change', applyFilters);
el('tabPeople').addEventListener('click', () => setActiveTab('people'));
el('tabProjects').addEventListener('click', () => setActiveTab('projects'));
el('prevMonth').addEventListener('click', () => shiftMonth(-1));
el('nextMonth').addEventListener('click', () => shiftMonth(1));
el('goMonth').addEventListener('click', goToPickedMonth);
el('monthPicker').addEventListener('change', goToPickedMonth);
el('btnLoadSiteWorksheet').addEventListener('click', loadSiteWorksheetForSelectedPerson);
el('prevYearProject').addEventListener('click', () => shiftProjectYear(-1));
el('nextYearProject').addEventListener('click', () => shiftProjectYear(1));
el('goMonthProject').addEventListener('click', goToPickedProjectMonth);
el('yearPickerProject').addEventListener('change', goToPickedProjectMonth);
el('end').value = todayYmd();
currentMonth = todayYm();
currentProjectYear = String(new Date().getFullYear());
el('monthPicker').value = currentMonth;
el('yearPickerProject').value = currentProjectYear;
el('projectGroupFilter').style.display = 'none';
loadPeople();
</script>
</body>
</html>