feat: 경영성과 대시보드 DB·엑셀 업로드·HTML 차트 연동

- mgmt_perf_uploads / mgmt_perf_snapshots 스키마
- POST /api/mgmt-perf/upload, 기본 페이로드 data/mgmt-perf-default-payload.json
- 대시보드 페이지: 업로드 영역 + iframe embed
- public/mgmt-perf: 원본 HTML 기반 CSS·dashboard-app.js
- xlsx 미설치 시 기본 페이로드+메타만 저장

Made-with: Cursor
This commit is contained in:
2026-04-13 13:21:31 +09:00
parent 485bd31798
commit fdcf1e0528
12 changed files with 3268 additions and 17 deletions

View File

@@ -0,0 +1,910 @@
(function () {
const payloadEl = document.getElementById("mgmt-perf-payload-json");
if (!payloadEl || !payloadEl.textContent.trim()) {
console.error("mgmt-perf: missing payload");
return;
}
let P;
try {
P = JSON.parse(payloadEl.textContent);
} catch (err) {
console.error("mgmt-perf: invalid JSON", err);
return;
}
const UM = P.UM;
const ORDER_CAT = P.ORDER_CAT;
const CUSTS = P.CUSTS;
const RISK_ROWS = P.RISK_ROWS;
const ORDER_DATA = P.ORDER_DATA;
const MODELS = P.MODELS;
const FORECAST = P.FORECAST;
let state = {
currentDivision: 'all',
currentMonth: 'all',
currentSection: '매출현황',
charts: {}
};
// Utility Functions
function formatCurrency(value) {
return '₩' + (value / 100000000).toFixed(1) + '억';
}
function formatNum(value) {
return (value / 100000000).toFixed(2);
}
function getDaysInMonth(month) {
const days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return days[month - 1];
}
// Initialize (iframe에서도 DOMContentLoaded가 이미 지난 경우 대비)
function bootMgmtPerfDashboard() {
setupEventListeners();
renderOverviewMode();
renderOrderSection();
renderForecastSection();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bootMgmtPerfDashboard);
} else {
bootMgmtPerfDashboard();
}
function setupEventListeners() {
// Division slicer
document.querySelectorAll('#divisionSlicer .slicer-tab').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('#divisionSlicer .slicer-tab').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
state.currentDivision = e.target.dataset.division;
renderDivisionView();
});
});
// Month slicer
document.querySelectorAll('#monthSlicer .slicer-tab').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('#monthSlicer .slicer-tab').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
state.currentMonth = e.target.dataset.month;
renderDivisionView();
});
});
// Section tabs
document.querySelectorAll('.section-tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.section-tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
e.target.classList.add('active');
const sectionName = e.target.dataset.section;
state.currentSection = sectionName;
document.getElementById(sectionName).classList.add('active');
});
});
}
function destroyChart(chartId) {
if (state.charts[chartId]) {
state.charts[chartId].destroy();
delete state.charts[chartId];
}
}
// Division-to-order-key mapping
const DIV_TO_ORDER = {
fscan: ['fscan'],
fscanovs: ['ovsFscan'],
xscan: ['xscan'],
xscanovs: ['ovsXscan'],
battery: ['battery'],
newbiz: ['newbiz']
};
function renderDivisionView() {
const isOverviewMode = state.currentDivision === 'all';
document.getElementById('overviewMode').style.display = isOverviewMode ? 'block' : 'none';
document.getElementById('divisionDetailMode').style.display = isOverviewMode ? 'none' : 'block';
if (isOverviewMode) {
renderOverviewMode();
} else {
renderDivisionDetailMode();
}
// Hide FSCAN/XSCAN model charts when 배터리 or 신사업 is selected
const showModels = !(['battery','newbiz'].includes(state.currentDivision));
const modelDisplay = showModels ? 'grid' : 'none';
const smr = document.getElementById('salesModelRow');
const omr = document.getElementById('orderModelRow');
if(smr) smr.style.display = modelDisplay;
if(omr) omr.style.display = modelDisplay;
// Update model charts and category charts for current division/month
const mon = state.currentMonth;
const monthIdx = mon === 'all' ? [0,1,2] : [parseInt(mon)-1];
if (showModels) {
renderSalesModelCharts(monthIdx);
}
// Always update category breakdown
renderSalesCategoryCharts(monthIdx);
// Also update 수주현황 and 예상전망
renderOrderSection();
renderForecastSection();
}
// Helper: get total sales for a division for given month indices
function getDivSales(d, monthIdx) {
return monthIdx.reduce((s, i) => s + (d.mp[i]||0) + (d.mc[i]||0) + (d.mg[i]||0), 0);
}
function renderOverviewMode() {
const divisionKeys = ['fscan', 'fscanovs', 'xscan', 'xscanovs', 'battery', 'newbiz'];
const divisions = divisionKeys.map(k => UM[k]);
const mon = state.currentMonth;
const monthIdx = mon === 'all' ? [0,1,2] : [parseInt(mon)-1];
const monLabel = mon === 'all' ? 'Q1' : mon + '월';
// Calculate totals based on selected month
const totalSales = divisions.reduce((s, d) => s + getDivSales(d, monthIdx), 0);
const totalTgt = mon === 'all'
? divisions.reduce((s, d) => s + d.tgt, 0)
: divisions.reduce((s, d) => s + d.tgt / 3, 0); // monthly avg target
const achRate = totalTgt > 0 ? (totalSales / totalTgt * 100).toFixed(1) : '0.0';
// KPI Cards
const kpiHtml = `
<div class="kpi-card">
<div class="kpi-label">${monLabel} 매출</div>
<div class="kpi-value large">${formatNum(totalSales)}</div>
<div class="kpi-subtext">억원</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${monLabel} 목표</div>
<div class="kpi-value">${formatNum(totalTgt)}</div>
<div class="kpi-subtext">억원</div>
</div>
<div class="kpi-card">
<div class="kpi-label">목표달성률</div>
<div class="kpi-value">${achRate}%</div>
<div class="kpi-subtext">${parseFloat(achRate) >= 100 ? '초과달성' : '진행중'}</div>
</div>
<div class="kpi-card warning">
<div class="kpi-label">미발행 잔액</div>
<div class="kpi-value" style="color: #ff9800;">${formatNum(UM.all.ub)}</div>
<div class="kpi-subtext">리스크관리 필요</div>
</div>
`;
document.getElementById('overviewKpis').innerHTML = kpiHtml;
// Monthly Trend - highlight selected month
destroyChart('monthlyTrendChart');
const monthlyCtx = document.getElementById('monthlyTrendChart').getContext('2d');
state.charts['monthlyTrendChart'] = new Chart(monthlyCtx, {
type: 'line',
data: {
labels: ['1월', '2월', '3월'],
datasets: divisionKeys.map(k => {
const d = UM[k];
const pRadius = [0,1,2].map(i => {
if (mon === 'all') return 6;
return parseInt(mon)-1 === i ? 10 : 4;
});
return {
label: d.nm.replace(/ (영업|사업)본부/,''),
data: d.mp.map(v => v/1e8),
borderColor: d.c,
backgroundColor: d.c + '15',
borderWidth: 3, pointRadius: pRadius, pointBackgroundColor: d.c,
tension: 0.3, fill: false
};
})
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: true, position: 'top' } },
scales: { y: { beginAtZero: true } }
}
});
// Division Sales - filtered by month
destroyChart('divisionSalesChart');
const divSalesCtx = document.getElementById('divisionSalesChart').getContext('2d');
const divSalesData = divisions.map(d => getDivSales(d, monthIdx) / 1e8);
state.charts['divisionSalesChart'] = new Chart(divSalesCtx, {
type: 'bar',
data: {
labels: divisions.map(d => d.nm),
datasets: [{
label: monLabel + ' 매출',
data: divSalesData,
backgroundColor: divisions.map(d => d.c),
borderRadius: 4
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true } }
}
});
// Target vs Actual - filtered by month
destroyChart('targetVsActualChart');
const tgtCtx = document.getElementById('targetVsActualChart').getContext('2d');
state.charts['targetVsActualChart'] = new Chart(tgtCtx, {
type: 'bar',
data: {
labels: divisions.map(d => d.nm),
datasets: [
{ label: '목표', data: divisions.map(d => (mon==='all' ? d.tgt : d.tgt/3) / 1e8), backgroundColor: '#ddd', borderRadius: 4 },
{ label: '실적', data: divisions.map(d => getDivSales(d, monthIdx) / 1e8), backgroundColor: divisions.map(d => d.c), borderRadius: 4 }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } }
}
});
// Achievement Rate - Horizontal Bar (month-aware)
destroyChart('achievementRateChart');
const achCtx = document.getElementById('achievementRateChart').getContext('2d');
const achRates = divisions.map(d => {
const tgt = mon === 'all' ? d.tgt : d.tgt / 3;
const sales = getDivSales(d, monthIdx);
return tgt > 0 ? (sales / tgt * 100) : 0;
});
state.charts['achievementRateChart'] = new Chart(achCtx, {
type: 'bar',
data: {
labels: divisions.map(d => d.nm),
datasets: [{
label: '달성률 (%)',
data: achRates,
backgroundColor: divisions.map((d, i) => achRates[i] >= 100 ? '#cc0000' : '#999'),
borderRadius: 4
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => ctx.raw.toFixed(1) + '%' } }
},
scales: {
x: {
beginAtZero: true,
ticks: { callback: v => v + '%' },
grid: { display: true }
},
y: { grid: { display: false } }
}
},
plugins: [{
afterDatasetsDraw: function(chart) {
const ctx2 = chart.ctx;
chart.data.datasets[0].data.forEach((val, i) => {
const meta = chart.getDatasetMeta(0).data[i];
if (meta) {
ctx2.fillStyle = '#333';
ctx2.font = 'bold 12px sans-serif';
ctx2.textAlign = 'left';
ctx2.textBaseline = 'middle';
ctx2.fillText(val.toFixed(1) + '%', meta.x + 6, meta.y);
}
});
}
}]
});
}
function renderSalesCategoryCharts(monthIdx) {
const div = state.currentDivision;
const divKeys = div === 'all' ? ['fscan','fscanovs','xscan','xscanovs','battery','newbiz'] : [div];
let prodTotal = 0, csTotal = 0, goodsTotal = 0;
divKeys.forEach(k => {
const d = UM[k];
prodTotal += monthIdx.reduce((s,i) => s + (d.mp[i]||0), 0);
csTotal += monthIdx.reduce((s,i) => s + (d.mc[i]||0), 0);
goodsTotal += monthIdx.reduce((s,i) => s + (d.mg[i]||0), 0);
});
const total = prodTotal + csTotal + goodsTotal;
const titleSuffix = div === 'all' ? '' : ' (' + UM[div].nm + ')';
document.getElementById('salesCatTitle').textContent = '매출유형별 비중' + titleSuffix;
document.getElementById('salesCatBarTitle').textContent = '매출유형별 금액 (단위: 억원)' + titleSuffix;
// Doughnut chart
destroyChart('salesCatChart');
const catData = [prodTotal, csTotal, goodsTotal].map(v => v / 1e8);
const catColors = ['#cc0000', '#1565C0', '#F57C00'];
const catLabels = ['제품매출', 'CS매출', '상품매출'];
// Filter out zero values
const filtered = catLabels.map((l, i) => ({label: l, value: catData[i], color: catColors[i]})).filter(x => x.value > 0);
state.charts['salesCatChart'] = new Chart(document.getElementById('salesCatChart').getContext('2d'), {
type: 'doughnut',
data: {
labels: filtered.map(x => x.label),
datasets: [{
data: filtered.map(x => x.value),
backgroundColor: filtered.map(x => x.color),
borderWidth: 2, borderColor: '#fff'
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { padding: 15, font: { size: 12 } } },
tooltip: { callbacks: { label: ctx => {
const pct = total > 0 ? (ctx.raw * 1e8 / total * 100).toFixed(1) : '0';
return ctx.label + ': ' + ctx.raw.toFixed(1) + '억 (' + pct + '%)';
}}}
}
}
});
// Bar chart
destroyChart('salesCatBarChart');
state.charts['salesCatBarChart'] = new Chart(document.getElementById('salesCatBarChart').getContext('2d'), {
type: 'bar',
data: {
labels: filtered.map(x => x.label),
datasets: [{
data: filtered.map(x => x.value),
backgroundColor: filtered.map(x => x.color),
borderRadius: 4
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false },
tooltip: { callbacks: { label: ctx => ctx.raw.toFixed(1) + '억원' } }
},
scales: { y: { beginAtZero: true, ticks: { callback: v => v + '억' } }, x: { grid: { display: false } } }
}
});
}
function renderOrderCategoryCharts(monthIdx) {
const div = state.currentDivision;
const divKeys = div === 'all' ? ['fscan','fscanovs','xscan','xscanovs','battery','newbiz'] : [div];
let prodTotal = 0, goodsTotal = 0;
divKeys.forEach(k => {
const d = ORDER_CAT[k];
if (d) {
prodTotal += monthIdx.reduce((s,i) => s + (d.op[i]||0), 0);
goodsTotal += monthIdx.reduce((s,i) => s + (d.og[i]||0), 0);
}
});
const total = prodTotal + goodsTotal;
const titleSuffix = div === 'all' ? '' : ' (' + UM[div].nm + ')';
document.getElementById('orderCatTitle').textContent = '수주유형별 비중' + titleSuffix;
document.getElementById('orderCatBarTitle').textContent = '수주유형별 금액 (단위: 억원)' + titleSuffix;
// Doughnut
destroyChart('orderCatChart');
const catData = [prodTotal, goodsTotal].map(v => v / 1e8);
const catColors = ['#cc0000', '#F57C00'];
const catLabels = ['제품수주', '상품수주'];
const filtered = catLabels.map((l, i) => ({label: l, value: catData[i], color: catColors[i]})).filter(x => x.value > 0);
if (filtered.length > 0) {
state.charts['orderCatChart'] = new Chart(document.getElementById('orderCatChart').getContext('2d'), {
type: 'doughnut',
data: {
labels: filtered.map(x => x.label),
datasets: [{
data: filtered.map(x => x.value),
backgroundColor: filtered.map(x => x.color),
borderWidth: 2, borderColor: '#fff'
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { padding: 15, font: { size: 12 } } },
tooltip: { callbacks: { label: ctx => {
const pct = total > 0 ? (ctx.raw * 1e8 / total * 100).toFixed(1) : '0';
return ctx.label + ': ' + ctx.raw.toFixed(1) + '억 (' + pct + '%)';
}}}
}
}
});
}
// Bar
destroyChart('orderCatBarChart');
if (filtered.length > 0) {
state.charts['orderCatBarChart'] = new Chart(document.getElementById('orderCatBarChart').getContext('2d'), {
type: 'bar',
data: {
labels: filtered.map(x => x.label),
datasets: [{
data: filtered.map(x => x.value),
backgroundColor: filtered.map(x => x.color),
borderRadius: 4
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false },
tooltip: { callbacks: { label: ctx => ctx.raw.toFixed(1) + '억원' } }
},
scales: { y: { beginAtZero: true, ticks: { callback: v => v + '억' } }, x: { grid: { display: false } } }
}
});
}
}
function renderModelChart(canvasId, models, monthIdx, color) {
destroyChart(canvasId);
const ctx = document.getElementById(canvasId).getContext('2d');
const top6 = models.slice(0, 6);
const data = top6.map(m => monthIdx.reduce((s, i) => s + (m.m[i]||0), 0) / 1e8);
state.charts[canvasId] = new Chart(ctx, {
type: 'bar',
data: {
labels: top6.map(m => m.model),
datasets: [{
label: '매출 (억원)',
data: data,
backgroundColor: top6.map((_, i) => {
const opacity = 1 - (i * 0.12);
return color + Math.round(opacity * 255).toString(16).padStart(2, '0');
}),
borderRadius: 4
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true }, y: { grid: { display: false } } }
}
});
}
// Combine two model arrays, re-sort by amt and take top 5
function combineModels(a, b) {
return [...(a||[]), ...(b||[])].sort((x,y) => y.amt - x.amt).slice(0, 5);
}
function getModelDataForDiv(section, brand) {
const div = state.currentDivision;
const src = section === 'sales' ? MODELS.sales : MODELS.orders;
if (brand === 'fscan') {
if (div === 'fscan') return src.fscan || [];
if (div === 'fscanovs') return src.fscanovs || [];
return combineModels(src.fscan, src.fscanovs); // all or xscan divisions
} else {
if (div === 'xscan') return src.xscan || [];
if (div === 'xscanovs') return src.xscanovs || [];
return combineModels(src.xscan, src.xscanovs); // all or fscan divisions
}
}
function getModelTitle(brand, section, div) {
const sectionLabel = section === 'sales' ? '매출' : '수주';
if (brand === 'fscan') {
if (div === 'fscan') return 'FSCAN 국내 주요 모델별 ' + sectionLabel + ' Top 5';
if (div === 'fscanovs') return 'FSCAN 해외 주요 모델별 ' + sectionLabel + ' Top 5';
return 'FSCAN 주요 모델별 ' + sectionLabel + ' Top 5';
} else {
if (div === 'xscan') return 'XSCAN 국내 주요 모델별 ' + sectionLabel + ' Top 5';
if (div === 'xscanovs') return 'XSCAN 해외 주요 모델별 ' + sectionLabel + ' Top 5';
return 'XSCAN 주요 모델별 ' + sectionLabel + ' Top 5';
}
}
function renderSalesModelCharts(monthIdx) {
const div = state.currentDivision;
document.getElementById('fscanSalesModelTitle').textContent = getModelTitle('fscan', 'sales', div);
document.getElementById('xscanSalesModelTitle').textContent = getModelTitle('xscan', 'sales', div);
renderModelChart('fscanModelChart', getModelDataForDiv('sales', 'fscan'), monthIdx, '#cc0000');
renderModelChart('xscanModelChart', getModelDataForDiv('sales', 'xscan'), monthIdx, '#2E7D32');
}
function renderOrderModelCharts(monthIdx) {
const div = state.currentDivision;
document.getElementById('fscanOrderModelTitle').textContent = getModelTitle('fscan', 'orders', div);
document.getElementById('xscanOrderModelTitle').textContent = getModelTitle('xscan', 'orders', div);
renderModelChart('fscanOrderModelChart', getModelDataForDiv('orders', 'fscan'), monthIdx, '#cc0000');
renderModelChart('xscanOrderModelChart', getModelDataForDiv('orders', 'xscan'), monthIdx, '#2E7D32');
}
function renderDivisionDetailMode() {
const div = UM[state.currentDivision];
if (!div) return;
const mon = state.currentMonth;
const monthIdx = mon === 'all' ? [0,1,2] : [parseInt(mon)-1];
const monLabel = mon === 'all' ? 'Q1' : mon + '월';
const customers = CUSTS[state.currentDivision] || [];
const totalCust = customers.reduce((s, c) => s + c.amt, 0);
// Detail KPIs - month aware
const divSales = getDivSales(div, monthIdx);
const divTgt = mon === 'all' ? div.tgt : div.tgt / 3;
const achieveRate = divTgt > 0 ? (divSales / divTgt * 100).toFixed(1) : '0.0';
const domPct = div.q1 > 0 ? ((div.dom / div.q1) * 100).toFixed(1) : '0.0';
const ovsPct = div.q1 > 0 ? ((div.ovs / div.q1) * 100).toFixed(1) : '0.0';
const prevMonthGrowth = div.mp[2] > 0 && div.mp[1] > 0 ? ((div.mp[2] / div.mp[1] - 1) * 100).toFixed(1) : 'N/A';
const detailKpis = `
<div class="kpi-card">
<div class="kpi-label">${monLabel} 매출</div>
<div class="kpi-value">${formatNum(divSales)}</div>
<div class="kpi-subtext">억원</div>
</div>
<div class="kpi-card">
<div class="kpi-label">목표달성률</div>
<div class="kpi-value">${achieveRate}%</div>
<div class="kpi-subtext">${parseFloat(achieveRate) >= 100 ? '초과' : '진행중'}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">국내/해외 비중</div>
<div class="kpi-value">${domPct}% / ${ovsPct}%</div>
<div class="kpi-subtext">국내 / 해외</div>
</div>
<div class="kpi-card warning">
<div class="kpi-label">미발행 잔액</div>
<div class="kpi-value" style="color: #ff9800;">${formatNum(div.ub)}</div>
<div class="kpi-subtext">억원</div>
</div>
<div class="kpi-card">
<div class="kpi-label">전월비 성장률</div>
<div class="kpi-value">${prevMonthGrowth}%</div>
<div class="kpi-subtext">3월 vs 2월</div>
</div>
`;
document.getElementById('divisionDetailKpis').innerHTML = detailKpis;
// Monthly Trend for Division
destroyChart('divisionMonthlyChart');
const monthCtx = document.getElementById('divisionMonthlyChart').getContext('2d');
state.charts['divisionMonthlyChart'] = new Chart(monthCtx, {
type: 'bar',
data: {
labels: ['1월', '2월', '3월'],
datasets: [{
label: div.nm,
data: [div.mp[0]/100000000, div.mp[1]/100000000, div.mp[2]/100000000],
backgroundColor: div.c
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } }
}
});
// Customer Share
destroyChart('customerShareChart');
const custCtx = document.getElementById('customerShareChart').getContext('2d');
const topCustomers = customers.slice(0, 5);
state.charts['customerShareChart'] = new Chart(custCtx, {
type: 'doughnut',
data: {
labels: topCustomers.map(c => c.nm),
datasets: [{
data: topCustomers.map(c => (c.amt / totalCust * 100).toFixed(1)),
backgroundColor: ['#cc0000', '#1565C0', '#2E7D32', '#F57C00', '#7B1FA2']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: true, position: 'bottom' } }
}
});
// Customer Table
const custTable = document.getElementById('customerTable').querySelector('tbody');
custTable.innerHTML = customers.map((c, i) => `
<tr>
<td><span class="rank-badge">${i+1}</span></td>
<td>${c.nm}</td>
<td>${c.gbn}</td>
<td>${c.acc}</td>
<td class="num-cell">${formatCurrency(c.amt)}</td>
<td class="percent-cell">${(c.amt / totalCust * 100).toFixed(1)}%</td>
<td class="num-cell">${formatCurrency(c.ub)}</td>
</tr>
`).join('');
// Risk Section
const riskTotal = customers.reduce((s, c) => s + c.ub, 0);
document.getElementById('divisionRiskTotal').textContent = formatCurrency(riskTotal);
// Risk by Customer
const riskCustomers = customers.filter(c => c.ub > 0).sort((a, b) => b.ub - a.ub);
destroyChart('riskByCustomerChart');
const riskCtx = document.getElementById('riskByCustomerChart').getContext('2d');
state.charts['riskByCustomerChart'] = new Chart(riskCtx, {
type: 'bar',
data: {
labels: riskCustomers.map(c => c.nm.substring(0, 15)),
datasets: [{
label: '미발행금액',
data: riskCustomers.map(c => c.ub / 100000000),
backgroundColor: '#ff9800'
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true } }
}
});
// Risk Detail Table
const riskTable = document.getElementById('riskDetailTable').querySelector('tbody');
const filteredRisk = RISK_ROWS.filter(r => {
const divMap = {'FSCAN국내': 'fscan', 'FSCAN해외': 'fscanovs', 'XSCAN': 'xscan', 'XSCAN해외': 'xscanovs', '배터리': 'battery', '신사업': 'newbiz'};
return divMap[r.] === state.currentDivision;
});
riskTable.innerHTML = filteredRisk.map(r => `
<tr>
<td>${r.}월</td>
<td>${r.}</td>
<td>${r.}</td>
<td>${r.}</td>
<td class="num-cell">${formatCurrency(r.amt)}</td>
</tr>
`).join('');
}
function renderOrderSection() {
const div = state.currentDivision;
const mon = state.currentMonth;
const divData = ORDER_DATA.divisions;
const monthIdx = mon === 'all' ? [0,1,2] : [parseInt(mon)-1];
const monthLabels = mon === 'all' ? ['1월','2월','3월'] : [mon + '월'];
// Filter divisions by slicer
let filteredDivKeys;
if (div === 'all') {
filteredDivKeys = Object.keys(divData);
} else {
filteredDivKeys = DIV_TO_ORDER[div] || [];
}
// Calculate aggregated actual/plan for filtered divisions & months
let totalActual = 0, totalPlan = 0;
filteredDivKeys.forEach(k => {
const d = divData[k];
if (d) monthIdx.forEach(i => { totalActual += d.actual[i]; totalPlan += d.plan[i]; });
});
// Monthly aggregates for chart
const monthlyActual = [0,1,2].map(i => {
let s = 0; filteredDivKeys.forEach(k => { if(divData[k]) s += divData[k].actual[i]; }); return s;
});
const monthlyPlan = [0,1,2].map(i => {
let s = 0; filteredDivKeys.forEach(k => { if(divData[k]) s += divData[k].plan[i]; }); return s;
});
const divLabel = div === 'all' ? '전체' : UM[div].nm;
const monLabel = mon === 'all' ? 'Q1' : mon + '월';
// KPIs
const achRate = totalPlan > 0 ? (totalActual / totalPlan * 100).toFixed(1) : '0.0';
const kpis = `
<div class="kpi-card">
<div class="kpi-label">${monLabel} 수주 실적</div>
<div class="kpi-value">${totalActual.toFixed(2)}</div>
<div class="kpi-subtext">억원 ${div !== 'all' ? '| ' + divLabel : ''}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${monLabel} 계획</div>
<div class="kpi-value">${totalPlan.toFixed(2)}</div>
<div class="kpi-subtext">억원</div>
</div>
<div class="kpi-card">
<div class="kpi-label">달성률</div>
<div class="kpi-value">${achRate}%</div>
<div class="kpi-subtext">계획 대비</div>
</div>
<div class="kpi-card">
<div class="kpi-label">GAP</div>
<div class="kpi-value" style="color:${totalActual >= totalPlan ? '#2E7D32' : '#cc0000'}">${(totalActual - totalPlan).toFixed(2)}</div>
<div class="kpi-subtext">억원 ${totalActual >= totalPlan ? '초과' : '부족'}</div>
</div>
`;
document.getElementById('orderKpis').innerHTML = kpis;
// Monthly Order Chart (always show all 3 months, highlight selected)
destroyChart('orderMonthlyChart');
const monthCtx = document.getElementById('orderMonthlyChart').getContext('2d');
const barBgActual = ['1월','2월','3월'].map((m,i) => {
if (mon === 'all' || parseInt(mon)-1 === i) return '#cc0000';
return 'rgba(204,0,0,0.25)';
});
const barBgPlan = ['1월','2월','3월'].map((m,i) => {
if (mon === 'all' || parseInt(mon)-1 === i) return '#ccc';
return 'rgba(204,204,204,0.25)';
});
state.charts['orderMonthlyChart'] = new Chart(monthCtx, {
type: 'bar',
data: {
labels: ['1월','2월','3월'],
datasets: [
{ label: '실적', data: monthlyActual, backgroundColor: barBgActual, borderRadius: 4 },
{ label: '계획', data: monthlyPlan, backgroundColor: barBgPlan, borderRadius: 4 }
]
},
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }
});
// Division Order Chart
destroyChart('orderByDivisionChart');
const divCtx = document.getElementById('orderByDivisionChart').getContext('2d');
const divColors = {fscan:'#cc0000', xscan:'#2E7D32', ovsFscan:'#1565C0', ovsXscan:'#0D47A1', battery:'#F57C00', newbiz:'#7B1FA2'};
const showDivKeys = div === 'all' ? Object.keys(divData) : filteredDivKeys;
state.charts['orderByDivisionChart'] = new Chart(divCtx, {
type: 'bar',
data: {
labels: showDivKeys.map(k => divData[k].nm),
datasets: [{
label: monLabel + ' 실적',
data: showDivKeys.map(k => {
let s = 0; monthIdx.forEach(i => { s += divData[k].actual[i]; }); return s;
}),
backgroundColor: showDivKeys.map(k => divColors[k] || '#cc0000'),
borderRadius: 4
},{
label: monLabel + ' 계획',
data: showDivKeys.map(k => {
let s = 0; monthIdx.forEach(i => { s += divData[k].plan[i]; }); return s;
}),
backgroundColor: showDivKeys.map(() => '#ddd'),
borderRadius: 4
}]
},
options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, scales: { x: { beginAtZero: true } } }
});
// Detail Table
const detailTable = document.getElementById('orderDetailTable').querySelector('tbody');
detailTable.innerHTML = showDivKeys.map(key => {
const d = divData[key];
const q1 = d.actual[0] + d.actual[1] + d.actual[2];
const planQ1 = d.plan[0] + d.plan[1] + d.plan[2];
const rate = planQ1 > 0 ? (q1 / planQ1 * 100).toFixed(1) : '-';
const isSel = mon !== 'all';
return `
<tr${isSel ? '' : ''}>
<td>${d.nm}</td>
<td class="num-cell" style="${mon==='1'?'font-weight:700;color:#cc0000':''}">${d.actual[0].toFixed(2)}</td>
<td class="num-cell" style="${mon==='2'?'font-weight:700;color:#cc0000':''}">${d.actual[1].toFixed(2)}</td>
<td class="num-cell" style="${mon==='3'?'font-weight:700;color:#cc0000':''}">${d.actual[2].toFixed(2)}</td>
<td class="num-cell" style="font-weight:700;">${q1.toFixed(2)}</td>
<td class="num-cell">${planQ1.toFixed(2)}</td>
<td class="percent-cell">${rate}%</td>
</tr>
`;
}).join('');
// Order Model Charts — division-aware
renderOrderModelCharts(monthIdx);
// Order Category Charts (제품수주/상품수주)
renderOrderCategoryCharts(monthIdx);
}
function renderForecastSection() {
const div = state.currentDivision;
const divData = ORDER_DATA.divisions;
// If a specific division is selected, compute division-level forecast
let planData, actualData, bepVal, annPlan, annBep;
if (div === 'all') {
planData = FORECAST.plan;
actualData = FORECAST.actual;
bepVal = FORECAST.bep;
annPlan = FORECAST.annualPlanTotal;
annBep = FORECAST.annualBepTotal;
} else {
const keys = DIV_TO_ORDER[div] || [];
// Build monthly plan/actual from division order data (6 months available)
planData = Array(12).fill(0);
actualData = Array(12).fill(0);
keys.forEach(k => {
const d = divData[k];
if (d) {
for (let i = 0; i < 6; i++) { planData[i] += d.plan[i]; actualData[i] += d.actual[i]; }
}
});
annPlan = planData.reduce((s,v) => s+v, 0);
annBep = annPlan * (FORECAST.annualBepTotal / FORECAST.annualPlanTotal);
bepVal = annBep / 12;
}
const divLabel = div === 'all' ? '전체' : UM[div].nm;
const annualActual = actualData.reduce((s, v) => s + v, 0);
const achRate = annPlan > 0 ? (annualActual / annPlan * 100).toFixed(1) : '0.0';
const kpis = `
<div class="kpi-card">
<div class="kpi-label">연간 계획 ${div !== 'all' ? '| ' + divLabel : ''}</div>
<div class="kpi-value">${annPlan.toFixed(2)}</div>
<div class="kpi-subtext">억원</div>
</div>
<div class="kpi-card">
<div class="kpi-label">손익분기점</div>
<div class="kpi-value">${annBep.toFixed(2)}</div>
<div class="kpi-subtext">억원</div>
</div>
<div class="kpi-card">
<div class="kpi-label">누적 실적 (Q1)</div>
<div class="kpi-value">${annualActual.toFixed(2)}</div>
<div class="kpi-subtext">억원</div>
</div>
<div class="kpi-card">
<div class="kpi-label">연간 달성률</div>
<div class="kpi-value">${achRate}%</div>
<div class="kpi-subtext">계획 대비</div>
</div>
`;
document.getElementById('forecastKpis').innerHTML = kpis;
// Annual Forecast Chart
destroyChart('annualForecastChart');
const fcCtx = document.getElementById('annualForecastChart').getContext('2d');
const months = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월'];
// Highlight selected month if any
const mon = state.currentMonth;
const pointRadius = months.map((m,i) => {
if (mon === 'all') return 3;
return parseInt(mon)-1 === i ? 8 : 3;
});
state.charts['annualForecastChart'] = new Chart(fcCtx, {
type: 'line',
data: {
labels: months,
datasets: [
{ label: '계획', data: planData, borderColor: '#999', backgroundColor: 'rgba(153,153,153,0.1)', fill: true, tension: 0.4, pointRadius: 3 },
{ label: '손익분기점', data: Array(12).fill(bepVal), borderColor: '#ff9800', fill: false, borderDash: [5, 5], tension: 0, pointRadius: 0 },
{ label: '실적', data: actualData, borderColor: '#cc0000', backgroundColor: 'rgba(204,0,0,0.1)', fill: true, tension: 0.4, pointRadius: pointRadius, borderWidth: 3 }
]
},
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }
});
// Forecast Table
const fcTable = document.getElementById('forecastTable').querySelector('tbody');
fcTable.innerHTML = months.map((m, i) => {
const achieved = actualData[i] > 0 && planData[i] > 0 ? (actualData[i] / planData[i] * 100).toFixed(1) : '-';
const isSelected = mon !== 'all' && parseInt(mon)-1 === i;
return `
<tr style="${isSelected ? 'background:#fff3e0;font-weight:700;' : ''}">
<td>${m}</td>
<td class="num-cell">${planData[i].toFixed(2)}</td>
<td class="num-cell">${bepVal.toFixed(2)}</td>
<td class="num-cell">${actualData[i].toFixed(2)}</td>
<td class="percent-cell">${achieved}${achieved !== '-' ? '%' : ''}</td>
</tr>
`;
}).join('');
}
})();

View File

@@ -0,0 +1,399 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--red: #cc0000;
--red2: #e80000;
--redd: #8b0000;
--blk: #1a1a1a;
--blk2: #2d2d2d;
--gray-light: #f5f5f5;
--gray-mid: #e8e8e8;
--gray-dark: #666666;
--gold: #d4af37;
--orange: #ff9800;
}
body {
font-family: '맑은 고딕', 'Apple SD Gothic Neo', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, var(--blk) 0%, var(--blk2) 100%);
color: var(--blk);
line-height: 1.6;
padding: 20px;
}
.container {
max-width: 1600px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
/* Header */
.header {
background: linear-gradient(135deg, var(--redd) 0%, var(--red) 100%);
color: white;
padding: 40px 30px;
border-bottom: 4px solid var(--red2);
}
.header h1 {
font-size: 32px;
margin-bottom: 5px;
font-weight: 700;
letter-spacing: 0.5px;
}
.header p {
font-size: 14px;
opacity: 0.95;
font-weight: 300;
}
/* Slicer Bars Container */
.slicers-container {
display: flex;
gap: 30px;
padding: 20px 30px;
background: var(--gray-light);
border-bottom: 1px solid var(--gray-mid);
align-items: flex-start;
}
.slicer-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.slicer-label {
font-size: 12px;
font-weight: 700;
color: var(--blk);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.slicer-tabs {
display: flex;
gap: 4px;
}
.slicer-tab {
padding: 8px 14px;
background: white;
border: 1px solid var(--gray-mid);
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
color: var(--blk);
}
.slicer-tab:hover {
background: var(--gray-mid);
}
.slicer-tab.active {
background: var(--red);
color: white;
border-color: var(--red);
box-shadow: 0 2px 6px rgba(204, 0, 0, 0.3);
}
/* Section Tabs Navigation */
.section-tabs {
display: flex;
background: white;
border-bottom: 2px solid var(--gray-mid);
padding: 0;
gap: 0;
}
.section-tab-btn {
flex: 1;
padding: 16px 20px;
background: white;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: var(--blk);
transition: all 0.3s ease;
border-bottom: 3px solid transparent;
max-width: 200px;
}
.section-tab-btn:hover {
background: var(--gray-light);
color: var(--red);
}
.section-tab-btn.active {
background: white;
color: var(--red);
border-bottom-color: var(--red);
}
/* Content Area */
.content {
padding: 30px;
min-height: 600px;
}
.section {
display: none;
}
.section.active {
display: block;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* KPI Cards */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.kpi-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-top: 4px solid var(--red);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.kpi-card.warning {
border-top-color: var(--orange);
background: rgba(255, 152, 0, 0.05);
}
.kpi-label {
font-size: 12px;
font-weight: 600;
color: var(--gray-dark);
text-transform: uppercase;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.kpi-value {
font-size: 24px;
font-weight: 700;
color: var(--red);
margin-bottom: 4px;
}
.kpi-value.large {
font-size: 32px;
}
.kpi-subtext {
font-size: 11px;
color: var(--gray-dark);
margin-top: 8px;
}
/* Charts */
.chart-container {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
}
.chart-title {
font-size: 16px;
font-weight: 700;
color: var(--blk);
margin-bottom: 20px;
}
.chart-wrapper {
position: relative;
height: 300px;
width: 100%;
}
.chart-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
background: white;
}
thead {
background: var(--gray-light);
position: sticky;
top: 0;
z-index: 10;
}
th {
padding: 12px 8px;
text-align: left;
font-size: 12px;
font-weight: 700;
color: var(--blk);
border-bottom: 2px solid var(--gray-mid);
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 12px 8px;
font-size: 12px;
border-bottom: 1px solid var(--gray-mid);
}
tbody tr:hover {
background: var(--gray-light);
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--red);
color: white;
font-size: 11px;
font-weight: 700;
}
.num-cell {
text-align: right;
font-family: 'Courier New', monospace;
}
.percent-cell {
text-align: right;
color: var(--red);
font-weight: 600;
}
/* Table Wrapper */
.table-wrapper {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow-x: auto;
}
.table-title {
font-size: 14px;
font-weight: 700;
color: var(--blk);
margin-bottom: 15px;
}
/* Risk Section */
.risk-section {
background: linear-gradient(135deg, rgba(255, 152, 0, 0.05) 0%, rgba(212, 175, 55, 0.05) 100%);
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
border-left: 4px solid var(--orange);
}
.risk-title {
font-size: 16px;
font-weight: 700;
color: var(--orange);
margin-bottom: 20px;
}
.risk-value {
font-size: 36px;
font-weight: 700;
color: var(--orange);
}
.risk-label {
font-size: 12px;
color: var(--gray-dark);
margin-top: 5px;
}
/* Division Detail View */
.division-detail {
display: none;
}
.division-detail.active {
display: block;
animation: fadeIn 0.3s ease;
}
/* Responsive */
@media (max-width: 1200px) {
.chart-row {
grid-template-columns: 1fr;
}
.slicers-container {
flex-direction: column;
gap: 15px;
}
}
@media (max-width: 768px) {
.kpi-grid {
grid-template-columns: 1fr;
}
.section-tab-btn {
max-width: none;
}
}
@media print {
body {
background: white;
padding: 0;
}
.container {
box-shadow: none;
border-radius: 0;
}
.section {
page-break-inside: avoid;
}
}