From 02bcf47a9f26476abd59ead067c22c70e5f5dd71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=98=9C=EC=9D=B8?= Date: Wed, 4 Mar 2026 10:34:16 +0900 Subject: [PATCH] Update cost-pdf.html with new C-type allocation logic and file caching features. --- cost-pdf.html | 571 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 505 insertions(+), 66 deletions(-) diff --git a/cost-pdf.html b/cost-pdf.html index 4df7a86..0c90172 100644 --- a/cost-pdf.html +++ b/cost-pdf.html @@ -150,8 +150,21 @@ } catch(e) { console.warn("Firebase not available."); } const POOL_A_PROJECTS = ['총무 [26-관리-03]', '부서 공통 [26-관리-06]', '공통']; - const POOL_B_PROJECTS = ['관리', '생산']; + const POOL_B_PROJECTS = ['관리', '생산', '인사 [26-관리-02]']; const TEAM_RATIOS = { '일반경비': { '철근팀': 0.45, '제작팀': 0.30, '공무팀': 0.25 } }; + const NON_MANAGED_FORMS = ['가족사지원', '공통', '기타', '시설관리', '연구개발', '현장자재', '현장지원', '공통(거더)', '공통(데크,가로보)', '품질']; + const FORM_ALLOC_C_RULES = { + '가족사지원': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '공통': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '기타': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '시설관리': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '연구개발': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '현장자재': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '현장지원': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '공통(거더)': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더'], + '공통(데크,가로보)': ['강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '가로보'], + '품질': ['노출거더', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '가로보'] + }; const FACTORY_WORKER_FALLBACK = [ { name: '강병흔', rate: 3065880, regularType: '정규직' }, { name: '곽병목', rate: 2313800, regularType: '정규직' }, { name: '김경수', rate: 2674500, regularType: '계약직' }, { name: '김용정', rate: 2889740, regularType: '계약직' }, @@ -182,6 +195,7 @@ const [showReport, setShowReport] = useState(false); const [selectedDetail, setSelectedDetail] = useState(null); const [detailTab, setDetailTab] = useState('account'); + const [settingsTab, setSettingsTab] = useState('wage'); const [expenses, setExpenses] = useState([]); const [laborRows, setLaborRows] = useState([]); @@ -190,6 +204,7 @@ const [workerTeamFilter, setWorkerTeamFilter] = useState('ALL'); const [formVolumes, setFormVolumes] = useState({}); const [mgmtPoolAAccounts, setMgmtPoolAAccounts] = useState(['(복리)식대비', '(복리)회식비', '(복리)간식비']); + const [uploadedFiles, setUploadedFiles] = useState({ expense: null, labor: null, hr: null }); const [allocPoolA, setAllocPoolA] = useState(true); const [allocPoolB, setAllocPoolB] = useState(true); @@ -231,6 +246,134 @@ await setDoc(docRef, { ...updates, updatedAt: new Date().toISOString() }, { merge: true }); }; + const UPLOAD_CACHE_KEY = 'costPdfUploadedFilesV1'; + const loadUploadCache = () => { + try { + const raw = localStorage.getItem(UPLOAD_CACHE_KEY); + if (!raw) return { expense: null, labor: null, hr: null }; + const parsed = JSON.parse(raw); + return { expense: parsed.expense || null, labor: parsed.labor || null, hr: parsed.hr || null }; + } catch (_) { + return { expense: null, labor: null, hr: null }; + } + }; + + const saveUploadCache = (next) => { + try { + localStorage.setItem(UPLOAD_CACHE_KEY, JSON.stringify(next)); + } catch (_) { + alert('파일 저장 공간이 부족해 업로드 원본 저장에 실패했습니다. 파일 크기를 확인해주세요.'); + } + }; + + const dataUrlToBinary = (dataUrl = '') => { + const parts = String(dataUrl).split(','); + const b64 = parts.length > 1 ? parts[1] : parts[0]; + return atob(b64 || ''); + }; + + const mapExpenseRows = (data) => data.flatMap((r, i) => { + const account = String(r['계정'] || r['소계정명'] || '미분류').trim(); + const team = String(r['팀'] || r['팀명'] || '기타').trim(); + const projectName = String(r['교량명'] || r['사업명'] || r['사업코드'] || '미지정').trim(); + const amount = utils.parseNum(r['공급가액'] || r['공급가'] || r['합계']); + const date = utils.parseDate(r['거래일'] || r['날짜']); + const desc = r['적요'] || ''; + const form = String(r['형식'] || '미분류').trim(); + return { id: `e-${Date.now()}-${i}`, date, account, team, projectName, amount, description: desc, form }; + }); + + const mapLaborRows = (data) => data.flatMap((r, i) => { + const date = utils.parseDate(r['근무일'] || r['날짜']); + const worker = r['근무자명'] || r['성명'] || ''; + const team = String(r['근무팀'] || r['소속팀'] || '기타').trim(); + const projectName = String(r['교량명'] || r['사업명'] || '미지정').trim(); + const hours = utils.parseNum(r['근무시간'] || r['시간']); + const form = String(r['형식'] || '미분류').trim(); + return { id: `l-${Date.now()}-${i}`, date, worker, team, projectName, hours, form }; + }); + + const applyExpenseData = (rows) => { + const mapped = mapExpenseRows(rows || []); + setExpenses(mapped); + saveData({ expenses: mapped }); + }; + + const applyLaborData = (rows) => { + const lData = mapLaborRows(rows || []); + setLaborRows(lData); + const newWages = { ...wageSettings }; + [...new Set(lData.map(l => l.worker))].filter(Boolean).forEach(n => { + if (!newWages[n]) newWages[n] = { rate: 0, type: 'monthly' }; + }); + setWageSettings(newWages); + saveData({ laborRows: lData, wageSettings: newWages }); + }; + + const restoreDataFromCache = (cache) => { + try { + if (cache.expense?.dataUrl) { + const wb = XLSX.read(dataUrlToBinary(cache.expense.dataUrl), { type: 'binary', cellDates: true }); + const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); + applyExpenseData(rows); + } + if (cache.labor?.dataUrl) { + const wb = XLSX.read(dataUrlToBinary(cache.labor.dataUrl), { type: 'binary', cellDates: true }); + const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); + applyLaborData(rows); + } + if (cache.hr?.dataUrl) { + const isHtml = /html/i.test(cache.hr.mime || '') || /\.(html?)$/i.test(cache.hr.name || ''); + if (isHtml) { + loadFactoryDefaultsFromText(dataUrlToBinary(cache.hr.dataUrl)); + } else { + const wb = XLSX.read(dataUrlToBinary(cache.hr.dataUrl), { type: 'binary', cellDates: true }); + const html = XLSX.utils.sheet_to_html(wb.Sheets[wb.SheetNames[0]]); + loadFactoryDefaultsFromText(html); + } + } + } catch (e) { + console.warn('업로드 캐시 복원 실패:', e); + } + }; + + const cacheUploadedFile = (type, file) => { + const reader = new FileReader(); + reader.onload = (evt) => { + const payload = { + name: file.name, + size: file.size, + mime: file.type || 'application/octet-stream', + dataUrl: evt.target.result, + savedAt: new Date().toISOString() + }; + setUploadedFiles(prev => { + const next = { ...prev, [type]: payload }; + saveUploadCache(next); + return next; + }); + }; + reader.readAsDataURL(file); + }; + + const downloadUploadedFile = (type) => { + const info = uploadedFiles[type]; + if (!info || !info.dataUrl) return; + const a = document.createElement('a'); + a.href = info.dataUrl; + a.download = info.name || `${type}.xlsx`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + const fmtSavedAt = (iso) => { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return ''; + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; + }; + const parseFactoryWorkerHtml = (htmlText) => { const doc = new DOMParser().parseFromString(String(htmlText || ''), 'text/html'); const rows = [...doc.querySelectorAll('tr')]; @@ -289,6 +432,12 @@ applyFactoryDefaults(parsed); }; + useEffect(() => { + const cache = loadUploadCache(); + setUploadedFiles(cache); + restoreDataFromCache(cache); + }, []); + useEffect(() => { // 프로젝트 루트의 FactoryWorker.xls(HTML 형식)에서 기본 인원/단가를 로드 fetch('./FactoryWorker.xls') @@ -315,20 +464,52 @@ formatWon: (v) => `₩${Math.round(v || 0).toLocaleString()}`, formatHr: (v) => `${Number(v || 0).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}`, parseNum: (v) => { const n = parseFloat(String(v).replace(/,/g, '')); return isNaN(n) ? 0 : n; }, + localYmd: (d) => { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + }, parseDate: (val) => { if (!val) return ''; - if (val instanceof Date) return val.toISOString().split('T')[0]; + if (val instanceof Date) return utils.localYmd(val); if (typeof val === 'number') { const d = new Date(Math.round((val - 25569) * 86400 * 1000)); - return d.toISOString().split('T')[0]; + return utils.localYmd(d); } return String(val).trim().substring(0, 10); + }, + monthSpan: (startYm, endYm) => { + const s = String(startYm || ''); + const e = String(endYm || ''); + if (!s && !e) return 1; + if (s && !e) return 1; + if (!s && e) return 1; + const [sy, sm] = s.split('-').map(Number); + const [ey, em] = e.split('-').map(Number); + if (!sy || !sm || !ey || !em) return 1; + const diff = (ey - sy) * 12 + (em - sm) + 1; + return diff > 0 ? diff : 1; } }; + const normalizeFormKey = (name) => String(name || '').replace(/\s+/g, '').trim().toLowerCase(); + + const toMonthKey = (dateStr) => String(dateStr || '').slice(0, 7); + const inMonthRange = (dateStr) => { + const mk = toMonthKey(dateStr); + if (!mk) return false; + return (!startDate || mk >= startDate) && (!endDate || mk <= endDate); + }; + + const isInvalidProjectName = (name) => { + const n = String(name ?? '').trim(); + return !n || ['미분류', '미지정', 'null', 'NULL', 'Null'].includes(n); + }; const onUpload = (e, type) => { const file = e.target.files[0]; if (!file) return; + cacheUploadedFile(type, file); if (type === 'hr') { const reader = new FileReader(); reader.onload = (evt) => { @@ -342,31 +523,9 @@ const wb = XLSX.read(evt.target.result, { type: 'binary', cellDates: true }); const data = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); if (type === 'expense') { - const mapped = data.flatMap((r, i) => { - const account = String(r['계정'] || r['소계정명'] || '미분류').trim(); - const team = String(r['팀'] || r['팀명'] || '기타').trim(); - const projectName = String(r['교량명'] || r['사업명'] || r['사업코드'] || '미지정').trim(); - const amount = utils.parseNum(r['공급가액'] || r['공급가'] || r['합계']); - const date = utils.parseDate(r['거래일'] || r['날짜']); - const desc = r['적요'] || ''; - const form = String(r['형식'] || '미분류').trim(); - return { id: `e-${Date.now()}-${i}`, date, account, team, projectName, amount, description: desc, form }; - }); - setExpenses(mapped); saveData({ expenses: mapped }); + applyExpenseData(data); } else { - const lData = data.flatMap((r, i) => { - const date = utils.parseDate(r['근무일'] || r['날짜']); - const worker = r['근무자명'] || r['성명'] || ''; - const team = String(r['근무팀'] || r['소속팀'] || '기타').trim(); - const projectName = String(r['교량명'] || r['사업명'] || '미지정').trim(); - const hours = utils.parseNum(r['근무시간'] || r['시간']); - const form = String(r['형식'] || '미분류').trim(); - return { id: `l-${Date.now()}-${i}`, date, worker, team, projectName, hours, form }; - }); - setLaborRows(lData); - const newWages = { ...wageSettings }; - [...new Set(lData.map(l => l.worker))].filter(Boolean).forEach(n => { if (!newWages[n]) newWages[n] = { rate: 0, type: 'monthly' }; }); - setWageSettings(newWages); saveData({ laborRows: lData, wageSettings: newWages }); + applyLaborData(data); } }; reader.readAsBinaryString(file); @@ -375,13 +534,21 @@ const calculateSettlement = (mode) => { const isB = (v) => POOL_B_PROJECTS.includes(v); const isA = (v) => POOL_A_PROJECTS.includes(v); - const fEx = expenses.filter(i => (!startDate || i.date >= startDate) && (!endDate || i.date <= endDate)); - const fLb = laborRows.filter(i => (!startDate || i.date >= startDate) && (!endDate || i.date <= endDate)); + const fEx = expenses.filter(i => inMonthRange(i.date)); + const fLb = laborRows.filter(i => inMonthRange(i.date)); + const monthFactor = utils.monthSpan(startDate, endDate); const workerTotalHours = {}; fLb.forEach(r => { workerTotalHours[r.worker] = (workerTotalHours[r.worker] || 0) + r.hours; }); + const laborWithCost = fLb.map(r => { const cfg = wageSettings[r.worker] || { rate: 0, type: 'monthly' }; - let cost = cfg.type === 'hourly' ? r.hours * cfg.rate : (workerTotalHours[r.worker] > 0 ? (r.hours / workerTotalHours[r.worker]) * cfg.rate : 0); + let cost = 0; + if (cfg.type === 'hourly') { + cost = r.hours * cfg.rate; + } else { + const monthlyTotal = (cfg.rate || 0) * monthFactor; + cost = workerTotalHours[r.worker] > 0 ? (r.hours / workerTotalHours[r.worker]) * monthlyTotal : 0; + } return { ...r, cost }; }); @@ -425,46 +592,135 @@ }); const customSort = (a, b) => { - const invalidNames = ['미분류', '미지정']; - if (invalidNames.includes(a.name) && !invalidNames.includes(b.name)) return 1; - if (!invalidNames.includes(a.name) && invalidNames.includes(b.name)) return -1; + if (isInvalidProjectName(a.name) && !isInvalidProjectName(b.name)) return 1; + if (!isInvalidProjectName(a.name) && isInvalidProjectName(b.name)) return -1; return b.final - a.final; }; - if (mode === 'project') return settledProjects.sort(customSort); + if (mode === 'project') { + return settledProjects + .map(p => ({ + ...p, + allocMeta: { poolAVal, poolBVal, revenueHrs }, + allocTrace: [{ + projectName: p.name, + directShare: p.direct, + ratio: p.direct > 0 ? 1 : 0, + projectHours: p.hours, + shareHours: p.hours, + allocA: p.allocA, + allocB: p.allocB + }] + })) + .sort(customSort); + } if (mode === 'type') { const formMap = {}; settledProjects.forEach(p => { Object.entries(p.byForm).forEach(([fName, val]) => { - if (!formMap[fName]) formMap[fName] = { name: fName, direct: 0, allocA: 0, allocB: 0, final: 0, hours: 0, breakdown: {} }; + if (!formMap[fName]) formMap[fName] = { name: fName, direct: 0, allocA: 0, allocB: 0, final: 0, hours: 0, breakdown: {}, allocTrace: [] }; formMap[fName].direct += val; const ratio = p.direct > 0 ? val / p.direct : 0; - formMap[fName].allocA += p.allocA * ratio; - formMap[fName].allocB += p.allocB * ratio; - formMap[fName].hours += p.hours * ratio; + const shareHours = p.hours * ratio; + const shareAllocA = p.allocA * ratio; + const shareAllocB = p.allocB * ratio; + formMap[fName].allocA += shareAllocA; + formMap[fName].allocB += shareAllocB; + formMap[fName].hours += shareHours; formMap[fName].breakdown[p.name] = (formMap[fName].breakdown[p.name] || 0) + val; + formMap[fName].allocTrace.push({ + projectName: p.name, + directShare: val, + ratio, + projectHours: p.hours, + shareHours, + allocA: shareAllocA, + allocB: shareAllocB + }); }); }); - return Object.values(formMap).map(t => { - const finalVal = t.direct + t.allocA + t.allocB; + const typeRows = Object.values(formMap).map(t => ({ + ...t, + allocC: 0, + allocCIn: 0, + allocCOut: 0, + allocCTraceIn: [], + allocCTraceOut: [], + baseFinal: t.direct + t.allocA + t.allocB + })); + + const byKey = {}; + typeRows.forEach(r => { byKey[normalizeFormKey(r.name)] = r; }); + + Object.entries(FORM_ALLOC_C_RULES).forEach(([sourceName, targetNames]) => { + const src = byKey[normalizeFormKey(sourceName)]; + if (!src) return; + const amount = src.baseFinal || 0; + if (amount === 0) return; + const targets = (targetNames || []) + .map(name => byKey[normalizeFormKey(name)]) + .filter(Boolean); + if (!targets.length) return; + const share = amount / targets.length; + src.allocC -= amount; + src.allocCOut += amount; + src.allocCTraceOut.push({ source: sourceName, amount, targetCount: targets.length, perTarget: share }); + targets.forEach(tg => { + tg.allocC += share; + tg.allocCIn += share; + tg.allocCTraceIn.push({ source: sourceName, amount: share }); + }); + }); + + return typeRows.map(t => { + const finalVal = t.baseFinal + t.allocC; const volInfo = formVolumes[t.name] || { value: 0, unit: 'ton' }; - return { ...t, final: finalVal, volInfo, unitCost: volInfo.value > 0 ? finalVal / volInfo.value : 0 }; + return { + ...t, + final: finalVal, + volInfo, + unitCost: volInfo.value > 0 ? finalVal / volInfo.value : 0, + allocMeta: { poolAVal, poolBVal, revenueHrs }, + allocTrace: (t.allocTrace || []).sort((a, b) => (b.allocA + b.allocB) - (a.allocA + a.allocB)), + allocCMeta: { + isSource: NON_MANAGED_FORMS.some(f => normalizeFormKey(f) === normalizeFormKey(t.name)), + rulesApplied: t.allocCTraceOut || [], + receivedFrom: t.allocCTraceIn || [] + } + }; }).sort(customSort); } if (mode === 'team') { const tmMap = {}; settledProjects.forEach(p => { Object.entries(p.byTeam).forEach(([tm, val]) => { - if (!tmMap[tm]) tmMap[tm] = { name: tm, direct: 0, allocA: 0, allocB: 0, final: 0, hours: 0, breakdown: {} }; + if (!tmMap[tm]) tmMap[tm] = { name: tm, direct: 0, allocA: 0, allocB: 0, final: 0, hours: 0, breakdown: {}, allocTrace: [] }; tmMap[tm].direct += val; const ratio = p.direct > 0 ? val / p.direct : 0; - tmMap[tm].allocA += p.allocA * ratio; - tmMap[tm].allocB += p.allocB * ratio; - tmMap[tm].hours += p.hours * ratio; + const shareHours = p.hours * ratio; + const shareAllocA = p.allocA * ratio; + const shareAllocB = p.allocB * ratio; + tmMap[tm].allocA += shareAllocA; + tmMap[tm].allocB += shareAllocB; + tmMap[tm].hours += shareHours; tmMap[tm].breakdown[p.name] = (tmMap[tm].breakdown[p.name] || 0) + val; + tmMap[tm].allocTrace.push({ + projectName: p.name, + directShare: val, + ratio, + projectHours: p.hours, + shareHours, + allocA: shareAllocA, + allocB: shareAllocB + }); }); }); - return Object.values(tmMap).map(t => ({ ...t, final: t.direct + t.allocA + t.allocB })).sort(customSort); + return Object.values(tmMap).map(t => ({ + ...t, + final: t.direct + t.allocA + t.allocB, + allocMeta: { poolAVal, poolBVal, revenueHrs }, + allocTrace: (t.allocTrace || []).sort((a, b) => (b.allocA + b.allocB) - (a.allocA + a.allocB)) + })).sort(customSort); } return { poolAVal, poolBVal, grandTotalHrs: fLb.reduce((s,x)=>s+x.hours, 0), revenueHrs }; }; @@ -474,7 +730,9 @@ const project = calculateSettlement('project'); const type = calculateSettlement('type'); const team = calculateSettlement('team'); - const displayData = { project, type, team }[viewMode] || project; + const hiddenTypeKeys = new Set(NON_MANAGED_FORMS.map(normalizeFormKey)); + const displayType = type.filter(item => !hiddenTypeKeys.has(normalizeFormKey(item.name))); + const displayData = { project, type: displayType, team }[viewMode] || project; const totalDirect = project.reduce((s, x) => s + x.direct, 0); const total = totalDirect + (allocPoolA ? meta.poolAVal : 0) + (allocPoolB ? meta.poolBVal : 0); return { displayData, poolA: meta.poolAVal, poolB: meta.poolBVal, total, grandTotalHrs: meta.grandTotalHrs, revenueHrs: meta.revenueHrs, all: { project, type, team } }; @@ -516,6 +774,38 @@ return names.filter(n => workerTeamMap[n] === workerTeamFilter); }, [wageSettings, workerTeamFilter, workerTeamMap]); + const laborCalcRows = useMemo(() => { + const fLb = laborRows.filter(i => inMonthRange(i.date)); + const monthFactor = utils.monthSpan(startDate, endDate); + const workerMap = {}; + fLb.forEach(r => { + const worker = String(r.worker || '').trim(); + if (!worker) return; + const h = Number(r.hours || 0); + const pName = String(r.projectName || '미지정').trim() || '미지정'; + if (!workerMap[worker]) workerMap[worker] = { totalHours: 0, projects: {} }; + workerMap[worker].totalHours += h; + workerMap[worker].projects[pName] = (workerMap[worker].projects[pName] || 0) + h; + }); + + const workers = Array.from(new Set([...Object.keys(wageSettings || {}), ...Object.keys(workerMap)])).sort(); + return workers.map(name => { + const cfg = wageSettings[name] || { rate: 0, type: 'monthly' }; + const totalHours = workerMap[name]?.totalHours || 0; + const monthlyTotal = (cfg.rate || 0) * monthFactor; + const appliedHourly = cfg.type === 'hourly' ? (cfg.rate || 0) : (totalHours > 0 ? monthlyTotal / totalHours : 0); + const projects = Object.entries(workerMap[name]?.projects || {}) + .map(([projectName, hours]) => ({ + projectName, + hours, + amount: hours * appliedHourly + })) + .sort((a, b) => b.amount - a.amount); + const totalAmount = projects.reduce((s, c) => s + c.amount, 0); + return { name, type: cfg.type || 'monthly', rate: cfg.rate || 0, totalHours, appliedHourly, totalAmount, projects }; + }); + }, [laborRows, wageSettings, startDate, endDate]); + useEffect(() => { if (!selectedDetail) return; if (selectedDetail.byAccount) setDetailTab('account'); @@ -604,7 +894,7 @@ {results.all.project.map(p => ( - + {p.name} {Math.round(p.direct).toLocaleString()} {allocPoolA && {Math.round(p.allocA).toLocaleString()}} @@ -629,6 +919,7 @@ 직접비 {allocPoolA && 운영비 추가(A)} {allocPoolB && 관리비 추가(B)} + 형식배분(C) 총액 생산량/단위 단위당 원가(₩) @@ -636,11 +927,12 @@ {results.all.type.map(t => ( - + {t.name} {Math.round(t.direct).toLocaleString()} {allocPoolA && {Math.round(t.allocA).toLocaleString()}} {allocPoolB && {Math.round(t.allocB).toLocaleString()}} + = 0 ? 'text-violet-700' : 'text-rose-600'}`}>{Math.round(t.allocC || 0).toLocaleString()} ₩{Math.round(t.final).toLocaleString()} {t.volInfo.value.toLocaleString()} {t.volInfo.unit} ₩{Math.round(t.unitCost).toLocaleString()} @@ -681,7 +973,7 @@
-

JANGHEON COST ANALYSIS ENGINE v5.5

+

Skilled JANGHEON COST ANALYSIS ENGINE v5.5

Approved
JANGHEON
@@ -742,9 +1034,9 @@
- setStartDate(e.target.value)} /> + setStartDate(e.target.value)} /> ~ - setEndDate(e.target.value)} /> + setEndDate(e.target.value)} />
@@ -764,13 +1056,15 @@ 직접비 배분(A) 배분(B) + {viewMode === 'type' && 배분(C)} + {viewMode === 'type' && C 배분내역} 최종 원가 {viewMode === 'type' && 생산량 / 단위원가} {results.displayData.map(item => { - const isInvalid = item.name === '미분류' || item.name === '미지정'; + const isInvalid = isInvalidProjectName(item.name); return ( setSelectedDetail(item)}> @@ -784,6 +1078,26 @@ ₩{Math.round(item.direct).toLocaleString()} ₩{Math.round(item.allocA).toLocaleString()} ₩{Math.round(item.allocB).toLocaleString()} + {viewMode === 'type' && ( + = 0 ? 'text-violet-600' : 'text-rose-600'}`}> + ₩{Math.round(item.allocC || 0).toLocaleString()} + + )} + {viewMode === 'type' && ( + + {(item.allocCMeta?.receivedFrom || []).length ? ( +
+ {item.allocCMeta.receivedFrom.map((src, idx) => ( +
+ {src.source}: {utils.formatWon(src.amount)} +
+ ))} +
+ ) : ( + - + )} + + )} setSelectedDetail(item)}>₩{Math.round(item.final).toLocaleString()} {viewMode === 'type' && ( @@ -827,8 +1141,25 @@

{u.title}

+ {uploadedFiles[u.id] && ( +
+
+ 저장됨: {uploadedFiles[u.id].name} ({Math.round((uploadedFiles[u.id].size || 0) / 1024)}KB) +
+
+ {fmtSavedAt(uploadedFiles[u.id].savedAt)} +
+ +
+ )}

{u.count} RECORDS SYNCED

))} @@ -853,9 +1184,27 @@ )} {activeTab === 'settings' && ( -
+

인사 관리

+
+ {[ + { id: 'wage', label: '단가 설정' }, + { id: 'calc', label: '계산 상세' } + ].map(tab => ( + + ))} +
+ + {settingsTab === 'wage' && ( + <>
{['철근팀', '제작팀', '공무팀', '일용직'].map(team => (
@@ -907,6 +1256,56 @@
))}
+ + )} + + {settingsTab === 'calc' && ( +
+
+ 표시 기준: {startDate || '전체'} ~ {endDate || '전체'} / 월급제는 `(월급 × 개월수) ÷ 개인 총근무시간`으로 환산 +
+
+ + + + + + + + + + + + + + {laborCalcRows.map(row => ( + + + + + + + + + + ))} + +
근무자타입입력 단가총근무시간적용 시급총 인건비프로젝트별 금액
{row.name}{row.type === 'hourly' ? '시급제' : '월급제'}₩{Math.round(row.rate).toLocaleString()}{utils.formatHr(row.totalHours)}₩{Math.round(row.appliedHourly).toLocaleString()}₩{Math.round(row.totalAmount).toLocaleString()} + {row.projects.length === 0 ? ( + - + ) : ( +
+ {row.projects.map(p => ( + + {p.projectName}: ₩{Math.round(p.amount).toLocaleString()} + + ))} +
+ )} +
+
+
+ )}
)} @@ -914,8 +1313,8 @@ {selectedDetail && (
-
-
+
+

{selectedDetail.name} ANALYTICS

@@ -926,15 +1325,55 @@
-
Direct Input{utils.formatWon(selectedDetail.direct)}
-
Alloc(A+B){utils.formatWon(selectedDetail.allocA + selectedDetail.allocB)}
-
+
Direct Input{utils.formatWon(selectedDetail.direct)}
+
Alloc(A+B){utils.formatWon(selectedDetail.allocA + selectedDetail.allocB)}
+
+ + {selectedDetail.allocMeta && ( +
+
+
+

A/B 배분 계산과정

+
+
+
기준식: 배분금액 = (배분공수 / 전체 수익공수) × Pool 금액
+
A = ({utils.formatHr(selectedDetail.hours || 0)} / {utils.formatHr(selectedDetail.allocMeta.revenueHrs || 0)}) × {utils.formatWon(selectedDetail.allocMeta.poolAVal || 0)} = {utils.formatWon(selectedDetail.allocA || 0)}
+
B = ({utils.formatHr(selectedDetail.hours || 0)} / {utils.formatHr(selectedDetail.allocMeta.revenueHrs || 0)}) × {utils.formatWon(selectedDetail.allocMeta.poolBVal || 0)} = {utils.formatWon(selectedDetail.allocB || 0)}
+
+
+ + + + + + + + + + + + + {(selectedDetail.allocTrace || []).map((r, idx) => ( + + + + + + + + + ))} + +
프로젝트직접비 기여직접비 비율배분공수A 기여B 기여
{r.projectName}{utils.formatWon(r.directShare || 0)}{((r.ratio || 0) * 100).toFixed(2)}%{utils.formatHr(r.shareHours || 0)}{utils.formatWon(r.allocA || 0)}{utils.formatWon(r.allocB || 0)}
+
+
+ )}
-
-
-

Breakdown Analysis

-
+
+
+

Breakdown Analysis

+
{selectedDetail.byAccount && (
{[ @@ -965,7 +1404,7 @@ if (!groups) return

No Breakdown Data

; return Object.entries(groups).sort((a,b) => b[1]-a[1]).map(([name, val], i) => { const p = selectedDetail.direct > 0 ? (val / selectedDetail.direct * 100).toFixed(1) : 0; - const isErrParent = ['미분류', '미지정'].includes(selectedDetail.name); + const isErrParent = isInvalidProjectName(selectedDetail.name); return (
@@ -983,7 +1422,7 @@
- +