1349 lines
103 KiB
HTML
1349 lines
103 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>프로젝트 통합 실행분석 시스템</title>
|
|
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
<script src="https://unpkg.com/@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://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; background-color: #f8fafc; font-size: 13px; color: #1e293b; }
|
|
.custom-scrollbar::-webkit-scrollbar { width: 5px; }
|
|
.custom-scrollbar::-webkit-scrollbar-track { background: #f1f5f9; }
|
|
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
|
.nav-container { background-color: #f1f5f9; padding: 4px; border-radius: 1rem; display: flex; gap: 3px; }
|
|
.tab-btn { padding: 6px 18px; border-radius: 0.7rem; font-size: 13px; font-weight: 700; transition: all 0.2s ease; cursor: pointer; border: none; color: #64748b; background-color: transparent; }
|
|
.tab-btn.active { background-color: #6366f1; color: white; box-shadow: 0 2px 6px rgba(99, 102, 241, 0.2); }
|
|
.mode-btn { padding: 4px 12px; 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; }
|
|
.code-chip { background: #eff6ff; padding: 3px 8px; border-radius: 6px; font-size: 11.5px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #bfdbfe; font-weight: 700; color: #1e40af; }
|
|
.delete-chip-btn { background: #fee2e2; color: #ef4444; border-radius: 4px; width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; font-size: 12px; cursor: pointer; border: 1px solid #fecaca; }
|
|
.add-code-trigger { width: 26px; height: 26px; border-radius: 7px; background: #6366f1; color: white; display: flex; align-items: center; justify-content: center; cursor: pointer; border: none; font-size: 18px; font-weight: 600; }
|
|
.input-emphasize { border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px; font-weight: 800; text-align: right; font-size: 12px; width: 100%; }
|
|
.input-emphasize:focus { border-color: #6366f1; outline: none; background: #fcfcff; }
|
|
.input-auto-calc { background-color: #f8fafc !important; color: #6366f1 !important; border: 1px dashed #cbd5e1 !important; cursor: not-allowed; }
|
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 5000; backdrop-filter: blur(3px); }
|
|
.modal-content { background: white; border-radius: 1.5rem; width: 85%; max-width: 800px; max-height: 85vh; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.2); }
|
|
.tooltip-container { position: relative; }
|
|
.tooltip-content { visibility: hidden; width: auto; min-width: 140px; background-color: #1e293b; color: #fff; text-align: center; border-radius: 8px; padding: 10px; position: absolute; z-index: 100; top: 105%; left: 50%; transform: translateX(-50%); margin-top: 6px; opacity: 0; transition: opacity 0.2s; font-size: 10px; pointer-events: none; font-weight: 700; border: 1px solid rgba(255,255,255,0.1); }
|
|
.tooltip-container:hover .tooltip-content { visibility: visible; opacity: 1; }
|
|
.group-header-row { background-color: #f8fafc; border-left: 5px solid #6366f1; }
|
|
.group-title { font-size: 13px; font-weight: 900; color: #334155; text-transform: uppercase; }
|
|
.status-chip { padding: 2px 10px; border-radius: 9999px; font-size: 10px; font-weight: 900; text-transform: uppercase; }
|
|
.status-ok { background: #ecfdf5; color: #10b981; border: 1px solid #d1fae5; }
|
|
.status-warn { background: #fffbeb; color: #f59e0b; border: 1px solid #fef3c7; }
|
|
.factory-control-box { display: flex; align-items: center; gap: 8px; background: white; padding: 4px 12px; border-radius: 10px; border: 1px solid #e2e8f0; }
|
|
.factory-year-select { font-size: 12px; font-weight: 800; border: none; outline: none; background: transparent; cursor: pointer; color: #6366f1; }
|
|
.factory-apply-btn { font-size: 12px; font-weight: 900; color: white; background: #6366f1; padding: 4px 14px; border-radius: 8px; cursor: pointer; border: none; transition: all 0.2s; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
|
.animate-fade { animation: fadeIn 0.3s ease-out forwards; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useMemo, useRef } = React;
|
|
|
|
const CAT_REV = "수입";
|
|
const CAT_CONST = "시공원가";
|
|
const CAT_MFG = "제조원가";
|
|
const CAT_LABOR = "인건비";
|
|
const CAT_ADMIN = "관리비";
|
|
const CAT_NON_OP = "기타 수지/자산";
|
|
|
|
const CATEGORY_COLORS = {
|
|
[CAT_REV]: '#6366f1',
|
|
[CAT_CONST]: '#10b981',
|
|
[CAT_MFG]: '#ef4444',
|
|
[CAT_LABOR]: '#f59e0b',
|
|
[CAT_ADMIN]: '#ec4899',
|
|
[CAT_NON_OP]: '#94a3b8'
|
|
};
|
|
|
|
const GROUP_LABELS = {
|
|
REVENUE: "수입 (Revenue)",
|
|
DIRECT: "직접비 (Direct Costs)",
|
|
FACTORY: "공장가공비 (Factory Costs)",
|
|
INDIRECT: "직관비 (Indirect/Admin Costs)"
|
|
};
|
|
|
|
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':'차량유지비 [관리]','823':'교육훈련비','825':'교육훈련비 [관리]','826':'도서인쇄비 [관리]','827':'수선비 [관리]','829':'사무용품비 [관리]','830':'소모품비 [관리]','831':'지급수수료 [관리]','833':'광고선전비 [관리]','845':'상품','846':'부서비','849':'지원서비스','850':'공상처리비',
|
|
'901':'이자수입','902':'잡이익','903':'배당금','904':'국고보조금','911':'이자비용','912':'잡손실','913':'기부금','914':'기술료','999':'법인세등'
|
|
};
|
|
|
|
const MASTER_PTC = {
|
|
'103':'보통예금','110':'받을어음','124':'매도가능증권','135':'매입부가세','178':'회원권','191':'출자금','192':'임차보증금','193':'주임종대여금','194':'전도금','195':'보증금','196':'대여금','206':'기계장치','208':'차량운반구','210':'공구와기구','212':'비품','219':'시설장치','231':'영업권','241':'사용수익기부자산','257':'가수금','258':'매출부가세','259':'선수금','260':'단기차입금','290':'주임종차입금','293':'장기차입금','294':'임대보증금','401':'공사수입','402':'용역수입','403':'기타수입','501':'관리 임금','502':'공무 임금','503':'시공 임금','504':'설계 임금','505':'지원 임금','511':'관리 퇴직금','512':'공무 퇴직금','513':'시공 퇴직금','514':'설계 퇴직금','515':'지원 퇴직금','521':'소득세','522':'주민세','523':'4대보험','524':'퇴직급여','711':'강관','712':'PHC','713':'결합구','714':'부자재','715':'주자재','721':'항타장비','722':'두부보강','723':'시험용역','724':'노무비','725':'외주비 등','726':'제작','727':'인장','728':'가설','729':'철근가공','730':'공장제작','731':'장비비','732':'유류비','733':'운반비','734':'주재비','735':'기타경비','736':'복리후생비','737':'여비교통비','738':'지급임차료','739':'보증수수료','740':'소모자재비','741':'잡자재대','742':'가스수도료','743':'수선비','744':'안전관리비(현장)','801':'감가상각비(자산)','811':'복리후생비','812':'여비교통비','813':'접대비','814':'통신비','817':'세금과공과금','819':'지급임차료','821':'보험료','822':'차량유지비','823':'연구개발비','825':'교육훈련비','826':'도서인쇄비','827':'광고선전비','829':'사무용품비','830':'소모품비','831':'지급수수료','843':'부서비','849':'지원서비스','850':'안전관리비(본사)','901':'이자수입','902':'국고보조금','903':'잡이익','904':'배당수익','961':'이자비용','962':'잡손실','963':'가지급금','999':'법인세등'
|
|
};
|
|
|
|
const INITIAL_JH_CATEGORIES = [
|
|
{ id: 'income', name: '공사수입', codes: ['401'], type: 'revenue', group: 'REVENUE' },
|
|
{ id: 'mat_main', name: '주자재', codes: ['601'], type: 'cost', group: 'DIRECT' },
|
|
{ id: 'mat_sub', name: '부자재', codes: ['602'], type: 'cost', group: 'DIRECT' },
|
|
{ id: 'eff_prod', name: '제작(능률급)', codes: ['604'], type: 'cost', group: 'DIRECT' },
|
|
{ id: 'eff_tens', name: '인장(능률급)', codes: ['605'], type: 'cost', group: 'DIRECT' },
|
|
{ id: 'eff_inst', name: '가설(능률급)', codes: ['606'], type: 'cost', group: 'DIRECT' },
|
|
{ id: 'eff_sale', name: '판/가(능률급)', codes: ['607'], type: 'cost', group: 'DIRECT' },
|
|
{ id: 'safety', name: '안전관리비 (현장)', codes: ['603'], type: 'cost', group: 'DIRECT' },
|
|
{ id: 'residence', name: '주재비 및 기타경비', codes: ['612', '615'], type: 'cost', group: 'DIRECT' },
|
|
{ id: 'site_manager', name: '현장소장인건비', codes: [], type: 'cost', group: 'DIRECT' },
|
|
{ id: 'etc_trip', name: '보증/검수/출장비', codes: ['613', '614', '616'], type: 'cost', group: 'DIRECT' },
|
|
{ id: 'direct_exp', name: '직접경비', codes: ['610', '608', '609'], type: 'cost', group: 'DIRECT' },
|
|
{ id: 'outsource', name: '외주비', codes: ['611'], type: 'cost', group: 'DIRECT' },
|
|
{ id: 'factory_rebar', name: '철근가공', codes: [], type: 'cost', group: 'FACTORY' },
|
|
{ id: 'factory_predeck', name: 'Pre-Deck제작,운반', codes: [], type: 'cost', group: 'FACTORY' },
|
|
{ id: 'factory_girder', name: '가로보제작,운반', codes: [], type: 'cost', group: 'FACTORY' },
|
|
{ id: 'maint_mold', name: '거푸집', codes: [], type: 'cost', group: 'FACTORY' },
|
|
{ id: 'maint_bench', name: '제작대', codes: [], type: 'cost', group: 'FACTORY' },
|
|
{ id: 'admin_hq', name: '본사관리비', codes: [], type: 'cost', note: '수입 x 15% 자동', isAuto: true, group: 'INDIRECT' },
|
|
{ id: 'as_cost', name: 'A/S 비용', codes: [], type: 'cost', note: '수입 x 3% 자동', isAuto: true, group: 'INDIRECT' }
|
|
];
|
|
const DEFAULT_FACTORY_STANDARDS = {
|
|
'2023': { factory_rebar: 409355, factory_predeck: 315802, factory_girder: 315802, maint_mold: 122489, maint_bench: 25492 },
|
|
'2024': { factory_rebar: 497479, factory_predeck: 315802, factory_girder: 315802, maint_mold: 158496, maint_bench: 24061 }
|
|
};
|
|
const JANGDONG_BUDGET_FALLBACK = {
|
|
nonFactoryBudgets: {
|
|
income: 445133000,
|
|
mat_main: 112688920.4,
|
|
mat_sub: 1126889,
|
|
eff_prod: 64000000,
|
|
eff_tens: 11200000,
|
|
eff_inst: 74200000,
|
|
eff_sale: 21320000,
|
|
safety: 0,
|
|
residence: 2880000,
|
|
site_manager: 0,
|
|
etc_trip: 3414400,
|
|
direct_exp: 2410000,
|
|
outsource: 0,
|
|
admin_hq: 66769950,
|
|
as_cost: 13353990,
|
|
factory_rebar: 11066155.2,
|
|
factory_predeck: 35230250,
|
|
factory_girder: 4500000,
|
|
maint_mold: 5000000,
|
|
maint_bench: 8000000
|
|
},
|
|
factory: {
|
|
factory_rebar: { unitPrice: 300000, quantity: 36.887184 },
|
|
factory_predeck: { unitPrice: 230000, quantity: 557 },
|
|
factory_girder: { unitPrice: 250000, quantity: 18 },
|
|
maint_mold: { unitPrice: 5000000, quantity: 1 },
|
|
maint_bench: { unitPrice: 20000, quantity: 400 }
|
|
},
|
|
bridgeInfo: {
|
|
bridgeName: '장동교',
|
|
contractStart: '2023.03.20.',
|
|
contractEnd: '2027.06.15.',
|
|
bridgeType: '거더',
|
|
location: '전북 완주군 이서면 일대'
|
|
}
|
|
};
|
|
|
|
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({});
|
|
const [unitPrices, setUnitPrices] = useState({});
|
|
const [quantities, setQuantities] = useState({});
|
|
const [budgetUnitPrices, setBudgetUnitPrices] = useState({});
|
|
const [budgetQuantities, setBudgetQuantities] = useState({});
|
|
const [jhMapping, setJhMapping] = useState(INITIAL_JH_CATEGORIES);
|
|
const [mappingTargetId, setMappingTargetId] = useState(null);
|
|
const [isAccountSelectOpen, setIsAccountSelectOpen] = useState(false);
|
|
const [isMasterModalOpen, setIsMasterModalOpen] = useState(false);
|
|
const [masterModalType, setMasterModalType] = useState('JANGHEON');
|
|
const [factoryYear, setFactoryYear] = useState('2024');
|
|
const [factoryStandards, setFactoryStandards] = useState(DEFAULT_FACTORY_STANDARDS);
|
|
const [isFactoryModalOpen, setIsFactoryModalOpen] = useState(false);
|
|
const [newFactoryYear, setNewFactoryYear] = useState('');
|
|
const [manualInputs, setManualInputs] = useState({});
|
|
const [autoBudgetAppliedProjects, setAutoBudgetAppliedProjects] = useState({});
|
|
const [uploadedProjectPresets, setUploadedProjectPresets] = useState({});
|
|
const [projectBridgeMeta, setProjectBridgeMeta] = useState({});
|
|
const [analysisDetailCat, setAnalysisDetailCat] = useState(null);
|
|
const jangdongPresetRef = useRef(null);
|
|
const budgetUploadRef = useRef(null);
|
|
|
|
useEffect(() => { if (window.lucide) window.lucide.createIcons(); }, [activeTab, isAccountSelectOpen, isMasterModalOpen, isFactoryModalOpen, rawData, currentMode]);
|
|
|
|
const getRoot = (code, mode, pjtName = "", pjtCat = "", desc = "") => {
|
|
const cStr = String(code).trim();
|
|
if (cStr === '801') return CAT_NON_OP;
|
|
if (['401','402','403','404','405'].includes(cStr)) return CAT_REV;
|
|
const isMfg = (String(pjtName) + String(pjtCat) + String(desc)).toLowerCase().includes('제조');
|
|
if (mode === 'JANGHEON') {
|
|
const c = parseInt(cStr);
|
|
if (c >= 600 && c < 700) return isMfg ? CAT_MFG : 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 {
|
|
const 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 normalizeAccountName = (name) => String(name || '')
|
|
.replace(/\[[^\]]*\]/g, '')
|
|
.replace(/\([^)]*\)/g, '')
|
|
.replace(/\s+/g, '')
|
|
.trim();
|
|
|
|
const findJangheonRecommendationByName = (code, isMfgPjt) => {
|
|
const c = parseInt(code);
|
|
if (!Number.isFinite(c)) return null;
|
|
const fromStart = isMfgPjt ? 600 : 700;
|
|
const fromEnd = isMfgPjt ? 699 : 799;
|
|
const toStart = isMfgPjt ? 700 : 600;
|
|
const toEnd = isMfgPjt ? 799 : 699;
|
|
if (c < fromStart || c > fromEnd) return null;
|
|
|
|
const srcName = MASTER_JANGHEON[String(code)];
|
|
if (!srcName) return null;
|
|
const srcNorm = normalizeAccountName(srcName);
|
|
if (!srcNorm) return null;
|
|
|
|
let fallback = null;
|
|
for (let i = toStart; i <= toEnd; i++) {
|
|
const targetCode = String(i);
|
|
const tName = MASTER_JANGHEON[targetCode];
|
|
if (!tName) continue;
|
|
const tNorm = normalizeAccountName(tName);
|
|
if (!tNorm) continue;
|
|
if (tNorm === srcNorm) return targetCode;
|
|
|
|
// Exact name may not exist across 6xx/7xx, so keep safe keyword-based fallback
|
|
if (!fallback) {
|
|
const hasTrip = srcNorm.includes('출장') || srcNorm.includes('여비교통');
|
|
const hasWarranty = srcNorm.includes('보증') || srcNorm.includes('보상');
|
|
const hasSafety = srcNorm.includes('안전');
|
|
const hasRent = srcNorm.includes('임차');
|
|
const hasTransport = srcNorm.includes('운반');
|
|
if (hasTrip && (tNorm.includes('출장') || tNorm.includes('여비교통'))) fallback = targetCode;
|
|
else if (hasWarranty && (tNorm.includes('보증') || tNorm.includes('보상'))) fallback = targetCode;
|
|
else if (hasSafety && tNorm.includes('안전')) fallback = targetCode;
|
|
else if (hasRent && tNorm.includes('임차')) fallback = targetCode;
|
|
else if (hasTransport && tNorm.includes('운반')) fallback = targetCode;
|
|
}
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
const API_BASE = 'http://localhost:4000';
|
|
|
|
const loadTransactionsFromApi = async () => {
|
|
const res = await fetch(`${API_BASE}/api/transactions?project=All`);
|
|
if (!res.ok) throw new Error('거래내역 조회 실패');
|
|
const data = await res.json();
|
|
const items = (data.items || []).map((row, idx) => ({
|
|
id: row.id ?? idx,
|
|
code: String(row.code || '').trim(),
|
|
pjt: String(row.pjt || '미지정').trim(),
|
|
pjtCode: String(row.pjtCode || '').trim(),
|
|
pjtCategory: String(row.pjtCategory || '').trim(),
|
|
date: String(row.date || '').trim(),
|
|
description: String(row.description || ''),
|
|
income: parseFloat(row.income || 0) || 0,
|
|
expense: parseFloat(row.expense || 0) || 0,
|
|
isCorrected: !!row.isCorrected
|
|
}));
|
|
setRawData(items);
|
|
};
|
|
|
|
const handleFileUpload = async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
setUploading(true);
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
const uploadRes = await fetch(`${API_BASE}/api/import/transactions`, {
|
|
method: 'POST',
|
|
body: fd
|
|
});
|
|
if (!uploadRes.ok) throw new Error('업로드 실패');
|
|
await loadTransactionsFromApi();
|
|
} catch (err) {
|
|
console.error(err);
|
|
window.alert('업로드 또는 DB 저장에 실패했습니다. 서버 실행 상태를 확인해 주세요.');
|
|
} finally {
|
|
setUploading(false);
|
|
e.target.value = '';
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadTransactionsFromApi().catch(err => {
|
|
console.error(err);
|
|
});
|
|
}, []);
|
|
const processedData = useMemo(() => {
|
|
return rawData.map(d => {
|
|
const root = getRoot(d.code, currentMode, d.pjt, d.pjtCategory, d.description);
|
|
const master = (currentMode === 'JANGHEON') ? MASTER_JANGHEON : MASTER_PTC;
|
|
let name = master[d.code.trim()] || "계정 " + d.code;
|
|
if (currentMode === 'JANGHEON' && ['601', '602', '612'].includes(String(d.code))) {
|
|
name += (root === CAT_MFG ? " [제조]" : " [시공]");
|
|
}
|
|
// ✅ 601, 602, 612 추천 제외 로직
|
|
let recommendation = null;
|
|
const codeNum = parseInt(String(d.code), 10);
|
|
const hasMfgKeyword = (String(d.pjt) + String(d.pjtCategory) + String(name) + String(d.description)).toLowerCase().includes('제조');
|
|
const inferredIsMfg = hasMfgKeyword || (!Number.isNaN(codeNum) && codeNum >= 700 && codeNum <= 799);
|
|
if (!d.isCorrected && currentMode === 'JANGHEON' && !['601', '602', '612'].includes(String(d.code))) {
|
|
recommendation = findJangheonRecommendationByName(String(d.code), inferredIsMfg);
|
|
}
|
|
return { ...d, root, name, recommendation, inferredIsMfg };
|
|
});
|
|
}, [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 kw = searchTerm.toLowerCase();
|
|
const matchSearch = !searchTerm
|
|
|| d.code.includes(searchTerm)
|
|
|| d.name.toLowerCase().includes(kw)
|
|
|| String(d.pjt || '').toLowerCase().includes(kw)
|
|
|| String(d.pjtCategory || '').toLowerCase().includes(kw);
|
|
return matchDate && matchPjt && matchSearch;
|
|
});
|
|
}, [processedData, startDate, endDate, selectedPjt, searchTerm]);
|
|
|
|
const inferredProjectFromSearch = useMemo(() => {
|
|
if (selectedPjt !== 'All' || !searchTerm) return '';
|
|
const kw = searchTerm.toLowerCase();
|
|
const matches = [...new Set(rawData.map(r => String(r.pjt || '').trim()).filter(Boolean))]
|
|
.filter(p => p.toLowerCase().includes(kw));
|
|
return matches.length === 1 ? matches[0] : '';
|
|
}, [selectedPjt, searchTerm, rawData]);
|
|
|
|
const activeBudgetProject = useMemo(() => (
|
|
selectedPjt !== 'All' ? selectedPjt : inferredProjectFromSearch
|
|
), [selectedPjt, inferredProjectFromSearch]);
|
|
|
|
const stats = useMemo(() => {
|
|
let rev = 0, costSum = 0, nonOpSum = 0;
|
|
const breakdown = { [CAT_REV]: 0 };
|
|
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) { const amt = d.income - d.expense; rev += amt; breakdown[CAT_REV] += amt; }
|
|
else if (breakdown[d.root] !== undefined) { const amt = d.expense - d.income; breakdown[d.root] += amt; costSum += amt; }
|
|
else if (d.root === CAT_NON_OP) { nonOpSum += (d.expense - d.income); }
|
|
});
|
|
const margin = rev > 0 ? ((rev - costSum) / rev * 100).toFixed(1) : 0;
|
|
const status = parseFloat(margin) > 15 ? 'EXCELLENT' : parseFloat(margin) > 5 ? 'STABLE' : 'WARN';
|
|
return { rev, costSum, profit: rev - costSum, breakdown, nonOpSum, margin, status };
|
|
}, [filteredData, currentMode]);
|
|
|
|
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 sortedFactoryYears = useMemo(() => (
|
|
Object.keys(factoryStandards).sort((a, b) => parseInt(a) - parseInt(b))
|
|
), [factoryStandards]);
|
|
|
|
const jhAnalysisData = useMemo(() => {
|
|
return jhMapping.map(cat => {
|
|
const keyProject = activeBudgetProject || selectedPjt;
|
|
const key = `${keyProject}_${cat.id}`;
|
|
const rawActual = filteredData.reduce((sum, d) => {
|
|
if (cat.codes.includes(d.code)) return sum + (cat.type === 'revenue' ? (d.income - d.expense) : (d.expense - d.income));
|
|
return sum;
|
|
}, 0);
|
|
let actual = rawActual;
|
|
let budget = budgets[key] || 0;
|
|
if (cat.group === 'FACTORY') {
|
|
actual = (unitPrices[key] || 0) * (quantities[key] || 0);
|
|
budget = budgets[key] || 0;
|
|
}
|
|
// 본사관리비/A/S/현장소장은 실제집행과 실행예산을 동일값으로 본다.
|
|
if (['admin_hq', 'as_cost', 'site_manager'].includes(cat.id)) {
|
|
actual = budget;
|
|
}
|
|
return { ...cat, actual, budget };
|
|
});
|
|
}, [jhMapping, filteredData, budgets, unitPrices, quantities, selectedPjt, activeBudgetProject]);
|
|
|
|
const analyticSummaries = useMemo(() => {
|
|
const inc = jhAnalysisData.find(c => c.id === 'income');
|
|
const progress = (inc && inc.budget > 0) ? (inc.actual / inc.budget * 100).toFixed(1) : "0.0";
|
|
const totalCostAct = jhAnalysisData.filter(c => c.type === 'cost').reduce((s, c) => s + c.actual, 0);
|
|
const totalCostBud = jhAnalysisData.filter(c => c.type === 'cost').reduce((s, c) => s + c.budget, 0);
|
|
const velocity = totalCostBud > 0 ? (totalCostAct / totalCostBud * 100).toFixed(1) : "0.0";
|
|
return { progress, velocity };
|
|
}, [jhAnalysisData]);
|
|
|
|
const analysisCostTotals = useMemo(() => {
|
|
const income = jhAnalysisData.find(c => c.id === 'income');
|
|
const incomeActual = Math.round(income?.actual || 0);
|
|
const incomeBudget = Math.round(income?.budget || 0);
|
|
let totalActual = 0;
|
|
let totalBudget = 0;
|
|
|
|
jhAnalysisData.forEach(cat => {
|
|
// 합계는 수입 그룹을 완전히 제외하고 지출 항목만 반영
|
|
if (cat.group === 'REVENUE' || cat.id === 'income' || cat.type !== 'cost') return;
|
|
const actualAmount = Math.round(cat.actual || 0);
|
|
const budgetAmount = Math.round(cat.budget || 0);
|
|
totalActual += actualAmount;
|
|
totalBudget += budgetAmount;
|
|
});
|
|
|
|
const diff = totalActual - totalBudget;
|
|
const rate = totalBudget > 0 ? (totalActual / totalBudget * 100).toFixed(1) : "0.0";
|
|
const budgetVsIncome = incomeBudget > 0 ? (totalBudget / incomeBudget * 100).toFixed(1) : "0.0";
|
|
const actualVsIncome = incomeActual > 0 ? (totalActual / incomeActual * 100).toFixed(1) : "0.0";
|
|
const diffVsIncome = incomeActual > 0 ? (diff / incomeActual * 100).toFixed(1) : "0.0";
|
|
return { totalActual, totalBudget, diff, rate, budgetVsIncome, actualVsIncome, diffVsIncome };
|
|
}, [jhAnalysisData]);
|
|
|
|
const executionInsights = useMemo(() => {
|
|
const costItems = jhAnalysisData
|
|
.filter(c => c.type === 'cost' && c.group !== 'REVENUE' && c.id !== 'income')
|
|
.map(c => {
|
|
const actual = Math.round(c.actual || 0);
|
|
const budget = Math.round(c.budget || 0);
|
|
const diff = actual - budget;
|
|
const rate = budget > 0 ? (actual / budget * 100) : 0;
|
|
return { id: c.id, name: c.name, actual, budget, diff, rate };
|
|
});
|
|
|
|
const overSpent = costItems
|
|
.filter(i => i.diff > 0)
|
|
.sort((a, b) => b.diff - a.diff)
|
|
.slice(0, 3);
|
|
|
|
const underSpent = costItems
|
|
.filter(i => i.diff < 0)
|
|
.sort((a, b) => a.diff - b.diff)
|
|
.slice(0, 3);
|
|
|
|
const overallRate = parseFloat(analysisCostTotals.rate);
|
|
const totalBudget = analysisCostTotals.totalBudget;
|
|
const totalActual = analysisCostTotals.totalActual;
|
|
const totalDiff = analysisCostTotals.diff;
|
|
const diffVsIncome = Math.abs(parseFloat(analysisCostTotals.diffVsIncome)).toFixed(1);
|
|
const topOverText = overSpent.length
|
|
? overSpent.map(i => `${i.name}(${i.diff.toLocaleString()}원)`).join(', ')
|
|
: '초과집행 주요 항목 없음';
|
|
const topUnderText = underSpent.length
|
|
? underSpent.map(i => `${i.name}(${Math.abs(i.diff).toLocaleString()}원 절감)`).join(', ')
|
|
: '절감 주요 항목 없음';
|
|
|
|
let opinion = `예상된 집행내역은 ${totalBudget.toLocaleString()}원이었고 실제 집행은 ${totalActual.toLocaleString()}원입니다.`;
|
|
if (totalDiff > 0) {
|
|
opinion = `예상된 집행내역은 ${totalBudget.toLocaleString()}원이었지만, ${topOverText} 등의 추가 비용으로 인해 ${totalDiff.toLocaleString()}원이 더 투입되었고 수입대비 ${diffVsIncome}%의 손해가 발생하였습니다.`;
|
|
} else if (totalDiff < 0) {
|
|
opinion = `예상된 집행내역은 ${totalBudget.toLocaleString()}원이었고, ${topUnderText} 중심의 절감으로 ${Math.abs(totalDiff).toLocaleString()}원이 덜 투입되어 수입대비 ${diffVsIncome}%의 이익이 발생하였습니다.`;
|
|
}
|
|
|
|
return { overSpent, underSpent, overallRate, opinion };
|
|
}, [jhAnalysisData, analysisCostTotals]);
|
|
|
|
const analysisDetailItems = useMemo(() => {
|
|
if (!analysisDetailCat) return [];
|
|
const codeSet = new Set((analysisDetailCat.codes || []).map(v => String(v)));
|
|
return filteredData
|
|
.filter(d => codeSet.has(String(d.code)))
|
|
.map(d => ({
|
|
...d,
|
|
amount: Math.round((analysisDetailCat.type === 'revenue')
|
|
? (d.income - d.expense)
|
|
: (d.expense - d.income))
|
|
}))
|
|
.filter(d => d.amount !== 0)
|
|
.sort((a, b) => {
|
|
const byDate = String(b.date || '').localeCompare(String(a.date || ''));
|
|
if (byDate !== 0) return byDate;
|
|
return Math.abs(b.amount) - Math.abs(a.amount);
|
|
});
|
|
}, [analysisDetailCat, filteredData]);
|
|
|
|
const analysisDetailTotal = useMemo(() => (
|
|
analysisDetailItems.reduce((sum, item) => sum + item.amount, 0)
|
|
), [analysisDetailItems]);
|
|
|
|
useEffect(() => {
|
|
if (factoryStandards[factoryYear]) return;
|
|
const fallbackYear = sortedFactoryYears[sortedFactoryYears.length - 1] || '';
|
|
if (fallbackYear) setFactoryYear(fallbackYear);
|
|
}, [factoryStandards, factoryYear, sortedFactoryYears]);
|
|
|
|
const handleBudgetInput = (catId, value) => {
|
|
const targetProject = activeBudgetProject || selectedPjt;
|
|
if (!targetProject) return;
|
|
const key = `${targetProject}_${catId}`;
|
|
const num = parseFloat(value);
|
|
setBudgets(prev => ({ ...prev, [key]: Number.isFinite(num) ? Math.round(num) : 0 }));
|
|
};
|
|
|
|
const handleUnitPriceInput = (catId, value) => {
|
|
const targetProject = activeBudgetProject || selectedPjt;
|
|
if (!targetProject) return;
|
|
const key = `${targetProject}_${catId}`;
|
|
const num = parseFloat(value);
|
|
setUnitPrices(prev => ({ ...prev, [key]: Number.isFinite(num) ? Math.round(num) : 0 }));
|
|
};
|
|
|
|
const handleQuantityInput = (catId, value) => {
|
|
const targetProject = activeBudgetProject || selectedPjt;
|
|
if (!targetProject) return;
|
|
const key = `${targetProject}_${catId}`;
|
|
const num = parseFloat(value);
|
|
setQuantities(prev => ({ ...prev, [key]: Number.isFinite(num) ? Math.round(num) : 0 }));
|
|
};
|
|
|
|
const addFactoryYear = () => {
|
|
const year = String(newFactoryYear).trim();
|
|
if (!/^\d{4}$/.test(year)) {
|
|
window.alert('연도는 4자리 숫자로 입력하세요. 예: 2025');
|
|
return;
|
|
}
|
|
setFactoryStandards(prev => {
|
|
if (prev[year]) {
|
|
window.alert('이미 존재하는 연도입니다.');
|
|
return prev;
|
|
}
|
|
const factoryCats = jhMapping.filter(cat => cat.group === 'FACTORY');
|
|
const base = prev[factoryYear] || {};
|
|
const nextYearRates = {};
|
|
factoryCats.forEach(cat => { nextYearRates[cat.id] = Number(base[cat.id]) || 0; });
|
|
return { ...prev, [year]: nextYearRates };
|
|
});
|
|
setFactoryYear(year);
|
|
setNewFactoryYear('');
|
|
};
|
|
|
|
const deleteFactoryYear = (year) => {
|
|
setFactoryStandards(prev => {
|
|
const years = Object.keys(prev);
|
|
if (years.length <= 1) {
|
|
window.alert('최소 1개 연도는 유지되어야 합니다.');
|
|
return prev;
|
|
}
|
|
const next = { ...prev };
|
|
delete next[year];
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const updateFactoryStandard = (year, itemId, value) => {
|
|
const num = parseFloat(value);
|
|
setFactoryStandards(prev => ({
|
|
...prev,
|
|
[year]: { ...(prev[year] || {}), [itemId]: Number.isFinite(num) ? num : 0 }
|
|
}));
|
|
};
|
|
|
|
const toNum = (v) => {
|
|
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
|
|
if (typeof v === 'string') {
|
|
const n = parseFloat(v.replace(/,/g, '').trim());
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const normalizeText = (v) => String(v || '').replace(/\s+/g, '');
|
|
|
|
const toDateString = (v) => {
|
|
if (!v && v !== 0) return '';
|
|
if (v instanceof Date && !isNaN(v.getTime())) return v.toISOString().split('T')[0];
|
|
if (typeof v === 'number') {
|
|
if (v > 30000) {
|
|
const d = new Date((v - 25569) * 86400 * 1000);
|
|
if (!isNaN(d.getTime())) return d.toISOString().split('T')[0];
|
|
}
|
|
return '';
|
|
}
|
|
const s = String(v).trim().replace(/[.\s]+$/g, '');
|
|
if (/^\d{4}[-./]\d{1,2}[-./]\d{1,2}$/.test(s)) return s.replace(/[./]/g, '-');
|
|
return '';
|
|
};
|
|
|
|
const extractBridgeInfoFromRows = (rows, selectedProjectName = '') => {
|
|
const findByLabel = (label, offsets = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) => {
|
|
const target = normalizeText(label);
|
|
for (let r = 0; r < rows.length; r++) {
|
|
const row = rows[r] || [];
|
|
for (let c = 0; c < row.length; c++) {
|
|
if (normalizeText(row[c]).includes(target)) {
|
|
for (const o of offsets) {
|
|
const cand = row[c + o];
|
|
if (cand !== null && cand !== undefined && String(cand).trim() !== '') return String(cand).trim();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const collectDatesNearLabel = (label) => {
|
|
const out = [];
|
|
const target = normalizeText(label);
|
|
for (let r = 0; r < rows.length; r++) {
|
|
const row = rows[r] || [];
|
|
for (let c = 0; c < row.length; c++) {
|
|
if (!normalizeText(row[c]).includes(target)) continue;
|
|
const candidates = [
|
|
rows[r]?.[c + 1], rows[r]?.[c + 2], rows[r]?.[c + 3], rows[r]?.[c + 4],
|
|
rows[r + 1]?.[c], rows[r + 1]?.[c + 1], rows[r + 1]?.[c + 2], rows[r + 1]?.[c + 3]
|
|
];
|
|
candidates.forEach(v => {
|
|
const d = toDateString(v);
|
|
if (d) out.push(d);
|
|
});
|
|
}
|
|
}
|
|
return out[0] || '';
|
|
};
|
|
|
|
const collectTextNearLabel = (label) => {
|
|
const target = normalizeText(label);
|
|
for (let r = 0; r < rows.length; r++) {
|
|
const row = rows[r] || [];
|
|
for (let c = 0; c < row.length; c++) {
|
|
if (!normalizeText(row[c]).includes(target)) continue;
|
|
const candidates = [
|
|
rows[r]?.[c + 1], rows[r]?.[c + 2], rows[r]?.[c + 3], rows[r]?.[c + 4], rows[r]?.[c + 5], rows[r]?.[c + 6], rows[r]?.[c + 7], rows[r]?.[c + 8],
|
|
rows[r + 1]?.[c], rows[r + 1]?.[c + 1], rows[r + 1]?.[c + 2], rows[r + 1]?.[c + 3], rows[r + 1]?.[c + 4]
|
|
];
|
|
for (const v of candidates) {
|
|
if (v === null || v === undefined) continue;
|
|
const t = String(v).trim();
|
|
if (!t) continue;
|
|
if (normalizeText(t) === target) continue;
|
|
return t;
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const bridgeName = findByLabel('교량명') || String(selectedProjectName || '').trim();
|
|
const locationRaw = findByLabel('현장주소');
|
|
const locationNext = (() => {
|
|
for (let r = 0; r < rows.length; r++) {
|
|
const row = rows[r] || [];
|
|
for (let c = 0; c < row.length; c++) {
|
|
if (normalizeText(row[c]).includes('현장주소')) {
|
|
const cand = rows[r + 1]?.[c + 6] || rows[r + 1]?.[c + 1];
|
|
if (cand !== null && cand !== undefined && String(cand).trim() !== '') return String(cand).trim();
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
})();
|
|
const bridgeType = findByLabel('교량제원') || findByLabel('형식');
|
|
// 계약기간은 날짜 변환이 아니라 원문 텍스트 그대로 우선 사용
|
|
const contractDateRaw = findByLabel('계약일', [1, 2, 3, 4, 5, 6, 7, 8]) || collectTextNearLabel('계약일');
|
|
const finishDateRaw = findByLabel('종료일', [1, 2, 3, 4, 5, 6, 7, 8]) || collectTextNearLabel('종료일');
|
|
const completeDateRaw = findByLabel('준공일', [1, 2, 3, 4, 5, 6, 7, 8]) || collectTextNearLabel('준공일');
|
|
const contractStart = String(contractDateRaw || collectDatesNearLabel('계약일') || collectDatesNearLabel('시작일') || collectDatesNearLabel('착공일') || '').trim();
|
|
const contractEnd = String(finishDateRaw || completeDateRaw || collectDatesNearLabel('종료일') || collectDatesNearLabel('준공일') || '').trim();
|
|
const location = (locationNext && locationNext.length > locationRaw.length) ? locationNext : (locationRaw || locationNext);
|
|
return { bridgeName, contractStart, contractEnd, bridgeType, location };
|
|
};
|
|
|
|
const makeBridgeSummary = (info, fallbackName = '') => {
|
|
if (!info) return '';
|
|
const name = info.bridgeName || fallbackName || '해당 교량';
|
|
const locationPart = info.location ? `${info.location}에서` : '현장에서';
|
|
const typePart = info.bridgeType ? `${info.bridgeType} 형식의 ` : '';
|
|
const period = (info.contractStart && info.contractEnd)
|
|
? `${info.contractStart}부터 ${info.contractEnd}까지`
|
|
: '계약일로부터 종료일까지';
|
|
return `${name}는 ${locationPart} 진행되는 ${typePart}교량 공사입니다. ${period} 공정 및 예산을 관리합니다.`;
|
|
};
|
|
|
|
const makeBudgetPresetFromSheet = (sheet) => {
|
|
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: true, defval: null });
|
|
const amount = (r) => toNum(rows[r]?.[18]);
|
|
const unit = (r) => toNum(rows[r]?.[8]);
|
|
const qty = (r) => toNum(rows[r]?.[31]);
|
|
const findRows = (keyword) => rows.reduce((acc, row, idx) => {
|
|
if (String(row[2] || '').includes(keyword)) acc.push(idx);
|
|
return acc;
|
|
}, []);
|
|
const firstRow = (keyword) => findRows(keyword)[0];
|
|
const lastRow = (keyword) => {
|
|
const list = findRows(keyword);
|
|
return list.length ? list[list.length - 1] : undefined;
|
|
};
|
|
const exactRow = (keyword) => {
|
|
const target = normalizeText(keyword);
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const label = normalizeText(rows[i]?.[2] || '');
|
|
if (label === target) return i;
|
|
}
|
|
const list = findRows(keyword);
|
|
return list.length ? list[list.length - 1] : undefined;
|
|
};
|
|
|
|
const rowEffSalePrecast = firstRow('Precast 가로보');
|
|
const rowEffSalePreDeck = firstRow('Pre-Deck 설치');
|
|
const rowDirectExpense = firstRow('5.직접경비');
|
|
const rowSafety = lastRow('안전관리비');
|
|
const rowFactoryRebar = exactRow('철근가공');
|
|
const rowFactoryPreDeck = exactRow('Pre-Deck제작');
|
|
const rowFactoryGirder = exactRow('가로보제작');
|
|
const rowMaintMold = exactRow('거푸집');
|
|
const rowMaintBench = exactRow('제작대');
|
|
|
|
const incomeFromAdmin = amount(firstRow('본사관리비')) > 0 ? amount(firstRow('본사관리비')) / 0.15 : 0;
|
|
const incomeFromAs = amount(firstRow('AS 비용')) > 0 ? amount(firstRow('AS 비용')) / 0.03 : 0;
|
|
const income = incomeFromAdmin || incomeFromAs || 0;
|
|
|
|
const nonFactoryBudgets = {
|
|
income,
|
|
mat_main: amount(firstRow('주자재')),
|
|
mat_sub: amount(firstRow('부자재')),
|
|
eff_prod: amount(firstRow('거더 제작')),
|
|
eff_tens: amount(firstRow('거더인장')),
|
|
eff_inst: amount(firstRow('거더거치')),
|
|
eff_sale: amount(rowEffSalePrecast) + amount(rowEffSalePreDeck),
|
|
safety: amount(rowSafety),
|
|
residence: amount(firstRow('주재비')),
|
|
site_manager: 0,
|
|
etc_trip: amount(firstRow('출장비')) + amount(firstRow('해외출장비')) + amount(firstRow('보증비')) + amount(firstRow('기타비')),
|
|
direct_exp: amount(rowDirectExpense),
|
|
outsource: amount(firstRow('외주비')),
|
|
admin_hq: amount(firstRow('본사관리비')),
|
|
as_cost: amount(firstRow('AS 비용')),
|
|
factory_rebar: amount(rowFactoryRebar),
|
|
factory_predeck: amount(rowFactoryPreDeck),
|
|
factory_girder: amount(rowFactoryGirder),
|
|
maint_mold: amount(rowMaintMold),
|
|
maint_bench: amount(rowMaintBench)
|
|
};
|
|
|
|
const makeFactoryEntry = (rowIdx) => {
|
|
const u = unit(rowIdx);
|
|
const a = amount(rowIdx);
|
|
const q = qty(rowIdx) || (u > 0 ? (a / u) : 0);
|
|
return { unitPrice: u || a, quantity: q || (a > 0 ? 1 : 0) };
|
|
};
|
|
|
|
const factory = {
|
|
factory_rebar: makeFactoryEntry(rowFactoryRebar),
|
|
factory_predeck: makeFactoryEntry(rowFactoryPreDeck),
|
|
factory_girder: makeFactoryEntry(rowFactoryGirder),
|
|
maint_mold: makeFactoryEntry(rowMaintMold),
|
|
maint_bench: makeFactoryEntry(rowMaintBench)
|
|
};
|
|
const bridgeInfo = extractBridgeInfoFromRows(rows, selectedPjt);
|
|
return { nonFactoryBudgets, factory, bridgeInfo };
|
|
};
|
|
|
|
const applyPresetToProject = (projectName, preset) => {
|
|
setBudgets(prev => {
|
|
const next = { ...prev };
|
|
Object.entries(preset.nonFactoryBudgets || {}).forEach(([id, val]) => {
|
|
next[`${projectName}_${id}`] = toNum(val);
|
|
});
|
|
return next;
|
|
});
|
|
setUnitPrices(prev => {
|
|
const next = { ...prev };
|
|
Object.entries(preset.factory || {}).forEach(([id, val]) => {
|
|
next[`${projectName}_${id}`] = toNum(val.unitPrice);
|
|
});
|
|
return next;
|
|
});
|
|
setQuantities(prev => {
|
|
const next = { ...prev };
|
|
Object.entries(preset.factory || {}).forEach(([id, val]) => {
|
|
next[`${projectName}_${id}`] = toNum(val.quantity);
|
|
});
|
|
return next;
|
|
});
|
|
setBudgetQuantities(prev => {
|
|
const next = { ...prev };
|
|
Object.entries(preset.factory || {}).forEach(([id, val]) => {
|
|
next[`${projectName}_${id}`] = toNum(val.quantity);
|
|
});
|
|
return next;
|
|
});
|
|
setBudgetUnitPrices(prev => {
|
|
const next = { ...prev };
|
|
Object.entries(preset.factory || {}).forEach(([id, val]) => {
|
|
next[`${projectName}_${id}`] = toNum(val.unitPrice);
|
|
});
|
|
return next;
|
|
});
|
|
setAutoBudgetAppliedProjects(prev => ({ ...prev, [projectName]: true }));
|
|
if (preset.bridgeInfo) {
|
|
setProjectBridgeMeta(prev => ({ ...prev, [projectName]: preset.bridgeInfo }));
|
|
}
|
|
};
|
|
|
|
const pickBestSheetName = (workbook, projectName, fileName = '') => {
|
|
const sheetNames = workbook.SheetNames || [];
|
|
const p = String(projectName || '').trim();
|
|
const f = String(fileName || '');
|
|
if ((p.includes('장동교') || f.includes('장동교')) && sheetNames.some(n => String(n).includes('장동교'))) {
|
|
return sheetNames.find(n => String(n).includes('장동교'));
|
|
}
|
|
if (!p) return sheetNames[0];
|
|
const exact = sheetNames.find(n => String(n).trim() === p);
|
|
if (exact) return exact;
|
|
const partial = sheetNames.find(n => String(n).includes(p) || p.includes(String(n)));
|
|
if (partial) return partial;
|
|
|
|
// 프로젝트명 매칭이 안 되면 예산 항목 키워드 점수로 시트 선택
|
|
const scoreSheet = (name) => {
|
|
const ws = workbook.Sheets?.[name];
|
|
if (!ws) return -1;
|
|
const rows = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
|
|
const keywords = ['주자재', '부자재', '철근가공', 'Pre-Deck제작', '가로보제작', '거푸집', '제작대', '본사관리비', 'AS 비용'];
|
|
const head = rows.slice(0, 80);
|
|
let score = 0;
|
|
head.forEach(row => {
|
|
const line = row.map(v => String(v || '')).join(' ');
|
|
keywords.forEach(k => { if (line.includes(k)) score += 1; });
|
|
if (line.includes(p)) score += 5;
|
|
});
|
|
return score;
|
|
};
|
|
|
|
let best = sheetNames[0];
|
|
let bestScore = -1;
|
|
sheetNames.forEach(name => {
|
|
const s = scoreSheet(name);
|
|
if (s > bestScore) { bestScore = s; best = name; }
|
|
});
|
|
return best;
|
|
};
|
|
|
|
const handleBudgetTemplateUpload = (e) => {
|
|
const file = e.target.files?.[0];
|
|
const targetProject = selectedPjt !== 'All' ? selectedPjt : inferredProjectFromSearch;
|
|
if (!file || !targetProject) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (evt) => {
|
|
try {
|
|
const arr = evt.target.result;
|
|
const wb = XLSX.read(arr, { type: 'array' });
|
|
const sheetName = pickBestSheetName(wb, targetProject, file.name);
|
|
const sheet = wb.Sheets[sheetName];
|
|
if (!sheet) throw new Error('시트를 찾지 못했습니다.');
|
|
const preset = makeBudgetPresetFromSheet(sheet);
|
|
setUploadedProjectPresets(prev => ({ ...prev, [targetProject]: preset }));
|
|
applyPresetToProject(targetProject, preset);
|
|
} catch (err) {
|
|
console.error('예산서 업로드/적용 실패:', err);
|
|
window.alert('예산서 적용에 실패했습니다. 시트 구조를 확인해 주세요.');
|
|
} finally {
|
|
if (budgetUploadRef.current) budgetUploadRef.current.value = '';
|
|
}
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
};
|
|
|
|
const loadJangdongPreset = async () => {
|
|
if (jangdongPresetRef.current) return jangdongPresetRef.current;
|
|
let preset = null;
|
|
try {
|
|
const filePath = '공사실행예산서(장동교).xlsx';
|
|
const res = await fetch(encodeURI(filePath));
|
|
if (!res.ok) throw new Error('예산서 파일 로드 실패');
|
|
const arr = await res.arrayBuffer();
|
|
const wb = XLSX.read(arr, { type: 'array' });
|
|
const targetName = wb.SheetNames.find(name => String(name).includes('장동교')) || wb.SheetNames[0];
|
|
const sheet = wb.Sheets[targetName];
|
|
if (!sheet) throw new Error('장동교 시트를 찾을 수 없음');
|
|
preset = makeBudgetPresetFromSheet(sheet);
|
|
} catch (err) {
|
|
console.warn('장동교 예산서 파일 로드 실패, 기본값 사용:', err);
|
|
preset = JANGDONG_BUDGET_FALLBACK;
|
|
}
|
|
jangdongPresetRef.current = preset;
|
|
return preset;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (currentMode !== 'JANGHEON') return;
|
|
if (selectedPjt === 'All') return;
|
|
if (autoBudgetAppliedProjects[selectedPjt]) return;
|
|
if (uploadedProjectPresets[selectedPjt]) {
|
|
applyPresetToProject(selectedPjt, uploadedProjectPresets[selectedPjt]);
|
|
return;
|
|
}
|
|
if (!String(selectedPjt).includes('장동교')) return;
|
|
|
|
(async () => {
|
|
try {
|
|
const preset = await loadJangdongPreset();
|
|
applyPresetToProject(selectedPjt, preset);
|
|
} catch (err) {
|
|
console.error('장동교 예산 자동반영 실패:', err);
|
|
}
|
|
})();
|
|
}, [currentMode, selectedPjt, autoBudgetAppliedProjects, uploadedProjectPresets, inferredProjectFromSearch]);
|
|
|
|
const bridgeTargetPjt = useMemo(() => (
|
|
selectedPjt !== 'All' ? selectedPjt : inferredProjectFromSearch
|
|
), [selectedPjt, inferredProjectFromSearch]);
|
|
|
|
const currentBridgeInfo = useMemo(() => {
|
|
if (!bridgeTargetPjt) return null;
|
|
if (projectBridgeMeta[bridgeTargetPjt]) return projectBridgeMeta[bridgeTargetPjt];
|
|
if (currentMode === 'JANGHEON' && String(bridgeTargetPjt).includes('장동교')) return JANGDONG_BUDGET_FALLBACK.bridgeInfo;
|
|
return null;
|
|
}, [currentMode, bridgeTargetPjt, projectBridgeMeta]);
|
|
|
|
const currentBridgeSummary = useMemo(() => (
|
|
makeBridgeSummary(currentBridgeInfo, bridgeTargetPjt || selectedPjt)
|
|
), [currentBridgeInfo, bridgeTargetPjt, selectedPjt]);
|
|
|
|
// ✅ 기준단가 자동 반영 로직 (수정)
|
|
const applyStandards = () => {
|
|
const std = factoryStandards[factoryYear];
|
|
if (!std) return;
|
|
|
|
const factoryIds = jhMapping.filter(cat => cat.group === 'FACTORY').map(cat => cat.id);
|
|
const targetProjects = selectedPjt === 'All'
|
|
? ['All', ...new Set(rawData.map(item => item.pjt).filter(Boolean))]
|
|
: [selectedPjt];
|
|
|
|
setUnitPrices(prev => {
|
|
const next = { ...prev };
|
|
targetProjects.forEach(pjt => {
|
|
factoryIds.forEach(id => {
|
|
if (std[id] !== undefined) next[`${pjt}_${id}`] = std[id];
|
|
});
|
|
});
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const applyCorrection = (id, newCode) => {
|
|
if (!newCode) return;
|
|
setRawData(prev => prev.map(item => item.id === id ? { ...item, code: String(newCode).trim(), isCorrected: true } : item));
|
|
};
|
|
|
|
const addCodeToMapping = (code) => {
|
|
setJhMapping(p => p.map(c => c.id === mappingTargetId ? {...c, codes: [...new Set([...c.codes, code])]} : c));
|
|
setIsAccountSelectOpen(false);
|
|
};
|
|
|
|
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 justify-between items-center bg-white p-4 rounded-3xl shadow-sm border border-slate-200">
|
|
<div className="flex items-center gap-4">
|
|
<div className="bg-indigo-600 p-2.5 rounded-xl text-white shadow-lg font-bold"><i className="lucide-activity w-5 h-5"></i></div>
|
|
<div><h1 className="text-xl font-black text-slate-900 leading-none">통합 실행분석 시스템</h1><div className="flex gap-2 mt-1.5"><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-4">
|
|
<nav className="nav-container font-bold"><button onClick={() => setActiveTab('dashboard')} className={`tab-btn ${activeTab === 'dashboard' ? 'active' : ''}`}>대시보드</button><button onClick={() => setActiveTab('analysis')} className={`tab-btn ${activeTab === 'analysis' ? 'active' : ''}`}>실행 예산 분석</button></nav>
|
|
<label className="px-5 py-2.5 bg-indigo-600 text-white rounded-2xl cursor-pointer text-sm font-bold shadow-md flex items-center gap-2">데이터 업로드<input type="file" className="hidden" accept=".xlsx, .xls" onChange={handleFileUpload} /></label>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="grid grid-cols-3 gap-6 bg-white p-5 rounded-3xl border shadow-sm font-bold">
|
|
<div className="space-y-1.5"><label className="text-[10px] text-slate-400 font-black uppercase">Date Range</label><div className="flex gap-2"><input type="month" className="w-full bg-slate-50 rounded-xl p-2.5 border border-slate-100 outline-none font-bold" value={startDate} onChange={e => setStartDate(e.target.value)} /><input type="month" className="w-full bg-slate-50 rounded-xl p-2.5 border border-slate-100 outline-none font-bold" value={endDate} onChange={e => setEndDate(e.target.value)} /></div></div>
|
|
<div className="space-y-1.5"><label className="text-[10px] text-slate-400 font-bold uppercase">Project Selection</label><select className="w-full bg-slate-50 rounded-xl p-3 border border-slate-100 outline-none cursor-pointer font-bold" value={selectedPjt} onChange={e => setSelectedPjt(e.target.value)}><option value="All">전체 프로젝트 보기</option>{[...new Set(rawData.map(r => r.pjt))].sort().map(p => <option key={p} value={p}>{p}</option>)}</select></div>
|
|
<div className="space-y-1.5 font-bold">
|
|
<label className="text-[10px] text-slate-400 font-bold uppercase">Search</label>
|
|
<input type="text" placeholder="검색..." className="w-full bg-slate-50 rounded-xl p-3 border border-slate-100 outline-none font-bold" value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
|
|
{activeTab === 'analysis' && (selectedPjt !== 'All' || inferredProjectFromSearch) && (
|
|
<label className="inline-flex mt-1 px-4 py-2 bg-emerald-600 text-white rounded-xl cursor-pointer text-[11px] font-black shadow-sm items-center gap-2">
|
|
{(selectedPjt !== 'All' ? selectedPjt : inferredProjectFromSearch)} 예산서 업로드
|
|
<input ref={budgetUploadRef} type="file" className="hidden" accept=".xlsx, .xls" onChange={handleBudgetTemplateUpload} />
|
|
</label>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{currentBridgeInfo && (
|
|
<div className="bg-white p-5 rounded-3xl border shadow-sm">
|
|
<div className="text-[10px] text-slate-400 font-black uppercase mb-3">Bridge Info</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
|
<div className="bg-slate-50 rounded-xl p-3">
|
|
<div className="text-slate-400 font-black mb-1">교량명</div>
|
|
<div className="text-slate-800 font-bold">{currentBridgeInfo.bridgeName || selectedPjt}</div>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-xl p-3">
|
|
<div className="text-slate-400 font-black mb-1">계약기간</div>
|
|
<div className="text-slate-800 font-bold">{(currentBridgeInfo.contractStart && currentBridgeInfo.contractEnd) ? `${currentBridgeInfo.contractStart} ~ ${currentBridgeInfo.contractEnd}` : '-'}</div>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-xl p-3">
|
|
<div className="text-slate-400 font-black mb-1">교량 형식</div>
|
|
<div className="text-slate-800 font-bold">{currentBridgeInfo.bridgeType || '-'}</div>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-xl p-3">
|
|
<div className="text-slate-400 font-black mb-1">현장 위치</div>
|
|
<div className="text-slate-800 font-bold">{currentBridgeInfo.location || '-'}</div>
|
|
</div>
|
|
</div>
|
|
<p className="mt-4 text-sm text-slate-700 font-bold">{currentBridgeSummary}</p>
|
|
</div>
|
|
)}
|
|
|
|
{currentBridgeInfo && activeTab === 'analysis' && (
|
|
<div className="bg-white p-8 rounded-[2.5rem] border border-slate-200 shadow-sm">
|
|
<div className="flex items-center justify-between mb-5">
|
|
<h4 className="text-base font-black text-slate-800">실행예산 집행 분석</h4>
|
|
<span className={`px-3 py-1 rounded-full text-xs font-black ${executionInsights.overallRate > 100 ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600'}`}>
|
|
총 집행률 {analysisCostTotals.rate}%
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-slate-600 leading-relaxed">{executionInsights.opinion}</p>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'dashboard' ? (
|
|
<div className="space-y-6 animate-fade">
|
|
<div className="grid grid-cols-3 gap-6 text-center font-bold">
|
|
<div className="bg-white p-6 rounded-3xl border shadow-sm font-bold"><p className="text-xs text-emerald-500 font-black">REVENUE</p><p className="text-2xl font-black">{stats.rev.toLocaleString()}원</p></div>
|
|
<div className="bg-white p-6 rounded-3xl border shadow-sm font-bold"><p className="text-xs text-rose-500 font-black">EXPENSE</p><p className="text-2xl font-black">{stats.costSum.toLocaleString()}원</p></div>
|
|
<div className="bg-[#111827] p-6 rounded-3xl text-white shadow-xl font-bold"><p className="text-xs text-indigo-300 font-black">PROFIT</p><p className={`text-2xl font-black ${stats.profit >= 0 ? 'text-white' : 'text-rose-400'}`}>{stats.profit.toLocaleString()}원</p></div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-6 font-bold">
|
|
<div className="bg-white p-8 rounded-3xl border shadow-sm flex flex-col font-bold">
|
|
<h3 className="text-sm font-black text-slate-800 mb-6 flex items-center gap-2 font-bold"><i className="lucide-pie-chart text-indigo-600 w-4 h-4"></i> 수익/지출 비중 분석</h3>
|
|
<div className="space-y-4">
|
|
{Object.entries(stats.breakdown).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) || 1;
|
|
const pct = (Math.abs(val) / total * 100).toFixed(1);
|
|
return (
|
|
<div key={cat} onClick={() => setActiveCategoryFilter(cat)} className={`p-2.5 rounded-xl cursor-pointer hover:bg-slate-50 transition-all ${activeCategoryFilter === cat ? 'bg-indigo-50 border border-indigo-100 font-bold' : ''}`}>
|
|
<div className="flex justify-between text-xs mb-1.5 font-bold"><span>{cat} ({pct}%)</span><span className="font-black text-slate-700">{val.toLocaleString()}원</span></div>
|
|
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden font-bold"><div className="h-full transition-all" style={{width: `${pct}%`, backgroundColor: CATEGORY_COLORS[cat] || '#94a3b8'}}></div></div>
|
|
</div>
|
|
);
|
|
})}
|
|
<div onClick={() => setActiveCategoryFilter(CAT_NON_OP)} className={`mt-5 pt-5 border-t cursor-pointer hover:bg-slate-50 p-2.5 rounded-xl ${activeCategoryFilter === CAT_NON_OP ? 'bg-slate-50 border border-slate-200' : ''}`}><div className="flex justify-between text-xs text-slate-400 font-black"><span>기타 수지/자산 합계</span><span>{stats.nonOpSum.toLocaleString()}원</span></div></div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white p-8 rounded-3xl border shadow-sm h-[500px] flex flex-col font-bold">
|
|
<h3 className="text-sm font-black text-slate-800 mb-6 flex items-center gap-2 font-bold"><i className="lucide-list text-indigo-600 w-4 h-4 font-bold"></i> 정산 상세 내역 ({activeCategoryFilter})</h3>
|
|
<div className="flex-1 overflow-y-auto pr-1 custom-scrollbar space-y-2.5 font-bold">
|
|
{groupedAccountData.map((i, idx) => (
|
|
<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-4 bg-slate-50 rounded-2xl border border-slate-100 hover:border-indigo-300 transition-all">
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-[10px] font-mono text-slate-400">{i.code}</span>
|
|
<span className="text-[13.5px] font-bold text-slate-700">{i.name}</span>
|
|
</div>
|
|
<span className="text-[13.5px] font-black text-slate-900">{(i.root === CAT_REV ? i.income-i.expense : i.expense-i.income).toLocaleString()}원</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-3xl border border-amber-100 shadow-sm overflow-hidden mt-2 font-bold">
|
|
<div className="px-10 py-6 bg-amber-50/40 border-b border-amber-100 flex justify-between items-center font-bold">
|
|
<div className="flex items-center gap-4 font-bold"><div className="bg-amber-100 p-3 rounded-2xl text-amber-600 font-bold"><i className="lucide-alert-triangle w-5 h-5"></i></div><h3 className="text-base font-black text-amber-900">계정 체계 교정 제안</h3></div>
|
|
<div className="flex gap-2">
|
|
<button onClick={() => { setMasterModalType('JANGHEON'); setIsMasterModalOpen(true); }} className="px-5 py-2.5 bg-white text-indigo-600 border border-indigo-100 rounded-xl text-xs font-black shadow-sm">장헌 계정표</button>
|
|
<button onClick={() => { setMasterModalType('PTC'); setIsMasterModalOpen(true); }} className="px-5 py-2.5 bg-white text-indigo-600 border border-indigo-100 rounded-xl text-xs font-black shadow-sm font-bold">PTC 계정표</button>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-x-auto font-bold"><table className="w-full text-sm text-left font-bold"><thead><tr className="bg-slate-50 text-slate-400 border-b uppercase font-bold"><th className="p-4 pl-10 font-bold">코드</th><th>유형</th><th>프로젝트코드</th><th>명칭</th><th>적요</th><th className="text-right">금액</th><th className="text-center font-bold">교정 적용</th></tr></thead><tbody className="divide-y divide-slate-100 text-slate-700">
|
|
{filteredData.filter(d => d.recommendation).map(item => (<tr key={item.id} className="hover:bg-amber-50/10 transition-colors font-bold font-bold font-bold"><td className="p-3 pl-10 font-mono text-slate-400 font-bold">{item.code}</td><td><span className={`px-2 py-0.5 rounded-full text-[10px] font-black ${item.inferredIsMfg ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600'}`}>{item.inferredIsMfg ? '제조' : '시공/기타'}</span></td><td className="font-mono text-xs text-slate-500 font-bold">{item.pjtCode || '-'}</td><td className="font-bold">{item.name}</td><td className="max-w-[320px] truncate text-slate-500 font-bold" title={item.description || ''}>{item.description || '-'}</td><td className="text-right font-black font-bold font-bold">{Math.max(item.income, item.expense).toLocaleString()}원</td><td className="p-3 text-center"><div className="flex gap-2 justify-center items-center font-bold"><button onClick={() => applyCorrection(item.id, item.recommendation)} className="px-4 py-2 bg-amber-500 text-white rounded-xl text-xs font-black shadow-md">권장: {item.recommendation}</button><input type="text" placeholder="코드" className="border border-slate-200 rounded-xl p-2 w-16 text-center text-xs outline-none" onChange={e => setManualInputs({...manualInputs, [item.id]: e.target.value})} /><button onClick={() => applyCorrection(item.id, manualInputs[item.id])} className="px-3 py-2 bg-slate-800 text-white rounded-xl text-xs font-black">적용</button></div></td></tr>))}
|
|
</tbody></table></div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6 animate-fade font-bold font-bold">
|
|
<div className="bg-white p-8 rounded-[2.5rem] border shadow-sm font-bold overflow-hidden font-bold">
|
|
<table className="w-full text-sm text-left border-collapse font-bold">
|
|
<thead className="bg-slate-50 text-slate-400 border-b font-bold font-bold"><tr><th className="p-4 pl-10 uppercase">운영 분류</th><th>계정 매핑</th><th className="text-center font-bold">실행 예산(A)</th><th className="text-right">실제 집행(B)</th><th className="text-right font-bold">차액(B-A)</th><th className="text-right font-bold">집행률</th></tr></thead>
|
|
<tbody className="font-bold">
|
|
{currentMode === 'JANGHEON' ? (
|
|
<>
|
|
{Object.entries(GROUP_LABELS).map(([gk, gl]) => (
|
|
<React.Fragment key={gk}>
|
|
<tr className="group-header-row font-bold"><td colSpan="6" className="p-4 font-bold"><div className="flex items-center justify-between font-bold"><span className="group-title ml-8 font-bold">{gl}</span>{gk === 'FACTORY' && (<div className="flex items-center gap-3 font-bold"><div className="factory-control-box font-bold font-bold"><select className="factory-year-select font-bold" value={factoryYear} onChange={e => setFactoryYear(e.target.value)}>{sortedFactoryYears.map(y => <option key={y} value={y}>{y}년 단가</option>)}</select><button onClick={() => setIsFactoryModalOpen(true)} className="px-3 py-1.5 rounded-lg border border-indigo-200 text-indigo-600 text-[11px] font-black">단가 관리</button><div className="w-px h-4 bg-slate-200 mx-1 font-bold"></div><button onClick={applyStandards} className="factory-apply-btn font-bold font-bold">기준단가 적용</button></div></div>)}</div></td></tr>
|
|
{jhAnalysisData.filter(c => c.group === gk).map(cat => {
|
|
const rate = ['admin_hq', 'as_cost'].includes(cat.id)
|
|
? "100.0"
|
|
: (cat.budget > 0 ? (cat.actual / cat.budget * 100).toFixed(1) : "0.0");
|
|
const sk = `${(activeBudgetProject || selectedPjt)}_${cat.id}`;
|
|
const actualAmount = Math.round(cat.actual || 0);
|
|
const budgetAmount = Math.round(cat.budget || 0);
|
|
const diff = actualAmount - budgetAmount;
|
|
const isOverSpent = actualAmount > budgetAmount;
|
|
const diffText = `${diff > 0 ? '+' : ''}${diff.toLocaleString()}원`;
|
|
const budgetUnit = budgetUnitPrices[sk] || 0;
|
|
const budgetQty = budgetQuantities[sk] || 0;
|
|
const hideMapping = cat.group === 'FACTORY' || ['admin_hq', 'as_cost', 'site_manager'].includes(cat.id);
|
|
const hideActual = false;
|
|
return (
|
|
<tr key={cat.id} className="hover:bg-slate-50 border-b border-slate-50 transition-colors font-bold font-bold"><td className="p-4 pl-12 font-black text-slate-700 w-48 font-bold">{cat.codes.length > 0 ? <button type="button" onClick={() => setAnalysisDetailCat({ id: cat.id, name: cat.name, type: cat.type, codes: cat.codes })} className="text-left hover:text-indigo-600 underline underline-offset-2">{cat.name}</button> : cat.name}</td>
|
|
<td>{hideMapping ? <span className="text-slate-300 font-black">-</span> : <div className="flex flex-wrap gap-2 items-center font-bold font-bold">{cat.codes.map(c => (<span key={c} className="code-chip font-bold font-bold">{c}<button onClick={() => setJhMapping(p => p.map(x => x.id === cat.id ? {...x, codes: x.codes.filter(v => v !== c)} : x))} className="delete-chip-btn font-bold font-bold">-</button></span>))}<button onClick={() => {setMappingTargetId(cat.id); setIsAccountSelectOpen(true);}} className="add-code-trigger shadow-md font-bold font-bold">+</button></div>}</td>
|
|
<td className="p-2 text-center font-bold font-bold">
|
|
{cat.group === 'FACTORY' ? (
|
|
<div className="flex items-center gap-2 justify-center font-bold font-bold">
|
|
<div className="w-24 font-bold">
|
|
<span className="text-[10px] text-slate-400 block text-right font-black">단가</span>
|
|
<input
|
|
type="number"
|
|
readOnly
|
|
className="input-emphasize input-auto-calc font-bold"
|
|
value={budgetUnit || ''}
|
|
/>
|
|
</div>
|
|
<div className="w-16 font-bold">
|
|
<span className="text-[10px] text-slate-400 block text-right font-black">수량</span>
|
|
<input
|
|
type="number"
|
|
readOnly
|
|
className="input-emphasize input-auto-calc font-bold"
|
|
value={budgetQty || ''}
|
|
/>
|
|
</div>
|
|
<div className="min-w-[120px] text-right ml-2 font-bold font-bold">
|
|
<span className="text-[10px] text-indigo-500 block font-bold font-bold">실행예산(A)</span>
|
|
<div className="text-[14px] font-black text-indigo-600 font-bold font-bold">{budgetAmount.toLocaleString()}</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<input
|
|
type="number"
|
|
className="w-40 input-emphasize font-bold"
|
|
value={budgetAmount || ''}
|
|
onChange={e => handleBudgetInput(cat.id, e.target.value)}
|
|
placeholder="값 입력"
|
|
/>
|
|
)}
|
|
</td>
|
|
<td className="text-right font-black w-40 font-bold font-bold" style={{ whiteSpace: 'nowrap', wordBreak: 'keep-all' }}>
|
|
{cat.group === 'FACTORY' ? (
|
|
<div className="flex items-center gap-2 justify-end font-bold font-bold">
|
|
<div className="w-24 font-bold"><span className="text-[10px] text-slate-400 block text-right font-black">단가</span><input type="number" className="input-emphasize font-bold font-bold" value={unitPrices[sk] || ''} onChange={e => handleUnitPriceInput(cat.id, e.target.value)} /></div>
|
|
<div className="w-16 font-bold"><span className="text-[10px] text-slate-400 block text-right font-black">수량</span><input type="number" className="input-emphasize font-bold font-bold" value={quantities[sk] || ''} onChange={e => handleQuantityInput(cat.id, e.target.value)} /></div>
|
|
<div className="min-w-[120px] text-right ml-2 font-bold font-bold"><span className="text-[10px] text-emerald-500 block font-bold font-bold">실제집행(B)</span><div className="text-[14px] font-black text-emerald-600 font-bold font-bold">{actualAmount.toLocaleString()}</div></div>
|
|
</div>
|
|
) : (hideActual ? '-' : <span style={{ whiteSpace: 'nowrap', wordBreak: 'keep-all' }}>{`${actualAmount.toLocaleString()}원`}</span>)}
|
|
</td>
|
|
<td className={`p-4 text-right font-black w-40 ${isOverSpent ? 'text-rose-500' : diff < 0 ? 'text-emerald-600' : 'text-slate-900'}`} style={{ whiteSpace: 'nowrap', wordBreak: 'keep-all' }}>
|
|
{hideActual ? '-' : <span style={{ whiteSpace: 'nowrap', wordBreak: 'keep-all' }}>{diffText}</span>}
|
|
</td>
|
|
<td className={`p-4 text-right font-black w-24 ${parseFloat(rate) > 100 ? 'text-rose-500' : 'text-slate-900'}`}>{rate}%</td></tr>
|
|
);
|
|
})}
|
|
</React.Fragment>
|
|
))}
|
|
<tr className="bg-slate-100 border-t-2 border-slate-300">
|
|
<td className="p-4 pl-12 font-black text-slate-800">합계</td>
|
|
<td className="text-slate-400 font-black">-</td>
|
|
<td className="p-4 text-right font-black text-indigo-700">{analysisCostTotals.totalBudget.toLocaleString()}원</td>
|
|
<td className="p-4 text-right font-black text-emerald-700">{analysisCostTotals.totalActual.toLocaleString()}원</td>
|
|
<td className={`p-4 text-right font-black ${analysisCostTotals.diff > 0 ? 'text-rose-500' : analysisCostTotals.diff < 0 ? 'text-emerald-600' : 'text-slate-900'}`} style={{ whiteSpace: 'nowrap', wordBreak: 'keep-all' }}><span style={{ whiteSpace: 'nowrap', wordBreak: 'keep-all' }}>{`${analysisCostTotals.diff > 0 ? '+' : ''}${analysisCostTotals.diff.toLocaleString()}원`}</span></td>
|
|
<td className={`p-4 text-right font-black ${parseFloat(analysisCostTotals.rate) > 100 ? 'text-rose-500' : 'text-slate-900'}`}>{analysisCostTotals.rate}%</td>
|
|
</tr>
|
|
<tr className="bg-slate-50 border-b border-slate-200">
|
|
<td className="p-3 pl-12 font-black text-slate-500 text-xs">수입대비</td>
|
|
<td className="text-slate-300">-</td>
|
|
<td className="p-3 text-right font-black text-indigo-500 text-xs">{analysisCostTotals.budgetVsIncome}%</td>
|
|
<td className="p-3 text-right font-black text-emerald-500 text-xs">{analysisCostTotals.actualVsIncome}%</td>
|
|
<td className={`p-3 text-right font-black text-xs ${parseFloat(analysisCostTotals.diffVsIncome) > 0 ? 'text-rose-500' : parseFloat(analysisCostTotals.diffVsIncome) < 0 ? 'text-emerald-600' : 'text-slate-500'}`}>{analysisCostTotals.diffVsIncome}%</td>
|
|
<td className="p-3 text-right text-slate-300">-</td>
|
|
</tr>
|
|
</>
|
|
) : (
|
|
<tr>
|
|
<td className="p-8 text-center text-slate-300 font-black" colSpan="6"> </td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 모달: 운영분류 지출내역 */}
|
|
{analysisDetailCat && (
|
|
<div className="modal-overlay" onClick={() => setAnalysisDetailCat(null)}>
|
|
<div className="modal-content max-h-[85vh] font-bold" onClick={e => e.stopPropagation()}>
|
|
<div className="p-6 bg-indigo-50 border-b border-indigo-100 flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-black text-indigo-900">{analysisDetailCat.name} 지출내역</h2>
|
|
<div className="text-xs text-slate-500 mt-1">매핑 계정: {(analysisDetailCat.codes || []).join(', ') || '-'}</div>
|
|
</div>
|
|
<button onClick={() => setAnalysisDetailCat(null)} className="px-4 py-2 bg-white border border-slate-200 rounded-lg text-xs font-black">닫기</button>
|
|
</div>
|
|
<div className="px-6 py-3 bg-slate-50 border-b text-xs text-slate-600 flex items-center justify-between">
|
|
<span>총 {analysisDetailItems.length}건</span>
|
|
<span className="font-black text-slate-900">합계 {analysisDetailTotal.toLocaleString()}원</span>
|
|
</div>
|
|
<div className="p-6 overflow-y-auto custom-scrollbar flex-1 bg-white">
|
|
{analysisDetailItems.length === 0 ? (
|
|
<div className="text-center text-slate-400 py-14 text-sm font-black">해당 분류에 표시할 지출내역이 없습니다.</div>
|
|
) : (
|
|
<table className="w-full text-xs text-left border-collapse">
|
|
<thead className="bg-slate-50 text-slate-500 border-b">
|
|
<tr>
|
|
<th className="p-3">일자</th>
|
|
<th>프로젝트</th>
|
|
<th>계정코드</th>
|
|
<th>계정명</th>
|
|
<th>적요</th>
|
|
<th className="text-right">금액</th>
|
|
<th className="text-center">계정변경</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{analysisDetailItems.map(item => (
|
|
<tr key={`${analysisDetailCat.id}_${item.id}`} className="hover:bg-slate-50">
|
|
<td className="p-3 whitespace-nowrap text-slate-600">{item.date || '-'}</td>
|
|
<td className="whitespace-nowrap text-slate-700">{item.pjt || '-'}</td>
|
|
<td className="font-mono text-slate-500">{item.code || '-'}</td>
|
|
<td className="text-slate-700">{item.name || '-'}</td>
|
|
<td className="max-w-[360px] truncate text-slate-500" title={item.description || ''}>{item.description || '-'}</td>
|
|
<td className={`text-right font-black ${item.amount > 0 ? 'text-rose-500' : 'text-emerald-600'}`}>{item.amount.toLocaleString()}원</td>
|
|
<td className="text-center p-2">
|
|
<div className="flex items-center justify-center gap-2">
|
|
<input
|
|
type="text"
|
|
placeholder={item.code || '코드'}
|
|
className="border border-slate-200 rounded-lg px-2 py-1 w-16 text-center text-[11px] outline-none"
|
|
value={manualInputs[item.id] ?? ''}
|
|
onChange={e => setManualInputs({...manualInputs, [item.id]: e.target.value})}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') applyCorrection(item.id, manualInputs[item.id]);
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={() => applyCorrection(item.id, manualInputs[item.id])}
|
|
className="px-2.5 py-1.5 bg-slate-800 text-white rounded-lg text-[11px] font-black"
|
|
>
|
|
적용
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 모달: 계정 매핑 */}
|
|
{isAccountSelectOpen && (
|
|
<div className="modal-overlay" onClick={() => setIsAccountSelectOpen(false)}>
|
|
<div className="modal-content font-bold font-bold" onClick={e => e.stopPropagation()}>
|
|
<div className="p-8 bg-indigo-50 flex justify-between items-center font-bold font-bold"><h2 className="text-xl font-black text-indigo-900 font-bold font-bold">매핑 계정 선택 ({currentMode})</h2><button onClick={() => setIsAccountSelectOpen(false)} className="p-2 hover:bg-indigo-100 rounded-full text-indigo-400 font-bold font-bold font-bold"><i className="lucide-x w-6 h-6 font-bold font-bold"></i></button></div>
|
|
<div className="p-6 overflow-y-auto custom-scrollbar flex-1 grid grid-cols-2 md:grid-cols-3 gap-3 bg-slate-50 font-bold font-bold">
|
|
{Object.entries(currentMode === 'JANGHEON' ? MASTER_JANGHEON : MASTER_PTC).sort((a,b)=>parseInt(a[0])-parseInt(b[0])).map(([k, v]) => (
|
|
<button key={k} onClick={() => addCodeToMapping(k)} className="flex items-center gap-3 p-4 bg-white rounded-xl border border-slate-100 shadow-sm text-left hover:bg-indigo-600 hover:text-white transition-all group font-bold">
|
|
<span className="text-indigo-600 font-mono text-xs w-8 font-black group-hover:text-white font-bold">{k}</span><span className="text-sm truncate font-bold text-slate-700 group-hover:text-white font-bold font-bold">{v}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 모달: 마스터 리스트 */}
|
|
{isMasterModalOpen && (
|
|
<div className="modal-overlay" onClick={() => setIsMasterModalOpen(false)}>
|
|
<div className="modal-content max-h-[85vh] font-bold font-bold" onClick={e => e.stopPropagation()}>
|
|
<div className="p-8 bg-slate-900 text-white flex justify-between items-center font-bold font-bold font-bold font-bold"><h2 className="text-xl font-black font-bold font-bold">{masterModalType} 계정 일람표</h2><button onClick={() => setIsMasterModalOpen(false)} className="p-2 hover:bg-white/10 rounded-full font-bold font-bold font-bold font-bold font-bold font-bold"><i className="lucide-x w-6 h-6 font-bold font-bold font-bold font-bold"></i></button></div>
|
|
<div className="p-8 overflow-y-auto custom-scrollbar flex-1 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 bg-slate-50 font-bold 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-3 bg-white rounded-xl border border-slate-100 shadow-sm font-bold font-bold font-bold">
|
|
<span className="text-indigo-600 font-mono text-xs w-8 font-black font-bold font-bold">{k}</span><span className="text-[13px] text-slate-700 font-bold font-bold font-bold font-bold">{v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="p-4 bg-slate-50 border-t flex justify-end font-bold font-bold font-bold"><button onClick={() => setIsMasterModalOpen(false)} className="px-10 py-3 bg-slate-800 text-white rounded-xl font-black text-sm font-bold font-bold">닫기</button></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 모달: 공장 단가 관리 */}
|
|
{isFactoryModalOpen && (
|
|
<div className="modal-overlay" onClick={() => setIsFactoryModalOpen(false)}>
|
|
<div className="modal-content max-h-[85vh] font-bold" onClick={e => e.stopPropagation()}>
|
|
<div className="p-6 bg-indigo-600 text-white flex justify-between items-center">
|
|
<h2 className="text-lg font-black">공장가공비 기준단가 관리</h2>
|
|
<button onClick={() => setIsFactoryModalOpen(false)} className="px-3 py-1 text-xs bg-white/20 rounded-lg font-black">닫기</button>
|
|
</div>
|
|
<div className="p-6 bg-slate-50 border-b">
|
|
<div className="flex items-center gap-2">
|
|
<input type="text" value={newFactoryYear} onChange={e => setNewFactoryYear(e.target.value)} placeholder="연도(예: 2025)" className="input-emphasize w-40 text-left" />
|
|
<button onClick={addFactoryYear} className="px-4 py-2 bg-indigo-600 text-white rounded-lg text-xs font-black">연도 추가</button>
|
|
</div>
|
|
</div>
|
|
<div className="p-6 overflow-y-auto custom-scrollbar flex-1 space-y-4 bg-slate-50">
|
|
{sortedFactoryYears.map(year => (
|
|
<div key={year} className="bg-white rounded-2xl border border-slate-200 overflow-hidden">
|
|
<div className="px-4 py-3 bg-slate-100 flex items-center justify-between">
|
|
<div className="text-sm font-black text-slate-700">{year}년 단가</div>
|
|
<button onClick={() => deleteFactoryYear(year)} className="px-3 py-1.5 bg-rose-500 text-white rounded-lg text-[11px] font-black">연도 삭제</button>
|
|
</div>
|
|
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{jhMapping.filter(cat => cat.group === 'FACTORY').map(cat => (
|
|
<label key={`${year}_${cat.id}`} className="flex items-center justify-between gap-3 text-xs">
|
|
<span className="font-bold text-slate-700">{cat.name}</span>
|
|
<input
|
|
type="number"
|
|
className="input-emphasize w-36"
|
|
value={factoryStandards[year]?.[cat.id] ?? ''}
|
|
onChange={e => updateFactoryStandard(year, cat.id, e.target.value)}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<App />);
|
|
</script>
|
|
</body>
|
|
</html>
|