Update cost-pdf.html with new C-type allocation logic and file caching features.

This commit is contained in:
2026-03-04 10:34:16 +09:00
parent 76c4c0cc1c
commit 02bcf47a9f

View File

@@ -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 @@
</thead>
<tbody>
{results.all.project.map(p => (
<tr key={p.name} className={['미분류', '미지정'].includes(p.name) ? 'bg-red-50 text-red-700' : ''}>
<tr key={p.name} className={isInvalidProjectName(p.name) ? 'bg-red-50 text-red-700' : ''}>
<td className="font-bold">{p.name}</td>
<td className="text-right">{Math.round(p.direct).toLocaleString()}</td>
{allocPoolA && <td className="text-right text-blue-700">{Math.round(p.allocA).toLocaleString()}</td>}
@@ -629,6 +919,7 @@
<th style={{ width: '14%' }} className="text-right">직접비</th>
{allocPoolA && <th style={{ width: '14%' }} className="text-right text-blue-700">운영비 추가(A)</th>}
{allocPoolB && <th style={{ width: '14%' }} className="text-right text-orange-700">관리비 추가(B)</th>}
<th style={{ width: '14%' }} className="text-right text-violet-700">형식배분(C)</th>
<th style={{ width: '14%' }} className="text-right font-black">총액</th>
<th style={{ width: '10%' }} className="text-right">생산량/단위</th>
<th style={{ width: '10%' }} className="text-right text-emerald-700 font-black">단위당 원가()</th>
@@ -636,11 +927,12 @@
</thead>
<tbody>
{results.all.type.map(t => (
<tr key={t.name} className={['미분류', '미지정'].includes(t.name) ? 'bg-red-50' : ''}>
<tr key={t.name} className={isInvalidProjectName(t.name) ? 'bg-red-50' : ''}>
<td className="font-bold">{t.name}</td>
<td className="text-right">{Math.round(t.direct).toLocaleString()}</td>
{allocPoolA && <td className="text-right text-blue-700">{Math.round(t.allocA).toLocaleString()}</td>}
{allocPoolB && <td className="text-right text-orange-700">{Math.round(t.allocB).toLocaleString()}</td>}
<td className={`text-right ${t.allocC >= 0 ? 'text-violet-700' : 'text-rose-600'}`}>{Math.round(t.allocC || 0).toLocaleString()}</td>
<td className="text-right font-black">{Math.round(t.final).toLocaleString()}</td>
<td className="text-right italic text-slate-500">{t.volInfo.value.toLocaleString()} {t.volInfo.unit}</td>
<td className="text-right font-black text-emerald-700">{Math.round(t.unitCost).toLocaleString()}</td>
@@ -681,7 +973,7 @@
</div>
<div className="mt-20 pt-10 border-t border-slate-100 text-center space-y-4">
<p className="text-[9px] font-bold text-slate-300 uppercase tracking-[0.6em] italic">JANGHEON COST ANALYSIS ENGINE v5.5</p>
<p className="text-[9px] font-bold text-slate-300 uppercase tracking-[0.6em] italic">Skilled JANGHEON COST ANALYSIS ENGINE v5.5</p>
<div className="flex justify-center gap-10 py-6 opacity-20 grayscale">
<div className="border-2 border-slate-900 p-2 px-8 rounded-full font-black text-slate-900 text-lg uppercase italic">Approved</div>
<div className="border-2 border-slate-900 p-2 px-8 rounded-full font-black text-slate-900 text-lg uppercase italic">JANGHEON</div>
@@ -742,9 +1034,9 @@
</div>
<div className="h-4 w-px bg-slate-200"></div>
<div className="flex items-center gap-2 shrink-0">
<input type="date" className="bg-slate-50 border rounded-lg text-xs font-bold px-3 py-1.5 focus:ring-1 focus:ring-blue-300 outline-none" value={startDate} onChange={e => setStartDate(e.target.value)} />
<input type="month" className="bg-slate-50 border rounded-lg text-xs font-bold px-3 py-1.5 focus:ring-1 focus:ring-blue-300 outline-none" value={startDate} onChange={e => setStartDate(e.target.value)} />
<span className="text-slate-300 text-xs font-bold">~</span>
<input type="date" className="bg-slate-50 border rounded-lg text-xs font-bold px-3 py-1.5 focus:ring-1 focus:ring-blue-300 outline-none" value={endDate} onChange={e => setEndDate(e.target.value)} />
<input type="month" className="bg-slate-50 border rounded-lg text-xs font-bold px-3 py-1.5 focus:ring-1 focus:ring-blue-300 outline-none" value={endDate} onChange={e => setEndDate(e.target.value)} />
</div>
</div>
<div className="flex bg-slate-100 p-0.5 rounded-lg gap-0.5 shrink-0 border">
@@ -764,13 +1056,15 @@
<th className="text-right">직접비</th>
<th className="text-right">배분(A)</th>
<th className="text-right">배분(B)</th>
{viewMode === 'type' && <th className="text-right">배분(C)</th>}
{viewMode === 'type' && <th>C 배분내역</th>}
<th className="text-right">최종 원가</th>
{viewMode === 'type' && <th className="text-center">생산량 / 단위원가</th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100 font-semibold text-sm">
{results.displayData.map(item => {
const isInvalid = item.name === '미분류' || item.name === '미지정';
const isInvalid = isInvalidProjectName(item.name);
return (
<tr key={item.name} className={`transition-all group ${isInvalid ? 'bg-red-50 hover:bg-red-100/30' : 'hover:bg-blue-50/20'}`}>
<td className="px-8 py-4 cursor-pointer" onClick={() => setSelectedDetail(item)}>
@@ -784,6 +1078,26 @@
<td className="px-5 py-4 text-right text-slate-500 italic text-sm">{Math.round(item.direct).toLocaleString()}</td>
<td className="px-5 py-4 text-right text-blue-500 text-sm italic">{Math.round(item.allocA).toLocaleString()}</td>
<td className="px-5 py-4 text-right text-orange-500 text-sm italic">{Math.round(item.allocB).toLocaleString()}</td>
{viewMode === 'type' && (
<td className={`px-5 py-4 text-right text-sm italic ${item.allocC >= 0 ? 'text-violet-600' : 'text-rose-600'}`}>
{Math.round(item.allocC || 0).toLocaleString()}
</td>
)}
{viewMode === 'type' && (
<td className="px-5 py-4 text-xs text-slate-600">
{(item.allocCMeta?.receivedFrom || []).length ? (
<div className="space-y-1">
{item.allocCMeta.receivedFrom.map((src, idx) => (
<div key={`${item.name}_csrc_${idx}`} className="whitespace-nowrap">
{src.source}: <span className="font-black text-violet-700">{utils.formatWon(src.amount)}</span>
</div>
))}
</div>
) : (
<span className="text-slate-300">-</span>
)}
</td>
)}
<td className="px-8 py-4 text-right text-base font-black text-slate-900 tracking-tighter cursor-pointer" onClick={() => setSelectedDetail(item)}>{Math.round(item.final).toLocaleString()}</td>
{viewMode === 'type' && (
<td className="px-8 py-4 bg-slate-50/50">
@@ -827,8 +1141,25 @@
<div className={`mx-auto p-5 rounded-2xl bg-slate-50 ${u.color} mb-4 shadow-sm w-max`}><Icon name={u.icon} size={36} /></div>
<h3 className="text-lg font-bold text-slate-800 mb-4 uppercase">{u.title}</h3>
<label className="bg-slate-800 text-white px-8 py-2 rounded-xl text-sm font-bold cursor-pointer hover:bg-slate-700 transition-all inline-block shadow-md">
<Icon name="upload" className="inline-block mr-2" size={14} /> 엑셀 파일 로드 <input type="file" className="hidden" accept={u.id === 'hr' ? '.xls,.xlsx,.html' : '.xls,.xlsx'} onChange={e => onUpload(e, u.id)} />
<Icon name="upload" className="inline-block mr-2" size={14} /> {uploadedFiles[u.id] ? '파일 교체' : '엑셀 파일 로드'} <input type="file" className="hidden" accept={u.id === 'hr' ? '.xls,.xlsx,.html' : '.xls,.xlsx'} onChange={e => onUpload(e, u.id)} />
</label>
{uploadedFiles[u.id] && (
<div className="mt-4 space-y-2">
<div className="text-[11px] text-slate-500 font-bold">
저장됨: {uploadedFiles[u.id].name} ({Math.round((uploadedFiles[u.id].size || 0) / 1024)}KB)
</div>
<div className="text-[10px] text-slate-400 font-semibold">
{fmtSavedAt(uploadedFiles[u.id].savedAt)}
</div>
<button
type="button"
onClick={() => downloadUploadedFile(u.id)}
className="px-4 py-1.5 rounded-lg bg-blue-50 text-blue-700 text-xs font-black border border-blue-200 hover:bg-blue-100 transition-all"
>
<Icon name="download" className="inline-block mr-1" size={12} /> 저장 파일 다운로드
</button>
</div>
)}
<p className="mt-4 text-xs font-bold text-slate-300 uppercase tracking-widest">{u.count} RECORDS SYNCED</p>
</div>
))}
@@ -853,9 +1184,27 @@
)}
{activeTab === 'settings' && (
<div className="max-w-3xl mx-auto space-y-8 py-6 animate-fade-in text-slate-900">
<div className="max-w-6xl mx-auto space-y-8 py-6 animate-fade-in text-slate-900">
<div className="bg-white p-10 rounded-3xl border border-slate-200 shadow-sm text-slate-900">
<h3 className="text-xl font-black uppercase tracking-tight mb-8 border-b pb-6 italic">인사 관리</h3>
<div className="mb-6 flex items-center gap-2">
{[
{ id: 'wage', label: '단가 설정' },
{ id: 'calc', label: '계산 상세' }
].map(tab => (
<button
key={tab.id}
type="button"
onClick={() => setSettingsTab(tab.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-all ${settingsTab === tab.id ? 'bg-slate-800 text-white border-slate-800' : 'bg-white text-slate-500 border-slate-200 hover:text-slate-800'}`}
>
{tab.label}
</button>
))}
</div>
{settingsTab === 'wage' && (
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
{['철근팀', '제작팀', '공무팀', '일용직'].map(team => (
<div key={team} className="bg-slate-50 rounded-2xl border border-slate-100 p-4">
@@ -907,6 +1256,56 @@
</div>
))}
</div>
</>
)}
{settingsTab === 'calc' && (
<div className="space-y-4">
<div className="text-xs text-slate-500 font-bold">
표시 기준: {startDate || '전체'} ~ {endDate || '전체'} / 월급제는 `(월급 × 개월수) ÷ 개인 총근무시간`으로 환산
</div>
<div className="overflow-auto border border-slate-200 rounded-2xl">
<table className="w-full min-w-[980px] text-xs">
<thead className="bg-slate-50">
<tr className="text-slate-500 font-black">
<th className="px-3 py-2 text-left">근무자</th>
<th className="px-3 py-2 text-center whitespace-nowrap">타입</th>
<th className="px-3 py-2 text-right">입력 단가</th>
<th className="px-3 py-2 text-right whitespace-nowrap">총근무시간</th>
<th className="px-3 py-2 text-right">적용 시급</th>
<th className="px-3 py-2 text-right"> 인건비</th>
<th className="px-3 py-2 text-left">프로젝트별 금액</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{laborCalcRows.map(row => (
<tr key={row.name}>
<td className="px-3 py-2 font-bold text-slate-800">{row.name}</td>
<td className="px-3 py-2 text-center whitespace-nowrap min-w-[64px]">{row.type === 'hourly' ? '시급제' : '월급제'}</td>
<td className="px-3 py-2 text-right">{Math.round(row.rate).toLocaleString()}</td>
<td className="px-3 py-2 text-right whitespace-nowrap min-w-[88px]">{utils.formatHr(row.totalHours)}</td>
<td className="px-3 py-2 text-right text-blue-700 font-bold">{Math.round(row.appliedHourly).toLocaleString()}</td>
<td className="px-3 py-2 text-right font-bold text-slate-900">{Math.round(row.totalAmount).toLocaleString()}</td>
<td className="px-3 py-2">
{row.projects.length === 0 ? (
<span className="text-slate-300">-</span>
) : (
<div className="flex flex-wrap gap-1">
{row.projects.map(p => (
<span key={`${row.name}-${p.projectName}`} className="text-[10px] bg-slate-50 border border-slate-200 rounded-md px-2 py-0.5">
{p.projectName}: {Math.round(p.amount).toLocaleString()}
</span>
))}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
)}
@@ -914,8 +1313,8 @@
{selectedDetail && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6 bg-slate-900/70 backdrop-blur-md animate-fade-in no-print text-slate-900">
<div className={`bg-white w-full max-w-2xl rounded-[40px] shadow-2xl overflow-hidden flex flex-col max-h-[85vh] border-2 ${['미분류', '미지정'].includes(selectedDetail.name) ? 'border-red-400' : 'border-white/20'}`}>
<div className={`p-8 text-white flex justify-between items-center shrink-0 ${['미분류', '미지정'].includes(selectedDetail.name) ? 'bg-red-800' : 'bg-slate-800'}`}>
<div className={`bg-white w-full max-w-2xl rounded-[40px] shadow-2xl overflow-hidden flex flex-col max-h-[85vh] border-2 ${isInvalidProjectName(selectedDetail.name) ? 'border-red-400' : 'border-white/20'}`}>
<div className={`p-8 text-white flex justify-between items-center shrink-0 ${isInvalidProjectName(selectedDetail.name) ? 'bg-red-800' : 'bg-slate-800'}`}>
<h3 className="text-xl font-black italic tracking-tighter uppercase leading-tight">{selectedDetail.name} ANALYTICS</h3>
<button onClick={() => setSelectedDetail(null)} className="p-2 hover:bg-white/10 rounded-full transition-colors"><Icon name="x" size={20} /></button>
</div>
@@ -926,15 +1325,55 @@
</div>
<div className="flex justify-center gap-12 pt-4 font-bold border-y py-6 border-slate-100">
<div className="flex flex-col gap-1 text-center"><span className="text-[9px] text-slate-400 uppercase tracking-widest">Direct Input</span><span className="text-xl tracking-tighter italic">{utils.formatWon(selectedDetail.direct)}</span></div>
<div className="flex flex-col gap-1 text-center"><span className="text-[9px] text-blue-500 uppercase tracking-widest">Alloc(A+B)</span><span className="text-xl tracking-tighter italic">{utils.formatWon(selectedDetail.allocA + selectedDetail.allocB)}</span></div>
</div>
<div className="flex flex-col gap-1 text-center"><span className="text-[9px] text-slate-400 uppercase tracking-widest">Direct Input</span><span className="text-xl tracking-tighter italic">{utils.formatWon(selectedDetail.direct)}</span></div>
<div className="flex flex-col gap-1 text-center"><span className="text-[9px] text-blue-500 uppercase tracking-widest">Alloc(A+B)</span><span className="text-xl tracking-tighter italic">{utils.formatWon(selectedDetail.allocA + selectedDetail.allocB)}</span></div>
</div>
{selectedDetail.allocMeta && (
<div className="space-y-4 text-left">
<div className="flex items-center gap-3">
<div className={`w-1.5 h-6 rounded-full ${isInvalidProjectName(selectedDetail.name) ? 'bg-red-600' : 'bg-indigo-600'}`}></div>
<h4 className="font-bold text-slate-800 text-base uppercase tracking-tighter italic">A/B 배분 계산과정</h4>
</div>
<div className="bg-indigo-50 border border-indigo-100 rounded-2xl p-4 text-xs text-slate-700 leading-relaxed">
<div>기준식: <span className="font-black">배분금액 = (배분공수 / 전체 수익공수) × Pool 금액</span></div>
<div className="mt-1">A = ({utils.formatHr(selectedDetail.hours || 0)} / {utils.formatHr(selectedDetail.allocMeta.revenueHrs || 0)}) × {utils.formatWon(selectedDetail.allocMeta.poolAVal || 0)} = <span className="font-black text-blue-700">{utils.formatWon(selectedDetail.allocA || 0)}</span></div>
<div>B = ({utils.formatHr(selectedDetail.hours || 0)} / {utils.formatHr(selectedDetail.allocMeta.revenueHrs || 0)}) × {utils.formatWon(selectedDetail.allocMeta.poolBVal || 0)} = <span className="font-black text-orange-700">{utils.formatWon(selectedDetail.allocB || 0)}</span></div>
</div>
<div className="overflow-auto border border-slate-200 rounded-xl">
<table className="w-full min-w-[760px] text-[11px]">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-3 py-2 text-left">프로젝트</th>
<th className="px-3 py-2 text-right">직접비 기여</th>
<th className="px-3 py-2 text-right">직접비 비율</th>
<th className="px-3 py-2 text-right">배분공수</th>
<th className="px-3 py-2 text-right text-blue-700">A 기여</th>
<th className="px-3 py-2 text-right text-orange-700">B 기여</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(selectedDetail.allocTrace || []).map((r, idx) => (
<tr key={`${selectedDetail.name}_trace_${idx}`}>
<td className="px-3 py-2 font-bold text-slate-700">{r.projectName}</td>
<td className="px-3 py-2 text-right">{utils.formatWon(r.directShare || 0)}</td>
<td className="px-3 py-2 text-right">{((r.ratio || 0) * 100).toFixed(2)}%</td>
<td className="px-3 py-2 text-right">{utils.formatHr(r.shareHours || 0)}</td>
<td className="px-3 py-2 text-right text-blue-700">{utils.formatWon(r.allocA || 0)}</td>
<td className="px-3 py-2 text-right text-orange-700">{utils.formatWon(r.allocB || 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="space-y-6 text-left">
<div className="flex items-center gap-3 mb-4">
<div className={`w-1.5 h-6 rounded-full ${['미분류', '미지정'].includes(selectedDetail.name) ? 'bg-red-600' : 'bg-slate-800'}`}></div>
<h4 className="font-bold text-slate-800 text-base uppercase tracking-tighter italic">Breakdown Analysis</h4>
</div>
<div className="flex items-center gap-3 mb-4">
<div className={`w-1.5 h-6 rounded-full ${isInvalidProjectName(selectedDetail.name) ? 'bg-red-600' : 'bg-slate-800'}`}></div>
<h4 className="font-bold text-slate-800 text-base uppercase tracking-tighter italic">Breakdown Analysis</h4>
</div>
{selectedDetail.byAccount && (
<div className="flex items-center gap-2 mb-3">
{[
@@ -965,7 +1404,7 @@
if (!groups) return <p className="text-center py-10 text-slate-300 font-bold uppercase italic text-xs tracking-widest">No Breakdown Data</p>;
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 (
<div key={i} className={`bg-slate-50 p-5 rounded-2xl border transition-all ${isErrParent ? 'hover:border-red-400' : 'hover:border-blue-200 hover:bg-white'}`}>
<div className="flex justify-between text-xs font-bold mb-3 text-slate-900">
@@ -983,7 +1422,7 @@
</div>
<div className="text-center pt-8">
<button onClick={() => setSelectedDetail(null)} className={`w-full max-w-xs py-4 text-white font-bold rounded-2xl text-sm uppercase shadow-lg transition-all hover:scale-105 active:scale-95 ${['미분류', '미지정'].includes(selectedDetail.name) ? 'bg-red-800 hover:bg-red-900' : 'bg-slate-800 hover:bg-slate-700'}`}>확인 완료</button>
<button onClick={() => setSelectedDetail(null)} className={`w-full max-w-xs py-4 text-white font-bold rounded-2xl text-sm uppercase shadow-lg transition-all hover:scale-105 active:scale-95 ${isInvalidProjectName(selectedDetail.name) ? 'bg-red-800 hover:bg-red-900' : 'bg-slate-800 hover:bg-slate-700'}`}>확인 완료</button>
</div>
</div>
</div>