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("")}${options.actions ? `${options.actions(row) || ""}` : ""}` ) .join("") : `데이터가 없습니다.`; return `
${columns .map((column) => ``) .join("")}${options.actions ? "" : ""}${body}
${column.text}작업
`; } function pill(value) { return `${value}`; } function hasRole(requiredRole) { const roleOrder = { PUBLIC: 0, VIEWER: 10, OPERATOR: 20, ADMIN: 30 }; return (roleOrder[state.session?.roleCode] || 0) >= (roleOrder[requiredRole] || 0); } function isAdmin() { return hasRole("ADMIN"); } function isOperator() { return hasRole("OPERATOR"); } function cloneRows(rows) { return (rows || []).map((row) => ({ ...row })); } function createMasterEditor() { const datasets = state.master?.datasets || {}; return { entities: cloneRows(datasets.entities || []), fxRates: cloneRows(datasets.fxRates || []), dirty: { entities: false, fxRates: false }, feedback: { entities: null, fxRates: null }, errors: { entities: null, fxRates: null } }; } function syncMasterEditor() { state.masterEditor = createMasterEditor(); } function ensureMasterEditor() { if (!state.masterEditor) { syncMasterEditor(); } return state.masterEditor; } function resetMasterSection(section) { const datasets = state.master?.datasets || {}; const editor = ensureMasterEditor(); if (section === "entities") { editor.entities = cloneRows(datasets.entities || []); } else { editor.fxRates = cloneRows(datasets.fxRates || []); } editor.dirty[section] = false; editor.feedback[section] = null; editor.errors[section] = null; } function setSectionError(section, message) { const editor = ensureMasterEditor(); editor.errors[section] = message; editor.feedback[section] = null; } function setSectionFeedback(section, message) { const editor = ensureMasterEditor(); editor.feedback[section] = message; editor.errors[section] = null; } function markMasterDirty(section) { const editor = ensureMasterEditor(); editor.dirty[section] = true; editor.feedback[section] = null; editor.errors[section] = null; } function emptyEntityRow() { return { entityCode: "", entityName: "", baseCurrency: "KRW", __isNew: true }; } function emptyFxRateRow() { return { fiscalPeriod: "", currencyCode: "KRW", rateToKrw: "", __isNew: true }; } function normalizeEntityRows(rows) { return rows.map((row) => ({ entityCode: String(row.entityCode || "").trim(), entityName: String(row.entityName || "").trim(), baseCurrency: String(row.baseCurrency || "").trim().toUpperCase() })); } function normalizeFxRateRows(rows) { return rows.map((row) => { const numericValue = Number(String(row.rateToKrw ?? "").trim()); return { fiscalPeriod: String(row.fiscalPeriod || "").trim(), currencyCode: String(row.currencyCode || "").trim().toUpperCase(), rateToKrw: Number.isFinite(numericValue) ? numericValue : null }; }); } function renderNav() { return window.HANWHA_FORMS.map((form) => { const active = state.currentRoute === form.route ? "active" : ""; const locked = form.authority !== "PUBLIC" && !state.session; return ``; }).join(""); } function heroContent() { const form = formMap.get(state.currentRoute); const note = form?.messages?.[0]?.text || "Spec driven preview"; return `

${form.title}

${note}

`; } function sectionStatus(section) { const editor = ensureMasterEditor(); if (editor.dirty[section]) { return pill("DIRTY"); } return `서버와 동기화됨`; } function sectionMessage(section) { const editor = ensureMasterEditor(); if (editor.errors[section]) { return `
${escapeHtml(editor.errors[section])}
`; } if (editor.feedback[section]) { return `
${escapeHtml(editor.feedback[section])}
`; } return ""; } function editableTable(section, columns, rows) { const canEdit = isAdmin(); const body = rows.length ? rows .map( (row, index) => `${columns .map((column) => { const value = row[column.id] ?? ""; const type = column.type || "text"; const step = column.step ? `step="${column.step}"` : ""; const readOnly = column.lockOnExisting && !row.__isNew; return ``; }) .join("")}` ) .join("") : `데이터가 없습니다.`; return `
${columns .map((column) => ``) .join("")}${body}
${column.text}삭제
`; } function renderLogin() { return ` ${heroContent()}

세션 로그인

기본 계정: 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}
법인 수
${entities.length}
계정 수
${accounts.length}
환율 수
${fxRates.length}
지분율 수
${ownerships.length}

법인정보

${sectionStatus("entities")}

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

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

계정코드

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

환율

${sectionStatus("fxRates")}

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

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

지분율

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

업로드

업로드 이력

${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; }, actions(row) { return ``; } } )}

오류내역

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

집계 실행

집계 이력

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

리포트 산출물

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

최근 배치 로그

${table( [ { id: "id", text: "로그ID" }, { id: "jobType", text: "작업유형" }, { id: "referenceId", text: "참조ID" }, { id: "logLevel", text: "레벨" }, { id: "logMessage", text: "메시지" }, { id: "createdAt", text: "생성시각" } ], logs, { render(column, value) { if (column.id === "logLevel") { return pill(value); } return value; } } )}
`; } function shellContent(content) { return `
${content}
`; } function render() { let content = ""; switch (state.currentRoute) { case "login": content = renderLogin(); break; case "master": content = renderMaster(); break; case "uploads": content = renderUploads(); break; case "consolidation": content = renderConsolidation(); break; case "reports": content = renderReports(); break; default: content = "
정의되지 않은 화면입니다.
"; } document.getElementById("app").innerHTML = shellContent(content); bindEvents(); } async function loadSession() { try { state.session = await api("/api/auth/me"); } catch (error) { state.session = null; } } async function loadMaster() { if (!state.session) return; state.master = await api("/api/tx/master/reference"); syncMasterEditor(); } async function loadUploads() { if (!state.session) return; state.uploads = await api("/api/tx/uploads/overview"); } async function loadRuns() { if (!state.session) return; state.runs = await api("/api/tx/consolidations/overview"); } async function loadReports() { if (!state.session) return; state.reports = await api("/api/tx/reports/overview"); } async function refreshAll() { await loadSession(); if (state.session) { await Promise.all([loadMaster(), loadUploads(), loadRuns(), loadReports()]); } else { state.master = null; state.masterEditor = null; state.uploads = null; state.runs = null; state.reports = null; } render(); } async function saveMasterSection(section) { if (!isAdmin()) { return; } const editor = ensureMasterEditor(); const rows = section === "entities" ? normalizeEntityRows(editor.entities) : normalizeFxRateRows(editor.fxRates); const path = section === "entities" ? "/api/master/entities" : "/api/master/fx-rates"; try { const response = await api(path, { method: "PUT", body: JSON.stringify({ rows }) }); await loadMaster(); setSectionFeedback(section, response.message || "저장되었습니다."); render(); } catch (error) { setSectionError(section, error.message); render(); } } function bindEvents() { document.querySelectorAll("[data-route]").forEach((element) => { element.addEventListener("click", async () => { state.currentRoute = element.dataset.route; if (state.currentRoute === "master" && !state.masterEditor) await loadMaster(); if (state.currentRoute === "uploads") await loadUploads(); if (state.currentRoute === "consolidation") await loadRuns(); if (state.currentRoute === "reports") await loadReports(); render(); }); }); const loginButton = document.querySelector("[data-action='login']"); if (loginButton) { loginButton.addEventListener("click", async () => { const username = document.getElementById("login-username").value; const password = document.getElementById("login-password").value; await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); state.currentRoute = "master"; await refreshAll(); }); } const logoutButton = document.querySelector("[data-action='logout']"); if (logoutButton) { logoutButton.addEventListener("click", async () => { await api("/api/auth/logout", { method: "POST" }); state.currentRoute = "login"; await refreshAll(); }); } document.querySelectorAll("[data-master-add]").forEach((button) => { button.addEventListener("click", () => { const section = button.dataset.masterAdd; const editor = ensureMasterEditor(); if (section === "entities") { editor.entities.push(emptyEntityRow()); } else { editor.fxRates.push(emptyFxRateRow()); } markMasterDirty(section); render(); }); }); document.querySelectorAll("[data-master-delete]").forEach((button) => { button.addEventListener("click", () => { const section = button.dataset.masterDelete; const index = Number(button.dataset.masterIndex); const editor = ensureMasterEditor(); if (section === "entities") { editor.entities.splice(index, 1); } else { editor.fxRates.splice(index, 1); } markMasterDirty(section); render(); }); }); document.querySelectorAll("[data-master-reset]").forEach((button) => { button.addEventListener("click", () => { resetMasterSection(button.dataset.masterReset); render(); }); }); document.querySelectorAll("[data-master-save]").forEach((button) => { button.addEventListener("click", async () => { await saveMasterSection(button.dataset.masterSave); }); }); document.querySelectorAll("[data-master-field]").forEach((input) => { input.addEventListener("input", () => { const section = input.dataset.masterSection; const field = input.dataset.masterField; const index = Number(input.dataset.masterIndex); const editor = ensureMasterEditor(); const rows = section === "entities" ? editor.entities : editor.fxRates; rows[index][field] = input.value; markMasterDirty(section); }); input.addEventListener("change", () => { render(); }); }); const uploadButton = document.querySelector("[data-action='upload']"); if (uploadButton) { uploadButton.addEventListener("click", async () => { if (!isOperator()) { return; } 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(); }); } document.querySelectorAll("[data-action='download-sample']").forEach((button) => { button.addEventListener("click", () => { const fiscalPeriod = document.getElementById("upload-period")?.value?.trim() || "2026-03"; const sampleCode = button.dataset.sampleCode; window.location.href = `/api/uploads/samples/${sampleCode}/download?fiscalPeriod=${encodeURIComponent(fiscalPeriod)}`; }); }); document.querySelectorAll("[data-action='delete-upload']").forEach((button) => { button.addEventListener("click", async () => { if (!isOperator()) { return; } const batchId = button.dataset.batchId; const confirmed = window.confirm(`업로드 이력 ${batchId}번을 삭제하시겠습니까? 관련 오류내역과 업로드 행도 함께 삭제됩니다.`); if (!confirmed) { return; } await api(`/api/uploads/${batchId}`, { method: "DELETE" }); await loadUploads(); render(); }); }); const runButton = document.querySelector("[data-action='request-run']"); if (runButton) { runButton.addEventListener("click", async () => { const fiscalPeriod = document.getElementById("run-period").value; await api("/api/consolidations/runs", { method: "POST", body: JSON.stringify({ fiscalPeriod, reportCurrency: "KRW" }) }); await loadRuns(); render(); }); } const reloadRunsButton = document.querySelector("[data-action='reload-runs']"); if (reloadRunsButton) { reloadRunsButton.addEventListener("click", async () => { await loadRuns(); await loadReports(); render(); }); } } refreshAll().catch((error) => { console.error(error); document.getElementById("app").innerHTML = `

초기화 실패

${error.message}

`; });