Add dashboard.html for data analysis system
This commit is contained in:
561
dashboard.html
Normal file
561
dashboard.html
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DfMA 팀별/개인별 데이터 분석 시스템</title>
|
||||||
|
<!-- Tailwind CSS CDN -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<!-- SheetJS CDN -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||||
|
<!-- Google Charts CDN -->
|
||||||
|
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
|
||||||
|
<!-- Lucide Icons CDN -->
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100;300;400;500;700;900&display=swap');
|
||||||
|
body { font-family: 'Noto Sans KR', sans-serif; background-color: #f8fafc; }
|
||||||
|
.google-visualization-tooltip {
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 1rem !important;
|
||||||
|
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1) !important;
|
||||||
|
padding: 1rem !important;
|
||||||
|
}
|
||||||
|
/* 스크롤바 디자인 커스텀 */
|
||||||
|
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track { background: #f1f5f9; }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="p-4 md:p-8">
|
||||||
|
|
||||||
|
<!-- Upload Zone -->
|
||||||
|
<div id="upload-container" class="min-h-[80vh] flex flex-col items-center justify-center">
|
||||||
|
<div onclick="document.getElementById('file-input').click()"
|
||||||
|
class="w-full max-w-2xl bg-white border-4 border-dashed border-blue-200 rounded-[2.5rem] p-16 text-center hover:border-blue-400 transition-all cursor-pointer group shadow-2xl shadow-blue-100/50">
|
||||||
|
<div class="bg-blue-50 w-28 h-28 rounded-[2rem] flex items-center justify-center mx-auto mb-10 group-hover:scale-110 transition-transform shadow-inner text-blue-600">
|
||||||
|
<i data-lucide="upload" size="56"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-4xl font-black text-slate-800 mb-6 tracking-tight">팀별/개인별 데이터 분석 시스템</h2>
|
||||||
|
<p class="text-slate-500 mb-12 text-xl font-medium leading-relaxed">
|
||||||
|
엑셀 파일을 업로드하세요. <br/>
|
||||||
|
</p>
|
||||||
|
<button class="bg-blue-600 text-white px-12 py-5 rounded-2xl font-black shadow-2xl shadow-blue-300 hover:bg-blue-700 transition-all text-xl active:scale-95">
|
||||||
|
파일 선택 및 분석 시작
|
||||||
|
</button>
|
||||||
|
<input id="file-input" type="file" class="hidden" accept=".xlsx, .xls">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Content (Hidden at first) -->
|
||||||
|
<div id="dashboard-content" class="hidden animate-in fade-in duration-700">
|
||||||
|
|
||||||
|
<!-- Header & Filters -->
|
||||||
|
<div class="bg-white rounded-[2rem] shadow-2xl shadow-slate-200/50 border border-slate-200 p-6 mb-10 flex flex-wrap items-center justify-between gap-8 sticky top-4 z-50 backdrop-blur-xl bg-white/90">
|
||||||
|
<div class="flex items-center gap-4 min-w-0">
|
||||||
|
<div class="bg-blue-600 p-3 rounded-2xl text-white shadow-xl shadow-blue-200 shrink-0">
|
||||||
|
<i data-lucide="activity" size="28"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 id="main-title" class="font-black text-2xl tracking-tighter text-slate-900 leading-none whitespace-nowrap overflow-hidden text-ellipsis"></h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">팀</span>
|
||||||
|
<div class="bg-slate-50 px-4 py-2 rounded-xl border border-slate-200">
|
||||||
|
<select id="team-select" class="bg-transparent border-none text-sm font-black outline-none cursor-pointer text-slate-700 min-w-[100px] py-1"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">팀원</span>
|
||||||
|
<div class="bg-slate-50 px-4 py-2 rounded-xl border border-slate-200 flex items-center gap-2">
|
||||||
|
<i data-lucide="user" size="14" class="text-blue-500"></i>
|
||||||
|
<select id="person-select" class="bg-transparent border-none text-sm font-black outline-none cursor-pointer text-blue-600 min-w-[140px] py-1">
|
||||||
|
<option value="">전체 합계 비교 (직급순)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">날짜</span>
|
||||||
|
<div class="bg-slate-50 px-4 py-2 rounded-xl border border-slate-200 flex items-center gap-3">
|
||||||
|
<i data-lucide="calendar" size="16" class="text-slate-400"></i>
|
||||||
|
<input type="date" id="start-date" class="bg-transparent border-none text-sm outline-none font-bold text-slate-700">
|
||||||
|
<span class="text-slate-300 font-black">~</span>
|
||||||
|
<input type="date" id="end-date" class="bg-transparent border-none text-sm outline-none font-bold text-slate-700">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="location.reload()" class="mt-5 p-3 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-2xl transition-all border border-transparent hover:border-red-100">
|
||||||
|
<i data-lucide="refresh-cw" size="24"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10" id="kpi-container">
|
||||||
|
<!-- Cards will be injected here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Status Area (Matrix Updated with Visuals) -->
|
||||||
|
<div class="bg-white rounded-[3rem] shadow-2xl shadow-slate-200/50 border border-slate-100 overflow-hidden mb-10">
|
||||||
|
<div class="p-10 border-b bg-slate-50/50 flex flex-wrap items-center justify-between gap-6">
|
||||||
|
<h3 class="font-black text-3xl flex items-center gap-4 tracking-tighter text-slate-900">
|
||||||
|
<i data-lucide="file-text" size="36" class="text-emerald-500"></i> 팀별 현황
|
||||||
|
</h3>
|
||||||
|
<!-- 요약 통계를 헤더 내부로 이동하여 슬림하게 변경 -->
|
||||||
|
<div id="matrix-footer-visual" class="flex items-center gap-6">
|
||||||
|
<!-- Global statistics will be injected here as compact badges -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-10">
|
||||||
|
<div class="flex flex-col lg:flex-row gap-10">
|
||||||
|
<!-- Left: Business Unit Ratio (Pie Chart) -->
|
||||||
|
<div class="lg:w-1/3 bg-slate-50/50 rounded-[2rem] p-6 border border-slate-100 flex flex-col items-center">
|
||||||
|
<h4 class="text-sm font-black text-slate-400 uppercase tracking-widest mb-4">Business Unit Ratio</h4>
|
||||||
|
<div id="biz_pie_chart" class="w-full h-[350px]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Project List by Business Unit -->
|
||||||
|
<div class="lg:w-2/3">
|
||||||
|
<h4 class="text-sm font-black text-slate-400 uppercase tracking-widest mb-4 ml-2">Projects by Unit</h4>
|
||||||
|
<div id="matrix-visual-container" class="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
<!-- Project cards will be injected here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Area (Individual Status) -->
|
||||||
|
<div class="bg-white p-10 rounded-[3rem] shadow-2xl shadow-slate-200/50 border border-slate-100 mb-20">
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center justify-between mb-12 gap-6">
|
||||||
|
<div class="flex items-center gap-5">
|
||||||
|
<div class="p-4 bg-slate-50 rounded-[1.5rem] border border-slate-100">
|
||||||
|
<i id="chart-icon" data-lucide="bar-chart-2" class="text-blue-600" size="32"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 id="chart-title" class="text-3xl font-black text-slate-900 tracking-tighter">분석 데이터를 기다리는 중...</h3>
|
||||||
|
<p id="chart-desc" class="text-slate-400 font-bold mt-1">파일 업로드 시 상세 가동률이 표출됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="target-badge" class="hidden flex items-center gap-4 bg-red-50/50 p-5 rounded-[2rem] border border-red-100/50">
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-[10px] font-black text-red-400 uppercase tracking-widest block mb-1">Target Limit</span>
|
||||||
|
<div id="target-value" class="text-3xl font-black text-red-600 leading-none">0.00h</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="chart_div" class="w-full min-h-[500px]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- Constants & Config ---
|
||||||
|
const HOLIDAYS = ["2026-01-01", "2026-03-01", "2026-05-05", "2026-06-06", "2026-08-15", "2026-10-03", "2026-10-09", "2026-12-25"];
|
||||||
|
const RANK_WEIGHTS = { '수석': 1, '책임': 2, '선임': 3, '연구원': 4 };
|
||||||
|
let fullData = [];
|
||||||
|
|
||||||
|
// --- Utils ---
|
||||||
|
const formatNum = (v) => Number(v || 0).toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
const dStr = (v) => {
|
||||||
|
if (!v) return "";
|
||||||
|
let d = (typeof v === 'number') ? new Date(Math.round((v - 25569) * 86400 * 1000)) : new Date(v);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
return `${d.getFullYear()}-${("0" + (d.getMonth() + 1)).slice(-2)}-${("0" + d.getDate()).slice(-2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Initialize Google Charts ---
|
||||||
|
google.charts.load('current', {'packages':['corechart']});
|
||||||
|
|
||||||
|
// --- File Handling ---
|
||||||
|
document.getElementById('file-input').addEventListener('change', function(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(evt) {
|
||||||
|
const workbook = XLSX.read(evt.target.result, {type: 'binary', cellDates: true, dateNF: 'yyyy-mm-dd'});
|
||||||
|
fullData = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], {header: 1, defval: ""});
|
||||||
|
initDashboard();
|
||||||
|
};
|
||||||
|
reader.readAsBinaryString(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
function initDashboard() {
|
||||||
|
if (fullData.length < 2) return;
|
||||||
|
document.getElementById('upload-container').classList.add('hidden');
|
||||||
|
document.getElementById('dashboard-content').classList.remove('hidden');
|
||||||
|
|
||||||
|
const dates = fullData.slice(1).map(r => dStr(r[0])).filter(Boolean).sort();
|
||||||
|
document.getElementById('start-date').value = dates[0];
|
||||||
|
document.getElementById('end-date').value = dates[dates.length - 1];
|
||||||
|
|
||||||
|
const teams = [...new Set(fullData.slice(1).map(r => String(r[2] || '').trim()))].filter(Boolean).sort();
|
||||||
|
const teamSelect = document.getElementById('team-select');
|
||||||
|
teamSelect.innerHTML = teams.map(t => `<option value="${t}">${t}</option>`).join('');
|
||||||
|
|
||||||
|
updateFilters();
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilters() {
|
||||||
|
const team = document.getElementById('team-select').value;
|
||||||
|
document.getElementById('main-title').innerText = team;
|
||||||
|
|
||||||
|
const people = [...new Set(fullData.slice(1).filter(r => String(r[2] || '').trim() === team).map(r => String(r[4] || '').trim()))].filter(Boolean).sort();
|
||||||
|
|
||||||
|
const personSelect = document.getElementById('person-select');
|
||||||
|
const currentVal = personSelect.value;
|
||||||
|
personSelect.innerHTML = '<option value="">전체 합계 비교 (직급순)</option>' + people.map(p => `<option value="${p}">${p}</option>`).join('');
|
||||||
|
personSelect.value = people.includes(currentVal) ? currentVal : "";
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('team-select').addEventListener('change', updateFilters);
|
||||||
|
document.getElementById('person-select').addEventListener('change', render);
|
||||||
|
document.getElementById('start-date').addEventListener('change', render);
|
||||||
|
document.getElementById('end-date').addEventListener('change', render);
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const team = document.getElementById('team-select').value;
|
||||||
|
const person = document.getElementById('person-select').value;
|
||||||
|
const start = document.getElementById('start-date').value;
|
||||||
|
const end = document.getElementById('end-date').value;
|
||||||
|
|
||||||
|
let workDays = 0;
|
||||||
|
let curr = new Date(start);
|
||||||
|
const stop = new Date(end);
|
||||||
|
while (curr <= stop) {
|
||||||
|
const fmt = dStr(curr);
|
||||||
|
if (curr.getDay() !== 0 && curr.getDay() !== 6 && !HOLIDAYS.includes(fmt)) workDays++;
|
||||||
|
curr.setDate(curr.getDate() + 1);
|
||||||
|
}
|
||||||
|
const targetH = workDays * 8;
|
||||||
|
|
||||||
|
const results = processData(team, person, start, end, targetH);
|
||||||
|
|
||||||
|
renderKPIs(results.stats);
|
||||||
|
renderMatrix(results.matrix);
|
||||||
|
drawChart(results.chartData, targetH, person);
|
||||||
|
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function processData(teamF, personF, startStr, endStr, targetH) {
|
||||||
|
let pMap = {}; let mMap = {};
|
||||||
|
let stats = { totalMH: 0, personCount: 0, extraHolMH: 0, overCount: 0 };
|
||||||
|
const slots = [
|
||||||
|
{ biz: 8, proj: 10, time: 12, extra: false },
|
||||||
|
{ biz: 14, proj: 16, time: 18, extra: false },
|
||||||
|
{ biz: 19, proj: 21, time: 23, extra: false },
|
||||||
|
{ biz: 24, proj: 26, time: 28, extra: false },
|
||||||
|
{ biz: 29, proj: 31, time: 33, extra: false },
|
||||||
|
{ biz: 34, proj: 36, time: 38, extra: false },
|
||||||
|
{ biz: 39, proj: 41, time: 44, extra: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
fullData.slice(1).forEach(row => {
|
||||||
|
const d = dStr(row[0]);
|
||||||
|
if (!d || d < startStr || d > endStr || String(row[2] || '').trim() !== teamF) return;
|
||||||
|
|
||||||
|
const isWeekend = String(row[6] || '').includes("주말");
|
||||||
|
const name = String(row[4] || '').trim();
|
||||||
|
const rank = String(row[5] || '연구원').trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
if (!pMap[name]) pMap[name] = { name, rank, normal: 0, extra: 0, holiday: 0, total: 0, projects: {} };
|
||||||
|
|
||||||
|
slots.forEach(s => {
|
||||||
|
const val = parseFloat(String(row[s.time]).replace(/[^0-9.]/g, '')) || 0;
|
||||||
|
const pName = String(row[s.proj] || '').trim();
|
||||||
|
const biz = String(row[s.biz] || '공통').trim();
|
||||||
|
|
||||||
|
if (val > 0 && pName) {
|
||||||
|
stats.totalMH += val;
|
||||||
|
pMap[name].total += val;
|
||||||
|
|
||||||
|
let type = 'normal';
|
||||||
|
if (isWeekend) { type = 'holiday'; pMap[name].holiday += val; stats.extraHolMH += val; }
|
||||||
|
else if (s.extra) { type = 'extra'; pMap[name].extra += val; stats.extraHolMH += val; }
|
||||||
|
else { pMap[name].normal += val; }
|
||||||
|
|
||||||
|
if (!pMap[name].projects[pName]) pMap[name].projects[pName] = { normal: 0, extra: 0, holiday: 0, total: 0 };
|
||||||
|
pMap[name].projects[pName].total += val;
|
||||||
|
pMap[name].projects[pName][type] += val;
|
||||||
|
|
||||||
|
if (!mMap[biz]) mMap[biz] = {};
|
||||||
|
if (!mMap[biz][pName]) {
|
||||||
|
mMap[biz][pName] = {
|
||||||
|
total: 0,
|
||||||
|
ps: new Set(),
|
||||||
|
rankDetails: {
|
||||||
|
"수석": { mh: 0, ps: new Set() },
|
||||||
|
"책임": { mh: 0, ps: new Set() },
|
||||||
|
"선임": { mh: 0, ps: new Set() },
|
||||||
|
"연구원": { mh: 0, ps: new Set() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
mMap[biz][pName].total += val;
|
||||||
|
mMap[biz][pName].ps.add(name);
|
||||||
|
|
||||||
|
const rKey = rank.includes("수석") ? "수석" : rank.includes("책임") ? "책임" : rank.includes("선임") ? "선임" : "연구원";
|
||||||
|
mMap[biz][pName].rankDetails[rKey].mh += val;
|
||||||
|
mMap[biz][pName].rankDetails[rKey].ps.add(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamList = Object.values(pMap).sort((a, b) => {
|
||||||
|
const wa = RANK_WEIGHTS[a.rank.includes('수석') ? '수석' : a.rank.includes('책임') ? '책임' : a.rank.includes('선임') ? '선임' : '연구원'] || 99;
|
||||||
|
const wb = RANK_WEIGHTS[b.rank.includes('수석') ? '수석' : b.rank.includes('책임') ? '책임' : b.rank.includes('선임') ? '선임' : '연구원'] || 99;
|
||||||
|
return (wa !== wb) ? wa - wb : a.name.localeCompare(b.name, 'ko');
|
||||||
|
});
|
||||||
|
|
||||||
|
stats.personCount = teamList.length;
|
||||||
|
teamList.forEach(p => { if (p.total > targetH) stats.overCount++; });
|
||||||
|
|
||||||
|
let chartData = [];
|
||||||
|
if (!personF) {
|
||||||
|
chartData = teamList.map(p => ({ label: p.name, sub: p.rank, normal: p.normal, extra: p.extra, holiday: p.holiday, total: p.total }));
|
||||||
|
} else if (pMap[personF]) {
|
||||||
|
const sel = pMap[personF];
|
||||||
|
chartData = Object.entries(sel.projects).map(([pn, d]) => ({ label: pn, sub: '프로젝트', normal: d.normal, extra: d.extra, holiday: d.holiday, total: d.total })).sort((a,b) => b.total - a.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stats, matrix: mMap, chartData };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKPIs(s) {
|
||||||
|
const container = document.getElementById('kpi-container');
|
||||||
|
const cards = [
|
||||||
|
{ title: "총 투입 시간", val: formatNum(s.totalMH) + 'h', icon: "clock", color: "blue", sub: "Team Total" },
|
||||||
|
{ title: "참여 실무자", val: s.personCount + '명', icon: "users", color: "emerald", sub: "Active Members" },
|
||||||
|
{ title: "총 연장/휴일", val: formatNum(s.extraHolMH) + 'h', icon: "trending-up", color: "amber", sub: "Overtime & Weekend" },
|
||||||
|
{ title: "과부하 위험", val: s.overCount + '명', icon: "alert-circle", color: "red", sub: "Overlimit", isAlert: s.overCount > 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = cards.map(c => `
|
||||||
|
<div class="bg-white p-7 rounded-[2rem] shadow-xl border-l-[12px] border-${c.color}-500 transition-all hover:-translate-y-1 ${c.isAlert ? 'ring-4 ring-red-500/10' : ''}">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<div class="p-3 bg-${c.color}-50 rounded-2xl text-${c.color}-600 border border-${c.color}-100 shadow-sm"><i data-lucide="${c.icon}"></i></div>
|
||||||
|
<span class="text-[10px] font-black text-slate-300 uppercase tracking-widest">${c.sub}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-black text-slate-800 mb-2 tracking-tighter leading-none">${c.val}</div>
|
||||||
|
<div class="text-[11px] font-black text-slate-400 uppercase tracking-[0.2em]">${c.title}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMatrix(m) {
|
||||||
|
const visualContainer = document.getElementById('matrix-visual-container');
|
||||||
|
const footerVisual = document.getElementById('matrix-footer-visual');
|
||||||
|
|
||||||
|
let bizData = [];
|
||||||
|
let cardsHtml = "";
|
||||||
|
let grandTotal = 0;
|
||||||
|
let grandPeople = new Set();
|
||||||
|
|
||||||
|
Object.entries(m).forEach(([biz, projs]) => {
|
||||||
|
let bizTotal = 0;
|
||||||
|
let bizProjs = [];
|
||||||
|
Object.entries(projs).forEach(([projName, d]) => {
|
||||||
|
bizTotal += d.total;
|
||||||
|
grandTotal += d.total;
|
||||||
|
d.ps.forEach(p => grandPeople.add(p));
|
||||||
|
bizProjs.push({ name: projName, total: d.total, peopleCount: d.ps.size, rankDetails: d.rankDetails });
|
||||||
|
});
|
||||||
|
|
||||||
|
bizData.push([biz, bizTotal]);
|
||||||
|
|
||||||
|
cardsHtml += `
|
||||||
|
<div class="bg-white border border-slate-100 rounded-[1.5rem] p-5 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h5 class="font-black text-slate-900 text-lg tracking-tight truncate mr-2">${biz}</h5>
|
||||||
|
<span class="bg-blue-50 text-blue-600 px-3 py-1 rounded-full text-[11px] font-black">${formatNum(bizTotal)}h</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
${bizProjs.map(p => `
|
||||||
|
<div class="border-b border-slate-50 last:border-0 pb-3 last:pb-0 group">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-slate-700 font-bold truncate w-2/3 group-hover:text-blue-500 transition-colors">${p.name}</span>
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<span class="text-slate-900 font-black">${formatNum(p.total)}h</span>
|
||||||
|
<span class="text-[10px] text-slate-400 font-bold">(${p.peopleCount}명)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-1 mt-2">
|
||||||
|
${Object.entries(p.rankDetails).filter(([r, info]) => info.mh > 0).map(([rank, info]) => `
|
||||||
|
<div class="flex items-center justify-between text-[10px] bg-slate-50 px-2 py-0.5 rounded">
|
||||||
|
<span class="text-slate-400 font-bold">${rank}</span>
|
||||||
|
<span class="text-slate-600 font-black">${formatNum(info.mh)}h <small class="text-slate-400">(${info.ps.size})</small></span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
visualContainer.innerHTML = cardsHtml;
|
||||||
|
// 푸터 디자인을 컴팩트한 상단 요약 배지 형태로 변경
|
||||||
|
footerVisual.innerHTML = `
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Total Volume</span>
|
||||||
|
<div class="text-xl font-black text-blue-600 leading-none">${formatNum(grandTotal)}h</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-8 bg-slate-200"></div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Involved</span>
|
||||||
|
<div class="text-xl font-black text-slate-900 leading-none">${grandPeople.size}명</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dt = new google.visualization.DataTable();
|
||||||
|
dt.addColumn('string', 'Business Unit');
|
||||||
|
dt.addColumn('number', 'Total MH');
|
||||||
|
dt.addRows(bizData);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
pieHole: 0.5,
|
||||||
|
colors: ['#3b82f6', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6', '#64748b'],
|
||||||
|
legend: { position: 'bottom', textStyle: { fontSize: 11, fontName: 'Noto Sans KR', bold: true, color: '#64748b' } },
|
||||||
|
chartArea: { width: '90%', height: '80%' },
|
||||||
|
pieSliceText: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
tooltip: { isHtml: true, textStyle: { fontName: 'Noto Sans KR' } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const chart = new google.visualization.PieChart(document.getElementById('biz_pie_chart'));
|
||||||
|
chart.draw(dt, options);
|
||||||
|
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChart(data, targetH, personF) {
|
||||||
|
const container = document.getElementById('chart_div');
|
||||||
|
const titleEl = document.getElementById('chart-title');
|
||||||
|
const descEl = document.getElementById('chart-desc');
|
||||||
|
const badgeEl = document.getElementById('target-badge');
|
||||||
|
const valueEl = document.getElementById('target-value');
|
||||||
|
const iconEl = document.getElementById('chart-icon');
|
||||||
|
|
||||||
|
let displayData = [...data];
|
||||||
|
|
||||||
|
if (personF) {
|
||||||
|
titleEl.innerText = `${personF} 상세 투입 현황`;
|
||||||
|
descEl.innerText = ""; // 문구 제거
|
||||||
|
badgeEl.classList.add('hidden');
|
||||||
|
iconEl.setAttribute('data-lucide', 'user');
|
||||||
|
|
||||||
|
// 전체 합계 데이터 추가
|
||||||
|
if (displayData.length > 0) {
|
||||||
|
const sum = displayData.reduce((acc, curr) => ({
|
||||||
|
normal: acc.normal + curr.normal,
|
||||||
|
extra: acc.extra + curr.extra,
|
||||||
|
holiday: acc.holiday + curr.holiday,
|
||||||
|
total: acc.total + curr.total
|
||||||
|
}), { normal: 0, extra: 0, holiday: 0, total: 0 });
|
||||||
|
displayData.push({ label: '전체 합계 (Total)', sub: '종합', ...sum });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
titleEl.innerText = `개인별 현황`;
|
||||||
|
descEl.innerText = `(평일 8시간 근무 기준)`;
|
||||||
|
badgeEl.classList.remove('hidden');
|
||||||
|
valueEl.innerText = formatNum(targetH) + 'h';
|
||||||
|
iconEl.setAttribute('data-lucide', 'bar-chart-2');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dt = new google.visualization.DataTable();
|
||||||
|
dt.addColumn('string', '항목');
|
||||||
|
dt.addColumn('number', '정규');
|
||||||
|
dt.addColumn({type: 'string', role: 'tooltip', p:{html:true}});
|
||||||
|
dt.addColumn('number', '연장');
|
||||||
|
dt.addColumn({type: 'string', role: 'tooltip', p:{html:true}});
|
||||||
|
dt.addColumn('number', '휴일');
|
||||||
|
dt.addColumn({type: 'string', role: 'tooltip', p:{html:true}});
|
||||||
|
dt.addColumn('number', '전체');
|
||||||
|
dt.addColumn({type: 'string', role: 'annotation'});
|
||||||
|
|
||||||
|
displayData.forEach(d => {
|
||||||
|
const tooltipHtml = `
|
||||||
|
<div style="font-family:'Noto Sans KR'; padding:12px; min-width:180px;">
|
||||||
|
<div style="border-bottom:1px solid #eee; padding-bottom:8px; margin-bottom:8px;">
|
||||||
|
<b style="font-size:16px; color:#1e293b;">${d.label}</b>
|
||||||
|
<span style="font-size:11px; color:#94a3b8; margin-left:5px;">${d.sub}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:5px;">
|
||||||
|
<span style="color:#64748b;">정규 근무:</span> <b style="color:#1e293b;">${formatNum(d.normal)}h</b>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:5px;">
|
||||||
|
<span style="color:#f59e0b;">연장 근무:</span> <b style="color:#d97706;">${formatNum(d.extra)}h</b>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||||||
|
<span style="color:#ef4444;">휴일 근무:</span> <b style="color:#dc2626;">${formatNum(d.holiday)}h</b>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #eee; padding-top:8px; display:flex; justify-content:space-between; font-weight:900; font-size:18px; color:#2563eb;">
|
||||||
|
<span>합계:</span> <span>${formatNum(d.total)}h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
dt.addRow([
|
||||||
|
d.label,
|
||||||
|
d.normal, tooltipHtml,
|
||||||
|
d.extra, tooltipHtml,
|
||||||
|
d.holiday, tooltipHtml,
|
||||||
|
0.0001, (d.total > targetH && !personF ? "🚨 " : "") + formatNum(d.total) + "h"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartHeight = Math.max(500, displayData.length * 55);
|
||||||
|
container.style.height = chartHeight + 'px';
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
isStacked: true,
|
||||||
|
tooltip: { isHtml: true },
|
||||||
|
colors: ['#3b82f6', '#f59e0b', '#f43f5e', 'transparent'],
|
||||||
|
chartArea: { width: '70%', height: '90%', left: 200 },
|
||||||
|
hAxis: { gridlines: { color: '#f1f5f9' }, viewWindow: { min: 0 }, format: '#,##0.00' },
|
||||||
|
vAxis: { textStyle: { fontSize: 13, bold: true, color: '#475569' } },
|
||||||
|
annotations: { alwaysOutside: true, textStyle: { fontSize: 13, bold: true, color: '#1e293b' } },
|
||||||
|
legend: { position: 'top', alignment: 'center' },
|
||||||
|
series: { 3: { visibleInLegend: false, tooltip: false } },
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
};
|
||||||
|
|
||||||
|
const chart = new google.visualization.BarChart(container);
|
||||||
|
|
||||||
|
if (!personF) {
|
||||||
|
google.visualization.events.addListener(chart, 'ready', () => {
|
||||||
|
const cli = chart.getChartLayoutInterface();
|
||||||
|
const xPos = cli.getXLocation(targetH);
|
||||||
|
const svg = container.querySelector('svg');
|
||||||
|
if(svg && !isNaN(xPos)) {
|
||||||
|
const area = cli.getChartAreaBoundingBox();
|
||||||
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||||
|
line.setAttribute('x1', xPos); line.setAttribute('y1', area.top);
|
||||||
|
line.setAttribute('x2', xPos); line.setAttribute('y2', area.top + area.height);
|
||||||
|
line.setAttribute('stroke', '#ef4444'); line.setAttribute('stroke-width', '3'); line.setAttribute('stroke-dasharray', '8,8');
|
||||||
|
svg.appendChild(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.draw(dt, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = function() {
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user