const fs = require("fs"); const path = require("path"); const yaml = require("js-yaml"); const repoRoot = path.resolve(__dirname, "../.."); const specDir = path.join(repoRoot, "spec", "nexacro"); const templateDir = path.join(repoRoot, "templates", "nexacro"); const outputDir = path.join(repoRoot, "client", "nexacro-src"); const previewDir = path.join(repoRoot, "client", "nexacro-deploy"); const UTF8_BOM = "\uFEFF"; const FRAME_TOP_HEIGHT = 56; const FRAME_LEFT_WIDTH = 240; const FORMS_DIR = "forms"; const FRAMEBASE_DIR = "FrameBase"; const LIB_DIR = "lib"; function readYaml(filePath) { return yaml.load(fs.readFileSync(filePath, "utf8")); } function loadSpecs(baseDir = specDir) { const appSpec = readYaml(path.join(baseDir, "app.yaml")); const formFiles = fs .readdirSync(baseDir) .filter((file) => file.endsWith(".yaml") && file !== "app.yaml") .sort(); const forms = formFiles.map((file) => readYaml(path.join(baseDir, file))); return { appSpec, forms }; } function readTemplate(relativePath) { return fs.readFileSync(path.join(templateDir, relativePath), "utf8"); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function removePathIfExists(targetPath) { if (fs.existsSync(targetPath)) { fs.rmSync(targetPath, { recursive: true, force: true }); } } function writeGeneratedFile(filePath, content, options = {}) { const includeBom = options.bom !== false; const payload = includeBom ? `${UTF8_BOM}${content}` : content; fs.writeFileSync(filePath, payload); } function cleanGeneratedOutput(baseOutputDir, projectName) { [ `${projectName}.xprj`, "application.xadl", "Application_Desktop.xadl", "environment.xml", "typedefinition.xml", "appvariables.xml", `${projectName}.xprj.bak`, "$Geninfo$.geninfo", ".DS_Store" ].forEach((file) => removePathIfExists(path.join(baseOutputDir, file))); ["forms", FORMS_DIR, "frame", FRAMEBASE_DIR, "lib", LIB_DIR].forEach((dir) => removePathIfExists(path.join(baseOutputDir, dir)) ); } function renderTemplate(template, values) { return Object.entries(values).reduce((content, [key, value]) => { return content.replaceAll(`{{${key}}}`, value); }, template); } function escapeXml(value = "") { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function indentBlock(content, size = 4) { const indent = " ".repeat(size); return content .split("\n") .map((line) => (line ? `${indent}${line}` : line)) .join("\n"); } function toXfdlDataset(dataset) { const columns = (dataset.columns || []) .map((column) => ` `) .join("\n"); return [ ` `, " ", columns, " ", " " ].join("\n"); } function componentTag(component, index = 0) { const attrs = [ `id="${component.id}"`, `taborder="${index}"`, `left="${component.left ?? 0}"`, `top="${component.top ?? 0}"`, `width="${component.width ?? 120}"`, `height="${component.height ?? 32}"` ]; if (component.text) { attrs.push(`text="${escapeXml(component.text)}"`); } if (component.prompt && ["Edit", "MaskEdit", "TextArea"].includes(component.type)) { attrs.push(`displaynulltext="${escapeXml(component.prompt)}"`); } if (component.bind && ["Edit", "MaskEdit", "TextArea"].includes(component.type)) { const [, columnId] = component.bind.split("."); attrs.push(`value="bind:${columnId}"`); } if (component.type === "Edit" && component.id.toLowerCase().includes("password")) { attrs.push('password="true"'); } if (component.type === "Combo") { attrs.push('codecolumn="code"'); attrs.push('datacolumn="label"'); } return ` <${component.type} ${attrs.join(" ")}/>`; } function gridFormats(grid) { const columns = grid.columns || []; const width = Math.max(120, Math.floor((grid.width || 800) / Math.max(columns.length, 1))); const columnXml = columns.map(() => ``).join(""); const headerCells = columns .map((column, index) => ``) .join(""); const bodyCells = columns .map((column, index) => ``) .join(""); return `${columnXml}${headerCells}${bodyCells}`; } function gridTag(grid) { return [ ` `, ` `, ` ${gridFormats(grid)}`, " " ].join("\n"); } function buildFormScript(form) { const transactions = (form.transactions || []) .map( (transaction) => `this.${transaction.id} = function(obj, e)\n{\n this.gfnShowMessage("${escapeXml(transaction.id)} -> ${escapeXml(transaction.endpoint)}");\n};` ) .join("\n\n"); const actions = (form.actions || []) .map( (action) => `this.${action.id} = function(obj, e)\n{\n this.gfnShowMessage("${escapeXml(action.label)}");\n};` ) .join("\n\n"); return [ "this.Form_onload = function(obj, e)", "{", ` this.gfnShowMessage("${escapeXml(form.title)} loaded");`, "};", "", transactions, "", actions ] .join("\n") .trim(); } function renderXfdl(form, appSpec) { const datasets = (form.datasets || []).map(toXfdlDataset).join("\n"); const components = (form.components || []).map((component, index) => componentTag(component, index)).join("\n"); const grids = (form.grids || []).map(gridTag).join("\n"); const layout = form.layout || appSpec.layout; return [ '', '', `
`, " ", datasets || " ", " ", " ", components, grids, " ", ` `, " ", "
", "
", "" ].join("\n"); } function renderTopFrame(appSpec) { return [ '', '', `
`, ` `, ' ', " ", ` `, " ", " ", "
", "" ].join("\n"); } function renderLeftFrame(forms) { const menuItems = forms .map( (form, index) => ` ` ) .join("\n"); return [ '', '', `
`, ' ', menuItems, " ", ' ', " ", " ", "
", "" ].join("\n"); } function renderWorkFrame(appSpec, defaultFormId) { const workWidth = Math.max(appSpec.layout.width - FRAME_LEFT_WIDTH, 800); const workHeight = Math.max(appSpec.layout.height - FRAME_TOP_HEIGHT, 640); return [ '', '', `
`, `
`, " ", ` `, " ", " ", "", "" ].join("\n"); } function generateProjectFiles(appSpec, forms, baseOutputDir = outputDir) { cleanGeneratedOutput(baseOutputDir, appSpec.projectName); ensureDir(baseOutputDir); ensureDir(path.join(baseOutputDir, FORMS_DIR)); ensureDir(path.join(baseOutputDir, FRAMEBASE_DIR)); ensureDir(path.join(baseOutputDir, LIB_DIR)); writeGeneratedFile( path.join(baseOutputDir, `${appSpec.projectName}.xprj`), renderTemplate(readTemplate("project.xml.tpl"), { projectName: appSpec.projectName }) ); writeGeneratedFile( path.join(baseOutputDir, "Application_Desktop.xadl"), renderTemplate(readTemplate("application.xadl.tpl"), { appTitle: appSpec.appTitle, width: String(appSpec.layout.width), height: String(appSpec.layout.height), topHeight: String(FRAME_TOP_HEIGHT), leftWidth: String(FRAME_LEFT_WIDTH) }) ); writeGeneratedFile( path.join(baseOutputDir, "environment.xml"), renderTemplate(readTemplate("environment.xml.tpl"), { themeId: appSpec.themeId }) ); writeGeneratedFile(path.join(baseOutputDir, "typedefinition.xml"), readTemplate("typedefinition.xml.tpl")); writeGeneratedFile(path.join(baseOutputDir, "appvariables.xml"), readTemplate("appvariables.xml.tpl"), { bom: false }); writeGeneratedFile(path.join(baseOutputDir, LIB_DIR, "common.xjs"), readTemplate(path.join("common", "common.xjs.tpl")), { bom: false }); writeGeneratedFile(path.join(baseOutputDir, FRAMEBASE_DIR, "Form_Top.xfdl"), renderTopFrame(appSpec)); writeGeneratedFile(path.join(baseOutputDir, FRAMEBASE_DIR, "Form_Left.xfdl"), renderLeftFrame(forms)); const defaultForm = forms.find((form) => form.route === "login") || forms[0]; writeGeneratedFile( path.join(baseOutputDir, FRAMEBASE_DIR, "Form_Work.xfdl"), renderWorkFrame(appSpec, defaultForm?.formId || "frmLogin") ); forms.forEach((form) => { writeGeneratedFile(path.join(baseOutputDir, FORMS_DIR, `${form.formId}.xfdl`), renderXfdl(form, appSpec)); }); } function previewHtml(appSpec) { return ` ${escapeXml(appSpec.previewTitle)}
`; } function previewScript(forms) { const manifest = JSON.stringify( forms.map((form) => ({ formId: form.formId, title: form.title, route: form.route, authority: form.authority, messages: form.messages || [] })), null, 2 ); return `window.HANWHA_FORMS = ${manifest}; `; } function previewCss() { return `:root { --bg: #f6f8fb; --surface: #ffffff; --surface-alt: #eef4ff; --line: #d8dfeb; --text: #10203a; --muted: #5c6d86; --accent: #f57c23; --accent-soft: #ffe6d1; --blue: #1f5fbf; --danger: #c53b3b; --success: #237c52; --shadow: 0 16px 40px rgba(16, 32, 58, 0.08); } * { box-sizing: border-box; } body { margin: 0; font-family: "Pretendard", "Noto Sans KR", sans-serif; background: radial-gradient(circle at top right, rgba(245, 124, 35, 0.15), transparent 18rem), linear-gradient(180deg, #fbfcff 0%, var(--bg) 100%); color: var(--text); } a { color: var(--blue); text-decoration: none; } button, input, select { font: inherit; } button { cursor: pointer; border: 0; border-radius: 12px; padding: 12px 16px; background: var(--accent); color: #fff; font-weight: 700; } button.secondary { background: #fff; color: var(--text); border: 1px solid var(--line); } button.ghost { background: var(--surface-alt); color: var(--blue); } input, select { width: 100%; border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; background: #fff; } .shell { display: grid; grid-template-columns: 280px 1fr; min-height: 100vh; } .sidebar { padding: 28px 22px; background: rgba(255, 255, 255, 0.8); border-right: 1px solid rgba(216, 223, 235, 0.7); backdrop-filter: blur(16px); } .brand { margin-bottom: 28px; } .brand h1 { margin: 0; font-size: 28px; line-height: 1.1; } .brand p { margin: 10px 0 0; color: var(--muted); font-size: 14px; } .nav-list { display: grid; gap: 10px; } .nav-item { padding: 14px 16px; border-radius: 14px; background: transparent; border: 1px solid transparent; text-align: left; color: var(--text); } .nav-item.active { background: var(--surface-alt); border-color: #c9dafd; } .main { padding: 28px; } .topbar { display: flex; justify-content: space-between; gap: 16px; align-items: center; margin-bottom: 24px; } .topbar h2 { margin: 0; font-size: 32px; } .status-card, .panel, .hero-card { background: var(--surface); border: 1px solid rgba(216, 223, 235, 0.7); border-radius: 24px; box-shadow: var(--shadow); } .hero-card { padding: 26px; margin-bottom: 24px; background: linear-gradient(135deg, rgba(245, 124, 35, 0.08), rgba(31, 95, 191, 0.08)), var(--surface); } .hero-card p { margin: 8px 0 0; color: var(--muted); } .grid-2, .grid-3, .grid-4 { display: grid; gap: 18px; } .grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } .grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } .status-card { padding: 20px; } .status-card .label { font-size: 13px; color: var(--muted); } .status-card .value { font-size: 30px; margin-top: 10px; font-weight: 800; } .panel { padding: 22px; margin-bottom: 18px; } .panel h3 { margin-top: 0; margin-bottom: 14px; } .stack { display: grid; gap: 12px; } .row { display: grid; grid-template-columns: 140px 1fr; gap: 12px; align-items: center; margin-bottom: 12px; } .row label { font-size: 14px; color: var(--muted); } .row.actions { grid-template-columns: 1fr; display: flex; gap: 10px; flex-wrap: wrap; } .notice { border-radius: 16px; padding: 14px 16px; background: var(--accent-soft); color: #7a4313; margin-bottom: 16px; } .table-wrap { overflow: auto; } table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 12px 10px; border-bottom: 1px solid #ecf0f6; font-size: 14px; } th { color: var(--muted); font-weight: 700; } .pill { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; } .pill.ACCEPTED, .pill.SUCCESS { background: rgba(35, 124, 82, 0.12); color: var(--success); } .pill.REJECTED, .pill.ERROR, .pill.FAILED { background: rgba(197, 59, 59, 0.12); color: var(--danger); } .pill.REQUESTED, .pill.PROCESSING, .pill.INFO { background: rgba(31, 95, 191, 0.12); color: var(--blue); } .login-box { max-width: 420px; } .muted { color: var(--muted); font-size: 14px; } .footer-links { display: flex; gap: 12px; flex-wrap: wrap; } @media (max-width: 1100px) { .shell { grid-template-columns: 1fr; } .sidebar { border-right: 0; border-bottom: 1px solid rgba(216, 223, 235, 0.7); } .grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; } } `; } function previewAppJs(appSpec) { return `const state = { currentRoute: "login", session: null, master: null, uploads: null, runs: null, reports: null, selectedBatchId: null }; const formMap = new Map(window.HANWHA_FORMS.map((form) => [form.route, form])); async function api(path, options = {}) { const response = await fetch(path, { credentials: "same-origin", headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...(options.headers || {}) }, ...options }); if (!response.ok) { const payload = await response.json().catch(() => ({ message: response.statusText })); throw new Error(payload.message || "요청 처리에 실패했습니다."); } const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { return response.json(); } return response.blob(); } function formatValue(value) { if (value === null || value === undefined || value === "") { return "-"; } return value; } function table(columns, rows, options = {}) { const body = rows.length ? rows .map( (row) => \`\${columns .map((column) => { const value = options.render ? options.render(column, row[column.id], row) : row[column.id]; return \`\${formatValue(value)}\`; }) .join("")}\` ) .join("") : \`데이터가 없습니다.\`; return \`
\${columns .map((column) => \`\`) .join("")}\${body}
\${column.text}
\`; } function pill(value) { return \`\${value}\`; } function renderNav() { return window.HANWHA_FORMS.map((form) => { const active = state.currentRoute === form.route ? "active" : ""; const locked = form.authority !== "PUBLIC" && !state.session; return \`\`; }).join(""); } function heroContent() { const form = formMap.get(state.currentRoute); const note = form?.messages?.[0]?.text || "Spec driven preview"; return \`

\${form.title}

\${note}

\`; } function renderLogin() { return \` \${heroContent()} \`; } function renderMaster() { const datasets = state.master?.datasets || {}; const entities = datasets.entities || []; const accounts = datasets.accounts || []; const fxRates = datasets.fxRates || []; const ownerships = datasets.ownerships || []; return \` \${heroContent()}
법인 수
\${entities.length}
계정 수
\${accounts.length}
환율 수
\${fxRates.length}
지분율 수
\${ownerships.length}

법인정보

\${table([{ id: "entityCode", text: "법인코드" }, { id: "entityName", text: "법인명" }, { id: "baseCurrency", text: "통화" }], entities)}

계정코드

\${table([{ id: "accountCode", text: "계정" }, { id: "accountName", text: "계정명" }, { id: "accountCategory", text: "분류" }, { id: "internalTradeYn", text: "내부거래" }], accounts)}

환율

\${table([{ id: "fiscalPeriod", text: "회계기간" }, { id: "currencyCode", text: "통화" }, { id: "rateToKrw", text: "환산율" }], fxRates)}

지분율

\${table([{ id: "parentEntityCode", text: "모법인" }, { id: "childEntityCode", text: "자법인" }, { id: "ownershipRatio", text: "지분율" }], ownerships)}
\`; } function renderUploads() { const datasets = state.uploads?.datasets || {}; const batches = datasets.uploadBatches || []; const issues = datasets.validationIssues || []; return \` \${heroContent()}

업로드

오류 샘플 정상 TB 정상 Forecast

업로드 이력

\${table( [ { id: "id", text: "배치ID" }, { id: "templateCode", text: "템플릿" }, { id: "fiscalPeriod", text: "회계기간" }, { id: "statusCode", text: "상태" }, { id: "originalFilename", text: "파일명" }, { id: "rowCount", text: "건수" }, { id: "errorCount", text: "오류" }, { id: "uploadedAt", text: "업로드시각" } ], batches, { render(column, value) { if (column.id === "statusCode") { return pill(value); } return value; } } )}

오류내역

\${table( [ { id: "batchId", text: "배치ID" }, { id: "rowNumber", text: "행" }, { id: "issueCode", text: "오류코드" }, { id: "issueMessage", text: "오류메시지" }, { id: "severityCode", text: "등급" } ], issues, { render(column, value) { if (column.id === "severityCode") { return pill(value); } return value; } } )}
\`; } function renderConsolidation() { const runs = state.runs?.datasets?.runs || []; return \` \${heroContent()}

집계 실행

집계 이력

\${table( [ { id: "id", text: "실행ID" }, { id: "fiscalPeriod", text: "회계기간" }, { id: "statusCode", text: "상태" }, { id: "requestedBy", text: "요청자" }, { id: "requestedAt", text: "요청시각" }, { id: "finishedAt", text: "완료시각" }, { id: "summaryMessage", text: "요약" } ], runs, { render(column, value) { if (column.id === "statusCode") { return pill(value); } return value; } } )}
\`; } function renderReports() { const datasets = state.reports?.datasets || {}; const artifacts = datasets.artifacts || []; const logs = datasets.jobLogs || []; return \` \${heroContent()}
산출물 수
\${artifacts.length}
최근 로그 수
\${logs.length}
세션 사용자
\${state.session?.fullName || "-"}

리포트 산출물

\${table( [ { id: "id", text: "산출물ID" }, { id: "runId", text: "실행ID" }, { id: "artifactType", text: "형식" }, { id: "downloadName", text: "파일명" }, { id: "createdAt", text: "생성시각" } ], artifacts, { render(column, value, row) { if (column.id === "downloadName") { return \`\${value}\`; } return value; } } )}

최근 배치 로그

\${table( [ { id: "id", text: "로그ID" }, { id: "jobType", text: "작업유형" }, { id: "referenceId", text: "참조ID" }, { id: "logLevel", text: "레벨" }, { id: "logMessage", text: "메시지" }, { id: "createdAt", text: "생성시각" } ], logs, { render(column, value) { if (column.id === "logLevel") { return pill(value); } return value; } } )}
\`; } function shellContent(content) { return \`
\${content}
\`; } function render() { let content = ""; switch (state.currentRoute) { case "login": content = renderLogin(); break; case "master": content = renderMaster(); break; case "uploads": content = renderUploads(); break; case "consolidation": content = renderConsolidation(); break; case "reports": content = renderReports(); break; default: content = "
정의되지 않은 화면입니다.
"; } document.getElementById("app").innerHTML = shellContent(content); bindEvents(); } async function loadSession() { try { state.session = await api("/api/auth/me"); } catch (error) { state.session = null; } } async function loadMaster() { if (!state.session) return; state.master = await api("/api/tx/master/reference"); } async function loadUploads() { if (!state.session) return; state.uploads = await api("/api/tx/uploads/overview"); } async function loadRuns() { if (!state.session) return; state.runs = await api("/api/tx/consolidations/overview"); } async function loadReports() { if (!state.session) return; state.reports = await api("/api/tx/reports/overview"); } async function refreshAll() { await loadSession(); if (state.session) { await Promise.all([loadMaster(), loadUploads(), loadRuns(), loadReports()]); } else { state.master = null; state.uploads = null; state.runs = null; state.reports = null; } render(); } function bindEvents() { document.querySelectorAll("[data-route]").forEach((element) => { element.addEventListener("click", async () => { state.currentRoute = element.dataset.route; if (state.currentRoute === "master") await loadMaster(); if (state.currentRoute === "uploads") await loadUploads(); if (state.currentRoute === "consolidation") await loadRuns(); if (state.currentRoute === "reports") await loadReports(); render(); }); }); const loginButton = document.querySelector("[data-action='login']"); if (loginButton) { loginButton.addEventListener("click", async () => { const username = document.getElementById("login-username").value; const password = document.getElementById("login-password").value; await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); state.currentRoute = "master"; await refreshAll(); }); } const logoutButton = document.querySelector("[data-action='logout']"); if (logoutButton) { logoutButton.addEventListener("click", async () => { await api("/api/auth/logout", { method: "POST" }); state.currentRoute = "login"; await refreshAll(); }); } const uploadButton = document.querySelector("[data-action='upload']"); if (uploadButton) { uploadButton.addEventListener("click", async () => { const templateCode = document.getElementById("upload-template").value; const fiscalPeriod = document.getElementById("upload-period").value; const file = document.getElementById("upload-file").files[0]; if (!file) { alert("업로드할 파일을 선택하세요."); return; } const formData = new FormData(); formData.append("templateCode", templateCode); formData.append("fiscalPeriod", fiscalPeriod); formData.append("file", file); await api("/api/uploads", { method: "POST", body: formData }); await loadUploads(); render(); }); } const reloadUploadsButton = document.querySelector("[data-action='reload-uploads']"); if (reloadUploadsButton) { reloadUploadsButton.addEventListener("click", async () => { await loadUploads(); render(); }); } const runButton = document.querySelector("[data-action='request-run']"); if (runButton) { runButton.addEventListener("click", async () => { const fiscalPeriod = document.getElementById("run-period").value; await api("/api/consolidations/runs", { method: "POST", body: JSON.stringify({ fiscalPeriod, reportCurrency: "KRW" }) }); await loadRuns(); render(); }); } const reloadRunsButton = document.querySelector("[data-action='reload-runs']"); if (reloadRunsButton) { reloadRunsButton.addEventListener("click", async () => { await loadRuns(); await loadReports(); render(); }); } } refreshAll().catch((error) => { console.error(error); document.getElementById("app").innerHTML = \`

초기화 실패

\${error.message}

\`; }); `; } function generatePreview(appSpec, forms, basePreviewDir = previewDir) { ensureDir(path.join(basePreviewDir, "assets")); ensureDir(path.join(basePreviewDir, "sample-data")); fs.writeFileSync(path.join(basePreviewDir, "index.html"), previewHtml(appSpec)); fs.writeFileSync(path.join(basePreviewDir, "assets", "forms.js"), previewScript(forms)); fs.writeFileSync(path.join(basePreviewDir, "assets", "styles.css"), previewCss()); const appJs = `${fs.readFileSync(path.join(basePreviewDir, "assets", "forms.js"), "utf8")}\n${previewAppJs(appSpec)}`; fs.writeFileSync(path.join(basePreviewDir, "assets", "app.js"), appJs); } function generate() { const { appSpec, forms } = loadSpecs(); generateProjectFiles(appSpec, forms); generatePreview(appSpec, forms); } if (require.main === module) { generate(); } module.exports = { loadSpecs, generateProjectFiles, generatePreview, generate, renderXfdl };