지분율
@@ -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) => `| ${column.text} | `)
+ .join("")}
${body}
`;
+}
+
+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 `
`;
+}
+
+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) => `| ${column.text} | `)
+ .join("")}삭제 |
${body}
`;
+}
+
+function renderLogin() {
+ return `
+ ${heroContent()}
+
+
세션 로그인
+
기본 계정: admin/operator/viewer / demo1234
+
+
+
+
+
+
+ `;
+}
+
+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}
+
+
+
+
+
지분율 수
${ownerships.length}
+
+
+
+
+
법인정보
+
+
+
+
+
+
+
+ ${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)}
+
+
+
+
환율
+
+
+
+
+
+
+
+ ${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()}
+
+
업로드
+
+
+
+
+
+
+
+
+
업로드 이력
+ ${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()}
+
+
+
+
세션 사용자
${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 `
+
+ `;
+}
+
+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 = `
`;
+});
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) => \`| \${column.text} | \`)
- .join("")}
\${body}
\`;
-}
-
-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 \`
\`;
-}
-
-function renderLogin() {
- return \`
- \${heroContent()}
-
-
세션 로그인
-
기본 계정: admin/operator/viewer / demo1234
-
-
-
-
-
-
- \`;
-}
-
-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()}
-
-
-
-
-
지분율 수
\${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()}
-
-
업로드
-
-
-
-
-
-
-
-
-
업로드 이력
- \${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}
-
-
세션 사용자
\${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 \`
-
- \`;
-}
-
-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 = \`
\`;
-});
-`;
}
function generatePreview(appSpec, forms, basePreviewDir = previewDir) {