feat(mgmt-perf): 대시보드 연도·분기 조회(스냅샷별 로드)

- getPayloadRowForPeriod, listDistinctPeriods
- /dashboard/business-performance?year=&quarter= 및 상단 GET 폼
- 해당 기간 업로드 없을 때 샘플+안내

Made-with: Cursor
This commit is contained in:
2026-04-13 20:05:35 +09:00
parent dc7fca414e
commit 3872c32a91
4 changed files with 227 additions and 8 deletions

View File

@@ -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 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)

View File

@@ -142,6 +142,100 @@ async function getLatestPayloadRow(pgPool) {
}
}
/**
* DB에 저장된 연도·분기별 최신 스냅샷 1건. 해당 기간 업로드가 없으면 기본 페이로드와 `_noSnapshotForPeriod`.
* @param {import("pg").Pool | null} pgPool
* @param {number} fiscalYear
* @param {number} quarter 14
*/
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,

View File

@@ -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(/</g, "\\u003c");
const y = row.fiscal_year != null ? row.fiscal_year : viewYear;
const q = row.quarter != null ? row.quarter : viewQuarter;
const payloadJson = JSON.stringify(row.payload).replace(/</g, "\\u003c");
const dashboardTitle = `${y} Q${q} 경영성과 대시보드`;
const quarterLabel = `Q${q}`;
const yearOptions = mgmtPerfBuildYearOptions(periods, viewYear);
res.render("dashboard-business-performance", {
activeMenu: "dashboard",
adminMode: res.locals.adminMode,
opsUserEmail: !!res.locals.opsUserEmail,
defaultYear: y,
selectedQuarter: q,
defaultYear: viewYear,
selectedQuarter: viewQuarter,
viewYear,
viewQuarter,
yearOptions,
noSnapshotForPeriod: !!row._noSnapshotForPeriod,
uploadHistory,
payloadJson,
dashboardTitle,
@@ -1343,7 +1382,17 @@ pageRouter.get("/dashboard/business-performance", requireDashboardAccess, async
});
pageRouter.get("/dashboard/business-performance/embed", requireDashboardAccess, async (req, res, next) => {
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(/</g, "\\u003c");

View File

@@ -139,6 +139,55 @@
opacity: 0.5;
cursor: not-allowed;
}
.mgmt-dash-period-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
margin-bottom: 12px;
padding: 12px 14px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 10px;
font-size: 13px;
}
.mgmt-dash-period-toolbar label {
display: flex;
flex-direction: column;
gap: 4px;
font-weight: 500;
color: #374151;
}
.mgmt-dash-period-toolbar select {
min-width: 110px;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid #d1d5db;
background: #fff;
font-size: 14px;
}
.btn-mgmt-period {
padding: 8px 16px;
border-radius: 8px;
border: none;
background: #1d4ed8;
color: #fff;
font-weight: 600;
cursor: pointer;
font-size: 14px;
}
.btn-mgmt-period:hover {
background: #1e40af;
}
.mgmt-dash-empty-hint {
margin: 0 0 12px;
padding: 10px 12px;
font-size: 13px;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
}
</style>
</head>
<body>
@@ -157,6 +206,31 @@
<div class="mgmt-perf-split">
<section class="mgmt-dash-panel" aria-labelledby="mgmt-dash-heading">
<h2 id="mgmt-dash-heading">대시보드 조회</h2>
<form class="mgmt-dash-period-toolbar" method="get" action="/dashboard/business-performance">
<label>
연도
<select name="year" aria-label="조회 연도">
<% var _yopts = typeof yearOptions !== 'undefined' && yearOptions && yearOptions.length ? yearOptions : [typeof viewYear !== 'undefined' ? viewYear : 2026]; %>
<% _yopts.forEach(function (yy) { %>
<option value="<%= yy %>" <%= (typeof viewYear !== 'undefined' && Number(viewYear) === Number(yy)) ? 'selected' : '' %>><%= yy %>년</option>
<% }); %>
</select>
</label>
<label>
분기
<select name="quarter" aria-label="조회 분기">
<% [1, 2, 3, 4].forEach(function (qn) { %>
<option value="<%= qn %>" <%= (typeof viewQuarter !== 'undefined' && Number(viewQuarter) === qn) ? 'selected' : '' %>><%= qn %>분기 (Q<%= qn %>)</option>
<% }); %>
</select>
</label>
<button type="submit" class="btn-mgmt-period">조회</button>
</form>
<% if (typeof noSnapshotForPeriod !== 'undefined' && noSnapshotForPeriod) { %>
<p class="mgmt-dash-empty-hint" role="status">
선택한 연도·분기에 저장된 업로드가 없어 기본 샘플 데이터를 표시합니다. 해당 기간 엑셀을 업로드하면 여기에 반영됩니다.
</p>
<% } %>
<div class="mgmt-dash-inline-wrap">
<div class="mgmt-perf-embed" id="mgmtPerfDashRoot">
<%- include('partials/mgmt_perf_dashboard_container', {