Update combine.html with latest project integration features and UI improvements
This commit is contained in:
386
combine.html
386
combine.html
@@ -1,5 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Note: combine.html은 test.html이 아닌 Execution.html의 업데이트 버전입니다. -->
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -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 @@
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-black text-slate-900 leading-none">통합 실행분석 시스템</h1>
|
||||
<span className="bg-indigo-50 text-indigo-500 text-[9px] px-1.5 py-0.5 rounded border border-indigo-100 font-black">Update from Execution.html</span>
|
||||
</div>
|
||||
<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><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>
|
||||
@@ -908,6 +1014,18 @@
|
||||
</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">
|
||||
@@ -961,53 +1079,38 @@
|
||||
</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.pjtCategory.includes('제조') ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600'}`}>{item.pjtCategory.includes('제조') ? '제조' : '시공/기타'}</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>))}
|
||||
{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="grid grid-cols-2 gap-6 font-bold font-bold">
|
||||
<div className="bg-white p-10 rounded-[2.5rem] border border-slate-100 shadow-sm flex flex-col items-center justify-center space-y-4 font-bold">
|
||||
<h4 className="text-sm font-black text-slate-400">실행 공정률 (수입기준)</h4>
|
||||
<div className="text-5xl font-black text-indigo-600">{analyticSummaries.progress}%</div>
|
||||
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden font-bold"><div className="h-full bg-indigo-600 transition-all duration-1000" style={{width: `${Math.min(analyticSummaries.progress, 100)}%`}}></div></div>
|
||||
</div>
|
||||
<div className="bg-white p-10 rounded-[2.5rem] border border-slate-100 shadow-sm flex flex-col items-center justify-center space-y-4 font-bold">
|
||||
<h4 className="text-sm font-black text-slate-400 font-bold">예산 소진 속도 (지출기준)</h4>
|
||||
<div className="text-5xl font-black text-emerald-600">{analyticSummaries.velocity}%</div>
|
||||
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden font-bold"><div className="h-full bg-emerald-500 transition-all duration-1000" style={{width: `${Math.min(analyticSummaries.velocity, 100)}%`}}></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-right">실제 집행(A)</th><th className="text-center font-bold">실행 예산(B)</th><th className="text-right font-bold">집행률</th></tr></thead>
|
||||
<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]) => (
|
||||
{currentMode === 'JANGHEON' ? (
|
||||
<>
|
||||
{Object.entries(GROUP_LABELS).map(([gk, gl]) => (
|
||||
<React.Fragment key={gk}>
|
||||
<tr className="group-header-row font-bold"><td colSpan="5" 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>
|
||||
<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 = ['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.name}</td>
|
||||
<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="text-right font-black w-40 font-bold font-bold">
|
||||
{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">실제집행</span><div className="text-[14px] font-black text-emerald-600 font-bold font-bold">{cat.actual.toLocaleString()}</div></div>
|
||||
</div>
|
||||
) : (hideActual ? '-' : `${cat.actual.toLocaleString()}원`)}
|
||||
</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">
|
||||
@@ -1030,36 +1133,137 @@
|
||||
/>
|
||||
</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">실행예산</span>
|
||||
<div className="text-[14px] font-black text-indigo-600 font-bold font-bold">{cat.budget.toLocaleString()}</div>
|
||||
<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"
|
||||
readOnly
|
||||
className="w-40 input-emphasize input-auto-calc font-bold"
|
||||
value={cat.budget || ''}
|
||||
placeholder="업로드값"
|
||||
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="5"> </td>
|
||||
<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)}>
|
||||
|
||||
Reference in New Issue
Block a user