Update cost-pdf.html with new C-type allocation logic and file caching features.
This commit is contained in:
571
cost-pdf.html
571
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 @@
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user