From 90358f05a72d6c1e4d3d23bee9b8c0533d4fd87c Mon Sep 17 00:00:00 2001
From: dsyoon
Date: Mon, 13 Apr 2026 19:43:57 +0900
Subject: [PATCH] =?UTF-8?q?feat(mgmt-perf):=20=EC=97=85=EB=A1=9C=EB=93=9C?=
=?UTF-8?q?=20=EC=98=81=EC=97=AD=20=ED=95=98=EB=8B=A8=20=EB=B0=B0=EC=B9=98?=
=?UTF-8?q?,=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=82=AD=EC=A0=9C=20API,=20?=
=?UTF-8?q?=EC=95=B1=20=EB=82=B4=20=EB=B0=9D=EC=9D=80=20=EB=B0=B0=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 대시보드 조회를 위·엑셀 업로드를 아래로 재배치
- DELETE /api/mgmt-perf/upload/:id 및 최근 업로드 행 삭제 버튼
- dashboard.css 전역 body 어두운 배경을 body.mgmt-perf-standalone로 한정, 임베드는 투명
- mgmt_perf_embed에 standalone 클래스 유지
Made-with: Cursor
---
README.md | 2 +-
lib/mgmt-perf.js | 37 +++++++
public/mgmt-perf/dashboard.css | 13 ++-
server.js | 16 +++-
views/dashboard-business-performance.ejs | 117 +++++++++++++++++++----
views/mgmt_perf_embed.ejs | 2 +-
6 files changed, 163 insertions(+), 24 deletions(-)
diff --git a/README.md b/README.md
index dcbe5ed..7602814 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`는 직접 열람용으로 유지). Express에서 **`/mgmt-perf/*` → `public/mgmt-perf/`** 정적 제공이 등록되어 있어 `dashboard-app.js`·`chart.umd.min.js`(CDN 대신 동봉)가 항상 같은 오리진에서 로드됩니다. 업로드 시 **한글 파일명**은 multer 기본(`defParamCharset` 생략 시 latin1)으로 온 `originalname`을 **`lib/decode-upload-filename.js`**의 `decodeUploadFilename`으로 보정합니다(`decodeURIComponent(escape(...))` 우선, 이어서 `Buffer` latin1→utf8). Busboy에 `defParamCharset: 'utf8'`를 켜면 이중 디코딩으로 깨질 수 있어 두지 않습니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시 사용 시 업로드 실패하면 **`client_max_body_size`**(예: 64m)와 **`/api/`·`/mgmt-perf/` → Node** 전달 여부를 확인. 엑셀 집계 치환은 `npm install`로 `xlsx` 설치 후 서버 재시작.
+- **경영성과 대시보드** (`/dashboard/business-performance`): **위쪽 대시보드 조회**(Chart.js 인라인), **아래 엑셀 업로드**(`.xlsx`, 매출일보 시트) 순서. 업로드는 DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 최근 업로드 행 **`DELETE /api/mgmt-perf/upload/:id`** 로 삭제(PG는 CASCADE, 파일 전용 모드는 `id=file`). 단독 임베드 페이지는 `/dashboard/business-performance/embed`(본문에 `body.mgmt-perf-standalone`으로 어두운 배경). Express에서 **`/mgmt-perf/*` → `public/mgmt-perf/`** 정적 제공이 등록되어 있어 `dashboard-app.js`·`chart.umd.min.js`(CDN 대신 동봉)가 항상 같은 오리진에서 로드됩니다. 업로드 시 **한글 파일명**은 multer 기본(`defParamCharset` 생략 시 latin1)으로 온 `originalname`을 **`lib/decode-upload-filename.js`**의 `decodeUploadFilename`으로 보정합니다(`decodeURIComponent(escape(...))` 우선, 이어서 `Buffer` latin1→utf8). Busboy에 `defParamCharset: 'utf8'`를 켜면 이중 디코딩으로 깨질 수 있어 두지 않습니다. 탭 전환·차트 렌더는 **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 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)
diff --git a/lib/mgmt-perf.js b/lib/mgmt-perf.js
index 10bd269..8f77340 100644
--- a/lib/mgmt-perf.js
+++ b/lib/mgmt-perf.js
@@ -174,11 +174,48 @@ async function listUploads(pgPool, limit = 20) {
return r.rows;
}
+/**
+ * 업로드 행 삭제(PG: 스냅샷은 ON DELETE CASCADE). 디스크의 업로드 파일도 제거합니다.
+ * 파일 전용 모드: `id === "file"` 일 때 스냅샷 JSON만 삭제합니다.
+ * @param {import("pg").Pool | null} pgPool
+ * @param {string} uploadId UUID 또는 `"file"`
+ */
+async function deleteUpload(pgPool, uploadId) {
+ const id = String(uploadId || "").trim();
+ if (!id) return { ok: false, error: "id가 없습니다." };
+
+ if (!pgPool) {
+ if (id !== "file") return { ok: false, error: "지원하지 않는 항목입니다." };
+ try {
+ if (fs.existsSync(FILE_STATE_PATH)) fs.unlinkSync(FILE_STATE_PATH);
+ } catch (e) {
+ return { ok: false, error: e.message || "삭제 실패" };
+ }
+ return { ok: true, fileBacked: true };
+ }
+
+ const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+ if (!uuidRe.test(id)) return { ok: false, error: "잘못된 id입니다." };
+
+ const r = await pgPool.query(`DELETE FROM mgmt_perf_uploads WHERE id = $1::uuid RETURNING file_path`, [id]);
+ if (r.rowCount === 0) return { ok: false, error: "항목을 찾을 수 없습니다." };
+ const fp = r.rows[0].file_path;
+ if (fp && fs.existsSync(fp)) {
+ try {
+ fs.unlinkSync(fp);
+ } catch (_) {
+ /* 로그만; DB는 이미 삭제됨 */
+ }
+ }
+ return { ok: true };
+}
+
module.exports = {
loadDefaultPayload,
buildPayloadFromWorkbook,
saveUploadAndSnapshot,
getLatestPayloadRow,
listUploads,
+ deleteUpload,
FILE_STATE_PATH,
};
diff --git a/public/mgmt-perf/dashboard.css b/public/mgmt-perf/dashboard.css
index 8521119..f96aef7 100644
--- a/public/mgmt-perf/dashboard.css
+++ b/public/mgmt-perf/dashboard.css
@@ -18,12 +18,14 @@
--orange: #ff9800;
}
- body {
+ /* 앱 셸(/dashboard/business-performance)에서는 styles.css의 body 배경을 쓰고, 단독 임베드 페이지에만 어두운 배경 */
+ body.mgmt-perf-standalone {
font-family: '맑은 고딕', 'Apple SD Gothic Neo', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, var(--blk) 0%, var(--blk2) 100%);
color: var(--blk);
line-height: 1.6;
padding: 20px;
+ min-height: 100vh;
}
.container {
@@ -412,12 +414,17 @@
}
.mgmt-perf-embed {
font-family: '맑은 고딕', 'Apple SD Gothic Neo', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
- background: linear-gradient(135deg, var(--blk) 0%, var(--blk2) 100%);
+ background: transparent;
color: var(--blk);
line-height: 1.6;
+ padding: 0;
+ border-radius: 0;
+ overflow-x: auto;
+ }
+
+ body.mgmt-perf-standalone .mgmt-perf-embed {
padding: 16px;
border-radius: 10px;
- overflow-x: auto;
}
.mgmt-perf-embed .chart-wrapper {
diff --git a/server.js b/server.js
index 69cdc5c..ad64475 100644
--- a/server.js
+++ b/server.js
@@ -1251,7 +1251,7 @@ app.post(
quarter,
payload,
});
- res.json({ ok: true, message: "저장되었습니다. 아래 대시보드가 갱신되었습니다." });
+ res.json({ ok: true, message: "저장되었습니다. 대시보드가 갱신되었습니다." });
} catch (err) {
console.error("mgmt-perf upload:", err);
res.status(500).json({ error: err.message || "처리 실패" });
@@ -1259,6 +1259,20 @@ app.post(
}
);
+app.delete("/api/mgmt-perf/upload/:id", requireDashboardAccess, async (req, res) => {
+ try {
+ const result = await mgmtPerf.deleteUpload(pgPool, req.params.id);
+ if (!result.ok) {
+ const code = result.error === "항목을 찾을 수 없습니다." ? 404 : 400;
+ return res.status(code).json({ ok: false, error: result.error });
+ }
+ res.json({ ok: true, message: "삭제되었습니다." });
+ } catch (err) {
+ console.error("mgmt-perf delete:", err);
+ res.status(500).json({ ok: false, error: err?.message || "삭제 실패" });
+ }
+});
+
const pageRouter = express.Router();
pageRouter.get("/chat", (req, res) =>
res.render("chat", {
diff --git a/views/dashboard-business-performance.ejs b/views/dashboard-business-performance.ejs
index fd6a37c..b30f1cd 100644
--- a/views/dashboard-business-performance.ejs
+++ b/views/dashboard-business-performance.ejs
@@ -20,7 +20,8 @@
border: 1px solid var(--border, #e0e0e0);
border-radius: 10px;
padding: 20px;
- background: var(--panel-bg, #fafafa);
+ background: #ffffff;
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
.mgmt-upload-panel h2 {
font-size: 1.05rem;
@@ -86,6 +87,11 @@
border-radius: 10px;
overflow: hidden;
min-height: 400px;
+ background: #ffffff;
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+ }
+ .mgmt-perf-page .mgmt-perf-embed .container {
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.mgmt-upload-history {
margin-top: 16px;
@@ -100,11 +106,35 @@
padding: 8px;
border-bottom: 1px solid #eee;
text-align: left;
+ vertical-align: middle;
+ }
+ .mgmt-upload-history th:last-child,
+ .mgmt-upload-history td.mgmt-upload-delete-cell {
+ text-align: right;
+ width: 88px;
+ white-space: nowrap;
}
.mgmt-upload-history th {
font-weight: 600;
color: #555;
}
+ .btn-mgmt-upload-delete {
+ padding: 6px 12px;
+ font-size: 12px;
+ border-radius: 6px;
+ border: 1px solid #fecaca;
+ background: #fff;
+ color: #b91c1c;
+ cursor: pointer;
+ font-weight: 600;
+ }
+ .btn-mgmt-upload-delete:hover {
+ background: #fef2f2;
+ }
+ .btn-mgmt-upload-delete:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
@@ -123,10 +153,25 @@
+
+ 대시보드 조회
+
+
+ <%- include('partials/mgmt_perf_dashboard_container', {
+ dashboardTitle: typeof dashboardTitle !== 'undefined' ? dashboardTitle : '경영성과 대시보드',
+ quarterLabel: typeof quarterLabel !== 'undefined' ? quarterLabel : 'Q1',
+ }) %>
+
+
+
+
+
+
+
엑셀 업로드
- 매출 집계 엑셀(매출일보 시트 포함)을 업로드하면 스냅샷이 저장되고, 아래 대시보드에 반영됩니다.
+ 매출 집계 엑셀(매출일보 시트 포함)을 업로드하면 스냅샷이 저장되고, 위 대시보드에 반영됩니다.
<% if (typeof uploadHistory !== 'undefined' && uploadHistory && uploadHistory.length) { %>
-
+
최근 업로드
@@ -160,6 +205,7 @@
| 일시 |
파일명 |
연도·분기 |
+ |
@@ -168,6 +214,9 @@
<%= u.created_at ? new Date(u.created_at).toLocaleString('ko-KR') : '-' %> |
<%= u.original_filename || '-' %> |
<%= u.fiscal_year != null && u.fiscal_year !== '' ? u.fiscal_year + '년 Q' + u.quarter : '-' %> |
+
+
+ |
<% }); %>
@@ -175,21 +224,6 @@
<% } %>
-
-
- 대시보드 조회
-
-
- <%- include('partials/mgmt_perf_dashboard_container', {
- dashboardTitle: typeof dashboardTitle !== 'undefined' ? dashboardTitle : '경영성과 대시보드',
- quarterLabel: typeof quarterLabel !== 'undefined' ? quarterLabel : 'Q1',
- }) %>
-
-
-
-
-
-
@@ -247,6 +281,53 @@
});
});
})();
+
+ (function () {
+ var root = document.getElementById("mgmtUploadHistory");
+ if (!root) return;
+ root.addEventListener("click", function (e) {
+ var t = e.target;
+ if (!t || !t.closest) return;
+ var del = t.closest(".btn-mgmt-upload-delete");
+ if (!del) return;
+ var id = del.getAttribute("data-upload-id");
+ if (!id) return;
+ if (!window.confirm("이 업로드 기록을 삭제할까요?")) return;
+ del.disabled = true;
+ fetch("/api/mgmt-perf/upload/" + encodeURIComponent(id), {
+ method: "DELETE",
+ credentials: "same-origin",
+ })
+ .then(function (r) {
+ return r.text().then(function (text) {
+ var j = {};
+ if (text) {
+ try {
+ j = JSON.parse(text);
+ } catch (parseErr) {
+ j = { error: text.slice(0, 200) };
+ }
+ }
+ return { ok: r.ok, body: j, status: r.status };
+ });
+ })
+ .then(function (ref) {
+ if (ref.ok && ref.body && ref.body.ok === true) {
+ window.location.reload();
+ return;
+ }
+ var err =
+ (ref.body && (ref.body.error || ref.body.message)) || "삭제에 실패했습니다.";
+ window.alert(err);
+ })
+ .catch(function () {
+ window.alert("삭제 요청에 실패했습니다.");
+ })
+ .finally(function () {
+ del.disabled = false;
+ });
+ });
+ })();