/* ============================================================================= * M01_MasterData_UI_Page.ts * 마스터 요소 화면 — 좌측 도킹 패널(그룹 탭 7 · 파일 고르기) / 우측 요소 표 · 표 목록 * * 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 — * 자동저장 없음. 로직 그룹은 다음 화면. * ========================================================================== */ import "@ui/ui_template_workflow_layout.css"; import { ROUTES } from "@config/config_frontend"; import { createButton, el, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchFiles, fetchGroups, saveFiles, type FileInfo, type GroupInfo, } from "./M01_MasterData_Api_Fetch"; import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft"; import { renderRows } from "./M01_MasterData_UI_Rows"; import { renderTables } from "./M01_MasterData_UI_Tables"; import "./M01_MasterData_UI_Style.css"; const TABLE_GROUPS = ["소요량", "계수"]; const fileLabel = (file: string): string => file.replace(/^[^_]+_/, "").replace(/\.json$/, ""); export async function renderM01MasterData(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); if (user?.role !== "SYSTEM_ADMIN") { showToast(L("M01_AdminOnly"), "error"); navigateTo(ROUTES.B01_ACCOUNT); return; } showLoadingOverlay(); let groups: GroupInfo[] = []; try { groups = (await fetchGroups()).filter((g) => g.group !== "로직"); } catch (error) { showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); } finally { hideLoadingOverlay(); } root.innerHTML = ""; root.append(buildPage(groups)); } function buildPage(groups: GroupInfo[]): HTMLElement { let group = ""; let current: FileInfo | null = null; let query = ""; let dispose = (): void => {}; /* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */ const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") }); const search = el("input", { className: "m01-master__search", attrs: { type: "search", placeholder: L("M01_Search") }, }); const summary = el("span", { className: "m01-master__summary" }); const save = createButton({ label: L("M01_Save"), variant: "filled" }); const drop = createButton({ label: L("M01_Discard"), variant: "ghost" }); const notice = el("div", { className: "m01-master__notice", attrs: { hidden: "" } }); const body = el("div", { className: "m01-master__body" }); const head = el("div", { className: "m01-master__head", children: [title, search, summary, drop, save], }); const showNotice = (nodes: (HTMLElement | string)[]): void => { notice.replaceChildren(...nodes); notice.hidden = nodes.length === 0; }; const refreshBar = (): void => { const n = totalCount(); summary.textContent = n ? L("M01_Changed").replace("{value}", String(n)) : ""; save.disabled = drop.disabled = n === 0; }; onDraftChange(refreshBar); refreshBar(); const openFile = (file: FileInfo): void => { dispose(); current = file; title.textContent = `${group} · ${fileLabel(file.file)}`; const view = TABLE_GROUPS.includes(group) ? renderTables : renderRows; dispose = view(body, file.file, query); showNotice(isStale(file.file, file.version) ? [L("M01_FileStale")] : []); }; let searchTimer: number | undefined; search.addEventListener("input", () => { window.clearTimeout(searchTimer); searchTimer = window.setTimeout(() => { query = search.value.trim(); if (current) openFile(current); }, 300); }); drop.addEventListener("click", () => { if (!window.confirm(L("M01_DiscardConfirm"))) return; discard(); showNotice([]); }); save.addEventListener("click", async () => { save.disabled = true; try { const result = await saveFiles(payload()); if (result.status === 200 && "files" in result) { discard(result.files.map((f) => f.file)); showNotice([]); showToast(L("M01_Saved"), "success"); await reloadFiles(); } else if (result.status === 409 && "stale" in result) { showNotice(staleNotice(result.stale)); } else if (result.status === 422 && "errors" in result) { showNotice([ el("strong", { text: L("M01_CheckErrors") }), ...result.errors.map((e) => el("p", { text: e })), ]); } else if ("detail" in result) { showNotice([L("M01_SaveFailed").replace("{value}", result.detail)]); } } catch (error) { showNotice([ L("M01_SaveFailed").replace("{value}", error instanceof Error ? error.message : ""), ]); } finally { refreshBar(); } }); const staleNotice = (stale: string[]): (HTMLElement | string)[] => { const again = createButton({ label: L("M01_StaleReload"), variant: "ghost" }); again.addEventListener("click", async () => { discard(stale); showNotice([]); await reloadFiles(); }); return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again]; }; /* --- 좌측: 그룹 탭 + 파일 --- */ const tabs = el("div", { className: "m01-master__tabs" }); const files = el("div", { className: "m01-master__files" }); const reloadFiles = async (): Promise => { if (!group) return; let list: FileInfo[] = []; try { list = await fetchFiles(group); } catch (error) { showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); } files.replaceChildren( ...list.map((f) => { const button = el("button", { className: "m01-master__file", attrs: { type: "button" }, children: [ el("span", { text: fileLabel(f.file) }), el("span", { className: "m01-master__muted", text: f.rows.toLocaleString("ko-KR") }), ], }); button.classList.toggle("is-active", f.file === current?.file); button.addEventListener("click", () => { files.querySelector(".is-active")?.classList.remove("is-active"); button.classList.add("is-active"); openFile(f); }); return button; }), ); const again = list.find((f) => f.file === current?.file); if (again) openFile(again); }; for (const g of groups) { const tab = el("button", { className: "m01-master__tab", attrs: { type: "button" }, children: [ el("span", { text: g.group }), el("span", { className: "m01-master__muted", text: String(g.files) }), ], }); tab.addEventListener("click", () => { tabs.querySelector(".is-active")?.classList.remove("is-active"); tab.classList.add("is-active"); group = g.group; current = null; dispose(); body.replaceChildren(); title.textContent = L("M01_PickFile"); void reloadFiles(); }); tabs.append(tab); } const logicTab = el("button", { className: "m01-master__tab", attrs: { type: "button" }, text: L("M01_LogicTab"), }); logicTab.addEventListener("click", () => { tabs.querySelector(".is-active")?.classList.remove("is-active"); logicTab.classList.add("is-active"); group = ""; current = null; dispose(); files.replaceChildren(); body.replaceChildren(); title.textContent = L("M01_LogicTab"); void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(body)); }); tabs.append(logicTab); const layout = el("div", { className: "ui-workflow-layout m01-master" }); const main = el("main", { className: "ui-workflow-layout__main", children: [el("div", { className: "m01-master__panel", children: [head, notice, body] })], }); const overlays = createWorkflowOverlays({ title: L("M01_Title"), optionsContent: el("div", { className: "m01-master__side", children: [tabs, files] }), showProjectName: false, onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen), }); layout.append( el("div", { className: "ui-workflow-layout__body", children: [main] }), overlays.root, ); return layout; }