diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js
index 16cb25921..3046a4050 100644
--- a/tools/est-test/native-business-actions.js
+++ b/tools/est-test/native-business-actions.js
@@ -605,7 +605,8 @@ const EstBusinessActions = (() => {
(typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) ||
(typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) ||
(typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) ||
- (typeof EstBid !== "undefined" && EstBid.handle(name, state))
+ (typeof EstBid !== "undefined" && EstBid.handle(name, state)) ||
+ (typeof EstFormsLibrary !== "undefined" && EstFormsLibrary.handle(name, state, node))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
diff --git a/tools/est-test/native-forms-library.js b/tools/est-test/native-forms-library.js
new file mode 100644
index 000000000..f5b14a3e7
--- /dev/null
+++ b/tools/est-test/native-forms-library.js
@@ -0,0 +1,111 @@
+// 원본 원가계산서 양식(TFRMWONGABASE popupSaveAs·popupFormats·popupLoadFrom)과 양식목록(TFRMFORMATFILELIST)
+// — 현재 원가 표를 이름 붙여 서버 보관함(data/forms.json)에 등록하고, 목록에서 열기·삭제, 파일로 내려받기·불러오기.
+const EstFormsLibrary = (() => {
+ // 원가 표 양식으로 남길 필드 — 금액(Cost)은 계산 결과라 남기지 않는다.
+ function strip(rows) {
+ return rows.map((row) => {
+ const { Cost, ...rest } = row;
+ return rest;
+ });
+ }
+ function validRows(rows) {
+ if (!Array.isArray(rows) || !rows.length) throw Error("원가계산서 양식에 행이 없습니다.");
+ if (rows.some((row) => !row || typeof row !== "object" || Array.isArray(row)))
+ throw Error("원가계산서 양식 행이 올바르지 않습니다.");
+ return rows;
+ }
+
+ async function list() {
+ return (await EstApp.api("/api/forms")).filter((f) => f.kind === "wonga");
+ }
+ async function show(state) {
+ const { esc } = EstApp,
+ forms = await list();
+ EstApp.showDialog(
+ "원가계산서 양식 목록",
+ `현재 원가계산서의 항목·수식·요율을 양식으로 등록하고, 다른 공사에서 열어 씁니다. 금액은 열 때 다시 계산합니다.
| 양식 이름 | 행 | 등록 | 작업 |
${forms.map((f) => `| ${esc(f.name)} | ${f.data.length} | ${esc(f.saved.slice(0, 10))} | |
`).join("") || '| 등록된 양식이 없습니다. |
'}
`,
+ );
+ state.formsLibrary = forms;
+ }
+ function apply(state, rows, name) {
+ validRows(rows);
+ if (!confirm(`현재 원가계산서를 「${name}」 양식으로 바꾸시겠습니까?`)) return;
+ EstApp.byId("dialog").close();
+ // 도급액 고정은 저장된 금액을 그대로 쓰므로 금액 없는 양식에서는 풀어야 다시 계산된다(기초 뼈대와 같음).
+ const fixed = state.project.options.TotalFix;
+ EstApp.change((project) => {
+ project.options.TotalFix = false;
+ project.tables.Wonga = structuredClone(rows);
+ });
+ state.notice = `원가계산서를 「${name}」 양식으로 바꿨습니다.${fixed ? " 도급액 고정은 풀었으니 필요하면 다시 맞추세요." : ""}`;
+ }
+
+ async function run(name, state, node) {
+ if (name === "wonga-forms") await show(state);
+ else if (name === "wonga-form-register") {
+ const title = prompt("양식 이름", state.project.name || "원가계산서");
+ if (!title) return;
+ const data = strip(validRows(state.project.tables.Wonga));
+ await EstApp.api("/api/forms", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ kind: "wonga", name: title, data }),
+ });
+ await show(state);
+ } else if (name === "wonga-form-open") {
+ const form = state.formsLibrary?.find((f) => f.name === node.dataset.name);
+ if (!form) throw Error("양식을 찾을 수 없습니다. 목록을 다시 여세요.");
+ apply(state, form.data, form.name);
+ } else if (name === "wonga-form-delete") {
+ if (!confirm(`「${node.dataset.name}」 양식을 지우시겠습니까?`)) return;
+ await EstApp.api("/api/forms/delete", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ kind: "wonga", name: node.dataset.name }),
+ });
+ await show(state);
+ } else if (name === "wonga-form-export") {
+ const data = strip(validRows(state.project.tables.Wonga));
+ EstApp.download(
+ `${state.project.name || "원가계산서"}.wonga.json`,
+ JSON.stringify({ kind: "est-web-wonga-1", rows: data }, null, 2),
+ );
+ } else if (name === "wonga-form-import") {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = ".json";
+ input.addEventListener("change", async () => {
+ try {
+ const file = input.files[0];
+ if (!file) return;
+ const parsed = JSON.parse(await file.text());
+ if (parsed?.kind !== "est-web-wonga-1") throw Error("원가계산서 양식 파일이 아닙니다.");
+ apply(state, parsed.rows, file.name);
+ } catch (error) {
+ state.notice = error.message;
+ }
+ EstApp.render();
+ });
+ input.click();
+ }
+ }
+ const names = new Set([
+ "wonga-forms",
+ "wonga-form-register",
+ "wonga-form-open",
+ "wonga-form-delete",
+ "wonga-form-export",
+ "wonga-form-import",
+ ]);
+ // 동작 사슬은 동기 — 이름만 먼저 가리고 서버 요청은 뒤에서 돌린다.
+ function handle(name, state, node) {
+ if (!names.has(name)) return false;
+ run(name, state, node).catch((error) => {
+ state.notice = error.message;
+ EstApp.render();
+ });
+ return true;
+ }
+
+ return { strip, validRows, handle };
+})();
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index a82541fe8..aeb685f74 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -9,6 +9,7 @@ const exchange = require("./exchange.cjs");
const { exportEstx } = require("./export-estx.cjs");
const jdl = require("./jdl.cjs");
const { inspectAccom } = require("./inspect-accom.cjs");
+const forms = require("./forms-store.cjs");
const base = __dirname,
port = Number(process.env.EST_TEST_PORT || 5175),
host = process.env.EST_TEST_HOST || "100.65.141.15";
@@ -64,6 +65,7 @@ const allowed = new Set([
"native-excel-map.js",
"native-transfer.js",
"native-bid.js",
+ "native-forms-library.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",
@@ -152,6 +154,16 @@ const server = http.createServer(async (req, res) => {
}
return;
}
+ if (url.pathname === "/api/forms" && ["GET", "POST"].includes(req.method)) {
+ const input = req.method === "POST" ? JSON.parse((await body(req)).toString("utf8")) : null;
+ reply(res, 200, input ? forms.save(dataDirectory, input) : forms.read(dataDirectory));
+ return;
+ }
+ if (req.method === "POST" && url.pathname === "/api/forms/delete") {
+ const input = JSON.parse((await body(req)).toString("utf8"));
+ reply(res, 200, forms.remove(dataDirectory, input.kind, input.name));
+ return;
+ }
if (req.method === "GET" && url.pathname === "/api/projects") {
reply(res, 200, list());
return;