feat: add all-team filter to mh analysis

This commit is contained in:
hyunho
2026-03-30 10:22:12 +09:00
parent 8d0cc78abc
commit c9a93ea936
3 changed files with 110 additions and 72 deletions

View File

@@ -1481,7 +1481,8 @@
const RANK_WEIGHTS = { '수석': 1, '책임': 2, '선임': 3, '연구원': 4 }; const RANK_WEIGHTS = { '수석': 1, '책임': 2, '선임': 3, '연구원': 4 };
let teamData = []; let teamData = [];
const ALL_TEAM_VALUE = '__ALL__';
let allTeams = []; let allTeams = [];
let allPeopleData = []; let allPeopleData = [];
let searchTeams = []; let searchTeams = [];
@@ -1637,16 +1638,20 @@
const buildScopedPeopleData = (rows) => { const buildScopedPeopleData = (rows) => {
const seen = new Set(); const seen = new Set();
return rows.map(r => ({ return rows.map(r => ({
name: String(r[columnMap.name] || '').trim(), name: String(r[columnMap.name] || '').trim(),
team: String(r[columnMap.team] || '').trim() team: String(r[columnMap.team] || '').trim()
})).filter(p => { })).filter(p => {
if (!p.name || !p.team) return false; if (!p.name || !p.team) return false;
const key = `${p.name}-${p.team}`; const key = `${p.name}-${p.team}`;
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
return true; return true;
}); }).map(p => ({
}; ...p,
value: `${p.name}|${p.team}`,
label: `${p.name} (${p.team})`
}));
};
const findBizIndexByProject = (normalizedHeaders, projIdx) => { const findBizIndexByProject = (normalizedHeaders, projIdx) => {
const bizKey = normalizeHeader('사업 종류'); const bizKey = normalizeHeader('사업 종류');
for (let i = projIdx - 1; i >= 0; i--) { for (let i = projIdx - 1; i >= 0; i--) {
@@ -2465,23 +2470,23 @@
searchTeams = [...new Set(allRows.map(r => String(r[columnMap.team] || '').trim()))].filter(Boolean).sort(); searchTeams = [...new Set(allRows.map(r => String(r[columnMap.team] || '').trim()))].filter(Boolean).sort();
searchPeopleData = buildScopedPeopleData(allRows); searchPeopleData = buildScopedPeopleData(allRows);
teamSelect.innerHTML = '<option value="">팀 선택</option>' + allTeams.map(t => `<option value="${t}">${t}</option>`).join(''); teamSelect.innerHTML = `<option value="${ALL_TEAM_VALUE}">전체</option>` + allTeams.map(t => `<option value="${t}">${t}</option>`).join('');
if (currentTeam && allTeams.includes(currentTeam)) { if (currentTeam === ALL_TEAM_VALUE) {
teamSelect.value = currentTeam; teamSelect.value = ALL_TEAM_VALUE;
} else if (allTeams.length > 0) { } else if (currentTeam && allTeams.includes(currentTeam)) {
teamSelect.value = allTeams[0]; teamSelect.value = currentTeam;
} else { } else {
teamSelect.value = ''; teamSelect.value = ALL_TEAM_VALUE;
} }
updateFilters(); updateFilters();
if (teamSelect.value && currentPerson && allPeopleData.some(p => p.team === teamSelect.value && p.name === currentPerson)) { if (currentPerson && [...personSelect.options].some(option => option.value === currentPerson)) {
personSelect.value = currentPerson; personSelect.value = currentPerson;
} else { } else {
personSelect.value = ''; personSelect.value = '';
} }
} }
function setScope(scope, preserveSelection = true) { function setScope(scope, preserveSelection = true) {
@@ -2621,7 +2626,10 @@
setScope(getTeamScope(item.dataset.team), false); setScope(getTeamScope(item.dataset.team), false);
teamSelect.value = item.dataset.team; teamSelect.value = item.dataset.team;
updateFilters(); updateFilters();
personSelect.value = item.dataset.name; const targetValue = `${item.dataset.name}|${item.dataset.team}`;
personSelect.value = [...personSelect.options].some(option => option.value === targetValue)
? targetValue
: item.dataset.name;
} }
mainSearchInput.value = ""; mainSearchInput.value = "";
@@ -2646,19 +2654,25 @@
function updateFilters() { function updateFilters() {
if (teamData.length === 0) return; if (teamData.length === 0) return;
const team = document.getElementById('team-select').value; const team = document.getElementById('team-select').value;
const personSelect = document.getElementById('person-select'); const personSelect = document.getElementById('person-select');
if (!team) { if (!team) {
document.getElementById('main-title').innerText = ''; document.getElementById('main-title').innerText = '';
personSelect.innerHTML = '<option value="">팀원을 선택하세요</option>'; personSelect.innerHTML = '<option value="">팀원을 선택하세요</option>';
return; return;
} }
document.getElementById('main-title').innerText = team; if (team === ALL_TEAM_VALUE) {
const teamPeople = [...new Set(getScopedRows().filter(r => String(r[columnMap.team] || '').trim() === team).map(r => String(r[columnMap.name] || '').trim()))].filter(Boolean).sort(); document.getElementById('main-title').innerText = '전체';
personSelect.innerHTML = '<option value="">팀원을 선택하세요</option>' + teamPeople.map(p => `<option value="${p}">${p}</option>`).join(''); const allPeople = buildScopedPeopleData(getScopedRows());
} personSelect.innerHTML = '<option value="">전체 구성원</option>' + allPeople.map(p => `<option value="${p.value}">${p.label}</option>`).join('');
return;
}
document.getElementById('main-title').innerText = team;
const teamPeople = [...new Set(getScopedRows().filter(r => String(r[columnMap.team] || '').trim() === team).map(r => String(r[columnMap.name] || '').trim()))].filter(Boolean).sort();
personSelect.innerHTML = '<option value="">팀원을 선택하세요</option>' + teamPeople.map(p => `<option value="${p}">${p}</option>`).join('');
}
function resetMhView() { function resetMhView() {
if (teamData.length < 2) return; if (teamData.length < 2) return;
@@ -2759,14 +2773,17 @@
const latePeople = new Set(); const latePeople = new Set();
const latePeopleMap = {}; const latePeopleMap = {};
teamData.slice(1).forEach(row => { teamData.slice(1).forEach(row => {
const d = dStr(row[columnMap.date]); const d = dStr(row[columnMap.date]);
if (!d || d < startStr || d > endStr || !isRowInScope(row) || String(row[columnMap.team] || '').trim() !== teamF) return; const rowTeam = String(row[columnMap.team] || '').trim();
if (!d || d < startStr || d > endStr || !isRowInScope(row) || (teamF !== ALL_TEAM_VALUE && rowTeam !== teamF)) return;
const lateFlagText = String(row[columnMap.lateFlag] || '').trim(); const lateFlagText = String(row[columnMap.lateFlag] || '').trim();
const userStateText = String(row[columnMap.userState] || '').trim(); const userStateText = String(row[columnMap.userState] || '').trim();
const isWeekend = lateFlagText.includes("\uC8FC\uB9D0"); const isWeekend = lateFlagText.includes("\uC8FC\uB9D0");
const name = String(row[columnMap.name] || '').trim(); const name = String(row[columnMap.name] || '').trim();
const rank = String(row[columnMap.rank] || '연구원').trim(); const rank = String(row[columnMap.rank] || '연구원').trim();
const personKey = teamF === ALL_TEAM_VALUE ? `${name}|${rowTeam}` : name;
const personLabel = teamF === ALL_TEAM_VALUE ? `${name} (${rowTeam})` : name;
const hasSourceCostCols = columnMap.normalCost >= 0 || columnMap.extraCost >= 0; const hasSourceCostCols = columnMap.normalCost >= 0 || columnMap.extraCost >= 0;
const sourceNormalLaborCost = columnMap.normalCost >= 0 ? parseNumber(row[columnMap.normalCost]) : 0; const sourceNormalLaborCost = columnMap.normalCost >= 0 ? parseNumber(row[columnMap.normalCost]) : 0;
const sourceExtraLaborCost = columnMap.extraCost >= 0 ? parseNumber(row[columnMap.extraCost]) : 0; const sourceExtraLaborCost = columnMap.extraCost >= 0 ? parseNumber(row[columnMap.extraCost]) : 0;
@@ -2783,24 +2800,24 @@
stats.normalLaborCost += normalLaborCost; stats.normalLaborCost += normalLaborCost;
stats.extraLaborCost += extraLaborCost; stats.extraLaborCost += extraLaborCost;
stats.laborCost += normalLaborCost + extraLaborCost; stats.laborCost += normalLaborCost + extraLaborCost;
if (!pMap[name]) pMap[name] = { name, rank, normal: 0, extra: 0, holiday: 0, extraCount: 0, holidayCount: 0, total: 0, projects: {} }; if (!pMap[personKey]) pMap[personKey] = { name, team: rowTeam, displayName: personLabel, personKey, rank, normal: 0, extra: 0, holiday: 0, extraCount: 0, holidayCount: 0, total: 0, projects: {} };
slots.forEach(s => { slots.forEach(s => {
const val = parseNumber(row[s.time]); const val = parseNumber(row[s.time]);
const projectCode = (s.code >= 0 ? String(row[s.code] || '') : '').trim(); const projectCode = (s.code >= 0 ? String(row[s.code] || '') : '').trim();
const pName = String(row[s.proj] || '').trim(); const pName = String(row[s.proj] || '').trim();
const biz = (s.biz >= 0 ? String(row[s.biz] || '') : '').trim() || '공통'; const biz = (s.biz >= 0 ? String(row[s.biz] || '') : '').trim() || '공통';
if (val > 0 && pName) { if (val > 0 && pName) {
const ownershipInfo = getProjectOwnership(teamF, projectCode); const ownershipInfo = getProjectOwnership(teamF === ALL_TEAM_VALUE ? rowTeam : teamF, projectCode);
stats.totalMH += val; pMap[name].total += val; stats.totalMH += val; pMap[personKey].total += val;
let type = 'normal'; let type = 'normal';
if (isWeekend) { type = 'holiday'; pMap[name].holiday += val; pMap[name].holidayCount += 1; stats.extraHolMH += val; } if (isWeekend) { type = 'holiday'; pMap[personKey].holiday += val; pMap[personKey].holidayCount += 1; stats.extraHolMH += val; }
else if (s.extra) { type = 'extra'; pMap[name].extra += val; pMap[name].extraCount += 1; stats.extraHolMH += val; } else if (s.extra) { type = 'extra'; pMap[personKey].extra += val; pMap[personKey].extraCount += 1; stats.extraHolMH += val; }
else { pMap[name].normal += val; stats.normalMH += val; } else { pMap[personKey].normal += val; stats.normalMH += val; }
if (!pMap[name].projects[pName]) { if (!pMap[personKey].projects[pName]) {
pMap[name].projects[pName] = { pMap[personKey].projects[pName] = {
normal: 0, normal: 0,
extra: 0, extra: 0,
holiday: 0, holiday: 0,
@@ -2812,11 +2829,11 @@
}; };
} }
pMap[name].projects[pName].total += val; pMap[name].projects[pName][type] += val; pMap[personKey].projects[pName].total += val; pMap[personKey].projects[pName][type] += val;
if (type === 'extra') pMap[name].projects[pName].extraCount += 1; if (type === 'extra') pMap[personKey].projects[pName].extraCount += 1;
if (type === 'holiday') pMap[name].projects[pName].holidayCount += 1; if (type === 'holiday') pMap[personKey].projects[pName].holidayCount += 1;
if (!pMap[name].projects[pName].firstDate || d < pMap[name].projects[pName].firstDate) pMap[name].projects[pName].firstDate = d; if (!pMap[personKey].projects[pName].firstDate || d < pMap[personKey].projects[pName].firstDate) pMap[personKey].projects[pName].firstDate = d;
if (!pMap[name].projects[pName].lastDate || d > pMap[name].projects[pName].lastDate) pMap[name].projects[pName].lastDate = d; if (!pMap[personKey].projects[pName].lastDate || d > pMap[personKey].projects[pName].lastDate) pMap[personKey].projects[pName].lastDate = d;
if (!mMap[biz]) mMap[biz] = {}; if (!mMap[biz]) mMap[biz] = {};
@@ -2832,7 +2849,7 @@
if (!mMap[biz][pName].ownership) mMap[biz][pName].ownership = ownershipInfo.ownership; if (!mMap[biz][pName].ownership) mMap[biz][pName].ownership = ownershipInfo.ownership;
else if (ownershipInfo.ownership === '\uC8FC\uAD00') mMap[biz][pName].ownership = ownershipInfo.ownership; else if (ownershipInfo.ownership === '\uC8FC\uAD00') mMap[biz][pName].ownership = ownershipInfo.ownership;
mMap[biz][pName].total += val; mMap[biz][pName].ps.add(name); mMap[biz][pName].total += val; mMap[biz][pName].ps.add(personLabel);
const rKey = rank.includes("수석") ? "수석" : rank.includes("책임") ? "책임" : rank.includes("선임") ? "선임" : "연구원"; const rKey = rank.includes("수석") ? "수석" : rank.includes("책임") ? "책임" : rank.includes("선임") ? "선임" : "연구원";
@@ -2852,17 +2869,17 @@
const wb = RANK_WEIGHTS[b.rank.includes('수석') ? '수석' : b.rank.includes('책임') ? '책임' : b.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'); return (wa !== wb) ? wa - wb : (a.displayName || a.name).localeCompare((b.displayName || b.name), 'ko');
}); });
stats.personCount = teamList.length; stats.personCount = teamList.length;
stats.memberNames = teamList.map(p => p.name); stats.memberNames = teamList.map(p => ({ name: p.name, label: p.displayName || p.name }));
stats.lateNames = teamList.map(p => p.name).filter(name => latePeople.has(name)); stats.lateNames = teamList.filter(p => latePeople.has(p.name)).map(p => p.displayName || p.name);
stats.lateDetails = stats.lateNames.map(name => ({ stats.lateDetails = teamList.filter(p => latePeople.has(p.name)).map(p => ({
name, name: p.name,
label: `${name}(${latePeopleMap[name]?.count || 0})`, label: `${p.displayName || p.name}(${latePeopleMap[p.name]?.count || 0})`,
firstDate: latePeopleMap[name]?.firstDate || '' firstDate: latePeopleMap[p.name]?.firstDate || ''
})); }));
stats.lateCount = stats.lateNames.length; stats.lateCount = stats.lateNames.length;
@@ -2870,8 +2887,11 @@
const personRows = teamList.map(p => ({ const personRows = teamList.map(p => ({
name: p.name, name: p.name,
team: p.team,
displayName: p.displayName || p.name,
personKey: p.personKey || p.name,
rank: p.rank, rank: p.rank,
normal: p.normal, normal: p.normal,
extra: p.extra, extra: p.extra,
@@ -3218,7 +3238,7 @@
targetBadge.classList.add('hidden'); targetBadge.classList.add('hidden');
iconEl.setAttribute('data-lucide', 'layout-grid'); iconEl.setAttribute('data-lucide', 'layout-grid');
const rows = (personF ? personRows.filter(p => p.name === personF) : personRows) || []; const rows = (personF ? personRows.filter(p => p.personKey === personF || p.name === personF) : personRows) || [];
if (!rows.length) { if (!rows.length) {
container.innerHTML = '<div class="text-slate-300 font-bold italic p-10 text-center w-full">표시할 데이터가 없습니다.</div>'; container.innerHTML = '<div class="text-slate-300 font-bold italic p-10 text-center w-full">표시할 데이터가 없습니다.</div>';
return; return;
@@ -3278,7 +3298,7 @@
const over = total > summaryTargetH; const over = total > summaryTargetH;
const overHour = Math.max(0, total - summaryTargetH); const overHour = Math.max(0, total - summaryTargetH);
const projects = person.projects || []; const projects = person.projects || [];
const personKey = encodeURIComponent(`${person.name}|${person.rank}`); const personKey = encodeURIComponent(`${person.personKey || person.name}|${person.rank}`);
const isOpen = personF ? true : personChartOpenState[personKey] === true; const isOpen = personF ? true : personChartOpenState[personKey] === true;
return ` return `
<div class="mh-person-row border border-slate-200 rounded-xl p-3"> <div class="mh-person-row border border-slate-200 rounded-xl p-3">
@@ -3286,7 +3306,7 @@
<div class="mh-person-summary"> <div class="mh-person-summary">
<div> <div>
<div class="text-base font-black text-slate-900 leading-tight"> <div class="text-base font-black text-slate-900 leading-tight">
${person.name} <span class="text-xs text-slate-500 ml-1">${person.rank}</span>${over ? `<span class="mh-person-over-inline"><i data-lucide="alert-circle" size="12"></i><span>초과 ${formatHour(overHour)}h</span></span>` : ''} ${person.displayName || person.name} <span class="text-xs text-slate-500 ml-1">${person.rank}</span>${over ? `<span class="mh-person-over-inline"><i data-lucide="alert-circle" size="12"></i><span>초과 ${formatHour(overHour)}h</span></span>` : ''}
</div> </div>
</div> </div>
<div class="mh-person-summary-right"> <div class="mh-person-summary-right">

View File

@@ -817,6 +817,20 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
} }
.list-toolbar-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.list-toolbar-divider {
width: 1px;
align-self: stretch;
min-height: 36px;
background: #dbe2ea;
}
.list-mode-btn { .list-mode-btn {
border: 1px solid #c7d2fe; border: 1px solid #c7d2fe;
background: #eef2ff; background: #eef2ff;

View File

@@ -1393,12 +1393,16 @@ function openListViewModal(event) {
fieldsArea.innerHTML = ` fieldsArea.innerHTML = `
<div class="list-toolbar"> <div class="list-toolbar">
<div class="list-toolbar-row"> <div class="list-toolbar-row">
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button> <div class="list-toolbar-group">
<div class="list-date-group"> <button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input"> <input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button> <button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button>
</div> </div>
<div class="list-date-group"> <div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input"> <input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input">
<span class="list-date-separator">~</span> <span class="list-date-separator">~</span>
<input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input"> <input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input">