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.
This commit is contained in:
DongHeon Jang 2026-04-13 22:48:24 +09:00
parent bbaa6f3e0b
commit 75a786f681
6 changed files with 1665 additions and 764 deletions

View File

@ -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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
function formatValue(value) {
if (value === null || value === undefined || value === "") {
return "-";
@ -130,6 +139,114 @@ function pill(value) {
return `<span class="pill ${value}">${value}</span>`;
}
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 `<div class="hero-card"><h2>${form.title}</h2><p>${note}</p></div>`;
}
function sectionStatus(section) {
const editor = ensureMasterEditor();
if (editor.dirty[section]) {
return pill("DIRTY");
}
return `<span class="muted">서버와 동기화됨</span>`;
}
function sectionMessage(section) {
const editor = ensureMasterEditor();
if (editor.errors[section]) {
return `<div class="notice error">${escapeHtml(editor.errors[section])}</div>`;
}
if (editor.feedback[section]) {
return `<div class="notice success">${escapeHtml(editor.feedback[section])}</div>`;
}
return "";
}
function editableTable(section, columns, rows) {
const canEdit = isAdmin();
const body = rows.length
? rows
.map(
(row, index) => `<tr>${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 `<td><input type="${type}" ${step} value="${escapeHtml(value)}" data-master-section="${section}" data-master-index="${index}" data-master-field="${column.id}" ${readOnly ? "readonly" : ""} ${canEdit ? "" : "disabled"} /></td>`;
})
.join("")}<td class="actions-cell"><button class="danger small" data-master-delete="${section}" data-master-index="${index}" ${canEdit ? "" : "disabled"}>삭제</button></td></tr>`
)
.join("")
: `<tr><td colspan="${columns.length + 1}">데이터가 없습니다.</td></tr>`;
return `<div class="table-wrap master-table"><table><thead><tr>${columns
.map((column) => `<th>${column.text}</th>`)
.join("")}<th>삭제</th></tr></thead><tbody>${body}</tbody></table></div>`;
}
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()
? ""
: `<div class="notice">기준정보 저장은 ADMIN 권한에서만 가능합니다. 현재는 읽기 전용입니다.</div>`;
return `
${heroContent()}
<div class="notice warn">기준정보를 수정하거나 삭제하면 이후 업로드 검증과 집계 결과가 달라질 있습니다.</div>
${adminOnlyNotice}
<div class="grid-4">
<div class="status-card"><div class="label">법인 </div><div class="value">${entities.length}</div></div>
<div class="status-card"><div class="label">계정 </div><div class="value">${accounts.length}</div></div>
@ -176,16 +341,56 @@ function renderMaster() {
</div>
<div class="grid-2" style="margin-top: 18px;">
<div class="panel">
<h3>법인정보</h3>
${table([{ id: "entityCode", text: "법인코드" }, { id: "entityName", text: "법인명" }, { id: "baseCurrency", text: "통화" }], entities)}
<div class="panel-head">
<h3>법인정보</h3>
<div class="panel-tools">
<button class="ghost small" data-master-add="entities" ${isAdmin() ? "" : "disabled"}> 추가</button>
<button class="secondary small" data-master-reset="entities" ${isAdmin() ? "" : "disabled"}>되돌리기</button>
<button class="small" data-master-save="entities" ${isAdmin() ? "" : "disabled"}>저장</button>
</div>
</div>
<div class="master-meta">
${sectionStatus("entities")}
<p class="muted">기존 행에서는 법인코드를 변경할 없습니다.</p>
</div>
${sectionMessage("entities")}
${editableTable(
"entities",
[
{ id: "entityCode", text: "법인코드", lockOnExisting: true },
{ id: "entityName", text: "법인명" },
{ id: "baseCurrency", text: "통화" }
],
entities
)}
</div>
<div class="panel">
<h3>계정코드</h3>
${table([{ id: "accountCode", text: "계정" }, { id: "accountName", text: "계정명" }, { id: "accountCategory", text: "분류" }, { id: "internalTradeYn", text: "내부거래" }], accounts)}
</div>
<div class="panel">
<h3>환율</h3>
${table([{ id: "fiscalPeriod", text: "회계기간" }, { id: "currencyCode", text: "통화" }, { id: "rateToKrw", text: "환산율" }], fxRates)}
<div class="panel-head">
<h3>환율</h3>
<div class="panel-tools">
<button class="ghost small" data-master-add="fxRates" ${isAdmin() ? "" : "disabled"}> 추가</button>
<button class="secondary small" data-master-reset="fxRates" ${isAdmin() ? "" : "disabled"}>되돌리기</button>
<button class="small" data-master-save="fxRates" ${isAdmin() ? "" : "disabled"}>저장</button>
</div>
</div>
<div class="master-meta">
${sectionStatus("fxRates")}
<p class="muted">기존 행에서는 회계기간과 통화를 변경할 없습니다.</p>
</div>
${sectionMessage("fxRates")}
${editableTable(
"fxRates",
[
{ id: "fiscalPeriod", text: "회계기간", lockOnExisting: true },
{ id: "currencyCode", text: "통화", lockOnExisting: true },
{ id: "rateToKrw", text: "환산율", type: "number", step: "0.000001" }
],
fxRates
)}
</div>
<div class="panel">
<h3>지분율</h3>
@ -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 () => {

View File

@ -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; }
}

View File

@ -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);
}
}

View File

@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
function formatValue(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
return value;
}
function table(columns, rows, options = {}) {
const body = rows.length
? rows
.map(
(row) => `<tr>${columns
.map((column) => {
const value = options.render ? options.render(column, row[column.id], row) : row[column.id];
return `<td>${formatValue(value)}</td>`;
})
.join("")}</tr>`
)
.join("")
: `<tr><td colspan="${columns.length}">데이터가 없습니다.</td></tr>`;
return `<div class="table-wrap"><table><thead><tr>${columns
.map((column) => `<th>${column.text}</th>`)
.join("")}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function pill(value) {
return `<span class="pill ${value}">${value}</span>`;
}
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 `<button class="nav-item ${active}" data-route="${form.route}" ${locked ? "disabled" : ""}><strong>${form.title}</strong><div class="muted">${form.authority}</div></button>`;
}).join("");
}
function heroContent() {
const form = formMap.get(state.currentRoute);
const note = form?.messages?.[0]?.text || "Spec driven preview";
return `<div class="hero-card"><h2>${form.title}</h2><p>${note}</p></div>`;
}
function sectionStatus(section) {
const editor = ensureMasterEditor();
if (editor.dirty[section]) {
return pill("DIRTY");
}
return `<span class="muted">서버와 동기화됨</span>`;
}
function sectionMessage(section) {
const editor = ensureMasterEditor();
if (editor.errors[section]) {
return `<div class="notice error">${escapeHtml(editor.errors[section])}</div>`;
}
if (editor.feedback[section]) {
return `<div class="notice success">${escapeHtml(editor.feedback[section])}</div>`;
}
return "";
}
function editableTable(section, columns, rows) {
const canEdit = isAdmin();
const body = rows.length
? rows
.map(
(row, index) => `<tr>${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 `<td><input type="${type}" ${step} value="${escapeHtml(value)}" data-master-section="${section}" data-master-index="${index}" data-master-field="${column.id}" ${readOnly ? "readonly" : ""} ${canEdit ? "" : "disabled"} /></td>`;
})
.join("")}<td class="actions-cell"><button class="danger small" data-master-delete="${section}" data-master-index="${index}" ${canEdit ? "" : "disabled"}>삭제</button></td></tr>`
)
.join("")
: `<tr><td colspan="${columns.length + 1}">데이터가 없습니다.</td></tr>`;
return `<div class="table-wrap master-table"><table><thead><tr>${columns
.map((column) => `<th>${column.text}</th>`)
.join("")}<th>삭제</th></tr></thead><tbody>${body}</tbody></table></div>`;
}
function renderLogin() {
return `
${heroContent()}
<div class="panel login-box">
<h3>세션 로그인</h3>
<div class="notice">기본 계정: admin/operator/viewer / demo1234</div>
<div class="row"><label>사용자 ID</label><input id="login-username" value="admin" /></div>
<div class="row"><label>비밀번호</label><input id="login-password" type="password" value="demo1234" /></div>
<div class="row actions">
<button data-action="login">로그인</button>
</div>
</div>
`;
}
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()
? ""
: `<div class="notice">기준정보 저장은 ADMIN 권한에서만 가능합니다. 현재는 읽기 전용입니다.</div>`;
return `
${heroContent()}
<div class="notice warn">기준정보를 수정하거나 삭제하면 이후 업로드 검증과 집계 결과가 달라질 수 있습니다.</div>
${adminOnlyNotice}
<div class="grid-4">
<div class="status-card"><div class="label">법인 수</div><div class="value">${entities.length}</div></div>
<div class="status-card"><div class="label">계정 수</div><div class="value">${accounts.length}</div></div>
<div class="status-card"><div class="label">환율 수</div><div class="value">${fxRates.length}</div></div>
<div class="status-card"><div class="label">지분율 수</div><div class="value">${ownerships.length}</div></div>
</div>
<div class="grid-2" style="margin-top: 18px;">
<div class="panel">
<div class="panel-head">
<h3>법인정보</h3>
<div class="panel-tools">
<button class="ghost small" data-master-add="entities" ${isAdmin() ? "" : "disabled"}>행 추가</button>
<button class="secondary small" data-master-reset="entities" ${isAdmin() ? "" : "disabled"}>되돌리기</button>
<button class="small" data-master-save="entities" ${isAdmin() ? "" : "disabled"}>저장</button>
</div>
</div>
<div class="master-meta">
${sectionStatus("entities")}
<p class="muted">기존 행에서는 법인코드를 변경할 수 없습니다.</p>
</div>
${sectionMessage("entities")}
${editableTable(
"entities",
[
{ id: "entityCode", text: "법인코드", lockOnExisting: true },
{ id: "entityName", text: "법인명" },
{ id: "baseCurrency", text: "통화" }
],
entities
)}
</div>
<div class="panel">
<h3>계정코드</h3>
${table([{ id: "accountCode", text: "계정" }, { id: "accountName", text: "계정명" }, { id: "accountCategory", text: "분류" }, { id: "internalTradeYn", text: "내부거래" }], accounts)}
</div>
<div class="panel">
<div class="panel-head">
<h3>환율</h3>
<div class="panel-tools">
<button class="ghost small" data-master-add="fxRates" ${isAdmin() ? "" : "disabled"}>행 추가</button>
<button class="secondary small" data-master-reset="fxRates" ${isAdmin() ? "" : "disabled"}>되돌리기</button>
<button class="small" data-master-save="fxRates" ${isAdmin() ? "" : "disabled"}>저장</button>
</div>
</div>
<div class="master-meta">
${sectionStatus("fxRates")}
<p class="muted">기존 행에서는 회계기간과 통화를 변경할 수 없습니다.</p>
</div>
${sectionMessage("fxRates")}
${editableTable(
"fxRates",
[
{ id: "fiscalPeriod", text: "회계기간", lockOnExisting: true },
{ id: "currencyCode", text: "통화", lockOnExisting: true },
{ id: "rateToKrw", text: "환산율", type: "number", step: "0.000001" }
],
fxRates
)}
</div>
<div class="panel">
<h3>지분율</h3>
${table([{ id: "parentEntityCode", text: "모법인" }, { id: "childEntityCode", text: "자법인" }, { id: "ownershipRatio", text: "지분율" }], ownerships)}
</div>
</div>
`;
}
function renderUploads() {
const datasets = state.uploads?.datasets || {};
const batches = datasets.uploadBatches || [];
const issues = datasets.validationIssues || [];
return `
${heroContent()}
<div class="panel">
<h3>업로드</h3>
<div class="row"><label>템플릿</label>
<select id="upload-template">
<option value="trial-balance">trial-balance</option>
<option value="forecast">forecast</option>
</select>
</div>
<div class="row"><label>회계기간</label><input id="upload-period" value="2026-03" /></div>
<div class="row"><label>파일 선택</label><input id="upload-file" type="file" /></div>
<div class="row actions">
<button data-action="upload">파일 업로드</button>
<button class="secondary" data-action="reload-uploads">내역 새로고침</button>
<a class="ghost" href="/sample-data/trial-balance-invalid.xlsx" download>오류 샘플</a>
<a class="ghost" href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
<a class="ghost" href="/sample-data/forecast-valid.xlsx" download>정상 Forecast</a>
</div>
</div>
<div class="panel">
<h3>업로드 이력</h3>
${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;
}
}
)}
</div>
<div class="panel">
<h3>오류내역</h3>
${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;
}
}
)}
</div>
`;
}
function renderConsolidation() {
const runs = state.runs?.datasets?.runs || [];
return `
${heroContent()}
<div class="panel">
<h3>집계 실행</h3>
<div class="row"><label>회계기간</label><input id="run-period" value="2026-03" /></div>
<div class="row actions">
<button data-action="request-run">집계 실행</button>
<button class="secondary" data-action="reload-runs">상태 새로고침</button>
</div>
</div>
<div class="panel">
<h3>집계 이력</h3>
${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;
}
}
)}
</div>
`;
}
function renderReports() {
const datasets = state.reports?.datasets || {};
const artifacts = datasets.artifacts || [];
const logs = datasets.jobLogs || [];
return `
${heroContent()}
<div class="grid-3">
<div class="status-card"><div class="label">산출물 수</div><div class="value">${artifacts.length}</div></div>
<div class="status-card"><div class="label">최근 로그 수</div><div class="value">${logs.length}</div></div>
<div class="status-card"><div class="label">세션 사용자</div><div class="value">${state.session?.fullName || "-"}</div></div>
</div>
<div class="panel" style="margin-top: 18px;">
<h3>리포트 산출물</h3>
${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 `<a href="/api/reports/${row.id}/download">${value}</a>`;
}
return value;
}
}
)}
</div>
<div class="panel">
<h3>최근 배치 로그</h3>
${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;
}
}
)}
</div>
`;
}
function shellContent(content) {
return `
<div class="shell">
<aside class="sidebar">
<div class="brand">
<h1>{{appTitle}}</h1>
<p>Spec driven preview generated from Nexacro DSL</p>
</div>
<div class="nav-list">${renderNav()}</div>
<div class="panel" style="margin-top: 18px;">
<h3 style="margin-top:0;">세션</h3>
<div class="muted">${state.session ? `${state.session.fullName} / ${state.session.roleCode}` : "로그인 필요"}</div>
<div class="row actions" style="margin-top: 12px;">
<button class="secondary" data-action="logout" ${state.session ? "" : "disabled"}>로그아웃</button>
</div>
</div>
</aside>
<main class="main">
<div class="topbar">
<div></div>
<div class="footer-links">
<a href="/sample-data/trial-balance-invalid.xlsx" download>오류 샘플</a>
<a href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
<a href="/sample-data/forecast-valid.xlsx" download>정상 Forecast</a>
</div>
</div>
${content}
</main>
</div>
`;
}
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 = "<div class='panel'>정의되지 않은 화면입니다.</div>";
}
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 = `<div class="panel"><h3>초기화 실패</h3><p>${error.message}</p></div>`;
});

View File

@ -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; }
}

View File

@ -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) => \`<tr>\${columns
.map((column) => {
const value = options.render ? options.render(column, row[column.id], row) : row[column.id];
return \`<td>\${formatValue(value)}</td>\`;
})
.join("")}</tr>\`
)
.join("")
: \`<tr><td colspan="\${columns.length}">데이터가 없습니다.</td></tr>\`;
return \`<div class="table-wrap"><table><thead><tr>\${columns
.map((column) => \`<th>\${column.text}</th>\`)
.join("")}</tr></thead><tbody>\${body}</tbody></table></div>\`;
}
function pill(value) {
return \`<span class="pill \${value}">\${value}</span>\`;
}
function renderNav() {
return window.HANWHA_FORMS.map((form) => {
const active = state.currentRoute === form.route ? "active" : "";
const locked = form.authority !== "PUBLIC" && !state.session;
return \`<button class="nav-item \${active}" data-route="\${form.route}" \${locked ? "disabled" : ""}><strong>\${form.title}</strong><div class="muted">\${form.authority}</div></button>\`;
}).join("");
}
function heroContent() {
const form = formMap.get(state.currentRoute);
const note = form?.messages?.[0]?.text || "Spec driven preview";
return \`<div class="hero-card"><h2>\${form.title}</h2><p>\${note}</p></div>\`;
}
function renderLogin() {
return \`
\${heroContent()}
<div class="panel login-box">
<h3>세션 로그인</h3>
<div class="notice">기본 계정: admin/operator/viewer / demo1234</div>
<div class="row"><label>사용자 ID</label><input id="login-username" value="admin" /></div>
<div class="row"><label>비밀번호</label><input id="login-password" type="password" value="demo1234" /></div>
<div class="row actions">
<button data-action="login">로그인</button>
</div>
</div>
\`;
}
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()}
<div class="grid-4">
<div class="status-card"><div class="label">법인 </div><div class="value">\${entities.length}</div></div>
<div class="status-card"><div class="label">계정 </div><div class="value">\${accounts.length}</div></div>
<div class="status-card"><div class="label">환율 </div><div class="value">\${fxRates.length}</div></div>
<div class="status-card"><div class="label">지분율 </div><div class="value">\${ownerships.length}</div></div>
</div>
<div class="grid-2" style="margin-top: 18px;">
<div class="panel">
<h3>법인정보</h3>
\${table([{ id: "entityCode", text: "법인코드" }, { id: "entityName", text: "법인명" }, { id: "baseCurrency", text: "통화" }], entities)}
</div>
<div class="panel">
<h3>계정코드</h3>
\${table([{ id: "accountCode", text: "계정" }, { id: "accountName", text: "계정명" }, { id: "accountCategory", text: "분류" }, { id: "internalTradeYn", text: "내부거래" }], accounts)}
</div>
<div class="panel">
<h3>환율</h3>
\${table([{ id: "fiscalPeriod", text: "회계기간" }, { id: "currencyCode", text: "통화" }, { id: "rateToKrw", text: "환산율" }], fxRates)}
</div>
<div class="panel">
<h3>지분율</h3>
\${table([{ id: "parentEntityCode", text: "모법인" }, { id: "childEntityCode", text: "자법인" }, { id: "ownershipRatio", text: "지분율" }], ownerships)}
</div>
</div>
\`;
}
function renderUploads() {
const datasets = state.uploads?.datasets || {};
const batches = datasets.uploadBatches || [];
const issues = datasets.validationIssues || [];
return \`
\${heroContent()}
<div class="panel">
<h3>업로드</h3>
<div class="row"><label>템플릿</label>
<select id="upload-template">
<option value="trial-balance">trial-balance</option>
<option value="forecast">forecast</option>
</select>
</div>
<div class="row"><label>회계기간</label><input id="upload-period" value="2026-03" /></div>
<div class="row"><label>파일 선택</label><input id="upload-file" type="file" /></div>
<div class="row actions">
<button data-action="upload">파일 업로드</button>
<button class="secondary" data-action="reload-uploads">내역 새로고침</button>
<a class="ghost" href="/sample-data/trial-balance-invalid.xlsx" download>오류 샘플</a>
<a class="ghost" href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
<a class="ghost" href="/sample-data/forecast-valid.xlsx" download>정상 Forecast</a>
</div>
</div>
<div class="panel">
<h3>업로드 이력</h3>
\${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;
}
}
)}
</div>
<div class="panel">
<h3>오류내역</h3>
\${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;
}
}
)}
</div>
\`;
}
function renderConsolidation() {
const runs = state.runs?.datasets?.runs || [];
return \`
\${heroContent()}
<div class="panel">
<h3>집계 실행</h3>
<div class="row"><label>회계기간</label><input id="run-period" value="2026-03" /></div>
<div class="row actions">
<button data-action="request-run">집계 실행</button>
<button class="secondary" data-action="reload-runs">상태 새로고침</button>
</div>
</div>
<div class="panel">
<h3>집계 이력</h3>
\${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;
}
}
)}
</div>
\`;
}
function renderReports() {
const datasets = state.reports?.datasets || {};
const artifacts = datasets.artifacts || [];
const logs = datasets.jobLogs || [];
return \`
\${heroContent()}
<div class="grid-3">
<div class="status-card"><div class="label">산출물 </div><div class="value">\${artifacts.length}</div></div>
<div class="status-card"><div class="label">최근 로그 </div><div class="value">\${logs.length}</div></div>
<div class="status-card"><div class="label">세션 사용자</div><div class="value">\${state.session?.fullName || "-"}</div></div>
</div>
<div class="panel" style="margin-top: 18px;">
<h3>리포트 산출물</h3>
\${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 \`<a href="/api/reports/\${row.id}/download">\${value}</a>\`;
}
return value;
}
}
)}
</div>
<div class="panel">
<h3>최근 배치 로그</h3>
\${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;
}
}
)}
</div>
\`;
}
function shellContent(content) {
return \`
<div class="shell">
<aside class="sidebar">
<div class="brand">
<h1>${escapeXml(appSpec.appTitle)}</h1>
<p>Spec driven preview generated from Nexacro DSL</p>
</div>
<div class="nav-list">\${renderNav()}</div>
<div class="panel" style="margin-top: 18px;">
<h3 style="margin-top:0;">세션</h3>
<div class="muted">\${state.session ? \`\${state.session.fullName} / \${state.session.roleCode}\` : "로그인 필요"}</div>
<div class="row actions" style="margin-top: 12px;">
<button class="secondary" data-action="logout" \${state.session ? "" : "disabled"}>로그아웃</button>
</div>
</div>
</aside>
<main class="main">
<div class="topbar">
<div></div>
<div class="footer-links">
<a href="/sample-data/trial-balance-invalid.xlsx" download>오류 샘플</a>
<a href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
<a href="/sample-data/forecast-valid.xlsx" download>정상 Forecast</a>
</div>
</div>
\${content}
</main>
</div>
\`;
}
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 = "<div class='panel'>정의되지 않은 화면입니다.</div>";
}
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 = \`<div class="panel"><h3>초기화 실패</h3><p>\${error.message}</p></div>\`;
});
`;
}
function generatePreview(appSpec, forms, basePreviewDir = previewDir) {