Upload mh.html with fixed encoding
This commit is contained in:
642
mh.html
Normal file
642
mh.html
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DfMA 분석 대시보드 (MH 관리 시스템)</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
|
||||||
|
<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; }
|
||||||
|
|
||||||
|
#search-dropdown {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="p-4 md:p-8">
|
||||||
|
|
||||||
|
<input id="file-input" type="file" class="hidden" accept=".xlsx, .xls">
|
||||||
|
|
||||||
|
<!-- Project Members Modal -->
|
||||||
|
<div id="project-members-modal" class="hidden fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
|
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onclick="closeProjectModal()"></div>
|
||||||
|
<div class="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-lg overflow-hidden relative z-10 animate-in zoom-in duration-300">
|
||||||
|
<div class="p-8 border-b bg-slate-50/50 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 id="modal-project-title" class="text-xl font-black text-slate-900 leading-tight">프로젝트 참여 명단</h3>
|
||||||
|
<p id="modal-project-count" class="text-sm text-slate-400 font-bold mt-1">총 0명의 참여 인원</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeProjectModal()" class="p-2 hover:bg-slate-200 rounded-full transition-colors text-slate-400 hover:text-slate-600">
|
||||||
|
<i data-lucide="x" size="24"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="modal-members-list" class="p-8 max-h-[60vh] overflow-y-auto custom-scrollbar flex flex-col gap-2">
|
||||||
|
</div>
|
||||||
|
<div class="p-6 bg-slate-50/50 border-t flex justify-end">
|
||||||
|
<button onclick="closeProjectModal()" class="px-6 py-2.5 bg-slate-900 text-white rounded-xl font-black text-sm hover:bg-slate-800 transition-all">닫기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-[1600px] mx-auto animate-in fade-in duration-700">
|
||||||
|
<!-- Header & Controls -->
|
||||||
|
<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">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="flex flex-col gap-1 relative">
|
||||||
|
<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="search" size="16" class="text-blue-500"></i>
|
||||||
|
<input type="text" id="main-search" autocomplete="off" placeholder="팀 또는 이름 검색" class="bg-transparent border-none text-sm font-bold outline-none text-slate-700 w-[160px]">
|
||||||
|
</div>
|
||||||
|
<div id="search-dropdown" class="hidden absolute top-full left-0 w-full mt-2 bg-white border border-slate-200 rounded-2xl shadow-2xl overflow-hidden custom-scrollbar">
|
||||||
|
<div id="search-results-list" class="py-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Select -->
|
||||||
|
<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">
|
||||||
|
<option value="">팀 선택</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Person Select -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Date Range -->
|
||||||
|
<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="document.getElementById('file-input').click()" class="mt-5 flex items-center gap-2 bg-blue-600 text-white px-6 py-3 rounded-xl font-black shadow-lg hover:bg-blue-700 transition-all active:scale-95">
|
||||||
|
<i data-lucide="upload-cloud" size="18"></i>
|
||||||
|
<span>데이터 업로드</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div class="bg-white p-7 rounded-[2rem] shadow-xl border border-slate-200 animate-pulse h-32"></div>
|
||||||
|
<div class="bg-white p-7 rounded-[2rem] shadow-xl border border-slate-200 animate-pulse h-32"></div>
|
||||||
|
<div class="bg-white p-7 rounded-[2rem] shadow-xl border border-slate-200 animate-pulse h-32"></div>
|
||||||
|
<div class="bg-white p-7 rounded-[2rem] shadow-xl border border-slate-200 animate-pulse h-32"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team & Project Status -->
|
||||||
|
<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-4"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-10">
|
||||||
|
<div class="flex flex-col lg:flex-row gap-10">
|
||||||
|
<div class="lg:w-1/3 bg-slate-50/50 rounded-[2rem] p-6 border border-slate-100 flex flex-col items-center self-start">
|
||||||
|
<h4 class="text-sm font-black text-slate-400 uppercase tracking-widest mb-4">비즈니스 유닛별 비율</h4>
|
||||||
|
<div id="biz_pie_chart" class="w-full h-[350px] flex items-center justify-center text-slate-300 font-bold italic">데이터 업로드 필요</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:w-2/3">
|
||||||
|
<h4 class="text-sm font-black text-slate-400 uppercase tracking-widest mb-4 ml-2">유닛별 프로젝트 상세</h4>
|
||||||
|
<div id="matrix-visual-container" class="columns-1 md:columns-2 gap-4 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
<div class="text-slate-300 font-bold italic p-10 text-center w-full">데이터를 업로드하면 프로젝트 상세 정보가 표시됩니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Analysis Chart -->
|
||||||
|
<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] flex items-center justify-center text-slate-200">
|
||||||
|
<i data-lucide="bar-chart" size="64"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- Configuration ---
|
||||||
|
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 teamData = [];
|
||||||
|
let allTeams = [];
|
||||||
|
let allPeopleData = [];
|
||||||
|
|
||||||
|
// --- 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)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
google.charts.load('current', {'packages':['corechart']});
|
||||||
|
|
||||||
|
// --- Popup Functions ---
|
||||||
|
function showProjectMembers(pName) {
|
||||||
|
const modal = document.getElementById('project-members-modal');
|
||||||
|
const titleEl = document.getElementById('modal-project-title');
|
||||||
|
const countEl = document.getElementById('modal-project-count');
|
||||||
|
const listEl = document.getElementById('modal-members-list');
|
||||||
|
|
||||||
|
const startStr = document.getElementById('start-date').value;
|
||||||
|
const endStr = document.getElementById('end-date').value;
|
||||||
|
|
||||||
|
let memberMap = {};
|
||||||
|
const slots = [10, 16, 21, 26, 31, 36, 41];
|
||||||
|
const timeSlots = [12, 18, 23, 28, 33, 38, 44];
|
||||||
|
|
||||||
|
teamData.slice(1).forEach(row => {
|
||||||
|
const d = dStr(row[0]);
|
||||||
|
if (!d || d < startStr || d > endStr) return;
|
||||||
|
const team = String(row[2] || '').trim();
|
||||||
|
const name = String(row[4] || '').trim();
|
||||||
|
const rank = String(row[5] || '연구원').trim();
|
||||||
|
|
||||||
|
slots.forEach((sIdx, i) => {
|
||||||
|
const rowPName = String(row[sIdx] || '').trim();
|
||||||
|
const val = parseFloat(String(row[timeSlots[i]]).replace(/[^0-9.]/g, '')) || 0;
|
||||||
|
if (rowPName === pName && val > 0) {
|
||||||
|
const key = `${name}|${team}|${rank}`;
|
||||||
|
memberMap[key] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = Object.keys(memberMap).map(k => {
|
||||||
|
const [n, t, r] = k.split('|');
|
||||||
|
return { name: n, team: t, rank: r };
|
||||||
|
}).sort((a,b) => a.name.localeCompare(b.name, 'ko'));
|
||||||
|
|
||||||
|
titleEl.innerText = pName;
|
||||||
|
countEl.innerText = `총 ${members.length}명의 참여 인원 (중복 제거)`;
|
||||||
|
|
||||||
|
listEl.innerHTML = members.map(m => `
|
||||||
|
<div class="flex items-center justify-between p-4 bg-slate-50/50 border border-slate-100 rounded-2xl group hover:bg-blue-50 transition-all">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="text-base font-black text-slate-800">${m.name}</span>
|
||||||
|
<span class="text-[11px] font-black text-blue-600 bg-blue-50 px-2 py-0.5 rounded-lg border border-blue-100">${m.rank}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-slate-400 font-bold mt-1">
|
||||||
|
${m.team}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="goToMemberAnalysis('${m.team}', '${m.name}')" class="p-2.5 bg-white rounded-full shadow-sm border border-slate-100 hover:border-blue-300 hover:scale-110 transition-all text-slate-300 hover:text-blue-500">
|
||||||
|
<i data-lucide="chevron-right" size="18"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProjectModal() {
|
||||||
|
document.getElementById('project-members-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToMemberAnalysis(team, name) {
|
||||||
|
closeProjectModal();
|
||||||
|
const teamSelect = document.getElementById('team-select');
|
||||||
|
const personSelect = document.getElementById('person-select');
|
||||||
|
teamSelect.value = team;
|
||||||
|
updateFilters();
|
||||||
|
personSelect.value = name;
|
||||||
|
render();
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
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'});
|
||||||
|
teamData = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], {header: 1, defval: ""});
|
||||||
|
initTeamTabAfterUpload();
|
||||||
|
};
|
||||||
|
reader.readAsBinaryString(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
function initTeamTabAfterUpload() {
|
||||||
|
if (teamData.length < 2) return;
|
||||||
|
const dates = teamData.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];
|
||||||
|
|
||||||
|
allTeams = [...new Set(teamData.slice(1).map(r => String(r[2] || '').trim()))].filter(Boolean).sort();
|
||||||
|
allPeopleData = teamData.slice(1).map(r => ({
|
||||||
|
name: String(r[4] || '').trim(),
|
||||||
|
team: String(r[2] || '').trim()
|
||||||
|
})).filter(p => p.name && p.team);
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
allPeopleData = allPeopleData.filter(p => {
|
||||||
|
const key = `${p.name}-${p.team}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamSelect = document.getElementById('team-select');
|
||||||
|
teamSelect.innerHTML = allTeams.map(t => `<option value="${t}">${t}</option>`).join('');
|
||||||
|
updateFilters();
|
||||||
|
render();
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dashboard Search & Filters ---
|
||||||
|
const mainSearchInput = document.getElementById('main-search');
|
||||||
|
const searchDropdown = document.getElementById('search-dropdown');
|
||||||
|
const searchResultsList = document.getElementById('search-results-list');
|
||||||
|
|
||||||
|
mainSearchInput.addEventListener('input', function(e) {
|
||||||
|
const val = e.target.value.trim().toLowerCase();
|
||||||
|
if (!val || teamData.length === 0) {
|
||||||
|
searchDropdown.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matchedTeams = allTeams.filter(t => t.toLowerCase().includes(val));
|
||||||
|
const matchedPeople = allPeopleData.filter(p => p.name.toLowerCase().includes(val));
|
||||||
|
|
||||||
|
if (matchedTeams.length === 0 && matchedPeople.length === 0) {
|
||||||
|
searchResultsList.innerHTML = `<div class="px-4 py-3 text-slate-400 text-sm italic text-center">검색 결과가 없습니다.</div>`;
|
||||||
|
} else {
|
||||||
|
let html = "";
|
||||||
|
if (matchedTeams.length > 0) {
|
||||||
|
html += `<div class="px-4 py-1 text-[10px] font-black text-blue-500 uppercase tracking-widest bg-blue-50/50">Teams</div>`;
|
||||||
|
matchedTeams.forEach(t => {
|
||||||
|
html += `<div class="search-item px-4 py-2 hover:bg-slate-50 cursor-pointer text-sm font-bold text-slate-700" data-type="team" data-value="${t}">${t}</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (matchedPeople.length > 0) {
|
||||||
|
html += `<div class="px-4 py-1 text-[10px] font-black text-emerald-500 uppercase tracking-widest bg-emerald-50/50 mt-1">People</div>`;
|
||||||
|
matchedPeople.forEach(p => {
|
||||||
|
html += `<div class="search-item px-4 py-2 hover:bg-slate-50 cursor-pointer text-sm flex items-center justify-between" data-type="person" data-name="${p.name}" data-team="${p.team}">
|
||||||
|
<span class="font-bold text-slate-700">${p.name}</span>
|
||||||
|
<span class="text-[10px] text-slate-400 font-medium">${p.team}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
searchResultsList.innerHTML = html;
|
||||||
|
}
|
||||||
|
searchDropdown.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
searchResultsList.addEventListener('click', function(e) {
|
||||||
|
const item = e.target.closest('.search-item');
|
||||||
|
if (!item) return;
|
||||||
|
const type = item.dataset.type;
|
||||||
|
const teamSelect = document.getElementById('team-select');
|
||||||
|
const personSelect = document.getElementById('person-select');
|
||||||
|
|
||||||
|
if (type === 'team') {
|
||||||
|
teamSelect.value = item.dataset.value;
|
||||||
|
updateFilters();
|
||||||
|
personSelect.value = "";
|
||||||
|
} else {
|
||||||
|
teamSelect.value = item.dataset.team;
|
||||||
|
updateFilters();
|
||||||
|
personSelect.value = item.dataset.name;
|
||||||
|
}
|
||||||
|
mainSearchInput.value = "";
|
||||||
|
searchDropdown.classList.add('hidden');
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!mainSearchInput.contains(e.target) && !searchDropdown.contains(e.target)) {
|
||||||
|
searchDropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateFilters() {
|
||||||
|
if (teamData.length === 0) return;
|
||||||
|
const team = document.getElementById('team-select').value;
|
||||||
|
if (!team) return;
|
||||||
|
document.getElementById('main-title').innerText = team;
|
||||||
|
const teamPeople = [...new Set(teamData.slice(1).filter(r => String(r[2] || '').trim() === team).map(r => String(r[4] || '').trim()))].sort();
|
||||||
|
const personSelect = document.getElementById('person-select');
|
||||||
|
personSelect.innerHTML = '<option value="">전체 합계 비교 (직급순)</option>' + teamPeople.map(p => `<option value="${p}">${p}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('team-select').addEventListener('change', () => { updateFilters(); render(); });
|
||||||
|
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;
|
||||||
|
if (!team) return;
|
||||||
|
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 r = processData(team, person, start, end, targetH);
|
||||||
|
renderKPIs(r.stats);
|
||||||
|
renderMatrix(r.matrix);
|
||||||
|
drawChart(r.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 }
|
||||||
|
];
|
||||||
|
|
||||||
|
teamData.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]) chartData = Object.entries(pMap[personF].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: "text-blue-600", bg: "bg-blue-50", iconBorder: "border-blue-100", ring: "ring-blue-500/10", sub: "Team Total" },
|
||||||
|
{ title: "참여 실무자", val: s.personCount + '명', icon: "users", color: "text-emerald-600", bg: "bg-emerald-50", iconBorder: "border-emerald-100", ring: "ring-emerald-500/10", sub: "Active Members" },
|
||||||
|
{ title: "총 연장/휴일", val: formatNum(s.extraHolMH) + 'h', icon: "trending-up", color: "text-amber-600", bg: "bg-amber-50", iconBorder: "border-amber-100", ring: "ring-amber-500/10", sub: "Overtime & Weekend" },
|
||||||
|
{ title: "과부하 위험", val: s.overCount + '명', icon: "alert-circle", color: "text-red-600", bg: "bg-red-50", iconBorder: "border-red-100", ring: "ring-red-500/10", sub: "Overlimit" }
|
||||||
|
];
|
||||||
|
container.innerHTML = cards.map(c => `
|
||||||
|
<div class="bg-white p-7 rounded-[2rem] shadow-xl border border-slate-200 transition-all hover:-translate-y-1 ring-4 ${c.ring}">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<div class="p-3 ${c.bg} ${c.color} rounded-2xl border ${c.iconBorder} 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();
|
||||||
|
let totalProjectsCount = 0;
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
totalProjectsCount++;
|
||||||
|
});
|
||||||
|
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 break-inside-avoid-column mb-4">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h5 class="font-black text-slate-900 text-lg tracking-tight mr-2">${biz}</h5>
|
||||||
|
<span class="bg-blue-50 text-blue-600 px-3 py-1 rounded-full text-[11px] font-black shrink-0">${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 break-inside-avoid">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span onclick="showProjectMembers('${p.name.replace(/'/g, "\\'")}')" class="text-slate-700 font-bold hover:text-blue-600 hover:underline cursor-pointer transition-colors flex-1 pr-2">${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="space-y-2 mt-2">
|
||||||
|
${Object.entries(p.rankDetails).filter(([r, info]) => info.mh > 0).map(([rank, info]) => `
|
||||||
|
<div class="text-[10px] bg-slate-50/80 px-2.5 py-2 rounded-xl border border-slate-100">
|
||||||
|
<div class="flex justify-between items-center mb-1">
|
||||||
|
<span class="text-slate-500 font-black">${rank}</span>
|
||||||
|
<span class="text-slate-900 font-black">${formatNum(info.mh)}h <small class="text-slate-400">(${info.ps.size}명)</small></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-slate-400 font-bold leading-relaxed">
|
||||||
|
${Array.from(info.ps).join(', ')}
|
||||||
|
</div>
|
||||||
|
</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">수수행 프로젝트</span>
|
||||||
|
<div class="text-xl font-black text-blue-600 leading-none">${totalProjectsCount}개</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dt = new google.visualization.DataTable(); dt.addColumn('string', 'Business Unit'); dt.addColumn('number', 'Total MH'); dt.addRows(bizData);
|
||||||
|
const chart = new google.visualization.PieChart(document.getElementById('biz_pie_chart'));
|
||||||
|
chart.draw(dt, { 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 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChart(data, targetH, personF) {
|
||||||
|
const container = document.getElementById('chart_div');
|
||||||
|
let displayData = [...data];
|
||||||
|
|
||||||
|
const maxTotal = displayData.length > 0 ? Math.max(...displayData.map(d => d.total)) : 0;
|
||||||
|
const hAxisMax = Math.max(maxTotal, targetH) * 1.2;
|
||||||
|
|
||||||
|
if (personF && 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 });
|
||||||
|
document.getElementById('chart-title').innerText = `${personF} 상세 투입 현황`;
|
||||||
|
document.getElementById('target-badge').classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
document.getElementById('chart-title').innerText = `개인별 현황`;
|
||||||
|
document.getElementById('target-badge').classList.remove('hidden');
|
||||||
|
document.getElementById('target-value').innerText = formatNum(targetH) + 'h';
|
||||||
|
}
|
||||||
|
|
||||||
|
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></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>`;
|
||||||
|
const annotationText = (d.total > targetH && !personF ? "⚠️ " : "") + formatNum(d.total) + "h";
|
||||||
|
dt.addRow([d.label, d.normal, tooltipHtml, d.extra, tooltipHtml, d.holiday, tooltipHtml, 0, annotationText]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
isStacked: true,
|
||||||
|
tooltip: { isHtml: true },
|
||||||
|
colors: ['#166534', '#f59e0b', '#f43f5e', 'transparent'],
|
||||||
|
chartArea: { width: '70%', height: '90%', left: 200 },
|
||||||
|
hAxis: { gridlines: { color: '#f1f5f9' }, viewWindow: { min: 0, max: hAxisMax } },
|
||||||
|
vAxis: { textStyle: { fontSize: 13, bold: true, color: '#475569' } },
|
||||||
|
annotations: { alwaysOutside: true, textStyle: { fontSize: 13, bold: true, color: '#1e293b' }, stem: { color: 'none' } },
|
||||||
|
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