From 75a786f6817758d2d6d6aaa0b24533a0673a5f0d Mon Sep 17 00:00:00 2001 From: DongHeon Jang Date: Mon, 13 Apr 2026 22:48:24 +0900 Subject: [PATCH] Add master data management features and UI enhancements - Introduced a masterEditor state to manage entities and FX rates. - Implemented functions for creating, resetting, and syncing the master editor. - Added error handling and feedback mechanisms for data operations. - Enhanced the UI with editable tables for entities and FX rates, including admin-only actions. - Updated styles for better visual feedback on data status and actions. - Created unit tests for master data controller to ensure proper access control and validation. --- client/nexacro-deploy/assets/app.js | 304 ++++++- client/nexacro-deploy/assets/styles.css | 76 +- .../master/MasterDataControllerTest.java | 187 +++++ templates/nexacro/preview-app.js.tpl | 777 ++++++++++++++++++ templates/nexacro/preview.css.tpl | 330 ++++++++ tools/nexacro-gen/index.js | 755 +---------------- 6 files changed, 1665 insertions(+), 764 deletions(-) create mode 100644 server/api/src/test/java/com/hanwha/nexacrodemo/master/MasterDataControllerTest.java create mode 100644 templates/nexacro/preview-app.js.tpl create mode 100644 templates/nexacro/preview.css.tpl diff --git a/client/nexacro-deploy/assets/app.js b/client/nexacro-deploy/assets/app.js index 5ab0d2c..ada360e 100644 --- a/client/nexacro-deploy/assets/app.js +++ b/client/nexacro-deploy/assets/app.js @@ -70,6 +70,7 @@ const state = { currentRoute: "login", session: null, master: null, + masterEditor: null, uploads: null, runs: null, reports: null, @@ -100,6 +101,14 @@ async function api(path, options = {}) { return response.blob(); } +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """); +} + function formatValue(value) { if (value === null || value === undefined || value === "") { return "-"; @@ -130,6 +139,114 @@ function pill(value) { return `${value}`; } +function isAdmin() { + return state.session?.roleCode === "ADMIN"; +} + +function cloneRows(rows) { + return (rows || []).map((row) => ({ ...row })); +} + +function createMasterEditor() { + const datasets = state.master?.datasets || {}; + return { + entities: cloneRows(datasets.entities || []), + fxRates: cloneRows(datasets.fxRates || []), + dirty: { + entities: false, + fxRates: false + }, + feedback: { + entities: null, + fxRates: null + }, + errors: { + entities: null, + fxRates: null + } + }; +} + +function syncMasterEditor() { + state.masterEditor = createMasterEditor(); +} + +function ensureMasterEditor() { + if (!state.masterEditor) { + syncMasterEditor(); + } + return state.masterEditor; +} + +function resetMasterSection(section) { + const datasets = state.master?.datasets || {}; + const editor = ensureMasterEditor(); + if (section === "entities") { + editor.entities = cloneRows(datasets.entities || []); + } else { + editor.fxRates = cloneRows(datasets.fxRates || []); + } + editor.dirty[section] = false; + editor.feedback[section] = null; + editor.errors[section] = null; +} + +function setSectionError(section, message) { + const editor = ensureMasterEditor(); + editor.errors[section] = message; + editor.feedback[section] = null; +} + +function setSectionFeedback(section, message) { + const editor = ensureMasterEditor(); + editor.feedback[section] = message; + editor.errors[section] = null; +} + +function markMasterDirty(section) { + const editor = ensureMasterEditor(); + editor.dirty[section] = true; + editor.feedback[section] = null; + editor.errors[section] = null; +} + +function emptyEntityRow() { + return { + entityCode: "", + entityName: "", + baseCurrency: "KRW", + __isNew: true + }; +} + +function emptyFxRateRow() { + return { + fiscalPeriod: "", + currencyCode: "KRW", + rateToKrw: "", + __isNew: true + }; +} + +function normalizeEntityRows(rows) { + return rows.map((row) => ({ + entityCode: String(row.entityCode || "").trim(), + entityName: String(row.entityName || "").trim(), + baseCurrency: String(row.baseCurrency || "").trim().toUpperCase() + })); +} + +function normalizeFxRateRows(rows) { + return rows.map((row) => { + const numericValue = Number(String(row.rateToKrw ?? "").trim()); + return { + fiscalPeriod: String(row.fiscalPeriod || "").trim(), + currencyCode: String(row.currencyCode || "").trim().toUpperCase(), + rateToKrw: Number.isFinite(numericValue) ? numericValue : null + }; + }); +} + function renderNav() { return window.HANWHA_FORMS.map((form) => { const active = state.currentRoute === form.route ? "active" : ""; @@ -144,6 +261,48 @@ function heroContent() { return `

${form.title}

${note}

`; } +function sectionStatus(section) { + const editor = ensureMasterEditor(); + if (editor.dirty[section]) { + return pill("DIRTY"); + } + return `서버와 동기화됨`; +} + +function sectionMessage(section) { + const editor = ensureMasterEditor(); + if (editor.errors[section]) { + return `
${escapeHtml(editor.errors[section])}
`; + } + if (editor.feedback[section]) { + return `
${escapeHtml(editor.feedback[section])}
`; + } + return ""; +} + +function editableTable(section, columns, rows) { + const canEdit = isAdmin(); + const body = rows.length + ? rows + .map( + (row, index) => `${columns + .map((column) => { + const value = row[column.id] ?? ""; + const type = column.type || "text"; + const step = column.step ? `step="${column.step}"` : ""; + const readOnly = column.lockOnExisting && !row.__isNew; + return ``; + }) + .join("")}` + ) + .join("") + : `데이터가 없습니다.`; + + return `
${columns + .map((column) => ``) + .join("")}${body}
${column.text}삭제
`; +} + function renderLogin() { return ` ${heroContent()} @@ -161,13 +320,19 @@ function renderLogin() { function renderMaster() { const datasets = state.master?.datasets || {}; - const entities = datasets.entities || []; const accounts = datasets.accounts || []; - const fxRates = datasets.fxRates || []; const ownerships = datasets.ownerships || []; + const editor = ensureMasterEditor(); + const entities = editor.entities || []; + const fxRates = editor.fxRates || []; + const adminOnlyNotice = isAdmin() + ? "" + : `
기준정보 저장은 ADMIN 권한에서만 가능합니다. 현재는 읽기 전용입니다.
`; return ` ${heroContent()} +
기준정보를 수정하거나 삭제하면 이후 업로드 검증과 집계 결과가 달라질 수 있습니다.
+ ${adminOnlyNotice}
법인 수
${entities.length}
계정 수
${accounts.length}
@@ -176,16 +341,56 @@ function renderMaster() {
-

법인정보

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

법인정보

+
+ + + +
+
+
+ ${sectionStatus("entities")} +

기존 행에서는 법인코드를 변경할 수 없습니다.

+
+ ${sectionMessage("entities")} + ${editableTable( + "entities", + [ + { id: "entityCode", text: "법인코드", lockOnExisting: true }, + { 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)} +
+

환율

+
+ + + +
+
+
+ ${sectionStatus("fxRates")} +

기존 행에서는 회계기간과 통화를 변경할 수 없습니다.

+
+ ${sectionMessage("fxRates")} + ${editableTable( + "fxRates", + [ + { id: "fiscalPeriod", text: "회계기간", lockOnExisting: true }, + { id: "currencyCode", text: "통화", lockOnExisting: true }, + { id: "rateToKrw", text: "환산율", type: "number", step: "0.000001" } + ], + fxRates + )}

지분율

@@ -433,6 +638,7 @@ async function loadSession() { async function loadMaster() { if (!state.session) return; state.master = await api("/api/tx/master/reference"); + syncMasterEditor(); } async function loadUploads() { @@ -456,6 +662,7 @@ async function refreshAll() { await Promise.all([loadMaster(), loadUploads(), loadRuns(), loadReports()]); } else { state.master = null; + state.masterEditor = null; state.uploads = null; state.runs = null; state.reports = null; @@ -463,11 +670,36 @@ async function refreshAll() { render(); } +async function saveMasterSection(section) { + if (!isAdmin()) { + return; + } + + const editor = ensureMasterEditor(); + const rows = section === "entities" + ? normalizeEntityRows(editor.entities) + : normalizeFxRateRows(editor.fxRates); + const path = section === "entities" ? "/api/master/entities" : "/api/master/fx-rates"; + + try { + const response = await api(path, { + method: "PUT", + body: JSON.stringify({ rows }) + }); + await loadMaster(); + setSectionFeedback(section, response.message || "저장되었습니다."); + render(); + } catch (error) { + setSectionError(section, error.message); + 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 === "master" && !state.masterEditor) await loadMaster(); if (state.currentRoute === "uploads") await loadUploads(); if (state.currentRoute === "consolidation") await loadRuns(); if (state.currentRoute === "reports") await loadReports(); @@ -498,6 +730,64 @@ function bindEvents() { }); } + document.querySelectorAll("[data-master-add]").forEach((button) => { + button.addEventListener("click", () => { + const section = button.dataset.masterAdd; + const editor = ensureMasterEditor(); + if (section === "entities") { + editor.entities.push(emptyEntityRow()); + } else { + editor.fxRates.push(emptyFxRateRow()); + } + markMasterDirty(section); + render(); + }); + }); + + document.querySelectorAll("[data-master-delete]").forEach((button) => { + button.addEventListener("click", () => { + const section = button.dataset.masterDelete; + const index = Number(button.dataset.masterIndex); + const editor = ensureMasterEditor(); + if (section === "entities") { + editor.entities.splice(index, 1); + } else { + editor.fxRates.splice(index, 1); + } + markMasterDirty(section); + render(); + }); + }); + + document.querySelectorAll("[data-master-reset]").forEach((button) => { + button.addEventListener("click", () => { + resetMasterSection(button.dataset.masterReset); + render(); + }); + }); + + document.querySelectorAll("[data-master-save]").forEach((button) => { + button.addEventListener("click", async () => { + await saveMasterSection(button.dataset.masterSave); + }); + }); + + document.querySelectorAll("[data-master-field]").forEach((input) => { + input.addEventListener("input", () => { + const section = input.dataset.masterSection; + const field = input.dataset.masterField; + const index = Number(input.dataset.masterIndex); + const editor = ensureMasterEditor(); + const rows = section === "entities" ? editor.entities : editor.fxRates; + rows[index][field] = input.value; + markMasterDirty(section); + }); + + input.addEventListener("change", () => { + render(); + }); + }); + const uploadButton = document.querySelector("[data-action='upload']"); if (uploadButton) { uploadButton.addEventListener("click", async () => { diff --git a/client/nexacro-deploy/assets/styles.css b/client/nexacro-deploy/assets/styles.css index a60246e..d1f4b24 100644 --- a/client/nexacro-deploy/assets/styles.css +++ b/client/nexacro-deploy/assets/styles.css @@ -9,7 +9,9 @@ --accent-soft: #ffe6d1; --blue: #1f5fbf; --danger: #c53b3b; + --danger-soft: #fde8e8; --success: #237c52; + --success-soft: #e8f8ef; --shadow: 0 16px 40px rgba(16, 32, 58, 0.08); } @@ -43,6 +45,21 @@ button.ghost { background: var(--surface-alt); color: var(--blue); } +button.danger { + background: var(--danger-soft); + color: var(--danger); +} +button.small { + padding: 8px 12px; + border-radius: 10px; + font-size: 13px; +} +button:disabled, +input:disabled, +select:disabled { + opacity: 0.55; + cursor: not-allowed; +} input, select { width: 100%; border: 1px solid var(--line); @@ -111,11 +128,6 @@ input, select { margin-bottom: 24px; } -.topbar h2 { - margin: 0; - font-size: 32px; -} - .status-card, .panel, .hero-card { @@ -174,6 +186,20 @@ input, select { margin-bottom: 14px; } +.panel-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-bottom: 14px; +} + +.panel-tools { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + .stack { display: grid; gap: 12px; @@ -207,6 +233,21 @@ input, select { margin-bottom: 16px; } +.notice.warn { + background: #fff1dc; + color: #8a5100; +} + +.notice.error { + background: var(--danger-soft); + color: var(--danger); +} + +.notice.success { + background: var(--success-soft); + color: var(--success); +} + .table-wrap { overflow: auto; } @@ -221,6 +262,7 @@ th, td { padding: 12px 10px; border-bottom: 1px solid #ecf0f6; font-size: 14px; + vertical-align: top; } th { @@ -228,6 +270,28 @@ th { font-weight: 700; } +.master-table input { + min-width: 110px; + padding: 10px 12px; +} + +.master-table td.actions-cell { + white-space: nowrap; + width: 88px; +} + +.master-meta { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-bottom: 12px; +} + +.master-meta .muted { + margin: 0; +} + .pill { display: inline-flex; align-items: center; @@ -241,6 +305,7 @@ th { .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); } +.pill.DIRTY { background: rgba(245, 124, 35, 0.16); color: #9a4d00; } .login-box { max-width: 420px; @@ -261,4 +326,5 @@ th { .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; } + .panel-head, .master-meta { flex-direction: column; align-items: flex-start; } } diff --git a/server/api/src/test/java/com/hanwha/nexacrodemo/master/MasterDataControllerTest.java b/server/api/src/test/java/com/hanwha/nexacrodemo/master/MasterDataControllerTest.java new file mode 100644 index 0000000..5f94698 --- /dev/null +++ b/server/api/src/test/java/com/hanwha/nexacrodemo/master/MasterDataControllerTest.java @@ -0,0 +1,187 @@ +package com.hanwha.nexacrodemo.master; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class MasterDataControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void adminCanReplaceEntitiesAndReferenceReadShowsLatestValues() throws Exception { + MockHttpSession session = login("admin", "demo1234"); + + mockMvc.perform(put("/api/master/entities") + .contentType(MediaType.APPLICATION_JSON) + .session(session) + .content(""" + { + "rows": [ + { "entityCode": "HQ", "entityName": "Hanwha HQ Updated", "baseCurrency": "KRW" }, + { "entityCode": "US1", "entityName": "Hanwha USA", "baseCurrency": "USD" }, + { "entityCode": "JP1", "entityName": "Hanwha Japan", "baseCurrency": "JPY" } + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.ok").value(true)) + .andExpect(jsonPath("$.savedCount").value(3)); + + mockMvc.perform(get("/api/tx/master/reference").session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.datasets.entities.length()").value(3)) + .andExpect(jsonPath("$.datasets.entities[0].entityCode").value("HQ")) + .andExpect(jsonPath("$.datasets.entities[0].entityName").value("Hanwha HQ Updated")) + .andExpect(jsonPath("$.datasets.entities[2].entityCode").value("US1")); + } + + @Test + void adminCanReplaceFxRatesAndReferenceReadShowsLatestValues() throws Exception { + MockHttpSession session = login("admin", "demo1234"); + + mockMvc.perform(put("/api/master/fx-rates") + .contentType(MediaType.APPLICATION_JSON) + .session(session) + .content(""" + { + "rows": [ + { "fiscalPeriod": "2026-03", "currencyCode": "KRW", "rateToKrw": 1.0 }, + { "fiscalPeriod": "2026-03", "currencyCode": "USD", "rateToKrw": 1401.25 }, + { "fiscalPeriod": "2026-05", "currencyCode": "EUR", "rateToKrw": 1499.99 } + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.ok").value(true)) + .andExpect(jsonPath("$.savedCount").value(3)); + + mockMvc.perform(get("/api/tx/master/reference").session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.datasets.fxRates.length()").value(3)) + .andExpect(jsonPath("$.datasets.fxRates[1].currencyCode").value("USD")) + .andExpect(jsonPath("$.datasets.fxRates[1].rateToKrw").value(1401.25)); + } + + @Test + void nonAdminCannotSaveMasterData() throws Exception { + MockHttpSession operatorSession = login("operator", "demo1234"); + MockHttpSession viewerSession = login("viewer", "demo1234"); + + mockMvc.perform(put("/api/master/entities") + .contentType(MediaType.APPLICATION_JSON) + .session(operatorSession) + .content("{\"rows\":[]}")) + .andExpect(status().isForbidden()); + + mockMvc.perform(put("/api/master/fx-rates") + .contentType(MediaType.APPLICATION_JSON) + .session(viewerSession) + .content("{\"rows\":[]}")) + .andExpect(status().isForbidden()); + } + + @Test + void duplicateEntityCodeReturnsBadRequest() throws Exception { + MockHttpSession session = login("admin", "demo1234"); + + mockMvc.perform(put("/api/master/entities") + .contentType(MediaType.APPLICATION_JSON) + .session(session) + .content(""" + { + "rows": [ + { "entityCode": "HQ", "entityName": "Hanwha HQ", "baseCurrency": "KRW" }, + { "entityCode": "HQ", "entityName": "Hanwha HQ 2", "baseCurrency": "USD" } + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("중복된 법인코드는 저장할 수 없습니다.")); + } + + @Test + void duplicateFxRateKeyReturnsBadRequest() throws Exception { + MockHttpSession session = login("admin", "demo1234"); + + mockMvc.perform(put("/api/master/fx-rates") + .contentType(MediaType.APPLICATION_JSON) + .session(session) + .content(""" + { + "rows": [ + { "fiscalPeriod": "2026-03", "currencyCode": "USD", "rateToKrw": 1400.0 }, + { "fiscalPeriod": "2026-03", "currencyCode": "usd", "rateToKrw": 1401.0 } + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("중복된 회계기간/통화 조합은 저장할 수 없습니다.")); + } + + @Test + void invalidFiscalPeriodReturnsBadRequest() throws Exception { + MockHttpSession session = login("admin", "demo1234"); + + mockMvc.perform(put("/api/master/fx-rates") + .contentType(MediaType.APPLICATION_JSON) + .session(session) + .content(""" + { + "rows": [ + { "fiscalPeriod": "202603", "currencyCode": "USD", "rateToKrw": 1400.0 } + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("회계기간은 YYYY-MM 형식이어야 합니다.")); + } + + @Test + void nonPositiveFxRateReturnsBadRequest() throws Exception { + MockHttpSession session = login("admin", "demo1234"); + + mockMvc.perform(put("/api/master/fx-rates") + .contentType(MediaType.APPLICATION_JSON) + .session(session) + .content(""" + { + "rows": [ + { "fiscalPeriod": "2026-03", "currencyCode": "USD", "rateToKrw": 0 } + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("환산율은 0보다 커야 합니다.")); + } + + private MockHttpSession login(String username, String password) throws Exception { + MvcResult result = mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "%s", + "password": "%s" + } + """.formatted(username, password))) + .andExpect(status().isOk()) + .andReturn(); + return (MockHttpSession) result.getRequest().getSession(false); + } +} diff --git a/templates/nexacro/preview-app.js.tpl b/templates/nexacro/preview-app.js.tpl new file mode 100644 index 0000000..9ce06a1 --- /dev/null +++ b/templates/nexacro/preview-app.js.tpl @@ -0,0 +1,777 @@ +const state = { + currentRoute: "login", + session: null, + master: null, + masterEditor: 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 escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """); +} + +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 isAdmin() { + return state.session?.roleCode === "ADMIN"; +} + +function cloneRows(rows) { + return (rows || []).map((row) => ({ ...row })); +} + +function createMasterEditor() { + const datasets = state.master?.datasets || {}; + return { + entities: cloneRows(datasets.entities || []), + fxRates: cloneRows(datasets.fxRates || []), + dirty: { + entities: false, + fxRates: false + }, + feedback: { + entities: null, + fxRates: null + }, + errors: { + entities: null, + fxRates: null + } + }; +} + +function syncMasterEditor() { + state.masterEditor = createMasterEditor(); +} + +function ensureMasterEditor() { + if (!state.masterEditor) { + syncMasterEditor(); + } + return state.masterEditor; +} + +function resetMasterSection(section) { + const datasets = state.master?.datasets || {}; + const editor = ensureMasterEditor(); + if (section === "entities") { + editor.entities = cloneRows(datasets.entities || []); + } else { + editor.fxRates = cloneRows(datasets.fxRates || []); + } + editor.dirty[section] = false; + editor.feedback[section] = null; + editor.errors[section] = null; +} + +function setSectionError(section, message) { + const editor = ensureMasterEditor(); + editor.errors[section] = message; + editor.feedback[section] = null; +} + +function setSectionFeedback(section, message) { + const editor = ensureMasterEditor(); + editor.feedback[section] = message; + editor.errors[section] = null; +} + +function markMasterDirty(section) { + const editor = ensureMasterEditor(); + editor.dirty[section] = true; + editor.feedback[section] = null; + editor.errors[section] = null; +} + +function emptyEntityRow() { + return { + entityCode: "", + entityName: "", + baseCurrency: "KRW", + __isNew: true + }; +} + +function emptyFxRateRow() { + return { + fiscalPeriod: "", + currencyCode: "KRW", + rateToKrw: "", + __isNew: true + }; +} + +function normalizeEntityRows(rows) { + return rows.map((row) => ({ + entityCode: String(row.entityCode || "").trim(), + entityName: String(row.entityName || "").trim(), + baseCurrency: String(row.baseCurrency || "").trim().toUpperCase() + })); +} + +function normalizeFxRateRows(rows) { + return rows.map((row) => { + const numericValue = Number(String(row.rateToKrw ?? "").trim()); + return { + fiscalPeriod: String(row.fiscalPeriod || "").trim(), + currencyCode: String(row.currencyCode || "").trim().toUpperCase(), + rateToKrw: Number.isFinite(numericValue) ? numericValue : null + }; + }); +} + +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 sectionStatus(section) { + const editor = ensureMasterEditor(); + if (editor.dirty[section]) { + return pill("DIRTY"); + } + return `서버와 동기화됨`; +} + +function sectionMessage(section) { + const editor = ensureMasterEditor(); + if (editor.errors[section]) { + return `
${escapeHtml(editor.errors[section])}
`; + } + if (editor.feedback[section]) { + return `
${escapeHtml(editor.feedback[section])}
`; + } + return ""; +} + +function editableTable(section, columns, rows) { + const canEdit = isAdmin(); + const body = rows.length + ? rows + .map( + (row, index) => `${columns + .map((column) => { + const value = row[column.id] ?? ""; + const type = column.type || "text"; + const step = column.step ? `step="${column.step}"` : ""; + const readOnly = column.lockOnExisting && !row.__isNew; + return ``; + }) + .join("")}` + ) + .join("") + : `데이터가 없습니다.`; + + return `
${columns + .map((column) => ``) + .join("")}${body}
${column.text}삭제
`; +} + +function renderLogin() { + return ` + ${heroContent()} + + `; +} + +function renderMaster() { + const datasets = state.master?.datasets || {}; + const accounts = datasets.accounts || []; + const ownerships = datasets.ownerships || []; + const editor = ensureMasterEditor(); + const entities = editor.entities || []; + const fxRates = editor.fxRates || []; + const adminOnlyNotice = isAdmin() + ? "" + : `
기준정보 저장은 ADMIN 권한에서만 가능합니다. 현재는 읽기 전용입니다.
`; + + return ` + ${heroContent()} +
기준정보를 수정하거나 삭제하면 이후 업로드 검증과 집계 결과가 달라질 수 있습니다.
+ ${adminOnlyNotice} +
+
법인 수
${entities.length}
+
계정 수
${accounts.length}
+
환율 수
${fxRates.length}
+
지분율 수
${ownerships.length}
+
+
+
+
+

법인정보

+
+ + + +
+
+
+ ${sectionStatus("entities")} +

기존 행에서는 법인코드를 변경할 수 없습니다.

+
+ ${sectionMessage("entities")} + ${editableTable( + "entities", + [ + { id: "entityCode", text: "법인코드", lockOnExisting: true }, + { id: "entityName", text: "법인명" }, + { id: "baseCurrency", text: "통화" } + ], + entities + )} +
+
+

계정코드

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

환율

+
+ + + +
+
+
+ ${sectionStatus("fxRates")} +

기존 행에서는 회계기간과 통화를 변경할 수 없습니다.

+
+ ${sectionMessage("fxRates")} + ${editableTable( + "fxRates", + [ + { id: "fiscalPeriod", text: "회계기간", lockOnExisting: true }, + { id: "currencyCode", text: "통화", lockOnExisting: true }, + { id: "rateToKrw", text: "환산율", type: "number", step: "0.000001" } + ], + 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"); + syncMasterEditor(); +} + +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.masterEditor = null; + state.uploads = null; + state.runs = null; + state.reports = null; + } + render(); +} + +async function saveMasterSection(section) { + if (!isAdmin()) { + return; + } + + const editor = ensureMasterEditor(); + const rows = section === "entities" + ? normalizeEntityRows(editor.entities) + : normalizeFxRateRows(editor.fxRates); + const path = section === "entities" ? "/api/master/entities" : "/api/master/fx-rates"; + + try { + const response = await api(path, { + method: "PUT", + body: JSON.stringify({ rows }) + }); + await loadMaster(); + setSectionFeedback(section, response.message || "저장되었습니다."); + render(); + } catch (error) { + setSectionError(section, error.message); + render(); + } +} + +function bindEvents() { + document.querySelectorAll("[data-route]").forEach((element) => { + element.addEventListener("click", async () => { + state.currentRoute = element.dataset.route; + if (state.currentRoute === "master" && !state.masterEditor) 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(); + }); + } + + document.querySelectorAll("[data-master-add]").forEach((button) => { + button.addEventListener("click", () => { + const section = button.dataset.masterAdd; + const editor = ensureMasterEditor(); + if (section === "entities") { + editor.entities.push(emptyEntityRow()); + } else { + editor.fxRates.push(emptyFxRateRow()); + } + markMasterDirty(section); + render(); + }); + }); + + document.querySelectorAll("[data-master-delete]").forEach((button) => { + button.addEventListener("click", () => { + const section = button.dataset.masterDelete; + const index = Number(button.dataset.masterIndex); + const editor = ensureMasterEditor(); + if (section === "entities") { + editor.entities.splice(index, 1); + } else { + editor.fxRates.splice(index, 1); + } + markMasterDirty(section); + render(); + }); + }); + + document.querySelectorAll("[data-master-reset]").forEach((button) => { + button.addEventListener("click", () => { + resetMasterSection(button.dataset.masterReset); + render(); + }); + }); + + document.querySelectorAll("[data-master-save]").forEach((button) => { + button.addEventListener("click", async () => { + await saveMasterSection(button.dataset.masterSave); + }); + }); + + document.querySelectorAll("[data-master-field]").forEach((input) => { + input.addEventListener("input", () => { + const section = input.dataset.masterSection; + const field = input.dataset.masterField; + const index = Number(input.dataset.masterIndex); + const editor = ensureMasterEditor(); + const rows = section === "entities" ? editor.entities : editor.fxRates; + rows[index][field] = input.value; + markMasterDirty(section); + }); + + input.addEventListener("change", () => { + render(); + }); + }); + + 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}

`; +}); diff --git a/templates/nexacro/preview.css.tpl b/templates/nexacro/preview.css.tpl new file mode 100644 index 0000000..d1f4b24 --- /dev/null +++ b/templates/nexacro/preview.css.tpl @@ -0,0 +1,330 @@ +:root { + --bg: #f6f8fb; + --surface: #ffffff; + --surface-alt: #eef4ff; + --line: #d8dfeb; + --text: #10203a; + --muted: #5c6d86; + --accent: #f57c23; + --accent-soft: #ffe6d1; + --blue: #1f5fbf; + --danger: #c53b3b; + --danger-soft: #fde8e8; + --success: #237c52; + --success-soft: #e8f8ef; + --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); +} +button.danger { + background: var(--danger-soft); + color: var(--danger); +} +button.small { + padding: 8px 12px; + border-radius: 10px; + font-size: 13px; +} +button:disabled, +input:disabled, +select:disabled { + opacity: 0.55; + cursor: not-allowed; +} +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; +} + +.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; +} + +.panel-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-bottom: 14px; +} + +.panel-tools { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.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; +} + +.notice.warn { + background: #fff1dc; + color: #8a5100; +} + +.notice.error { + background: var(--danger-soft); + color: var(--danger); +} + +.notice.success { + background: var(--success-soft); + color: var(--success); +} + +.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; + vertical-align: top; +} + +th { + color: var(--muted); + font-weight: 700; +} + +.master-table input { + min-width: 110px; + padding: 10px 12px; +} + +.master-table td.actions-cell { + white-space: nowrap; + width: 88px; +} + +.master-meta { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-bottom: 12px; +} + +.master-meta .muted { + margin: 0; +} + +.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); } +.pill.DIRTY { background: rgba(245, 124, 35, 0.16); color: #9a4d00; } + +.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; } + .panel-head, .master-meta { flex-direction: column; align-items: flex-start; } +} diff --git a/tools/nexacro-gen/index.js b/tools/nexacro-gen/index.js index 5043c63..57c333c 100644 --- a/tools/nexacro-gen/index.js +++ b/tools/nexacro-gen/index.js @@ -353,762 +353,13 @@ function previewScript(forms) { } 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; } -} -`; + return readTemplate("preview.css.tpl"); } 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 + return renderTemplate(readTemplate("preview-app.js.tpl"), { + appTitle: appSpec.appTitle }); - - 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) {