139 lines
8.8 KiB
HTML
139 lines
8.8 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>
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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>
|