882 lines
28 KiB
JavaScript
882 lines
28 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": "배치가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다."
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"formId": "frmUploadValidation",
|
|
"title": "업로드/검증",
|
|
"route": "uploads",
|
|
"authority": "OPERATOR",
|
|
"messages": [
|
|
{
|
|
"code": "UPLOAD_HINT",
|
|
"level": "INFO",
|
|
"text": "오류 샘플로 오류 시나리오를 확인한 뒤 정상 샘플을 재업로드합니다."
|
|
}
|
|
]
|
|
}
|
|
];
|
|
|
|
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) => `<tr>${columns
|
|
.map((column) => {
|
|
const value = options.render ? options.render(column, row[column.id], row) : row[column.id];
|
|
return `<td>${formatValue(value)}</td>`;
|
|
})
|
|
.join("")}${options.actions ? `<td class="actions-cell">${options.actions(row) || ""}</td>` : ""}</tr>`
|
|
)
|
|
.join("")
|
|
: `<tr><td colspan="${columns.length + (options.actions ? 1 : 0)}">데이터가 없습니다.</td></tr>`;
|
|
|
|
return `<div class="table-wrap"><table><thead><tr>${columns
|
|
.map((column) => `<th>${column.text}</th>`)
|
|
.join("")}${options.actions ? "<th>작업</th>" : ""}</tr></thead><tbody>${body}</tbody></table></div>`;
|
|
}
|
|
|
|
function pill(value) {
|
|
return `<span class="pill ${value}">${value}</span>`;
|
|
}
|
|
|
|
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 `<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" ${isOperator() ? "" : "disabled"}>파일 업로드</button>
|
|
<button class="secondary" data-action="reload-uploads">내역 새로고침</button>
|
|
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-invalid">오류 샘플</button>
|
|
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-valid">정상 TB</button>
|
|
<button class="ghost" data-action="download-sample" data-sample-code="forecast-valid">정상 전망</button>
|
|
</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;
|
|
},
|
|
actions(row) {
|
|
return `<button class="danger small" data-action="delete-upload" data-batch-id="${row.id}" ${isOperator() ? "" : "disabled"}>삭제</button>`;
|
|
}
|
|
}
|
|
)}
|
|
</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>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">
|
|
${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 () => {
|
|
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 = `<div class="panel"><h3>초기화 실패</h3><p>${error.message}</p></div>`;
|
|
});
|