feat(mgmt-perf): 대시보드 연도·분기 조회(스냅샷별 로드)
- getPayloadRowForPeriod, listDistinctPeriods - /dashboard/business-performance?year=&quarter= 및 상단 GET 폼 - 해당 기간 업로드 없을 때 샘플+안내 Made-with: Cursor
This commit is contained in:
@@ -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 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)
|
||||
|
||||
@@ -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,
|
||||
|
||||
63
server.js
63
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(/</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");
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user