From 419f529d068979914b2d07985f9ac4168eb1b244 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Mon, 13 Apr 2026 18:48:04 +0900 Subject: [PATCH] =?UTF-8?q?fix(mgmt-perf):=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=ED=83=AD=20=EB=8F=99=EA=B8=B0=ED=99=94,=20=ED=95=9C=EA=B8=80?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 섹션 id를 ASCII(mgmt-sec-*)로 통일하고 isSectionActive를 state 기준으로 변경 - multipart 파일명 UTF-8 복원(decodeMultipartFilename) 후 스냅샷 메타에 저장 - Chart.js 미로드·UM 누락 시 조기 종료 및 README 정리 Made-with: Cursor --- README.md | 6 ++--- public/mgmt-perf/dashboard-app.js | 27 ++++++++++++++----- server.js | 18 ++++++++++++- .../mgmt_perf_dashboard_container.ejs | 12 ++++----- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b2d22eb..576124e 100644 --- a/README.md +++ b/README.md @@ -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 성공 사례 목록(카드) diff --git a/public/mgmt-perf/dashboard-app.js b/public/mgmt-perf/dashboard-app.js index ad62d16..c458247 100644 --- a/public/mgmt-perf/dashboard-app.js +++ b/public/mgmt-perf/dashboard-app.js @@ -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(); } } diff --git a/server.js b/server.js index e481aaa..a47d775 100644 --- a/server.js +++ b/server.js @@ -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, diff --git a/views/partials/mgmt_perf_dashboard_container.ejs b/views/partials/mgmt_perf_dashboard_container.ejs index 1cc8467..4db4aed 100644 --- a/views/partials/mgmt_perf_dashboard_container.ejs +++ b/views/partials/mgmt_perf_dashboard_container.ejs @@ -32,15 +32,15 @@
- - - + + +
-
+
@@ -173,7 +173,7 @@
-
+
@@ -237,7 +237,7 @@
-
+
3개월 예상실적 (단위: 억원)