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) => `| ${column.text} | `)
.join("")}${options.actions ? "작업 | " : ""}
${body}
`;
}
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 ``;
}
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()}
`;
}
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;
},
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()}
세션 사용자
${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 = ``;
});