hanhwa_nexacro/client/nexacro-deploy/assets/app.js

556 lines
17 KiB
JavaScript

window.HANWHA_FORMS = [
{
"formId": "frmConsolidation",
"title": "집계 실행",
"route": "consolidation",
"authority": "OPERATOR",
"messages": [
{
"code": "RUN_HINT",
"level": "INFO",
"text": "업로드가 ACCEPTED 상태인 파일만 집계 대상에 포함됩니다."
}
]
},
{
"formId": "frmLogin",
"title": "로그인",
"route": "login",
"authority": "PUBLIC",
"messages": [
{
"code": "LOGIN_HINT",
"level": "INFO",
"text": "기본 계정 admin/operator/viewer, 비밀번호 demo1234"
}
]
},
{
"formId": "frmMasterData",
"title": "기준정보 관리",
"route": "master",
"authority": "VIEWER",
"messages": [
{
"code": "MASTER_LOAD",
"level": "INFO",
"text": "기준정보, 계정, 환율, 지분율을 확인합니다."
}
]
},
{
"formId": "frmReportsOps",
"title": "리포트/운영",
"route": "reports",
"authority": "VIEWER",
"messages": [
{
"code": "REPORT_HINT",
"level": "INFO",
"text": "batch가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다."
}
]
},
{
"formId": "frmUploadValidation",
"title": "업로드/검증",
"route": "uploads",
"authority": "OPERATOR",
"messages": [
{
"code": "UPLOAD_HINT",
"level": "INFO",
"text": "invalid 샘플로 오류 시나리오를 확인한 뒤 valid 샘플을 재업로드합니다."
}
]
}
];
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
});
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>Hanwha Nexacro Demo</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>`;
});