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', + }) %> +
+
+ + + +
+

엑셀 업로드

- 매출 집계 엑셀(매출일보 시트 포함)을 업로드하면 스냅샷이 저장되고, 아래 대시보드에 반영됩니다. + 매출 집계 엑셀(매출일보 시트 포함)을 업로드하면 스냅샷이 저장되고, 대시보드에 반영됩니다.