fix(mgmt-perf): 차트 탭 동기화, 한글 파일명 복원

- 섹션 id를 ASCII(mgmt-sec-*)로 통일하고 isSectionActive를 state 기준으로 변경
- multipart 파일명 UTF-8 복원(decodeMultipartFilename) 후 스냅샷 메타에 저장
- Chart.js 미로드·UM 누락 시 조기 종료 및 README 정리

Made-with: Cursor
This commit is contained in:
2026-04-13 18:48:04 +09:00
parent f6b94eea64
commit 419f529d06
4 changed files with 46 additions and 17 deletions

View File

@@ -42,7 +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`에 스냅샷 저장. 하단 **대시보드 조회**는 동일 페이지에 Chart.js 템플릿을 인라인으로 렌더(iframe 제거, 별도 `/dashboard/business-performance/embed`는 직접 열람용으로 유지). 리버스 프록시 사용 시 업로드 실패하면 **`client_max_body_size`**(예: 64m)와 **`/api/` → Node** 전달 여부를 확인. 엑셀 집계 치환은 `npm install``xlsx` 설치 후 서버 재시작.
- **경영성과 대시보드** (`/dashboard/business-performance`): 상단 **엑셀 업로드**(`.xlsx`, 매출일보 시트) → DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 하단 **대시보드 조회**는 동일 페이지에 Chart.js 템플릿을 인라인으로 렌더(iframe 제거, 별도 `/dashboard/business-performance/embed`는 직접 열람용으로 유지). 업로드 시 **한글 파일명**은 multipart 인코딩 복원(`decodeMultipartFilename`) 후 저장합니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시 사용 시 업로드 실패하면 **`client_max_body_size`**(예: 64m)와 **`/api/` → Node** 전달 여부를 확인. 엑셀 집계 치환은 `npm install``xlsx` 설치 후 서버 재시작.
- **경영성과 데이터 확인**: 브라우저에서 `GET /api/mgmt-perf/status`(JSON)로 최근 스냅샷의 `payloadKeys`, `_uploadMeta`(행 수 등)를 확인할 수 있습니다. **현재 구현**은 엑셀에서 **매출일보 행 수·시트명만** `payload._uploadMeta`에 넣고, **차트 수치는 기본 시드 JSON**(`data/mgmt-perf-default-payload.json`)을 씁니다. 5,000행이어도 차트가 엑셀 집계와 일치하려면 **별도 집계·매핑 로직**이 필요합니다.
- **대시보드 메뉴 접근**: `.env``DASHBOARD_MENU_ALLOWED_EMAILS`**쉼표로 구분한 OPS 로그인 이메일**만 좌측 **대시보드** 메뉴·`/dashboard`·경영성과 API가 보입니다. 목록이 비어 있으면 누구에게도 표시되지 않습니다. 로컬(DEV)에서 관리자 토큰만 쓰는 경우 `DASHBOARD_MENU_DEV_USE_MEETING_EMAIL=1``MEETING_DEV_EMAIL`을 허용 목록과 맞추면 대조됩니다.
- **프롬프트 라이브러리** (`/ai-explore/prompts`): 업무별 기본 프롬프트 카드 선택·미리보기·클립보드 복사 (`data/company-prompts.json`). **좌측 메뉴(채팅·AI·AI 성공 사례·학습센터·AX 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)
@@ -98,8 +98,8 @@ ai_platform/
│ ├─ chat.ejs # 채팅
│ ├─ ai-explore.ejs # AI 탐색 (전체 너비, 프롬프트 카드·검색)
│ ├─ dashboard.ejs # 대시보드 목록(카드·검색)
│ ├─ dashboard-business-performance.ejs # 경영성과(업로드 + iframe 조회)
│ ├─ mgmt_perf_embed.ejs # 경영성과 차트 단독 페이지(iframe용)
│ ├─ dashboard-business-performance.ejs # 경영성과(업로드 + 인라인 Chart.js 조회)
│ ├─ mgmt_perf_embed.ejs # 경영성과 차트 단독 페이지(직접 열람·임베드용)
│ ├─ partials/mgmt_perf_dashboard_container.ejs
│ ├─ ai-prompts.ejs # 프롬프트 라이브러리 (카드·미리보기·복사)
│ ├─ ai-cases.ejs # AI 성공 사례 목록(카드)

View File

@@ -11,17 +11,31 @@
console.error("mgmt-perf: invalid JSON", err);
return;
}
if (typeof Chart === "undefined") {
console.error("mgmt-perf: Chart.js not loaded");
return;
}
const UM = P.UM;
if (!UM || typeof UM !== "object") {
console.error("mgmt-perf: payload missing UM");
return;
}
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;
/** HTML id / data-section — 한글 id는 브라우저·정규화 이슈로 getElementById가 실패할 수 있어 ASCII만 사용 */
const SECTION = {
SALES: "mgmt-sec-sales",
ORDER: "mgmt-sec-order",
FORECAST: "mgmt-sec-forecast",
};
let state = {
currentDivision: 'all',
currentMonth: 'all',
currentSection: '매출현황',
currentSection: SECTION.SALES,
charts: {}
};
@@ -40,8 +54,7 @@ let state = {
}
function isSectionActive(sectionId) {
const el = document.getElementById(sectionId);
return !!(el && el.classList.contains("active"));
return state.currentSection === sectionId;
}
// Initialize (iframe에서도 DOMContentLoaded가 이미 지난 경우 대비)
@@ -129,7 +142,7 @@ let state = {
const monthIdx = mon === "all" ? [0, 1, 2] : [parseInt(mon, 10) - 1];
// 매출현황 탭이 보일 때만 KPI·매출 차트 렌더 (숨겨진 영역에 그리면 Chart.js 높이 0)
if (isSectionActive("매출현황")) {
if (isSectionActive(SECTION.SALES)) {
document.getElementById("overviewMode").style.display = isOverviewMode ? "block" : "none";
document.getElementById("divisionDetailMode").style.display = isOverviewMode ? "none" : "block";
if (isOverviewMode) {
@@ -146,14 +159,14 @@ let state = {
}
const omr = document.getElementById("orderModelRow");
if (omr && isSectionActive("수주현황")) {
if (omr && isSectionActive(SECTION.ORDER)) {
omr.style.display = modelDisplay;
}
if (isSectionActive("수주현황")) {
if (isSectionActive(SECTION.ORDER)) {
renderOrderSection();
}
if (isSectionActive("예상전망")) {
if (isSectionActive(SECTION.FORECAST)) {
renderForecastSection();
}
}

View File

@@ -28,6 +28,22 @@ const {
const { fetchOpenGraphImageUrl } = require("./lib/link-preview");
const mgmtPerf = require("./lib/mgmt-perf");
/**
* multipart `filename`이 Latin-1로 잘못 해석된 UTF-8 바이트일 때 복원합니다.
* 이미 한글이 올바른 유니코드로 들어온 경우는 그대로 둡니다.
*/
function decodeMultipartFilename(name) {
if (name == null || typeof name !== "string") return "";
if (/[\uAC00-\uD7A3]/.test(name)) return name;
try {
const dec = Buffer.from(name, "latin1").toString("utf8");
if (dec && !dec.includes("\uFFFD") && /[\uAC00-\uD7A3]/.test(dec)) return dec;
} catch (_) {
/* ignore */
}
return name;
}
const app = express();
const PORT = process.env.PORT || 8030;
/** 로컬 전용으로만 열 때: HOST=127.0.0.1 (기본은 모든 인터페이스) */
@@ -1241,7 +1257,7 @@ app.post(
const payload = mgmtPerf.buildPayloadFromWorkbook(buf, defaultPayload);
await mgmtPerf.saveUploadAndSnapshot(pgPool, {
userEmail: email,
originalFilename: req.file.originalname,
originalFilename: decodeMultipartFilename(req.file.originalname),
filePath: req.file.path,
fiscalYear,
quarter,

View File

@@ -32,15 +32,15 @@
<!-- 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>
<button class="section-tab-btn active" data-section="mgmt-sec-sales">매출현황</button>
<button class="section-tab-btn" data-section="mgmt-sec-order">수주현황</button>
<button class="section-tab-btn" data-section="mgmt-sec-forecast">예상실적</button>
</div>
<!-- Content Area -->
<div class="content">
<!-- 매출현황 Section -->
<div id="매출현황" class="section active">
<div id="mgmt-sec-sales" class="section active">
<!-- Overview Mode -->
<div id="overviewMode">
<div class="kpi-grid" id="overviewKpis"></div>
@@ -173,7 +173,7 @@
</div>
<!-- 수주현황 Section -->
<div id="수주현황" class="section">
<div id="mgmt-sec-order" class="section">
<div class="kpi-grid" id="orderKpis"></div>
<div class="chart-row">
<div class="chart-container">
@@ -237,7 +237,7 @@
</div>
<!-- 예상전망 Section -->
<div id="예상전망" class="section">
<div id="mgmt-sec-forecast" class="section">
<div class="kpi-grid" id="forecastKpis"></div>
<div class="chart-container">
<div class="chart-title">3개월 예상실적 (단위: 억원)</div>