refactor(stmate-test): 끼워 넣기 구조 — scr_*.js 화면·메뉴 자동 등록 · api_*.py 라우터 자동 연결 · app.js 700줄 아래로

- STMATE_PLUGINS(screens · actions · menus) · STMATE_APP(ctx 와 화면 도우미) — app.js 가 등록된 화면을 그리고 메뉴 단추를 붙임
- /plugins_manifest.js 가 scr_*.js 를 불러옴 · server.py 가 api_*.py 의 router 를 연결
- 도구 창 바인딩을 app_modals.js 로 분리(app.js 718 → 613줄)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 18:14:42 +09:00
co-authored by Claude Opus 5.5
parent 78d5e9cace
commit 123727e725
4 changed files with 210 additions and 144 deletions
+37 -144
View File
@@ -49,6 +49,24 @@
const recompute = () => (ctx.computed = Calc.computeAll(ctx.state));
// 끼워 넣기 — scr_*.js 가 STMATE_PLUGINS 에 화면·메뉴 동작을 등록(plugins_manifest.js 가 불러옴)
const PLUG = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
screens: {},
actions: {},
menus: [],
});
const API = (global.STMATE_APP = {
ctx,
Calc,
fmt,
num,
$,
onClick,
onChange,
bindEach,
closeModal,
});
function renderChromeBars() {
const prjBar = $("prj-context-bar"),
stBar = $("stmate-statusbar");
@@ -85,8 +103,12 @@
renderChromeBars();
const ws = $("mdi-workspace");
if (!ws) return;
ws.innerHTML = (RENDERERS[ctx.uiState.activeScreen] || ViewPrjBoq.renderWPrjList)(ctx);
const scr = PLUG.screens[ctx.uiState.activeScreen];
ws.innerHTML = scr
? scr.render(ctx, API)
: (RENDERERS[ctx.uiState.activeScreen] || ViewPrjBoq.renderWPrjList)(ctx);
bindWorkspaceEvents();
if (scr && scr.bind) scr.bind(ctx, API);
}
async function postJson(url, body) {
@@ -154,149 +176,9 @@
bindModalEvents();
}
function bindModalEvents() {
onClick("modal-apply-itnum", () => {
if (!ctx.dbfData || !ctx.dbfData.boq_rows) return;
let t4 = 0,
t2 = 0,
lf = 0;
const fmtVal = $("itnum-leaf-fmt").value;
ctx.dbfData.boq_rows.forEach((r) => {
const bd = String(r.BDCODE1 || "").trim();
if (bd === "T4") ((t4 += 1), (t2 = 0), (r.ITNUM = `${t4}.`));
else if (bd === "T2") ((t2 += 1), (lf = 0), (r.ITNUM = `${t4}.${t2}`));
else if (bd && !/^(T\d|E\d|C\d|:)$/.test(bd)) {
lf += 1;
r.ITNUM = fmtVal === "01" ? String(lf).padStart(2, "0") : fmtVal === "-1" ? `-${lf}` : "";
}
});
closeModal();
renderWorkspace();
});
onClick("modal-apply-boqrate", () => {
const [rm, rl, rs] = ["m", "l", "s"].map((k) => num($(`boqrate-${k}`).value, 100) / 100);
ctx.state.resources.forEach((r) => {
const mult = r.kind === "재료비" ? rm : r.kind === "노무비" ? rl : rs;
r.manualPrice = Math.floor(num(r.manualPrice) * mult);
});
closeModal();
renderWorkspace();
});
onClick("modal-apply-mrate", () => {
ctx.state.sysConfig.ecdDigits = parseInt($("mrate-digits").value, 10) || 0;
closeModal();
renderWorkspace();
});
onClick("modal-apply-expnd", () => {
const code = ($("expnd-code") || {}).value;
const ilwi =
(ctx.state.ilwiItems || []).find((x) => x.code === code) || ctx.state.ilwiItems[0];
((ilwi && ilwi.lines) || []).forEach((ln) => {
const r = ctx.state.resources.find((x) => x.id === ln.refId || x.code === ln.refId);
insertBoqRow(
r ? r.code : ln.refId,
`[전개] ${r ? r.name : ln.refId}`,
r ? r.spec : "",
r ? r.unit : "식",
ln.qty || 1,
);
});
closeModal();
renderWorkspace();
});
bindEach(".btn-apply-qty-expr", "onclick", (btn) => {
const id = btn.getAttribute("data-id");
const inp = document.querySelector(`.qty-expr-input[data-id="${id}"]`);
const val = inp ? Calc.evalFormula(inp.value, {}) : 0;
const item = ctx.state.boqItems.find((b) => b.id === id);
if (item && val > 0) {
item.qty = Math.round(val * 100) / 100;
const drow =
ctx.dbfData &&
ctx.dbfData.boq_rows &&
ctx.dbfData.boq_rows.find((x) => x._recno === item._recno);
if (drow) drow.QTY1 = String(item.qty);
renderWorkspace();
}
});
onClick("modal-apply-boxa", () => {
const mode = $("boxa-adopt-mode").value;
(ctx.dbfData ? ctx.dbfData.costn_rows : []).forEach((r) => {
const code = String(r.CODE || "").trim();
if (!code.startsWith("M")) return;
const nonZero = [
num(r.AMT_11),
num(r.AMT_21),
num(r.AMT_31),
num(r.AMT_41),
num(r.AMT_51),
].filter((x) => x > 0);
if (nonZero.length) {
const picked =
mode === "min" ? Math.min(...nonZero) : nonZero[parseInt(mode, 10)] || nonZero[0];
ViewPrjBoq.syncCostnUnitPrice(ctx, code, picked, 0, 0);
}
});
closeModal();
cascadeAndRender();
});
onClick("modal-apply-edthal", () => {
const kind = $("edthal-kind").value,
pct = num($("edthal-pct").value, 100) / 100,
rmode = $("edthal-round").value;
(ctx.dbfData ? ctx.dbfData.costn_rows : []).forEach((r) => {
const code = String(r.CODE || "").trim();
if (!code || r.UC === "T" || (kind !== "ALL" && code[0].toUpperCase() !== kind)) return;
const fn = (v) => (rmode === "round" ? Math.round(v * pct) : Math.floor(v * pct));
ViewPrjBoq.syncCostnUnitPrice(
ctx,
code,
fn(num(r.MAMT1)),
fn(num(r.LAMT1)),
fn(num(r.SAMT1)),
);
});
closeModal();
cascadeAndRender();
});
bindEach(".btn-use-special-san", "onclick", (btn) => {
const spec = (ctx.catalogData.special_san || [])[parseInt(btn.getAttribute("data-idx"), 10)];
if (!spec) return;
ctx.uiState.editedSmtMap[ctx.uiState.selectedIlwiCode || "D00016"] = (
spec["산식행"] || []
).join("\r\n");
ctx.uiState.activeScreen = "wM_Edit_iLWi";
closeModal();
renderWorkspace();
});
}
function insertBoqRow(code, name, spec, unit, qty) {
const recno = Date.now() % 100000;
ctx.state.boqItems.push({
id: "BOQ_" + recno,
code,
name,
refId: code,
rowType: "normal",
qty,
changedQty: qty,
_recno: recno,
});
if (ctx.dbfData && ctx.dbfData.boq_rows) {
ctx.dbfData.boq_rows.splice(4, 0, {
_recno: recno,
ITNUM: "",
BDCODE1: code,
NAME1: name,
SIZE1: spec,
UNIT1: unit,
QTY1: String(qty),
ENDCODE: "0.0000",
});
}
renderWorkspace();
}
// 도구 창 바인딩·내역 행 끼워 넣기 — app_modals.js
const bindModalEvents = () => global.STMATE_APP_MODALS.bindModalEvents(API);
const insertBoqRow = (...args) => global.STMATE_APP_MODALS.insertBoqRow(API, ...args);
function bindWorkspaceEvents() {
const goScreen = (scr) => {
@@ -650,6 +532,9 @@
onClick(id, () => openMappedModal(key));
}
Object.assign(API, { renderWorkspace, recompute, openModal, postJson, downloadBlob });
Object.assign(API, { cascadeAndRender, loadProjectFromServer, insertBoqRow });
async function initStmateApp() {
bindEach("#main-toolbar .tb-btn[data-screen]", "onclick", (btn) => {
ctx.uiState.activeScreen = btn.getAttribute("data-screen");
@@ -657,8 +542,16 @@
});
onClick("btn-calc-all", saveAllToDbfServer);
onClick("modal-close-btn", closeModal);
// 끼워 넣은 메뉴 단추 — m.menu = 상단 메뉴 data-menu(m1~m5)
PLUG.menus.forEach((m) => {
const head = document.querySelector(`#main-menubar [data-menu="${m.menu}"]`);
const box = head && head.querySelector(".menu-dropdown");
if (box && !box.querySelector(`[data-act="${m.act}"]`))
box.insertAdjacentHTML("beforeend", `<button data-act="${m.act}">${m.label}</button>`);
});
bindEach("#main-menubar [data-act]", "onclick", (btn) => {
const act = btn.getAttribute("data-act");
if (PLUG.actions[act]) return PLUG.actions[act](ctx, API, btn);
if (MENU_SCREEN_MAP[act]) ctx.uiState.activeScreen = MENU_SCREEN_MAP[act];
else if (MENU_MODAL_MAP[act]) return openMappedModal(act);
else if (act === "Act_ReloadDbf" && ctx.currentSourcePath)
+159
View File
@@ -0,0 +1,159 @@
/**
* STMATE 도구 창(wITNum · wBoqRate · wM_Rate · wExpnd · wQtyList · wM_Boxa · wM_Edt_Hal) 바인딩 (app_modals.js)
* app.js 에서 떼어 냄 — api = window.STMATE_APP
*/
(function (global) {
"use strict";
const ViewPrjBoq = () => global.STMATE_VIEW_PRJ_BOQ;
function bindModalEvents(api) {
const { ctx, Calc, num, $, onClick, bindEach, closeModal, renderWorkspace, cascadeAndRender } =
api;
const insertBoqRow = (...a) => api.insertBoqRow(...a);
onClick("modal-apply-itnum", () => {
if (!ctx.dbfData || !ctx.dbfData.boq_rows) return;
let t4 = 0,
t2 = 0,
lf = 0;
const fmtVal = $("itnum-leaf-fmt").value;
ctx.dbfData.boq_rows.forEach((r) => {
const bd = String(r.BDCODE1 || "").trim();
if (bd === "T4") ((t4 += 1), (t2 = 0), (r.ITNUM = `${t4}.`));
else if (bd === "T2") ((t2 += 1), (lf = 0), (r.ITNUM = `${t4}.${t2}`));
else if (bd && !/^(T\d|E\d|C\d|:)$/.test(bd)) {
lf += 1;
r.ITNUM = fmtVal === "01" ? String(lf).padStart(2, "0") : fmtVal === "-1" ? `-${lf}` : "";
}
});
closeModal();
renderWorkspace();
});
onClick("modal-apply-boqrate", () => {
const [rm, rl, rs] = ["m", "l", "s"].map((k) => num($(`boqrate-${k}`).value, 100) / 100);
ctx.state.resources.forEach((r) => {
const mult = r.kind === "재료비" ? rm : r.kind === "노무비" ? rl : rs;
r.manualPrice = Math.floor(num(r.manualPrice) * mult);
});
closeModal();
renderWorkspace();
});
onClick("modal-apply-mrate", () => {
ctx.state.sysConfig.ecdDigits = parseInt($("mrate-digits").value, 10) || 0;
closeModal();
renderWorkspace();
});
onClick("modal-apply-expnd", () => {
const code = ($("expnd-code") || {}).value;
const ilwi =
(ctx.state.ilwiItems || []).find((x) => x.code === code) || ctx.state.ilwiItems[0];
((ilwi && ilwi.lines) || []).forEach((ln) => {
const r = ctx.state.resources.find((x) => x.id === ln.refId || x.code === ln.refId);
insertBoqRow(
r ? r.code : ln.refId,
`[전개] ${r ? r.name : ln.refId}`,
r ? r.spec : "",
r ? r.unit : "식",
ln.qty || 1,
);
});
closeModal();
renderWorkspace();
});
bindEach(".btn-apply-qty-expr", "onclick", (btn) => {
const id = btn.getAttribute("data-id");
const inp = document.querySelector(`.qty-expr-input[data-id="${id}"]`);
const val = inp ? Calc.evalFormula(inp.value, {}) : 0;
const item = ctx.state.boqItems.find((b) => b.id === id);
if (item && val > 0) {
item.qty = Math.round(val * 100) / 100;
const drow =
ctx.dbfData &&
ctx.dbfData.boq_rows &&
ctx.dbfData.boq_rows.find((x) => x._recno === item._recno);
if (drow) drow.QTY1 = String(item.qty);
renderWorkspace();
}
});
onClick("modal-apply-boxa", () => {
const mode = $("boxa-adopt-mode").value;
(ctx.dbfData ? ctx.dbfData.costn_rows : []).forEach((r) => {
const code = String(r.CODE || "").trim();
if (!code.startsWith("M")) return;
const nonZero = [
num(r.AMT_11),
num(r.AMT_21),
num(r.AMT_31),
num(r.AMT_41),
num(r.AMT_51),
].filter((x) => x > 0);
if (nonZero.length) {
const picked =
mode === "min" ? Math.min(...nonZero) : nonZero[parseInt(mode, 10)] || nonZero[0];
ViewPrjBoq().syncCostnUnitPrice(ctx, code, picked, 0, 0);
}
});
closeModal();
cascadeAndRender();
});
onClick("modal-apply-edthal", () => {
const kind = $("edthal-kind").value,
pct = num($("edthal-pct").value, 100) / 100,
rmode = $("edthal-round").value;
(ctx.dbfData ? ctx.dbfData.costn_rows : []).forEach((r) => {
const code = String(r.CODE || "").trim();
if (!code || r.UC === "T" || (kind !== "ALL" && code[0].toUpperCase() !== kind)) return;
const fn = (v) => (rmode === "round" ? Math.round(v * pct) : Math.floor(v * pct));
ViewPrjBoq().syncCostnUnitPrice(
ctx,
code,
fn(num(r.MAMT1)),
fn(num(r.LAMT1)),
fn(num(r.SAMT1)),
);
});
closeModal();
cascadeAndRender();
});
bindEach(".btn-use-special-san", "onclick", (btn) => {
const spec = (ctx.catalogData.special_san || [])[parseInt(btn.getAttribute("data-idx"), 10)];
if (!spec) return;
ctx.uiState.editedSmtMap[ctx.uiState.selectedIlwiCode || "D00016"] = (
spec["산식행"] || []
).join("\r\n");
ctx.uiState.activeScreen = "wM_Edit_iLWi";
closeModal();
renderWorkspace();
});
}
function insertBoqRow(api, code, name, spec, unit, qty) {
const { ctx, renderWorkspace } = api;
const recno = Date.now() % 100000;
ctx.state.boqItems.push({
id: "BOQ_" + recno,
code,
name,
refId: code,
rowType: "normal",
qty,
changedQty: qty,
_recno: recno,
});
if (ctx.dbfData && ctx.dbfData.boq_rows) {
ctx.dbfData.boq_rows.splice(4, 0, {
_recno: recno,
ITNUM: "",
BDCODE1: code,
NAME1: name,
SIZE1: spec,
UNIT1: unit,
QTY1: String(qty),
ENDCODE: "0.0000",
});
}
renderWorkspace();
}
global.STMATE_APP_MODALS = { bindModalEvents, insertBoqRow };
})(window);
+2
View File
@@ -177,6 +177,8 @@
<script src="view_prj_boq.js"></script>
<script src="view_dan_ilwi.js"></script>
<script src="view_kanjub_prt.js"></script>
<script src="app_modals.js"></script>
<script src="plugins_manifest.js"></script>
<script src="app.js"></script>
</body>
</html>
+12
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import importlib
import pathlib
import re
import zipfile
@@ -645,6 +646,17 @@ def api_export_xlsx(req: ExportXlsxReq):
raise HTTPException(status_code=500, detail=str(e))
# 끼워 넣기 — api_*.py 의 router 자동 연결 · scr_*.js 화면 파일 자동 불러오기
for _mod in sorted(HERE.glob("api_*.py")):
app.include_router(importlib.import_module(_mod.stem).router)
@app.get("/plugins_manifest.js")
def plugins_manifest():
tags = "".join(f'<script src="{f.name}"></script>' for f in sorted(HERE.glob("scr_*.js")))
return Response(content=f"document.write({tags!r});", media_type="application/javascript")
app.mount("/", StaticFiles(directory=str(HERE), html=True), name="static")
if __name__ == "__main__":