Add JH work data page and database

This commit is contained in:
2026-06-05 15:34:26 +09:00
commit c196a31e4a
58 changed files with 295313 additions and 0 deletions

138
detail-view-project.html Normal file
View File

@@ -0,0 +1,138 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>프로젝트 상세보기</title>
<style>
body{font-family:"Pretendard","Noto Sans KR",sans-serif;margin:0;background:#f5f1ea;color:#1c1b1a}
.wrap{max-width:1200px;margin:14px auto;padding:0 14px}
.card{background:#fff;border:1px solid #e1d7cb;border-radius:14px;padding:14px;margin-bottom:12px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
select{padding:7px 9px;border:1px solid #e1d7cb;border-radius:10px}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{border-bottom:1px solid #ece4d9;padding:7px 8px;text-align:left;white-space:nowrap}
th{background:#f4ece0;color:#6e675f}
.table-wrap{max-height:60vh;overflow:auto}
.donut-wrap{display:grid;grid-template-columns:260px 1fr;gap:16px;align-items:center;background:#fff;border:1px solid #e1d7cb;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 #e1d7cb}
.center{position:absolute;inset:0;display:grid;place-items:center;font-weight:900;z-index:2;text-align:center;font-size:13px}
.legend{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.legend-item{display:flex;gap:8px;align-items:center;font-size:12px;background:#fff;border:1px solid #e1d7cb;border-radius:10px;padding:7px 9px}
.dot{width:11px;height:11px;border-radius:999px}
</style>
</head>
<body>
<div class="wrap">
<div class="card"><div class="row" style="justify-content:space-between"><h3 id="title" style="margin:0">선택 프로젝트 상세 (년도별)</h3><label>년도 <select id="yearSel"></select></label></div></div>
<div class="card"><div id="donutWrap" class="donut-wrap"></div></div>
<div class="card"><h3 style="margin:0 0 8px">인원 투입 상세 (년/월)</h3><div class="table-wrap"><table id="tblA"></table></div></div>
</div>
<script>
const q = new URLSearchParams(location.search);
const projectCode = (q.get('projectCode')||'').trim();
const start = (q.get('start')||'').trim();
const end = (q.get('end')||'').trim();
const colors=['#2f6b5f','#e27d2f','#4f5bd5','#b33c2e','#0f766e','#0369a1','#c2410c','#7c3aed','#0891b2','#ca8a04'];
const esc=(s)=>String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const personNameWithRank=(name, rank)=>{const n=String(name||'').trim();const r=String(rank||'').trim();if(n&&r)return `${n} ${r}`;return n||'';};
let rows=[];
let alias={};
const projectLabel=(code)=>{const c=String(code||'').trim(); if(!c) return ''; const short=alias[c]||''; return short?`${short} (${c})`:c;};
function getYears(){
const ys=[...new Set(rows.map(r=>String(r.yearMonth||'').slice(0,4)).filter(v=>/^\d{4}$/.test(v)))];
return ys.sort((a,b)=>Number(a)-Number(b));
}
function byYear(y){ return rows.filter(r=>String(r.yearMonth||'').startsWith(y+'-')); }
function agg(y){
const data=byYear(y); const m=new Map(); let t=0;
data.forEach(r=>{
const person=(r.korName||'').trim()?`${personNameWithRank(r.korName||'', r.rankName||'')} (${r.MemberNo||''})`:String(r.MemberNo||'');
const h=Number(r.hours||0); const rh=Number(r.regularHours||0); const oh=Number(r.overtimeHours||0);
const prev=m.get(person)||{hours:0,regularHours:0,overtimeHours:0};
m.set(person,{hours:prev.hours+h,regularHours:prev.regularHours+rh,overtimeHours:prev.overtimeHours+oh}); t+=h;
});
const people=[...m.entries()].map(([person,v])=>({
person,
hours:v.hours,
regularHours:v.regularHours,
overtimeHours:v.overtimeHours,
sharePct:t?v.hours/t*100:0
})).sort((a,b)=>b.hours-a.hours);
return {rows:data,total:t,people};
}
function renderTable(el,rows,heads){
if(!rows.length){el.innerHTML='<tr><td>데이터 없음</td></tr>';return;}
el.innerHTML='<thead><tr>'+heads.map(h=>'<th>'+h+'</th>').join('')+'</tr></thead><tbody>'+
rows.map(r=>'<tr>'+heads.map(h=>'<td>'+esc(r[h]??'')+'</td>').join('')+'</tr>').join('')+'</tbody>';
}
function renderMerged(el,rows){
if(!rows.length){el.innerHTML='<tr><td>데이터 없음</td></tr>';return;}
const heads=['yearMonth','korName','MemberNo','hours','regularHours','overtimeHours'];
const c=new Map(); rows.forEach(r=>c.set(r.yearMonth,(c.get(r.yearMonth)||0)+1));
const seen=new Set();
const sum = rows.reduce((acc, r) => {
acc.hours += Number(r.hours || 0);
acc.regular += Number(r.regularHours || 0);
acc.overtime += Number(r.overtimeHours || 0);
return acc;
}, {hours:0, regular:0, overtime:0});
let body = '';
let curMonth = '';
let monthSum = {hours:0, regular:0, overtime:0};
for(const r of rows){
const ym = String(r.yearMonth || '');
if(curMonth && ym !== curMonth){
body += '<tr><td colspan="3" style="font-weight:700;background:#f8f2e8">월 소계 ('+esc(curMonth)+')</td><td style="font-weight:700;background:#f8f2e8">'+monthSum.hours.toFixed(2)+'</td><td style="font-weight:700;background:#f8f2e8">'+monthSum.regular.toFixed(2)+'</td><td style="font-weight:700;background:#f8f2e8">'+monthSum.overtime.toFixed(2)+'</td></tr>';
monthSum = {hours:0, regular:0, overtime:0};
}
const m=seen.has(ym)?'':'<td rowspan="'+c.get(ym)+'">'+esc(ym)+'</td>';
seen.add(ym);
body += '<tr>'+m+'<td>'+esc(personNameWithRank(r.korName, r.rankName))+'</td><td>'+esc(r.MemberNo)+'</td><td>'+esc(r.hours)+'</td><td>'+esc(r.regularHours)+'</td><td>'+esc(r.overtimeHours)+'</td></tr>';
monthSum.hours += Number(r.hours || 0);
monthSum.regular += Number(r.regularHours || 0);
monthSum.overtime += Number(r.overtimeHours || 0);
curMonth = ym;
}
if(curMonth){
body += '<tr><td colspan="3" style="font-weight:700;background:#f8f2e8">월 소계 ('+esc(curMonth)+')</td><td style="font-weight:700;background:#f8f2e8">'+monthSum.hours.toFixed(2)+'</td><td style="font-weight:700;background:#f8f2e8">'+monthSum.regular.toFixed(2)+'</td><td style="font-weight:700;background:#f8f2e8">'+monthSum.overtime.toFixed(2)+'</td></tr>';
}
el.innerHTML='<thead><tr>'+heads.map(h=>'<th>'+h+'</th>').join('')+'</tr></thead><tbody>'+body+'</tbody><tfoot><tr><td colspan="3" style="font-weight:800;background:#f4ece0">합계</td><td style="font-weight:800;background:#f4ece0">'+sum.hours.toFixed(2)+'</td><td style="font-weight:800;background:#f4ece0">'+sum.regular.toFixed(2)+'</td><td style="font-weight:800;background:#f4ece0">'+sum.overtime.toFixed(2)+'</td></tr></tfoot>';
}
function draw(y){
const g=agg(y); const total=g.total||0;
const sorted=g.rows.slice().sort((a,b)=>String(a.yearMonth).localeCompare(String(b.yearMonth))||Number(b.hours)-Number(a.hours));
renderMerged(document.getElementById('tblA'), sorted.map(r=>({...r, korName:r.korName||''})));
if(!g.people.length){document.getElementById('donutWrap').innerHTML='<div>데이터 없음</div>';return;}
let acc=0; const segs=g.people.map((p,i)=>{const s=acc; acc+=p.sharePct; return colors[i%colors.length]+' '+s.toFixed(1)+'% '+acc.toFixed(1)+'%';}).join(', ');
const legend=g.people.map((p,i)=>'<div class="legend-item"><span class="dot" style="background:'+colors[i%colors.length]+'"></span><span>'+esc(p.person)+': '+p.hours.toFixed(1)+'h ('+p.sharePct.toFixed(1)+'%)</span></div>').join('');
document.getElementById('donutWrap').innerHTML='<div style="position:relative"><div class="donut" style="background:conic-gradient('+segs+')"></div><div class="center">'+esc(y)+'년<br>'+total.toFixed(1)+'h</div></div><div class="legend">'+legend+'</div>';
}
async function init(){
if(!projectCode || !start || !end){ document.body.innerHTML='<div style="padding:20px">필수 파라미터가 없습니다.</div>'; return; }
alias=((await (await fetch('/api/project-aliases')).json()).aliases)||{};
document.getElementById('title').textContent='선택 프로젝트 상세 (년도별): '+projectLabel(projectCode);
const d=await (await fetch(`/api/project-monthly-detail?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&projectCode=${encodeURIComponent(projectCode)}`)).json();
rows=(d.rows||[]).map(x=>({
yearMonth:x.yearMonth||'',
MemberNo:x.MemberNo||'',
korName:x.korName||'',
rankName:x.rankName||'',
hours:Number(x.hours||0),
regularHours:Number(x.regularHours||0),
overtimeHours:Number(x.overtimeHours||0)
}));
const years=getYears();
const sel=document.getElementById('yearSel');
sel.innerHTML=years.map(y=>`<option value="${y}">${y}년</option>`).join('');
sel.value=years[0]||'';
sel.addEventListener('change',()=>draw(sel.value));
draw(sel.value);
}
init();
</script>
</body>
</html>