diff --git a/combine.html b/combine.html index bf12f47..e5788a2 100644 --- a/combine.html +++ b/combine.html @@ -1,5 +1,4 @@ - @@ -97,13 +96,11 @@ { 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'], 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'], type: 'cost', group: 'DIRECT' }, - { id: 'etc_comp', name: '보증/보상비', codes: ['614'], type: 'cost', group: 'DIRECT' }, - { id: 'etc_misc', name: '기타경비', codes: ['615'], type: 'cost', group: 'DIRECT' }, - { id: 'etc_overseas', name: '해외출장비', codes: ['616'], type: 'cost', group: 'DIRECT' }, - { id: 'direct_exp', name: '직접경비 (외주비)', codes: ['611'], 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' }, @@ -128,11 +125,9 @@ safety: 0, residence: 2880000, site_manager: 0, - etc_trip: 0, - etc_comp: 0, - etc_misc: 3414400, - etc_overseas: 0, + etc_trip: 3414400, direct_exp: 2410000, + outsource: 0, admin_hq: 66769950, as_cost: 13353990, factory_rebar: 11066155.2, @@ -185,6 +180,7 @@ 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); @@ -255,33 +251,54 @@ return fallback; }; - const handleFileUpload = (e) => { - const file = e.target.files[0]; if (!file) return; - setUploading(true); - const reader = new FileReader(); - reader.onload = (evt) => { - try { - const data = new Uint8Array(evt.target.result); - const workbook = XLSX.read(data, {type: 'array'}); - const json = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]); - 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 pjtCode = String(row['프로젝트코드'] || row['프로젝트 코드'] || row['PJT코드'] || row['PJT 코드'] || row['project no.'] || '').trim(); - const cat = String(row['PJT코드_수정'] || row['PJT코드수정'] || '').trim(); - const dateRaw = row['거래일'] || row['일자'] || ''; - let date = (typeof dateRaw === 'number') ? new Date((dateRaw - 25569) * 86400 * 1000).toISOString().split('T')[0] : String(dateRaw).trim().replace(/[\.\/]/g, '-'); - return { id: idx, code, pjt, pjtCode, pjtCategory: cat, date, description: String(row['적요'] || ''), income: type.includes('입') ? supply : 0, expense: type.includes('출') ? supply : 0, isCorrected: false }; - }); - setRawData(newItems); - } catch (err) { console.error(err); } - setUploading(false); - }; - reader.readAsArrayBuffer(file); + 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); @@ -292,11 +309,13 @@ } // ✅ 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))) { - const isMfgPjt = (String(d.pjt) + String(d.pjtCategory)).toLowerCase().includes('제조'); - recommendation = findJangheonRecommendationByName(String(d.code), isMfgPjt); + recommendation = findJangheonRecommendationByName(String(d.code), inferredIsMfg); } - return { ...d, root, name, recommendation }; + return { ...d, root, name, recommendation, inferredIsMfg }; }); }, [rawData, currentMode]); @@ -370,6 +389,10 @@ 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]); @@ -383,6 +406,96 @@ 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] || ''; @@ -390,27 +503,27 @@ }, [factoryStandards, factoryYear, sortedFactoryYears]); const handleBudgetInput = (catId, value) => { - const targetProject = activeBudgetProject; + const targetProject = activeBudgetProject || selectedPjt; if (!targetProject) return; const key = `${targetProject}_${catId}`; const num = parseFloat(value); - setBudgets(prev => ({ ...prev, [key]: Number.isFinite(num) ? num : 0 })); + setBudgets(prev => ({ ...prev, [key]: Number.isFinite(num) ? Math.round(num) : 0 })); }; const handleUnitPriceInput = (catId, value) => { - const targetProject = activeBudgetProject; + const targetProject = activeBudgetProject || selectedPjt; if (!targetProject) return; const key = `${targetProject}_${catId}`; const num = parseFloat(value); - setUnitPrices(prev => ({ ...prev, [key]: Number.isFinite(num) ? num : 0 })); + setUnitPrices(prev => ({ ...prev, [key]: Number.isFinite(num) ? Math.round(num) : 0 })); }; const handleQuantityInput = (catId, value) => { - const targetProject = activeBudgetProject; + const targetProject = activeBudgetProject || selectedPjt; if (!targetProject) return; const key = `${targetProject}_${catId}`; const num = parseFloat(value); - setQuantities(prev => ({ ...prev, [key]: Number.isFinite(num) ? num : 0 })); + setQuantities(prev => ({ ...prev, [key]: Number.isFinite(num) ? Math.round(num) : 0 })); }; const addFactoryYear = () => { @@ -625,11 +738,9 @@ safety: amount(rowSafety), residence: amount(firstRow('주재비')), site_manager: 0, - etc_trip: 0, - etc_comp: 0, - etc_misc: amount(firstRow('기타비')), - etc_overseas: 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), @@ -855,12 +966,7 @@
-
-
-

통합 실행분석 시스템

- Update from Execution.html -
-
+

통합 실행분석 시스템

@@ -908,6 +1014,18 @@
)} + {currentBridgeInfo && activeTab === 'analysis' && ( +
+
+

실행예산 집행 분석

+ 100 ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600'}`}> + 총 집행률 {analysisCostTotals.rate}% + +
+

{executionInsights.opinion}

+
+ )} + {activeTab === 'dashboard' ? (
@@ -961,53 +1079,38 @@
- {filteredData.filter(d => d.recommendation).map(item => ())} + {filteredData.filter(d => d.recommendation).map(item => ())}
코드유형프로젝트코드명칭적요금액교정 적용
{item.code}{item.pjtCategory.includes('제조') ? '제조' : '시공/기타'}{item.pjtCode || '-'}{item.name}{item.description || '-'}{Math.max(item.income, item.expense).toLocaleString()}원
setManualInputs({...manualInputs, [item.id]: e.target.value})} />
{item.code}{item.inferredIsMfg ? '제조' : '시공/기타'}{item.pjtCode || '-'}{item.name}{item.description || '-'}{Math.max(item.income, item.expense).toLocaleString()}원
setManualInputs({...manualInputs, [item.id]: e.target.value})} />
) : (
-
-
-

실행 공정률 (수입기준)

-
{analyticSummaries.progress}%
-
-
-
-

예산 소진 속도 (지출기준)

-
{analyticSummaries.velocity}%
-
-
-
-
- + - {currentMode === 'JANGHEON' ? Object.entries(GROUP_LABELS).map(([gk, gl]) => ( - - - {jhAnalysisData.filter(c => c.group === gk).map(cat => { + {currentMode === 'JANGHEON' ? ( + <> + {Object.entries(GROUP_LABELS).map(([gk, gl]) => ( + + + {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 = ['admin_hq', 'as_cost', 'site_manager'].includes(cat.id); - return ( - + const hideActual = false; + return ( + - + + - ); - })} - - )) : ( + ); + })} + + ))} + + + + + + + + + + + + + + + + + + ) : ( - + )}
운영 분류계정 매핑실제 집행(A)실행 예산(B)집행률
운영 분류계정 매핑실행 예산(A)실제 집행(B)차액(B-A)집행률
{gl}{gk === 'FACTORY' && (
)}
{gl}{gk === 'FACTORY' && (
)}
{cat.name}
{cat.codes.length > 0 ? : cat.name} {hideMapping ? - :
{cat.codes.map(c => ({c}))}
}
- {cat.group === 'FACTORY' ? ( -
-
단가 handleUnitPriceInput(cat.id, e.target.value)} />
-
수량 handleQuantityInput(cat.id, e.target.value)} />
-
실제집행
{cat.actual.toLocaleString()}
-
- ) : (hideActual ? '-' : `${cat.actual.toLocaleString()}원`)} -
{cat.group === 'FACTORY' ? (
@@ -1030,36 +1133,137 @@ />
- 실행예산 -
{cat.budget.toLocaleString()}
+ 실행예산(A) +
{budgetAmount.toLocaleString()}
) : ( handleBudgetInput(cat.id, e.target.value)} + placeholder="값 입력" /> )}
+ {cat.group === 'FACTORY' ? ( +
+
단가 handleUnitPriceInput(cat.id, e.target.value)} />
+
수량 handleQuantityInput(cat.id, e.target.value)} />
+
실제집행(B)
{actualAmount.toLocaleString()}
+
+ ) : (hideActual ? '-' : {`${actualAmount.toLocaleString()}원`})} +
+ {hideActual ? '-' : {diffText}} + 100 ? 'text-rose-500' : 'text-slate-900'}`}>{rate}%
합계-{analysisCostTotals.totalBudget.toLocaleString()}원{analysisCostTotals.totalActual.toLocaleString()}원 0 ? 'text-rose-500' : analysisCostTotals.diff < 0 ? 'text-emerald-600' : 'text-slate-900'}`} style={{ whiteSpace: 'nowrap', wordBreak: 'keep-all' }}>{`${analysisCostTotals.diff > 0 ? '+' : ''}${analysisCostTotals.diff.toLocaleString()}원`} 100 ? 'text-rose-500' : 'text-slate-900'}`}>{analysisCostTotals.rate}%
수입대비-{analysisCostTotals.budgetVsIncome}%{analysisCostTotals.actualVsIncome}% 0 ? 'text-rose-500' : parseFloat(analysisCostTotals.diffVsIncome) < 0 ? 'text-emerald-600' : 'text-slate-500'}`}>{analysisCostTotals.diffVsIncome}%-
+
)} + {/* 모달: 운영분류 지출내역 */} + {analysisDetailCat && ( +
setAnalysisDetailCat(null)}> +
e.stopPropagation()}> +
+
+

{analysisDetailCat.name} 지출내역

+
매핑 계정: {(analysisDetailCat.codes || []).join(', ') || '-'}
+
+ +
+
+ 총 {analysisDetailItems.length}건 + 합계 {analysisDetailTotal.toLocaleString()}원 +
+
+ {analysisDetailItems.length === 0 ? ( +
해당 분류에 표시할 지출내역이 없습니다.
+ ) : ( + + + + + + + + + + + + + + {analysisDetailItems.map(item => ( + + + + + + + + + + ))} + +
일자프로젝트계정코드계정명적요금액계정변경
{item.date || '-'}{item.pjt || '-'}{item.code || '-'}{item.name || '-'}{item.description || '-'} 0 ? 'text-rose-500' : 'text-emerald-600'}`}>{item.amount.toLocaleString()}원 +
+ setManualInputs({...manualInputs, [item.id]: e.target.value})} + onKeyDown={e => { + if (e.key === 'Enter') applyCorrection(item.id, manualInputs[item.id]); + }} + /> + +
+
+ )} +
+
+
+ )} + {/* 모달: 계정 매핑 */} {isAccountSelectOpen && (
setIsAccountSelectOpen(false)}> @@ -1141,4 +1345,4 @@ root.render(); - \ No newline at end of file +