/* ============================================================================= * Z01_MasterData_UI_Rows.ts * 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 칸을 고침 * * 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `save` 가 있어 editable 칸을 고침. * 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 원래 값 · 산출근거가 섬 * (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것). * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch"; import { cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, shownColumns, type CellInfo, type MasterColumn, } from "./Z01_MasterData_UI_Cells"; const PAGE_SIZE = 50; const SEARCH_DELAY_MS = 300; type Row = Record; export interface RowsSource { title: string; key: string; /** 표 위 알림 한 줄(요율 — 법이 정한 값) */ notice?: string; load: (query: RowsQuery) => Promise; /** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */ save?: (rowId: string, values: Row) => Promise; } export interface RowsView { root: HTMLElement; show: (source: RowsSource) => void; } /** 사전 문구의 {value} 자리에 값을 넣음. */ function fill(key: Parameters[0], value: string): string { return L(key).replace("{value}", value); } /** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */ function stateText(info: CellInfo, column: MasterColumn): string { const original = cellText(info.original, column.unit); switch (info.state) { case "source_changed": return `${fill("Z01_MasterData_State_SourceChanged", cellText(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`; case "orphan": return L("Z01_MasterData_State_Orphan"); case "missing": return L("Z01_MasterData_State_Missing"); default: return fill("Z01_MasterData_State_Edited", original); } } export function buildRowsView(): RowsView { let source: RowsSource | null = null; const state = { page: 1, q: "" }; let showHidden = false; let last: MasterRows | null = null; let selected: { index: number; key: string } | null = null; let searchTimer = 0; const fetchLatest = latestOnly((from: RowsSource, query: RowsQuery) => from.load(query)); 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 notice = el("p", { className: "z01-master__notice" }); const detail = el("div", { className: "z01-master__detail" }); 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] }), notice, toolbar, detail, grid, pager], }); toolbar.hidden = true; pager.hidden = true; detail.hidden = true; notice.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 (!source) return; grid.classList.add("is-loading"); let data: MasterRows | undefined; try { data = await fetchLatest(source, { 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; selected = null; detail.hidden = true; draw(); } function headCell(meta: MasterRows, column: MasterColumn): HTMLElement { const th = el("th", { children: [el("span", { text: columnTitle(column) })] }); const marks: [string, string][] = []; if (source?.save && meta.editable?.includes(column.key)) { marks.push(["✎", L("Z01_MasterData_Edit_Can")]); } const reason = meta.locked?.[column.key]; if (reason) marks.push(["🔒", reason]); const rule = meta.formula?.[column.key]; if (rule) marks.push(["ƒ", rule]); for (const [mark, tip] of marks) { th.append(el("span", { className: "z01-master__mark", text: mark, attrs: { title: tip } })); } if (column.label !== column.key) { th.append(el("span", { className: "z01-master__key", text: column.key })); } return th; } function draw(): void { if (!last) return; const meta = last; const columns = shownColumns(meta.columns, showHidden); const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); const body = el("tbody"); meta.rows.forEach((row, index) => { const texts = rowCells(row, columns); const tr = el("tr"); columns.forEach((column, i) => { const info = cellInfo(meta, row, column.key); const td = el("td", { text: texts[i], attrs: { title: texts[i] } }); if (info.state) { // 원본이 바뀐 칸은 줄째 또렷이 — 흐리면 옛 값이 조용히 계속 쓰임(2026-09-15 브레인). td.classList.add("is-overridden", `is-${info.state.replace("_", "-")}`); if (info.state !== "edited") { td.textContent = `⚠ ${texts[i]}`; tr.classList.add("has-warning"); } td.title = stateText(info, column); } const canEdit = Boolean(source?.save) && info.editable && row["@id"] != null; if (canEdit) td.classList.add("is-editable"); if (selected?.index === index && selected.key === column.key) td.classList.add("is-selected"); td.addEventListener("click", () => { if (td.querySelector("input")) return; selectCell(index, column); td.classList.add("is-selected"); if (canEdit) startEdit(td, index, column); }); tr.append(td); }); body.append(tr); }); grid.replaceChildren( meta.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(meta.total, PAGE_SIZE); pageInput.value = String(state.page); pageInput.max = String(pages); pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${meta.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`; first.disabled = prev.disabled = state.page <= 1; next.disabled = end.disabled = state.page >= pages; } /** 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식. */ function selectCell(index: number, column: MasterColumn): void { if (!last) return; grid.querySelector("td.is-selected")?.classList.remove("is-selected"); selected = { index, key: column.key }; const row = last.rows[index]; const info = cellInfo(last, row, column.key); const lines: HTMLElement[] = [el("strong", { text: columnTitle(column) })]; const line = (text: string) => el("span", { className: "z01-master__detail-line", text }); if (source?.save) { lines.push( line( info.editable ? L("Z01_MasterData_Edit_Can") : `${L("Z01_MasterData_Edit_Locked")} — ${info.reason ?? L("Z01_MasterData_Edit_NotListed")}`, ), ); } if (info.state) { const stateLine = line(stateText(info, column)); if (info.state !== "edited") stateLine.classList.add("is-warning"); lines.push(stateLine); // 단추 글자에 무엇으로 바뀌는지 값을 넣음 — 안 보이고 누르면 금액이 말없이 바뀜(브레인). const target = info.state === "source_changed" ? fill("Z01_MasterData_Edit_TakeSource", cellText(info.current, column.unit)) : info.state === "orphan" ? null : fill("Z01_MasterData_Edit_RevertTo", cellText(info.original, column.unit)); if (target && source?.save) { lines.push( createButton({ label: target, variant: "ghost", onClick: () => void commit(index, column, null), }), ); } } if (info.rule) lines.push(line(`${L("Z01_MasterData_Formula")} ${info.rule}`)); if (info.filled) lines.push(line(info.filled)); detail.replaceChildren(...lines); detail.hidden = false; } function startEdit(td: HTMLElement, index: number, column: MasterColumn): void { if (!last) return; const current = last.rows[index][column.key]; const input = el("input", { className: "ui-input z01-master__cell-input" }); input.value = current == null ? "" : String(current); td.replaceChildren(input); input.focus(); input.select(); let finished = false; const finish = (save: boolean): void => { if (finished) return; finished = true; const value = save ? parseCellInput(input.value, current) : current; if (value === undefined) showToast(L("Z01_MasterData_Edit_Invalid"), "error"); if (value === undefined || value === current) { draw(); return; } void commit(index, column, value); }; input.addEventListener("keydown", (event) => { if (event.key === "Enter") finish(true); if (event.key === "Escape") finish(false); }); input.addEventListener("blur", () => finish(true)); } /** 저장 — null 은 덮개에서 그 칸을 빼 원래 값으로 되돌림. */ async function commit(index: number, column: MasterColumn, value: unknown): Promise { const from = source; const table = last; const rowId = table?.rows[index]["@id"]; if (!from?.save || !table || rowId == null) return; try { const updated = await from.save(String(rowId), { [column.key]: value }); if (from !== source || table !== last) return; table.rows[index] = updated; draw(); selectCell(index, column); showToast( L(value === null ? "Z01_MasterData_Edit_Reverted" : "Z01_MasterData_Edit_Saved"), "success", ); } catch (error) { showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error"); draw(); } } function show(picked: RowsSource): void { source = picked; state.page = 1; title.textContent = picked.title; titleKey.textContent = picked.key; notice.textContent = picked.notice ?? ""; notice.hidden = !picked.notice; toolbar.hidden = false; pager.hidden = false; detail.hidden = true; last = null; selected = null; grid.replaceChildren(); void load(); } return { root, show }; }