1926 lines
114 KiB
HTML
1926 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);
|
|
}
|
|
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="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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
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,'<')}</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,'"')}">${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>
|