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:
@@ -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 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
16
server.js
16
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", {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user