diff --git a/README.md b/README.md index 3a38479..e6e32d3 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`): **위쪽 대시보드 조회**(Chart.js 인라인), **아래 엑셀 업로드**(`.xlsx`, 매출일보 시트) 순서. 본문은 AI 프롬프트 페이지와 동일하게 **`main.container.container-ai-full`**(전체 너비·좌우 24px)로 맞춤. `public/mgmt-perf/dashboard.css`에 있던 **범용 `.container { background: white }`** 는 앱 페이지에서 `main.container`까지 적용되어 회색 본문이 가려졌으므로 제거하고, 흰 카드는 **`.mgmt-perf-embed .container`** 만 사용합니다. 업로드는 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` 설치 후 서버 재시작. +- **경영성과 대시보드** (`/dashboard/business-performance`): **위쪽 대시보드 조회**(Chart.js 인라인), **아래 엑셀 업로드**(`.xlsx`, 매출일보 시트) 순서. 본문은 AI 프롬프트 페이지와 동일하게 **`main.container.container-ai-full`**(전체 너비·좌우 24px)로 맞춤. 대시보드 상단 **연도·분기**로 `mgmt_perf_uploads`에 저장된 해당 기간 **최신 스냅샷**을 불러오며, 쿼리 **`?year=2026&quarter=1`** 또는 폼 조회와 동일. 해당 기간 업로드가 없으면 기본 JSON 샘플을 쓰고 안내 문구를 표시합니다. `public/mgmt-perf/dashboard.css`에 있던 **범용 `.container { background: white }`** 는 앱 페이지에서 `main.container`까지 적용되어 회색 본문이 가려졌으므로 제거하고, 흰 카드는 **`.mgmt-perf-embed .container`** 만 사용합니다. 업로드는 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 8f77340..4d1601e 100644 --- a/lib/mgmt-perf.js +++ b/lib/mgmt-perf.js @@ -142,6 +142,100 @@ async function getLatestPayloadRow(pgPool) { } } +/** + * DB에 저장된 연도·분기별 최신 스냅샷 1건. 해당 기간 업로드가 없으면 기본 페이로드와 `_noSnapshotForPeriod`. + * @param {import("pg").Pool | null} pgPool + * @param {number} fiscalYear + * @param {number} quarter 1–4 + */ +async function getPayloadRowForPeriod(pgPool, fiscalYear, quarter) { + const y = Math.min(2100, Math.max(2020, Math.floor(Number(fiscalYear)) || new Date().getFullYear())); + const q = Math.min(4, Math.max(1, Math.floor(Number(quarter)) || 1)); + + if (pgPool) { + const r = await pgPool.query( + ` + SELECT s.payload, u.fiscal_year, u.quarter, u.original_filename, u.created_at + FROM mgmt_perf_snapshots s + JOIN mgmt_perf_uploads u ON u.id = s.upload_id + WHERE u.fiscal_year = $1 AND u.quarter = $2 + ORDER BY u.created_at DESC + LIMIT 1 + `, + [y, q] + ); + if (r.rows[0]) { + const row = r.rows[0]; + return { + payload: row.payload, + fiscal_year: row.fiscal_year, + quarter: row.quarter, + original_filename: row.original_filename, + created_at: row.created_at, + _noSnapshotForPeriod: false, + }; + } + return { + payload: loadDefaultPayload(), + fiscal_year: y, + quarter: q, + original_filename: null, + created_at: null, + _noSnapshotForPeriod: true, + }; + } + + try { + const j = JSON.parse(fs.readFileSync(FILE_STATE_PATH, "utf8")); + const my = j.meta?.fiscalYear; + const mq = j.meta?.quarter; + if (my === y && mq === q) { + return { + payload: j.payload, + fiscal_year: y, + quarter: q, + original_filename: j.meta?.originalFilename, + created_at: j.meta?.savedAt ? new Date(j.meta.savedAt) : null, + _noSnapshotForPeriod: false, + }; + } + } catch (_) { + /* no file */ + } + return { + payload: loadDefaultPayload(), + fiscal_year: y, + quarter: q, + original_filename: null, + created_at: null, + _noSnapshotForPeriod: true, + }; +} + +/** + * 업로드가 존재하는 (연도, 분기) 목록 — 조회 셀렉트 옵션용 + * @param {import("pg").Pool | null} pgPool + */ +async function listDistinctPeriods(pgPool) { + if (!pgPool) { + try { + const j = JSON.parse(fs.readFileSync(FILE_STATE_PATH, "utf8")); + if (j.meta?.fiscalYear != null && j.meta?.quarter != null) { + return [{ fiscal_year: j.meta.fiscalYear, quarter: j.meta.quarter }]; + } + } catch (_) { + /* empty */ + } + return []; + } + const r = await pgPool.query(` + SELECT DISTINCT fiscal_year, quarter + FROM mgmt_perf_uploads + ORDER BY fiscal_year DESC, quarter DESC + `); + return r.rows; +} + /** * @param {import("pg").Pool | null} pgPool * @param {number} [limit=20] @@ -215,6 +309,8 @@ module.exports = { buildPayloadFromWorkbook, saveUploadAndSnapshot, getLatestPayloadRow, + getPayloadRowForPeriod, + listDistinctPeriods, listUploads, deleteUpload, FILE_STATE_PATH, diff --git a/server.js b/server.js index ad64475..f606fef 100644 --- a/server.js +++ b/server.js @@ -1317,21 +1317,60 @@ pageRouter.get("/dashboard", requireDashboardAccess, (req, res) => opsUserEmail: !!res.locals.opsUserEmail, }) ); +function mgmtPerfBuildYearOptions(periods, viewYear) { + const set = new Set(); + (periods || []).forEach((p) => { + if (p.fiscal_year != null) set.add(Number(p.fiscal_year)); + }); + const y = Number(viewYear) || new Date().getFullYear(); + set.add(y); + const cy = new Date().getFullYear(); + for (let i = cy - 3; i <= cy + 2; i++) set.add(i); + return Array.from(set) + .filter((n) => n >= 2020 && n <= 2100) + .sort((a, b) => b - a); +} + pageRouter.get("/dashboard/business-performance", requireDashboardAccess, async (req, res, next) => { try { - const latest = await mgmtPerf.getLatestPayloadRow(pgPool); + const qy = parseInt(req.query.year, 10); + const qq = parseInt(req.query.quarter, 10); + const hasPeriodQuery = + Number.isFinite(qy) && qy >= 2020 && qy <= 2100 && Number.isFinite(qq) && qq >= 1 && qq <= 4; + + const periods = await mgmtPerf.listDistinctPeriods(pgPool); + let row; + let viewYear; + let viewQuarter; + + if (hasPeriodQuery) { + viewYear = qy; + viewQuarter = qq; + row = await mgmtPerf.getPayloadRowForPeriod(pgPool, viewYear, viewQuarter); + } else { + const latest = await mgmtPerf.getLatestPayloadRow(pgPool); + viewYear = latest.fiscal_year || new Date().getFullYear(); + viewQuarter = latest.quarter || 1; + row = { ...latest, _noSnapshotForPeriod: false }; + } + const uploadHistory = await mgmtPerf.listUploads(pgPool, 12); - const y = latest.fiscal_year || new Date().getFullYear(); - const q = latest.quarter || 1; - const payloadJson = JSON.stringify(latest.payload).replace(/ { try { - const row = await mgmtPerf.getLatestPayloadRow(pgPool); + const qy = parseInt(req.query.year, 10); + const qq = parseInt(req.query.quarter, 10); + const hasPeriodQuery = + Number.isFinite(qy) && qy >= 2020 && qy <= 2100 && Number.isFinite(qq) && qq >= 1 && qq <= 4; + + let row; + if (hasPeriodQuery) { + row = await mgmtPerf.getPayloadRowForPeriod(pgPool, qy, qq); + } else { + row = await mgmtPerf.getLatestPayloadRow(pgPool); + } const fy = row.fiscal_year || new Date().getFullYear(); const q = row.quarter || 1; const payloadJson = JSON.stringify(row.payload).replace(/
@@ -157,6 +206,31 @@+ 선택한 연도·분기에 저장된 업로드가 없어 기본 샘플 데이터를 표시합니다. 해당 기간 엑셀을 업로드하면 여기에 반영됩니다. +
+ <% } %>