feat(mgmt-perf): 업로드 영역 하단 배치, 업로드 삭제 API, 앱 내 밝은 배경

- 대시보드 조회를 위·엑셀 업로드를 아래로 재배치
- DELETE /api/mgmt-perf/upload/:id 및 최근 업로드 행 삭제 버튼
- dashboard.css 전역 body 어두운 배경을 body.mgmt-perf-standalone로 한정, 임베드는 투명
- mgmt_perf_embed에 standalone 클래스 유지

Made-with: Cursor
This commit is contained in:
2026-04-13 19:43:57 +09:00
parent 200632f580
commit 90358f05a7
6 changed files with 163 additions and 24 deletions

View File

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

View File

@@ -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,
};

View File

@@ -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 {

View File

@@ -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", {

View File

@@ -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;
}
</style>
</head>
<body>
@@ -123,10 +153,25 @@
</p>
<div class="mgmt-perf-split">
<section class="mgmt-dash-panel" aria-labelledby="mgmt-dash-heading">
<h2 id="mgmt-dash-heading">대시보드 조회</h2>
<div class="mgmt-dash-inline-wrap">
<div class="mgmt-perf-embed" id="mgmtPerfDashRoot">
<%- include('partials/mgmt_perf_dashboard_container', {
dashboardTitle: typeof dashboardTitle !== 'undefined' ? dashboardTitle : '경영성과 대시보드',
quarterLabel: typeof quarterLabel !== 'undefined' ? quarterLabel : 'Q1',
}) %>
</div>
</div>
<script type="application/json" id="mgmt-perf-payload-json"><%- typeof payloadJson !== 'undefined' ? payloadJson : '{}' %></script>
<script src="/mgmt-perf/chart.umd.min.js"></script>
<script src="/mgmt-perf/dashboard-app.js"></script>
</section>
<section class="mgmt-upload-panel" aria-labelledby="mgmt-upload-heading">
<h2 id="mgmt-upload-heading">엑셀 업로드</h2>
<p class="subtitle" style="margin: 0 0 12px; font-size: 14px">
매출 집계 엑셀(<strong>매출일보</strong> 시트 포함)을 업로드하면 스냅샷이 저장되고, 아래 대시보드에 반영됩니다.
매출 집계 엑셀(<strong>매출일보</strong> 시트 포함)을 업로드하면 스냅샷이 저장되고, <strong>위</strong> 대시보드에 반영됩니다.
</p>
<form id="mgmtPerfUploadForm" class="mgmt-upload-form" enctype="multipart/form-data">
<label>
@@ -152,7 +197,7 @@
</form>
<div id="mgmtPerfUploadMsg" class="mgmt-upload-msg" role="status"></div>
<% if (typeof uploadHistory !== 'undefined' && uploadHistory && uploadHistory.length) { %>
<div class="mgmt-upload-history">
<div class="mgmt-upload-history" id="mgmtUploadHistory">
<strong>최근 업로드</strong>
<table>
<thead>
@@ -160,6 +205,7 @@
<th>일시</th>
<th>파일명</th>
<th>연도·분기</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -168,6 +214,9 @@
<td><%= u.created_at ? new Date(u.created_at).toLocaleString('ko-KR') : '-' %></td>
<td><%= u.original_filename || '-' %></td>
<td><%= u.fiscal_year != null && u.fiscal_year !== '' ? u.fiscal_year + '년 Q' + u.quarter : '-' %></td>
<td class="mgmt-upload-delete-cell">
<button type="button" class="btn-mgmt-upload-delete" data-upload-id="<%= u.id != null ? String(u.id) : '' %>">삭제</button>
</td>
</tr>
<% }); %>
</tbody>
@@ -175,21 +224,6 @@
</div>
<% } %>
</section>
<section class="mgmt-dash-panel" aria-labelledby="mgmt-dash-heading">
<h2 id="mgmt-dash-heading">대시보드 조회</h2>
<div class="mgmt-dash-inline-wrap">
<div class="mgmt-perf-embed" id="mgmtPerfDashRoot">
<%- include('partials/mgmt_perf_dashboard_container', {
dashboardTitle: typeof dashboardTitle !== 'undefined' ? dashboardTitle : '경영성과 대시보드',
quarterLabel: typeof quarterLabel !== 'undefined' ? quarterLabel : 'Q1',
}) %>
</div>
</div>
<script type="application/json" id="mgmt-perf-payload-json"><%- typeof payloadJson !== 'undefined' ? payloadJson : '{}' %></script>
<script src="/mgmt-perf/chart.umd.min.js"></script>
<script src="/mgmt-perf/dashboard-app.js"></script>
</section>
</div>
</main>
</div>
@@ -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;
});
});
})();
</script>
</body>
</html>

View File

@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/mgmt-perf/dashboard.css" />
<script src="/mgmt-perf/chart.umd.min.js"></script>
</head>
<body>
<body class="mgmt-perf-standalone">
<div class="mgmt-perf-embed">
<%- include('partials/mgmt_perf_dashboard_container', { dashboardTitle, quarterLabel }) %>
</div>