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:
parent
bbaa6f3e0b
commit
75a786f681
|
|
@ -70,6 +70,7 @@ const state = {
|
||||||
currentRoute: "login",
|
currentRoute: "login",
|
||||||
session: null,
|
session: null,
|
||||||
master: null,
|
master: null,
|
||||||
|
masterEditor: null,
|
||||||
uploads: null,
|
uploads: null,
|
||||||
runs: null,
|
runs: null,
|
||||||
reports: null,
|
reports: null,
|
||||||
|
|
@ -100,6 +101,14 @@ async function api(path, options = {}) {
|
||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """);
|
||||||
|
}
|
||||||
|
|
||||||
function formatValue(value) {
|
function formatValue(value) {
|
||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
return "-";
|
return "-";
|
||||||
|
|
@ -130,6 +139,114 @@ function pill(value) {
|
||||||
return `<span class="pill ${value}">${value}</span>`;
|
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() {
|
function renderNav() {
|
||||||
return window.HANWHA_FORMS.map((form) => {
|
return window.HANWHA_FORMS.map((form) => {
|
||||||
const active = state.currentRoute === form.route ? "active" : "";
|
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>`;
|
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() {
|
function renderLogin() {
|
||||||
return `
|
return `
|
||||||
${heroContent()}
|
${heroContent()}
|
||||||
|
|
@ -161,13 +320,19 @@ function renderLogin() {
|
||||||
|
|
||||||
function renderMaster() {
|
function renderMaster() {
|
||||||
const datasets = state.master?.datasets || {};
|
const datasets = state.master?.datasets || {};
|
||||||
const entities = datasets.entities || [];
|
|
||||||
const accounts = datasets.accounts || [];
|
const accounts = datasets.accounts || [];
|
||||||
const fxRates = datasets.fxRates || [];
|
|
||||||
const ownerships = datasets.ownerships || [];
|
const ownerships = datasets.ownerships || [];
|
||||||
|
const editor = ensureMasterEditor();
|
||||||
|
const entities = editor.entities || [];
|
||||||
|
const fxRates = editor.fxRates || [];
|
||||||
|
const adminOnlyNotice = isAdmin()
|
||||||
|
? ""
|
||||||
|
: `<div class="notice">기준정보 저장은 ADMIN 권한에서만 가능합니다. 현재는 읽기 전용입니다.</div>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
${heroContent()}
|
${heroContent()}
|
||||||
|
<div class="notice warn">기준정보를 수정하거나 삭제하면 이후 업로드 검증과 집계 결과가 달라질 수 있습니다.</div>
|
||||||
|
${adminOnlyNotice}
|
||||||
<div class="grid-4">
|
<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">${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">${accounts.length}</div></div>
|
||||||
|
|
@ -176,16 +341,56 @@ function renderMaster() {
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-2" style="margin-top: 18px;">
|
<div class="grid-2" style="margin-top: 18px;">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>법인정보</h3>
|
<div class="panel-head">
|
||||||
${table([{ id: "entityCode", text: "법인코드" }, { id: "entityName", text: "법인명" }, { id: "baseCurrency", text: "통화" }], entities)}
|
<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>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>계정코드</h3>
|
<h3>계정코드</h3>
|
||||||
${table([{ id: "accountCode", text: "계정" }, { id: "accountName", text: "계정명" }, { id: "accountCategory", text: "분류" }, { id: "internalTradeYn", text: "내부거래" }], accounts)}
|
${table([{ id: "accountCode", text: "계정" }, { id: "accountName", text: "계정명" }, { id: "accountCategory", text: "분류" }, { id: "internalTradeYn", text: "내부거래" }], accounts)}
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>환율</h3>
|
<div class="panel-head">
|
||||||
${table([{ id: "fiscalPeriod", text: "회계기간" }, { id: "currencyCode", text: "통화" }, { id: "rateToKrw", text: "환산율" }], fxRates)}
|
<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>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>지분율</h3>
|
<h3>지분율</h3>
|
||||||
|
|
@ -433,6 +638,7 @@ async function loadSession() {
|
||||||
async function loadMaster() {
|
async function loadMaster() {
|
||||||
if (!state.session) return;
|
if (!state.session) return;
|
||||||
state.master = await api("/api/tx/master/reference");
|
state.master = await api("/api/tx/master/reference");
|
||||||
|
syncMasterEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUploads() {
|
async function loadUploads() {
|
||||||
|
|
@ -456,6 +662,7 @@ async function refreshAll() {
|
||||||
await Promise.all([loadMaster(), loadUploads(), loadRuns(), loadReports()]);
|
await Promise.all([loadMaster(), loadUploads(), loadRuns(), loadReports()]);
|
||||||
} else {
|
} else {
|
||||||
state.master = null;
|
state.master = null;
|
||||||
|
state.masterEditor = null;
|
||||||
state.uploads = null;
|
state.uploads = null;
|
||||||
state.runs = null;
|
state.runs = null;
|
||||||
state.reports = null;
|
state.reports = null;
|
||||||
|
|
@ -463,11 +670,36 @@ async function refreshAll() {
|
||||||
render();
|
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() {
|
function bindEvents() {
|
||||||
document.querySelectorAll("[data-route]").forEach((element) => {
|
document.querySelectorAll("[data-route]").forEach((element) => {
|
||||||
element.addEventListener("click", async () => {
|
element.addEventListener("click", async () => {
|
||||||
state.currentRoute = element.dataset.route;
|
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 === "uploads") await loadUploads();
|
||||||
if (state.currentRoute === "consolidation") await loadRuns();
|
if (state.currentRoute === "consolidation") await loadRuns();
|
||||||
if (state.currentRoute === "reports") await loadReports();
|
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']");
|
const uploadButton = document.querySelector("[data-action='upload']");
|
||||||
if (uploadButton) {
|
if (uploadButton) {
|
||||||
uploadButton.addEventListener("click", async () => {
|
uploadButton.addEventListener("click", async () => {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@
|
||||||
--accent-soft: #ffe6d1;
|
--accent-soft: #ffe6d1;
|
||||||
--blue: #1f5fbf;
|
--blue: #1f5fbf;
|
||||||
--danger: #c53b3b;
|
--danger: #c53b3b;
|
||||||
|
--danger-soft: #fde8e8;
|
||||||
--success: #237c52;
|
--success: #237c52;
|
||||||
|
--success-soft: #e8f8ef;
|
||||||
--shadow: 0 16px 40px rgba(16, 32, 58, 0.08);
|
--shadow: 0 16px 40px rgba(16, 32, 58, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +45,21 @@ button.ghost {
|
||||||
background: var(--surface-alt);
|
background: var(--surface-alt);
|
||||||
color: var(--blue);
|
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 {
|
input, select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|
@ -111,11 +128,6 @@ input, select {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card,
|
.status-card,
|
||||||
.panel,
|
.panel,
|
||||||
.hero-card {
|
.hero-card {
|
||||||
|
|
@ -174,6 +186,20 @@ input, select {
|
||||||
margin-bottom: 14px;
|
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 {
|
.stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
@ -207,6 +233,21 @@ input, select {
|
||||||
margin-bottom: 16px;
|
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 {
|
.table-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
@ -221,6 +262,7 @@ th, td {
|
||||||
padding: 12px 10px;
|
padding: 12px 10px;
|
||||||
border-bottom: 1px solid #ecf0f6;
|
border-bottom: 1px solid #ecf0f6;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
|
|
@ -228,6 +270,28 @@ th {
|
||||||
font-weight: 700;
|
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 {
|
.pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -241,6 +305,7 @@ th {
|
||||||
.pill.ACCEPTED, .pill.SUCCESS { background: rgba(35, 124, 82, 0.12); color: var(--success); }
|
.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.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.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 {
|
.login-box {
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
|
|
@ -261,4 +326,5 @@ th {
|
||||||
.shell { grid-template-columns: 1fr; }
|
.shell { grid-template-columns: 1fr; }
|
||||||
.sidebar { border-right: 0; border-bottom: 1px solid rgba(216, 223, 235, 0.7); }
|
.sidebar { border-right: 0; border-bottom: 1px solid rgba(216, 223, 235, 0.7); }
|
||||||
.grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; }
|
.grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; }
|
||||||
|
.panel-head, .master-meta { flex-direction: column; align-items: flex-start; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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("&", "&")
|
||||||
|
.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("")}</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>`;
|
||||||
|
});
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -353,762 +353,13 @@ function previewScript(forms) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewCss() {
|
function previewCss() {
|
||||||
return `:root {
|
return readTemplate("preview.css.tpl");
|
||||||
--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; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewAppJs(appSpec) {
|
function previewAppJs(appSpec) {
|
||||||
return `const state = {
|
return renderTemplate(readTemplate("preview-app.js.tpl"), {
|
||||||
currentRoute: "login",
|
appTitle: appSpec.appTitle
|
||||||
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>${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) {
|
function generatePreview(appSpec, forms, basePreviewDir = previewDir) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue