fix(mgmt-perf): /mgmt-perf 정적 제공, Chart.js 동봉, 파일명 복원·차트 리플로

- Express에 /mgmt-perf → public/mgmt-perf 정적 마운트(기존 뷰 경로와 일치)
- jsdelivr 대신 chart.umd.min.js 동봉으로 CDN 차단·오프라인 대응
- decodeMultipartFilename: Latin-1→UTF-8 복원 시 한글 검사 제거(ASCII·깨진 문자열 모두)
- 페이로드/Chart 실패 시 사용자에게 빨간 안내, 차트 resize 이중 rAF

Made-with: Cursor
This commit is contained in:
2026-04-13 18:52:17 +09:00
parent 419f529d06
commit 3ab42d58ce
6 changed files with 57 additions and 4 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`는 직접 열람용으로 유지). 업로드 시 **한글 파일명**은 multipart 인코딩 복원(`decodeMultipartFilename`) 후 저장합니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시 사용 시 업로드 실패하면 **`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`는 직접 열람용으로 유지). Express에서 **`/mgmt-perf/*``public/mgmt-perf/`** 정적 제공이 등록되어 있어 `dashboard-app.js`·`chart.umd.min.js`(CDN 대신 동봉)가 항상 같은 오리진에서 로드됩니다. 업로드 시 **한글 파일명**은 multipart 인코딩 복원(`decodeMultipartFilename`) 후 저장합니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시 사용 시 업로드 실패하면 **`client_max_body_size`**(예: 64m)와 **`/api/`·`/mgmt-perf/` → 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 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)

14
public/mgmt-perf/chart.umd.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,22 @@
(function () {
function showMgmtPerfEmbedError(msg) {
var root = document.querySelector(".mgmt-perf-embed");
if (!root) return;
var prev = document.getElementById("mgmt-perf-embed-error");
if (prev) prev.remove();
var el = document.createElement("div");
el.id = "mgmt-perf-embed-error";
el.setAttribute("role", "alert");
el.style.cssText =
"background:#ffebee;color:#b71c1c;padding:12px;margin:8px;border-radius:8px;font-size:13px;";
el.textContent = msg;
root.insertBefore(el, root.firstChild);
}
const payloadEl = document.getElementById("mgmt-perf-payload-json");
if (!payloadEl || !payloadEl.textContent.trim()) {
console.error("mgmt-perf: missing payload");
showMgmtPerfEmbedError("대시보드 데이터(JSON)가 없습니다. 서버 배포·페이지 소스를 확인하세요.");
return;
}
let P;
@@ -9,15 +24,20 @@
P = JSON.parse(payloadEl.textContent);
} catch (err) {
console.error("mgmt-perf: invalid JSON", err);
showMgmtPerfEmbedError("대시보드 데이터 JSON 파싱에 실패했습니다.");
return;
}
if (typeof Chart === "undefined") {
console.error("mgmt-perf: Chart.js not loaded");
showMgmtPerfEmbedError(
"Chart.js를 불러오지 못했습니다. /mgmt-perf/chart.umd.min.js 경로·방화벽을 확인하세요."
);
return;
}
const UM = P.UM;
if (!UM || typeof UM !== "object") {
console.error("mgmt-perf: payload missing UM");
showMgmtPerfEmbedError("페이로드에 UM(매출 집계)이 없습니다. 스냅샷·기본 JSON을 확인하세요.");
return;
}
const ORDER_CAT = P.ORDER_CAT;
@@ -124,6 +144,18 @@ let state = {
}
}
/** 숨김 탭·레이아웃 직후 캔버스 크기 0인 경우 대비 */
function scheduleChartReflow() {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
Object.keys(state.charts).forEach(function (id) {
var ch = state.charts[id];
if (ch && typeof ch.resize === "function") ch.resize();
});
});
});
}
// Division-to-order-key mapping
const DIV_TO_ORDER = {
fscan: ['fscan'],
@@ -156,6 +188,7 @@ let state = {
renderSalesModelCharts(monthIdx);
}
renderSalesCategoryCharts(monthIdx);
scheduleChartReflow();
}
const omr = document.getElementById("orderModelRow");
@@ -165,9 +198,11 @@ let state = {
if (isSectionActive(SECTION.ORDER)) {
renderOrderSection();
scheduleChartReflow();
}
if (isSectionActive(SECTION.FORECAST)) {
renderForecastSection();
scheduleChartReflow();
}
}

View File

@@ -34,10 +34,12 @@ const mgmtPerf = require("./lib/mgmt-perf");
*/
function decodeMultipartFilename(name) {
if (name == null || typeof name !== "string") return "";
// 이미 한글 등 BMP가 올바르게 들어온 경우(멀티바이트 그대로) 덮어쓰지 않음
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;
// UTF-8이 Latin-1로 잘못 해석된 경우 복원. ASCII-only 파일명은 dec ≈ name.
if (dec && !dec.includes("\uFFFD")) return dec;
} catch (_) {
/* ignore */
}
@@ -1194,6 +1196,8 @@ async function syncAutoChecklistFromMeetingMinutes(openai, { pgPool, email, meet
}
app.use("/public", express.static(path.join(ROOT_DIR, "public")));
/** 경영성과 대시보드 정적 자산 (`/mgmt-perf/*` — 뷰에서 이 경로로 참조) */
app.use("/mgmt-perf", express.static(path.join(ROOT_DIR, "public", "mgmt-perf")));
app.get("/favicon.ico", (req, res) => {
res.type("image/x-icon");
res.sendFile(path.join(ROOT_DIR, "public", "favicon.ico"));

View File

@@ -187,7 +187,7 @@
</div>
</div>
<script type="application/json" id="mgmt-perf-payload-json"><%- typeof payloadJson !== 'undefined' ? payloadJson : '{}' %></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
<script src="/mgmt-perf/chart.umd.min.js"></script>
<script src="/mgmt-perf/dashboard-app.js"></script>
</section>
</div>

View File

@@ -5,7 +5,7 @@
<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>
<script src="/mgmt-perf/chart.umd.min.js"></script>
</head>
<body>
<div class="mgmt-perf-embed">