Files
JH/index.html
2026-06-05 15:59:53 +09:00

1928 lines
114 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>프로젝트 투입시간 대시보드</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800;900&display=swap');
:root {
--bg:#f5f1ea;
--bg-deep:#e9e2d7;
--card:#ffffff;
--surface-2:#f8f4ee;
--line:#e1d7cb;
--line-soft:rgba(216,200,182,.58);
--ink:#1c1b1a;
--muted:#6e675f;
--brand:#2f6b5f;
--brand-2:#e27d2f;
--brand-3:#4f5bd5;
--shadow:0 12px 30px rgba(30,24,16,.08);
}
body {
font-family: "Pretendard","Noto Sans KR", sans-serif;
margin: 0;
background:
radial-gradient(1200px 600px at 15% -10%, rgba(79, 91, 213, 0.12), transparent 60%),
radial-gradient(900px 500px at 90% 10%, rgba(47, 107, 95, 0.14), transparent 55%),
linear-gradient(180deg, var(--bg), var(--bg-deep));
color: var(--ink);
}
.wrap {
width: 100%;
max-width: 1360px;
margin: 14px auto;
padding: 0 14px;
box-sizing: border-box;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
padding: 13px;
margin-bottom: 10px;
box-shadow: var(--shadow);
}
.shared-note{margin:0 0 14px;padding:12px 14px;border:1px solid var(--line);border-radius:14px;background:#eef8fb;color:#355468;font-size:13px;line-height:1.6}
h2, h3 { margin: 0 0 10px; letter-spacing:-.02em; }
.hero-card{
background: linear-gradient(180deg,#fff 0%,#f8f4ee 100%);
border: 1px solid var(--line);
}
.hero-head{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
margin-bottom:10px;
padding-bottom:8px;
border-bottom:1px dashed var(--line);
}
.hero-title{
margin:0;
font-size:30px;
font-weight:900;
letter-spacing:-.03em;
color:#2b3a32;
}
.hero-control{
display:flex;
align-items:center;
flex-wrap:wrap;
gap:8px;
padding:10px 12px;
border:1px solid var(--line);
border-radius:14px;
background:rgba(255,255,255,.75);
margin-bottom:10px;
}
.hero-control label{
display:flex;
align-items:center;
gap:6px;
font-size:12px;
font-weight:700;
color:#5f5648;
}
.hero-spacer{ flex:1 1 auto; }
.status-compact{ margin:6px 0 0; font-size:12px; }
.card-head{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:10px; }
.icon-btn{
min-width:34px; height:34px; border-radius:999px; border:1px solid var(--line);
background:#fff; color:var(--brand); display:inline-flex; align-items:center; justify-content:center;
box-shadow:none; padding:0 10px; font-size:13px; gap:6px; font-weight:800;
}
.icon-btn:hover{ transform:translateY(-1px); box-shadow:0 8px 16px rgba(47,107,95,.15); }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
button {
padding: 10px 14px;
border: 0;
background: var(--brand);
color: #fff;
border-radius: 9px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 8px 16px rgba(47,107,95,.15);
}
input, select {
padding: 8px 9px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
color: var(--ink);
}
.muted { color: var(--muted); }
.kpis { display: grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 10px; }
.kpi { border: 1px solid var(--line); border-radius: 14px; background: var(--surface-2); padding: 10px; }
.kpi .label { color: var(--muted); font-size: 12px; }
.kpi .value { font-size: 20px; font-weight: 800; margin-top: 3px; }
.tabs { display: flex; gap: 6px; padding:4px; background:#f3eadc; border:1px solid var(--line); border-radius:999px; width:fit-content; }
.tab { background: transparent; color: #5f5648; box-shadow: none; border:1px solid transparent; padding:8px 12px; }
.tab.active { background: var(--brand); color: #fff; border-color: var(--brand); }
.pane { display: none; }
.pane.active { display: block; }
.grid { display: grid; grid-template-columns: 320px 1fr; gap: 12px; align-items: start; }
.list { max-height: 64vh; overflow: auto; border: 1px solid var(--line); border-radius: 11px; background: #fff; box-shadow: inset 0 1px 0 #fff; }
.item { padding: 10px; border-bottom: 1px solid #ecf1f8; cursor: pointer; }
.item:hover { background: #eef5ff; }
.item.active { background: #e8f3f0; color: #1e3f38; border-left: 5px solid var(--brand); padding-left: 8px; }
.item.active .small { color: #1f3d69; font-weight: 700; }
.item.has-record { background: #f7fbff; }
.item.no-record { background: #fff1f1; color: #7a4b4b; }
.item.no-record .small { color: #9a6a6a; }
.item.no-record.active { background: #ffcfcf; color: #5a1212; border-left: 6px solid #c83d3d; padding-left: 8px; }
.item.no-record.active .small { color: #7b2323; font-weight: 700; }
.group-title { padding: 8px 10px; font-size: 12px; font-weight: 700; color: #5f5648; background: #f4ece0; border-bottom: 1px solid var(--line); }
.small { font-size: 12px; color: var(--muted); }
.selected-badge { display: inline-block; margin-left: 8px; padding: 4px 10px; border-radius: 999px; background: #e8f1ff; color: #184a9c; font-size: 12px; font-weight: 700; }
.retired-badge{display:inline-block;margin-left:6px;padding:2px 6px;border-radius:999px;background:#fde8e8;color:#b42323;font-size:11px;font-weight:800;vertical-align:middle;}
.person-row-head{display:flex;align-items:center;justify-content:space-between;gap:8px;}
.person-row-left{min-width:0;}
.person-row-right{flex:0 0 auto;}
.team-inline{display:inline-block;padding:1px 7px;border-radius:999px;background:#eef4ff;color:#2a4f8a;font-size:11px;font-weight:700;border:1px solid #d3e2fb;}
.selected-person-info{margin:0 0 8px 0;color:#1f3d69;font-size:15px;font-weight:800;line-height:1.35;}
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border-bottom: 1px solid #e7eef8; padding: 7px 8px; text-align: left; white-space: nowrap; }
th { background: #f4ece0; color: var(--muted); position: sticky; top: 0; z-index: 1; font-weight: 800; }
.table-wrap { overflow: auto; max-height: 68vh; }
.toolbar { margin-bottom: 10px; }
#laborProjectPane .toolbar{
display:grid;
grid-template-columns: 100px 160px minmax(260px, 1fr) 180px 120px;
gap:10px;
align-items:end;
}
#laborProjectPane .toolbar label{
display:flex;
align-items:center;
gap:6px;
min-width:0;
white-space:nowrap;
}
#laborProjectPane .toolbar select,
#laborProjectPane .toolbar input{
width:100%;
box-sizing:border-box;
}
#laborProjectPane .toolbar button{
width:100%;
}
.jump {
color: inherit;
text-decoration: underline;
text-decoration-color: currentColor;
text-underline-offset: 2px;
cursor: pointer;
}
.subtabs { display: flex; gap: 8px; margin-bottom: 10px; }
.subtab { background: #f4ece0; color: #5f5648; padding: 7px 11px; border-radius: 999px; border: 1px solid var(--line); font-weight: 700; cursor: pointer; }
.subtab.active { background: var(--brand); color: #fff; border-color: var(--brand); }
.subpane { display: none; }
.subpane.active { display: block; }
.year-block { border: 1px solid var(--line); border-radius: 14px; padding: 12px; margin-bottom: 10px; background: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,.8); }
.year-title { font-weight: 900; margin-bottom: 8px; color: #3a3a3a; letter-spacing: .2px; }
.bar-row { display: grid; grid-template-columns: 190px 1fr 72px; gap: 10px; align-items: center; margin-bottom: 8px; font-size: 12px; }
.bar-wrap { height: 16px; background: #efe6d9; border-radius: 999px; overflow: hidden; border: 1px solid var(--line); }
.bar-fill { height: 100%; background: linear-gradient(90deg, #1e63d8, #4f86e8); }
.bar-stack { display: flex; height: 100%; width: 100%; }
.bar-regular { height: 100%; background: linear-gradient(90deg, #2f6b5f, #4b8f80); }
.bar-business-trip { height: 100%; background: linear-gradient(90deg, #1fa187, #45c5ab); }
.bar-overtime { height: 100%; background: linear-gradient(90deg, #e27d2f, #f3a869); }
.bar-fill-alt { height: 100%; background: linear-gradient(90deg, #0d9488, #2dd4bf); }
.bar-regular-alt { height: 100%; background: linear-gradient(90deg, #4f5bd5, #7882e6); }
.ghost-btn { background: #eef3ff; color: #214a93; border: 1px solid #c9d7f3; box-shadow: none; }
#laborPersonYearTbl { table-layout: fixed; width: 100%; }
#laborPersonCostPane .table-wrap{
box-sizing: border-box;
padding-right: 0;
}
#laborPersonCostPane #laborPersonYearTbl{
width: 100%;
}
#laborPersonYearTbl th, #laborPersonYearTbl td {
white-space: nowrap;
padding: 7px 8px;
font-size: 13px;
line-height: 1.35;
font-family: "Pretendard","Noto Sans KR", sans-serif;
font-variant-numeric: tabular-nums;
}
#laborPersonYearTbl th.num,
#laborPersonYearTbl td.num {
text-align: right;
font-variant-numeric: tabular-nums;
padding-right: 18px;
}
#laborPersonYearTbl th:nth-child(3),
#laborPersonYearTbl th:nth-child(4),
#laborPersonYearTbl th:nth-child(5),
#laborPersonYearTbl td:nth-child(3),
#laborPersonYearTbl td:nth-child(4),
#laborPersonYearTbl td:nth-child(5) {
padding-right: 30px !important;
}
#laborProjectTbl th:nth-child(n+2),
#laborProjectTbl td:nth-child(n+2) {
text-align: right;
font-variant-numeric: tabular-nums;
}
#laborProjectTbl {
table-layout: fixed;
width: 100%;
}
#laborMonthlyTbl th:nth-child(n+3),
#laborMonthlyTbl td:nth-child(n+3) {
text-align: right;
font-variant-numeric: tabular-nums;
}
#laborCommonMonthlyTbl th:nth-child(n+3),
#laborCommonMonthlyTbl td:nth-child(n+3),
#laborCommonProjectTbl th:nth-child(n+2),
#laborCommonProjectTbl td:nth-child(n+2) {
text-align: right;
font-variant-numeric: tabular-nums;
}
#laborMonthlyTbl {
table-layout: fixed;
width: 100%;
}
#laborCommonMonthlyTbl,
#laborCommonProjectTbl {
table-layout: fixed;
width: 100%;
}
#laborMonthlyTbl td.month-blank{
color: transparent;
user-select: none;
}
.ellipsis-cell{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
#laborPersonYearTbl th:first-child,
#laborPersonYearTbl th:nth-child(2),
#laborPersonYearTbl td:first-child,
#laborPersonYearTbl td:nth-child(2) {
text-align: left;
}
#laborPersonYearTbl td.member-cell {
overflow: hidden;
text-overflow: ellipsis;
}
#laborPersonYearTbl td.year-blank{
color: transparent;
user-select: none;
}
#laborPersonYearTbl .month-detail-row td {
background: #faf6ef;
color: #6e675f;
font-size: 13px;
font-family: "Pretendard","Noto Sans KR", sans-serif;
}
#laborPersonYearTbl .month-detail-row td:nth-child(2) {
padding-left: 14px;
}
#laborPersonYearTbl .month-detail-row td:nth-child(2) {
padding-left: 18px;
}
#laborPersonYearTbl .month-project-row td{
background:#fffdf8;
color:#5d5851;
font-size:12px;
}
#laborPersonYearTbl .month-project-row td:nth-child(2){
padding-left:28px;
}
.modal { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; z-index: 1000; }
.modal.open { display: flex; }
.modal-backdrop { position: absolute; inset: 0; background: rgba(15,26,44,.42); }
.modal-panel { position: relative; width: min(1100px, 94vw); max-height: 88vh; overflow: auto; background: #fff; border-radius: 14px; border: 1px solid var(--line); padding: 14px; }
.donut-wrap { display: grid; grid-template-columns: 260px 1fr; gap: 18px; align-items: center; background: #fff; border: 1px solid var(--line); border-radius: 14px; padding: 12px; }
.donut { width: 190px; height: 190px; border-radius: 50%; margin: 0 auto; position: relative; box-shadow: 0 8px 18px rgba(30,24,16,.12); }
.donut::after { content: ''; position: absolute; inset: 30%; background: #fff; border-radius: 50%; border: 1px solid var(--line); }
.donut-center { position: absolute; inset: 0; display: grid; place-items: center; font-weight: 900; z-index: 2; text-align: center; font-size: 13px; color: #3a3a3a; }
.legend { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.legend-item { display: flex; gap: 8px; align-items: center; font-size: 12px; background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 7px 9px; }
.dot { width: 11px; height: 11px; border-radius: 999px; flex: 0 0 auto; box-shadow: 0 0 0 2px rgba(255,255,255,.8); }
@media (max-width: 1200px) {
.kpis { grid-template-columns: repeat(2, minmax(180px, 1fr)); }
.grid { grid-template-columns: 1fr; }
.list { max-height: 36vh; }
}
@media (max-width: 900px) {
.donut-wrap { grid-template-columns: 1fr; }
.legend { grid-template-columns: 1fr; }
.wrap { padding: 10px; }
#laborProjectPane .toolbar { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<div class="top-band"></div>
<div class="wrap">
<div class="shared-note">`8091` 페이지입니다. 프로젝트별 투입시간, 인원 현황, 기간별 집계와 상세 분석을 보여주는 메인 대시보드입니다.</div>
<div class="card hero-card">
<div class="hero-head">
<h2 class="hero-title">프로젝트 투입시간 대시보드</h2>
</div>
<div class="hero-control">
<button id="loadBtn">DB 로드/갱신</button>
<label>시작년월 <input id="startMonth" type="month" /></label>
<label>종료년월 <input id="endMonth" type="month" /></label>
<button id="viewBtn">조회</button>
<span class="hero-spacer"></span>
<div class="tabs">
<button class="tab active" data-tab="personPane">개인별</button>
<button class="tab" data-tab="projectPane">프로젝트</button>
<button class="tab" data-tab="laborPane">인건비</button>
</div>
</div>
<p id="status" class="muted status-compact">대기 중</p>
</div>
<div class="card">
<h3>합계</h3>
<div class="kpis">
<div class="kpi"><div class="label">총 투입시간</div><div class="value" id="kpiHours">0</div></div>
<div class="kpi"><div class="label">총 작업건수</div><div class="value" id="kpiRows">0</div></div>
<div class="kpi"><div class="label">대상 인원수</div><div class="value" id="kpiPeople">0</div></div>
<div class="kpi"><div class="label">대상 프로젝트수</div><div class="value" id="kpiProjects">0</div></div>
</div>
</div>
<div id="personPane" class="pane active">
<div class="grid">
<div class="card">
<h3>사람 목록</h3>
<div id="personSelectedInfo" class="selected-person-info">현재 선택: -</div>
<div class="row toolbar"><input id="personSearch" type="text" placeholder="이름/사번 검색" style="width:100%;" /></div>
<div class="row toolbar" style="align-items:center;gap:8px;">
<label style="display:flex;align-items:center;gap:6px;white-space:nowrap;">
<select id="personTeamFilter" style="width:150px;">
<option value="">전체</option>
</select>
</label>
<label style="display:flex;align-items:center;gap:6px;white-space:nowrap;">
<input id="personIncludeRetired" type="checkbox" checked />
퇴사자 포함
</label>
</div>
<div id="peopleList" class="list"></div>
</div>
<div class="card">
<div class="card-head">
<h3 style="margin:0;">선택 인원의 프로젝트별 시간</h3>
<button id="openDetailPopupBtn" class="icon-btn" title="상세보기" aria-label="상세보기">🔍 상세보기</button>
</div>
<div class="subtabs">
<button class="subtab person-subtab active" data-psub="personTablePane">프로젝트별 시간</button>
<button class="subtab person-subtab" data-psub="personYearGraphPane">년도별 투입 그래프</button>
</div>
<div id="personTablePane" class="subpane active"><div class="table-wrap"><table id="personProjectTbl"></table></div></div>
<div id="personYearGraphPane" class="subpane"><div class="small" style="margin:0 0 8px 0;">초록=출장, 빨강=추가근무</div><div id="yearlyChartWrap"></div></div>
</div>
</div>
</div>
<div id="projectPane" class="pane">
<div class="grid">
<div class="card">
<h3>프로젝트 목록</h3>
<div id="projectSelectedInfo" class="selected-person-info" style="font-size:14px;">현재 선택 프로젝트: -</div>
<div class="row toolbar">
<label>구분 <select id="typeFilter"><option value="">전체</option></select></label>
<label>년도 <select id="yearFilter"><option value="">전체</option></select></label>
<label>순서 <select id="seqFilter"><option value="">전체</option></select></label>
<input id="projectSearch" type="text" placeholder="프로젝트코드 검색" style="width:100%;" />
</div>
<div id="projectList" class="list"></div>
</div>
<div class="card">
<div class="card-head"><h3 style="margin:0;">선택 프로젝트의 인원별 시간</h3><button id="openProjectDetailBtn" class="icon-btn" title="상세보기" aria-label="상세보기">🔍 상세보기</button></div>
<div id="projectPeopleSummaryInfo" class="small" style="margin:0 0 8px 0;">총 투입인원: 0명</div>
<div class="subtabs">
<button class="subtab project-subtab active" data-qsub="projectTablePane">인원별 시간</button>
<button class="subtab project-subtab" data-qsub="projectYearGraphPane">년도별 인원 그래프</button>
</div>
<div id="projectTablePane" class="subpane active"><div class="table-wrap"><table id="projectPeopleTbl"></table></div></div>
<div id="projectYearGraphPane" class="subpane"><div class="small" style="margin:0 0 8px 0;">초록=출장, 빨강=추가근무</div><div id="projectYearlyPeopleWrap"></div></div>
</div>
</div>
</div>
<div id="laborPane" class="pane">
<div class="card">
<div class="subtabs">
<button class="subtab labor-subtab active" data-lsub="laborPeoplePane">인건비 입력</button>
<button class="subtab labor-subtab" data-lsub="laborProjectPane">프로젝트 인건비</button>
<button class="subtab labor-subtab" data-lsub="laborPersonCostPane">개인별 인건비</button>
<button class="subtab labor-subtab" data-lsub="laborCommonPane">공통배부</button>
</div>
<div id="laborPeoplePane" class="subpane active">
<div class="grid">
<div class="card">
<h3>사람 목록(기간 자동 반영)</h3>
<div class="row toolbar">
<input id="laborMemberProjectSearch" type="text" placeholder="프로젝트명/코드 검색" style="width:100%;" />
<select id="laborMemberProjectFilter" style="width:100%;">
<option value="">전체 프로젝트</option>
</select>
</div>
<div class="row toolbar"><input id="laborMemberSearch" type="text" placeholder="이름/사번 검색" style="width:100%;" /></div>
<div id="laborPeopleList" class="list"></div>
<div class="small" style="margin-top:8px;">사람을 클릭하면 적용 시작년월/월급 입력 팝업이 열립니다.</div>
</div>
<div class="card">
<h3>설정된 사람별 월급(이력)</h3>
<div class="table-wrap"><table id="laborSalaryTbl"></table></div>
</div>
</div>
</div>
<div id="laborProjectPane" class="subpane">
<div class="card">
<div class="card-head">
<h3 style="margin:0;">프로젝트별 인건비 결과</h3>
<button id="clearLaborProjectBtn" class="ghost-btn" type="button">전체 보기</button>
</div>
<div class="row toolbar">
<label>구분
<select id="laborTypeFilter">
<option value="">전체</option>
</select>
</label>
<label>
<select id="laborTeamFilter">
<option value="">전체</option>
</select>
</label>
<label>프로젝트
<select id="laborProjectSelect"></select>
</label>
<input id="laborProjectSearch" type="text" placeholder="프로젝트명/코드 검색" />
<button id="laborRecalcBtn">인건비 계산</button>
</div>
<div id="laborProjectHint" class="small" style="margin-bottom:8px;"></div>
<div id="laborProjectSummaryInfo" class="small" style="margin-bottom:8px;">총 프로젝트: 0개 | 총 참여인원: 0명</div>
<div class="table-wrap" style="max-height:34vh;"><table id="laborProjectTbl"></table></div>
<div class="row" style="justify-content:space-between;align-items:center;margin:10px 0 8px;">
<h3 style="margin:0;">월별 배분 상세</h3>
<label>년도
<select id="laborYearFilter">
<option value="">전체</option>
</select>
</label>
</div>
<div id="laborMonthlySummaryInfo" class="small" style="margin:0 0 8px 0;">총 개월수: 0개월</div>
<div class="table-wrap"><table id="laborMonthlyTbl"></table></div>
</div>
</div>
<div id="laborPersonCostPane" class="subpane">
<div class="card">
<h3>개인별 인건비 집계</h3>
<div class="row toolbar">
<input id="laborPersonSearch" type="text" placeholder="이름/사번 검색" />
</div>
<div class="card" style="margin-top:8px;">
<h3 style="font-size:14px;">년도별 총 인건비</h3>
<div class="table-wrap" style="max-height:62vh;overflow-x:hidden;"><table id="laborPersonYearTbl"></table></div>
</div>
<div class="small" style="margin-top:8px;">연도를 누르면 바로 아래에 해당 월 상세가 펼쳐집니다.</div>
</div>
</div>
<div id="laborCommonPane" class="subpane">
<div class="card">
<div class="card-head">
<h3 style="margin:0;">공통배부</h3>
<div class="row" style="gap:8px;">
<span id="laborCommonSelectedLabel" class="small"></span>
<button id="clearCommonProjectBtn" class="ghost-btn" type="button">전체 보기</button>
</div>
</div>
<p class="muted">시공/설계/교영/구연 외 프로젝트를 공통배부 대상으로 분리한 결과입니다.</p>
<div class="table-wrap" style="max-height:32vh;"><table id="laborCommonProjectTbl"></table></div>
<h3 style="margin-top:10px;">월별 공통배부 상세</h3>
<div class="table-wrap"><table id="laborCommonMonthlyTbl"></table></div>
</div>
</div>
</div>
</div>
</div>
<div id="detailPopup" class="modal" aria-hidden="true">
<div class="modal-backdrop" id="detailPopupBackdrop"></div>
<div class="modal-panel">
<div class="row" style="justify-content:space-between;align-items:center;">
<h3>선택 인원 상세 (년도별)</h3>
<div class="row">
<label>년도 <select id="detailYearSelect"></select></label>
<button id="closeDetailPopupBtn" class="ghost-btn">닫기</button>
</div>
</div>
<div id="detailDonutWrap" class="donut-wrap" style="margin-top:8px;"></div>
<div class="card" style="margin-top:10px;">
<h3>프로젝트 투입 상세 (년/월/유형)</h3>
<div class="table-wrap"><table id="personProjectYearTbl"></table></div>
</div>
<div class="card">
<h3>년도별 유형 구성 (시공/설계/관리 등)</h3>
<div class="table-wrap"><table id="yearTypeTbl"></table></div>
</div>
</div>
</div>
<div id="laborDetailPopup" class="modal" aria-hidden="true">
<div class="modal-backdrop" id="laborDetailBackdrop"></div>
<div class="modal-panel">
<div class="row" style="justify-content:space-between;align-items:center;">
<h3 id="laborDetailTitle">인원별 인건비 상세</h3>
<button id="closeLaborDetailBtn" class="ghost-btn">닫기</button>
</div>
<div class="table-wrap"><table id="laborMemberDetailTbl"></table></div>
</div>
</div>
<div id="laborSalaryPopup" class="modal" aria-hidden="true">
<div class="modal-backdrop" id="laborSalaryBackdrop"></div>
<div class="modal-panel" style="width:min(760px,94vw); max-height:90vh;">
<div class="row" style="justify-content:space-between;align-items:center;">
<h3 id="laborSalaryPopupTitle">인건비 입력</h3>
<button id="closeLaborSalaryBtn" class="ghost-btn">닫기</button>
</div>
<div class="row toolbar" style="display:block;">
<div class="row">
<button id="laborAddRowBtn" class="ghost-btn" type="button">+ 행 추가</button>
<button id="laborDeleteBtn" class="ghost-btn" type="button">- 행 삭제</button>
<button id="laborSaveBtn" type="button">저장</button>
</div>
<div class="card" style="margin-top:10px;">
<h3 style="font-size:14px;margin-bottom:8px;">해당 인원 월급 이력(편집)</h3>
<div class="table-wrap" style="max-height:420px;overflow-x:hidden;"><table id="laborSalaryEditTbl"></table></div>
<div class="small" style="margin-top:6px;">행 선택 후 삭제, 마지막에 저장을 누르세요.</div>
</div>
</div>
</div>
</div>
<script>
const statusEl = document.getElementById('status');
const peopleListEl = document.getElementById('peopleList');
const projectListEl = document.getElementById('projectList');
const personProjectTbl = document.getElementById('personProjectTbl');
const personProjectYearTbl = document.getElementById('personProjectYearTbl');
const projectPeopleTbl = document.getElementById('projectPeopleTbl');
const yearTypeTbl = document.getElementById('yearTypeTbl');
const detailPopupEl = document.getElementById('detailPopup');
const detailPopupBackdropEl = document.getElementById('detailPopupBackdrop');
const openDetailPopupBtnEl = document.getElementById('openDetailPopupBtn');
const openProjectDetailBtnEl = document.getElementById('openProjectDetailBtn');
const closeDetailPopupBtnEl = document.getElementById('closeDetailPopupBtn');
const detailYearSelectEl = document.getElementById('detailYearSelect');
const detailDonutWrapEl = document.getElementById('detailDonutWrap');
const personSearchEl = document.getElementById('personSearch');
const personSelectedInfoEl = document.getElementById('personSelectedInfo');
const personTeamFilterEl = document.getElementById('personTeamFilter');
const personIncludeRetiredEl = document.getElementById('personIncludeRetired');
const projectSearchEl = document.getElementById('projectSearch');
const projectSelectedInfoEl = document.getElementById('projectSelectedInfo');
const typeFilterEl = document.getElementById('typeFilter');
const yearFilterEl = document.getElementById('yearFilter');
const seqFilterEl = document.getElementById('seqFilter');
const yearlyChartWrap = document.getElementById('yearlyChartWrap');
const projectYearlyPeopleWrap = document.getElementById('projectYearlyPeopleWrap');
const laborDetailPopupEl = document.getElementById('laborDetailPopup');
const laborDetailBackdropEl = document.getElementById('laborDetailBackdrop');
const closeLaborDetailBtnEl = document.getElementById('closeLaborDetailBtn');
const laborMemberDetailTblEl = document.getElementById('laborMemberDetailTbl');
const laborDetailTitleEl = document.getElementById('laborDetailTitle');
const laborPeopleListEl = document.getElementById('laborPeopleList');
const laborMemberProjectSearchEl = document.getElementById('laborMemberProjectSearch');
const laborMemberProjectFilterEl = document.getElementById('laborMemberProjectFilter');
const laborMemberSearchEl = document.getElementById('laborMemberSearch');
const laborSaveBtnEl = document.getElementById('laborSaveBtn');
const laborDeleteBtnEl = document.getElementById('laborDeleteBtn');
const laborAddRowBtnEl = document.getElementById('laborAddRowBtn');
const laborSalaryPopupEl = document.getElementById('laborSalaryPopup');
const laborSalaryBackdropEl = document.getElementById('laborSalaryBackdrop');
const closeLaborSalaryBtnEl = document.getElementById('closeLaborSalaryBtn');
const laborSalaryPopupTitleEl = document.getElementById('laborSalaryPopupTitle');
const laborSalaryEditTblEl = document.getElementById('laborSalaryEditTbl');
const laborRecalcBtnEl = document.getElementById('laborRecalcBtn');
const laborSalaryTblEl = document.getElementById('laborSalaryTbl');
const laborProjectTblEl = document.getElementById('laborProjectTbl');
const laborMonthlyTblEl = document.getElementById('laborMonthlyTbl');
const laborPersonSearchEl = document.getElementById('laborPersonSearch');
const laborPersonYearTblEl = document.getElementById('laborPersonYearTbl');
const laborProjectSelectEl = document.getElementById('laborProjectSelect');
const laborProjectSearchEl = document.getElementById('laborProjectSearch');
const laborProjectHintEl = document.getElementById('laborProjectHint');
const laborProjectSummaryInfoEl = document.getElementById('laborProjectSummaryInfo');
const laborMonthlySummaryInfoEl = document.getElementById('laborMonthlySummaryInfo');
const clearLaborProjectBtnEl = document.getElementById('clearLaborProjectBtn');
const laborTypeFilterEl = document.getElementById('laborTypeFilter');
const laborTeamFilterEl = document.getElementById('laborTeamFilter');
const laborYearFilterEl = document.getElementById('laborYearFilter');
const laborCommonProjectTblEl = document.getElementById('laborCommonProjectTbl');
const laborCommonMonthlyTblEl = document.getElementById('laborCommonMonthlyTbl');
const laborCommonSelectedLabelEl = document.getElementById('laborCommonSelectedLabel');
const clearCommonProjectBtnEl = document.getElementById('clearCommonProjectBtn');
let activeTab = 'personPane';
let personSubtab = 'personTablePane';
let projectSubtab = 'projectTablePane';
let laborSubtab = 'laborPeoplePane';
let selectedMemberNo = '';
let selectedProjectCode = '';
let personDetailWindowRef = null;
let projectDetailWindowRef = null;
let allPeopleRows = [];
let allProjectRows = [];
let personTeamFilterValue = '';
let personIncludeRetired = true;
let projectAliasMap = {};
let personProjectYearRowsState = [];
let yearTypeYearsState = [];
let rangeInitialized = false;
let selectedLaborProjectCode = '';
let laborProjectSearchText = '';
let laborMemberSearchText = '';
let laborMemberProjectSearchText = '';
let laborMemberProjectFilterValue = '';
let laborTypeFilterValue = '';
let laborTeamFilterValue = '';
let laborYearFilterValue = '';
let laborPersonSearchText = '';
let laborPersonSelectedYear = '';
let laborMonthlyDetailState = [];
const LABOR_SALARY_KEY = 'mh_labor_salary_map_v1';
let laborSalaryEntries = [];
let laborMonthlyRowsCache = [];
let selectedLaborSalaryMemberNo = '';
let laborPersonCostRowsCache = [];
let laborCoreRowsCache = [];
let laborCommonRowsCache = [];
let selectedLaborCommonProjectCode = '';
let laborSalaryDraftRows = [];
let laborVisibleMemberNos = new Set();
let salaryAvgByTitle = {};
let salaryAvgLoaded = false;
let peopleSummaryCacheKey = '';
let projectSummaryCacheKey = '';
let monthlyPersonProjectCacheKey = '';
let monthlyPersonProjectCacheRows = [];
const CORE_PROJECT_TYPES = new Set(['시공','설계','교영','구연']);
// TODO(준비): 직급별 월 최대 추가근무시간(시간) 설정.
// 예) { '부장': 40, '차장': 36 }
const RANK_MONTHLY_OT_CAP_HOURS = {};
function setKpis(d){document.getElementById('kpiHours').textContent=Number(d.totalHours||0).toLocaleString()+'h';document.getElementById('kpiRows').textContent=Number(d.totalRows||0).toLocaleString();document.getElementById('kpiPeople').textContent=Number(d.peopleCount||0).toLocaleString();document.getElementById('kpiProjects').textContent=Number(d.projectCount||0).toLocaleString();}
function esc(s){return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function projectLabel(code){const c=String(code||'').trim();if(!c)return '';const short=projectAliasMap[c]||'';return short?`${short} (${c})`:c;}
function personNameWithRank(name, rank, team){
const n=String(name||'').trim();
const r=String(rank||'').trim();
const t=String(team||'').trim();
const nr=(n&&r)?`${n} ${r}`:(n||r||'');
if(nr&&t) return `${nr} [${t}]`;
return nr||n||'';
}
function retiredBadgeHtml(p){return Number(p?.isRetired||0)>0?'<span class="retired-badge">퇴사</span>':'';}
function palette(i){const colors=['#1e63d8','#0d9488','#f59e0b','#e11d48','#7c3aed','#2563eb','#059669','#dc2626','#8b5cf6','#14b8a6'];return colors[i%colors.length];}
function debounce(fn, wait=250){
let timer = null;
return (...args)=>{
if(timer) clearTimeout(timer);
timer = setTimeout(()=>fn(...args), wait);
};
}
function yearMatch(rowYear, selectedYear){
const ry=String(rowYear||'').trim();
const sy=String(selectedYear||'').trim();
if(!ry||!sy) return false;
if(ry===sy) return true;
return ry.slice(-2)===sy.slice(-2);
}
function projectCodePrefix2(code){
const c=String(code||'').trim();
if(!c) return '';
const p=c.split('-')[0]||'';
return p.padStart(2,'0').slice(-2);
}
function normMonth(v){const s=String(v||'').trim();return /^\d{4}-\d{2}$/.test(s)?s:'';}
function parseMoneyInput(v){
const n=String(v||'').replace(/[^\d]/g,'');
return n?Number(n):0;
}
function formatMoneyInput(v){
const n=Math.max(0, Number(v||0));
return n?Math.round(n).toLocaleString():'';
}
function normTitle(v){
return String(v||'').trim().replace(/\s+/g,'');
}
async function fetchSalaryAvgByTitle(){
if(salaryAvgLoaded) return;
try{
const d=await (await fetch('/api/salary-avg-by-title')).json();
salaryAvgByTitle=d.byTitle||{};
}catch(_){
salaryAvgByTitle={};
}
salaryAvgLoaded=true;
}
function estimateSalaryByTitleYear(rankName, year){
const key=normTitle(rankName);
const info=salaryAvgByTitle[key];
if(!info) return 0;
const fixed=Number(info.fixedSalary||0);
return fixed>0 ? fixed : 0;
}
function applyAutoSalaryFromRankIfMissing(workRows=[]){
if(!Object.keys(salaryAvgByTitle||{}).length) return;
const memberFirstMonth=new Map();
for(const r of (workRows||[])){
const mno=String(r.MemberNo||'').trim();
const ym=String(r.yearMonth||'').trim();
const y=ym.slice(0,4);
if(!mno || !/^\d{4}$/.test(y)) continue;
if(!memberFirstMonth.has(mno) || ym < memberFirstMonth.get(mno)) memberFirstMonth.set(mno, ym);
}
let changed=false;
const keepMemberNos=new Set([...memberFirstMonth.keys()]);
// 근무기록 없는 인원의 자동/legacy 이력은 제거
const beforePruneLen=laborSalaryEntries.length;
laborSalaryEntries=laborSalaryEntries.filter(e=>{
const src=String(e.source||'').trim();
const mno=String(e.memberNo||'').trim();
if(src==='manual') return true;
return keepMemberNos.has(mno);
});
if(beforePruneLen!==laborSalaryEntries.length) changed=true;
for(const p of (allPeopleRows||[])){
const memberNo=String(p.MemberNo||'').trim();
const rank=String(p.rankName||'').trim();
if(!memberNo || !rank) continue;
const firstYm=memberFirstMonth.get(memberNo);
if(!firstYm) continue; // 근무기록 없는 인원은 자동 이력 생성 안함
// 인원별 이력은 1줄만 유지: 기존 모든 행 제거 후 자동 1줄 생성
const beforeLen=laborSalaryEntries.length;
laborSalaryEntries=laborSalaryEntries.filter(e=>String(e.memberNo||'').trim()!==memberNo);
if(beforeLen!==laborSalaryEntries.length) changed=true;
const salary=estimateSalaryByTitleYear(rank, Number(String(firstYm).slice(0,4)));
if(salary>0 && upsertSalaryEntry(memberNo, firstYm, salary, 'auto_rank_avg')){
changed=true;
}
}
if(changed) saveLaborSalaryMap();
}
function bindExcelMoneyInput(el){
if(!el) return;
el.addEventListener('input',()=>{
const n=parseMoneyInput(el.value);
el.value = n ? n.toLocaleString() : '';
});
el.addEventListener('focus',()=>{ el.select(); });
}
function loadLaborSalaryMap(){
try{
const raw=JSON.parse(localStorage.getItem(LABOR_SALARY_KEY)||'null');
if(raw && Array.isArray(raw.entries)){
laborSalaryEntries = raw.entries
.map(x=>({memberNo:String(x.memberNo||'').trim(), fromMonth:normMonth(x.fromMonth), salary:Math.max(0, Number(x.salary||0)), source:String(x.source||'legacy')}))
.filter(x=>x.memberNo&&x.fromMonth&&x.salary>0);
return;
}
if(raw && typeof raw==='object'){
// legacy: { MemberNo: salary } -> 2000-01 시작으로 마이그레이션
laborSalaryEntries = Object.entries(raw).map(([memberNo, salary])=>({
memberNo:String(memberNo||'').trim(),
fromMonth:'2000-01',
salary:Math.max(0, Number(salary||0)),
source:'legacy'
})).filter(x=>x.memberNo&&x.salary>0);
return;
}
}catch(_){}
laborSalaryEntries = [];
}
function saveLaborSalaryMap(){localStorage.setItem(LABOR_SALARY_KEY, JSON.stringify({entries:laborSalaryEntries||[]}));}
function getCompForMonth(memberNo, yearMonth){
const ym=normMonth(yearMonth);
if(!memberNo||!ym) return {salary:0};
const candidates=laborSalaryEntries
.filter(x=>x.memberNo===memberNo && x.fromMonth<=ym)
.sort((a,b)=>String(b.fromMonth).localeCompare(String(a.fromMonth)));
return { salary:Number(candidates[0]?.salary||0) };
}
function upsertSalaryEntry(memberNo, fromMonth, salary, source='manual'){
const mno=String(memberNo||'').trim();
const fm=normMonth(fromMonth);
const sv=Math.max(0, Number(salary||0));
if(!mno||!fm||sv<=0) return false;
const i=laborSalaryEntries.findIndex(x=>x.memberNo===mno && x.fromMonth===fm);
if(i>=0) { laborSalaryEntries[i].salary=sv; laborSalaryEntries[i].source=source||'manual'; }
else laborSalaryEntries.push({memberNo:mno, fromMonth:fm, salary:sv, source:source||'manual'});
return true;
}
function deleteSalaryEntry(memberNo, fromMonth){
const mno=String(memberNo||'').trim();
const fm=normMonth(fromMonth);
const prevLen=laborSalaryEntries.length;
laborSalaryEntries=laborSalaryEntries.filter(x=>!(x.memberNo===mno && x.fromMonth===fm));
return laborSalaryEntries.length!==prevLen;
}
function closeLaborSalaryPopup(){
laborSalaryPopupEl.classList.remove('open');
laborSalaryPopupEl.setAttribute('aria-hidden','true');
}
function renderLaborSalaryEditTable(){
if(!laborSalaryDraftRows.length){
laborSalaryDraftRows=[{
fromMonth: normMonth(document.getElementById('startMonth').value||'') || '2000-01',
salary: 0
}];
}
const colgroup='<colgroup><col style="width:10%"><col style="width:38%"><col style="width:52%"></colgroup>';
const head='<thead><tr><th></th><th>fromMonth</th><th>salary</th></tr></thead>';
const body='<tbody>'+laborSalaryDraftRows.map((r,idx)=>`<tr>
<td><input type="radio" name="salaryRowPick" value="${idx}" ${idx===0?'checked':''}></td>
<td><input type="month" class="draft-month" data-idx="${idx}" value="${esc(r.fromMonth||'')}"></td>
<td><input type="text" inputmode="numeric" class="draft-salary num-input" data-idx="${idx}" value="${formatMoneyInput(r.salary||0)}"></td>
</tr>`).join('')+'</tbody>';
laborSalaryEditTblEl.innerHTML=colgroup+head+body;
laborSalaryEditTblEl.querySelectorAll('.num-input').forEach(bindExcelMoneyInput);
}
function readLaborSalaryDraftFromTable(){
const out=[];
laborSalaryEditTblEl.querySelectorAll('tbody tr').forEach(tr=>{
const fm=normMonth(tr.querySelector('.draft-month')?.value||'');
const salary=parseMoneyInput(tr.querySelector('.draft-salary')?.value||'');
if(fm && salary>0){
out.push({fromMonth:fm, salary});
}
});
return out;
}
function openLaborSalaryPopup(memberNo, fromMonth=''){
const mno=String(memberNo||'').trim();
if(!mno) return;
selectedLaborSalaryMemberNo = mno;
const person = allPeopleRows.find(p=>String(p.MemberNo||'').trim()===mno) || {};
laborSalaryPopupTitleEl.textContent = `인건비 입력: ${personNameWithRank(person.korName, person.rankName, person.teamName)||mno} (${mno})`;
laborSalaryDraftRows=[...laborSalaryEntries]
.filter(x=>x.memberNo===mno)
.sort((a,b)=>String(a.fromMonth).localeCompare(String(b.fromMonth)))
.map(x=>({fromMonth:x.fromMonth,salary:Number(x.salary||0)}));
if(fromMonth){
const fm=normMonth(fromMonth);
if(fm && !laborSalaryDraftRows.some(x=>x.fromMonth===fm)){
laborSalaryDraftRows.push({fromMonth:fm,salary:0});
}
}
renderLaborSalaryEditTable();
laborSalaryPopupEl.classList.add('open');
laborSalaryPopupEl.setAttribute('aria-hidden','false');
}
function renderLaborTypeOptions(){
const types=[...new Set((laborCoreRowsCache||[]).map(r=>getProjectType(r)).filter(Boolean))].sort((a,b)=>a.localeCompare(b,'ko'));
laborTypeFilterEl.innerHTML='<option value="">전체</option>'+types.map(t=>`<option value="${esc(t)}">${esc(t)}</option>`).join('');
if(laborTypeFilterValue && types.includes(laborTypeFilterValue)) laborTypeFilterEl.value=laborTypeFilterValue;
else if(laborTypeFilterValue){ laborTypeFilterValue=''; laborTypeFilterEl.value=''; }
}
function renderLaborTeamOptions(rows){
const teams=[...new Set((rows||[]).map(r=>String(r.teamName||'').trim()).filter(Boolean))].sort((a,b)=>a.localeCompare(b,'ko'));
laborTeamFilterEl.innerHTML='<option value="">전체</option>'+teams.map(t=>`<option value="${esc(t)}">${esc(t)}</option>`).join('');
if(laborTeamFilterValue && teams.includes(laborTeamFilterValue)) laborTeamFilterEl.value=laborTeamFilterValue;
else if(laborTeamFilterValue){ laborTeamFilterValue=''; laborTeamFilterEl.value=''; }
}
function renderLaborYearOptions(monthlyRows){
const years=[...new Set((monthlyRows||[]).map(r=>String(r.yearMonth||'').slice(0,4)).filter(v=>/^\d{4}$/.test(v)))].sort();
const html='<option value="">전체</option>'+years.map(y=>`<option value="${y}">${y}</option>`).join('');
laborYearFilterEl.innerHTML=html;
if(laborYearFilterValue && years.includes(laborYearFilterValue)) { laborYearFilterEl.value=laborYearFilterValue; }
else if(laborYearFilterValue){ laborYearFilterValue=''; laborYearFilterEl.value=''; }
}
function renderLaborProjectOptions(sourceProjectCodes=[]){
const q=(laborProjectSearchText||'').trim().toLowerCase();
const source = (sourceProjectCodes && sourceProjectCodes.length)
? sourceProjectCodes
: [...new Set((laborCoreRowsCache||[]).map(r=>String(r.projectCode||'').trim()).filter(Boolean))];
const srcRows=[...new Set(source.map(c=>String(c||'').trim()).filter(Boolean))].map(code=>({projectCode:code}));
const filtered=srcRows.filter(r=>{
const t=parseProjectCode(r.projectCode).type;
if(laborTypeFilterValue && t!==laborTypeFilterValue) return false;
if(!q) return true;
const code=String(r.projectCode||'').toLowerCase();
const name=String(projectLabel(r.projectCode)||'').toLowerCase();
return code.includes(q) || name.includes(q);
}).sort((a,b)=>String(a.projectCode||'').localeCompare(String(b.projectCode||''), 'ko'));
const options=['<option value="">전체</option>'].concat(
filtered.map(r=>`<option value="${esc(r.projectCode)}">${esc(projectLabel(r.projectCode))}</option>`)
);
// 팀/구분 변경으로 후보 목록에서 빠져도 사용자가 고른 프로젝트는 유지
if(selectedLaborProjectCode && !filtered.some(r=>r.projectCode===selectedLaborProjectCode)){
options.push(`<option value="${esc(selectedLaborProjectCode)}">${esc(projectLabel(selectedLaborProjectCode))}</option>`);
}
laborProjectSelectEl.innerHTML=options.join('');
if(selectedLaborProjectCode) laborProjectSelectEl.value=selectedLaborProjectCode;
}
function renderLaborMemberOptions(){
const q=(laborMemberSearchText||'').trim().toLowerCase();
const selectedProject = String(laborMemberProjectFilterValue||'').trim();
const memberSet = selectedProject
? new Set(laborMonthlyRowsCache.filter(r=>String(r.projectCode||'').trim()===selectedProject).map(r=>String(r.MemberNo||'').trim()))
: null;
const rows=[...allPeopleRows]
.filter(p=>Number(p.totalRows||0)>0||Number(p.totalHours||0)>0) // 현재 기간 기록 있는 사람만
.filter(p=>!memberSet || memberSet.has(String(p.MemberNo||'').trim()))
.filter(p=>{
if(!q) return true;
return String(p.korName||'').toLowerCase().includes(q) || String(p.MemberNo||'').toLowerCase().includes(q);
})
.sort((a,b)=>String(a.korName||a.MemberNo).localeCompare(String(b.korName||b.MemberNo),'ko'));
laborVisibleMemberNos = new Set(rows.map(p=>String(p.MemberNo||'').trim()).filter(Boolean));
laborPeopleListEl.innerHTML='';
for(const p of rows){
const d=document.createElement('div');
d.className='item has-record';
const nameNo=p.korName?`${(p.korName||'').trim()} (${p.MemberNo})`:p.MemberNo;
const team=(p.teamName||'').trim();
const teamChip=team?`<span class="team-inline">[${esc(team)}]</span>`:'';
d.innerHTML=`<div class="person-row-head"><div class="person-row-left"><b>${esc(nameNo)}</b>${retiredBadgeHtml(p)}</div><div class="person-row-right">${teamChip}</div></div><div class="small">${Number(p.totalHours||0).toLocaleString()}h / ${Number(p.totalRows||0).toLocaleString()}건</div>`;
d.addEventListener('click',()=>openLaborSalaryPopup(p.MemberNo));
laborPeopleListEl.appendChild(d);
}
renderLaborSalaryTable();
}
function renderLaborMemberProjectFilterOptions(){
const q=(laborMemberProjectSearchText||'').trim().toLowerCase();
const projects=[...new Set((laborMonthlyRowsCache||[]).map(r=>String(r.projectCode||'').trim()).filter(Boolean))]
.map(code=>({projectCode:code}))
.filter(r=>{
if(!q) return true;
const code=String(r.projectCode||'').toLowerCase();
const name=String(projectLabel(r.projectCode)||'').toLowerCase();
return code.includes(q)||name.includes(q);
})
.sort((a,b)=>String(a.projectCode||'').localeCompare(String(b.projectCode||''),'ko'));
laborMemberProjectFilterEl.innerHTML='<option value="">전체 프로젝트</option>'+projects.map(r=>`<option value="${esc(r.projectCode)}">${esc(projectLabel(r.projectCode))}</option>`).join('');
if(laborMemberProjectFilterValue && projects.some(p=>p.projectCode===laborMemberProjectFilterValue)){
laborMemberProjectFilterEl.value=laborMemberProjectFilterValue;
}else if(laborMemberProjectFilterValue){
laborMemberProjectFilterValue='';
laborMemberProjectFilterEl.value='';
}
}
function renderLaborSalaryTable(){
const rows=[...laborSalaryEntries]
.filter(x=>!laborVisibleMemberNos.size || laborVisibleMemberNos.has(String(x.memberNo||'').trim()))
.map(x=>{
const p=allPeopleRows.find(v=>v.MemberNo===x.memberNo)||{};
return {
name:personNameWithRank(p.korName,p.rankName,p.teamName)||x.memberNo,
memberNo:x.memberNo,
fromMonth:x.fromMonth,
salary:Number(x.salary||0)
};
}).sort((a,b)=>String(a.memberNo).localeCompare(String(b.memberNo),'ko')||String(a.fromMonth).localeCompare(String(b.fromMonth)));
if(!rows.length){laborSalaryTblEl.innerHTML='<tr><td>설정 없음</td></tr>';return;}
const head='<thead><tr><th>name</th><th>MemberNo</th><th>fromMonth</th><th>salary</th></tr></thead>';
const counts=new Map();
for(const r of rows){counts.set(r.memberNo,(counts.get(r.memberNo)||0)+1);}
const seen=new Set();
const body='<tbody>'+rows.map(r=>{
const merged=seen.has(r.memberNo)?'':`<td rowspan="${counts.get(r.memberNo)}">${esc(r.name)}</td><td rowspan="${counts.get(r.memberNo)}">${esc(r.memberNo)}</td>`;
seen.add(r.memberNo);
return `<tr data-labor-member="${esc(r.memberNo)}" data-labor-from-month="${esc(r.fromMonth)}">${merged}<td>${esc(r.fromMonth)}</td><td>${Number(r.salary).toLocaleString()}</td></tr>`;
}).join('')+'</tbody>';
laborSalaryTblEl.innerHTML=head+body;
}
async function fetchMonthlyPersonProject(){
const {start,end}=getRange();
if(!start||!end) return [];
const key=`${start}__${end}`;
if(key===monthlyPersonProjectCacheKey && monthlyPersonProjectCacheRows.length){
return monthlyPersonProjectCacheRows.map(r=>({...r}));
}
const d=await (await fetch(`/api/monthly-person-project?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`)).json();
monthlyPersonProjectCacheRows=(d.rows||[]).map(r=>({...r}));
monthlyPersonProjectCacheKey=key;
return monthlyPersonProjectCacheRows.map(r=>({...r}));
}
function computeLaborAllocation(rows, denominatorRows){
const denom = Array.isArray(denominatorRows) && denominatorRows.length ? denominatorRows : rows;
const personMonthTotal=new Map();
for(const r of denom){
const k=`${r.yearMonth}__${r.MemberNo}`;
personMonthTotal.set(k,(personMonthTotal.get(k)||0)+Number(r.hours||0));
}
const monthlyDetail=[];
const byProject=new Map();
const grouped=new Map();
for(const r of rows){
const salary=Number(getCompForMonth(String(r.MemberNo||'').trim(), String(r.yearMonth||'')).salary||0);
if(salary<=0) continue;
const k=`${r.yearMonth}__${r.MemberNo}`;
const totalHours=Number(personMonthTotal.get(k)||0);
if(totalHours<=0) continue;
if(!grouped.has(k)) grouped.set(k, []);
grouped.get(k).push(r);
}
for(const [k, list] of grouped.entries()){
if(!list.length) continue;
const first=list[0];
const comp=getCompForMonth(String(first.MemberNo||'').trim(), String(first.yearMonth||''));
const salary=Number(comp.salary||0);
const total=Number(personMonthTotal.get(k)||0);
if(total<=0) continue;
const calc=list.map(r=>{
const projHours=Number(r.hours||0);
const regHours=Number(r.regularHours||0);
const otHours=Number(r.overtimeHours||0);
// 인건비(기본급)는 월 전체 프로젝트 시간 비율로 배분
const share=total>0?(projHours/total):0;
const exact=salary*share;
const base=Math.floor(exact);
const frac=exact-base;
return {r, projHours, regHours, otHours, share, exact, base, frac};
});
// 직급별 월 최대 OT 캡 준비 로직 (기본 비활성)
const rankName = String(first.rankName||'').trim();
const monthlyOtCap = Number(RANK_MONTHLY_OT_CAP_HOURS[rankName]||0);
if(monthlyOtCap > 0){
const sumOt = calc.reduce((s,x)=>s+Number(x.otHours||0),0);
if(sumOt > monthlyOtCap){
const scale = monthlyOtCap / sumOt;
for(const x of calc){
x.otHours = Number((Number(x.otHours||0) * scale).toFixed(2));
}
}
}
let assigned=calc.reduce((s,x)=>s+x.base,0);
let remainder=Math.max(0, Math.round(salary-assigned));
calc.sort((a,b)=>b.frac-a.frac);
for(let i=0;i<calc.length && remainder>0;i+=1,remainder-=1){calc[i].base+=1;}
calc.sort((a,b)=>String(a.r.projectCode||'').localeCompare(String(b.r.projectCode||''), 'ko'));
for(const x of calc){
const r=x.r;
const rawCode=String(r.projectCode||'').trim();
const label=projectLabel(rawCode);
monthlyDetail.push({
MemberNo:String(r.MemberNo||'').trim(),
yearMonth:r.yearMonth||'',
rawProjectCode:rawCode,
projectCode:label,
member:`${personNameWithRank(r.korName,r.rankName,r.teamName)||r.MemberNo} (${r.MemberNo})`,
memberMonthSalary:salary,
memberMonthHours:total,
projectHours:x.projHours,
regularHours:x.regHours,
overtimeHours:x.otHours,
overtimeCost:0,
sharePct:(x.share*100).toFixed(1)+'%',
allocatedCost:x.base
});
const prev=byProject.get(rawCode)||{rawProjectCode:rawCode,projectCode:label,totalCost:0,overtimeCost:0,totalHours:0,regularHours:0,overtimeHours:0};
prev.totalCost+=Number(x.base||0);
prev.overtimeCost+=0;
prev.totalHours+=x.projHours;
prev.regularHours+=x.regHours;
prev.overtimeHours+=x.otHours;
byProject.set(rawCode,prev);
}
}
const projectRows=[...byProject.values()].map(x=>({...x,totalCost:Math.round(Number(x.totalCost||0))+Math.round(Number(x.overtimeCost||0)),regularCost:Math.round(Number(x.totalCost||0)),overtimeCost:Math.round(Number(x.overtimeCost||0)),totalHours:Number(x.totalHours.toFixed(2)),regularHours:Number((x.regularHours||0).toFixed(2)),overtimeHours:Number((x.overtimeHours||0).toFixed(2))})).sort((a,b)=>b.totalCost-a.totalCost);
monthlyDetail.sort((a,b)=>String(a.yearMonth).localeCompare(String(b.yearMonth))||b.allocatedCost-a.allocatedCost);
return {projectRows, monthlyDetail};
}
function aggregateProjectFromMonthly(monthlyRows){
const byProject=new Map();
for(const r of monthlyRows){
const raw=String(r.rawProjectCode||'').trim();
const label=String(r.projectCode||'').trim();
const prev=byProject.get(raw)||{rawProjectCode:raw,projectCode:label,totalHours:0,regularHours:0,overtimeHours:0,regularCost:0,overtimeCost:0,totalCost:0};
prev.totalHours += Number(r.projectHours||0);
prev.regularHours += Number(r.regularHours||0);
prev.overtimeHours += Number(r.overtimeHours||0);
prev.regularCost += Number(String(r.allocatedCost||'').replace(/,/g,'')||0);
prev.overtimeCost += Number(r.overtimeCost||0);
byProject.set(raw,prev);
}
return [...byProject.values()].map(r=>({...r,totalHours:Number(r.totalHours.toFixed(2)),regularHours:Number(r.regularHours.toFixed(2)),overtimeHours:Number(r.overtimeHours.toFixed(2)),regularCost:Math.round(r.regularCost),overtimeCost:Math.round(r.overtimeCost),totalCost:Math.round(r.regularCost+r.overtimeCost)})).sort((a,b)=>b.totalCost-a.totalCost);
}
function aggregateMonthlyProjectRows(monthlyRows){
const byKey=new Map();
for(const r of monthlyRows){
const ym=String(r.yearMonth||'');
const code=String(r.rawProjectCode||'');
const label=String(r.projectCode||'');
const key=`${ym}__${code}`;
const prev=byKey.get(key)||{yearMonth:ym, rawProjectCode:code, projectCode:label, allocatedCost:0, overtimeCost:0};
prev.allocatedCost += Number(String(r.allocatedCost||'').replace(/,/g,'')||0);
prev.overtimeCost += Number(r.overtimeCost||0);
byKey.set(key, prev);
}
return [...byKey.values()].map(x=>({...x, allocatedCost:Math.round(x.allocatedCost), overtimeCost:Math.round(x.overtimeCost||0)}))
.sort((a,b)=>String(a.yearMonth).localeCompare(String(b.yearMonth))||b.allocatedCost-a.allocatedCost);
}
function openLaborMemberDetail(yearMonth, rawProjectCode){
const rows=laborMonthlyDetailState
.filter(r=>String(r.yearMonth||'')===String(yearMonth||'') && String(r.rawProjectCode||'')===String(rawProjectCode||''))
.map(r=>({
member:r.member,
memberMonthSalary:Number(r.memberMonthSalary||0).toLocaleString(),
memberMonthHours:r.memberMonthHours,
projectHours:r.projectHours,
sharePct:r.sharePct,
regularCost:Number(r.allocatedCost||0).toLocaleString(),
overtimeCost:Number(r.overtimeCost||0).toLocaleString(),
totalCost:Number((r.allocatedCost||0)+(r.overtimeCost||0)).toLocaleString()
}))
.sort((a,b)=>Number(String(b.regularCost).replace(/,/g,''))-Number(String(a.regularCost).replace(/,/g,'')));
laborDetailTitleEl.textContent = `인원별 인건비 상세: ${yearMonth} / ${projectLabel(rawProjectCode)}`;
renderTable(laborMemberDetailTblEl, rows, ['member','memberMonthSalary','memberMonthHours','projectHours','sharePct','regularCost','overtimeCost','totalCost']);
laborDetailPopupEl.classList.add('open');
laborDetailPopupEl.setAttribute('aria-hidden','false');
}
function renderLaborMonthlyMergedTable(el, rows){
if(!rows.length){el.innerHTML='<tr><td>데이터 없음</td></tr>';return;}
const colgroup='<colgroup><col style="width:14%"><col style="width:50%"><col style="width:12%"><col style="width:12%"><col style="width:12%"></colgroup>';
const head='<thead><tr><th>yearMonth</th><th>projectCode</th><th>regularCost</th><th>overtimeCost</th><th>totalCost</th></tr></thead>';
const byMonth=new Map();
for(const r of rows){
const ym=String(r.yearMonth||'');
if(!byMonth.has(ym)) byMonth.set(ym, []);
byMonth.get(ym).push(r);
}
let grandRegular=0;
let grandOvertime=0;
let bodyRows='';
for(const [ym, list] of byMonth.entries()){
const monthRegular=list.reduce((s,r)=>s+Number(String(r.allocatedCost||'0').replace(/,/g,'')),0);
const monthOvertime=list.reduce((s,r)=>s+Number(r.overtimeCost||0),0);
const monthTotal=monthRegular+monthOvertime;
grandRegular += monthRegular;
grandOvertime += monthOvertime;
list.forEach((r,idx)=>{
const monthCell=idx===0?`<td>${esc(ym)}</td>`:`<td class="month-blank">.</td>`;
bodyRows += `<tr>${monthCell}<td class="ellipsis-cell" title="${esc(String(r.projectCode||''))}">${r.projectCode}</td><td class="num">${esc(r.allocatedCost)}</td><td class="num">${Number(r.overtimeCost||0).toLocaleString()}</td><td class="num">${Number((Number(String(r.allocatedCost||'0').replace(/,/g,'')) + Number(r.overtimeCost||0))).toLocaleString()}</td></tr>`;
});
bodyRows += `<tr class="subtotal-row"><td><b>월 소계 (${esc(ym)})</b></td><td></td><td class="num"><b>${Math.round(monthRegular).toLocaleString()}</b></td><td class="num"><b>${Math.round(monthOvertime).toLocaleString()}</b></td><td class="num"><b>${Math.round(monthTotal).toLocaleString()}</b></td></tr>`;
}
const grandTotal=grandRegular+grandOvertime;
bodyRows += `<tr class="subtotal-row"><td><b>총 합계</b></td><td></td><td class="num"><b>${Math.round(grandRegular).toLocaleString()}</b></td><td class="num"><b>${Math.round(grandOvertime).toLocaleString()}</b></td><td class="num"><b>${Math.round(grandTotal).toLocaleString()}</b></td></tr>`;
const body='<tbody>'+bodyRows+'</tbody>';
el.innerHTML=colgroup+head+body;
}
function renderLaborMonthlyMemberTable(el, rows){
if(!rows.length){el.innerHTML='<tr><td>데이터 없음</td></tr>';return;}
const colgroup='<colgroup><col style="width:14%"><col style="width:50%"><col style="width:12%"><col style="width:12%"><col style="width:12%"></colgroup>';
const head='<thead><tr><th>yearMonth</th><th>member</th><th>regularCost</th><th>overtimeCost</th><th>totalCost</th></tr></thead>';
const byMonth=new Map();
for(const r of rows){
const ym=String(r.yearMonth||'');
if(!byMonth.has(ym)) byMonth.set(ym, []);
byMonth.get(ym).push(r);
}
let grandRegular=0;
let grandOvertime=0;
let bodyRows='';
for(const [ym, list] of byMonth.entries()){
const monthRegular=list.reduce((s,r)=>s+Number(r.allocatedCost||0),0);
const monthOvertime=list.reduce((s,r)=>s+Number(r.overtimeCost||0),0);
const monthTotal=monthRegular+monthOvertime;
grandRegular += monthRegular;
grandOvertime += monthOvertime;
list.sort((a,b)=>Number(b.allocatedCost||0)-Number(a.allocatedCost||0));
list.forEach((r,idx)=>{
const monthCell=idx===0?`<td>${esc(ym)}</td>`:`<td class="month-blank">.</td>`;
bodyRows += `<tr>${monthCell}<td>${esc(r.member||'')}</td><td class="num">${Number(r.allocatedCost||0).toLocaleString()}</td><td class="num">${Number(r.overtimeCost||0).toLocaleString()}</td><td class="num">${Number((r.allocatedCost||0)+(r.overtimeCost||0)).toLocaleString()}</td></tr>`;
});
bodyRows += `<tr class="subtotal-row"><td><b>월 소계 (${esc(ym)})</b></td><td></td><td class="num"><b>${Math.round(monthRegular).toLocaleString()}</b></td><td class="num"><b>${Math.round(monthOvertime).toLocaleString()}</b></td><td class="num"><b>${Math.round(monthTotal).toLocaleString()}</b></td></tr>`;
}
const grandTotal=grandRegular+grandOvertime;
bodyRows += `<tr class="subtotal-row"><td><b>총 합계</b></td><td></td><td class="num"><b>${Math.round(grandRegular).toLocaleString()}</b></td><td class="num"><b>${Math.round(grandOvertime).toLocaleString()}</b></td><td class="num"><b>${Math.round(grandTotal).toLocaleString()}</b></td></tr>`;
el.innerHTML=colgroup+head+('<tbody>'+bodyRows+'</tbody>');
}
function renderLaborPersonCostTables(rows){
const q=(laborPersonSearchText||'').trim().toLowerCase();
const filteredRaw=(rows||[]).filter(r=>{
if(!q) return true;
const n=String(r.member||'').toLowerCase();
const m=String(r.MemberNo||'').toLowerCase();
return n.includes(q)||m.includes(q);
});
const byYearMember=new Map();
const byMonthMember=new Map();
for(const r of filteredRaw){
const y=String(r.yearMonth||'').slice(0,4);
const ym=String(r.yearMonth||'');
const member=String(r.member||'');
const keyY=`${y}__${member}`;
const keyM=`${ym}__${member}`;
const yrPrev=byYearMember.get(keyY)||{regularCost:0,overtimeCost:0};
yrPrev.regularCost += Number(r.allocatedCost||0);
yrPrev.overtimeCost += Number(r.overtimeCost||0);
byYearMember.set(keyY,yrPrev);
const moPrev=byMonthMember.get(keyM)||{regularCost:0,overtimeCost:0};
moPrev.regularCost += Number(r.allocatedCost||0);
moPrev.overtimeCost += Number(r.overtimeCost||0);
byMonthMember.set(keyM,moPrev);
}
const yearRows=[...byYearMember.entries()].map(([k,v])=>{
const i=k.indexOf('__');
return {year:k.slice(0,i), member:k.slice(i+2), regularCost:Math.round(v.regularCost||0), overtimeCost:Math.round(v.overtimeCost||0)};
}).sort((a,b)=>String(a.year).localeCompare(String(b.year))||((b.regularCost+b.overtimeCost)-(a.regularCost+a.overtimeCost)));
const monthRows=[...byMonthMember.entries()].map(([k,v])=>{
const i=k.indexOf('__');
return {yearMonth:k.slice(0,i), member:k.slice(i+2), regularCost:Math.round(v.regularCost||0), overtimeCost:Math.round(v.overtimeCost||0)};
}).sort((a,b)=>String(a.yearMonth).localeCompare(String(b.yearMonth))||((b.regularCost+b.overtimeCost)-(a.regularCost+a.overtimeCost)));
renderLaborYearWithInlineMonthTable(laborPersonYearTblEl, yearRows, monthRows, filteredRaw);
}
function renderLaborYearWithInlineMonthTable(el, yearRows, monthRows, rawRows=[]){
if(!yearRows.length){el.innerHTML='<tr><td>데이터 없음</td></tr>';return;}
const colgroup='<colgroup><col style="width:10%"><col style="width:28%"><col style="width:10%"><col style="width:17%"><col style="width:17%"><col style="width:18%"></colgroup>';
const head='<thead><tr><th>year</th><th>member</th><th class="num">peopleCount</th><th class="num">regularCost</th><th class="num">overtimeCost</th><th class="num">totalCost</th></tr></thead>';
const byYearMember=new Map();
for(const r of yearRows){
const y=String(r.year||'');
if(!byYearMember.has(y)) byYearMember.set(y, []);
byYearMember.get(y).push(r);
}
const byYearMonthMember=new Map();
for(const r of monthRows){
const y=String(r.yearMonth||'').slice(0,4);
if(!byYearMonthMember.has(y)) byYearMonthMember.set(y, []);
byYearMonthMember.get(y).push(r);
}
let body='';
let grandRegular=0;
let grandOvertime=0;
for(const [y, list] of byYearMember.entries()){
const yearPeopleCount = new Set(list.map(r=>String(r.member||'').trim()).filter(Boolean)).size;
const subtotalRegular=list.reduce((s,r)=>s+Number(r.regularCost||0),0);
const subtotalOvertime=list.reduce((s,r)=>s+Number(r.overtimeCost||0),0);
const subtotal=subtotalRegular+subtotalOvertime;
grandRegular += subtotalRegular;
grandOvertime += subtotalOvertime;
list.forEach((r,idx)=>{
const yearCell = idx===0
? `<td><span class="jump" data-labor-person-year="${esc(y)}">${esc(y)}</span></td>`
: `<td class="year-blank">.</td>`;
const peopleCell = idx===0 ? `<td class="num">${yearPeopleCount.toLocaleString()}명</td>` : '<td class="num"></td>';
body += `<tr>${yearCell}<td class="member-cell">${esc(r.member||'')}</td>${peopleCell}<td class="num">${Number(r.regularCost||0).toLocaleString()}</td><td class="num">${Number(r.overtimeCost||0).toLocaleString()}</td><td class="num">${Number((r.regularCost||0)+(r.overtimeCost||0)).toLocaleString()}</td></tr>`;
});
body += `<tr class="subtotal-row"><td><b>연도 소계 (${esc(y)})</b></td><td></td><td class="num"><b>${yearPeopleCount.toLocaleString()}명</b></td><td class="num"><b>${Math.round(subtotalRegular).toLocaleString()}</b></td><td class="num"><b>${Math.round(subtotalOvertime).toLocaleString()}</b></td><td class="num"><b>${Math.round(subtotal).toLocaleString()}</b></td></tr>`;
if(laborPersonSelectedYear===y){
const mRows=(byYearMonthMember.get(y)||[]);
const byMonth=new Map();
for(const mr of mRows){
const ym=String(mr.yearMonth||'');
if(!byMonth.has(ym)) byMonth.set(ym, []);
byMonth.get(ym).push(mr);
}
for(const [ym, ml] of byMonth.entries()){
const mReg=ml.reduce((s,r)=>s+Number(r.regularCost||0),0);
const mOt=ml.reduce((s,r)=>s+Number(r.overtimeCost||0),0);
const mTotal=mReg+mOt;
const byProject=new Map();
for(const rr of rawRows){
if(String(rr.yearMonth||'')!==ym) continue;
if(ml.length && !ml.some(m=>String(m.member||'')===String(rr.member||''))) continue;
const key=String(rr.rawProjectCode||rr.projectCode||'').trim();
const label=String(rr.projectCode||'').trim();
const prev=byProject.get(key)||{projectCode:label, regular:0, overtime:0};
prev.regular += Number(rr.allocatedCost||0);
prev.overtime += Number(rr.overtimeCost||0);
byProject.set(key, prev);
}
ml.forEach((r)=>{
body += `<tr class="month-detail-row"><td>${esc(ym)}</td><td class="member-cell">${esc(r.member||'')}</td><td class="num">${Number(r.regularCost||0).toLocaleString()}</td><td class="num">${Number(r.overtimeCost||0).toLocaleString()}</td><td class="num">${Number((r.regularCost||0)+(r.overtimeCost||0)).toLocaleString()}</td></tr>`;
});
const projectRows=[...byProject.values()].sort((a,b)=>(b.regular+b.overtime)-(a.regular+a.overtime));
for(const p of projectRows){
const t=(p.regular||0)+(p.overtime||0);
body += `<tr class="month-project-row"><td></td><td class="member-cell">└ ${esc(p.projectCode||'')}</td><td class="num">${Math.round(p.regular||0).toLocaleString()}</td><td class="num">${Math.round(p.overtime||0).toLocaleString()}</td><td class="num">${Math.round(t).toLocaleString()}</td></tr>`;
}
body += `<tr class="subtotal-row"><td><b>월 소계 (${esc(ym)})</b></td><td></td><td class="num"><b>${Math.round(mReg).toLocaleString()}</b></td><td class="num"><b>${Math.round(mOt).toLocaleString()}</b></td><td class="num"><b>${Math.round(mTotal).toLocaleString()}</b></td></tr>`;
}
}
}
const grandTotal = grandRegular + grandOvertime;
const allPeopleCount = new Set(yearRows.map(r=>String(r.member||'').trim()).filter(Boolean)).size;
body += `<tr class="subtotal-row"><td><b>총 합계</b></td><td></td><td class="num"><b>${allPeopleCount.toLocaleString()}명</b></td><td class="num"><b>${Math.round(grandRegular).toLocaleString()}</b></td><td class="num"><b>${Math.round(grandOvertime).toLocaleString()}</b></td><td class="num"><b>${Math.round(grandTotal).toLocaleString()}</b></td></tr>`;
el.innerHTML=colgroup+head+`<tbody>${body}</tbody>`;
}
function renderLaborProjectSummaryTable(el, rows){
if(!rows.length){el.innerHTML='<tr><td>월급 설정 후 인건비 계산을 눌러주세요.</td></tr>';return;}
const head='<thead><tr><th>projectCode</th><th>totalHours</th><th>regularHours</th><th>overtimeHours</th><th>regularCost</th><th>overtimeCost</th><th>totalCost</th></tr></thead>';
let sumTotalHours=0, sumRegularHours=0, sumOvertimeHours=0, sumRegularCost=0, sumOvertimeCost=0, sumTotalCost=0;
const bodyRows=rows.map(r=>{
const th=Number(r.totalHours||0);
const rh=Number(r.regularHours||0);
const oh=Number(r.overtimeHours||0);
const rc=Number(r.regularCost||0);
const oc=Number(r.overtimeCost||0);
const tc=Number(r.totalCost||0);
sumTotalHours += th;
sumRegularHours += rh;
sumOvertimeHours += oh;
sumRegularCost += rc;
sumOvertimeCost += oc;
sumTotalCost += tc;
return `<tr>
<td class="ellipsis-cell" title="${esc(r.projectCode||'')}"><span class="jump" data-labor-select-project="${esc(r.rawProjectCode||'')}">${esc(r.projectCode||'')}</span></td>
<td>${th.toLocaleString()}</td>
<td>${rh.toLocaleString()}</td>
<td>${oh.toLocaleString()}</td>
<td>${Math.round(rc).toLocaleString()}</td>
<td>${Math.round(oc).toLocaleString()}</td>
<td>${Math.round(tc).toLocaleString()}</td>
</tr>`;
}).join('');
const totalRow=`<tr class="subtotal-row">
<td><b>총 합계</b></td>
<td><b>${Number(sumTotalHours.toFixed(2)).toLocaleString()}</b></td>
<td><b>${Number(sumRegularHours.toFixed(2)).toLocaleString()}</b></td>
<td><b>${Number(sumOvertimeHours.toFixed(2)).toLocaleString()}</b></td>
<td><b>${Math.round(sumRegularCost).toLocaleString()}</b></td>
<td><b>${Math.round(sumOvertimeCost).toLocaleString()}</b></td>
<td><b>${Math.round(sumTotalCost).toLocaleString()}</b></td>
</tr>`;
el.innerHTML=head+`<tbody>${bodyRows}${totalRow}</tbody>`;
}
async function renderLaborPane(){
await fetchPeopleSummary();
await fetchProjectAliases();
await fetchProjectSummary();
await fetchSalaryAvgByTitle();
const rows=await fetchMonthlyPersonProject();
applyAutoSalaryFromRankIfMissing(rows);
const coreRows = rows.filter(r=>isCoreProjectType(getProjectType(r)));
const commonRows = rows.filter(r=>!isCoreProjectType(getProjectType(r)));
renderLaborTeamOptions(coreRows);
const typeFilteredRows = laborTypeFilterValue
? coreRows.filter(r=>getProjectType(r)===laborTypeFilterValue)
: coreRows;
const teamFilteredRows = laborTeamFilterValue
? typeFilteredRows.filter(r=>String(r.teamName||'').trim()===laborTeamFilterValue)
: typeFilteredRows;
laborMonthlyRowsCache = rows.map(r=>({...r}));
laborCoreRowsCache = coreRows.map(r=>({...r}));
laborCommonRowsCache = commonRows.map(r=>({...r}));
renderLaborMemberProjectFilterOptions();
renderLaborMemberOptions();
renderLaborTypeOptions();
renderLaborSalaryTable();
const result=computeLaborAllocation(teamFilteredRows, rows);
renderLaborProjectOptions((result.projectRows||[]).map(r=>r.rawProjectCode));
const fullResult=computeLaborAllocation(rows, rows);
laborPersonCostRowsCache = (fullResult.monthlyDetail||[]).map(r=>({...r}));
renderLaborPersonCostTables(laborPersonCostRowsCache);
const filteredProjectRows = selectedLaborProjectCode
? result.projectRows.filter(r => r.rawProjectCode === selectedLaborProjectCode)
: result.projectRows;
const filteredMonthlyRows = selectedLaborProjectCode
? result.monthlyDetail.filter(r => r.rawProjectCode === selectedLaborProjectCode)
: result.monthlyDetail;
renderLaborYearOptions(filteredMonthlyRows);
const yearFilteredMonthlyRows = laborYearFilterValue
? filteredMonthlyRows.filter(r=>String(r.yearMonth||'').startsWith(`${laborYearFilterValue}-`))
: filteredMonthlyRows;
const yearFilteredProjectRows = aggregateProjectFromMonthly(yearFilteredMonthlyRows);
// 인건비 탭 합계는 현재 인건비 필터 기준으로 별도 표기
const laborPeopleCount = new Set(yearFilteredMonthlyRows.map(r=>String(r.MemberNo||'').trim()).filter(Boolean)).size;
const laborProjectCount = new Set(yearFilteredMonthlyRows.map(r=>String(r.rawProjectCode||'').trim()).filter(Boolean)).size;
const laborRowsCount = yearFilteredMonthlyRows.length;
const laborTotalHours = yearFilteredMonthlyRows.reduce((s,r)=>s+Number(r.projectHours||0),0);
setKpis({
totalHours: Number(laborTotalHours.toFixed(2)),
totalRows: laborRowsCount,
peopleCount: laborPeopleCount,
projectCount: laborProjectCount
});
laborProjectHintEl.textContent = '';
const projectCountForTable = new Set(yearFilteredProjectRows.map(r=>String(r.rawProjectCode||'').trim()).filter(Boolean)).size;
const peopleCountForTable = new Set(yearFilteredMonthlyRows.map(r=>String(r.MemberNo||'').trim()).filter(Boolean)).size;
laborProjectSummaryInfoEl.textContent = `총 프로젝트: ${projectCountForTable.toLocaleString()}개 | 총 참여인원: ${peopleCountForTable.toLocaleString()}`;
const monthCountForTable = new Set(yearFilteredMonthlyRows.map(r=>String(r.yearMonth||'').trim()).filter(Boolean)).size;
laborMonthlySummaryInfoEl.textContent = `총 개월수: ${monthCountForTable.toLocaleString()}개월`;
clearLaborProjectBtnEl.style.display = selectedLaborProjectCode ? 'inline-block' : 'none';
if(selectedLaborProjectCode){
const srcRows = teamFilteredRows.filter(r => (r.projectCode||'').trim() === selectedLaborProjectCode);
const noSalaryMembers = [...new Map(
srcRows
.filter(r => Number(getCompForMonth(String(r.MemberNo||'').trim(), String(r.yearMonth||'')).salary||0) <= 0)
.map(r => [r.MemberNo, `${personNameWithRank(r.korName,r.rankName,r.teamName)||r.MemberNo} (${r.MemberNo})`])
).values()];
if(noSalaryMembers.length){
laborProjectHintEl.textContent = `월급 미입력 인원: ${noSalaryMembers.join(', ')}`;
}
}
renderLaborProjectSummaryTable(laborProjectTblEl, yearFilteredProjectRows);
laborMonthlyDetailState = yearFilteredMonthlyRows.map(r=>({...r}));
if(!yearFilteredMonthlyRows.length){laborMonthlyTblEl.innerHTML='<tr><td>데이터 없음</td></tr>';} else {
if(selectedLaborProjectCode){
const monthlyMemberRows=yearFilteredMonthlyRows.map(r=>({
yearMonth:r.yearMonth,
member:r.member,
allocatedCost:Number(r.allocatedCost||0),
overtimeCost:Number(r.overtimeCost||0)
})).sort((a,b)=>String(a.yearMonth).localeCompare(String(b.yearMonth))||Number(b.allocatedCost)-Number(a.allocatedCost));
renderLaborMonthlyMemberTable(laborMonthlyTblEl, monthlyMemberRows);
}else{
const monthlyProjectRows = aggregateMonthlyProjectRows(yearFilteredMonthlyRows).map(r=>({
yearMonth:r.yearMonth,
projectCode:`${esc(r.projectCode)}`,
allocatedCost:Number(r.allocatedCost||0).toLocaleString(),
overtimeCost:Number(r.overtimeCost||0)
}));
renderLaborMonthlyMergedTable(laborMonthlyTblEl, monthlyProjectRows);
}
}
const commonResult = computeLaborAllocation(commonRows, rows);
const commonBaseMonthlyRows = laborYearFilterValue
? (commonResult.monthlyDetail||[]).filter(r=>String(r.yearMonth||'').startsWith(`${laborYearFilterValue}-`))
: (commonResult.monthlyDetail||[]);
const commonYearFilteredProjectRows = aggregateProjectFromMonthly(commonBaseMonthlyRows);
const commonYearFilteredMonthlyRows = selectedLaborCommonProjectCode
? commonBaseMonthlyRows.filter(r=>String(r.rawProjectCode||'').trim()===selectedLaborCommonProjectCode)
: commonBaseMonthlyRows;
const commonProjectRowsForTable = selectedLaborCommonProjectCode
? commonYearFilteredProjectRows.filter(r=>String(r.rawProjectCode||'').trim()===selectedLaborCommonProjectCode)
: commonYearFilteredProjectRows;
if(selectedLaborCommonProjectCode && !commonYearFilteredProjectRows.some(r=>String(r.rawProjectCode||'').trim()===selectedLaborCommonProjectCode)){
selectedLaborCommonProjectCode = '';
}
laborCommonSelectedLabelEl.textContent = selectedLaborCommonProjectCode
? `선택 프로젝트: ${projectLabel(selectedLaborCommonProjectCode)}`
: '';
clearCommonProjectBtnEl.style.display = selectedLaborCommonProjectCode ? 'inline-block' : 'none';
renderLaborProjectSummaryTable(laborCommonProjectTblEl, commonProjectRowsForTable);
if(!commonYearFilteredMonthlyRows.length){
laborCommonMonthlyTblEl.innerHTML='<tr><td>데이터 없음</td></tr>';
}else{
if(selectedLaborCommonProjectCode){
const commonMonthlyMemberRows = commonYearFilteredMonthlyRows.map(r=>({
yearMonth:r.yearMonth,
member:r.member,
allocatedCost:Number(r.allocatedCost||0),
overtimeCost:Number(r.overtimeCost||0)
})).sort((a,b)=>String(a.yearMonth).localeCompare(String(b.yearMonth))||Number(b.allocatedCost)-Number(a.allocatedCost));
renderLaborMonthlyMemberTable(laborCommonMonthlyTblEl, commonMonthlyMemberRows);
}else{
const commonMonthlyProjectRows = aggregateMonthlyProjectRows(commonYearFilteredMonthlyRows).map(r=>({
yearMonth:r.yearMonth,
projectCode:`${esc(r.projectCode)}`,
allocatedCost:Number(r.allocatedCost||0).toLocaleString(),
overtimeCost:Number(r.overtimeCost||0)
}));
renderLaborMonthlyMergedTable(laborCommonMonthlyTblEl, commonMonthlyProjectRows);
}
}
}
function getCalendarYearsFromRows(){
const ys=[...new Set(personProjectYearRowsState.map(r=>String(r.yearMonth||'').slice(0,4)).filter(v=>/^\d{4}$/.test(v)))];
return ys.sort((a,b)=>Number(a)-Number(b));
}
function aggregateTypesByCalendarYear(year){
const rows=personProjectYearRowsState.filter(r=>String(r.yearMonth||'').startsWith(`${year}-`));
const byType=new Map();
for(const r of rows){
const t=String(r.typeCode||'기타').trim()||'기타';
byType.set(t,(byType.get(t)||0)+Number(r.hours||0));
}
const total=rows.reduce((s,r)=>s+Number(r.hours||0),0);
const types=[...byType.entries()].map(([typeCode,hours])=>({typeCode,hours,sharePct:total>0?(hours/total*100):0})).sort((a,b)=>b.hours-a.hours);
return {rows,total,types};
}
function renderDetailYearTables(){
const year=String(detailYearSelectEl.value||'');
const srcRows=personProjectYearRowsState
.filter(r=>String(r.yearMonth||'').startsWith(`${year}-`))
.sort((a,b)=>String(a.yearMonth).localeCompare(String(b.yearMonth))||Number(b.hours||0)-Number(a.hours||0))
.map(r=>({
yearMonth:r.yearMonth,
typeCode:r.typeCode,
projectCode:r.projectCode,
hours:Number(Number(r.hours||0).toFixed(2)),
regularHours:Number(Number(r.regularHours||0).toFixed(2)),
overtimeHours:Number(Number(r.overtimeHours||0).toFixed(2))
}));
renderMonthMergedTable(personProjectYearTbl,srcRows);
const agg=aggregateTypesByCalendarYear(year);
const typeRows=agg.types.map(t=>({yearCode:year,yearTotalHours:Number(agg.total.toFixed(2)),typeCode:t.typeCode,hours:Number(t.hours.toFixed(2)),sharePct:`${Number(t.sharePct).toFixed(1)}%`}));
renderYearTypeMergedTable(yearTypeTbl,typeRows);
}
function renderDetailDonut(){const year=String(detailYearSelectEl.value||'');const agg=aggregateTypesByCalendarYear(year);const total=Number(agg.total||0);const types=agg.types;if(!types.length||total<=0){detailDonutWrapEl.innerHTML='<div class="small">데이터 없음</div>';return;}let acc=0;const segs=types.map((t,i)=>{const pct=Number(t.sharePct||0);const start=acc;acc+=pct;return `${palette(i)} ${start.toFixed(1)}% ${acc.toFixed(1)}%`;}).join(', ');const legend=types.map((t,i)=>`<div class="legend-item"><span class="dot" style="background:${palette(i)}"></span><span>${esc(t.typeCode)}: ${Number(t.hours||0).toFixed(1)}h (${Number(t.sharePct||0).toFixed(1)}%)</span></div>`).join('');detailDonutWrapEl.innerHTML=`<div style="position:relative;"><div class="donut" style="background:conic-gradient(${segs});" title="${esc(year)}년 총 ${total.toFixed(1)}h"></div><div class="donut-center">${esc(year)}년<br>${total.toFixed(1)}h</div></div><div class="legend">${legend}</div>`;}
function openDetailPopup(){
const years=getCalendarYearsFromRows();
if(!years.length){alert('조회된 상세 데이터가 없습니다. 사람과 기간을 먼저 선택해 주세요.');return;}
const r=getRange();
if(!selectedMemberNo||!r.start||!r.end){alert('사람/기간 선택이 필요합니다.');return;}
const u=`/detail-view.html?memberNo=${encodeURIComponent(selectedMemberNo)}&start=${encodeURIComponent(r.start)}&end=${encodeURIComponent(r.end)}`;
const width = 1180;
const height = 860;
const left = Math.max(0, window.screenX + (window.outerWidth - width - 20));
const top = Math.max(0, window.screenY + 40);
const features = `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
const w = window.open(u, 'detailWindow', features);
personDetailWindowRef = w || null;
if(!w){ window.location.href = u; }
else { try { w.focus(); } catch(_) {} }
}
function closeDetailPopup(){detailPopupEl.classList.remove('open');detailPopupEl.setAttribute('aria-hidden','true');}
function closeLaborDetailPopup(){laborDetailPopupEl.classList.remove('open');laborDetailPopupEl.setAttribute('aria-hidden','true');}
function openProjectDetailPopup(){
const r=getRange();
if(!selectedProjectCode||!r.start||!r.end){alert('프로젝트/기간 선택이 필요합니다.');return;}
const u=`/detail-view-project.html?projectCode=${encodeURIComponent(selectedProjectCode)}&start=${encodeURIComponent(r.start)}&end=${encodeURIComponent(r.end)}`;
const width = 1180, height = 860;
const left = Math.max(0, window.screenX + (window.outerWidth - width - 20));
const top = Math.max(0, window.screenY + 40);
const features = `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
const w = window.open(u, 'projectDetailWindow', features);
projectDetailWindowRef = w || null;
if(!w){ window.location.href = u; } else { try { w.focus(); } catch(_) {} }
}
function syncOpenedDetailWindows(){
const r=getRange();
if(!r.start||!r.end) return;
if(personDetailWindowRef && !personDetailWindowRef.closed && selectedMemberNo){
const u=`/detail-view.html?memberNo=${encodeURIComponent(selectedMemberNo)}&start=${encodeURIComponent(r.start)}&end=${encodeURIComponent(r.end)}`;
try{
if(personDetailWindowRef.location.pathname + personDetailWindowRef.location.search !== u){
personDetailWindowRef.location.replace(u);
}
}catch(_){}
}
if(projectDetailWindowRef && !projectDetailWindowRef.closed && selectedProjectCode){
const u=`/detail-view-project.html?projectCode=${encodeURIComponent(selectedProjectCode)}&start=${encodeURIComponent(r.start)}&end=${encodeURIComponent(r.end)}`;
try{
if(projectDetailWindowRef.location.pathname + projectDetailWindowRef.location.search !== u){
projectDetailWindowRef.location.replace(u);
}
}catch(_){}
}
}
function renderTable(el,rows,headers,rawKeys=[]){if(!rows.length){el.innerHTML='<tr><td>데이터 없음</td></tr>';return;}const rawSet=new Set(rawKeys||[]);const head='<thead><tr>'+headers.map(h=>`<th>${h}</th>`).join('')+'</tr></thead>';const body='<tbody>'+rows.map(r=>'<tr>'+headers.map(h=>{const v=(r[h]??'').toString();return rawSet.has(h)?`<td>${v}</td>`:`<td>${v.replace(/</g,'&lt;')}</td>`;}).join('')+'</tr>').join('')+'</tbody>';el.innerHTML=head+body;}
function renderMonthMergedTable(el,rows){
if(!rows.length){el.innerHTML='<tr><td>데이터 없음</td></tr>';return;}
const head='<thead><tr><th>yearMonth</th><th>typeCode</th><th>projectCode</th><th>hours</th><th>regularHours</th><th>overtimeHours</th></tr></thead>';
const counts=new Map();
for(const r of rows){const ym=String(r.yearMonth||'');counts.set(ym,(counts.get(ym)||0)+1);}
const seen=new Set();
const body='<tbody>'+rows.map(r=>{
const ym=String(r.yearMonth||'');
const monthCell=seen.has(ym)?'':`<td rowspan="${counts.get(ym)}">${esc(ym)}</td>`;
seen.add(ym);
return `<tr>${monthCell}<td>${esc(r.typeCode??'')}</td><td>${esc(r.projectCode??'')}</td><td>${esc(r.hours??'')}</td><td>${esc(r.regularHours??'')}</td><td>${esc(r.overtimeHours??'')}</td></tr>`;
}).join('')+'</tbody>';
el.innerHTML=head+body;
}
function renderYearTypeMergedTable(el,rows){
if(!rows.length){el.innerHTML='<tr><td>데이터 없음</td></tr>';return;}
const head='<thead><tr><th>yearCode</th><th>yearTotalHours</th><th>typeCode</th><th>hours</th><th>sharePct</th></tr></thead>';
const counts=new Map();
for(const r of rows){const y=String(r.yearCode||'');counts.set(y,(counts.get(y)||0)+1);}
const seen=new Set();
const body='<tbody>'+rows.map(r=>{
const y=String(r.yearCode||'');
const merged=seen.has(y)?'':`<td rowspan="${counts.get(y)}">${esc(y)}</td><td rowspan="${counts.get(y)}">${esc(r.yearTotalHours??'')}</td>`;
seen.add(y);
return `<tr>${merged}<td>${esc(r.typeCode??'')}</td><td>${esc(r.hours??'')}</td><td>${esc(r.sharePct??'')}</td></tr>`;
}).join('')+'</tbody>';
el.innerHTML=head+body;
}
function renderPersonProjectTable(rows){
if(!rows.length){personProjectTbl.innerHTML='<tr><td>데이터 없음</td></tr>';return;}
const head='<thead><tr><th>project</th><th>totalHours</th><th>regularHours</th><th>overtimeHours</th><th>sharePct</th></tr></thead>';
const body='<tbody>'+rows.map(r=>{
const workHours=Number(r.regularHours||0);
return `<tr><td><span class="jump" data-project="${esc(r.projectCode||'')}">${esc(projectLabel(r.projectCode||''))}</span></td><td>${r.hours??''}</td><td>${workHours}</td><td>${r.overtimeHours??0}</td><td>${r.sharePct??''}</td></tr>`;
}).join('')+'</tbody>';
personProjectTbl.innerHTML=head+body;
}
function renderProjectPeopleTable(rows){
const infoEl=document.getElementById('projectPeopleSummaryInfo');
if(!rows.length){
if(infoEl) infoEl.textContent='총 투입인원: 0명';
projectPeopleTbl.innerHTML='<tr><td>데이터 없음</td></tr>';
return;
}
const head='<thead><tr><th>korName</th><th>MemberNo</th><th>totalHours</th><th>regularHours</th><th>overtimeHours</th><th>sharePct</th></tr></thead>';
let sumHours=0, sumRegular=0, sumOt=0;
const bodyRows=rows.map(r=>{
const workHours=Number(r.regularHours||0);
const h=Number(r.hours||0), ot=Number(r.overtimeHours||0);
sumHours+=h; sumRegular+=workHours; sumOt+=ot;
return `<tr><td><span class="jump" data-member="${String(r.MemberNo||'').replace(/"/g,'&quot;')}">${personNameWithRank(r.korName||'', r.rankName||'', r.teamName||'')}</span></td><td>${r.MemberNo??''}</td><td>${r.hours??''}</td><td>${workHours}</td><td>${r.overtimeHours??0}</td><td>${r.sharePct??''}</td></tr>`;
}).join('');
if(infoEl) infoEl.textContent=`총 투입인원: ${rows.length.toLocaleString()}`;
const totalRow=`<tr class="subtotal-row"><td><b>총계</b></td><td><b>${rows.length}명</b></td><td><b>${Number(sumHours.toFixed(2)).toLocaleString()}</b></td><td><b>${Number(sumRegular.toFixed(2)).toLocaleString()}</b></td><td><b>${Number(sumOt.toFixed(2)).toLocaleString()}</b></td><td><b>100.0%</b></td></tr>`;
projectPeopleTbl.innerHTML=head+`<tbody>${bodyRows}${totalRow}</tbody>`;
}
function renderYearlyProjectChart(projectYearlyRows){
const rows=projectYearlyRows||[];
if(!rows.length){yearlyChartWrap.innerHTML='<div class="small">데이터 없음</div>';return;}
const byYearProject=new Map();
const byYearType=new Map();
for(const r of rows){
const y=String(r.yearCode||'').trim()||'기타';
const p=String(r.projectCode||'').trim()||'기타';
const t=String(r.typeCode||'기타').trim()||'기타';
const h=Number(r.hours||0);
const rh=Number(r.regularHours||0);
const bh=Number(r.businessTripHours||0);
const oh=Number(r.overtimeHours||0);
if(!byYearProject.has(y))byYearProject.set(y,new Map());
const prevP=byYearProject.get(y).get(p)||{hours:0,regular:0,businessTrip:0,overtime:0};
byYearProject.get(y).set(p,{hours:prevP.hours+h,regular:prevP.regular+rh,businessTrip:prevP.businessTrip+bh,overtime:prevP.overtime+oh});
if(!byYearType.has(y))byYearType.set(y,new Map());
const prevT=byYearType.get(y).get(t)||{hours:0,regular:0,businessTrip:0,overtime:0};
byYearType.get(y).set(t,{hours:prevT.hours+h,regular:prevT.regular+rh,businessTrip:prevT.businessTrip+bh,overtime:prevT.overtime+oh});
}
const years=[...byYearProject.keys()].sort();
yearlyChartWrap.innerHTML=years.map(y=>{
const items=[...byYearProject.get(y).entries()]
.map(([projectCode,v])=>({projectCode,hours:v.hours,regular:v.regular,businessTrip:v.businessTrip,overtime:v.overtime}))
.sort((a,b)=>b.hours-a.hours);
const total=items.reduce((s,x)=>s+x.hours,0);
const top=items.slice(0,12);
const bars=top.map(it=>{
const pct=total>0?(it.hours/total*100):0;
const regPct=it.hours>0?(it.regular/it.hours*100):0;
const btPct=it.hours>0?(it.businessTrip/it.hours*100):0;
const otPct=it.hours>0?(it.overtime/it.hours*100):0;
const tip=`${it.hours.toFixed(2)}h | 근무 ${it.regular.toFixed(2)}h | 출장 ${it.businessTrip.toFixed(2)}h | 추가 ${it.overtime.toFixed(2)}h`;
return `<div class="bar-row"><div>${esc(projectLabel(it.projectCode))}</div><div class="bar-wrap"><div class="bar-stack" title="${tip}"><div class="bar-regular" style="width:${regPct.toFixed(1)}%" title="근무 ${it.regular.toFixed(2)}h"></div><div class="bar-business-trip" style="width:${btPct.toFixed(1)}%" title="출장 ${it.businessTrip.toFixed(2)}h"></div><div class="bar-overtime" style="width:${otPct.toFixed(1)}%" title="추가 ${it.overtime.toFixed(2)}h"></div></div></div><div>${pct.toFixed(1)}%</div></div>`;
}).join('');
const typeSummary=[...byYearType.get(y).entries()]
.map(([typeCode,v])=>({typeCode,hours:v.hours,regular:v.regular,businessTrip:v.businessTrip,overtime:v.overtime}))
.sort((a,b)=>b.hours-a.hours)
.map(it=>`${it.typeCode}: 총 ${it.hours.toFixed(1)}h (근무 ${it.regular.toFixed(1)}h / 출장 ${it.businessTrip.toFixed(1)}h / 추가 ${it.overtime.toFixed(1)}h)`)
.join(' | ');
return `<div class="year-block"><div class="year-title">${y}년</div><div class="small" style="margin:2px 0 8px 0;">${typeSummary}</div>${bars}</div>`;
}).join('');
}
function renderProjectYearlyPeopleChart(peopleYearlyRows){
const rows=peopleYearlyRows||[];
if(!rows.length){projectYearlyPeopleWrap.innerHTML='<div class="small">데이터 없음</div>';return;}
const byYear=new Map();
const overall=new Map();
for(const r of rows){
const y=String(r.yearCode||'').trim()||'기타';
const person=((r.korName||'').trim()?`${personNameWithRank(r.korName||'', r.rankName||'', r.teamName||'')} (${r.MemberNo||''})`:String(r.MemberNo||'').trim()||'기타');
const h=Number(r.hours||0);
const rh=Number(r.regularHours||0);
const bh=Number(r.businessTripHours||0);
const oh=Number(r.overtimeHours||0);
if(!byYear.has(y))byYear.set(y,new Map());
const prev=byYear.get(y).get(person)||{hours:0,regular:0,businessTrip:0,overtime:0};
byYear.get(y).set(person,{hours:prev.hours+h,regular:prev.regular+rh,businessTrip:prev.businessTrip+bh,overtime:prev.overtime+oh});
const op=overall.get(person)||{hours:0,regular:0,businessTrip:0,overtime:0};
overall.set(person,{hours:op.hours+h,regular:op.regular+rh,businessTrip:op.businessTrip+bh,overtime:op.overtime+oh});
}
const years=[...byYear.keys()].sort();
const yearBlocks=years.map(y=>{
const items=[...byYear.get(y).entries()].map(([person,v])=>({person,hours:v.hours,regular:v.regular,businessTrip:v.businessTrip,overtime:v.overtime})).sort((a,b)=>b.hours-a.hours);
const total=items.reduce((s,x)=>s+x.hours,0);
const regTotal=items.reduce((s,x)=>s+x.regular,0);
const btTotal=items.reduce((s,x)=>s+x.businessTrip,0);
const otTotal=items.reduce((s,x)=>s+x.overtime,0);
const top=items.slice(0,12);
const bars=top.map(it=>{
const pct=total>0?(it.hours/total*100):0;
const regPct=it.hours>0?(it.regular/it.hours*100):0;
const btPct=it.hours>0?(it.businessTrip/it.hours*100):0;
const otPct=it.hours>0?(it.overtime/it.hours*100):0;
const tip=`${it.hours.toFixed(2)}h | 근무 ${it.regular.toFixed(2)}h | 출장 ${it.businessTrip.toFixed(2)}h | 추가 ${it.overtime.toFixed(2)}h`;
return `<div class="bar-row"><div>${it.person}</div><div class="bar-wrap"><div class="bar-stack" title="${tip}"><div class="bar-regular-alt" style="width:${regPct.toFixed(1)}%" title="근무 ${it.regular.toFixed(2)}h"></div><div class="bar-business-trip" style="width:${btPct.toFixed(1)}%" title="출장 ${it.businessTrip.toFixed(2)}h"></div><div class="bar-overtime" style="width:${otPct.toFixed(1)}%" title="추가 ${it.overtime.toFixed(2)}h"></div></div></div><div>${pct.toFixed(1)}%</div></div>`;
}).join('');
const summary=`<div class="small" style="margin:2px 0 8px 0;">총 투입인원 ${items.length}명 | 총 ${total.toFixed(1)}h (근무 ${regTotal.toFixed(1)}h / 출장 ${btTotal.toFixed(1)}h / 추가 ${otTotal.toFixed(1)}h)</div>`;
return `<div class="year-block"><div class="year-title">${y}년</div>${summary}${bars}</div>`;
});
const allItems=[...overall.entries()].map(([person,v])=>({person,hours:v.hours,regular:v.regular,businessTrip:v.businessTrip,overtime:v.overtime})).sort((a,b)=>b.hours-a.hours);
const allTotal=allItems.reduce((s,x)=>s+x.hours,0);
const allReg=allItems.reduce((s,x)=>s+x.regular,0);
const allBt=allItems.reduce((s,x)=>s+x.businessTrip,0);
const allOt=allItems.reduce((s,x)=>s+x.overtime,0);
const allBars=allItems.slice(0,12).map(it=>{
const pct=allTotal>0?(it.hours/allTotal*100):0;
const regPct=it.hours>0?(it.regular/it.hours*100):0;
const btPct=it.hours>0?(it.businessTrip/it.hours*100):0;
const otPct=it.hours>0?(it.overtime/it.hours*100):0;
const tip=`${it.hours.toFixed(2)}h | 근무 ${it.regular.toFixed(2)}h | 출장 ${it.businessTrip.toFixed(2)}h | 추가 ${it.overtime.toFixed(2)}h`;
return `<div class="bar-row"><div>${it.person}</div><div class="bar-wrap"><div class="bar-stack" title="${tip}"><div class="bar-regular-alt" style="width:${regPct.toFixed(1)}%"></div><div class="bar-business-trip" style="width:${btPct.toFixed(1)}%"></div><div class="bar-overtime" style="width:${otPct.toFixed(1)}%"></div></div></div><div>${pct.toFixed(1)}%</div></div>`;
}).join('');
const allSummary=`<div class="small" style="margin:2px 0 8px 0;">총 투입인원 ${allItems.length}명 | 총 ${allTotal.toFixed(1)}h (근무 ${allReg.toFixed(1)}h / 출장 ${allBt.toFixed(1)}h / 추가 ${allOt.toFixed(1)}h)</div>`;
yearBlocks.push(`<div class="year-block"><div class="year-title">총계</div>${allSummary}${allBars}</div>`);
projectYearlyPeopleWrap.innerHTML=yearBlocks.join('');
}
function tabUI(){document.querySelectorAll('.tab').forEach(btn=>btn.classList.toggle('active',btn.dataset.tab===activeTab));document.querySelectorAll('.pane').forEach(p=>p.classList.toggle('active',p.id===activeTab));}
function personSubtabUI(){document.querySelectorAll('.person-subtab').forEach(btn=>btn.classList.toggle('active',btn.dataset.psub===personSubtab));document.getElementById('personTablePane').classList.toggle('active',personSubtab==='personTablePane');document.getElementById('personYearGraphPane').classList.toggle('active',personSubtab==='personYearGraphPane');}
function projectSubtabUI(){document.querySelectorAll('.project-subtab').forEach(btn=>btn.classList.toggle('active',btn.dataset.qsub===projectSubtab));document.getElementById('projectTablePane').classList.toggle('active',projectSubtab==='projectTablePane');document.getElementById('projectYearGraphPane').classList.toggle('active',projectSubtab==='projectYearGraphPane');}
function laborSubtabUI(){document.querySelectorAll('.labor-subtab').forEach(btn=>btn.classList.toggle('active',btn.dataset.lsub===laborSubtab));document.getElementById('laborPeoplePane').classList.toggle('active',laborSubtab==='laborPeoplePane');document.getElementById('laborProjectPane').classList.toggle('active',laborSubtab==='laborProjectPane');document.getElementById('laborPersonCostPane').classList.toggle('active',laborSubtab==='laborPersonCostPane');document.getElementById('laborCommonPane').classList.toggle('active',laborSubtab==='laborCommonPane');}
function getRange(){const s=document.getElementById('startMonth').value;const e=document.getElementById('endMonth').value;if(!s||!e)return{start:'',end:''};const[sy,sm]=s.split('-').map(Number);const[ey,em]=e.split('-').map(Number);const start=`${sy.toString().padStart(4,'0')}-${String(sm).padStart(2,'0')}-01`;const endLast=new Date(ey,em,0).getDate();const end=`${ey.toString().padStart(4,'0')}-${String(em).padStart(2,'0')}-${String(endLast).padStart(2,'0')}`;return{start,end};}
function toMonth(x){return(!x||x.length<7)?'':x.slice(0,7);}
async function initDefaultRange(){if(rangeInitialized)return;const s=document.getElementById('startMonth');const e=document.getElementById('endMonth');try{const d=await (await fetch('/api/date-range')).json();const min=toMonth(d.minDate),max=toMonth(d.maxDate);if(min)s.value=min;if(max)e.value=max;}catch{if(!s.value)s.value='2024-01';if(!e.value)e.value='2024-12';}rangeInitialized=true;}
function parseProjectCode(code){const p=String(code||'').split('-');return{year:p[0]||'',type:p[1]||'',seq:p[2]||''};}
function getProjectType(row){const t1=String(row?.typeCode||'').trim();if(t1)return t1;return String(parseProjectCode(row?.projectCode||'').type||'').trim();}
function isCoreProjectType(typeCode){return CORE_PROJECT_TYPES.has(String(typeCode||'').trim());}
function updateSelectedLabels(opts={}){
const mno=String(opts.memberNo||selectedMemberNo||'').trim();
if(!mno){
personSelectedInfoEl.textContent='현재 선택: -';
return;
}
const row=(allPeopleRows||[]).find(r=>String(r.MemberNo||'').trim()===mno) || {};
const nm=String(row.korName||opts.memberName||'').trim() || mno;
personSelectedInfoEl.textContent=`현재 선택: ${nm} (${mno})`;
}
function renderPersonTeamOptions(){
const teams=[...new Set((allPeopleRows||[]).map(p=>String(p.teamName||'').trim()).filter(Boolean))].sort((a,b)=>a.localeCompare(b,'ko'));
personTeamFilterEl.innerHTML='<option value="">전체</option>'+teams.map(t=>`<option value="${esc(t)}">${esc(t)}</option>`).join('');
if(personTeamFilterValue && teams.includes(personTeamFilterValue)) personTeamFilterEl.value=personTeamFilterValue;
else if(personTeamFilterValue){ personTeamFilterValue=''; personTeamFilterEl.value=''; }
}
function filteredPeople(){
const q=(personSearchEl.value||'').trim().toLowerCase();
return (allPeopleRows||[]).filter(p=>{
if(personTeamFilterValue && String(p.teamName||'').trim()!==personTeamFilterValue) return false;
if(!personIncludeRetired && Number(p.isRetired||0)>0) return false;
if(!q) return true;
return String(p.korName||'').toLowerCase().includes(q) || String(p.MemberNo||'').toLowerCase().includes(q);
});
}
function fillProjectFilters(rows){const years=new Set(),types=new Set(),seqs=new Set();for(const r of rows){const p=parseProjectCode(r.projectCode);if(p.year)years.add(p.year);if(p.type)types.add(p.type);if(p.seq)seqs.add(p.seq);}const setOptions=(el,vals,keep)=>{el.innerHTML='<option value="">전체</option>';vals.forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent=v;el.appendChild(o);});if(keep&&vals.includes(keep))el.value=keep;};const y=yearFilterEl.value,t=typeFilterEl.value,s=seqFilterEl.value;setOptions(typeFilterEl,[...types].sort(),t);setOptions(yearFilterEl,[...years].sort(),y);setOptions(seqFilterEl,[...seqs].sort((a,b)=>Number(a)-Number(b)||a.localeCompare(b)),s);}
function filteredProjects(){const q=(projectSearchEl.value||'').trim().toLowerCase();const y=yearFilterEl.value,t=typeFilterEl.value,s=seqFilterEl.value;return allProjectRows.filter(r=>{const p=parseProjectCode(r.projectCode);if(y&&p.year!==y)return false;if(t&&p.type!==t)return false;if(s&&p.seq!==s)return false;const code=String(r.projectCode||'').toLowerCase();const name=String(projectAliasMap[r.projectCode]||'').toLowerCase();if(q&&!(code.includes(q)||name.includes(q)))return false;return true;});}
async function fetchPeopleSummary(){
const{start,end}=getRange();
if(!start||!end) return;
const key=`${start}__${end}`;
if(key===peopleSummaryCacheKey && allPeopleRows.length){
renderPersonTeamOptions();
return;
}
allPeopleRows=((await (await fetch(`/api/people-summary?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`)).json()).people)||[];
peopleSummaryCacheKey=key;
renderPersonTeamOptions();
}
async function fetchProjectAliases(){projectAliasMap=((await (await fetch('/api/project-aliases')).json()).aliases)||{};}
async function fetchProjectSummary(){
const{start,end}=getRange();
if(!start||!end) return;
const key=`${start}__${end}`;
if(key===projectSummaryCacheKey && allProjectRows.length){
fillProjectFilters(allProjectRows);
return;
}
allProjectRows=((await (await fetch(`/api/project-summary?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`)).json()).projects)||[];
projectSummaryCacheKey=key;
fillProjectFilters(allProjectRows);
}
async function renderPeopleList(){const rows=filteredPeople();const has=rows.filter(r=>Number(r.totalRows||0)>0||Number(r.totalHours||0)>0);const no=rows.filter(r=>!(Number(r.totalRows||0)>0||Number(r.totalHours||0)>0));const ord=[...has,...no];if(!selectedMemberNo&&ord.length)selectedMemberNo=ord[0].MemberNo;if(selectedMemberNo&&!ord.some(r=>r.MemberNo===selectedMemberNo))selectedMemberNo=ord.length?ord[0].MemberNo:'';peopleListEl.innerHTML='';if(has.length){const g=document.createElement('div');g.className='group-title';g.textContent=`기록 있음 (${has.length}명)`;peopleListEl.appendChild(g);}for(const p of has){const d=document.createElement('div');d.className='item has-record'+(selectedMemberNo===p.MemberNo?' active':'');const nameNo=p.korName?`${(p.korName||'').trim()} (${p.MemberNo})`:p.MemberNo;const team=(p.teamName||'').trim();const teamChip=team?`<span class="team-inline">[${esc(team)}]</span>`:'';d.innerHTML=`<div class="person-row-head"><div class="person-row-left"><b>${esc(nameNo)}</b>${retiredBadgeHtml(p)}</div><div class="person-row-right">${teamChip}</div></div><div class="small">${Number(p.totalHours||0).toLocaleString()}h / ${Number(p.totalRows||0).toLocaleString()}건</div>`;d.addEventListener('click',async()=>{selectedMemberNo=p.MemberNo;await viewPersonDashboard();await renderPeopleList();});peopleListEl.appendChild(d);}if(no.length){const g=document.createElement('div');g.className='group-title';g.textContent=`기록 없음 (${no.length}명)`;peopleListEl.appendChild(g);}for(const p of no){const d=document.createElement('div');d.className='item no-record'+(selectedMemberNo===p.MemberNo?' active':'');const nameNo=p.korName?`${(p.korName||'').trim()} (${p.MemberNo})`:p.MemberNo;const team=(p.teamName||'').trim();const teamChip=team?`<span class="team-inline">[${esc(team)}]</span>`:'';d.innerHTML=`<div class="person-row-head"><div class="person-row-left"><b>${esc(nameNo)}</b>${retiredBadgeHtml(p)}</div><div class="person-row-right">${teamChip}</div></div><div class="small">0h / 0건</div>`;d.addEventListener('click',async()=>{selectedMemberNo=p.MemberNo;await viewPersonDashboard();await renderPeopleList();});peopleListEl.appendChild(d);}}
async function renderProjectList(){
const rows=filteredProjects();
if(!selectedProjectCode&&rows.length)selectedProjectCode=rows[0].projectCode;
if(selectedProjectCode&&!rows.some(r=>r.projectCode===selectedProjectCode))selectedProjectCode=rows.length?rows[0].projectCode:'';
projectSelectedInfoEl.textContent = selectedProjectCode ? `현재 선택 프로젝트: ${projectLabel(selectedProjectCode)}` : '현재 선택 프로젝트: -';
projectListEl.innerHTML='';
for(const p of rows){
const d=document.createElement('div');
d.className='item'+(selectedProjectCode===p.projectCode?' active':'');
d.innerHTML=`<div><b>${esc(projectLabel(p.projectCode))}</b></div><div class="small">${Number(p.totalHours||0).toLocaleString()}h / ${Number(p.totalRows||0).toLocaleString()}건 / ${Number(p.peopleCount||0).toLocaleString()}명</div>`;
d.addEventListener('click',async()=>{selectedProjectCode=p.projectCode;await viewProjectDashboard();await renderProjectList();});
projectListEl.appendChild(d);
}
}
async function viewPersonDashboard(){if(!selectedMemberNo){renderTable(personProjectTbl,[],['projectCode','hours','sharePct']);statusEl.textContent='사람을 선택해 주세요.';return;}const{start,end}=getRange();if(!start||!end){statusEl.textContent='시작년월/종료년월을 선택해 주세요.';return;}statusEl.textContent='사람 중심 조회 중...';const d=await (await fetch(`/api/member-dashboard?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&memberNo=${encodeURIComponent(selectedMemberNo)}`)).json();setKpis(d);updateSelectedLabels({memberNo:d.memberNo,memberName:d.korName});const rows=(d.projects||[]).map(x=>({...x,sharePct:d.totalHours>0?((Number(x.hours||0)/Number(d.totalHours||0))*100).toFixed(1)+'%':'0.0%'}));renderPersonProjectTable(rows);renderYearlyProjectChart(d.projectYearly||[]);personProjectYearRowsState=(d.projectYearly||[]).map(x=>({yearMonth:x.yearMonth||'',yearCode:x.yearCode||'',typeCode:x.typeCode||'',rawProjectCode:x.projectCode||'',projectCode:projectLabel(x.projectCode||''),hours:Number(x.hours||0),regularHours:Number(x.regularHours||0),overtimeHours:Number(x.overtimeHours||0)}));renderTable(personProjectYearTbl,personProjectYearRowsState,['yearMonth','yearCode','typeCode','projectCode','hours']);statusEl.textContent=`완료: ${d.start} ~ ${d.end}`.trim();await loadYearTypeBreakdown(start,end,d.memberNo||selectedMemberNo);syncOpenedDetailWindows();}
async function viewProjectDashboard(){if(!selectedProjectCode){renderTable(projectPeopleTbl,[],['korName','MemberNo','hours','sharePct']);statusEl.textContent='프로젝트를 선택해 주세요.';return;}const{start,end}=getRange();if(!start||!end){statusEl.textContent='시작년월/종료년월을 선택해 주세요.';return;}statusEl.textContent='프로젝트 중심 조회 중...';const d=await (await fetch(`/api/project-dashboard?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&projectCode=${encodeURIComponent(selectedProjectCode)}`)).json();setKpis(d);updateSelectedLabels({projectCode:d.projectCode});const rows=(d.people||[]).map(x=>({...x,sharePct:d.totalHours>0?((Number(x.hours||0)/Number(d.totalHours||0))*100).toFixed(1)+'%':'0.0%'}));renderProjectPeopleTable(rows);renderProjectYearlyPeopleChart(d.peopleYearly||[]);statusEl.textContent=`완료: ${d.start} ~ ${d.end}`;syncOpenedDetailWindows();}
async function loadYearTypeBreakdown(start,end,memberNo){if(!memberNo){yearTypeTbl.innerHTML='<tr><td>사람을 선택해 주세요.</td></tr>';yearTypeYearsState=[];return;}if(!start||!end)return;const d=await (await fetch(`/api/member-yearly-breakdown?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&memberNo=${encodeURIComponent(memberNo)}`)).json();const years=d.years||[];yearTypeYearsState=years;if(!years.length){yearTypeTbl.innerHTML='<tr><td>데이터 없음</td></tr>';return;}const rows=[];for(const y of years)for(const t of (y.types||[]))rows.push({yearCode:y.yearCode,yearTotalHours:y.totalHours,typeCode:t.typeCode,hours:t.hours,sharePct:`${t.sharePct}%`});renderTable(yearTypeTbl,rows,['yearCode','yearTotalHours','typeCode','hours','sharePct']);if(detailPopupEl.classList.contains('open')){renderDetailDonut();renderDetailYearTables();}}
async function refreshActiveTab(){await fetchProjectAliases();await fetchPeopleSummary();await fetchProjectSummary();if(activeTab==='personPane'){await renderPeopleList();await viewPersonDashboard();}else if(activeTab==='projectPane'){await renderProjectList();await viewProjectDashboard();}else{await renderLaborPane();}}
async function loadDb(){statusEl.textContent='DB 로딩 중...';const d=await (await fetch('/api/load',{method:'POST'})).json();statusEl.textContent=`완료: member ${Number(d.member_loaded||0).toLocaleString()}건, daily ${Number(d.dally_loaded||0).toLocaleString()}건, 중복제거 ${Number(d.duplicates_skipped||0).toLocaleString()}`;selectedMemberNo='';selectedProjectCode='';rangeInitialized=false;peopleSummaryCacheKey='';projectSummaryCacheKey='';monthlyPersonProjectCacheKey='';monthlyPersonProjectCacheRows=[];await initDefaultRange();await refreshActiveTab();}
document.getElementById('loadBtn').addEventListener('click',loadDb);
document.getElementById('viewBtn').addEventListener('click',refreshActiveTab);
document.querySelectorAll('.tab').forEach(btn=>btn.addEventListener('click',async()=>{activeTab=btn.dataset.tab;tabUI();await refreshActiveTab();}));
document.querySelectorAll('.person-subtab').forEach(btn=>btn.addEventListener('click',()=>{personSubtab=btn.dataset.psub;personSubtabUI();}));
document.querySelectorAll('.project-subtab').forEach(btn=>btn.addEventListener('click',()=>{projectSubtab=btn.dataset.qsub;projectSubtabUI();}));
document.querySelectorAll('.labor-subtab').forEach(btn=>btn.addEventListener('click',()=>{laborSubtab=btn.dataset.lsub;laborSubtabUI();}));
personSearchEl.addEventListener('input',debounce(async()=>{await renderPeopleList();},180));
personTeamFilterEl.addEventListener('change',async()=>{personTeamFilterValue=personTeamFilterEl.value||'';await renderPeopleList();await viewPersonDashboard();});
personIncludeRetiredEl.addEventListener('change',async()=>{personIncludeRetired=!!personIncludeRetiredEl.checked;await renderPeopleList();await viewPersonDashboard();});
[typeFilterEl,yearFilterEl,seqFilterEl].forEach(el=>{el.addEventListener('input',async()=>{await renderProjectList();await viewProjectDashboard();});el.addEventListener('change',async()=>{await renderProjectList();await viewProjectDashboard();});});
projectSearchEl.addEventListener('input',debounce(async()=>{await renderProjectList();},180));
projectSearchEl.addEventListener('change',async()=>{await renderProjectList();await viewProjectDashboard();});
personProjectTbl.addEventListener('click',async(e)=>{const t=e.target.closest('[data-project]');if(!t)return;selectedProjectCode=t.getAttribute('data-project')||'';activeTab='projectPane';tabUI();await fetchProjectSummary();await renderProjectList();await viewProjectDashboard();});
projectPeopleTbl.addEventListener('click',async(e)=>{const t=e.target.closest('[data-member]');if(!t)return;selectedMemberNo=t.getAttribute('data-member')||'';activeTab='personPane';tabUI();await fetchPeopleSummary();await renderPeopleList();await viewPersonDashboard();});
laborSaveBtnEl.addEventListener('click',()=>{
const mno=selectedLaborSalaryMemberNo||'';
if(!mno){alert('사람을 선택해 주세요.');return;}
const draft=readLaborSalaryDraftFromTable();
if(!draft.length){alert('유효한 행이 없습니다. (적용 시작년월/월급 확인)');return;}
laborSalaryEntries = laborSalaryEntries.filter(x=>x.memberNo!==mno);
for(const r of draft){
upsertSalaryEntry(mno,r.fromMonth,r.salary);
}
saveLaborSalaryMap();
renderLaborSalaryTable();
closeLaborSalaryPopup();
});
laborDeleteBtnEl.addEventListener('click',()=>{
const picked=laborSalaryEditTblEl.querySelector('input[name=\"salaryRowPick\"]:checked');
let idx=picked?Number(picked.value):-1;
if(!(idx>=0) && laborSalaryDraftRows.length) idx=laborSalaryDraftRows.length-1;
if(idx<0 || idx>=laborSalaryDraftRows.length) return;
laborSalaryDraftRows.splice(idx,1);
renderLaborSalaryEditTable();
});
laborAddRowBtnEl.addEventListener('click',()=>{
const base = normMonth(document.getElementById('startMonth').value||'') || '2000-01';
laborSalaryDraftRows.push({fromMonth:base,salary:0});
renderLaborSalaryEditTable();
});
laborRecalcBtnEl.addEventListener('click',async()=>{await renderLaborPane();});
laborProjectSelectEl.addEventListener('change',async()=>{selectedLaborProjectCode=laborProjectSelectEl.value||'';await renderLaborPane();});
laborProjectSearchEl.addEventListener('input',debounce(()=>{laborProjectSearchText=laborProjectSearchEl.value||'';renderLaborProjectOptions();},220));
laborMemberSearchEl.addEventListener('input',debounce(()=>{laborMemberSearchText=laborMemberSearchEl.value||'';renderLaborMemberOptions();},180));
laborMemberProjectSearchEl.addEventListener('input',debounce(()=>{laborMemberProjectSearchText=laborMemberProjectSearchEl.value||'';renderLaborMemberProjectFilterOptions();renderLaborMemberOptions();},180));
laborMemberProjectFilterEl.addEventListener('change',()=>{laborMemberProjectFilterValue=laborMemberProjectFilterEl.value||'';renderLaborMemberOptions();});
laborTypeFilterEl.addEventListener('change',async()=>{laborTypeFilterValue=laborTypeFilterEl.value||'';await renderLaborPane();});
laborTeamFilterEl.addEventListener('change',async()=>{laborTeamFilterValue=laborTeamFilterEl.value||'';await renderLaborPane();});
laborYearFilterEl.addEventListener('change',async()=>{laborYearFilterValue=laborYearFilterEl.value||'';selectedLaborCommonProjectCode='';await renderLaborPane();});
laborPersonSearchEl.addEventListener('input',debounce(()=>{
laborPersonSearchText=laborPersonSearchEl.value||'';
if(!String(laborPersonSearchText).trim()){
// 검색어를 지우면 전체 목록/접힘 상태로 복귀
laborPersonSelectedYear='';
}
renderLaborPersonCostTables(laborPersonCostRowsCache);
},180));
laborPersonYearTblEl.addEventListener('click',(e)=>{
const t=e.target.closest('[data-labor-person-year]');
if(!t) return;
const y=t.getAttribute('data-labor-person-year')||'';
laborPersonSelectedYear = (laborPersonSelectedYear===y) ? '' : y;
renderLaborPersonCostTables(laborPersonCostRowsCache);
});
laborProjectTblEl.addEventListener('click',async(e)=>{
const t=e.target.closest('[data-labor-select-project]');
if(!t) return;
selectedLaborProjectCode=t.getAttribute('data-labor-select-project')||'';
laborProjectSelectEl.value=selectedLaborProjectCode;
await renderLaborPane();
});
clearLaborProjectBtnEl.addEventListener('click', async()=>{
selectedLaborProjectCode='';
laborProjectSelectEl.value='';
await renderLaborPane();
});
laborCommonProjectTblEl.addEventListener('click',async(e)=>{
const t=e.target.closest('[data-labor-select-project]');
if(!t) return;
selectedLaborCommonProjectCode=t.getAttribute('data-labor-select-project')||'';
await renderLaborPane();
});
clearCommonProjectBtnEl.addEventListener('click', async()=>{
selectedLaborCommonProjectCode='';
await renderLaborPane();
});
laborSalaryTblEl.addEventListener('click',(e)=>{
const t=e.target.closest('[data-labor-member]');
if(!t)return;
openLaborSalaryPopup(
t.getAttribute('data-labor-member')||'',
t.getAttribute('data-labor-from-month')||''
);
});
closeLaborDetailBtnEl.addEventListener('click',closeLaborDetailPopup);
laborDetailBackdropEl.addEventListener('click',closeLaborDetailPopup);
closeLaborSalaryBtnEl.addEventListener('click',closeLaborSalaryPopup);
laborSalaryBackdropEl.addEventListener('click',closeLaborSalaryPopup);
openDetailPopupBtnEl.addEventListener('click',openDetailPopup);
if(openProjectDetailBtnEl){openProjectDetailBtnEl.addEventListener('click',openProjectDetailPopup);}
closeDetailPopupBtnEl.addEventListener('click',closeDetailPopup);
detailPopupBackdropEl.addEventListener('click',closeDetailPopup);
detailYearSelectEl.addEventListener('change',()=>{renderDetailDonut();renderDetailYearTables();});
loadLaborSalaryMap();
tabUI();personSubtabUI();projectSubtabUI();laborSubtabUI();initDefaultRange().then(refreshActiveTab);
</script>
</body>
</html>