1147 lines
53 KiB
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>
|