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

145
detail-view.html Normal file
View File

@@ -0,0 +1,145 @@
<!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}
.hours-summary{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px}
.hours-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border:1px solid #e1d7cb;border-radius:999px;font-size:12px;background:#fff}
</style>
</head>
<body>
<div class="wrap">
<div class="card"><div class="row" style="justify-content:space-between"><h3 id="detailTitle" 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 memberNo = (q.get('memberNo')||'').trim();
const start = (q.get('start')||'').trim();
const end = (q.get('end')||'').trim();
const colors=['#2f6b5f','#e27d2f','#4f5bd5','#b33c2e','#0f766e','#0369a1','#c2410c'];
const esc=(s)=>String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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;
let regularTotal=0, overtimeTotal=0, businessTripTotal=0;
data.forEach(r=>{
const k=r.typeCode||'기타'; const h=Number(r.hours||0);
m.set(k,(m.get(k)||0)+h); t+=h;
regularTotal += Number(r.regularHours||0);
overtimeTotal += Number(r.overtimeHours||0);
businessTripTotal += Number(r.businessTripHours||0);
});
const types=[...m.entries()].map(([typeCode,hours])=>({typeCode,hours,sharePct:t?hours/t*100:0})).sort((a,b)=>b.hours-a.hours);
return {rows:data,total:t,types,regularTotal,overtimeTotal,businessTripTotal};
}
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','typeCode','projectCode','hours','regularHours','businessTripHours','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.businessTrip += Number(r.businessTripHours || 0);
acc.overtime += Number(r.overtimeHours || 0);
return acc;
}, {hours:0, regular:0, businessTrip:0, overtime:0});
let body = '';
let curMonth = '';
let monthSum = {hours:0, regular:0, businessTrip: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.businessTrip.toFixed(2)+'</td><td style="font-weight:700;background:#f8f2e8">'+monthSum.overtime.toFixed(2)+'</td></tr>';
monthSum = {hours:0, regular:0, businessTrip:0, overtime:0};
}
const m=seen.has(ym)?'':'<td rowspan="'+c.get(ym)+'">'+esc(ym)+'</td>';
seen.add(ym);
body += '<tr>'+m+'<td>'+esc(r.typeCode)+'</td><td>'+esc(r.projectCode)+'</td><td>'+esc(r.hours)+'</td><td>'+esc(r.regularHours)+'</td><td>'+esc(r.businessTripHours)+'</td><td>'+esc(r.overtimeHours)+'</td></tr>';
monthSum.hours += Number(r.hours || 0);
monthSum.regular += Number(r.regularHours || 0);
monthSum.businessTrip += Number(r.businessTripHours || 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.businessTrip.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.businessTrip.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, projectCode: projectLabel(r.projectCode)})));
if(!g.types.length){document.getElementById('donutWrap').innerHTML='<div>데이터 없음</div>';return;}
let acc=0; const segs=g.types.map((t,i)=>{const s=acc; acc+=t.sharePct; return colors[i%colors.length]+' '+s.toFixed(1)+'% '+acc.toFixed(1)+'%';}).join(', ');
const legend=g.types.map((t,i)=>'<div class="legend-item"><span class="dot" style="background:'+colors[i%colors.length]+'"></span><span>'+esc(t.typeCode)+': '+t.hours.toFixed(1)+'h ('+t.sharePct.toFixed(1)+'%)</span></div>').join('');
const hoursSummary =
'<div class="hours-summary">' +
'<div class="hours-chip">근무: <b>'+g.regularTotal.toFixed(1)+'h</b></div>' +
'<div class="hours-chip">출장: <b>'+g.businessTripTotal.toFixed(1)+'h</b></div>' +
'<div class="hours-chip">추가: <b>'+g.overtimeTotal.toFixed(1)+'h</b></div>' +
'</div>';
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>'+hoursSummary+'<div class="legend">'+legend+'</div></div>';
}
async function init(){
if(!memberNo || !start || !end){ document.body.innerHTML='<div style="padding:20px">필수 파라미터가 없습니다.</div>'; return; }
alias=((await (await fetch('/api/project-aliases')).json()).aliases)||{};
const d=await (await fetch(`/api/member-dashboard?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&memberNo=${encodeURIComponent(memberNo)}`)).json();
const displayName = (d.korName||'').trim() ? `${d.korName} (${memberNo})` : memberNo;
document.getElementById('detailTitle').textContent = `선택 인원 상세 (년도별) - ${displayName}`;
document.title = `상세보기 - ${displayName}`;
rows=(d.projectYearly||[]).map(x=>({
yearMonth:x.yearMonth||'',
typeCode:x.typeCode||'',
projectCode:x.projectCode||'',
hours:Number(x.hours||0),
regularHours:Number(x.regularHours||0),
businessTripHours:Number(x.businessTripHours||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>