Files

1623 lines
85 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>프로젝트 대시보드</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/react-is/umd/react-is.production.min.js"></script>
<script src="https://unpkg.com/recharts/umd/Recharts.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useMemo, useEffect } = React;
const RechartsLib = window.Recharts || {};
const icon = (glyph) => ({ size = 14, className = '' }) => (
<span className={className} style={{ fontSize: `${size}px`, lineHeight: 1 }}>{glyph}</span>
);
const LayoutDashboard = icon('▦');
const Clock = icon('◷');
const Briefcase = icon('▣');
const Calendar = icon('◫');
const List = icon('≡');
const Target = icon('◎');
const Users = icon('👥');
const Upload = icon('⇪');
const CheckCircle2 = icon('●');
const FileText = icon('▤');
const RefreshCw = icon('↻');
const MapPin = icon('⌖');
const Coffee = icon('☕');
const Package = icon('▧');
const Wallet = icon('▮');
const UserCheck = icon('✓');
const Info = icon('ⓘ');
const App = () => {
const [expenseRaw, setExpenseRaw] = useState([]);
const [workRaw, setWorkRaw] = useState([]);
const [dataLoaded, setDataLoaded] = useState({ expense: false, work: false });
const [errorMsg, setErrorMsg] = useState(null);
const [selectedRev, setSelectedRev] = useState('전체');
const [selectedD1, setSelectedD1] = useState('전체');
const [selectedD2, setSelectedD2] = useState('전체');
const [selectedProject, setSelectedProject] = useState('전체');
const [projectSearch, setProjectSearch] = useState('');
const [startDate, setStartDate] = useState('2026-01-01');
const [endDate, setEndDate] = useState('2026-01-31');
const [selectedExpenseDetailCategory, setSelectedExpenseDetailCategory] = useState('');
const d1Order = { "매출": 1, "비매출": 2, "공통": 3, "총괄기획실": 4 };
const d2Order = { "바론계약": 1, "가족사 프로젝트": 2, "S/W 개발": 3, "기획/제안": 4, "총괄기획실": 5, "공통": 6 };
const normalizeKey = (v) => String(v || '').trim().replace(/\s+/g, '').toLowerCase();
const normalizeProjectKey = (v) =>
String(v || '')
.normalize('NFKC')
.toLowerCase()
.replace(/[\u200b-\u200d\ufeff]/g, '')
.replace(/[^0-9a-z가-힣]/g, '');
const d3DisplayOrder = [
'S/W판매', '기술용역계약', '콘텐츠 제작', 'R&D', '시공', '디자인', '내부 BIM 설계 지원', '기초조사 및 GIS',
'도로', '구조', '교통', '수리/수문', '그래픽', '구조해석', '솔루션', '운영S/W', '기획&관리', '총괄기획실', '공통'
];
const d2OrderMap = new Map(Object.keys(d2Order).map((name) => [normalizeKey(name), d2Order[name]]));
const d3OrderMap = new Map(d3DisplayOrder.map((name, idx) => [normalizeKey(name), idx + 1]));
const d3PriorityByD2 = {
's/w개발': { '기초조사및gis': 0 },
'기획/제안': { '운영s/w': 0 }
};
const getD2Order = (name) => {
const nk = normalizeKey(name);
return d2OrderMap.has(nk) ? d2OrderMap.get(nk) : 9999;
};
const getD3Order = (name, d2) => {
const nk = normalizeKey(name);
const override = d3PriorityByD2[normalizeKey(d2)]?.[nk];
if (override !== undefined) return override;
return d3OrderMap.has(nk) ? d3OrderMap.get(nk) + 100 : 9999;
};
const projectDisplayOrder = [
'EG-BIM (파이프텍코리아)', 'ERP시스템구축(한종)', 'GAIA 기능 개선 계약', '계양~강화 고속도로(5공구)',
'국도42 원주~흥업', '인주~염치(1공구)빅룸', '인주~염치(2공구)빅룸', '대산~당진(1공구) 빅룸',
'대산~당진(2공구) 빅룸', '대산~당진(3~4공구) 빅룸', '보은국토도로 사업관리', '한강교량 사업관리(2공구)',
'예산국토 제2권역', '가평군 하수관망시스템', 'PQ 시스템(지오메카이엔지)', '디지털 국토정보(1핵심)',
'디지털 국토정보(2핵심)', '스마트건설기술(10과제)', 'XR기반 건설설계 혁신시스템', '인천발 KTX 시공',
'가족사보고서 탬플릿 제작', '대산~당진(2공구) 시공BIM', '서산~명천 기본 및 실시설계', '울산외곽순환(1공구)',
'충남형 스마트팜 기본/실시', '원효대교북단및국제업무지구', '향촌지구우수유출저감사업관리', 'KNGIL',
'GIS Mapper', '천지인', 'Surveyor', 'GAIA', 'WayPrimal', 'WayConfirm', 'WayDraw', 'WayShop',
'WatchBIM', 'Twin Highway', 'WallZainer', 'Bridge planner', 'AbutZainer', 'BriZainer-DR',
'BriZainer-Nodular', 'TunnelZainer', 'TOVA', 'LifeLine-water', '강우강도산정 프로그램', 'HmEG(HmDraw)',
'EG-BIM Modeler', 'EG-BIM Drawer', 'StrAna', '문서관리시스템(PM)', 'CCP', 'bCMf', 'GSIM',
'단가/공정 solution', 'Domainer', 'ERP:장헌산업', 'ERP:PTC', 'ERP:한라', 'ERP:삼안', 'ERP:한맥',
'ERP:바론', 'ERP:장헌', 'ERP:산하종합기술', '입찰정보(용역/공사)조회시스템', 'PQ시스템', '전산운영관리',
'BEPs', '사전기획', '청용천교 업무지원', '수자원 해외사업', 'AI', 'CivilEngineeringLab', 'GIS 장비개발',
'프리캐스트 조립식 박스형 교량', '인덕원~동탄 스마트상황실', '한맥가족 배움터', '경영기획/전략', '인사/교육',
'운영지원(총무)', '업무/사업관리', '교육훈련, 참석', '공통(기타)'
];
const defaultProjectOrderMap = new Map(projectDisplayOrder.map((name, idx) => [normalizeProjectKey(name), idx]));
const [customProjectOrderMap, setCustomProjectOrderMap] = useState(null);
const getProjectOrder = (name) => {
const key = normalizeProjectKey(name);
if (!key) return 9999;
if (customProjectOrderMap && customProjectOrderMap.has(key)) return customProjectOrderMap.get(key);
return defaultProjectOrderMap.has(key) ? defaultProjectOrderMap.get(key) : 9999;
};
const costCategories = [
{ name: '인건비', color: '#0f3a2f' },
{ name: '출장비', color: '#a94832' },
{ name: '복리후생비', color: '#d68a3a' },
{ name: '구매비', color: '#4b87b3' },
{ name: '외주비', color: '#66756d' }
];
const positionStyles = {
'수석연구원': { bg: 'position-chip position-executive', text: 'position-text position-executive', border: 'position-border position-executive', icon: 'position-dot position-executive' },
'책임연구원': { bg: 'position-chip position-principal', text: 'position-text position-principal', border: 'position-border position-principal', icon: 'position-dot position-principal' },
'선임연구원': { bg: 'position-chip position-senior', text: 'position-text position-senior', border: 'position-border position-senior', icon: 'position-dot position-senior' },
'전임연구원': { bg: 'position-chip position-associate', text: 'position-text position-associate', border: 'position-border position-associate', icon: 'position-dot position-associate' },
'주임연구원': { bg: 'position-chip position-staff', text: 'position-text position-staff', border: 'position-border position-staff', icon: 'position-dot position-staff' },
'연구원': { bg: 'position-chip position-member', text: 'position-text position-member', border: 'position-border position-member', icon: 'position-dot position-member' },
'미지정': { bg: 'position-chip position-unset', text: 'position-text position-unset', border: 'position-border position-unset', icon: 'position-dot position-unset' }
};
const positionOrder = { '수석연구원': 1, '책임연구원': 2, '선임연구원': 3, '연구원': 4 };
const positionColorMap = {
'수석연구원': '#0f3a2f',
'책임연구원': '#1a5645',
'선임연구원': '#2f9973',
'전임연구원': '#4b87b3',
'주임연구원': '#9a6422',
'연구원': '#66756d',
'미지정': '#b7aa93'
};
const getPositionStyle = (pos) => positionStyles[pos] || positionStyles['미지정'];
const getCostColor = (name) => costCategories.find(c => c.name === name)?.color || '#66756d';
const getPositionColor = (name) => positionColorMap[name] || positionColorMap['미지정'];
const twoLineClampStyle = {
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
};
const formatWon = (value) => `${Number(value || 0).toLocaleString()}`;
const formatWonRounded = (value) => {
const rounded = Math.round(Number(value || 0));
return `${rounded.toLocaleString()}`;
};
const formatWonDash = (value) => (Number(value || 0) === 0 ? '-' : formatWon(value));
const formatWonRoundedDash = (value) => (Math.round(Number(value || 0)) === 0 ? '-' : formatWonRounded(value));
const formatHours = (value) => Number(value || 0).toFixed(1).replace(/\.0$/, '');
const formatActivityName = (value) => {
const raw = norm(value);
if (!raw) return '미지정 Activity';
const cleaned = raw.replace(/^[A-Za-z]\s*[\.)]\s*/, '').trim();
return cleaned || raw;
};
const buildDonutGradient = (items) => {
const total = items.reduce((sum, item) => sum + (item.value || 0), 0);
if (total <= 0) return 'conic-gradient(#eadcc4 0deg 360deg)';
let start = 0;
const slices = items.map((item) => {
const deg = ((item.value || 0) / total) * 360;
const end = start + deg;
const segment = `${getCostColor(item.name)} ${start}deg ${end}deg`;
start = end;
return segment;
});
return `conic-gradient(${slices.join(', ')})`;
};
const renderBreakdownTooltip = (breakdown, total) => (
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg payment-tooltip px-3 py-2 text-[12px] font-bold opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
{costCategories.map((cat) => {
const val = breakdown?.[cat.name] || 0;
const ratio = total > 0 ? ((val / total) * 100).toFixed(1) : '0.0';
return (
<div key={cat.name} className="flex items-center justify-between gap-3">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: getCostColor(cat.name) }}></span>
{cat.name}
</span>
<span>{formatWon(val)} ({ratio}%)</span>
</div>
);
})}
</div>
);
const renderPositionBreakdownTooltip = (breakdown, totalHrs) => (
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg payment-tooltip px-3 py-2 text-[12px] font-bold opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
{Object.entries(breakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, val]) => {
const ratio = totalHrs > 0 ? ((val / totalHrs) * 100).toFixed(1) : '0.0';
return (
<div key={pos} className="flex items-center justify-between gap-3">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: getPositionColor(pos) }}></span>
{pos}
</span>
<span>{val.toFixed(2)}h ({ratio}%)</span>
</div>
);
})}
</div>
);
const renderPositionBreakdownInline = (breakdown, details, totalHrs, totalWorkers) => {
const shortPosition = (name) => ({
'수석연구원': '수석',
'책임연구원': '책임',
'선임연구원': '선임',
'연구원': '연구원'
}[name] || name || '미지정');
const entries = Object.entries(breakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b));
if (!entries.length) return null;
return (
<div className="mt-2 grid grid-cols-[72px_1fr] items-center gap-2">
<div className="self-center text-center">
<div className="text-[16px] leading-none font-black payment-strong">{Number(totalWorkers || 0)}</div>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black payment-muted leading-tight">
{entries.map(([pos, val]) => {
const count = details?.[pos]?.names?.size || 0;
const hrsText = Number(val || 0).toFixed(1).replace(/\.0$/, '');
const ratio = totalHrs > 0 ? ((Number(val || 0) / totalHrs) * 100).toFixed(1).replace(/\.0$/, '') : '0';
return (
<span key={`legend-${pos}`} className="inline-flex items-center gap-1 whitespace-nowrap">
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: getPositionColor(pos) }}></span>
<span>{shortPosition(pos)} {count} · {hrsText}h · {ratio}%</span>
</span>
);
})}
</div>
</div>
);
};
const renderCostBreakdownTable = (breakdown) => {
const cells = [
{ key: '인건비', label: '인건비' },
{ key: '출장비', label: '출장비' },
{ key: '복리후생비', label: '복리후생비' },
{ key: '구매비', label: '구매비' },
{ key: '외주비', label: '외주비' }
];
return (
<div className="grid grid-cols-5">
{cells.map((cell) => {
const amount = Math.round(breakdown?.[cell.key] || 0);
return (
<div key={`${cell.key}-v`} className="px-2 py-1.5 text-right text-[11px] font-black payment-muted whitespace-nowrap">
{amount === 0 ? '-' : `${amount.toLocaleString()}`}
</div>
);
})}
</div>
);
};
const parseCSV = (text) => {
if (!text) return [];
const lines = String(text).split(/\r?\n/).filter(line => line.trim());
if (lines.length === 0) return [];
const splitLine = (line) => {
const out = [];
let cur = '';
let inQ = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') inQ = !inQ;
else if (ch === ',' && !inQ) {
out.push(cur);
cur = '';
} else {
cur += ch;
}
}
out.push(cur);
return out.map(v => String(v || '').replace(/^"|"$/g, '').replace(/[\ufeff\u200b]/g, '').trim());
};
const headers = splitLine(lines[0]);
const nHeaders = headers.map(h => normalizeKey(h));
return lines.slice(1).map(line => {
const values = splitLine(line);
const obj = { __values: values };
headers.forEach((header, i) => {
if (!header) return;
obj[header] = values[i] || '';
if (nHeaders[i]) obj[`__n_${nHeaders[i]}`] = values[i] || '';
});
return obj;
});
};
const parseProjectOrderMapFromCsv = (text) => {
const rows = parseCSV(text);
if (!rows.length) return null;
const map = new Map();
rows.forEach((row) => {
const candidates = [
getVal(row, ['사업명(인트라넷기준)', '사업면(인트라넷기준)', '사업명(인트라넷 기준)', '사업면(인트라넷 기준)', 'D4'], 9),
getVal(row, ['프로젝트명', '프로젝트', '프로젝트명 매칭'], 0)
];
const firstFilled = (row.__values || []).find((v) => norm(v));
const projectName = norm(candidates.find((v) => norm(v)) || firstFilled || '');
const key = normalizeProjectKey(projectName);
if (!key || map.has(key)) return;
map.set(key, map.size);
});
return map.size ? map : null;
};
const getVal = (row, candidates = [], fallbackIdx = -1) => {
for (const c of candidates) {
if (row[c] !== undefined && row[c] !== '') return row[c];
const nv = row[`__n_${normalizeKey(c)}`];
if (nv !== undefined && nv !== '') return nv;
}
if (fallbackIdx >= 0 && row.__values && row.__values[fallbackIdx] !== undefined) return row.__values[fallbackIdx];
return '';
};
const norm = (v) => String(v || '').replace(/[\ufeff\u200b]/g, '').trim();
const pnum = (v) => parseFloat(String(v || '').replace(/[^0-9.\-]/g, '')) || 0;
const normalizeD1Category = (value) => {
const text = norm(value);
if (!text) return '';
if (text === '공백' || text === '-' || text === '없음' || text === 'na' || text === 'n/a') return '';
return text;
};
const resetFilters = () => {
setSelectedRev('전체');
setSelectedD1('전체');
setSelectedD2('전체');
setSelectedProject('전체');
};
const handleFileUpload = (e, type) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = parseCSV(event.target.result);
if (type === 'expense') {
setExpenseRaw(data);
setDataLoaded(prev => ({ ...prev, expense: true }));
resetFilters();
} else {
setWorkRaw(data);
setDataLoaded(prev => ({ ...prev, work: true }));
resetFilters();
}
} catch (err) {
setErrorMsg(`오류: ${err.message}`);
}
};
reader.readAsText(file, 'euc-kr');
};
useEffect(() => {
const onParentMessage = (event) => {
const payload = event?.data;
if (!payload) return;
if (payload.source === 'total-upload') {
if (payload.type !== 'expense' && payload.type !== 'work') return;
try {
const data = parseCSV(payload.text || '');
if (payload.type === 'expense') {
setExpenseRaw(data);
setDataLoaded(prev => ({ ...prev, expense: true }));
resetFilters();
} else {
setWorkRaw(data);
setDataLoaded(prev => ({ ...prev, work: true }));
resetFilters();
}
} catch (err) {
setErrorMsg(`오류: ${err.message}`);
}
return;
}
if (payload.source === 'total-control' && payload.type === 'date-range') {
if ('startDate' in payload) setStartDate(payload.startDate || '');
if ('endDate' in payload) setEndDate(payload.endDate || '');
return;
}
if (payload.source === 'total-control' && payload.type === 'project-order-csv') {
try {
const parsedMap = parseProjectOrderMapFromCsv(payload.text || '');
if (parsedMap) setCustomProjectOrderMap(parsedMap);
} catch (err) {
console.error('프로젝트 표출순서 CSV 처리 실패:', err);
}
}
};
window.addEventListener('message', onParentMessage);
return () => window.removeEventListener('message', onParentMessage);
}, []);
useEffect(() => {
let cancelled = false;
const initializeFromIntegration = async () => {
try {
const response = await fetch('/api/integration/payment-source', { credentials: 'same-origin' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
if (cancelled) return;
const expenseRows = Array.isArray(payload.expense_rows) ? payload.expense_rows : [];
const workRows = Array.isArray(payload.work_rows) ? payload.work_rows : [];
setExpenseRaw(expenseRows);
setWorkRaw(workRows);
setDataLoaded({ expense: expenseRows.length > 0, work: workRows.length > 0 });
resetFilters();
setErrorMsg(null);
} catch (err) {
if (!cancelled) {
setErrorMsg(`통합 데이터를 불러오지 못했습니다: ${err.message}`);
}
}
};
initializeFromIntegration();
return () => {
cancelled = true;
};
}, []);
const formatWorkDate = (dateStr) => {
if (!dateStr) return '';
const clean = dateStr.replace(/\s/g, '').replace(/\./g, '-');
const parts = clean.split('-');
if (parts.length < 3) return clean;
return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;
};
const getExpenseDate = (row) => norm(getVal(row, ['발행일', '청구일', '발행 일자', '청구 일자'], 2));
const processedData = useMemo(() => {
if (!dataLoaded.expense || !dataLoaded.work) return null;
const roleHourlyRate = {
'수석': 46600,
'책임': 40500,
'선임': 35300,
'연구원': 28900
};
const getRoleHourlyRate = (position) => {
const pos = norm(position);
if (!pos) return roleHourlyRate['연구원'];
if (pos.includes('수석')) return roleHourlyRate['수석'];
if (pos.includes('책임')) return roleHourlyRate['책임'];
if (pos.includes('선임')) return roleHourlyRate['선임'];
return roleHourlyRate['연구원'];
};
const allocateRoundedLabor = (totalLabor, segments, isWeekend) => {
const targetLabor = Math.round(Number(totalLabor) || 0);
if (targetLabor <= 0 || !segments.length) return segments.map(() => 0);
const weighted = segments.map((seg, idx) => {
const mul = (isWeekend || seg.overtime) ? 1.5 : 1;
return { idx, weight: Math.max(0, Number(seg.hours) || 0) * mul };
});
const totalWeight = weighted.reduce((sum, item) => sum + item.weight, 0);
if (totalWeight <= 0) return segments.map(() => 0);
const allocations = weighted.map((item) => {
const raw = (targetLabor * item.weight) / totalWeight;
const base = Math.floor(raw);
return { idx: item.idx, base, frac: raw - base };
});
let remain = targetLabor - allocations.reduce((sum, part) => sum + part.base, 0);
if (remain > 0) {
allocations.sort((a, b) => (b.frac - a.frac) || (a.idx - b.idx));
for (let i = 0; i < allocations.length && remain > 0; i += 1, remain -= 1) allocations[i].base += 1;
} else if (remain < 0) {
allocations.sort((a, b) => (a.frac - b.frac) || (a.idx - b.idx));
for (let i = 0; i < allocations.length && remain < 0; i += 1, remain += 1) allocations[i].base -= 1;
}
allocations.sort((a, b) => a.idx - b.idx);
return allocations.map((item) => item.base);
};
const getExpenseProjectName = (row) => {
// Expense project name is fixed to K column (사업명(인트라넷기준)).
return norm(getVal(row, ['사업명(인트라넷기준)', '사업명(인트라넷 기준)'], 10));
};
const getWorkProjectName = (row) => norm(row?.projectName || getVal(row, ['프로젝트명 매칭', '프로젝트명', '사업명(표출PJT)', 'D4']));
const getExpenseProjectKey = (row) => normalizeProjectKey(getExpenseProjectName(row));
const getWorkProjectKey = (row) => normalizeProjectKey(getWorkProjectName(row));
const hasNamedHeader = (row, header) => {
if (!row || !header) return false;
if (Object.prototype.hasOwnProperty.call(row, header)) return true;
const nk = `__n_${normalizeKey(header)}`;
return Object.prototype.hasOwnProperty.call(row, nk);
};
const toWorkEntryList = (row) => {
const workDate = formatWorkDate(getVal(row, ['근무일자', '날짜', '일자']));
const workerName = norm(getVal(row, ['이름']));
const position = norm(getVal(row, ['직책', '직급']));
const userState = norm(getVal(row, ['user_state', 'User State', 'user state', 'userstate', 'User_State']));
const weekendFlag = norm(getVal(row, ['주말/지각']));
const isWeekend = userState.includes('주말') || weekendFlag.includes('주말');
const isMhSchema = hasNamedHeader(row, '메인업무 프로젝트명') || hasNamedHeader(row, '연장근무 프로젝트명') || hasNamedHeader(row, '연장근무 시간(가공)');
const importedLabor = pnum(getVal(row, ['산정금액', '인건비']));
const overtimeHoursFromRow = isMhSchema
? pnum(getVal(row, ['연장근무 시간(가공)', '연장근무시간(가공)', '연장근무 시간', '연장근무시간', '추가근무'], 44))
: pnum(getVal(row, ['연장근무 시간(가공)', '연장근무시간(가공)', '연장근무 시간', '연장근무시간', '추가근무']));
const d1 = normalizeD1Category(getVal(row, ['D1', '매출/비매출']));
const d2 = norm(getVal(row, ['D2', '사업분야', '분야']));
const d3 = norm(getVal(row, ['D3', '세부분야']));
const rate = getRoleHourlyRate(position);
const mhProjectFields = [
{ name: '메인업무 프로젝트명', hour: '메인업무 근무시간', sub: '메인업무 서브 코드', overtime: false, nameIdx: 10, hourIdx: 12 },
{ name: '추가업무1 프로젝트명', hour: '추가업무1 근무시간', sub: '추가업무1 서브 코드', overtime: false, nameIdx: 16, hourIdx: 18 },
{ name: '추가업무2 프로젝트명', hour: '추가업무2 근무시간', sub: '추가업무2 서브 코드', overtime: false, nameIdx: 21, hourIdx: 23 },
{ name: '추가업무3 프로젝트명', hour: '추가업무3 근무시간', sub: '추가업무3 서브 코드', overtime: false, nameIdx: 26, hourIdx: 28 },
{ name: '추가업무4 프로젝트명', hour: '추가업무4 근무시간', sub: '추가업무4 서브 코드', overtime: false, nameIdx: 31, hourIdx: 33 },
{ name: '추가업무5 프로젝트명', hour: '추가업무5 근무시간', sub: '추가업무5 서브 코드', overtime: false, nameIdx: 36, hourIdx: 38 },
{ name: '연장근무 프로젝트명', hour: '연장근무 시간(가공)', sub: '연장근무 서브 코드', overtime: true, nameIdx: 41, hourIdx: 44 }
];
const hasMhShape = isMhSchema && mhProjectFields.some((f) =>
norm(getVal(row, [f.name], f.nameIdx)) || pnum(getVal(row, [f.hour], f.hourIdx)) > 0
);
const segments = [];
if (hasMhShape) {
mhProjectFields.forEach((f) => {
const projectName = norm(getVal(row, [f.name], f.nameIdx));
const hours = pnum(getVal(row, [f.hour], f.hourIdx));
const activity = norm(getVal(row, [f.sub, f.sub?.replace(' ', ''), '서브 코드']));
if (!projectName || hours <= 0) return;
segments.push({ projectName, activity, hours, overtime: f.overtime });
});
if (isMhSchema && overtimeHoursFromRow > 0 && !segments.some((s) => s.overtime)) {
const fallbackProject = norm(getVal(row, ['메인업무 프로젝트명', '프로젝트명', '사업명(표출PJT)', 'D4'], 10));
const fallbackActivity = norm(getVal(row, ['연장근무 서브 코드', '메인업무 서브 코드', '서브 코드']));
if (fallbackProject) {
segments.push({ projectName: fallbackProject, activity: fallbackActivity, hours: overtimeHoursFromRow, overtime: true });
}
}
} else {
const projectName = getWorkProjectName(row);
const activity = norm(getVal(row, ['서브 코드']));
const hours = pnum(getVal(row, ['시간', '근무시간', '투입시간', '총근무시간', '일반근무']));
if (projectName && hours > 0) {
const otHours = Math.max(0, Math.min(hours, overtimeHoursFromRow));
const normalHours = Math.max(0, hours - otHours);
if (normalHours > 0) segments.push({ projectName, activity, hours: normalHours, overtime: false });
if (otHours > 0) segments.push({ projectName, activity, hours: otHours, overtime: true });
}
}
const importedLaborAllocations = importedLabor > 0
? allocateRoundedLabor(importedLabor, segments, isWeekend)
: null;
return segments.map((seg, idx) => {
const mul = (isWeekend || seg.overtime) ? 1.5 : 1;
return {
projectName: seg.projectName,
workDate,
workerName,
position,
userState,
activity: seg.activity,
hours: seg.hours,
labor: importedLaborAllocations
? (importedLaborAllocations[idx] || 0)
: Math.round(seg.hours * rate * mul),
d1,
d2,
d3
};
});
};
const workEntries = workRaw.flatMap(toWorkEntryList);
const unknownDepth = { d1: '미분류', d2: '미분류', d3: '미분류' };
const isUnknown = (v) => normalizeKey(v) === normalizeKey('미분류');
const depthScore = (d) => [d?.d1, d?.d2, d?.d3].reduce((acc, v) => acc + (isUnknown(v) ? 0 : 1), 0);
const mergeDepth = (current, incoming) => {
if (!current) return incoming;
const curScore = depthScore(current);
const inScore = depthScore(incoming);
if (inScore > curScore) return incoming;
if (inScore < curScore) return current;
return {
d1: isUnknown(current.d1) && !isUnknown(incoming.d1) ? incoming.d1 : current.d1,
d2: isUnknown(current.d2) && !isUnknown(incoming.d2) ? incoming.d2 : current.d2,
d3: isUnknown(current.d3) && !isUnknown(incoming.d3) ? incoming.d3 : current.d3
};
};
const projectToDepth = {};
const projectDisplayByKey = {};
expenseRaw.forEach(e => {
const pName = getExpenseProjectName(e);
const pKey = normalizeProjectKey(pName);
if (!pKey) return;
const d1 = normalizeD1Category(getVal(e, ['D1', '매출/비매출', '매출비매출'], 14));
if (!d1) return; // D1(K) empty rows are ignored.
if (!projectDisplayByKey[pKey]) projectDisplayByKey[pKey] = pName;
projectToDepth[pKey] = mergeDepth(projectToDepth[pKey], {
d1,
d2: norm(getVal(e, ['D2', '중분류'], 15)) || '미분류',
d3: norm(getVal(e, ['D3', '소분류'], 16)) || '미분류'
});
});
workEntries.forEach(w => {
const pName = getWorkProjectName(w);
const pKey = normalizeProjectKey(pName);
if (!pKey) return;
if (!projectDisplayByKey[pKey]) projectDisplayByKey[pKey] = pName;
const d1 = normalizeD1Category(getVal(w, ['D1', '매출/비매출'])) || normalizeD1Category(w.d1);
projectToDepth[pKey] = mergeDepth(projectToDepth[pKey], {
d1: d1 || '미분류',
d2: norm(getVal(w, ['D2', '사업분야', '분야'])) || norm(w.d2) || '미분류',
d3: norm(getVal(w, ['D3', '세부분야'])) || norm(w.d3) || '미분류'
});
});
const isWithinRange = (dateStr) => {
if (!dateStr) return false;
const d = new Date(String(dateStr).replace(/\./g, '-'));
return d >= new Date(startDate) && d <= new Date(endDate);
};
const toIssueMonth = (dateStr) => {
const raw = norm(dateStr);
if (!raw) return '';
const d = new Date(String(raw).replace(/\./g, '-'));
if (!Number.isNaN(d.getTime())) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
const m = raw.match(/(20\d{2})[.\-/년\s]*(\d{1,2})/);
if (m) return `${m[1]}-${String(m[2]).padStart(2, '0')}`;
return raw.length >= 7 ? raw.slice(0, 7) : raw;
};
const excludedWorkers = new Set(['정태원', '양병홍', '장종찬', '김형준']);
const selectedProjectKey = normalizeProjectKey(selectedProject === '전체' ? '' : selectedProject);
const projectSearchKey = normalizeProjectKey(projectSearch);
const baseFilteredWork = workEntries.filter(item => {
const pName = getWorkProjectName(item);
const pKey = getWorkProjectKey(item);
const workerName = norm(item.workerName || getVal(item, ['이름']));
if (workerName && excludedWorkers.has(workerName)) return false;
const info = projectToDepth[pKey] || unknownDepth;
const fDate = formatWorkDate(item.workDate || getVal(item, ['날짜', '일자', '근무일자']));
return (selectedRev === '전체' || info.d1 === selectedRev) &&
(selectedD1 === '전체' || info.d2 === selectedD1) &&
(selectedD2 === '전체' || info.d3 === selectedD2) &&
(selectedProject === '전체' || pKey === selectedProjectKey) &&
(!projectSearchKey || normalizeProjectKey(pName).includes(projectSearchKey) || pKey.includes(projectSearchKey)) &&
isWithinRange(fDate);
});
const baseFilteredExp = expenseRaw.filter(item => {
const pKey = getExpenseProjectKey(item);
const pName = getExpenseProjectName(item);
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
if (!d1FromRow) return false; // D1(K) empty rows are ignored.
const info = projectToDepth[pKey] || unknownDepth;
const issueDate = getExpenseDate(item);
return (selectedRev === '전체' || info.d1 === selectedRev) &&
(selectedD1 === '전체' || info.d2 === selectedD1) &&
(selectedD2 === '전체' || info.d3 === selectedD2) &&
(selectedProject === '전체' || pKey === selectedProjectKey) &&
(!projectSearchKey || normalizeProjectKey(pName).includes(projectSearchKey) || pKey.includes(projectSearchKey)) &&
isWithinRange(issueDate);
});
const baseAllWork = workEntries.filter(item => {
const workerName = norm(item.workerName || getVal(item, ['이름']));
if (workerName && excludedWorkers.has(workerName)) return false;
const fDate = formatWorkDate(item.workDate || getVal(item, ['날짜', '일자', '근무일자']));
return isWithinRange(fDate);
});
const baseAllExp = expenseRaw.filter(item => {
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
if (!d1FromRow) return false;
const issueDate = getExpenseDate(item);
return isWithinRange(issueDate);
});
const positionGroupMode = (selectedD2 !== '전체' || selectedProject !== '전체')
? 'D4'
: (selectedD1 !== '전체' ? 'D3' : (selectedRev !== '전체' ? 'D2' : 'D1'));
const positionAnalysis = {};
baseFilteredWork.forEach(w => {
const pName = getWorkProjectName(w);
const pKey = getWorkProjectKey(w);
const info = projectToDepth[pKey] || unknownDepth;
const displayName = projectDisplayByKey[pKey] || pName || '미지정 프로젝트';
const groupKey = positionGroupMode === 'D1' ? info.d1
: (positionGroupMode === 'D2' ? info.d2
: (positionGroupMode === 'D3' ? info.d3 : displayName));
const safeGroupKey = (groupKey && String(groupKey).trim()) || '미지정 프로젝트';
const pos = norm(getVal(w, ['직급'])) || norm(w.position) || '미지정';
const name = norm(getVal(w, ['이름'])) || norm(w.workerName) || '무명';
const val = pnum(w.labor || getVal(w, ['산정금액', '인건비']));
const h = pnum(w.hours || getVal(w, ['시간', '근무시간']));
if (!positionAnalysis[safeGroupKey]) positionAnalysis[safeGroupKey] = {};
if (!positionAnalysis[safeGroupKey][pos]) positionAnalysis[safeGroupKey][pos] = { labor: 0, hrs: 0, names: new Set() };
positionAnalysis[safeGroupKey][pos].labor += val;
positionAnalysis[safeGroupKey][pos].hrs += h;
positionAnalysis[safeGroupKey][pos].names.add(name);
});
const projectActivityMap = {};
baseFilteredWork.forEach((w) => {
const pName = getWorkProjectName(w);
const pKey = getWorkProjectKey(w);
const displayName = projectDisplayByKey[pKey] || pName || '미지정 프로젝트';
const safeProjectKey = normalizeProjectKey(displayName) || displayName;
const activityName = formatActivityName(norm(w.activity) || norm(getVal(w, ['서브 코드'])) || '미지정 Activity');
const workerName = norm(w.workerName || getVal(w, ['이름'])) || '미지정';
const hours = pnum(w.hours || getVal(w, ['시간', '근무시간']));
if (hours <= 0) return;
if (!projectActivityMap[safeProjectKey]) {
projectActivityMap[safeProjectKey] = {
projectName: displayName,
totalHours: 0,
workers: new Set(),
activities: {}
};
}
const projectInfo = projectActivityMap[safeProjectKey];
projectInfo.totalHours += hours;
projectInfo.workers.add(workerName);
if (!projectInfo.activities[activityName]) {
projectInfo.activities[activityName] = {
hours: 0,
workers: new Set(),
memberHours: {}
};
}
const activityInfo = projectInfo.activities[activityName];
activityInfo.hours += hours;
activityInfo.workers.add(workerName);
activityInfo.memberHours[workerName] = (activityInfo.memberHours[workerName] || 0) + hours;
});
const projectActivityList = Object.values(projectActivityMap)
.map((projectInfo) => ({
projectName: projectInfo.projectName,
totalHours: projectInfo.totalHours,
workerCount: projectInfo.workers.size,
activities: Object.entries(projectInfo.activities)
.map(([activityName, info]) => ({
activityName,
hours: info.hours,
workerCount: info.workers.size,
members: Object.entries(info.memberHours)
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
.map(([name, h]) => ({ name, hours: h }))
}))
.sort((a, b) => (b.hours - a.hours) || a.activityName.localeCompare(b.activityName))
}))
.sort((a, b) => (b.totalHours - a.totalHours) || a.projectName.localeCompare(b.projectName));
const allProjectKeys = Array.from(new Set([
...baseFilteredExp.map(e => getExpenseProjectKey(e)),
...baseFilteredWork.map(w => getWorkProjectKey(w))
])).filter(Boolean);
let summaryList = allProjectKeys.map(pKey => {
const pExp = baseFilteredExp.filter(e => getExpenseProjectKey(e) === pKey);
const pWork = baseFilteredWork.filter(w => getWorkProjectKey(w) === pKey);
const pName = projectDisplayByKey[pKey] || getExpenseProjectName(pExp[0]) || getWorkProjectName(pWork[0]) || '미지정 프로젝트';
const labor = pWork.reduce((s, c) => s + pnum(c.labor || getVal(c, ['산정금액', '인건비'])), 0);
const hrs = pWork.reduce((s, c) => s + pnum(c.hours || getVal(c, ['시간', '근무시간'])), 0);
const positionBreakdown = {};
const positionDetails = {};
pWork.forEach(w => {
const pos = norm(getVal(w, ['직급'])) || norm(w.position) || '미지정';
const h = pnum(w.hours || getVal(w, ['시간', '근무시간']));
const workerName = norm(getVal(w, ['이름'])) || norm(w.workerName) || '무명';
positionBreakdown[pos] = (positionBreakdown[pos] || 0) + h;
if (!positionDetails[pos]) positionDetails[pos] = { hrs: 0, names: new Set() };
positionDetails[pos].hrs += h;
if (workerName) positionDetails[pos].names.add(workerName);
});
const income = pExp.reduce((s, c) => s + pnum(getVal(c, ['수입'], 24)), 0);
const costBreakdown = { '인건비': labor, '출장비': 0, '복리후생비': 0, '구매비': 0, '외주비': 0 };
pExp.forEach(e => {
const div = norm(getVal(e, ['구분'], 26));
const spend = pnum(getVal(e, ['지출'], 23));
if (div === '제외' || !spend) return;
const isOutsource = (div === '외주비' || div === '외주');
if (div === '출장비') costBreakdown['출장비'] += spend;
else if (div === '복리후생비') costBreakdown['복리후생비'] += spend;
else if (isOutsource) costBreakdown['외주비'] += spend;
else costBreakdown['구매비'] += spend;
});
const depth = projectToDepth[pKey] || unknownDepth;
const total = Object.values(costBreakdown).reduce((a, b) => a + b, 0);
const workerNames = Array.from(new Set(pWork.map(w => norm(getVal(w, ['이름'])) || norm(w.workerName)).filter(Boolean)));
return {
name: pName, d1: depth.d1, d2: depth.d2, d3: depth.d3, income, labor, total, costBreakdown, positionBreakdown, positionDetails,
workers: workerNames.length,
workerNames,
hrs
};
}).filter(p => p.total > 0 || p.income > 0 || p.hrs > 0)
.sort((a, b) =>
(d1Order[a.d1] || 99) - (d1Order[b.d1] || 99) ||
getD2Order(a.d2) - getD2Order(b.d2) ||
a.d2.localeCompare(b.d2) ||
getD3Order(a.d3, a.d2) - getD3Order(b.d3, b.d2) ||
a.d3.localeCompare(b.d3) ||
getProjectOrder(a.name) - getProjectOrder(b.name) ||
a.name.localeCompare(b.name)
);
const isAllFiltersOff = selectedRev === '전체' && selectedD1 === '전체' && selectedD2 === '전체' && selectedProject === '전체' && !String(projectSearch || '').trim();
const showD2D3SubtotalsWhenD1Filtered =
selectedRev !== '전체' &&
selectedD1 === '전체' &&
selectedD2 === '전체' &&
selectedProject === '전체' &&
!String(projectSearch || '').trim();
const showD3SubtotalsWhenD2Filtered =
selectedRev !== '전체' &&
selectedD1 !== '전체' &&
selectedD2 === '전체' &&
selectedProject === '전체' &&
!String(projectSearch || '').trim();
const aggregateGroupSummary = (items, label, level) => {
const costBreakdown = { '인건비': 0, '출장비': 0, '복리후생비': 0, '구매비': 0, '외주비': 0 };
const positionBreakdown = {};
const positionDetails = {};
const workers = new Set();
items.forEach((item) => {
costBreakdown['인건비'] += item.costBreakdown?.['인건비'] || 0;
costBreakdown['출장비'] += item.costBreakdown?.['출장비'] || 0;
costBreakdown['복리후생비'] += item.costBreakdown?.['복리후생비'] || 0;
costBreakdown['구매비'] += item.costBreakdown?.['구매비'] || 0;
costBreakdown['외주비'] += item.costBreakdown?.['외주비'] || 0;
Object.entries(item.positionBreakdown || {}).forEach(([pos, val]) => {
positionBreakdown[pos] = (positionBreakdown[pos] || 0) + (val || 0);
});
Object.entries(item.positionDetails || {}).forEach(([pos, info]) => {
if (!positionDetails[pos]) positionDetails[pos] = { hrs: 0, names: new Set() };
positionDetails[pos].hrs += info?.hrs || 0;
(info?.names || new Set()).forEach((nm) => positionDetails[pos].names.add(nm));
});
(item.workerNames || []).forEach((name) => workers.add(name));
});
return {
isSubtotal: true,
subtotalLevel: level,
subtotalLabel: label,
labelColSpan:
level === 'd1'
? (isAllFiltersOff ? 4 : 4)
: (isAllFiltersOff
? 3
: (showD2D3SubtotalsWhenD1Filtered
? (level === 'd3' ? 2 : 3)
: (showD3SubtotalsWhenD2Filtered
? (level === 'd3' ? 2 : 4)
: 4))),
income: items.reduce((s, c) => s + (c.income || 0), 0),
total: items.reduce((s, c) => s + (c.total || 0), 0),
hrs: items.reduce((s, c) => s + (c.hrs || 0), 0),
workers: workers.size,
costBreakdown,
positionBreakdown,
positionDetails
};
};
const buildD3SummaryRow = (items, d1, d2, d3) => {
const merged = aggregateGroupSummary(items, `${d3} 소계`, 'd3');
return {
...merged,
isSubtotal: false,
isD3SummaryRow: true,
d1, d2, d3,
name: `${d3} 소계`
};
};
const finalDisplayList = [];
for (let i = 0; i < summaryList.length;) {
const d1 = summaryList[i].d1;
let d1End = i;
while (d1End < summaryList.length && summaryList[d1End].d1 === d1) d1End++;
const d2Ranges = [];
for (let j = i; j < d1End;) {
const d2 = summaryList[j].d2;
let d2End = j;
while (d2End < d1End && summaryList[d2End].d2 === d2) d2End++;
d2Ranges.push({ start: j, end: d2End, name: d2 });
j = d2End;
}
const d1D3Count = isAllFiltersOff
? new Set(summaryList.slice(i, d1End).map((row) => `${row.d2}|||${row.d3}`)).size
: 0;
const d1D3GroupCountWhenFiltered = showD2D3SubtotalsWhenD1Filtered
? new Set(summaryList.slice(i, d1End).map((row) => `${row.d2}|||${row.d3}`)).size
: 0;
const d1D3GroupCountWhenD2Filtered = showD3SubtotalsWhenD2Filtered
? new Set(summaryList.slice(i, d1End).map((row) => `${row.d2}|||${row.d3}`)).size
: 0;
const d1SpanTotal = isAllFiltersOff
? (d1D3Count + d2Ranges.length)
: (showD2D3SubtotalsWhenD1Filtered
? ((d1End - i) + d1D3GroupCountWhenFiltered + d2Ranges.length)
: (showD3SubtotalsWhenD2Filtered
? ((d1End - i) + d1D3GroupCountWhenD2Filtered)
: (d1End - i)));
let d1Placed = false;
d2Ranges.forEach((range) => {
const j = range.start;
const d2End = range.end;
const d2 = range.name;
const d3SummaryRows = [];
for (let k = j; k < d2End;) {
const d3 = summaryList[k].d3;
let d3End = k;
while (d3End < d2End && summaryList[d3End].d3 === d3) d3End++;
const d3Slice = summaryList.slice(k, d3End);
if (!isAllFiltersOff) {
const d2D3GroupCountWhenFiltered = showD2D3SubtotalsWhenD1Filtered
? new Set(summaryList.slice(j, d2End).map((row) => row.d3)).size
: 0;
const d2D3GroupCountWhenD2Filtered = showD3SubtotalsWhenD2Filtered
? new Set(summaryList.slice(j, d2End).map((row) => row.d3)).size
: 0;
const d2SpanTotal = showD2D3SubtotalsWhenD1Filtered
? ((d2End - j) + d2D3GroupCountWhenFiltered)
: (showD3SubtotalsWhenD2Filtered
? ((d2End - j) + d2D3GroupCountWhenD2Filtered)
: (d2End - j));
for (let r = k; r < d3End; r++) {
finalDisplayList.push({
...summaryList[r],
d1Span: !d1Placed ? d1SpanTotal : 0,
d2Span: r === j ? d2SpanTotal : 0,
d3Span: r === k ? (d3End - k) : 0
});
if (!d1Placed) d1Placed = true;
}
if (showD2D3SubtotalsWhenD1Filtered || showD3SubtotalsWhenD2Filtered) {
finalDisplayList.push(aggregateGroupSummary(d3Slice, `${d3} 소계`, 'd3'));
}
} else {
d3SummaryRows.push(buildD3SummaryRow(d3Slice, d1, d2, d3));
}
k = d3End;
}
if (isAllFiltersOff) {
d3SummaryRows.forEach((row, idxWithinD2) => {
finalDisplayList.push({
...row,
d1Span: !d1Placed && idxWithinD2 === 0 ? d1SpanTotal : 0,
d2Span: idxWithinD2 === 0 ? d3SummaryRows.length : 0,
d3Span: 1
});
if (!d1Placed && idxWithinD2 === 0) d1Placed = true;
});
}
if (isAllFiltersOff || showD2D3SubtotalsWhenD1Filtered) {
const d2Slice = summaryList.slice(j, d2End);
finalDisplayList.push(aggregateGroupSummary(d2Slice, `${d2} 소계`, 'd2'));
}
});
if (isAllFiltersOff) {
const d1Slice = summaryList.slice(i, d1End);
finalDisplayList.push(aggregateGroupSummary(d1Slice, `${d1} 합계`, 'd1'));
}
i = d1End;
}
const hierarchy = {};
Object.entries(projectToDepth).forEach(([pKey, info]) => {
if (!hierarchy[info.d1]) hierarchy[info.d1] = {};
if (!hierarchy[info.d1][info.d2]) hierarchy[info.d1][info.d2] = {};
if (!hierarchy[info.d1][info.d2][info.d3]) hierarchy[info.d1][info.d2][info.d3] = new Set();
hierarchy[info.d1][info.d2][info.d3].add(projectDisplayByKey[pKey] || pKey);
});
const totalExp = summaryList.reduce((s, c) => s + c.total, 0);
const expenseDetailByCategory = { '출장비': [], '복리후생비': [], '구매비': [], '외주비': [] };
baseFilteredExp.forEach((e) => {
const div = norm(getVal(e, ['구분'], 26));
const amount = pnum(getVal(e, ['지출'], 23));
if (!amount) return;
let category = '구매비';
if (div === '출장비') category = '출장비';
else if (div === '복리후생비') category = '복리후생비';
else if (div === '외주비' || div === '외주') category = '외주비';
else if (div === '제외') return;
const issueDate = getExpenseDate(e);
expenseDetailByCategory[category].push({
issueMonth: toIssueMonth(issueDate),
issueDate,
projectName: getExpenseProjectName(e) || '미지정 프로젝트',
summary: norm(getVal(e, ['적요', '전표적요', '전표 적요', '내용', '비고'], 20)),
vendor: norm(getVal(e, ['거래처', '거래처명', '거래처(상호)', '업체명', '상호', '공급처'], 19)),
amount
});
});
Object.keys(expenseDetailByCategory).forEach((key) => {
expenseDetailByCategory[key].sort((a, b) => String(b.issueDate).localeCompare(String(a.issueDate)));
});
const categoryData = costCategories.map(cat => {
const val = summaryList.reduce((acc, cur) => acc + (cur.costBreakdown[cat.name] || 0), 0);
return { name: cat.name, value: val, ratio: totalExp > 0 ? ((val / totalExp) * 100).toFixed(1) : 0 };
}).filter(d => d.value > 0);
const kpisAll = {
income: baseAllExp.reduce((s, c) => s + pnum(getVal(c, ['수입'], 24)), 0),
labor: baseAllWork.reduce((s, c) => s + pnum(c.labor || getVal(c, ['산정금액', '인건비'])), 0),
hours: baseAllWork.reduce((s, c) => s + pnum(c.hours || getVal(c, ['시간', '근무시간'])), 0),
workers: new Set(baseAllWork.map(w => norm(getVal(w, ['이름'])) || norm(w.workerName)).filter(Boolean)).size,
travel: baseAllExp.filter(e => norm(getVal(e, ['구분'], 26)) === '출장비').reduce((s, c) => s + pnum(getVal(c, ['지출'], 23)), 0),
welfare: baseAllExp.filter(e => norm(getVal(e, ['구분'], 26)) === '복리후생비').reduce((s, c) => s + pnum(getVal(c, ['지출'], 23)), 0),
others: baseAllExp.filter(e => !['출장비', '복리후생비', '제외'].includes(norm(getVal(e, ['구분'], 26)))).reduce((s, c) => s + pnum(getVal(c, ['지출'], 23)), 0)
};
return {
kpis: {
income: summaryList.reduce((s, c) => s + c.income, 0),
labor: summaryList.reduce((s, c) => s + c.labor, 0),
hours: summaryList.reduce((s, c) => s + c.hrs, 0),
workers: new Set(baseFilteredWork.map(w => norm(getVal(w, ['이름'])) || norm(w.workerName)).filter(Boolean)).size,
travel: baseFilteredExp.filter(e => norm(getVal(e, ['구분'], 26)) === '출장비').reduce((s, c) => s + pnum(getVal(c, ['지출'], 23)), 0),
welfare: baseFilteredExp.filter(e => norm(getVal(e, ['구분'], 26)) === '복리후생비').reduce((s, c) => s + pnum(getVal(c, ['지출'], 23)), 0),
others: baseFilteredExp.filter(e => !['출장비', '복리후생비', '제외'].includes(norm(getVal(e, ['구분'], 26)))).reduce((s, c) => s + pnum(getVal(c, ['지출'], 23)), 0)
},
kpisAll,
finalDisplayList, positionAnalysis, positionGroupMode, hierarchy, categoryData, expenseDetailByCategory, projectActivityList, isAllFiltersOff
};
}, [expenseRaw, workRaw, dataLoaded, selectedRev, selectedD1, selectedD2, selectedProject, projectSearch, startDate, endDate, customProjectOrderMap]);
useEffect(() => {
const allFiltersOff = processedData ? processedData.isAllFiltersOff : false;
if (allFiltersOff && selectedExpenseDetailCategory) {
setSelectedExpenseDetailCategory('');
}
}, [processedData, selectedExpenseDetailCategory]);
const handleD1Click = (d1) => {
if (selectedRev === d1 && selectedD1 === '전체' && selectedD2 === '전체' && selectedProject === '전체') {
setSelectedRev('전체');
setSelectedD1('전체');
setSelectedD2('전체');
setSelectedProject('전체');
setProjectSearch('');
return;
}
setSelectedRev(d1);
setSelectedD1('전체');
setSelectedD2('전체');
setSelectedProject('전체');
setProjectSearch('');
};
const handleD2Click = (d1, d2) => {
setSelectedRev(d1);
setSelectedD1(d2);
setSelectedD2('전체');
setSelectedProject('전체');
setProjectSearch('');
};
const handleD3Click = (d1, d2, d3) => {
setSelectedRev(d1);
setSelectedD1(d2);
setSelectedD2(d3);
setSelectedProject('전체');
setProjectSearch('');
};
const handleD4Click = (d1, d2, d3, d4) => {
setSelectedRev(d1);
setSelectedD1(d2);
setSelectedD2(d3);
setSelectedProject(d4);
setProjectSearch(d4);
};
const viewData = processedData || {
kpis: { income: 0, labor: 0, hours: 0, workers: 0, travel: 0, welfare: 0, others: 0 },
kpisAll: { income: 0, labor: 0, hours: 0, workers: 0, travel: 0, welfare: 0, others: 0 },
finalDisplayList: [],
positionAnalysis: {},
projectActivityList: [],
positionGroupMode: 'D1',
hierarchy: {},
categoryData: [],
expenseDetailByCategory: { '출장비': [], '복리후생비': [], '구매비': [], '외주비': [] },
isAllFiltersOff: false
};
const isAllFiltersApplied = selectedRev !== '전체' && selectedD1 !== '전체' && selectedD2 !== '전체' && selectedProject !== '전체';
return (
<div className="payment-theme min-h-screen p-6 font-sans">
<div className="w-full mx-auto space-y-6" style={{ maxWidth: '2000px' }}>
{!isAllFiltersApplied && (
<>
{/* KPIs */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 sticky top-0 z-30 payment-kpi-grid pb-3">
{[
{ label: '총 수입(매출)', value: formatWonRounded(viewData.kpis.income), totalValue: formatWonRounded(viewData.kpisAll.income), icon: Wallet, color: 'payment-kpi-income' },
{ label: '인건비 합계', value: formatWonRounded(viewData.kpis.labor), totalValue: formatWonRounded(viewData.kpisAll.labor), icon: Briefcase, color: 'payment-kpi-labor' },
{ label: '출장비', value: formatWonRounded(viewData.kpis.travel), totalValue: formatWonRounded(viewData.kpisAll.travel), icon: MapPin, color: 'payment-kpi-travel' },
{ label: '복리후생비', value: formatWonRounded(viewData.kpis.welfare), totalValue: formatWonRounded(viewData.kpisAll.welfare), icon: Coffee, color: 'payment-kpi-welfare' },
{ label: '구매/외주비', value: formatWonRounded(viewData.kpis.others), totalValue: formatWonRounded(viewData.kpisAll.others), icon: Package, color: 'payment-kpi-others' },
{ label: '투입시간', value: `${viewData.kpis.hours.toLocaleString()}h`, totalValue: `${viewData.kpisAll.hours.toLocaleString()}h`, icon: Clock, color: 'payment-kpi-hours' },
{ label: '참여인원', value: `${viewData.kpis.workers}`, totalValue: `${viewData.kpisAll.workers}`, icon: Users, color: 'payment-kpi-inverse', bg: 'payment-kpi-people' },
].map((kpi, i) => (
<div key={i} className={`payment-kpi-card ${kpi.bg || ''} ${kpi.color} p-4 rounded-[22px] flex flex-col h-24`}>
<span className="text-[11px] font-black uppercase opacity-60 flex justify-between">{kpi.label} <kpi.icon size={10}/></span>
<div className="flex flex-col leading-tight mt-1 gap-1">
<span className="text-lg font-black truncate">{kpi.value}</span>
<span className="text-[14px] font-black opacity-70 truncate text-right">전체 {kpi.totalValue}</span>
</div>
</div>
))}
</div>
</>
)}
{/* 상세 분석 테이블 */}
<section className="payment-panel payment-table-panel rounded-[35px] overflow-visible">
<div className={`payment-panel-head px-6 py-4 flex items-center justify-between gap-4 sticky ${!isAllFiltersApplied ? 'top-[108px]' : 'top-0'} z-40 backdrop-blur-sm`}>
<h2 className="text-lg font-black flex items-center gap-3"><List size={20} className="payment-icon-accent" /> 분야별 프로젝트 상세 분석</h2>
<div className="group relative shrink-0">
<button type="button" className="payment-filter-toggle px-3 py-2 rounded-xl text-[12px] font-black tracking-wide shadow-sm border">
카테고리 필터
</button>
<div className="payment-filter-pop absolute right-0 top-full mt-2 z-30 w-[min(980px,92vw)] rounded-2xl p-3 shadow-2xl opacity-0 pointer-events-none translate-y-1 transition-all duration-200 group-hover:opacity-100 group-hover:pointer-events-auto group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0">
<div className="flex items-center gap-2">
<div className="payment-filter-bar flex gap-2 p-1.5 rounded-2xl flex-1 min-w-[420px]">
<select value={selectedRev} onChange={e => {setSelectedRev(e.target.value); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체');}} className="filter-select flex-1">
<option value="전체">대분류 전체</option>
{Object.keys(viewData.hierarchy)
.filter(d => d && d !== '미분류')
.sort((a,b)=>(d1Order[a]||99)-(d1Order[b]||99) || a.localeCompare(b))
.map(d => <option key={d} value={d}>{d}</option>)}
</select>
<select value={selectedD1} onChange={e => {setSelectedD1(e.target.value); setSelectedD2('전체'); setSelectedProject('전체');}} className="filter-select flex-1">
<option value="전체">중분류 전체</option>
{selectedRev !== '전체' && viewData.hierarchy[selectedRev] && Object.keys(viewData.hierarchy[selectedRev])
.filter(d => d && d !== '미분류')
.sort((a,b)=>getD2Order(a)-getD2Order(b) || a.localeCompare(b))
.map(d => <option key={d} value={d}>{d}</option>)}
</select>
<select value={selectedD2} onChange={e => {setSelectedD2(e.target.value); setSelectedProject('전체');}} className="filter-select flex-1">
<option value="전체">소분류 전체</option>
{selectedRev !== '전체' && selectedD1 !== '전체' && viewData.hierarchy[selectedRev]?.[selectedD1] && Object.keys(viewData.hierarchy[selectedRev][selectedD1])
.filter(d => d && d !== '미분류')
.sort((a, b) => getD3Order(a, selectedD1) - getD3Order(b, selectedD1) || a.localeCompare(b))
.map(d => <option key={d} value={d}>{d}</option>)}
</select>
<select value={selectedProject} onChange={e => { const v = e.target.value; setSelectedProject(v); setProjectSearch(v === '전체' ? '' : v); }} className="filter-select flex-[1.1]">
<option value="전체">프로젝트명 전체</option>
{selectedRev !== '전체' && selectedD1 !== '전체' && selectedD2 !== '전체' && viewData.hierarchy[selectedRev]?.[selectedD1]?.[selectedD2] && Array.from(viewData.hierarchy[selectedRev][selectedD1][selectedD2])
.filter(p => p && p !== '미분류')
.sort((a, b) => getProjectOrder(a) - getProjectOrder(b) || a.localeCompare(b))
.map(p => <option key={p} value={p}>{p}</option>)}
</select>
<input
type="text"
value={projectSearch}
onChange={e => setProjectSearch(e.target.value)}
placeholder="프로젝트명 검색"
className="filter-select flex-[1.1]"
/>
</div>
<button onClick={() => {setSelectedRev('전체'); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체'); setProjectSearch('');}} className="payment-reset-btn p-1.5 rounded-xl transition-all shadow-sm shrink-0"><RefreshCw size={14}/></button>
</div>
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse table-fixed">
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '6%' }} />
<col style={{ width: '6%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '23%' }} />
<col style={{ width: '26%' }} />
</colgroup>
<thead className="payment-table-head">
<tr className="text-[12px] font-extrabold uppercase tracking-widest payment-table-head-row">
<th className="px-4 py-3 whitespace-nowrap">대분류</th>
<th className="px-4 py-3 whitespace-nowrap">중분류</th>
<th className="px-4 py-3 whitespace-nowrap">소분류</th>
<th className="px-4 py-3 whitespace-nowrap">프로젝트명</th>
<th className="px-4 py-3 text-right whitespace-nowrap">수입(매출)</th>
<th className="px-4 py-3 text-right whitespace-nowrap">지출 합계</th>
<th className="px-4 py-3 whitespace-nowrap text-center">
<div className="text-[11px] font-black payment-subhead mb-1 text-center">지출 구성비</div>
<div className="grid grid-cols-5 text-[10px] font-black payment-subhead normal-case tracking-normal">
<span className="py-1 text-center">인건비</span>
<span className="py-1 text-center">출장비</span>
<span className="py-1 text-center">복리후생비</span>
<span className="py-1 text-center">구매비</span>
<span className="py-1 text-center">외주비</span>
</div>
</th>
<th className="px-4 py-3 whitespace-nowrap">직급별 인원투입/상세인원</th>
</tr>
</thead>
<tbody className="text-[13px] font-bold">
{viewData.finalDisplayList.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-12 text-center payment-empty font-bold">표시할 데이터가 없습니다.</td>
</tr>
)}
{viewData.finalDisplayList.map((item, idx) => {
if (item.isSubtotal) {
const isGrandTotal = item.subtotalLevel === 'd1';
return (
<tr
key={`subtotal-${idx}`}
className={`h-12 border-y ${isGrandTotal ? 'payment-subtotal payment-subtotal-grand shadow-[inset_0_1px_0_rgba(33,70,52,0.18)]' : 'payment-subtotal payment-subtotal-mid'}`}
>
<td colSpan={item.labelColSpan || 4} className={`px-4 py-3 whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-label-grand text-[14px] font-extrabold' : 'payment-subtotal-label-mid font-black'}`}>
{item.subtotalLabel}
</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-income-grand text-[14px]' : 'payment-subtotal-income-mid'}`}>{formatWonDash(item.income)}</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-total-grand text-[14px]' : 'payment-subtotal-total-mid'}`}>{formatWonRoundedDash(item.total)}</td>
<td className="px-4 py-3">{renderCostBreakdownTable(item.costBreakdown)}</td>
<td className="px-4 py-3">
<div className={`h-2.5 rounded-full overflow-hidden flex shadow-inner ${isGrandTotal ? 'payment-progress-track-grand' : 'payment-progress-track-mid'}`}>
{Object.entries(item.positionBreakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, val]) => {
const ratio = (val / (item.hrs || 1)) * 100;
if (ratio < 0.1) return null;
return <div key={pos} className="h-full" style={{ width: `${ratio}%`, backgroundColor: getPositionColor(pos) }}></div>;
})}
</div>
{renderPositionBreakdownInline(item.positionBreakdown, item.positionDetails, item.hrs, item.workers)}
</td>
</tr>
);
}
return (
<tr key={`row-${idx}`} className="payment-data-row h-12 transition-all border-b group">
{item.d1Span > 0 && (
<td
rowSpan={item.d1Span}
onClick={() => handleD1Click(item.d1)}
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
>
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d1}</span>
</td>
)}
{item.d2Span > 0 && (
<td
rowSpan={item.d2Span}
onClick={() => handleD2Click(item.d1, item.d2)}
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
>
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d2}</span>
</td>
)}
{item.d3Span > 0 && (
<td
rowSpan={item.d3Span}
onClick={() => handleD3Click(item.d1, item.d2, item.d3)}
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 && selectedD2 === item.d3 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
>
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d3}</span>
</td>
)}
<td
onClick={() => { handleD4Click(item.d1, item.d2, item.d3, item.name); }}
className="px-4 py-3 payment-project-cell font-extrabold truncate cursor-pointer transition-colors"
>
{item.name}
</td>
<td className="px-4 py-3 text-right payment-income font-extrabold whitespace-nowrap">{formatWonDash(item.income)}</td>
<td className="px-4 py-3 text-right payment-expense font-extrabold whitespace-nowrap">{formatWonRoundedDash(item.total)}</td>
<td className="px-4 py-3">{renderCostBreakdownTable(item.costBreakdown)}</td>
<td className="px-4 py-3">
<div className="h-2.5 payment-progress-track rounded-full overflow-hidden flex shadow-inner">
{Object.entries(item.positionBreakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, val]) => {
const ratio = (val / (item.hrs || 1)) * 100;
if (ratio < 0.1) return null;
return <div key={pos} className="h-full" style={{ width: `${ratio}%`, backgroundColor: getPositionColor(pos) }}></div>;
})}
</div>
{renderPositionBreakdownInline(item.positionBreakdown, item.positionDetails, item.hrs, item.workers)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
{/* 하단 상세 차트 */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 pb-12">
<div className="lg:col-span-5 payment-panel p-8 rounded-[40px] min-h-[480px] flex flex-col">
<h3 className="text-lg font-black mb-4 flex items-center gap-3"><Target className="payment-icon-accent"/> 지출 구성 상세</h3>
<div className="flex-1">
{viewData.categoryData.length > 0 ? (
<div className="h-full flex flex-col gap-5">
<div className="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-5 items-center">
<div className="flex justify-center md:justify-start">
<div
className="relative h-56 w-56 rounded-full"
style={{ background: buildDonutGradient(viewData.categoryData) }}
>
<div className="absolute inset-11 payment-donut-center rounded-full flex flex-col items-center justify-center">
<span className="text-[12px] font-black payment-subhead"> 지출</span>
<span className="text-[15px] font-black payment-strong">
{formatWon(viewData.categoryData.reduce((sum, item) => sum + (item.value || 0), 0))}
</span>
</div>
</div>
</div>
<div className="w-full grid grid-cols-1 gap-y-2">
{viewData.categoryData.map((item) => {
const isSelectable = item.name !== '인건비';
const isSelected = selectedExpenseDetailCategory === item.name;
return (
<button
key={item.name}
type="button"
onClick={() => {
if (!isSelectable) return;
setSelectedExpenseDetailCategory((prev) => (prev === item.name ? '' : item.name));
}}
className={`payment-cost-row flex items-center justify-between gap-2 text-[13px] font-bold text-left rounded-lg px-2 py-1.5 transition-colors ${isSelectable ? 'cursor-pointer' : 'cursor-default'} ${isSelected ? 'payment-cost-row-active' : ''}`}
>
<span className="flex items-center gap-2 payment-muted truncate">
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: getCostColor(item.name) }}></span>
{item.name} ({item.ratio}%)
</span>
<span className="payment-strong">{formatWon(item.value)}</span>
</button>
);
})}
</div>
</div>
{viewData.isAllFiltersOff && (
<div className="w-full mt-4 text-[12px] payment-empty font-bold text-center">
상세 내역은 필터 적용 표시됩니다.
</div>
)}
{!viewData.isAllFiltersOff && selectedExpenseDetailCategory && selectedExpenseDetailCategory !== '인건비' && (
<div className="w-full mt-5 pt-4 payment-divider-top">
<div className="text-[12px] font-black payment-subhead mb-2">
{selectedExpenseDetailCategory} 지출 구성 상세 내역
</div>
{(viewData.expenseDetailByCategory?.[selectedExpenseDetailCategory] || []).length > 0 ? (
<div className="max-h-56 overflow-y-auto rounded-lg payment-mini-table-shell custom-scrollbar">
<table className="w-full text-[12px] table-fixed border-collapse">
<thead className="payment-mini-table-head font-black">
<tr>
<th className="px-2 py-2 text-left w-[74px]">발행월</th>
<th className="px-2 py-2 text-left w-[88px]">발행일</th>
<th className="px-2 py-2 text-left">적요</th>
<th className="px-2 py-2 text-left w-[160px]">거래처</th>
<th className="px-2 py-2 text-right w-[90px]">금액</th>
</tr>
</thead>
<tbody>
{(viewData.expenseDetailByCategory[selectedExpenseDetailCategory] || []).map((row, idx) => (
<tr key={`${selectedExpenseDetailCategory}-${idx}`} className="payment-mini-table-row">
<td className="px-2 py-1.5 whitespace-nowrap">{row.issueMonth || '-'}</td>
<td className="px-2 py-1.5 whitespace-nowrap">{row.issueDate || '-'}</td>
<td className="px-2 py-1.5 truncate">{row.summary || '-'}</td>
<td className="px-2 py-1.5 truncate">{row.vendor || '-'}</td>
<td className="px-2 py-1.5 text-right whitespace-nowrap">{formatWonRounded(row.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-[12px] payment-empty font-bold">표시할 전표 데이터가 없습니다.</div>
)}
</div>
)}
</div>
) : (
<div className="h-full flex items-center justify-center payment-empty text-sm font-bold">표시할 지출 데이터가 없습니다.</div>
)}
</div>
</div>
<div className="lg:col-span-7 payment-panel p-8 rounded-[40px] flex flex-col h-[560px] overflow-hidden">
<h3 className="text-lg font-black mb-4 flex items-center gap-3 shrink-0">
<UserCheck className="payment-icon-accent"/> 직급별 인원 투입 상세
<span className="payment-mode-chip ml-1 text-[11px] font-black px-2 py-1 rounded-lg">
기준: {viewData.positionGroupMode}
</span>
</h3>
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
{Object.keys(viewData.positionAnalysis).length > 0 ? (
Object.entries(viewData.positionAnalysis)
.sort(([a], [b]) => {
if (viewData.positionGroupMode === 'D1') {
return ((d1Order[a] || 99) - (d1Order[b] || 99)) || a.localeCompare(b);
}
return a.localeCompare(b);
})
.map(([pName, positions]) => (
<div key={pName} className="mb-8 last:mb-0">
<div className="payment-group-title px-4 py-1.5 rounded-xl text-[12px] font-black mb-4 sticky top-0 z-10">{pName}</div>
<div className="grid grid-cols-1 gap-3">
{Object.entries(positions)
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, data]) => {
const style = getPositionStyle(pos);
return (
<div key={pos} className={`payment-position-card border ${style.border} rounded-[28px] p-5 flex items-center gap-6 transition-all`}>
<div className={`flex items-center gap-3 w-1/4 shrink-0 px-4 py-2 rounded-2xl ${style.bg} border ${style.border}`}>
<div className={`w-3 h-3 rounded-full ${style.icon} shadow-sm`}></div>
<div className={`text-[14px] font-black ${style.text}`}>{pos}</div>
</div>
<div className="flex-1 grid grid-cols-2 gap-8 payment-divider-left pl-8">
<div>
<div className="text-[11px] payment-empty font-black uppercase mb-1">Estimated Cost</div>
<div className="text-[16px] font-black payment-icon-accent font-mono">{Math.round(data.labor).toLocaleString()}</div>
</div>
<div>
<div className="text-[11px] payment-empty font-black uppercase mb-1">Hours & Count</div>
<div className="text-[16px] font-black payment-strong">{data.hrs.toFixed(2)}h <span className="payment-divider-mark mx-1">|</span> {data.names.size}</div>
</div>
</div>
<div className="w-1/3 min-w-[260px] payment-divider-left pl-4">
<div className="overflow-x-auto overflow-y-hidden custom-scrollbar">
<div className="grid grid-rows-2 grid-flow-col auto-cols-max gap-x-1.5 gap-y-1.5 min-w-max pb-1">
{Array.from(data.names).map(name => (
<span key={name} className="payment-name-chip px-2 py-0.5 rounded-lg text-[11px] font-bold whitespace-nowrap">{name}</span>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
))
) : (
<div className="h-full flex flex-col items-center justify-center payment-empty gap-3">
<Info size={40} />
<span className="text-sm font-bold">표시할 데이터가 없습니다.</span>
</div>
)}
</div>
</div>
</div>
<section className="payment-panel rounded-[35px] overflow-hidden">
<div className="payment-panel-head px-6 py-4 flex items-center justify-between gap-4">
<h3 className="text-lg font-black flex items-center gap-3"><List size={18} className="payment-icon-accent" /> 프로젝트별 Activity 분석</h3>
</div>
<div className="p-6">
{viewData.projectActivityList.length > 0 ? (
<div className="space-y-4">
{viewData.projectActivityList.map((project) => (
<div key={`activity-${project.projectName}`} className="payment-activity-card border rounded-2xl overflow-hidden">
<div className="payment-activity-card-head px-4 py-3 flex items-center justify-between gap-3">
<div className="text-[14px] font-black payment-strong truncate">{project.projectName}</div>
<div className="text-[12px] font-black payment-icon-accent whitespace-nowrap"> {formatHours(project.totalHours)}h · {project.workerCount}</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse table-fixed">
<colgroup>
<col style={{ width: '180px' }} />
<col style={{ width: '110px' }} />
<col style={{ width: '90px' }} />
<col style={{ width: 'auto' }} />
</colgroup>
<thead className="payment-mini-table-head border-b">
<tr className="text-[11px] font-black payment-subhead uppercase tracking-wide">
<th className="px-3 py-2 whitespace-nowrap">Activity</th>
<th className="px-3 py-2 text-right whitespace-nowrap">투입시간</th>
<th className="px-3 py-2 text-right whitespace-nowrap">투입인원</th>
<th className="px-3 py-2 whitespace-nowrap">투입자(시간)</th>
</tr>
</thead>
<tbody>
{project.activities.map((activity) => (
<tr key={`${project.projectName}-${activity.activityName}`} className="payment-mini-table-row last:border-b-0">
<td className="px-3 py-2 text-[12px] font-black payment-strong whitespace-nowrap truncate">{activity.activityName}</td>
<td className="px-3 py-2 text-[12px] font-black text-right payment-icon-accent whitespace-nowrap">{formatHours(activity.hours)}h</td>
<td className="px-3 py-2 text-[12px] font-black text-right payment-muted whitespace-nowrap">{activity.workerCount}</td>
<td className="px-3 py-2 text-[12px] payment-muted">
<div className="flex flex-wrap gap-1.5">
{activity.members.map((m) => (
<span key={`${activity.activityName}-${m.name}`} className="payment-name-chip px-2 py-0.5 rounded-lg text-[11px] font-bold whitespace-nowrap">
{m.name} ({formatHours(m.hours)}h)
</span>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
) : (
<div className="py-10 text-center payment-empty text-sm font-bold">표시할 Activity 데이터가 없습니다.</div>
)}
</div>
</section>
</div>
<style>{`
@import url('/design-tokens.css');
@import url('/design-patterns.css');
@import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap');
body { font-family: 'Pretendard', sans-serif; letter-spacing: -0.025em; -webkit-font-smoothing: antialiased; background-color: var(--ds-bg); color: var(--ds-ink); }
.payment-theme { color: var(--ds-ink); }
.payment-kpi-income, .payment-kpi-hours { color: var(--ds-brand-soft); }
.payment-kpi-labor, .payment-kpi-others { color: var(--ds-text-soft); }
.payment-kpi-travel { color: var(--ds-status-danger); }
.payment-kpi-welfare { color: var(--ds-status-warning); }
.payment-filter-pop { border: 1px solid var(--ds-line); background: rgba(255,250,243,0.98); }
.payment-subtotal { border-color: var(--ds-line); }
.payment-subtotal-grand { background: #efe2ca; }
.payment-subtotal-mid { background: #f6e6c9; }
.payment-subtotal-label-grand, .payment-subtotal-total-grand { color: var(--ds-brand-deep); }
.payment-subtotal-income-grand { color: var(--ds-brand-soft); }
.payment-subtotal-label-mid, .payment-subtotal-total-mid { color: #9a6422; }
.payment-subtotal-income-mid { color: #7b5a20; }
.payment-donut-center { background: rgba(255,250,243,0.98); border: 1px solid var(--ds-line-soft); }
.payment-cost-row:hover { background: rgba(234,220,196,0.34); }
.payment-cost-row-active { background: rgba(242,196,132,0.18); }
.payment-position-card { background: rgba(255,250,243,0.96); box-shadow: var(--ds-shadow-soft); }
.payment-activity-card { border-color: var(--ds-line-soft); }
.payment-activity-card-head { background: rgba(246,237,221,0.68); border-bottom: 1px solid var(--ds-line-soft); }
.filter-select {
background-color: transparent; border: none; padding: 0.35rem 1.6rem 0.35rem 0.5rem; font-size: 10px; font-weight: 800;
outline: none; appearance: none; cursor: pointer; transition: all 0.2s;
color: var(--ds-ink);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2366756d'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 0.4rem center; background-size: 0.6rem;
}
.filter-select:hover { color: var(--ds-brand-soft); background-color: rgba(255,255,255,0.98); border-radius: 8px; }
.custom-scrollbar::-webkit-scrollbar { width: 4px; height: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: var(--ds-line); border-radius: 10px; }
`}</style>
</div>
);
};
const mountNode = document.getElementById('root');
if (ReactDOM.createRoot) {
ReactDOM.createRoot(mountNode).render(<App />);
} else {
ReactDOM.render(<App />, mountNode);
}
</script>
</body>
</html>