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

@@ -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
View 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>

View 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>