/* ============================================================================= * Z01_MasterData_UI_Page.ts * 마스터 데이터 — 좌 트리(갈래 → 파일 → 표) / 우 고른 표의 실제 줄(쪽 나누기 · 검색) * * 시스템 관리자만(서버도 require_system_admin 으로 다시 막음) · 읽기 전용부터 * (2026-09-15 브레인 Z01 — 마스터를 사람이 보고 고칠 수 있게 먼저 만들고 그 위에 일위대가 재조립). * ========================================================================== */ import { ROUTES } from "@config/config_frontend"; import { createButton, createInputField, el, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { t as L } from "@ui/ui_template_locale"; import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchMasterRows, fetchMasterTree, type MasterFile, type MasterGroup, type MasterRows, type MasterTable, } from "./Z01_MasterData_Api_Fetch"; import { clampPage, columnTitle, latestOnly, pageCount, rowCells, shownColumns, } from "./Z01_MasterData_UI_Cells"; import "./Z01_MasterData_UI_Style.css"; const PAGE_SIZE = 50; const SEARCH_DELAY_MS = 300; export async function renderZ01MasterData(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); if (user?.role !== "SYSTEM_ADMIN") { showToast(L("Z01_MasterData_AdminOnly"), "error"); navigateTo(ROUTES.B01_ACCOUNT); return; } showLoadingOverlay(); let groups: MasterGroup[] = []; try { groups = await fetchMasterTree(); } catch (error) { showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); } finally { hideLoadingOverlay(); } root.innerHTML = ""; root.append(buildPage(groups)); } function buildPage(groups: MasterGroup[]): HTMLElement { const view = buildRowsView(); const tree = buildTree(groups, view.pick); const content = el("div", { className: "z01-master__content", children: [tree, view.root] }); return createGeneralLayout({ pageClass: "z01-master", title: L("Z01_MasterData_Title"), subtitle: L("Z01_MasterData_Subtitle"), content, }).root; } function buildTree( groups: MasterGroup[], pick: (file: MasterFile, table: MasterTable) => void, ): HTMLElement { const nav = el("nav", { className: "z01-master__tree" }); for (const group of groups) { const groupBox = folder("z01-master__group", group.label, String(group.files.length)); groupBox.open = true; // 부산물은 계산이 남긴 기록 — 고칠 정본이 아님(2026-09-15 브레인 갈래 나눔표). if (group.key === "byproduct") { groupBox.append( el("p", { className: "z01-master__note", text: L("Z01_MasterData_Byproduct") }), ); } for (const file of group.files) { const fileBox = folder( "z01-master__file", file.label, file.label === file.key ? "" : file.key, ); for (const table of file.tables) { const button = el("button", { className: "z01-master__table", attrs: { type: "button", title: table.key }, children: [ el("span", { text: table.label }), el("span", { className: "z01-master__key", text: table.row_count.toLocaleString("ko-KR"), }), ], }); button.addEventListener("click", () => { nav.querySelector(".z01-master__table.is-active")?.classList.remove("is-active"); button.classList.add("is-active"); pick(file, table); }); fileBox.append(button); } groupBox.append(fileBox); } nav.append(groupBox); } return nav; } /** 접는 칸 — 브라우저 기본
로 둠. */ function folder(className: string, label: string, aside: string): HTMLDetailsElement { const summary = el("summary", { children: [ el("span", { text: label }), el("span", { className: "z01-master__key", text: aside }), ], }); return el("details", { className, children: [summary] }); } interface RowsView { root: HTMLElement; pick: (file: MasterFile, table: MasterTable) => void; } function buildRowsView(): RowsView { const state = { file: null as MasterFile | null, table: null as MasterTable | null, page: 1, q: "", }; let showHidden = false; let last: MasterRows | null = null; let searchTimer = 0; const fetchLatest = latestOnly(fetchMasterRows); const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") }); const titleKey = el("span", { className: "z01-master__key" }); const search = createInputField({ type: "search", placeholder: L("Z01_MasterData_Search"), onInput: (value) => { window.clearTimeout(searchTimer); searchTimer = window.setTimeout(() => { state.q = value.trim(); state.page = 1; void load(); }, SEARCH_DELAY_MS); }, }); const hiddenToggle = el("input", { attrs: { type: "checkbox" } }); hiddenToggle.addEventListener("change", () => { showHidden = hiddenToggle.checked; draw(); }); const hiddenLabel = el("label", { className: "z01-master__check", children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], }); const grid = el("div", { className: "z01-master__grid-wrap" }); const pageInput = el("input", { className: "ui-input z01-master__page-input", attrs: { type: "number", min: "1", "aria-label": L("Z01_MasterData_Page") }, }); pageInput.addEventListener("change", () => goTo(Number(pageInput.value))); const pageInfo = el("span", { className: "z01-master__page-info" }); // 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인). const pagerButton = (key: Parameters[0], onClick: () => void) => createButton({ label: L(key), variant: "ghost", onClick }); const first = pagerButton("Z01_MasterData_First", () => goTo(1)); const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1)); const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1)); const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity)); const toolbar = el("div", { className: "z01-master__toolbar", children: [search.root, hiddenLabel], }); const pager = el("div", { className: "z01-master__pager", children: [first, prev, pageInput, pageInfo, next, end], }); const root = el("section", { className: "z01-master__panel", children: [el("div", { children: [title, titleKey] }), toolbar, grid, pager], }); toolbar.hidden = true; pager.hidden = true; function goTo(page: number): void { const pages = pageCount(last?.total ?? 0, PAGE_SIZE); state.page = page === Infinity ? pages : clampPage(page, pages); void load(); } async function load(): Promise { if (!state.file || !state.table) return; grid.classList.add("is-loading"); let data: MasterRows | undefined; try { data = await fetchLatest({ file: state.file.id, table: state.table.id, page: state.page, size: PAGE_SIZE, q: state.q, }); } catch (error) { grid.classList.remove("is-loading"); showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); return; } // 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠. if (!data) return; grid.classList.remove("is-loading"); last = data; draw(); } function draw(): void { if (!last) return; const columns = shownColumns(last.columns, showHidden); const headRow = el("tr"); for (const column of columns) { const th = el("th", { children: [el("span", { text: columnTitle(column) })] }); if (column.label !== column.key) { th.append(el("span", { className: "z01-master__key", text: column.key })); } headRow.append(th); } const body = el("tbody"); for (const row of last.rows) { const tr = el("tr"); for (const text of rowCells(row, columns)) { tr.append(el("td", { text, attrs: { title: text } })); } body.append(tr); } grid.replaceChildren( last.rows.length ? el("table", { className: "z01-master__grid", children: [el("thead", { children: [headRow] }), body], }) : el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }), ); const pages = pageCount(last.total, PAGE_SIZE); pageInput.value = String(state.page); pageInput.max = String(pages); pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${last.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`; first.disabled = prev.disabled = state.page <= 1; next.disabled = end.disabled = state.page >= pages; } function pick(file: MasterFile, table: MasterTable): void { state.file = file; state.table = table; state.page = 1; title.textContent = `${file.label} › ${table.label}`; titleKey.textContent = `${file.key} / ${table.key}`; toolbar.hidden = false; pager.hidden = false; last = null; grid.replaceChildren(); void load(); } return { root, pick }; }