Files
issue-sample/cost-pdf.html

1618 lines
93 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>(주)장헌 통합 원가 정산 시스템 v5.5</title>
<!-- React / ReactDOM -->
<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>
<!-- Babel -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- SheetJS -->
<script src="https://cdn.sheetjs.com/xlsx-0.20.1/package/dist/xlsx.full.min.js"></script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap');
html, body {
height: 100%;
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif;
background-color: #f1f5f9;
color: #334155;
}
.animate-fade-in { animation: fadeIn 0.2s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } }
.custom-scrollbar::-webkit-scrollbar { width: 5px; height: 5px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
.glass-nav {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-bottom: 1px solid #e2e8f0;
}
/* 대시보드 테이블 가독성 스타일 */
.dashboard-table th {
background-color: #f8fafc;
font-size: 12px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
}
/* 보고서 표 공통 규격(화면) */
.report-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
border: 1px solid #e2e8f0;
background-color: #fff;
}
.report-table th,
.report-table td {
border: 1px solid #e2e8f0;
padding: 10px 12px;
font-size: 13px;
line-height: 1.35;
vertical-align: middle;
}
.report-table th {
background-color: #f8fafc;
color: #475569;
font-weight: 800;
text-align: center;
}
/* 전문 보고서 인쇄 스타일 (A4 가로 너비 100% 최적화) */
@media print {
@page { size: A4; margin: 10mm; }
.no-print { display: none !important; }
body { background-color: white !important; padding: 0 !important; color: black !important; }
.print-container { padding: 0 !important; margin: 0 !important; width: 100% !important; max-width: none !important; }
.report-page { border: none !important; box-shadow: none !important; padding: 0 !important; width: 100% !important; }
.report-table {
width: 100% !important;
border-collapse: collapse !important;
table-layout: fixed !important;
border: 1px solid #000 !important;
margin-bottom: 1.5rem !important;
}
.report-table th, .report-table td {
border: 0.5px solid #000 !important;
padding: 8px 10px !important;
font-size: 9pt !important;
line-height: 1.2 !important;
word-break: break-all !important;
}
.report-table th { background-color: #f1f5f9 !important; font-weight: bold !important; text-align: center !important; }
.page-break { page-break-before: always; }
.summary-box {
display: table !important;
width: 100% !important;
border: 1px solid #000 !important;
border-collapse: collapse;
}
.summary-row { display: table-row !important; }
.summary-cell {
display: table-cell !important;
border: 1px solid #000 !important;
padding: 12px !important;
width: 50% !important;
}
.text-blue-600 { color: #1e40af !important; }
.text-emerald-700 { color: #065f46 !important; }
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel" data-type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, onAuthStateChanged, signInAnonymously, signInWithCustomToken } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, setDoc, getDoc, collection, onSnapshot } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
const { useState, useMemo, useEffect, useRef } = React;
// --- Firebase 및 설정 ---
let db, auth, appId;
try {
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : null;
appId = typeof __app_id !== 'undefined' ? __app_id : 'jangheon-v55';
if (firebaseConfig) {
const app = initializeApp(firebaseConfig);
auth = getAuth(app);
db = getFirestore(app);
}
} catch(e) { console.warn("Firebase not available."); }
const POOL_A_PROJECTS = ['총무 [26-관리-03]', '부서 공통 [26-관리-06]'];
const POOL_B_PROJECTS = ['관리[26-공통-01]', '생산[26-공통-02]', '인사 [26-관리-02]'];
const TEAM_RATIOS = {
'지급임차료': { '철근': 0.13, '제작': 0.52, '공무': 0.35 },
'전력비': { '철근': 0.60, '제작': 0.30, '공무': 0.10 },
// 지급임차료/전력비 외 계정 + 풀 인건비는 일반경비로 본다.
'일반경비': { '철근': 0.45, '제작': 0.30, '공무': 0.25 }
};
const NON_MANAGED_FORMS = ['가족사지원', '공통', '기타', '시설관리', '연구개발', '현장자재', '현장지원', '공통(거더)', '공통(데크,가로보)', '품질'];
const FORM_ALLOC_C_RULES = {
'가족사지원': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'],
'공통': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'],
'기타': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'],
'시설관리': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'],
'연구개발': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'],
'현장자재': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'],
'현장지원': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'],
'공통(거더)': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더'],
'공통(데크,가로보)': ['강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '가로보'],
'품질': ['노출거더', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '가로보']
};
const FACTORY_WORKER_FALLBACK = [
{ name: '강병흔', rate: 3065880, regularType: '정규직' }, { name: '곽병목', rate: 2313800, regularType: '정규직' },
{ name: '김경수', rate: 2674500, regularType: '계약직' }, { name: '김용정', rate: 2889740, regularType: '계약직' },
{ name: '김인식', rate: 3007160, regularType: '정규직' }, { name: '김종옥', rate: 2400520, regularType: '계약직' },
{ name: '민인기', rate: 2400520, regularType: '계약직' }, { name: '박혁수', rate: 2400520, regularType: '정규직' },
{ name: '석길원', rate: 2889750, regularType: '계약직' }, { name: '손순기', rate: 2674480, regularType: '계약직' },
{ name: '양시용', rate: 2868000, regularType: '계약직' }, { name: '원종명', rate: 2557100, regularType: '계약직' },
{ name: '윤승근', rate: 2615760, regularType: '계약직' }, { name: '이상진', rate: 2459220, regularType: '정규직' },
{ name: '이신영', rate: 2557060, regularType: '정규직' }, { name: '이은재', rate: 2615770, regularType: '계약직' },
{ name: '이호성', rate: 3281180, regularType: '정규직' }, { name: '임성학', rate: 2791910, regularType: '계약직' },
{ name: '장기홍', rate: 2557060, regularType: '정규직' }, { name: '장래철', rate: 2948460, regularType: '계약직' },
{ name: '장만순', rate: 2948460, regularType: '계약직' }, { name: '정승정', rate: 2948440, regularType: '정규직' },
{ name: '조성근', rate: 2889750, regularType: '계약직' }, { name: '조성태', rate: 2615770, regularType: '계약직' },
{ name: '최정희', rate: 2283100, regularType: '계약직' }, { name: '최천환', rate: 3007160, regularType: '계약직' },
{ name: '한덕현', rate: 3007170, regularType: '정규직' }
];
const Icon = ({ name, size = 16, className = "" }) => {
useEffect(() => { if (window.lucide) window.lucide.createIcons(); }, [name]);
return <i data-lucide={name} className={className} style={{width: size, height: size}}></i>;
};
const App = () => {
const [user, setUser] = useState(null);
const [activeTab, setActiveTab] = useState('analysis');
const [viewMode, setViewMode] = useState('project');
const [isLoading, setIsLoading] = useState(true);
const [showReport, setShowReport] = useState(false);
const [selectedDetail, setSelectedDetail] = useState(null);
const [detailTab, setDetailTab] = useState('account');
const [settingsTab, setSettingsTab] = useState('wage');
const [expenses, setExpenses] = useState([]);
const [laborRows, setLaborRows] = useState([]);
const [wageSettings, setWageSettings] = useState({});
const [factoryWorkers, setFactoryWorkers] = useState([]);
const [workerTeamFilter, setWorkerTeamFilter] = useState('ALL');
const [formVolumes, setFormVolumes] = useState({});
const [mgmtPoolAAccounts, setMgmtPoolAAccounts] = useState(['(복리)식대비', '(복리)회식비', '(복리)간식비']);
const [uploadedFiles, setUploadedFiles] = useState({ expense: null, labor: null, hr: null });
const [allocPoolA, setAllocPoolA] = useState(true);
const [allocPoolB, setAllocPoolB] = useState(true);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
useEffect(() => {
if (!auth) { setIsLoading(false); return; }
const initAuth = async () => {
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
await signInWithCustomToken(auth, __initial_auth_token);
} else { await signInAnonymously(auth); }
};
initAuth();
return onAuthStateChanged(auth, setUser);
}, []);
useEffect(() => {
if (!user || !db) { setIsLoading(false); return; }
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'settings', 'masterData');
return onSnapshot(docRef, (snap) => {
if (snap.exists()) {
const data = snap.data();
setExpenses(data.expenses || []);
setLaborRows(data.laborRows || []);
setWageSettings(data.wageSettings || {});
setFormVolumes(data.formVolumes || {});
setMgmtPoolAAccounts(data.mgmtPoolAAccounts || ['(복리)식대비', '(복리)회식비', '(복리)간식비']);
if (data.allocPoolA !== undefined) setAllocPoolA(data.allocPoolA);
if (data.allocPoolB !== undefined) setAllocPoolB(data.allocPoolB);
}
setIsLoading(false);
}, () => setIsLoading(false));
}, [user]);
const saveData = async (updates) => {
if (!user || !db) return;
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'settings', 'masterData');
await setDoc(docRef, { ...updates, updatedAt: new Date().toISOString() }, { merge: true });
};
const UPLOAD_CACHE_KEY = 'costPdfUploadedFilesV1';
const loadUploadCache = () => {
try {
const raw = localStorage.getItem(UPLOAD_CACHE_KEY);
if (!raw) return { expense: null, labor: null, hr: null };
const parsed = JSON.parse(raw);
return { expense: parsed.expense || null, labor: parsed.labor || null, hr: parsed.hr || null };
} catch (_) {
return { expense: null, labor: null, hr: null };
}
};
const saveUploadCache = (next) => {
try {
localStorage.setItem(UPLOAD_CACHE_KEY, JSON.stringify(next));
} catch (_) {
alert('파일 저장 공간이 부족해 업로드 원본 저장에 실패했습니다. 파일 크기를 확인해주세요.');
}
};
const dataUrlToBinary = (dataUrl = '') => {
const parts = String(dataUrl).split(',');
const b64 = parts.length > 1 ? parts[1] : parts[0];
return atob(b64 || '');
};
const normalizeTeamName = (teamRaw = '') => {
const t = String(teamRaw || '').trim();
if (!t) return '공통';
if (t.includes('관리')) return '관리팀';
if (t.includes('공무')) return '공무';
if (t.includes('제작')) return '제작';
if (t.includes('철근')) return '철근';
if (t.includes('공통')) return '공통';
if (t.includes('일용')) return '공통';
return '공통';
};
const formatTeamLabel = (teamName = '') => {
const t = String(teamName || '').trim();
if (!t) return '공통팀';
if (t.endsWith('팀')) return t;
return `${t}`;
};
const mapExpenseRows = (data) => data.flatMap((r, i) => {
const account = String(r['계정'] || r['소계정명'] || '미분류').trim();
const team = normalizeTeamName(String(r['팀'] || r['팀명'] || '기타').trim());
const projectName = String(r['교량명'] || r['사업명'] || r['사업코드'] || '미지정').trim();
const amount = utils.parseNum(r['공급가액'] || r['공급가'] || r['합계']);
const date = utils.parseDate(r['거래일'] || r['날짜']);
const desc = r['적요'] || '';
const form = String(r['형식'] || '미분류').trim();
return { id: `e-${Date.now()}-${i}`, date, account, team, projectName, amount, description: desc, form };
});
const mapLaborRows = (data) => data.flatMap((r, i) => {
const date = utils.parseDate(r['근무일'] || r['날짜']);
const worker = r['근무자명'] || r['성명'] || '';
const team = normalizeTeamName(String(r['근무팀'] || r['소속팀'] || '기타').trim());
const projectName = String(r['교량명'] || r['사업명'] || '미지정').trim();
const hours = utils.parseNum(r['근무시간'] || r['시간']);
const form = String(r['형식'] || '미분류').trim();
return { id: `l-${Date.now()}-${i}`, date, worker, team, projectName, hours, form };
});
const applyExpenseData = (rows) => {
const mapped = mapExpenseRows(rows || []);
setExpenses(mapped);
saveData({ expenses: mapped });
};
const applyLaborData = (rows) => {
const lData = mapLaborRows(rows || []);
setLaborRows(lData);
const newWages = { ...wageSettings };
[...new Set(lData.map(l => l.worker))].filter(Boolean).forEach(n => {
if (!newWages[n]) newWages[n] = { rate: 0, type: 'monthly' };
});
setWageSettings(newWages);
saveData({ laborRows: lData, wageSettings: newWages });
};
const restoreDataFromCache = (cache) => {
try {
if (cache.expense?.dataUrl) {
const wb = XLSX.read(dataUrlToBinary(cache.expense.dataUrl), { type: 'binary', cellDates: true });
const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]);
applyExpenseData(rows);
}
if (cache.labor?.dataUrl) {
const wb = XLSX.read(dataUrlToBinary(cache.labor.dataUrl), { type: 'binary', cellDates: true });
const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]);
applyLaborData(rows);
}
if (cache.hr?.dataUrl) {
const isHtml = /html/i.test(cache.hr.mime || '') || /\.(html?)$/i.test(cache.hr.name || '');
if (isHtml) {
loadFactoryDefaultsFromText(dataUrlToBinary(cache.hr.dataUrl));
} else {
const wb = XLSX.read(dataUrlToBinary(cache.hr.dataUrl), { type: 'binary', cellDates: true });
const html = XLSX.utils.sheet_to_html(wb.Sheets[wb.SheetNames[0]]);
loadFactoryDefaultsFromText(html);
}
}
} catch (e) {
console.warn('업로드 캐시 복원 실패:', e);
}
};
const cacheUploadedFile = (type, file) => {
const reader = new FileReader();
reader.onload = (evt) => {
const payload = {
name: file.name,
size: file.size,
mime: file.type || 'application/octet-stream',
dataUrl: evt.target.result,
savedAt: new Date().toISOString()
};
setUploadedFiles(prev => {
const next = { ...prev, [type]: payload };
saveUploadCache(next);
return next;
});
};
reader.readAsDataURL(file);
};
const downloadUploadedFile = (type) => {
const info = uploadedFiles[type];
if (!info || !info.dataUrl) return;
const a = document.createElement('a');
a.href = info.dataUrl;
a.download = info.name || `${type}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const fmtSavedAt = (iso) => {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
};
const parseFactoryWorkerHtml = (htmlText) => {
const doc = new DOMParser().parseFromString(String(htmlText || ''), 'text/html');
const rows = [...doc.querySelectorAll('tr')];
const teamMfg = new Set(['이호성', '이신영', '곽병목', '최정희', '장래철']);
const teamAdmin = new Set(['양시용', '원종명', '김용정', '조성태', '강병흔', '장기홍', '정승정']);
const resolveTeam = (name = '', regularType = '') => {
const n = String(name || '').trim();
const r = String(regularType || '').trim();
if (r.includes('일용')) return '공통';
if (teamMfg.has(n)) return '제작';
if (teamAdmin.has(n)) return '공무';
return '철근';
};
const toNum = (v) => {
const n = parseFloat(String(v || '').replace(/[^0-9.-]/g, ''));
return Number.isFinite(n) ? n : 0;
};
const workers = [];
rows.forEach(tr => {
const nameCell = tr.querySelector('td#Worker_name');
const payCell = tr.querySelector('td#BasicPay');
const bonusCell = tr.querySelector('td#Bonus');
const regularCell = tr.querySelector('td#regularname');
if (!nameCell || !payCell) return;
const name = String(nameCell.textContent || '').trim();
if (!name) return;
const basicPay = toNum(payCell.textContent);
const bonusPay = toNum(bonusCell ? bonusCell.textContent : 0);
const regularType = String(regularCell ? regularCell.textContent : '').trim();
const team = resolveTeam(name, regularType);
workers.push({ name, team, rate: basicPay + bonusPay });
});
const uniq = {};
workers.forEach(w => { if (!uniq[w.name]) uniq[w.name] = w; });
return Object.values(uniq);
};
const applyFactoryDefaults = (workers) => {
if (!workers || workers.length === 0) return;
setWageSettings(prev => {
const next = { ...prev };
workers.forEach(w => {
const prevType = (next[w.name] && next[w.name].type) ? next[w.name].type : 'monthly';
next[w.name] = { rate: w.rate || 0, type: prevType };
});
saveData({ wageSettings: next });
return next;
});
};
const loadFactoryDefaultsFromText = (text) => {
const parsed = parseFactoryWorkerHtml(text);
setFactoryWorkers(parsed);
applyFactoryDefaults(parsed);
};
useEffect(() => {
const cache = loadUploadCache();
setUploadedFiles(cache);
restoreDataFromCache(cache);
}, []);
useEffect(() => {
// 프로젝트 루트의 FactoryWorker.xls(HTML 형식)에서 기본 인원/단가를 로드
fetch('./FactoryWorker.xls')
.then(r => r.text())
.then(loadFactoryDefaultsFromText)
.catch(() => {
const teamMfg = new Set(['이호성', '이신영', '곽병목', '최정희', '장래철']);
const teamAdmin = new Set(['양시용', '원종명', '김용정', '조성태', '강병흔', '장기홍', '정승정']);
const fallbackWorkers = FACTORY_WORKER_FALLBACK.map(w => ({
name: w.name,
rate: w.rate,
team: String(w.regularType || '').includes('일용')
? '공통'
: teamMfg.has(w.name) ? '제작'
: teamAdmin.has(name) ? '공무'
: '철근'
}));
setFactoryWorkers(fallbackWorkers);
applyFactoryDefaults(fallbackWorkers);
});
}, []);
const utils = {
formatWon: (v) => `${Math.round(v || 0).toLocaleString()}`,
formatHr: (v) => `${Number(v || 0).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}`,
parseNum: (v) => { const n = parseFloat(String(v).replace(/,/g, '')); return isNaN(n) ? 0 : n; },
localYmd: (d) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
},
parseDate: (val) => {
if (!val) return '';
if (val instanceof Date) return utils.localYmd(val);
if (typeof val === 'number') {
const d = new Date(Math.round((val - 25569) * 86400 * 1000));
return utils.localYmd(d);
}
return String(val).trim().substring(0, 10);
},
monthSpan: (startYm, endYm) => {
const s = String(startYm || '');
const e = String(endYm || '');
if (!s && !e) return 1;
if (s && !e) return 1;
if (!s && e) return 1;
const [sy, sm] = s.split('-').map(Number);
const [ey, em] = e.split('-').map(Number);
if (!sy || !sm || !ey || !em) return 1;
const diff = (ey - sy) * 12 + (em - sm) + 1;
return diff > 0 ? diff : 1;
}
};
const normalizeFormKey = (name) => String(name || '').replace(/\s+/g, '').trim().toLowerCase();
const normalizeProjectKey = (name) => {
const raw = String(name || '').trim();
const bracket = raw.match(/\[([^\]]+)\]/);
const base = (bracket?.[1] || raw).replace(/\s+/g, '').trim();
const alias = {
'관리': '26-공통-01',
'생산': '26-공통-02',
'인사': '26-관리-02',
'총무': '26-관리-03',
'부서공통': '26-관리-06'
};
return String(alias[base] || base).toLowerCase();
};
const toMonthKey = (dateStr) => String(dateStr || '').slice(0, 7);
const inMonthRange = (dateStr) => {
const mk = toMonthKey(dateStr);
if (!mk) return false;
return (!startDate || mk >= startDate) && (!endDate || mk <= endDate);
};
const isInvalidProjectName = (name) => {
const n = String(name ?? '').trim();
return !n || ['미분류', '미지정', 'null', 'NULL', 'Null'].includes(n);
};
const onUpload = (e, type) => {
const file = e.target.files[0];
if (!file) return;
cacheUploadedFile(type, file);
if (type === 'hr') {
const reader = new FileReader();
reader.onload = (evt) => {
loadFactoryDefaultsFromText(evt.target.result);
};
reader.readAsText(file);
return;
}
const reader = new FileReader();
reader.onload = (evt) => {
const wb = XLSX.read(evt.target.result, { type: 'binary', cellDates: true });
const data = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]);
if (type === 'expense') {
applyExpenseData(data);
} else {
applyLaborData(data);
}
};
reader.readAsBinaryString(file);
};
const calculateSettlement = (mode) => {
const isB = (v) => POOL_B_PROJECTS.some(p => normalizeProjectKey(p) === normalizeProjectKey(v));
const isA = (v) => POOL_A_PROJECTS.some(p => normalizeProjectKey(p) === normalizeProjectKey(v));
const fEx = expenses.filter(i => inMonthRange(i.date));
const fLb = laborRows.filter(i => inMonthRange(i.date));
const monthFactor = utils.monthSpan(startDate, endDate);
const workerTotalHours = {};
fLb.forEach(r => { workerTotalHours[r.worker] = (workerTotalHours[r.worker] || 0) + r.hours; });
const laborWithCost = fLb.map(r => {
const cfg = wageSettings[r.worker] || { rate: 0, type: 'monthly' };
let cost = 0;
if (cfg.type === 'hourly') {
cost = r.hours * cfg.rate;
} else {
const monthlyTotal = (cfg.rate || 0) * monthFactor;
cost = workerTotalHours[r.worker] > 0 ? (r.hours / workerTotalHours[r.worker]) * monthlyTotal : 0;
}
return { ...r, cost };
});
let poolAVal = 0; let poolBVal = 0; let revenueHrs = 0;
const projectMap = {};
fEx.forEach(e => {
if (isB(e.projectName)) poolBVal += e.amount;
else if (isA(e.projectName)) poolAVal += e.amount;
else {
if (!projectMap[e.projectName]) projectMap[e.projectName] = { name: e.projectName, direct: 0, hours: 0, byAccount: {}, byTeam: {}, byForm: {} };
projectMap[e.projectName].direct += e.amount;
projectMap[e.projectName].byAccount[e.account] = (projectMap[e.projectName].byAccount[e.account] || 0) + e.amount;
const nTeam = normalizeTeamName(e.team);
projectMap[e.projectName].byTeam[nTeam] = (projectMap[e.projectName].byTeam[nTeam] || 0) + e.amount;
const f = e.form || '미분류';
projectMap[e.projectName].byForm[f] = (projectMap[e.projectName].byForm[f] || 0) + e.amount;
}
});
laborWithCost.forEach(l => {
if (isB(l.projectName)) poolBVal += l.cost;
else if (isA(l.projectName)) poolAVal += l.cost;
else {
if (!projectMap[l.projectName]) projectMap[l.projectName] = { name: l.projectName, direct: 0, hours: 0, byAccount: {}, byTeam: {}, byForm: {} };
projectMap[l.projectName].direct += l.cost;
projectMap[l.projectName].hours += l.hours;
projectMap[l.projectName].byAccount['인건비(직접)'] = (projectMap[l.projectName].byAccount['인건비(직접)'] || 0) + l.cost;
const nTeam = normalizeTeamName(l.team);
projectMap[l.projectName].byTeam[nTeam] = (projectMap[l.projectName].byTeam[nTeam] || 0) + l.cost;
const f = l.form || '미분류';
projectMap[l.projectName].byForm[f] = (projectMap[l.projectName].byForm[f] || 0) + l.cost;
revenueHrs += l.hours;
}
});
const settledProjects = Object.values(projectMap).map(p => {
const allocA = (allocPoolA && revenueHrs > 0) ? (p.hours / revenueHrs) * poolAVal : 0;
const allocB = (allocPoolB && revenueHrs > 0) ? (p.hours / revenueHrs) * poolBVal : 0;
return { ...p, allocA, allocB, final: p.direct + allocA + allocB };
});
const customSort = (a, b) => {
if (isInvalidProjectName(a.name) && !isInvalidProjectName(b.name)) return 1;
if (!isInvalidProjectName(a.name) && isInvalidProjectName(b.name)) return -1;
return b.final - a.final;
};
if (mode === 'project') {
return settledProjects
.map(p => ({
...p,
allocMeta: { poolAVal, poolBVal, revenueHrs },
allocTrace: [{
projectName: p.name,
directShare: p.direct,
ratio: p.direct > 0 ? 1 : 0,
projectHours: p.hours,
shareHours: p.hours,
allocA: p.allocA,
allocB: p.allocB
}]
}))
.sort(customSort);
}
if (mode === 'type') {
const formMap = {};
settledProjects.forEach(p => {
Object.entries(p.byForm).forEach(([fName, val]) => {
if (!formMap[fName]) formMap[fName] = { name: fName, direct: 0, allocA: 0, allocB: 0, final: 0, hours: 0, breakdown: {}, allocTrace: [] };
formMap[fName].direct += val;
const ratio = p.direct > 0 ? val / p.direct : 0;
const shareHours = p.hours * ratio;
const shareAllocA = p.allocA * ratio;
const shareAllocB = p.allocB * ratio;
formMap[fName].allocA += shareAllocA;
formMap[fName].allocB += shareAllocB;
formMap[fName].hours += shareHours;
formMap[fName].breakdown[p.name] = (formMap[fName].breakdown[p.name] || 0) + val;
formMap[fName].allocTrace.push({
projectName: p.name,
directShare: val,
ratio,
projectHours: p.hours,
shareHours,
allocA: shareAllocA,
allocB: shareAllocB
});
});
});
const typeRows = Object.values(formMap).map(t => ({
...t,
allocC: 0,
allocCIn: 0,
allocCOut: 0,
allocCTraceIn: [],
allocCTraceOut: [],
baseFinal: t.direct + t.allocA + t.allocB
}));
const byKey = {};
typeRows.forEach(r => { byKey[normalizeFormKey(r.name)] = r; });
Object.entries(FORM_ALLOC_C_RULES).forEach(([sourceName, targetNames]) => {
const src = byKey[normalizeFormKey(sourceName)];
if (!src) return;
const amount = src.baseFinal || 0;
if (amount === 0) return;
const targets = (targetNames || [])
.map(name => byKey[normalizeFormKey(name)])
.filter(Boolean);
if (!targets.length) return;
const share = amount / targets.length;
src.allocC -= amount;
src.allocCOut += amount;
src.allocCTraceOut.push({ source: sourceName, amount, targetCount: targets.length, perTarget: share });
targets.forEach(tg => {
tg.allocC += share;
tg.allocCIn += share;
tg.allocCTraceIn.push({ source: sourceName, amount: share });
});
});
return typeRows.map(t => {
const finalVal = t.baseFinal + t.allocC;
const volInfo = formVolumes[t.name] || { value: 0, unit: 'ton' };
return {
...t,
final: finalVal,
volInfo,
unitCost: volInfo.value > 0 ? finalVal / volInfo.value : 0,
allocMeta: { poolAVal, poolBVal, revenueHrs },
allocTrace: (t.allocTrace || []).sort((a, b) => (b.allocA + b.allocB) - (a.allocA + a.allocB)),
allocCMeta: {
isSource: NON_MANAGED_FORMS.some(f => normalizeFormKey(f) === normalizeFormKey(t.name)),
rulesApplied: t.allocCTraceOut || [],
receivedFrom: t.allocCTraceIn || []
}
};
}).sort(customSort);
}
if (mode === 'team') {
const tmMap = {};
settledProjects.forEach(p => {
Object.entries(p.byTeam).forEach(([tm, val]) => {
if (!tmMap[tm]) tmMap[tm] = {
name: tm,
direct: 0,
allocA: 0,
allocB: 0,
final: 0,
hours: 0,
breakdown: {},
allocTrace: [],
allocBucket: {
A: { '지급임차료': 0, '전력비': 0, '일반경비': 0 },
B: { '지급임차료': 0, '전력비': 0, '일반경비': 0 }
}
};
tmMap[tm].direct += val;
tmMap[tm].hours += p.direct > 0 ? (p.hours * (val / p.direct)) : 0;
tmMap[tm].breakdown[p.name] = (tmMap[tm].breakdown[p.name] || 0) + val;
});
});
const ensureTeam = (name) => {
if (!tmMap[name]) tmMap[name] = { name, direct: 0, allocA: 0, allocB: 0, final: 0, hours: 0, breakdown: {}, allocTrace: [] };
return tmMap[name];
};
const bucketA = { '지급임차료': 0, '전력비': 0, '일반경비': 0 };
const bucketB = { '지급임차료': 0, '전력비': 0, '일반경비': 0 };
const classifyAccount = (account) => {
const raw = String(account || '');
const compact = raw.replace(/\s+/g, '').toLowerCase();
const digits = compact.replace(/[^0-9]/g, '');
if (digits === '819' || digits === '81901') return '지급임차료';
if (compact.includes('전력비') || compact.includes('전기료') || compact.includes('815')) return '전력비';
return '일반경비';
};
fEx.forEach(e => {
const bucket = classifyAccount(e.account);
if (isA(e.projectName)) {
bucketA[bucket] += e.amount;
} else if (isB(e.projectName)) {
bucketB[bucket] += e.amount;
}
});
laborWithCost.forEach(l => {
if (isA(l.projectName)) {
bucketA['일반경비'] += l.cost;
} else if (isB(l.projectName)) {
bucketB['일반경비'] += l.cost;
}
});
if (!allocPoolA) Object.keys(bucketA).forEach(k => { bucketA[k] = 0; });
if (!allocPoolB) Object.keys(bucketB).forEach(k => { bucketB[k] = 0; });
const distributeManagedByRatio = (poolName, bucketMap, allocKey) => {
Object.entries(bucketMap).forEach(([bucket, amount]) => {
if (!amount) return;
const ratios = TEAM_RATIOS[bucket] || TEAM_RATIOS['일반경비'];
Object.entries(ratios).forEach(([team, ratio]) => {
const share = amount * ratio;
const row = ensureTeam(team);
row[allocKey] += share;
row.allocBucket[allocKey === 'allocA' ? 'A' : 'B'][bucket] += share;
row.allocTrace.push({
projectName: `${poolName}/${bucket}`,
directShare: amount,
ratio,
projectHours: 0,
shareHours: 0,
allocA: allocKey === 'allocA' ? share : 0,
allocB: allocKey === 'allocB' ? share : 0
});
});
});
};
distributeManagedByRatio('POOL A', bucketA, 'allocA');
distributeManagedByRatio('POOL B', bucketB, 'allocB');
return Object.values(tmMap).map(t => ({
...t,
final: t.direct + t.allocA + t.allocB,
allocMeta: { poolAVal, poolBVal, revenueHrs },
allocTrace: (t.allocTrace || []).sort((a, b) => (b.allocA + b.allocB) - (a.allocA + a.allocB))
})).sort(customSort);
}
return { poolAVal, poolBVal, grandTotalHrs: fLb.reduce((s,x)=>s+x.hours, 0), revenueHrs };
};
const results = useMemo(() => {
const meta = calculateSettlement('meta');
const project = calculateSettlement('project');
const type = calculateSettlement('type');
const team = calculateSettlement('team');
const hiddenTypeKeys = new Set(NON_MANAGED_FORMS.map(normalizeFormKey));
const displayType = type.filter(item => !hiddenTypeKeys.has(normalizeFormKey(item.name)));
const displayData = { project, type: displayType, team }[viewMode] || project;
const totalDirect = project.reduce((s, x) => s + x.direct, 0);
const total = totalDirect + (allocPoolA ? meta.poolAVal : 0) + (allocPoolB ? meta.poolBVal : 0);
return { displayData, poolA: meta.poolAVal, poolB: meta.poolBVal, total, grandTotalHrs: meta.grandTotalHrs, revenueHrs: meta.revenueHrs, all: { project, type, team } };
}, [expenses, laborRows, wageSettings, startDate, endDate, viewMode, allocPoolA, allocPoolB, formVolumes]);
const workerTeamMap = useMemo(() => {
const teamMfg = new Set(['이호성', '이신영', '곽병목', '최정희', '장래철']);
const teamAdmin = new Set(['양시용', '원종명', '김용정', '조성태', '강병흔', '장기홍', '정승정']);
const dailyWorkers = new Set(
(laborRows || [])
.filter(r => String(r.team || '').includes('일용'))
.map(r => String(r.worker || '').trim())
.filter(Boolean)
);
const map = {};
factoryWorkers.forEach(w => { map[w.name] = w.team; });
Object.keys(wageSettings || {}).forEach(name => {
if (map[name]) return;
if (dailyWorkers.has(name) || String(name).includes('일용')) {
map[name] = '공통';
} else {
map[name] = teamMfg.has(name) ? '제작' : teamAdmin.has(name) ? '공무' : '철근';
}
});
return map;
}, [factoryWorkers, wageSettings, laborRows]);
const factoryTeamCounts = useMemo(() => {
const base = { '철근': 0, '제작': 0, '공무': 0, '공통': 0, '관리팀': 0 };
Object.values(workerTeamMap || {}).forEach(team => {
if (base[team] !== undefined) base[team] += 1;
});
return base;
}, [workerTeamMap]);
const visibleWorkerNames = useMemo(() => {
const names = Object.keys(wageSettings).sort();
if (workerTeamFilter === 'ALL') return names;
return names.filter(n => workerTeamMap[n] === workerTeamFilter);
}, [wageSettings, workerTeamFilter, workerTeamMap]);
const laborCalcRows = useMemo(() => {
const fLb = laborRows.filter(i => inMonthRange(i.date));
const monthFactor = utils.monthSpan(startDate, endDate);
const workerMap = {};
fLb.forEach(r => {
const worker = String(r.worker || '').trim();
if (!worker) return;
const h = Number(r.hours || 0);
const pName = String(r.projectName || '미지정').trim() || '미지정';
if (!workerMap[worker]) workerMap[worker] = { totalHours: 0, projects: {} };
workerMap[worker].totalHours += h;
workerMap[worker].projects[pName] = (workerMap[worker].projects[pName] || 0) + h;
});
const workers = Array.from(new Set([...Object.keys(wageSettings || {}), ...Object.keys(workerMap)])).sort();
return workers.map(name => {
const cfg = wageSettings[name] || { rate: 0, type: 'monthly' };
const totalHours = workerMap[name]?.totalHours || 0;
const monthlyTotal = (cfg.rate || 0) * monthFactor;
const appliedHourly = cfg.type === 'hourly' ? (cfg.rate || 0) : (totalHours > 0 ? monthlyTotal / totalHours : 0);
const projects = Object.entries(workerMap[name]?.projects || {})
.map(([projectName, hours]) => ({
projectName,
hours,
amount: hours * appliedHourly
}))
.sort((a, b) => b.amount - a.amount);
const totalAmount = projects.reduce((s, c) => s + c.amount, 0);
return { name, type: cfg.type || 'monthly', rate: cfg.rate || 0, totalHours, appliedHourly, totalAmount, projects };
});
}, [laborRows, wageSettings, startDate, endDate]);
useEffect(() => {
if (!selectedDetail) return;
if (selectedDetail.byAccount) setDetailTab('account');
else setDetailTab('breakdown');
}, [selectedDetail]);
if (isLoading) return <div className="h-screen flex items-center justify-center bg-slate-50 font-bold text-slate-400 uppercase tracking-widest animate-pulse">Syncing v5.5 Engine...</div>;
if (showReport) {
return (
<div className="min-h-screen bg-slate-200 p-10 print-container animate-fade-in text-slate-900">
<div className="max-w-5xl mx-auto space-y-6 no-print mb-8">
<div className="flex justify-between items-center bg-white p-4 rounded-2xl shadow-sm border border-slate-300">
<button onClick={() => setShowReport(false)} className="px-5 py-2 rounded-lg font-bold bg-slate-100 hover:bg-slate-200 flex items-center gap-2 transition-all">
<Icon name="arrow-left" size={16}/> 대시보드로 돌아가기
</button>
<button onClick={() => window.print()} className="px-8 py-3 rounded-lg font-black bg-blue-600 text-white shadow-lg hover:bg-blue-700 active:scale-95 transition-all flex items-center gap-2">
<Icon name="printer" size={20}/> 보고서 인쇄 (PDF 저장 가능)
</button>
</div>
</div>
<div className="bg-white p-14 shadow-2xl report-page mx-auto w-full max-w-5xl border border-slate-300">
<div className="border-b-4 border-slate-900 pb-8 mb-12 flex justify-between items-end">
<div>
<h2 className="text-3xl font-black italic tracking-tighter text-slate-900 uppercase underline decoration-blue-500 decoration-4 underline-offset-[12px] mb-4">통합 원가 정산 결과 보고서</h2>
<p className="text-slate-500 font-bold uppercase text-[10px] tracking-[0.4em]">()장헌 프로젝트 관리 시스템</p>
</div>
<div className="text-right">
<p className="text-xs font-bold text-slate-400 mb-1 italic">발행일: {new Date().toLocaleDateString()}</p>
<p className="text-[10px] font-black text-slate-900 uppercase tracking-widest bg-slate-100 px-3 py-1 rounded">Official Report</p>
</div>
</div>
<div className="space-y-16">
<section>
<div className="flex items-center gap-4 mb-6">
<div className="w-1.5 h-6 bg-slate-900 rounded-full" />
<h3 className="text-xl font-black uppercase italic tracking-tight">01. 정산 총괄 요약</h3>
</div>
<div className="summary-box">
<div className="summary-row">
<div className="summary-cell">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1 block">조회 기간 발생 원가</span>
<span className="text-xl font-black text-slate-900">{utils.formatWon(results.total)}</span>
</div>
<div className="summary-cell">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1 block"> 투입 누적 공수</span>
<span className="text-xl font-black text-slate-900">{utils.formatHr(results.grandTotalHrs)} HR</span>
</div>
</div>
{(allocPoolA || allocPoolB) && (
<div className="summary-row">
{allocPoolA && (
<div className="summary-cell">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1 block">운영비 배분 총액 (A)</span>
<span className="text-xl font-black text-blue-700">{utils.formatWon(results.poolA)}</span>
</div>
)}
{allocPoolB && (
<div className="summary-cell">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1 block">일반 관리비 배분액 (B)</span>
<span className="text-xl font-black text-orange-700">{utils.formatWon(results.poolB)}</span>
</div>
)}
</div>
)}
</div>
</section>
<section>
<div className="flex items-center gap-4 mb-6">
<div className="w-1.5 h-6 bg-slate-900 rounded-full" />
<h3 className="text-xl font-black uppercase italic tracking-tight">02. 교량별 정산 상세 내역</h3>
</div>
<table className="report-table">
<thead>
<tr>
<th style={{ width: '28%' }}>교량(프로젝트) 명칭</th>
<th style={{ width: '15%' }} className="text-right">직접비</th>
{allocPoolA && <th style={{ width: '15%' }} className="text-right text-blue-700">운영비 추가(A)</th>}
{allocPoolB && <th style={{ width: '15%' }} className="text-right text-orange-700">관리비 추가(B)</th>}
<th style={{ width: '17%' }} className="text-right font-black">총액</th>
<th style={{ width: '10%' }} className="text-center">공수(HR)</th>
</tr>
</thead>
<tbody>
{results.all.project.map(p => (
<tr key={p.name} className={isInvalidProjectName(p.name) ? 'bg-red-50 text-red-700' : ''}>
<td className="font-bold">{p.name}</td>
<td className="text-right">{Math.round(p.direct).toLocaleString()}</td>
{allocPoolA && <td className="text-right text-blue-700">{Math.round(p.allocA).toLocaleString()}</td>}
{allocPoolB && <td className="text-right text-orange-700">{Math.round(p.allocB).toLocaleString()}</td>}
<td className="text-right font-black">{Math.round(p.final).toLocaleString()}</td>
<td className="text-center font-bold text-slate-500">{utils.formatHr(p.hours)}</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="page-break">
<div className="flex items-center gap-4 mb-6">
<div className="w-1.5 h-6 bg-slate-900 rounded-full" />
<h3 className="text-xl font-black uppercase italic tracking-tight">03. 제품 형식별 제조 원가 분석</h3>
</div>
<table className="report-table">
<thead>
<tr>
<th style={{ width: '24%' }}>제품 형식</th>
<th style={{ width: '14%' }} className="text-right">직접비</th>
{allocPoolA && <th style={{ width: '14%' }} className="text-right text-blue-700">운영비 추가(A)</th>}
{allocPoolB && <th style={{ width: '14%' }} className="text-right text-orange-700">관리비 추가(B)</th>}
<th style={{ width: '14%' }} className="text-right text-violet-700">형식배분(C)</th>
<th style={{ width: '14%' }} className="text-right font-black">총액</th>
<th style={{ width: '10%' }} className="text-right">생산량/단위</th>
<th style={{ width: '10%' }} className="text-right text-emerald-700 font-black">단위당 원가()</th>
</tr>
</thead>
<tbody>
{results.all.type.map(t => (
<tr key={t.name} className={isInvalidProjectName(t.name) ? 'bg-red-50' : ''}>
<td className="font-bold">{t.name}</td>
<td className="text-right">{Math.round(t.direct).toLocaleString()}</td>
{allocPoolA && <td className="text-right text-blue-700">{Math.round(t.allocA).toLocaleString()}</td>}
{allocPoolB && <td className="text-right text-orange-700">{Math.round(t.allocB).toLocaleString()}</td>}
<td className={`text-right ${t.allocC >= 0 ? 'text-violet-700' : 'text-rose-600'}`}>{Math.round(t.allocC || 0).toLocaleString()}</td>
<td className="text-right font-black">{Math.round(t.final).toLocaleString()}</td>
<td className="text-right italic text-slate-500">{t.volInfo.value.toLocaleString()} {t.volInfo.unit}</td>
<td className="text-right font-black text-emerald-700">{Math.round(t.unitCost).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</section>
<section>
<div className="flex items-center gap-4 mb-6">
<div className="w-1.5 h-6 bg-slate-900 rounded-full" />
<h3 className="text-xl font-black uppercase italic tracking-tight">04. 팀별 원가 귀속 현황</h3>
</div>
<table className="report-table">
<thead>
<tr>
<th style={{ width: '30%' }}>담당 부서/</th>
<th style={{ width: '17%' }} className="text-right">직접 투입비</th>
{allocPoolA && <th style={{ width: '17%' }} className="text-right text-blue-700">운영비 추가(A)</th>}
{allocPoolB && <th style={{ width: '17%' }} className="text-right text-orange-700">관리비 추가(B)</th>}
<th style={{ width: '19%' }} className="text-right font-black">총액</th>
</tr>
</thead>
<tbody>
{results.all.team.map(tm => (
<tr key={tm.name}>
<td className="font-bold">{formatTeamLabel(tm.name)} ({utils.formatHr(tm.hours)} HR)</td>
<td className="text-right">{Math.round(tm.direct).toLocaleString()}</td>
{allocPoolA && <td className="text-right text-blue-700">{Math.round(tm.allocA).toLocaleString()}</td>}
{allocPoolB && <td className="text-right text-orange-700">{Math.round(tm.allocB).toLocaleString()}</td>}
<td className="text-right font-black">{Math.round(tm.final).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
<div className="mt-20 pt-10 border-t border-slate-100 text-center space-y-4">
<p className="text-[9px] font-bold text-slate-300 uppercase tracking-[0.6em] italic">JANGHEON COST ANALYSIS ENGINE v5.5</p>
<div className="flex justify-center gap-10 py-6 opacity-20 grayscale">
<div className="border-2 border-slate-900 p-2 px-8 rounded-full font-black text-slate-900 text-lg uppercase italic">Approved</div>
<div className="border-2 border-slate-900 p-2 px-8 rounded-full font-black text-slate-900 text-lg uppercase italic">JANGHEON</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex flex-col text-sm">
<header className="glass-nav h-14 px-6 flex items-center justify-between sticky top-0 z-50">
<div className="flex items-center gap-3">
<div className="bg-slate-900 p-1.5 rounded-lg text-white shadow-sm font-black italic"><Icon name="cpu" size={16} /></div>
<h1 className="font-bold text-lg tracking-tight text-slate-800 italic uppercase">장헌 Integrated COST v5.5</h1>
</div>
<div className="flex gap-1 bg-slate-100 p-0.5 rounded-xl shrink-0 border border-slate-200">
{[{id:'analysis', label:'정산 분석', icon:'layout'}, {id:'settings', label:'인사 관리', icon:'settings'}, {id:'data', label:'데이터&보고서', icon:'database'}].map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)} className={`flex items-center gap-2 px-4 py-1.5 rounded-lg font-bold transition-all ${activeTab === t.id ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'}`}>
<Icon name={t.icon} size={13} /> {t.label}
</button>
))}
</div>
</header>
<main className="flex-1 p-6 max-w-[1400px] mx-auto w-full animate-fade-in space-y-6 text-slate-700">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[
{ label: '조회 총 발생 원가', val: results.total, color: 'text-slate-900' },
{ label: '운영비 (A)', val: results.poolA, color: 'text-blue-600' },
{ label: '관리비 (B)', val: results.poolB, color: 'text-orange-600' },
{ label: '실 투입 총 공수', val: results.grandTotalHrs, unit: 'HR', color: 'text-emerald-600' }
].map((c, i) => (
<div key={i} className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm transition-all hover:bg-slate-50/50">
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{c.label}</p>
<p className={`text-xl font-extrabold ${c.color} tracking-tighter leading-none`}>
{c.unit ? (c.unit === 'HR' ? utils.formatHr(c.val) : c.val.toLocaleString()) : utils.formatWon(c.val)}
{c.unit && <span className="text-sm font-bold ml-1 opacity-50">{c.unit}</span>}
</p>
</div>
))}
</div>
{activeTab === 'analysis' && (
<div className="space-y-4">
<div className="bg-white px-6 py-2.5 rounded-2xl border border-slate-200 flex items-center justify-between gap-4 shadow-sm">
<div className="flex items-center gap-10 shrink-0">
<div className="flex items-center gap-8 font-bold text-xs text-slate-500 uppercase italic">
<label className="flex items-center gap-2 cursor-pointer group">
<input type="checkbox" className="w-4 h-4 rounded border-slate-300" checked={allocPoolA} onChange={e => setAllocPoolA(e.target.checked)} />
<span className={`${allocPoolA ? 'text-blue-600' : ''}`}>배분 (A)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer group">
<input type="checkbox" className="w-4 h-4 rounded border-slate-300" checked={allocPoolB} onChange={e => setAllocPoolB(e.target.checked)} />
<span className={`${allocPoolB ? 'text-orange-600' : ''}`}>배분 (B)</span>
</label>
</div>
<div className="h-4 w-px bg-slate-200"></div>
<div className="flex items-center gap-2 shrink-0">
<input type="month" className="bg-slate-50 border rounded-lg text-xs font-bold px-3 py-1.5 focus:ring-1 focus:ring-blue-300 outline-none" value={startDate} onChange={e => setStartDate(e.target.value)} />
<span className="text-slate-300 text-xs font-bold">~</span>
<input type="month" className="bg-slate-50 border rounded-lg text-xs font-bold px-3 py-1.5 focus:ring-1 focus:ring-blue-300 outline-none" value={endDate} onChange={e => setEndDate(e.target.value)} />
</div>
</div>
<div className="flex bg-slate-100 p-0.5 rounded-lg gap-0.5 shrink-0 border">
{[{id:'project', label:'교량별'}, {id:'type', label:'형식별'}, {id:'team', label:'팀별'}].map(v => (
<button key={v.id} onClick={() => setViewMode(v.id)} className={`px-4 py-1.5 rounded-md text-xs font-bold transition-all ${viewMode === v.id ? 'bg-slate-800 text-white shadow-sm' : 'text-slate-500 hover:text-slate-800'}`}>
{v.label}
</button>
))}
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 space-y-4">
<h3 className="text-sm font-black text-slate-800">배분 기준 안내</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
<div className="bg-blue-50 border border-blue-100 rounded-xl p-4 space-y-2">
<div className="font-black text-blue-800">운영비 (A)</div>
<div className="text-slate-700">
{viewMode === 'team'
? '팀별에서는 공수 대신 지급임차료/전력비/일반경비 계정을 팀별 고정 비율로 배분해 A 금액을 계산합니다.'
: '운영/공통 성격 비용을 수익 프로젝트 공수 비율로 배분하는 풀입니다.'}
</div>
<div className="text-slate-500">
{viewMode === 'team'
? '팀별 기준: 철근/제작/공무 비율표 적용 (공수 미사용)'
: `기본 프로젝트: ${POOL_A_PROJECTS.join(', ') || '-'}`}
</div>
</div>
<div className="bg-orange-50 border border-orange-100 rounded-xl p-4 space-y-2">
<div className="font-black text-orange-800">관리비 (B)</div>
<div className="text-slate-700">
{viewMode === 'team'
? '팀별에서는 공수 대신 지급임차료/전력비/일반경비 계정을 팀별 고정 비율로 배분해 B 금액을 계산합니다.'
: '관리/간접 성격 비용을 수익 프로젝트 공수 비율로 배분하는 풀입니다.'}
</div>
<div className="text-slate-500">
{viewMode === 'team'
? '팀별 기준: 철근/제작/공무 비율표 적용 (공수 미사용)'
: `기본 프로젝트: ${POOL_B_PROJECTS.join(', ') || '-'}`}
</div>
</div>
<div className="bg-violet-50 border border-violet-100 rounded-xl p-4 space-y-2">
<div className="font-black text-violet-800">형식배분 (C)</div>
<div className="text-slate-700">원가관리 비대상 형식 원가를 관리대상 형식으로 규칙에 따라 재배분합니다.</div>
<div className="text-blue-700 font-black">적용 범위: 형식별 전용 (교량별/팀별 미적용)</div>
<div className="text-slate-500">비대상 형식은 형식별 리스트에서 숨김 처리됩니다.</div>
</div>
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden overflow-x-auto custom-scrollbar">
<table className="w-full text-left min-w-[1000px] dashboard-table">
<thead>
<tr>
<th>정산 대상 명칭</th>
<th className="text-right">직접비</th>
<th className="text-right">배분(A)</th>
<th className="text-right">배분(B)</th>
{viewMode === 'type' && <th className="text-right">배분(C) <span className="text-[10px] text-violet-600">(형식별 전용)</span></th>}
{viewMode === 'type' && <th>C 배분내역 <span className="text-[10px] text-violet-600">(형식별 전용)</span></th>}
<th className="text-right">최종 원가</th>
{viewMode === 'type' && <th className="text-center">생산량 / 단위원가</th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100 font-semibold text-sm">
{results.displayData.map(item => {
const isInvalid = isInvalidProjectName(item.name);
return (
<tr key={item.name} className={`transition-all group ${isInvalid ? 'bg-red-50 hover:bg-red-100/30' : 'hover:bg-blue-50/20'}`}>
<td className="px-8 py-4 cursor-pointer" onClick={() => setSelectedDetail(item)}>
<div className="flex flex-col">
<span className={`font-bold block transition-colors text-sm leading-tight ${isInvalid ? 'text-red-700' : 'text-slate-800 group-hover:text-blue-600'}`}>{viewMode === 'team' ? formatTeamLabel(item.name) : item.name}</span>
<span className="text-xs text-slate-400 font-bold mt-1 inline-flex items-center gap-1.5 bg-slate-100 px-1.5 py-0.5 rounded-md w-max">
<Icon name="clock" size={10} /> {utils.formatHr(item.hours || 0)} HR
</span>
</div>
</td>
<td className="px-5 py-4 text-right text-slate-500 italic text-sm">{Math.round(item.direct).toLocaleString()}</td>
<td className="px-5 py-4 text-right text-blue-500 text-sm italic">{Math.round(item.allocA).toLocaleString()}</td>
<td className="px-5 py-4 text-right text-orange-500 text-sm italic">{Math.round(item.allocB).toLocaleString()}</td>
{viewMode === 'type' && (
<td className={`px-5 py-4 text-right text-sm italic ${item.allocC >= 0 ? 'text-violet-600' : 'text-rose-600'}`}>
{Math.round(item.allocC || 0).toLocaleString()}
</td>
)}
{viewMode === 'type' && (
<td className="px-5 py-4 text-xs text-slate-600">
{(item.allocCMeta?.receivedFrom || []).length ? (
<div className="space-y-1">
{item.allocCMeta.receivedFrom.map((src, idx) => (
<div key={`${item.name}_csrc_${idx}`} className="whitespace-nowrap">
{src.source}: <span className="font-black text-violet-700">{utils.formatWon(src.amount)}</span>
</div>
))}
</div>
) : (
<span className="text-slate-300">-</span>
)}
</td>
)}
<td className="px-8 py-4 text-right text-base font-black text-slate-900 tracking-tighter cursor-pointer" onClick={() => setSelectedDetail(item)}>{Math.round(item.final).toLocaleString()}</td>
{viewMode === 'type' && (
<td className="px-8 py-4 bg-slate-50/50">
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-1.5">
<input
type="number"
className="w-20 bg-white border border-slate-300 rounded-lg px-2 py-1 text-right font-bold text-blue-700 text-xs outline-none focus:ring-2 focus:ring-blue-100"
placeholder="물량"
value={item.volInfo.value || ''}
onChange={(e) => {
const next = { ...formVolumes, [item.name]: { ...item.volInfo, value: utils.parseNum(e.target.value) } };
setFormVolumes(next); saveData({ formVolumes: next });
}}
/>
<select className="bg-white border border-slate-300 rounded-lg px-1 py-1 text-xs font-bold outline-none cursor-pointer" value={item.volInfo.unit} onChange={(e) => {
const next = { ...formVolumes, [item.name]: { ...item.volInfo, unit: e.target.value } };
setFormVolumes(next); saveData({ formVolumes: next });
}}>
<option value="ton">ton</option><option value="m">m</option><option value="EA">EA</option>
</select>
</div>
<span className="text-xs font-black text-blue-800 tracking-tight">{Math.round(item.unitCost).toLocaleString()} <span className="text-[9px] opacity-40">/ {item.volInfo.unit}</span></span>
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'data' && (
<div className="space-y-6 animate-fade-in text-center">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4">
{[{id:'expense', title:'지출 원장 업로드', icon:'file-text', count: expenses.length, color:'text-blue-600'}, {id:'labor', title:'근무 기록 업로드', icon:'users', count: laborRows.length, color:'text-emerald-600'}, {id:'hr', title:'인사 관리 업로드', icon:'user-round', count: factoryWorkers.length, color:'text-violet-600'}].map(u => (
<div key={u.id} className="bg-white p-8 rounded-2xl border border-slate-200 shadow-sm group hover:border-slate-800 transition-all">
<div className={`mx-auto p-5 rounded-2xl bg-slate-50 ${u.color} mb-4 shadow-sm w-max`}><Icon name={u.icon} size={36} /></div>
<h3 className="text-lg font-bold text-slate-800 mb-4 uppercase">{u.title}</h3>
<label className="bg-slate-800 text-white px-8 py-2 rounded-xl text-sm font-bold cursor-pointer hover:bg-slate-700 transition-all inline-block shadow-md">
<Icon name="upload" className="inline-block mr-2" size={14} /> {uploadedFiles[u.id] ? '파일 교체' : '엑셀 파일 로드'} <input type="file" className="hidden" accept={u.id === 'hr' ? '.xls,.xlsx,.html' : '.xls,.xlsx'} onChange={e => onUpload(e, u.id)} />
</label>
{uploadedFiles[u.id] && (
<div className="mt-4 space-y-2">
<div className="text-[11px] text-slate-500 font-bold">
저장됨: {uploadedFiles[u.id].name} ({Math.round((uploadedFiles[u.id].size || 0) / 1024)}KB)
</div>
<div className="text-[10px] text-slate-400 font-semibold">
{fmtSavedAt(uploadedFiles[u.id].savedAt)}
</div>
<button
type="button"
onClick={() => downloadUploadedFile(u.id)}
className="px-4 py-1.5 rounded-lg bg-blue-50 text-blue-700 text-xs font-black border border-blue-200 hover:bg-blue-100 transition-all"
>
<Icon name="download" className="inline-block mr-1" size={12} /> 저장 파일 다운로드
</button>
</div>
)}
<p className="mt-4 text-xs font-bold text-slate-300 uppercase tracking-widest">{u.count} RECORDS SYNCED</p>
</div>
))}
</div>
<p className="text-sm font-bold text-slate-500">기본적인 인사 정보는 시스템에 저장되어 있으며, 인사 업로드 최신 파일 기준으로 /단가 정보가 갱신됩니다.</p>
<div className="bg-slate-900 p-12 rounded-[40px] text-white shadow-2xl relative overflow-hidden text-center space-y-6 group">
<div className="absolute top-0 right-0 w-80 h-80 bg-blue-500/10 blur-[100px] rounded-full" />
<Icon name="file-text" size={60} className="mx-auto text-blue-400 mb-2 transition-all duration-500 group-hover:scale-110" />
<div>
<h3 className="text-3xl font-black tracking-tighter uppercase italic">Professional Settlement Report</h3>
<p className="text-slate-400 font-semibold max-w-xl mx-auto text-sm mt-2 opacity-80 leading-relaxed italic">
모든 정산 결과(교량, 형식, ) 페이지 너비에 최적화된 통합 보고서 양식으로 생성합니다. <br/>
생성된 결과는 정식 문서 규격으로 인쇄하거나 PDF로 저장할 있습니다.
</p>
</div>
<button onClick={() => setShowReport(true)} className="relative z-10 bg-blue-600 hover:bg-blue-500 text-white px-12 py-4 rounded-2xl text-lg font-black transition-all hover:scale-105 active:scale-95 shadow-[0_15px_40px_rgba(37,99,235,0.3)] flex items-center gap-3 mx-auto uppercase italic">
<Icon name="printer" size={24} /> 전문 통합 보고서 생성
</button>
</div>
</div>
)}
{activeTab === 'settings' && (
<div className="max-w-6xl mx-auto space-y-8 py-6 animate-fade-in text-slate-900">
<div className="bg-white p-10 rounded-3xl border border-slate-200 shadow-sm text-slate-900">
<h3 className="text-xl font-black uppercase tracking-tight mb-8 border-b pb-6 italic">인사 관리</h3>
<div className="mb-6 flex items-center gap-2">
{[
{ id: 'wage', label: '단가 설정' },
{ id: 'calc', label: '계산 상세' }
].map(tab => (
<button
key={tab.id}
type="button"
onClick={() => setSettingsTab(tab.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-all ${settingsTab === tab.id ? 'bg-slate-800 text-white border-slate-800' : 'bg-white text-slate-500 border-slate-200 hover:text-slate-800'}`}
>
{tab.label}
</button>
))}
</div>
{settingsTab === 'wage' && (
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
{['철근', '제작', '공무', '공통', '관리팀'].map(team => (
<div key={team} className="bg-slate-50 rounded-2xl border border-slate-100 p-4">
<div className="text-xs font-black text-slate-500">{team}</div>
<div className="text-2xl font-black text-slate-800 mt-1">{factoryTeamCounts[team] || 0}<span className="text-sm ml-1 text-slate-400"></span></div>
</div>
))}
</div>
<div className="mb-6 flex items-center gap-2">
{[
{ id: 'ALL', label: '전체' },
{ id: '철근', label: '철근' },
{ id: '제작', label: '제작' },
{ id: '공무', label: '공무' },
{ id: '공통', label: '공통' },
{ id: '관리팀', label: '관리팀' }
].map(opt => (
<button
key={opt.id}
type="button"
onClick={() => setWorkerTeamFilter(opt.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-all ${workerTeamFilter === opt.id ? 'bg-slate-800 text-white border-slate-800' : 'bg-white text-slate-500 border-slate-200 hover:text-slate-700'}`}
>
{opt.label}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-4 text-slate-900">
{visibleWorkerNames.map(n => (
<div key={n} className="bg-slate-50 p-5 rounded-2xl border border-slate-100 flex justify-between items-center group hover:bg-white transition-all shadow-sm">
<div className="flex items-center gap-2">
<span className="font-bold text-base underline underline-offset-8 decoration-slate-200 tracking-tight italic">{n}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] font-black bg-slate-200 text-slate-700">{workerTeamMap[n] || '철근'}</span>
</div>
<div className="flex items-center gap-4">
<select className="bg-white border text-sm font-bold rounded-xl px-4 py-2.5 outline-none cursor-pointer focus:ring-4 focus:ring-blue-100 transition-all border-slate-200" value={wageSettings[n].type} onChange={e => {
const next = {...wageSettings, [n]: {...wageSettings[n], type: e.target.value}};
setWageSettings(next); saveData({ wageSettings: next });
}}>
<option value="monthly">월급제</option><option value="hourly"></option>
</select>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-300 font-bold text-lg italic"></span>
<input type="number" className="w-48 bg-white border border-slate-200 rounded-2xl pl-9 pr-6 py-2.5 text-right font-black text-lg outline-none focus:ring-4 focus:ring-blue-100 transition-all shadow-sm" value={wageSettings[n].rate} onChange={e => {
const next = {...wageSettings, [n]: {...wageSettings[n], rate: utils.parseNum(e.target.value)}};
setWageSettings(next); saveData({ wageSettings: next });
}} />
</div>
</div>
</div>
))}
</div>
</>
)}
{settingsTab === 'calc' && (
<div className="space-y-4">
<div className="text-xs text-slate-500 font-bold">
표시 기준: {startDate || '전체'} ~ {endDate || '전체'} / 월급제는 `(월급 × 개월수) ÷ 개인 총근무시간`으로 환산
</div>
<div className="overflow-auto border border-slate-200 rounded-2xl">
<table className="w-full min-w-[980px] text-xs">
<thead className="bg-slate-50">
<tr className="text-slate-500 font-black">
<th className="px-3 py-2 text-left">근무자</th>
<th className="px-3 py-2 text-center whitespace-nowrap">타입</th>
<th className="px-3 py-2 text-right">입력 단가</th>
<th className="px-3 py-2 text-right whitespace-nowrap">총근무시간</th>
<th className="px-3 py-2 text-right">적용 시급</th>
<th className="px-3 py-2 text-right"> 인건비</th>
<th className="px-3 py-2 text-left">프로젝트별 금액</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{laborCalcRows.map(row => (
<tr key={row.name}>
<td className="px-3 py-2 font-bold text-slate-800">{row.name}</td>
<td className="px-3 py-2 text-center whitespace-nowrap min-w-[64px]">{row.type === 'hourly' ? '시급제' : '월급제'}</td>
<td className="px-3 py-2 text-right">{Math.round(row.rate).toLocaleString()}</td>
<td className="px-3 py-2 text-right whitespace-nowrap min-w-[88px]">{utils.formatHr(row.totalHours)}</td>
<td className="px-3 py-2 text-right text-blue-700 font-bold">{Math.round(row.appliedHourly).toLocaleString()}</td>
<td className="px-3 py-2 text-right font-bold text-slate-900">{Math.round(row.totalAmount).toLocaleString()}</td>
<td className="px-3 py-2">
{row.projects.length === 0 ? (
<span className="text-slate-300">-</span>
) : (
<div className="flex flex-wrap gap-1">
{row.projects.map(p => (
<span key={`${row.name}-${p.projectName}`} className="text-[10px] bg-slate-50 border border-slate-200 rounded-md px-2 py-0.5">
{p.projectName}: {Math.round(p.amount).toLocaleString()}
</span>
))}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
)}
</main>
{selectedDetail && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6 bg-slate-900/70 backdrop-blur-md animate-fade-in no-print text-slate-900">
<div className={`bg-white w-full max-w-2xl rounded-[40px] shadow-2xl overflow-hidden flex flex-col max-h-[85vh] border-2 ${isInvalidProjectName(selectedDetail.name) ? 'border-red-400' : 'border-white/20'}`}>
<div className={`p-8 text-white flex justify-between items-center shrink-0 ${isInvalidProjectName(selectedDetail.name) ? 'bg-red-800' : 'bg-slate-800'}`}>
<h3 className="text-xl font-black italic tracking-tighter uppercase leading-tight">{selectedDetail.name} ANALYTICS</h3>
<button onClick={() => setSelectedDetail(null)} className="p-2 hover:bg-white/10 rounded-full transition-colors"><Icon name="x" size={20} /></button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-10 space-y-10 text-slate-800 text-center">
<div className="space-y-1">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest leading-none">Settlement Sum</p>
<p className="text-4xl font-black tracking-tight italic underline decoration-slate-100 underline-offset-8">{Math.round(selectedDetail.final).toLocaleString()}</p>
</div>
<div className="flex justify-center gap-12 pt-4 font-bold border-y py-6 border-slate-100">
<div className="flex flex-col gap-1 text-center"><span className="text-[9px] text-slate-400 uppercase tracking-widest">Direct Input</span><span className="text-xl tracking-tighter italic">{utils.formatWon(selectedDetail.direct)}</span></div>
<div className="flex flex-col gap-1 text-center"><span className="text-[9px] text-blue-500 uppercase tracking-widest">Alloc(A+B)</span><span className="text-xl tracking-tighter italic">{utils.formatWon(selectedDetail.allocA + selectedDetail.allocB)}</span></div>
</div>
{selectedDetail.allocMeta && (
<div className="space-y-4 text-left">
<div className="flex items-center gap-3">
<div className={`w-1.5 h-6 rounded-full ${isInvalidProjectName(selectedDetail.name) ? 'bg-red-600' : 'bg-indigo-600'}`}></div>
<h4 className="font-bold text-slate-800 text-base uppercase tracking-tighter italic">A/B 배분 계산과정</h4>
</div>
<div className="bg-indigo-50 border border-indigo-100 rounded-2xl p-4 text-xs text-slate-700 leading-relaxed">
{viewMode === 'team' ? (
<>
<div>기준식(팀별): <span className="font-black">지급임차료/전력비/일반경비 3 계정 비율 분배</span></div>
<div className="mt-1">
A = 3 계정 비율 합계 = <span className="font-black text-blue-700">{utils.formatWon(selectedDetail.allocA || 0)}</span>
</div>
<div>
B = 3 계정 비율 합계 = <span className="font-black text-orange-700">{utils.formatWon(selectedDetail.allocB || 0)}</span>
</div>
</>
) : (
<>
<div>기준식: <span className="font-black">배분금액 = (배분공수 / 전체 수익공수) × 배분 기준 금액</span></div>
<div className="mt-1">A = ({utils.formatHr(selectedDetail.hours || 0)} / {utils.formatHr(selectedDetail.allocMeta.revenueHrs || 0)}) × {utils.formatWon(selectedDetail.allocMeta.poolAVal || 0)} = <span className="font-black text-blue-700">{utils.formatWon(selectedDetail.allocA || 0)}</span></div>
<div>B = ({utils.formatHr(selectedDetail.hours || 0)} / {utils.formatHr(selectedDetail.allocMeta.revenueHrs || 0)}) × {utils.formatWon(selectedDetail.allocMeta.poolBVal || 0)} = <span className="font-black text-orange-700">{utils.formatWon(selectedDetail.allocB || 0)}</span></div>
</>
)}
</div>
<div className="overflow-auto border border-slate-200 rounded-xl">
{viewMode === 'team' ? (
<table className="w-full min-w-[560px] text-[11px]">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-3 py-2 text-left">구분</th>
<th className="px-3 py-2 text-right text-blue-700">A 배분</th>
<th className="px-3 py-2 text-right text-orange-700">B 배분</th>
<th className="px-3 py-2 text-right">합계</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{['일반경비', '전력비', '지급임차료'].map((k) => {
const a = selectedDetail.allocBucket?.A?.[k] || 0;
const b = selectedDetail.allocBucket?.B?.[k] || 0;
const teamKeyRaw = String(selectedDetail.name || '');
const teamKey = TEAM_RATIOS[k]?.[teamKeyRaw] !== undefined ? teamKeyRaw : teamKeyRaw.replace(/팀$/, '');
const pct = ((TEAM_RATIOS[k]?.[teamKey] || 0) * 100).toFixed(0);
return (
<tr key={`${selectedDetail.name}_bucket_${k}`}>
<td className="px-3 py-2 font-bold text-slate-700">{k} <span className="text-slate-400">({pct}%)</span></td>
<td className="px-3 py-2 text-right text-blue-700">{utils.formatWon(a)}</td>
<td className="px-3 py-2 text-right text-orange-700">{utils.formatWon(b)}</td>
<td className="px-3 py-2 text-right font-black">{utils.formatWon(a + b)}</td>
</tr>
);
})}
</tbody>
</table>
) : (
<table className="w-full min-w-[760px] text-[11px]">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-3 py-2 text-left">프로젝트</th>
<th className="px-3 py-2 text-right">직접비 기여</th>
<th className="px-3 py-2 text-right">직접비 비율</th>
<th className="px-3 py-2 text-right">배분공수</th>
<th className="px-3 py-2 text-right text-blue-700">A 기여</th>
<th className="px-3 py-2 text-right text-orange-700">B 기여</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(selectedDetail.allocTrace || []).map((r, idx) => (
<tr key={`${selectedDetail.name}_trace_${idx}`}>
<td className="px-3 py-2 font-bold text-slate-700">{r.projectName}</td>
<td className="px-3 py-2 text-right">{utils.formatWon(r.directShare || 0)}</td>
<td className="px-3 py-2 text-right">{((r.ratio || 0) * 100).toFixed(2)}%</td>
<td className="px-3 py-2 text-right">{utils.formatHr(r.shareHours || 0)}</td>
<td className="px-3 py-2 text-right text-blue-700">{utils.formatWon(r.allocA || 0)}</td>
<td className="px-3 py-2 text-right text-orange-700">{utils.formatWon(r.allocB || 0)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
<div className="space-y-6 text-left">
<div className="flex items-center gap-3 mb-4">
<div className={`w-1.5 h-6 rounded-full ${isInvalidProjectName(selectedDetail.name) ? 'bg-red-600' : 'bg-slate-800'}`}></div>
<h4 className="font-bold text-slate-800 text-base uppercase tracking-tighter italic">Breakdown Analysis</h4>
</div>
{selectedDetail.byAccount && (
<div className="flex items-center gap-2 mb-3">
{[
{ id: 'account', label: '계정별' },
{ id: 'form', label: '형식별' },
{ id: 'team', label: '팀별' }
].map(tab => (
<button
key={tab.id}
type="button"
onClick={() => setDetailTab(tab.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-all ${detailTab === tab.id ? 'bg-slate-800 text-white border-slate-800' : 'bg-white text-slate-500 border-slate-200 hover:text-slate-800'}`}
>
{tab.label}
</button>
))}
</div>
)}
<div className="space-y-3">
{(() => {
const groups = selectedDetail.byAccount
? (detailTab === 'form'
? selectedDetail.byForm
: detailTab === 'team'
? selectedDetail.byTeam
: selectedDetail.byAccount)
: selectedDetail.breakdown;
if (!groups) return <p className="text-center py-10 text-slate-300 font-bold uppercase italic text-xs tracking-widest">No Breakdown Data</p>;
return Object.entries(groups).sort((a,b) => b[1]-a[1]).map(([name, val], i) => {
const p = selectedDetail.direct > 0 ? (val / selectedDetail.direct * 100).toFixed(1) : 0;
const isErrParent = isInvalidProjectName(selectedDetail.name);
return (
<div key={i} className={`bg-slate-50 p-5 rounded-2xl border transition-all ${isErrParent ? 'hover:border-red-400' : 'hover:border-blue-200 hover:bg-white'}`}>
<div className="flex justify-between text-xs font-bold mb-3 text-slate-900">
<span className="text-slate-600 italic uppercase tracking-tighter">{name}</span>
<span className="text-slate-900">{utils.formatWon(val)} <span className={`${isErrParent ? 'text-red-600' : 'text-blue-600'} font-black ml-1.5`}>({p}%)</span></span>
</div>
<div className="w-full bg-slate-200 h-1 rounded-full overflow-hidden">
<div className={`h-full transition-all duration-[1000ms] ease-out ${isErrParent ? 'bg-red-600' : 'bg-slate-800'}`} style={{ width: `${p}%` }}></div>
</div>
</div>
);
});
})()}
</div>
</div>
<div className="text-center pt-8">
<button onClick={() => setSelectedDetail(null)} className={`w-full max-w-xs py-4 text-white font-bold rounded-2xl text-sm uppercase shadow-lg transition-all hover:scale-105 active:scale-95 ${isInvalidProjectName(selectedDetail.name) ? 'bg-red-800 hover:bg-red-900' : 'bg-slate-800 hover:bg-slate-700'}`}>확인 완료</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
</script>
</body>
</html>