1618 lines
93 KiB
HTML
1618 lines
93 KiB
HTML
<!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>
|