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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ data/meeting-ai-checklist.json
|
||||
# PPT 썸네일 이벤트 로그(PG 미사용 시 폴백·마이그레이션 백업)
|
||||
data/thumbnail-events.json
|
||||
data/thumbnail-events.json.migrated.bak
|
||||
data/mgmt-perf-last-state.json
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
- 학습센터 UI (좌측 메뉴 + 상단 헤더 + 강의 카드 레이아웃)
|
||||
- **AI 탐색** (`/ai-explore`): 전체 너비 레이아웃, AI 서비스 카드(프롬프트·회의록 등). 검색창에 **「프롬프트」**가 포함된 채 검색(Enter) 시 프롬프트 라이브러리로 이동
|
||||
- **대시보드** (`/dashboard`): AI 탐색과 유사한 카드 그리드·검색으로 대시보드를 모아 표시. 첫 카드 **경영성과 대시보드**는 `/dashboard/business-performance`로 연결
|
||||
- **경영성과 대시보드** (`/dashboard/business-performance`): 상단 **엑셀 업로드**(`.xlsx`, 매출일보 시트) → DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 하단 **대시보드 조회**는 기존 HTML 템플릿(Chart.js, 매출·수주·예상실적 탭)을 iframe(`/dashboard/business-performance/embed`)으로 표시. 엑셀에서 수치 집계를 치환하려면 `npm install`로 `xlsx` 패키지를 설치한 뒤 서버를 재시작하세요.
|
||||
- **프롬프트 라이브러리** (`/ai-explore/prompts`): 업무별 기본 프롬프트 카드 선택·미리보기·클립보드 복사 (`data/company-prompts.json`). **좌측 메뉴(채팅·AI·AI 성공 사례·학습센터·AX 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)
|
||||
- 검색/필터/페이지네이션
|
||||
- 검색어(`q`) 기반 제목/설명/태그 필터
|
||||
@@ -81,7 +82,7 @@ ai_platform/
|
||||
├─ .env # 환경 변수 (실제 값, .gitignore 대상)
|
||||
├─ .env.example # 환경 변수 예시 템플릿
|
||||
├─ db/
|
||||
│ └─ schema.sql # PostgreSQL lectures 테이블 스키마 (서버 기동 시 자동 적용)
|
||||
│ └─ schema.sql # PostgreSQL 스키마 (강의·회의록·경영성과 업로드 등, 기동 시 자동 적용)
|
||||
├─ scripts/
|
||||
│ └─ apply-schema.js # 수동 스키마 적용 스크립트 (npm run db:schema)
|
||||
├─ public/
|
||||
@@ -95,7 +96,9 @@ ai_platform/
|
||||
│ ├─ chat.ejs # 채팅
|
||||
│ ├─ ai-explore.ejs # AI 탐색 (전체 너비, 프롬프트 카드·검색)
|
||||
│ ├─ dashboard.ejs # 대시보드 목록(카드·검색)
|
||||
│ ├─ dashboard-business-performance.ejs # 경영성과 대시보드(진입·안내)
|
||||
│ ├─ dashboard-business-performance.ejs # 경영성과(업로드 + iframe 조회)
|
||||
│ ├─ mgmt_perf_embed.ejs # 경영성과 차트 단독 페이지(iframe용)
|
||||
│ ├─ partials/mgmt_perf_dashboard_container.ejs
|
||||
│ ├─ ai-prompts.ejs # 프롬프트 라이브러리 (카드·미리보기·복사)
|
||||
│ ├─ ai-cases.ejs # AI 성공 사례 목록(카드)
|
||||
│ ├─ ai-case-detail.ejs # AI 성공 사례 상세(마크다운)
|
||||
|
||||
1160
data/mgmt-perf-default-payload.json
Normal file
1160
data/mgmt-perf-default-payload.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -270,3 +270,28 @@ CREATE TABLE IF NOT EXISTS lecture_thumbnail_events (
|
||||
CREATE INDEX IF NOT EXISTS idx_lecture_thumbnail_events_occurred ON lecture_thumbnail_events (occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_lecture_thumbnail_events_type ON lecture_thumbnail_events (event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_lecture_thumbnail_events_lecture ON lecture_thumbnail_events (lecture_id);
|
||||
|
||||
-- 경영성과 대시보드: 엑셀 업로드·차트용 JSON 스냅샷
|
||||
CREATE TABLE IF NOT EXISTS mgmt_perf_uploads (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_email VARCHAR(320),
|
||||
original_filename TEXT NOT NULL,
|
||||
fiscal_year INT NOT NULL,
|
||||
quarter INT NOT NULL CHECK (quarter >= 1 AND quarter <= 4),
|
||||
file_path TEXT NOT NULL,
|
||||
file_size BIGINT,
|
||||
parse_status VARCHAR(32) NOT NULL DEFAULT 'ok',
|
||||
parse_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mgmt_perf_uploads_created ON mgmt_perf_uploads (created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mgmt_perf_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
upload_id UUID NOT NULL UNIQUE REFERENCES mgmt_perf_uploads (id) ON DELETE CASCADE,
|
||||
payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mgmt_perf_snapshots_upload ON mgmt_perf_snapshots (upload_id);
|
||||
|
||||
184
lib/mgmt-perf.js
Normal file
184
lib/mgmt-perf.js
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 경영성과 대시보드: 엑셀 업로드 파싱·스냅샷 저장·최신 페이로드 조회
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function loadXlsx() {
|
||||
try {
|
||||
return require("xlsx");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const DEFAULT_PAYLOAD_PATH = path.join(ROOT, "data", "mgmt-perf-default-payload.json");
|
||||
const FILE_STATE_PATH = path.join(ROOT, "data", "mgmt-perf-last-state.json");
|
||||
|
||||
function loadDefaultPayload() {
|
||||
const raw = fs.readFileSync(DEFAULT_PAYLOAD_PATH, "utf8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매출일보 시트를 읽어 기본 페이로드에 메타를 덧붙입니다. (집계 치환은 추후 확장)
|
||||
* @param {Buffer} buffer
|
||||
* @param {object} defaultPayload
|
||||
*/
|
||||
function buildPayloadFromWorkbook(buffer, defaultPayload) {
|
||||
const XLSX = loadXlsx();
|
||||
if (!XLSX) {
|
||||
const payload = JSON.parse(JSON.stringify(defaultPayload));
|
||||
payload._uploadMeta = {
|
||||
sheets: [],
|
||||
primarySheet: null,
|
||||
rowCount: 0,
|
||||
importedAt: new Date().toISOString(),
|
||||
note: "npm 패키지 `xlsx`가 없어 엑셀을 파싱하지 못했습니다. 프로젝트 루트에서 `npm install` 후 서버를 재시작하세요.",
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
const wb = XLSX.read(buffer, { type: "buffer", cellDates: true });
|
||||
const names = wb.SheetNames || [];
|
||||
const sheetName = names.includes("매출일보") ? "매출일보" : names[0];
|
||||
const ws = wb.Sheets[sheetName];
|
||||
const matrix = XLSX.utils.sheet_to_json(ws, { header: 1, defval: "" });
|
||||
const nonEmptyRows = matrix.filter((r) => Array.isArray(r) && r.some((c) => c !== "" && c != null));
|
||||
const payload = JSON.parse(JSON.stringify(defaultPayload));
|
||||
payload._uploadMeta = {
|
||||
sheets: names,
|
||||
primarySheet: sheetName,
|
||||
rowCount: nonEmptyRows.length,
|
||||
importedAt: new Date().toISOString(),
|
||||
note:
|
||||
"매출일보 행 수·시트명만 반영했습니다. 차트 수치를 엑셀 집계로 치환하려면 별도 매핑 로직이 필요합니다.",
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {{ userEmail: string | null, originalFilename: string, filePath: string, fiscalYear: number, quarter: number, payload: object }} row
|
||||
*/
|
||||
async function saveUploadAndSnapshot(pgPool, row) {
|
||||
const { userEmail, originalFilename, filePath, fiscalYear, quarter, payload } = row;
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!pgPool) {
|
||||
fs.writeFileSync(
|
||||
FILE_STATE_PATH,
|
||||
JSON.stringify(
|
||||
{
|
||||
payload,
|
||||
meta: {
|
||||
originalFilename,
|
||||
fiscalYear,
|
||||
quarter,
|
||||
savedAt: new Date().toISOString(),
|
||||
fileSize: stat.size,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
return { id: null, fileBacked: true };
|
||||
}
|
||||
const ins = await pgPool.query(
|
||||
`INSERT INTO mgmt_perf_uploads (user_email, original_filename, fiscal_year, quarter, file_path, file_size, parse_status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,'ok')
|
||||
RETURNING id`,
|
||||
[userEmail || null, originalFilename, fiscalYear, quarter, filePath, stat.size]
|
||||
);
|
||||
const uploadId = ins.rows[0].id;
|
||||
await pgPool.query(`INSERT INTO mgmt_perf_snapshots (upload_id, payload) VALUES ($1, $2::jsonb)`, [
|
||||
uploadId,
|
||||
JSON.stringify(payload),
|
||||
]);
|
||||
return { id: uploadId, fileBacked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
*/
|
||||
async function getLatestPayloadRow(pgPool) {
|
||||
if (pgPool) {
|
||||
const r = await pgPool.query(`
|
||||
SELECT s.payload, u.fiscal_year, u.quarter, u.original_filename, u.created_at
|
||||
FROM mgmt_perf_snapshots s
|
||||
JOIN mgmt_perf_uploads u ON u.id = s.upload_id
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
if (r.rows[0]) {
|
||||
const row = r.rows[0];
|
||||
return {
|
||||
payload: row.payload,
|
||||
fiscal_year: row.fiscal_year,
|
||||
quarter: row.quarter,
|
||||
original_filename: row.original_filename,
|
||||
created_at: row.created_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(FILE_STATE_PATH, "utf8"));
|
||||
return {
|
||||
payload: j.payload,
|
||||
fiscal_year: j.meta?.fiscalYear,
|
||||
quarter: j.meta?.quarter,
|
||||
original_filename: j.meta?.originalFilename,
|
||||
created_at: j.meta?.savedAt ? new Date(j.meta.savedAt) : null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
payload: loadDefaultPayload(),
|
||||
fiscal_year: new Date().getFullYear(),
|
||||
quarter: 1,
|
||||
original_filename: null,
|
||||
created_at: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {number} [limit=20]
|
||||
*/
|
||||
async function listUploads(pgPool, limit = 20) {
|
||||
if (!pgPool) {
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(FILE_STATE_PATH, "utf8"));
|
||||
return [
|
||||
{
|
||||
id: "file",
|
||||
original_filename: j.meta?.originalFilename,
|
||||
fiscal_year: j.meta?.fiscalYear,
|
||||
quarter: j.meta?.quarter,
|
||||
created_at: j.meta?.savedAt,
|
||||
parse_status: "ok",
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const r = await pgPool.query(
|
||||
`SELECT id, original_filename, fiscal_year, quarter, parse_status, created_at
|
||||
FROM mgmt_perf_uploads
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
return r.rows;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadDefaultPayload,
|
||||
buildPayloadFromWorkbook,
|
||||
saveUploadAndSnapshot,
|
||||
getLatestPayloadRow,
|
||||
listUploads,
|
||||
FILE_STATE_PATH,
|
||||
};
|
||||
@@ -34,6 +34,7 @@
|
||||
"nodemailer": "^6.10.1",
|
||||
"openai": "^6.29.0",
|
||||
"pg": "^8.20.0",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^13.0.0",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
|
||||
910
public/mgmt-perf/dashboard-app.js
Normal file
910
public/mgmt-perf/dashboard-app.js
Normal 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('');
|
||||
}
|
||||
|
||||
})();
|
||||
399
public/mgmt-perf/dashboard.css
Normal file
399
public/mgmt-perf/dashboard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
92
server.js
92
server.js
@@ -26,6 +26,7 @@ const {
|
||||
isOpsStateSuper,
|
||||
} = require("./lib/ops-state");
|
||||
const { fetchOpenGraphImageUrl } = require("./lib/link-preview");
|
||||
const mgmtPerf = require("./lib/mgmt-perf");
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 8030;
|
||||
@@ -535,6 +536,31 @@ const uploadMeetingAudio = multer({
|
||||
},
|
||||
});
|
||||
|
||||
const MGMT_PERF_UPLOAD_DIR = path.join(ROOT_DIR, "uploads", "mgmt-perf");
|
||||
const mgmtPerfStorage = multer.diskStorage({
|
||||
destination: (_, __, cb) => {
|
||||
fsSync.mkdirSync(MGMT_PERF_UPLOAD_DIR, { recursive: true });
|
||||
cb(null, MGMT_PERF_UPLOAD_DIR);
|
||||
},
|
||||
filename: (_, file, cb) => {
|
||||
const ext = (path.extname(file.originalname) || ".xlsx").toLowerCase();
|
||||
const safeExt = ext === ".xlsx" ? ".xlsx" : ".xlsx";
|
||||
cb(null, `${Date.now()}-${uuidv4()}${safeExt}`);
|
||||
},
|
||||
});
|
||||
const uploadMgmtPerfExcel = multer({
|
||||
storage: mgmtPerfStorage,
|
||||
limits: { fileSize: 55 * 1024 * 1024 },
|
||||
fileFilter: (_, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (ext !== ".xlsx") {
|
||||
cb(new Error("Excel (.xlsx) 파일만 업로드할 수 있습니다."));
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
const mapRowToAxAssignment = (row) => ({
|
||||
id: row.id,
|
||||
department: row.department || "",
|
||||
@@ -1130,6 +1156,30 @@ app.use("/resources/ax-apply", express.static(RESOURCES_AX_APPLY_DIR));
|
||||
app.use(opsAuth.middleware);
|
||||
opsAuth.registerRoutes(app);
|
||||
|
||||
app.post("/api/mgmt-perf/upload", uploadMgmtPerfExcel.single("file"), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: "파일이 없습니다." });
|
||||
const fiscalYear = Math.min(2100, Math.max(2020, parseInt(req.body.fiscalYear, 10) || new Date().getFullYear()));
|
||||
const quarter = Math.min(4, Math.max(1, parseInt(req.body.quarter, 10) || 1));
|
||||
const email = res.locals.opsUserEmail ? String(res.locals.opsUserEmail).trim() : null;
|
||||
const defaultPayload = mgmtPerf.loadDefaultPayload();
|
||||
const buf = fsSync.readFileSync(req.file.path);
|
||||
const payload = mgmtPerf.buildPayloadFromWorkbook(buf, defaultPayload);
|
||||
await mgmtPerf.saveUploadAndSnapshot(pgPool, {
|
||||
userEmail: email,
|
||||
originalFilename: req.file.originalname,
|
||||
filePath: req.file.path,
|
||||
fiscalYear,
|
||||
quarter,
|
||||
payload,
|
||||
});
|
||||
res.json({ ok: true, message: "저장되었습니다. 아래 대시보드가 갱신되었습니다." });
|
||||
} catch (err) {
|
||||
console.error("mgmt-perf upload:", err);
|
||||
res.status(500).json({ error: err.message || "처리 실패" });
|
||||
}
|
||||
});
|
||||
|
||||
const pageRouter = express.Router();
|
||||
pageRouter.get("/chat", (req, res) =>
|
||||
res.render("chat", {
|
||||
@@ -1174,13 +1224,41 @@ pageRouter.get("/dashboard", (req, res) =>
|
||||
opsUserEmail: !!res.locals.opsUserEmail,
|
||||
})
|
||||
);
|
||||
pageRouter.get("/dashboard/business-performance", (req, res) =>
|
||||
res.render("dashboard-business-performance", {
|
||||
activeMenu: "dashboard",
|
||||
adminMode: res.locals.adminMode,
|
||||
opsUserEmail: !!res.locals.opsUserEmail,
|
||||
})
|
||||
);
|
||||
pageRouter.get("/dashboard/business-performance", async (req, res, next) => {
|
||||
try {
|
||||
const latest = await mgmtPerf.getLatestPayloadRow(pgPool);
|
||||
const uploadHistory = await mgmtPerf.listUploads(pgPool, 12);
|
||||
const y = latest.fiscal_year || new Date().getFullYear();
|
||||
const q = latest.quarter || 1;
|
||||
res.render("dashboard-business-performance", {
|
||||
activeMenu: "dashboard",
|
||||
adminMode: res.locals.adminMode,
|
||||
opsUserEmail: !!res.locals.opsUserEmail,
|
||||
defaultYear: y,
|
||||
selectedQuarter: q,
|
||||
uploadHistory,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
pageRouter.get("/dashboard/business-performance/embed", async (req, res, next) => {
|
||||
try {
|
||||
const row = await mgmtPerf.getLatestPayloadRow(pgPool);
|
||||
const fy = row.fiscal_year || new Date().getFullYear();
|
||||
const q = row.quarter || 1;
|
||||
const payloadJson = JSON.stringify(row.payload).replace(/</g, "\\u003c");
|
||||
const dashboardTitle = `${fy} Q${q} 경영성과 대시보드`;
|
||||
const quarterLabel = `Q${q}`;
|
||||
res.render("mgmt_perf_embed", {
|
||||
dashboardTitle,
|
||||
quarterLabel,
|
||||
payloadJson,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
const AI_SUCCESS_ADMIN_LIST_PAGE_SIZE = 5;
|
||||
|
||||
pageRouter.get("/ai-cases/write", (req, res) => {
|
||||
|
||||
@@ -6,6 +6,112 @@
|
||||
<%- include('partials/favicon') %>
|
||||
<title>경영성과 대시보드 - XAVIS</title>
|
||||
<link rel="stylesheet" href="/public/styles.css" />
|
||||
<style>
|
||||
.mgmt-perf-page main.container {
|
||||
max-width: 1200px;
|
||||
}
|
||||
.mgmt-perf-split {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.mgmt-upload-panel {
|
||||
border: 1px solid var(--border, #e0e0e0);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
background: var(--panel-bg, #fafafa);
|
||||
}
|
||||
.mgmt-upload-panel h2 {
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.mgmt-upload-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.mgmt-upload-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mgmt-upload-form input[type="file"] {
|
||||
max-width: 280px;
|
||||
}
|
||||
.mgmt-upload-form select,
|
||||
.mgmt-upload-form input[type="number"] {
|
||||
min-width: 100px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.mgmt-upload-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-mgmt-upload {
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-mgmt-upload:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mgmt-upload-msg {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.mgmt-upload-msg.ok {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.mgmt-upload-msg.err {
|
||||
color: #c62828;
|
||||
}
|
||||
.mgmt-dash-panel h2 {
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.mgmt-dash-embed-wrap {
|
||||
border: 1px solid var(--border, #e0e0e0);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
min-height: 820px;
|
||||
}
|
||||
.mgmt-dash-embed-wrap iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 820px;
|
||||
border: 0;
|
||||
}
|
||||
.mgmt-upload-history {
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mgmt-upload-history table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.mgmt-upload-history th,
|
||||
.mgmt-upload-history td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
text-align: left;
|
||||
}
|
||||
.mgmt-upload-history th {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
@@ -13,7 +119,7 @@
|
||||
activeMenu: 'dashboard',
|
||||
adminMode: typeof adminMode !== 'undefined' ? adminMode : false,
|
||||
}) %>
|
||||
<div class="content-area">
|
||||
<div class="content-area mgmt-perf-page">
|
||||
<header class="topbar">
|
||||
<h1>경영성과 대시보드</h1>
|
||||
</header>
|
||||
@@ -21,14 +127,116 @@
|
||||
<p class="breadcrumb" style="margin-bottom: 16px">
|
||||
<a href="/dashboard">← 대시보드 목록으로</a>
|
||||
</p>
|
||||
<section class="panel">
|
||||
<p class="subtitle">
|
||||
이 화면은 향후 경영·성과 지표 연동 및 위젯 구성을 위한 진입점입니다. 필요한 데이터 소스와 차트
|
||||
요구사항이 정해지면 이어서 구현할 수 있습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="mgmt-perf-split">
|
||||
<section class="mgmt-upload-panel" aria-labelledby="mgmt-upload-heading">
|
||||
<h2 id="mgmt-upload-heading">엑셀 업로드</h2>
|
||||
<p class="subtitle" style="margin: 0 0 12px; font-size: 14px">
|
||||
매출 집계 엑셀(<strong>매출일보</strong> 시트 포함)을 업로드하면 스냅샷이 저장되고, 아래 대시보드에 반영됩니다.
|
||||
</p>
|
||||
<form id="mgmtPerfUploadForm" class="mgmt-upload-form" enctype="multipart/form-data">
|
||||
<label>
|
||||
연도
|
||||
<input type="number" name="fiscalYear" min="2020" max="2100" value="<%= typeof defaultYear !== 'undefined' ? defaultYear : 2026 %>" required />
|
||||
</label>
|
||||
<label>
|
||||
분기
|
||||
<select name="quarter" required>
|
||||
<option value="1" <%= (typeof selectedQuarter !== 'undefined' ? selectedQuarter : 1) === 1 ? 'selected' : '' %>>1분기 (Q1)</option>
|
||||
<option value="2" <%= (typeof selectedQuarter !== 'undefined' ? selectedQuarter : 1) === 2 ? 'selected' : '' %>>2분기 (Q2)</option>
|
||||
<option value="3" <%= (typeof selectedQuarter !== 'undefined' ? selectedQuarter : 1) === 3 ? 'selected' : '' %>>3분기 (Q3)</option>
|
||||
<option value="4" <%= (typeof selectedQuarter !== 'undefined' ? selectedQuarter : 1) === 4 ? 'selected' : '' %>>4분기 (Q4)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
파일 (.xlsx)
|
||||
<input type="file" name="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required />
|
||||
</label>
|
||||
<div class="mgmt-upload-actions">
|
||||
<button type="submit" class="btn-mgmt-upload" id="mgmtPerfUploadBtn">업로드 및 반영</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="mgmtPerfUploadMsg" class="mgmt-upload-msg" role="status"></div>
|
||||
<% if (typeof uploadHistory !== 'undefined' && uploadHistory && uploadHistory.length) { %>
|
||||
<div class="mgmt-upload-history">
|
||||
<strong>최근 업로드</strong>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일시</th>
|
||||
<th>파일명</th>
|
||||
<th>연도·분기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% uploadHistory.forEach(function (u) { %>
|
||||
<tr>
|
||||
<td><%= u.created_at ? new Date(u.created_at).toLocaleString('ko-KR') : '-' %></td>
|
||||
<td><%= u.original_filename || '-' %></td>
|
||||
<td><%= u.fiscal_year %>년 Q<%= u.quarter %></td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<section class="mgmt-dash-panel" aria-labelledby="mgmt-dash-heading">
|
||||
<h2 id="mgmt-dash-heading">대시보드 조회</h2>
|
||||
<div class="mgmt-dash-embed-wrap">
|
||||
<iframe
|
||||
id="mgmtPerfDashFrame"
|
||||
title="경영성과 차트"
|
||||
src="/dashboard/business-performance/embed"
|
||||
></iframe>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById("mgmtPerfUploadForm");
|
||||
var btn = document.getElementById("mgmtPerfUploadBtn");
|
||||
var msg = document.getElementById("mgmtPerfUploadMsg");
|
||||
var frame = document.getElementById("mgmtPerfDashFrame");
|
||||
if (!form || !btn || !msg) return;
|
||||
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
msg.textContent = "";
|
||||
msg.className = "mgmt-upload-msg";
|
||||
var fd = new FormData(form);
|
||||
btn.disabled = true;
|
||||
fetch("/api/mgmt-perf/upload", { method: "POST", body: fd, credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
return r.json().then(function (j) {
|
||||
return { ok: r.ok, body: j };
|
||||
});
|
||||
})
|
||||
.then(function (_ref) {
|
||||
var ok = _ref.ok;
|
||||
var body = _ref.body;
|
||||
if (ok) {
|
||||
msg.textContent = body.message || "저장되었습니다.";
|
||||
msg.className = "mgmt-upload-msg ok";
|
||||
if (frame) frame.src = "/dashboard/business-performance/embed?t=" + Date.now();
|
||||
} else {
|
||||
msg.textContent = body.error || "업로드에 실패했습니다.";
|
||||
msg.className = "mgmt-upload-msg err";
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
msg.textContent = "네트워크 오류입니다.";
|
||||
msg.className = "mgmt-upload-msg err";
|
||||
})
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
15
views/mgmt_perf_embed.ejs
Normal file
15
views/mgmt_perf_embed.ejs
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title><%= dashboardTitle %></title>
|
||||
<link rel="stylesheet" href="/mgmt-perf/dashboard.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<%- include('partials/mgmt_perf_dashboard_container', { dashboardTitle, quarterLabel }) %>
|
||||
<script type="application/json" id="mgmt-perf-payload-json"><%- payloadJson %></script>
|
||||
<script src="/mgmt-perf/dashboard-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
267
views/partials/mgmt_perf_dashboard_container.ejs
Normal file
267
views/partials/mgmt_perf_dashboard_container.ejs
Normal file
@@ -0,0 +1,267 @@
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1><%= typeof dashboardTitle !== 'undefined' ? dashboardTitle : '경영성과 대시보드' %></h1>
|
||||
<p>실시간 성과 분석 및 사업본부별 상세 현황</p>
|
||||
</div>
|
||||
|
||||
<!-- Slicers Container -->
|
||||
<div class="slicers-container">
|
||||
<div class="slicer-group">
|
||||
<div class="slicer-label">사업본부 (Division)</div>
|
||||
<div class="slicer-tabs" id="divisionSlicer">
|
||||
<button class="slicer-tab active" data-division="all">전체</button>
|
||||
<button class="slicer-tab" data-division="fscan">FSCAN국내</button>
|
||||
<button class="slicer-tab" data-division="fscanovs">FSCAN해외</button>
|
||||
<button class="slicer-tab" data-division="xscan">XSCAN국내</button>
|
||||
<button class="slicer-tab" data-division="xscanovs">XSCAN해외</button>
|
||||
<button class="slicer-tab" data-division="battery">배터리</button>
|
||||
<button class="slicer-tab" data-division="newbiz">신사업</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slicer-group">
|
||||
<div class="slicer-label">월별 (Month)</div>
|
||||
<div class="slicer-tabs" id="monthSlicer">
|
||||
<button class="slicer-tab active" data-month="all"><%= typeof quarterLabel !== 'undefined' ? quarterLabel : 'Q1' %> 전체</button>
|
||||
<button class="slicer-tab" data-month="1">1월</button>
|
||||
<button class="slicer-tab" data-month="2">2월</button>
|
||||
<button class="slicer-tab" data-month="3">3월</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Tabs -->
|
||||
<div class="section-tabs">
|
||||
<button class="section-tab-btn active" data-section="매출현황">매출현황</button>
|
||||
<button class="section-tab-btn" data-section="수주현황">수주현황</button>
|
||||
<button class="section-tab-btn" data-section="예상전망">예상실적</button>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="content">
|
||||
<!-- 매출현황 Section -->
|
||||
<div id="매출현황" class="section active">
|
||||
<!-- Overview Mode -->
|
||||
<div id="overviewMode">
|
||||
<div class="kpi-grid" id="overviewKpis"></div>
|
||||
<div class="chart-row">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">월별 매출 추이</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="monthlyTrendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">사업본부별 매출 현황</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="divisionSalesChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-row">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">목표 대비 실적</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="targetVsActualChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">사업본부별 목표달성률</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="achievementRateChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Division Detail Mode -->
|
||||
<div id="divisionDetailMode" style="display:none;">
|
||||
<div class="kpi-grid" id="divisionDetailKpis"></div>
|
||||
<div class="chart-row">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">월별 매출 추이</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="divisionMonthlyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">매출비중 (Top Customers)</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="customerShareChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<div class="table-title">주요 고객사 현황</div>
|
||||
<table id="customerTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;">순위</th>
|
||||
<th style="width: 200px;">고객사명</th>
|
||||
<th style="width: 80px;">구분</th>
|
||||
<th style="width: 100px;">매출유형</th>
|
||||
<th style="width: 120px;">매출액</th>
|
||||
<th style="width: 80px;">비중(%)</th>
|
||||
<th style="width: 120px;">미발행잔액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 미수채권 Section -->
|
||||
<div class="risk-section">
|
||||
<div class="risk-title">미수채권 (세금계산서 미발행 리스크)</div>
|
||||
<div style="margin-bottom: 30px;">
|
||||
<div class="risk-label">총 미발행 잔액</div>
|
||||
<div class="risk-value" id="divisionRiskTotal">0</div>
|
||||
</div>
|
||||
<div class="chart-container" style="margin-bottom: 0;">
|
||||
<div class="chart-title">고객사별 미발행금액</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="riskByCustomerChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<div class="table-title">미수채권 상세현황</div>
|
||||
<table id="riskDetailTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px;">월</th>
|
||||
<th style="width: 180px;">사업본부</th>
|
||||
<th style="width: 180px;">고객사</th>
|
||||
<th style="width: 150px;">제품</th>
|
||||
<th style="width: 120px;">미발행금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sales Category Breakdown -->
|
||||
<div class="chart-row" id="salesCatRow">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title" id="salesCatTitle">매출유형별 비중</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="salesCatChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title" id="salesCatBarTitle">매출유형별 금액 (단위: 억원)</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="salesCatBarChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Model Rankings (FSCAN/XSCAN only) -->
|
||||
<div class="chart-row" id="salesModelRow">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title" id="fscanSalesModelTitle">FSCAN 주요 모델별 매출 Top 5</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="fscanModelChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title" id="xscanSalesModelTitle">XSCAN 주요 모델별 매출 Top 5</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="xscanModelChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수주현황 Section -->
|
||||
<div id="수주현황" class="section">
|
||||
<div class="kpi-grid" id="orderKpis"></div>
|
||||
<div class="chart-row">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">월별 수주 현황 (단위: 억원)</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="orderMonthlyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">사업본부별 수주 실적</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="orderByDivisionChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-row">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title" id="orderCatTitle">수주유형별 비중</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="orderCatChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title" id="orderCatBarTitle">수주유형별 금액 (단위: 억원)</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="orderCatBarChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-row" id="orderModelRow">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title" id="fscanOrderModelTitle">FSCAN 주요 모델별 수주</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="fscanOrderModelChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title" id="xscanOrderModelTitle">XSCAN 주요 모델별 수주</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="xscanOrderModelChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<div class="table-title">사업본부별 월별 수주 현황 (단위: 억원)</div>
|
||||
<table id="orderDetailTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사업본부</th>
|
||||
<th style="text-align: right;">1월</th>
|
||||
<th style="text-align: right;">2월</th>
|
||||
<th style="text-align: right;">3월</th>
|
||||
<th style="text-align: right;">합계</th>
|
||||
<th style="text-align: right;">계획</th>
|
||||
<th style="text-align: right;">달성률</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 예상전망 Section -->
|
||||
<div id="예상전망" class="section">
|
||||
<div class="kpi-grid" id="forecastKpis"></div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">3개월 예상실적 (단위: 억원)</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="annualForecastChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<div class="table-title">월별 전망치</div>
|
||||
<table id="forecastTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 100px;">월</th>
|
||||
<th style="width: 150px;">계획(억원)</th>
|
||||
<th style="width: 150px;">손익분기점(억원)</th>
|
||||
<th style="width: 150px;">실적(억원)</th>
|
||||
<th style="width: 100px;">달성률</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user