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:
@@ -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 성공 사례 목록(카드)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
18
server.js
18
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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user