Delete Execution.html (moved to execution/Execution.html)

This commit is contained in:
2026-03-19 15:04:59 +09:00
parent b07461eeed
commit bde4eee3fa

View File

@@ -1,558 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로젝트 통합 실행분석 시스템</title>
<!-- 외부 라이브러리 CDN -->
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;800&display=swap" rel="stylesheet">
<style>
body { font-family: 'Pretendard', sans-serif; letter-spacing: -0.025em; }
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #f1f5f9; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
.nav-container { background-color: #f1f5f9; padding: 5px; border-radius: 1.2rem; display: flex; gap: 4px; }
.tab-btn { padding: 10px 22px; border-radius: 0.9rem; font-size: 14px; font-weight: 800; transition: all 0.2s ease; cursor: pointer; border: none; }
.tab-btn.active { background-color: #6366f1; color: white; box-shadow: 0 4px 10px rgba(99, 102, 241, 0.25); }
.tab-btn.inactive { color: #64748b; background-color: transparent; }
.mode-btn { padding: 8px 18px; border-radius: 9999px; font-size: 11px; font-weight: 800; border: 1px solid #e2e8f0; transition: all 0.2s ease; cursor: pointer; background: white; color: #94a3b8; }
.mode-btn.active { background-color: #1e293b !important; color: white !important; border-color: #1e293b !important; }
.tooltip-container { position: relative; }
.tooltip-content {
visibility: hidden; width: auto; min-width: 180px; background-color: #1e293b; color: #fff;
text-align: center; border-radius: 12px; padding: 12px; position: absolute;
z-index: 100; top: 105%; left: 50%; transform: translateX(-50%);
margin-top: 8px; opacity: 0; transition: opacity 0.2s;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
font-size: 11px; pointer-events: none;
border: 1px solid rgba(255,255,255,0.1);
font-weight: 800;
}
.tooltip-container:hover .tooltip-content { visibility: visible; opacity: 1; }
input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.report-card { background: white; border: 1px solid #eef2f6; border-radius: 1.5rem; padding: 1.5rem; }
.report-tag { font-size: 10px; font-weight: 800; padding: 2px 8px; border-radius: 4px; text-transform: uppercase; }
.status-ok { background: #dcfce7; color: #15803d; }
.status-warn { background: #fef9c3; color: #a16207; }
.status-crit { background: #fee2e2; color: #b91c1c; }
</style>
</head>
<body class="bg-[#f8fafc]">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useMemo, useRef } = React;
const CAT_REV = "수입", CAT_CONST = "시공원가", CAT_MFG = "제조원가", CAT_LABOR = "인건비", CAT_ADMIN = "관리비", CAT_NON_OP = "자산/부채/영업외";
const CATEGORY_COLORS = { [CAT_REV]: '#6366f1', [CAT_CONST]: '#10b981', [CAT_MFG]: '#ef4444', [CAT_LABOR]: '#f59e0b', [CAT_ADMIN]: '#ec4899' };
const MASTER_JANGHEON = {
'101':'현금','103':'보통예금','104':'기타제예금','110':'받을어음','124':'매도가능증권','135':'매입부가세','178':'회원권','191':'출자금','192':'임차보증금','193':'주임종대출금','194':'전도금','195':'보증금','196':'대여금','206':'기계장치(설비)','208':'차량운반구(장비)','210':'공구와기구(공구)','212':'비품(비품)','219':'시설장치(건물)','231':'영업권','241':'사용수익기부자산','254':'예수금','257':'가수금','258':'매출부가세','259':'선수금','260':'단기차입금','290':'주임종차입금','293':'장기차입금','294':'임대보증금','399':'매출부가세','801':'감가상각비(자산)',
'401':'공사수입','402':'상품매출','403':'용역수입','404':'기타수입','405':'임대수입',
'501':'관리 임금','502':'시공 임금','503':'설계 임금','504':'공무 임금','505':'지원 임금','506':'생산관리 임금','507':'생산직(계) 임금','508':'생산직(일) 노무비','551':'관리 퇴직금','552':'시공 퇴직금','553':'설계 퇴직금','554':'공무 퇴직금','555':'지원 퇴직금','556':'생산관리 퇴직금','557':'생산직(계) 퇴직금','558':'생산직(일) 퇴직금','560':'갑근/주민세','561':'4대보험','562':'일용직 퇴직금','563':'퇴직급여',
'601':'주자재','602':'부자재','603':'안전관리비(현장)','604':'제작(능률급)','605':'인장(능률급)','606':'가설(능률급)','607':'판/가(능률급)','608':'장비비','609':'운반비','610':'일용노무비','611':'외주비 등','612':'주재비','613':'출장비','614':'보증/보상비','615':'기타경비','616':'해외출장비','651':'산업안전보건관리비','652':'(불인정)산업안전보건관리비',
'701':'철근가공','702':'판넬제작','703':'현장지원','704':'프리빔제작','705':'상품','711':'복리후생비 [제조]','712':'여비교통비 [제조]','713':'접대비 [제조]','714':'통신비 [제조]','715':'수도광열비 [제조]','716':'전력비 [제조]','717':'세금과공과금 [제조]','719':'지급임차료 [제조]','720':'수선비 [제조]','721':'보험료 [제조]','722':'차량유지비 [제조]','723':'연구개발비','724':'운반비 [제조]','726':'도서인쇄비 [제조]','729':'사무용품비 [제조]','730':'소모품비 [제조]','731':'지급수수료 [제조]','734':'장비비 [제조]','737':'관리비 [제조]','739':'소모자재비 [제조]','740':'교육훈련비 [제조]','741':'유지보수료 [제조]','749':'폐기물처리비 [제조]','750':'잡비 [제조]','751':'산업안전보건관리비 [제조]','752':'(불인정)산업안전보건관리비 [제조]',
'803':'안전관리비(본사)','811':'복리후생비 [관리]','812':'여비교통비 [관리]','813':'접대비 [관리]','814':'통신비 [관리]','815':'전력비 [관리]','817':'세금과공과금 [관리]','819':'지급임차료 [관리]','821':'보험료 [관리]','822':'차량유지비 [관리]','825':'교육훈련비 [관리]','826':'도서인쇄비 [관리]','827':'수선비 [관리]','829':'사무용품비 [관리]','830':'소모품비 [관리]','831':'지급수수료 [관리]','833':'광고선전비 [관리]','845':'상품','846':'부서비','849':'지원서비스','850':'공상처리비',
'901':'이자수입','902':'잡이익','903':'배당금','904':'국고보조금','911':'이자비용','912':'잡손실','913':'기부금','914':'기술료','999':'법인세등'
};
const MASTER_PTC = {
'401':'공사수입','402':'용역수입','403':'기타수입','110':'받을어음','711':'강관','712':'PHC','713':'결합구','714':'부자재','715':'주자재','721':'항타장비','722':'두부보강','723':'시험용역','725':'외주비 등','726':'제작','727':'인장','728':'가설','729':'철근가공','730':'공장제작','724':'노무비','513':'시공 퇴직금','731':'장비비','733':'운반비','732':'유류비','744':'안전관리비(현장)','734':'주재비','735':'기타경비','736':'복리후생비','737':'여비교통비','738':'지급임차료','739':'보증수수료','740':'소모자재비','741':'잡자재대','742':'가스수도료','743':'수선비','811':'복리후생비','812':'여비교통비','813':'접대비','814':'통신비','822':'차량유지비','823':'연구개발비','825':'교육훈련비','826':'도서인쇄비','827':'광고선전비','829':'사무용품비','830':'소모품비','843':'부서비','817':'세금과공과금','819':'지급임차료','821':'보험료','831':'지급수수료','849':'지원서비스','850':'안전관리비(본사)','501':'관리 임금','502':'공무 임금','503':'시공 임금','504':'설계 임금','505':'지원 임금','511':'관리 퇴직금','512':'공무 퇴직금','514':'설계 퇴직금','515':'지원 퇴직금','521':'소득세','522':'주민세','523':'4대보험','524':'퇴직급여','901':'이자수입','902':'국고보조금','903':'잡이익','904':'배당수익','961':'이자비용','962':'잡손실','963':'가지급금','999':'법인세등','103':'보통예금','124':'매도가능증권','135':'매입부가세','178':'회원권','191':'출자금','192':'임차보증금','193':'주임종대여금','194':'전도금','195':'보증금','196':'대여금','206':'기계장치','208':'차량운반구','210':'공구와기구','212':'비품','219':'시설장치','231':'영업권','241':'사용수익기부자산','257':'가수금','258':'매출부가세','259':'선수금','260':'단기차입금','290':'주임종차입금','293':'장기차입금','294':'임대보증금','801':'감가상각비(자산)'
};
function App() {
const [activeTab, setActiveTab] = useState('dashboard');
const [currentMode, setCurrentMode] = useState('JANGHEON');
const [rawData, setRawData] = useState([]);
const [uploading, setUploading] = useState(false);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [selectedPjt, setSelectedPjt] = useState('All');
const [searchTerm, setSearchTerm] = useState('');
const [activeCategoryFilter, setActiveCategoryFilter] = useState('All');
const [budgets, setBudgets] = useState({ 'JANGHEON': {}, 'PTC': {} });
const [isMasterModalOpen, setIsMasterModalOpen] = useState(false);
const [masterModalType, setMasterModalType] = useState('JANGHEON');
const [manualInputs, setManualInputs] = useState({});
const chartRef = useRef(null);
const chartInstance = useRef(null);
useEffect(() => { if (window.lucide) window.lucide.createIcons(); }, [activeTab, isMasterModalOpen, rawData]);
const getRoot = (code, pjtName = "", pjtCat = "", desc = "") => {
const cStr = String(code).trim();
if (cStr === '801') return CAT_NON_OP;
if (currentMode === 'PTC' && cStr === '823') return CAT_NON_OP;
const revenues = currentMode === 'JANGHEON' ? ['401','402','403','404','405'] : ['401','402','403','110'];
if (revenues.includes(cStr)) return CAT_REV;
const meta = (String(pjtName) + String(pjtCat) + String(desc)).toLowerCase();
const isMfg = meta.includes('제조');
if (currentMode === 'JANGHEON') {
if (['601', '602', '612'].includes(cStr)) return isMfg ? CAT_MFG : CAT_CONST;
const c = parseInt(cStr);
if (c >= 600 && c < 700) return CAT_CONST;
if (c >= 700 && c < 800) return CAT_MFG;
if (c >= 500 && c < 600) return CAT_LABOR;
if (c >= 800 && c < 900) return CAT_ADMIN;
} else {
let c = parseInt(cStr);
if ((c >= 700 && c < 800) || c === 513) return CAT_CONST;
if ((c >= 800 && c < 900) || (c >= 500 && c < 600)) return CAT_ADMIN;
}
return CAT_NON_OP;
};
const getDynamicName = (code, root, modeOverride) => {
const activeMode = modeOverride || currentMode;
const master = activeMode === 'JANGHEON' ? MASTER_JANGHEON : MASTER_PTC;
let n = master[code] || "계정 " + code;
if (activeMode === 'JANGHEON' && ['601', '602', '612'].includes(String(code))) {
n += (root === CAT_MFG ? " [제조]" : " [시공]");
}
return n;
};
const getRecommendation = (item, activeMode) => {
if (activeMode === 'PTC') return null;
const cStr = String(item.code).trim();
const c = parseInt(cStr);
if (isNaN(c) || c < 400 || c >= 900 || ['601', '602', '612', '801', '823'].includes(cStr)) return null;
const isMfgPjt = (String(item.pjt) + String(item.pjtCategory)).toLowerCase().includes('제조');
if (isMfgPjt && c >= 600 && c < 700) return "7" + cStr.substring(1);
if (!isMfgPjt && c >= 700 && c < 800) return "6" + cStr.substring(1);
return null;
};
const handleFileUpload = (e) => {
const file = e.target.files[0]; if (!file) return;
setUploading(true);
const reader = new FileReader();
reader.onload = (evt) => {
const bstr = evt.target.result;
const wb = XLSX.read(bstr, { type: 'binary' });
const ws = wb.Sheets[wb.SheetNames[0]];
const json = XLSX.utils.sheet_to_json(ws);
const newItems = json.map((row, idx) => {
const code = String(row['계정코드'] || row['코드'] || row['계정'] || '').trim();
const supply = parseFloat(String(row['공급가액'] || row['금액'] || row['합계금액'] || 0).replace(/,/g, '')) || 0;
const type = String(row['입/출금'] || row['입출금'] || row['구분'] || '');
const pjt = String(row['프로젝트명'] || row['PJT명'] || row['현장명'] || '미지정').trim();
const cat = String(row['PJT코드_수정'] || row['PJT코드수정'] || '').trim();
const dateRaw = row['거래일'] || row['일자'] || '';
let date = "";
if (typeof dateRaw === 'number') {
const d = new Date((dateRaw - 25569) * 86400 * 1000);
date = d.toISOString().split('T')[0];
} else {
date = String(dateRaw).trim().replace(/[\.\/]/g, '-');
}
return { id: idx, code, pjt, pjtCategory: cat, date, description: String(row['적요'] || ''), income: type.includes('입') ? supply : 0, expense: type.includes('출') ? supply : 0, isCorrected: false };
});
setRawData(newItems);
setUploading(false);
};
reader.readAsBinaryString(file);
};
const applyCorrection = (id, newCode) => {
if (!newCode) return;
setRawData(prev => prev.map(item => item.id === id ? { ...item, code: String(newCode).trim(), isCorrected: true } : item));
};
const processedData = useMemo(() => {
return rawData.map(d => {
const root = getRoot(d.code, d.pjt, d.pjtCategory, d.description);
return { ...d, root, name: getDynamicName(d.code, root), recommendation: d.isCorrected ? null : getRecommendation(d, currentMode) };
});
}, [rawData, currentMode]);
const filteredData = useMemo(() => {
return processedData.filter(d => {
const m = d.date.substring(0, 7);
const matchDate = (!startDate || m >= startDate) && (!endDate || m <= endDate);
const matchPjt = selectedPjt === 'All' || d.pjt === selectedPjt;
const matchSearch = !searchTerm || d.code.includes(searchTerm) || d.name.toLowerCase().includes(searchTerm.toLowerCase()) || d.description.toLowerCase().includes(searchTerm.toLowerCase());
return matchDate && matchPjt && matchSearch;
});
}, [processedData, startDate, endDate, selectedPjt, searchTerm]);
// 🛠️ 기간 선택에 따른 유효한 프로젝트 목록만 추출
const availableProjects = useMemo(() => {
const dataFilteredByDate = rawData.filter(d => {
const m = d.date.substring(0, 7);
return (!startDate || m >= startDate) && (!endDate || m <= endDate);
});
return [...new Set(dataFilteredByDate.map(r => r.pjt))].sort();
}, [rawData, startDate, endDate]);
const groupedAccountData = useMemo(() => {
const grouped = filteredData.reduce((acc, curr) => {
const key = curr.code + '_' + curr.root;
if (!acc[key]) acc[key] = { code: curr.code, root: curr.root, name: curr.name, income: 0, expense: 0 };
acc[key].income += curr.income; acc[key].expense += curr.expense;
return acc;
}, {});
return Object.values(grouped).filter(i => activeCategoryFilter === 'All' || i.root === activeCategoryFilter).sort((a, b) => parseInt(a.code) - parseInt(b.code));
}, [filteredData, activeCategoryFilter]);
const stats = useMemo(() => {
let rev = 0, costSum = 0, nonOpSum = 0;
const breakdown = {};
const cats = (currentMode === 'JANGHEON') ? [CAT_CONST, CAT_MFG, CAT_LABOR, CAT_ADMIN] : [CAT_CONST, CAT_ADMIN];
cats.forEach(c => breakdown[c] = 0);
filteredData.forEach(d => {
if (d.root === CAT_REV) rev += (d.income - d.expense);
else if (breakdown[d.root] !== undefined) { const amt = d.expense - d.income; breakdown[d.root] += amt; costSum += amt; }
else nonOpSum += (d.expense - d.income);
});
return { rev, costSum, profit: rev - costSum, breakdown, nonOpSum };
}, [filteredData, currentMode]);
// --- 📊 대시보드 및 예산 리포트 ---
const dynamicDashboardReport = useMemo(() => {
if (rawData.length === 0) return <div className="text-slate-400 py-6 text-center">데이터를 업로드하면 운영 분석 보고서가 생성됩니다.</div>;
const profitMargin = stats.rev > 0 ? (stats.profit / stats.rev * 100).toFixed(1) : 0;
const pjtName = selectedPjt === 'All' ? '전체 프로젝트' : selectedPjt;
const margin = parseFloat(profitMargin);
const status = margin > 15 ? {l:'Excellent', c:'status-ok'} : margin > 5 ? {l:'Stable', c:'status-ok'} : margin > 0 ? {l:'Caution', c:'status-warn'} : {l:'Critical', c:'status-crit'};
const issues = filteredData.filter(d => d.recommendation).length;
return (
<div className="space-y-6">
<div className="flex justify-between items-center border-b border-slate-50 pb-4">
<div>
<h4 className="text-xs font-black text-indigo-500 uppercase">Operational Insight</h4>
<p className="text-sm font-black text-slate-800">{pjtName} 운영 리포트</p>
</div>
<span className={`report-tag ${status.c}`}>{status.l}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="report-card">
<h5 className="text-[11px] font-black text-slate-400 mb-2 uppercase">수익 구조 진단</h5>
<p className="text-xs text-slate-600 leading-relaxed font-bold">
현재 실적 기준 영업이익률은 <b className="text-indigo-600">{profitMargin}%</b>.
{margin > 10 ? " 효율적인 원가 통제로 견조한 수익성을 유지하고 있습니다." : margin > 0 ? " 수익성이 다소 타이트합니다. 고정비 절감 방안을 검토하십시오." : " 현재 지출이 매출을 초과하여 운영 손실 구간에 진입했습니다."}
</p>
</div>
<div className="report-card">
<h5 className="text-[11px] font-black text-slate-400 mb-2 uppercase">데이터 정합성 현황</h5>
<p className="text-xs text-slate-600 leading-relaxed font-bold">
{issues > 0 ? `현재 하단 교정 섹션에 ${issues}건의 계정 불일치 의심 항목이 발견되었습니다. 정확한 분석을 위해 추천 코드를 적용하십시오.` : "모든 데이터가 마스터 계정 체계 내에서 정상적으로 분류되었습니다."}
</p>
</div>
</div>
</div>
);
}, [stats, rawData, selectedPjt, filteredData]);
const dynamicExecutionReport = useMemo(() => {
if (rawData.length === 0) return <div className="text-slate-400 py-6 text-center">예산을 입력하고 효율 분석 리포트를 확인하세요.</div>;
const cats = (currentMode === 'JANGHEON') ? [CAT_CONST, CAT_MFG, CAT_LABOR, CAT_ADMIN] : [CAT_CONST, CAT_ADMIN];
const totalBud = cats.reduce((acc, cat) => acc + (budgets[currentMode][cat] || 0), 0);
const executionRate = totalBud > 0 ? (stats.costSum / totalBud * 100).toFixed(1) : 0;
const rateNum = parseFloat(executionRate);
const riskyCats = cats.filter(cat => {
const act = stats.breakdown[cat] || 0;
const bud = budgets[currentMode][cat] || 0;
return bud > 0 && act > (bud * 0.9);
});
return (
<div className="space-y-6">
<div className="flex justify-between items-center border-b border-slate-50 pb-4">
<div>
<h4 className="text-xs font-black text-emerald-500 uppercase">Budget Efficiency</h4>
<p className="text-sm font-black text-slate-800">예산 집행 효율 분석</p>
</div>
</div>
{totalBud > 0 ? (
<div className="space-y-6">
<div className="report-card">
<h5 className="text-[11px] font-black text-slate-400 mb-3 uppercase font-bold">전체 예산 소진 속도</h5>
<div className="flex justify-between items-end mb-2">
<span className="text-2xl font-black text-slate-800">{executionRate}%</span>
<span className="text-[10px] text-slate-400 font-black font-bold">Consumed of Total Budget</span>
</div>
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden">
<div className={`h-full ${rateNum > 90 ? 'bg-rose-500' : 'bg-emerald-500'}`} style={{width: `${Math.min(rateNum, 100)}%`}}></div>
</div>
<p className="text-[11px] text-slate-500 mt-3 font-bold">
{rateNum > 100 ? "예산을 초과하였습니다. 원가 상승 요인 분석 후 실행 변경이 필요합니다." : rateNum > 85 ? "예산 소진 임박 단계입니다. 자금 집행 우선순위를 조정하십시오." : "안정적인 집행률을 유지하고 있습니다."}
</p>
</div>
{riskyCats.length > 0 && (
<div className="report-card bg-rose-50/30 border-rose-100">
<h5 className="text-[11px] font-black text-rose-800 mb-2 font-bold uppercase">위험 관리 항목 (소진율 90%+)</h5>
<div className="flex flex-wrap gap-2">
{riskyCats.map(c => <span key={c} className="px-2 py-1 bg-rose-100 text-rose-700 text-[10px] rounded font-black">{c}</span>)}
</div>
</div>
)}
</div>
) : (
<div className="text-xs text-slate-400 italic py-6 text-center">우측 표에 실행 예산액을 입력하시면 정밀 효율 분석이 시작됩니다.</div>
)}
</div>
);
}, [stats, budgets, currentMode, rawData]);
// 차트 업데이트
useEffect(() => {
if (activeTab === 'analysis' && chartRef.current) {
if (chartInstance.current) chartInstance.current.destroy();
const ctx = chartRef.current.getContext('2d');
const cats = (currentMode === 'JANGHEON') ? [CAT_REV, CAT_CONST, CAT_MFG, CAT_LABOR, CAT_ADMIN] : [CAT_REV, CAT_CONST, CAT_ADMIN];
chartInstance.current = new Chart(ctx, {
type: 'bar',
data: {
labels: cats,
datasets: [
{ label: '실제 집행', data: cats.map(c => c === CAT_REV ? stats.rev : (stats.breakdown[c] || 0)), backgroundColor: '#6366f1', borderRadius: 6, barThickness: 20 },
{ label: '실행 예산', data: cats.map(c => budgets[currentMode][c] || 0), backgroundColor: '#e2e8f0', borderRadius: 6, barThickness: 20 }
]
},
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }
});
}
return () => { if (chartInstance.current) chartInstance.current.destroy(); };
}, [activeTab, currentMode, stats, budgets]);
return (
<div className="bg-[#f8fafc] min-h-screen text-slate-900 font-bold pb-20">
<div className="max-w-[1440px] mx-auto p-4 md:p-8 space-y-6">
<header className="flex flex-col lg:flex-row justify-between items-center bg-white p-6 rounded-[2.5rem] shadow-sm border border-slate-200 gap-6">
<div className="flex items-center gap-4">
<div className="bg-indigo-600 p-4 rounded-[1.3rem] text-white shadow-lg"><i className="lucide-activity"></i></div>
<div>
<h1 className="text-2xl font-black text-slate-900 leading-tight">프로젝트 통합 실행분석</h1>
<div className="flex gap-2 mt-2">
<button onClick={() => setCurrentMode('JANGHEON')} className={`mode-btn ${currentMode === 'JANGHEON' ? 'active' : ''}`}>장헌산업 기준</button>
<button onClick={() => setCurrentMode('PTC')} className={`mode-btn ${currentMode === 'PTC' ? 'active' : ''}`}>PTC 기준</button>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<nav className="nav-container mr-4">
<button onClick={() => setActiveTab('dashboard')} className={`tab-btn ${activeTab === 'dashboard' ? 'active' : 'inactive'}`}>종합 대시보드</button>
<button onClick={() => setActiveTab('analysis')} className={`tab-btn ${activeTab === 'analysis' ? 'active' : 'inactive'}`}>실행 예산 분석</button>
</nav>
<label className="px-6 py-3 bg-indigo-600 text-white rounded-2xl cursor-pointer font-bold shadow-md hover:bg-indigo-700 transition-all flex items-center gap-2">
{uploading ? <span className="animate-spin">🌀</span> : <i className="lucide-upload"></i>} 데이터 업로드
<input type="file" className="hidden" accept=".xlsx, .xls" onChange={handleFileUpload} />
</label>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 bg-white p-5 rounded-[2.5rem] border shadow-sm">
<div className="space-y-1 font-bold">
<label className="text-[10px] text-slate-400 uppercase font-black font-mono">DATE RANGE</label>
<div className="flex gap-1 font-bold">
<input type="month" className="w-full bg-slate-50 rounded-xl p-2 text-xs border-none font-bold outline-none" value={startDate} onChange={e => setStartDate(e.target.value)} />
<input type="month" className="w-full bg-slate-50 rounded-xl p-2 text-xs border-none font-bold outline-none" value={endDate} onChange={e => setEndDate(e.target.value)} />
</div>
</div>
<div className="space-y-1 font-bold">
<label className="text-[10px] text-slate-400 uppercase font-black font-mono font-bold">PROJECT</label>
<select className="w-full bg-slate-50 rounded-xl p-3 text-xs border-none cursor-pointer font-bold outline-none" value={selectedPjt} onChange={e => setSelectedPjt(e.target.value)}>
<option value="All">전체 프로젝트 보기</option>
{availableProjects.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div className="space-y-1 font-bold">
<label className="text-[10px] text-slate-400 uppercase font-black font-mono font-bold">SEARCH</label>
<input type="text" placeholder="명칭/코드/적요..." className="w-full bg-slate-50 rounded-xl p-3 text-xs border-none font-bold outline-none" value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
</div>
</div>
{activeTab === 'dashboard' ? (
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-center font-bold">
<div className="bg-white p-8 rounded-[2.5rem] border shadow-sm flex flex-col items-center justify-center space-y-1">
<p className="text-[11px] text-emerald-500 uppercase font-black font-mono font-bold">OPERATING REVENUE (A)</p>
<p className="text-3xl font-black">{stats.rev.toLocaleString()}</p>
</div>
<div className="bg-white p-8 rounded-[2.5rem] border shadow-sm flex flex-col items-center justify-center space-y-1 font-bold">
<p className="text-[11px] text-rose-500 uppercase font-black font-mono font-bold">OPERATING EXPENSE (B)</p>
<p className="text-3xl font-black">{stats.costSum.toLocaleString()}</p>
</div>
<div className="bg-[#111827] p-8 rounded-[2.5rem] text-white shadow-xl flex flex-col items-center justify-center space-y-1 font-bold">
<p className="text-[11px] text-indigo-300 uppercase font-black font-mono font-bold">ESTIMATED PROFIT (A-B)</p>
<p className={`text-3xl font-black ${stats.profit >= 0 ? 'text-white' : 'text-rose-400'}`}>{stats.profit.toLocaleString()}</p>
</div>
</div>
<div className="bg-white p-8 rounded-[3rem] border border-indigo-100 shadow-sm space-y-4 font-bold overflow-hidden">
<div className="flex items-center gap-3 border-b border-slate-50 pb-4 mb-4">
<div className="bg-indigo-600 p-2.5 rounded-xl text-white font-bold shadow-lg shadow-indigo-100">
<i className="lucide-activity"></i>
</div>
<h3 className="text-lg font-black text-slate-800">실시간 현장 운영 분석 보고서 (Operating Status)</h3>
</div>
{dynamicDashboardReport}
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 font-bold">
<div className="lg:col-span-5 bg-white p-8 rounded-[2.5rem] border shadow-sm min-h-[400px]">
<h3 className="text-base font-black text-slate-800 mb-6 flex items-center gap-2 font-bold"><i className="lucide-pie-chart text-indigo-600"></i> </h3>
<div className="space-y-4 font-bold">
{Object.entries(stats.breakdown).concat([[CAT_REV, stats.rev]]).sort((a,b) => Math.abs(b[1]) - Math.abs(a[1])).map(([cat, val]) => {
const total = Object.values(stats.breakdown).reduce((s,v)=>s+Math.abs(v), 0) + Math.abs(stats.rev) || 1;
const pct = (Math.abs(val) / total * 100).toFixed(1);
return (
<div key={cat} onClick={() => setActiveCategoryFilter(cat)} className={`p-2 rounded-xl cursor-pointer ${activeCategoryFilter === cat ? 'bg-indigo-50 border border-indigo-100' : ''}`}>
<div className="flex justify-between text-[11px] mb-1 font-bold"><span>{cat} ({pct}%)</span><span className="font-black">{val.toLocaleString()}</span></div>
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden font-bold"><div className="h-full transition-all duration-700" style={{width: `${pct}%`, backgroundColor: CATEGORY_COLORS[cat] || '#94a3b8'}}></div></div>
</div>
);
})}
<div onClick={() => setActiveCategoryFilter(CAT_NON_OP)} className={`pt-4 mt-2 border-t cursor-pointer ${activeCategoryFilter === CAT_NON_OP ? 'bg-slate-50' : ''}`}>
<div className="flex justify-between text-[11px] font-bold font-bold"><span>기타 수지/자산</span><span className="font-black">{stats.nonOpSum.toLocaleString()}</span></div>
</div>
</div>
</div>
<div className="lg:col-span-7 bg-white p-8 rounded-[2.5rem] border shadow-sm h-[650px] flex flex-col font-bold">
<div className="flex justify-between items-center mb-6">
<h3 className="text-base font-black text-slate-800 flex items-center gap-2 font-bold font-bold"><i className="lucide-list text-indigo-600"></i> </h3>
{activeCategoryFilter !== 'All' && <button onClick={() => setActiveCategoryFilter('All')} className="text-xs text-indigo-600 font-bold font-bold">초기화</button>}
</div>
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar space-y-3 font-bold">
{groupedAccountData.length === 0 ? <div className="text-center py-20 text-slate-400 font-bold">데이터 없음</div> : groupedAccountData.map((i, idx) => {
const net = i.root === CAT_REV ? (i.income - i.expense) : (i.expense - i.income);
return (
<div key={idx} className="tooltip-container">
<div className="tooltip-content font-bold">입금: {i.income.toLocaleString()} / 출금: {i.expense.toLocaleString()}</div>
<div className="flex items-center justify-between p-3.5 bg-white border border-slate-100 rounded-2xl hover:shadow-sm font-bold">
<div className="flex items-center gap-4">
<span className="text-[10px] font-mono text-slate-400 font-bold">{i.code}</span>
<span className="text-sm font-bold">{i.name}</span>
</div>
<span className="text-sm font-black font-bold">{net.toLocaleString()}</span>
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="bg-white rounded-[2.5rem] border border-amber-100 shadow-sm overflow-hidden mt-6 font-bold">
<div className="px-8 py-6 bg-amber-50/40 border-b border-amber-100 flex flex-wrap justify-between items-center gap-4 font-bold">
<div className="flex items-center gap-4 font-bold font-bold">
<div className="bg-amber-100 p-3 rounded-2xl text-amber-600 font-bold"><i className="lucide-alert-triangle"></i></div>
<h3 className="text-base font-black text-amber-900 font-bold font-bold">데이터 불일치 정밀 검토 교정</h3>
<div className="flex gap-2 ml-4">
<button onClick={() => { setMasterModalType('JANGHEON'); setIsMasterModalOpen(true); }} className="px-4 py-2 bg-white text-indigo-600 border border-indigo-100 rounded-xl text-xs font-black hover:bg-indigo-50 transition-all shadow-sm font-bold font-bold">장헌 계정표</button>
<button onClick={() => { setMasterModalType('PTC'); setIsMasterModalOpen(true); }} className="px-4 py-2 bg-white text-indigo-600 border border-indigo-100 rounded-xl text-xs font-black hover:bg-indigo-50 transition-all shadow-sm font-bold font-bold">PTC 계정표</button>
</div>
</div>
<span className="text-[11px] text-amber-700 bg-white px-3 py-1 rounded-full border border-amber-100 font-mono font-bold font-bold">{filteredData.filter(d => d.recommendation).length} 발견</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs text-left font-bold">
<thead>
<tr className="bg-slate-50 text-slate-400 border-b uppercase font-bold">
<th className="p-5 font-bold">코드</th><th className="p-5 font-bold"></th><th className="p-5 font-bold"></th><th className="p-5 font-bold"> </th><th className="p-5 font-bold"></th><th className="p-5 text-right font-bold"></th><th className="p-5 text-center font-bold"> </th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-slate-700 font-bold">
{filteredData.filter(d => d.recommendation).length === 0 ? <tr><td colSpan="7" className="p-10 text-center text-slate-400 font-bold">데이터 없음</td></tr> : filteredData.filter(d => d.recommendation).map((item) => (
<tr key={item.id} className="hover:bg-amber-50/20 transition-colors font-bold">
<td className="p-4 font-mono text-slate-400 font-bold">{item.code}</td><td className="p-4 font-bold">{item.date}</td>
<td className="p-4 truncate max-w-[120px] font-bold">{item.pjt}</td><td className="p-4 font-bold">{item.name}</td>
<td className="p-4 text-slate-400 font-bold">{item.description}</td><td className="p-4 text-right font-bold">{Math.max(item.income, item.expense).toLocaleString()}</td>
<td className="p-4 text-center font-bold">
<div className="flex gap-2 justify-center items-center">
<button onClick={() => applyCorrection(item.id, item.recommendation)} className="px-2 py-1 bg-amber-100 text-amber-700 rounded text-[10px] font-black hover:bg-amber-200 transition-all font-bold">{item.recommendation} 추천</button>
<input type="text" placeholder="코드" className="border border-slate-200 rounded-lg p-1 w-14 text-center text-[10px] font-bold outline-none" onChange={(e) => setManualInputs({ ...manualInputs, [item.id]: e.target.value })} />
<button onClick={() => applyCorrection(item.id, manualInputs[item.id])} className="px-2 py-1 bg-indigo-600 text-white rounded text-[10px] font-black hover:bg-indigo-700 transition-all font-bold">적용</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
) : (
<div className="bg-white p-8 rounded-[2.5rem] border shadow-sm space-y-6 font-bold">
<h3 className="text-lg font-black mb-6 flex items-center gap-2 font-bold"><i className="lucide-trending-up text-emerald-500"></i> </h3>
<div className="bg-slate-50 rounded-[2rem] p-8 border border-slate-100 mb-6 font-bold">
<div className="flex items-center gap-2 mb-4 text-emerald-600 font-black font-bold">
<i className="lucide-bar-chart-3"></i>
<span> 분석가 경영 효율 리포트 (Budget Efficiency Report)</span>
</div>
{dynamicExecutionReport}
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 font-bold">
<div className="lg:col-span-7 font-bold">
<div className="bg-white rounded-2xl border border-slate-100 overflow-hidden font-bold">
<table className="w-full text-sm text-left font-bold">
<thead className="bg-slate-100 text-slate-400 border-b text-[11px] font-bold">
<tr><th className="p-4 pl-6 font-bold">운영 분류</th><th className="p-4 text-right font-bold"> (A)</th><th className="p-4 text-center font-bold"> (B)</th><th className="p-4 text-right font-bold"> (%)</th></tr>
</thead>
<tbody className="divide-y divide-slate-100 font-bold">
{((currentMode === 'JANGHEON') ? [CAT_REV, CAT_CONST, CAT_MFG, CAT_LABOR, CAT_ADMIN] : [CAT_REV, CAT_CONST, CAT_ADMIN]).map(cat => {
const act = cat === CAT_REV ? stats.rev : (stats.breakdown[cat] || 0);
const bud = budgets[currentMode][cat] || 0;
const rate = bud > 0 ? (act / bud * 100).toFixed(1) : 0;
return (
<tr key={cat}>
<td className="p-4 pl-6 font-black font-bold">{cat}</td><td className="p-4 text-right font-bold">{act.toLocaleString()}</td>
<td className="p-4 text-center font-bold font-bold"><input type="number" className="w-32 bg-slate-50 rounded-lg p-2 text-right text-xs outline-none font-bold font-bold" value={bud || ''} onChange={e => setBudgets({...budgets, [currentMode]: {...budgets[currentMode], [cat]: parseFloat(e.target.value) || 0}})} /></td>
<td className="p-4 text-right font-black font-bold font-bold">{rate}%</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<div className="lg:col-span-5 space-y-6 font-bold">
<div className="h-[350px] w-full relative font-bold font-bold"><canvas ref={chartRef}></canvas></div>
</div>
</div>
</div>
)}
</div>
{isMasterModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[1000] backdrop-blur-sm" onClick={() => setIsMasterModalOpen(false)}>
<div className="bg-white w-90% max-w-[850px] max-h-[85vh] rounded-[2rem] overflow-hidden flex flex-col shadow-2xl font-bold" onClick={e => e.stopPropagation()}>
<div className="p-8 border-b flex justify-between items-center bg-slate-50 font-bold">
<h2 className="text-xl font-black text-slate-800 font-bold">{masterModalType === 'JANGHEON' ? "장헌산업 마스터 계정표" : "PTC 마스터 계정표"}</h2>
<button onClick={() => setIsMasterModalOpen(false)} className="p-2 hover:bg-slate-200 rounded-full text-slate-400 font-bold font-bold font-bold"><i className="lucide-x"></i></button>
</div>
<div className="p-6 overflow-y-auto custom-scrollbar flex-1 grid grid-cols-2 md:grid-cols-3 gap-3 font-bold">
{Object.entries(masterModalType === 'JANGHEON' ? MASTER_JANGHEON : MASTER_PTC).sort((a,b)=>parseInt(a[0])-parseInt(b[0])).map(([k, v]) => (
<div key={k} className="flex items-center gap-3 p-2 bg-slate-50 rounded-lg font-bold">
<span className="text-indigo-600 font-mono text-[11px] w-8 font-bold font-bold">{k}</span>
<span className="text-xs truncate font-bold font-bold font-bold">{v}</span>
</div>
))}
</div>
<div className="p-6 bg-slate-50 border-t flex justify-end font-bold font-bold"><button onClick={() => setIsMasterModalOpen(false)} className="px-8 py-3 bg-indigo-600 text-white rounded-xl font-black font-bold font-bold font-bold">닫기</button></div>
</div>
</div>
)}
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>