feat: 경영성과 대시보드 DB·엑셀 업로드·HTML 차트 연동

- mgmt_perf_uploads / mgmt_perf_snapshots 스키마
- POST /api/mgmt-perf/upload, 기본 페이로드 data/mgmt-perf-default-payload.json
- 대시보드 페이지: 업로드 영역 + iframe embed
- public/mgmt-perf: 원본 HTML 기반 CSS·dashboard-app.js
- xlsx 미설치 시 기본 페이로드+메타만 저장

Made-with: Cursor
This commit is contained in:
2026-04-13 13:21:31 +09:00
parent 485bd31798
commit fdcf1e0528
12 changed files with 3268 additions and 17 deletions

View File

@@ -26,6 +26,7 @@ const {
isOpsStateSuper,
} = require("./lib/ops-state");
const { fetchOpenGraphImageUrl } = require("./lib/link-preview");
const mgmtPerf = require("./lib/mgmt-perf");
const app = express();
const PORT = process.env.PORT || 8030;
@@ -535,6 +536,31 @@ const uploadMeetingAudio = multer({
},
});
const MGMT_PERF_UPLOAD_DIR = path.join(ROOT_DIR, "uploads", "mgmt-perf");
const mgmtPerfStorage = multer.diskStorage({
destination: (_, __, cb) => {
fsSync.mkdirSync(MGMT_PERF_UPLOAD_DIR, { recursive: true });
cb(null, MGMT_PERF_UPLOAD_DIR);
},
filename: (_, file, cb) => {
const ext = (path.extname(file.originalname) || ".xlsx").toLowerCase();
const safeExt = ext === ".xlsx" ? ".xlsx" : ".xlsx";
cb(null, `${Date.now()}-${uuidv4()}${safeExt}`);
},
});
const uploadMgmtPerfExcel = multer({
storage: mgmtPerfStorage,
limits: { fileSize: 55 * 1024 * 1024 },
fileFilter: (_, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (ext !== ".xlsx") {
cb(new Error("Excel (.xlsx) 파일만 업로드할 수 있습니다."));
return;
}
cb(null, true);
},
});
const mapRowToAxAssignment = (row) => ({
id: row.id,
department: row.department || "",
@@ -1130,6 +1156,30 @@ app.use("/resources/ax-apply", express.static(RESOURCES_AX_APPLY_DIR));
app.use(opsAuth.middleware);
opsAuth.registerRoutes(app);
app.post("/api/mgmt-perf/upload", uploadMgmtPerfExcel.single("file"), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: "파일이 없습니다." });
const fiscalYear = Math.min(2100, Math.max(2020, parseInt(req.body.fiscalYear, 10) || new Date().getFullYear()));
const quarter = Math.min(4, Math.max(1, parseInt(req.body.quarter, 10) || 1));
const email = res.locals.opsUserEmail ? String(res.locals.opsUserEmail).trim() : null;
const defaultPayload = mgmtPerf.loadDefaultPayload();
const buf = fsSync.readFileSync(req.file.path);
const payload = mgmtPerf.buildPayloadFromWorkbook(buf, defaultPayload);
await mgmtPerf.saveUploadAndSnapshot(pgPool, {
userEmail: email,
originalFilename: req.file.originalname,
filePath: req.file.path,
fiscalYear,
quarter,
payload,
});
res.json({ ok: true, message: "저장되었습니다. 아래 대시보드가 갱신되었습니다." });
} catch (err) {
console.error("mgmt-perf upload:", err);
res.status(500).json({ error: err.message || "처리 실패" });
}
});
const pageRouter = express.Router();
pageRouter.get("/chat", (req, res) =>
res.render("chat", {
@@ -1174,13 +1224,41 @@ pageRouter.get("/dashboard", (req, res) =>
opsUserEmail: !!res.locals.opsUserEmail,
})
);
pageRouter.get("/dashboard/business-performance", (req, res) =>
res.render("dashboard-business-performance", {
activeMenu: "dashboard",
adminMode: res.locals.adminMode,
opsUserEmail: !!res.locals.opsUserEmail,
})
);
pageRouter.get("/dashboard/business-performance", async (req, res, next) => {
try {
const latest = await mgmtPerf.getLatestPayloadRow(pgPool);
const uploadHistory = await mgmtPerf.listUploads(pgPool, 12);
const y = latest.fiscal_year || new Date().getFullYear();
const q = latest.quarter || 1;
res.render("dashboard-business-performance", {
activeMenu: "dashboard",
adminMode: res.locals.adminMode,
opsUserEmail: !!res.locals.opsUserEmail,
defaultYear: y,
selectedQuarter: q,
uploadHistory,
});
} catch (err) {
next(err);
}
});
pageRouter.get("/dashboard/business-performance/embed", async (req, res, next) => {
try {
const 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");
const dashboardTitle = `${fy} Q${q} 경영성과 대시보드`;
const quarterLabel = `Q${q}`;
res.render("mgmt_perf_embed", {
dashboardTitle,
quarterLabel,
payloadJson,
});
} catch (err) {
next(err);
}
});
const AI_SUCCESS_ADMIN_LIST_PAGE_SIZE = 5;
pageRouter.get("/ai-cases/write", (req, res) => {