feat: add Execution.html for integrated project execution analysis system
This commit is contained in:
558
Execution.html
Normal file
558
Execution.html
Normal file
@@ -0,0 +1,558 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user