/* ============================================================================= * Z01_MasterData_UI_Rows.ts * 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 고침 * * 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `edit` 이 있어 고침. * 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 산출근거가 섬 * (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것). * ⚠ 자동저장 금지 — 고친 칸·추가 줄·삭제 줄은 초안(`BaseDraft`)에 쌓고 [저장] 한 번에 보냄(PLAN 1-0). * ========================================================================== */ import { createButton, createInputField, createSelectField, el, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { ApiError, type BaseSave, type MasterRows, type RowsQuery, } from "./Z01_MasterData_Api_Fetch"; import { boldSegments, cellInfo, cellText, clampPage, columnTitle, filterValue, latestOnly, nextSort, noticeLines, outdatedBanner, pageCount, parseCellInput, rowCells, shownColumns, valueWithUnit, type MasterColumn, type SortOrder, } from "./Z01_MasterData_UI_Cells"; import { BaseDraft, type AddedRow } from "./Z01_MasterData_UI_Draft"; const PAGE_SIZE = 50; const SEARCH_DELAY_MS = 300; type Row = Record; type LocaleKey = Parameters[0]; export interface RowsSource { title: string; key: string; /** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표 */ searchable?: boolean; load: (query: RowsQuery) => Promise; /** 있으면 기초단가 — 초안에 쌓고 [저장] 한 번에 보냄 · [초기값으로]는 바로 저장 */ edit?: { scope: string; save: (body: BaseSave) => Promise; reset: (rowKeys: string[]) => Promise; }; } export interface RowsView { root: HTMLElement; show: (source: RowsSource) => void; } /** 사전 문구의 {value} 자리에 값을 넣음. */ function fill(key: LocaleKey, value: string): string { return L(key).replace("{value}", value); } /** 고른 칸 — 서버 줄은 row_key(`@id`) · 저장 전 새 줄은 tmp. */ interface Picked { row: string; added: boolean; column: string; } export function buildRowsView(): RowsView { let source: RowsSource | null = null; const state = { page: 1, q: "", sort: null as SortOrder | null, filters: {} as Record, }; let showHidden = false; let last: MasterRows | null = null; let picked: Picked | null = null; let draft: BaseDraft | null = null; /** 409 를 받음 — 다른 곳에서 먼저 저장해 이 초안은 저장 못 함(조용히 버리지 않고 알림) */ let stale = false; 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("div", { className: "z01-master__notice" }); const banner = el("div", { className: "z01-master__outdated" }); 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 filterBox = el("div", { className: "z01-master__toolbar" }); const toolbar = el("div", { className: "z01-master__toolbar", children: [search.root, filterBox, hiddenLabel], }); const pager = el("div", { className: "z01-master__pager", children: [first, prev, pageInput, pageInfo, next, end], }); // 기초단가 고치기 — [저장]·[고친 것 버리기]·[줄 추가]·[줄 삭제]·[초기값으로] const action = (key: LocaleKey, onClick: () => void, variant: "filled" | "ghost" = "ghost") => createButton({ label: L(key), variant, onClick }); const saveButton = action("Z01_MasterData_Save", () => void save(), "filled"); const discardButton = action("Z01_MasterData_Discard", discard); const addButton = action("Z01_MasterData_AddRow", addRow); const deleteButton = action("Z01_MasterData_DeleteRow", toggleDelete); const resetButton = action("Z01_MasterData_Reset", () => void reset()); const draftStatus = el("span", { className: "z01-master__draft-status" }); const actions = el("div", { className: "z01-master__toolbar", children: [saveButton, discardButton, addButton, deleteButton, resetButton, draftStatus], }); const root = el("section", { className: "z01-master__panel", children: [ el("div", { children: [title, titleKey] }), notice, banner, actions, toolbar, detail, grid, pager, ], }); toolbar.hidden = true; pager.hidden = true; detail.hidden = true; notice.hidden = true; banner.hidden = true; actions.hidden = true; // 저장 안 한 고침이 조용히 사라지지 않게 — 나갈 때 물음(자동저장은 안 만듦, CLAUDE.md 5장). window.addEventListener("beforeunload", (event) => { if (!draft?.count || !root.isConnected) return; event.preventDefault(); event.returnValue = L("Z01_MasterData_Unsaved"); }); 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; const asked = state.sort; grid.classList.add("is-loading"); let data: MasterRows | undefined; try { data = await fetchLatest(source, { page: state.page, size: PAGE_SIZE, q: state.q, sort: state.sort, filters: state.filters, }); } catch (error) { grid.classList.remove("is-loading"); showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); // 서버가 그 열로는 못 세운다고 하면(400) 누른 표시를 지움 — 눌렀는데 안 바뀐 것처럼 두지 않음. if (asked && state.sort === asked) { state.sort = null; draw(); } return; } // 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠. if (!data) return; grid.classList.remove("is-loading"); last = data; const edit = source.edit; if (edit && data.revision !== undefined) { // 고친 것이 남은 초안은 쪽을 넘겨도 지킴 — 그새 판이 바뀌었으면 stale 로 알림(조용히 안 버림). if (draft?.scope !== edit.scope || !draft.count) { draft = new BaseDraft(edit.scope, data.revision); draft.onChange = drawActions; } stale = draft.revision !== data.revision; } picked = null; detail.hidden = true; draw(); } const rowKey = (row: Row, index: number): string => row["@id"] == null ? `#${index}` : String(row["@id"]); const changedColumns = (row: Row): string[] => Array.isArray(row.changed_columns) ? (row.changed_columns as string[]) : []; /** 고른 서버 줄 — 저장 전 새 줄·열쇠 없는 줄이면 없음. */ function pickedRow(): Row | undefined { if (!picked || picked.added) return undefined; const key = picked.row; return last?.rows.find((row) => row["@id"] != null && String(row["@id"]) === key); } /** 새 줄에 적은 글자를 수로 읽을지 — 같은 열 다른 줄 값의 꼴을 따름(코드 칸이 수로 바뀌지 않게). */ const sample = (column: string): unknown => last?.rows.find((row) => row[column] != null)?.[column]; function drawActions(): void { // 서버가 판(revision)을 준 표만 고침 — 안 주는 표(공종 축 등 DB 밖)는 단추를 안 세움. actions.hidden = !draft; const count = draft?.count ?? 0; const row = pickedRow(); saveButton.disabled = count === 0 || stale; discardButton.disabled = count === 0 && !stale; addButton.disabled = !draft || stale; deleteButton.disabled = !draft || !(picked?.added || row); const undo = Boolean(row && draft?.deleted.has(String(row["@id"]))); deleteButton.querySelector(".ui-btn__label")!.textContent = L( undo ? "Z01_MasterData_UndeleteRow" : "Z01_MasterData_DeleteRow", ); // 초기값으로는 바로 저장 — 저장 안 한 초안이 있으면 판이 갈려 초안이 버려지니 먼저 저장·버리게 함. resetButton.disabled = !row || count > 0 || !(changedColumns(row).length || row.is_added); resetButton.title = count > 0 ? L("Z01_MasterData_ResetBlocked") : ""; draftStatus.classList.toggle("is-warning", stale); draftStatus.textContent = stale ? L("Z01_MasterData_Draft_Stale") : count && draft ? `${L("Z01_MasterData_Draft_Edits")} ${draft.edits.size} · ${L("Z01_MasterData_Draft_Added")} ${draft.added.length} · ${L("Z01_MasterData_Draft_Deleted")} ${draft.deleted.size} — ${L("Z01_MasterData_Draft_Pending")}` : L("Z01_MasterData_Draft_None"); } function headCell(meta: MasterRows, column: MasterColumn): HTMLElement { const th = el("th", { children: [el("span", { text: columnTitle(column) })] }); const marks: [string, string][] = []; if (source?.edit && 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 })); } // 차례 — 누르기 전에는 아무 표시도 없음(원본 차례라는 것이 보이게 · 2026-09-16 브레인). if (meta.sortable?.includes(column.key)) { th.classList.add("is-sortable"); if (state.sort?.key === column.key) { th.append(el("span", { className: "z01-master__sort", text: state.sort.desc ? "▼" : "▲" })); } th.addEventListener("click", () => { state.sort = nextSort(state.sort, column.key); state.page = 1; void load(); }); } return th; } function draw(): void { if (!last) return; const meta = last; // 표 머리 — 모두 서버 글자 그대로(화면이 사전을 가지면 두 벌이 됨). // ① 알림(첫 줄 「새로 만드는 프로젝트부터」 · 요율은 법정값 경고까지) ② 뒤처진 원본(서버 outdated 만) const notices = noticeLines(meta.notice); notice.replaceChildren( ...notices.map((text) => el("p", { children: boldSegments(text).map((part) => part.bold ? el("strong", { text: part.text }) : el("span", { text: part.text }), ), }), ), ); notice.hidden = notices.length === 0; const outdated = outdatedBanner(meta.source); banner.replaceChildren(...outdated.map((text) => el("p", { text: `⚠ ${text}` }))); banner.hidden = outdated.length === 0; // 거르기 — 선택지·이름·줄 수·기본값도, 어느 줄을 거를지 판정도 서버 것(화면은 고른 값만 보냄). filterBox.replaceChildren( ...(meta.filters ?? []).map( (filter) => createSelectField({ label: filter.label, options: filter.options.map((option) => ({ value: option.value, text: option.rows == null ? option.label : `${option.label} (${option.rows.toLocaleString("ko-KR")})`, })), value: filterValue(filter, state.filters), onChange: (value) => { state.filters[filter.key] = value; state.page = 1; void load(); }, }).root, ), ); // 숨긴 열이 없는 표(17 중 15)에서는 눌러도 아무 일이 없어 체크를 감춤. hiddenLabel.hidden = !meta.columns.some((column) => column.hidden); const columns = shownColumns(meta.columns, showHidden); const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); const body = el("tbody"); // 저장 전 새 줄은 어느 쪽에서나 맨 위 — 쪽을 넘겨도 안 사라짐. for (const added of draft?.added ?? []) body.append(addedRow(meta, columns, added)); meta.rows.forEach((row, index) => body.append(serverRow(meta, columns, row, index))); grid.replaceChildren( body.children.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; drawActions(); } const isPicked = (row: string, added: boolean, column: string): boolean => picked?.row === row && picked.added === added && picked.column === column; function serverRow(meta: MasterRows, columns: MasterColumn[], row: Row, index: number) { const key = rowKey(row, index); const texts = rowCells(row, columns); const changed = changedColumns(row); const deleted = draft?.deleted.has(key) ?? false; const tr = el("tr"); if (row.is_added) tr.classList.add("is-added"); if (deleted) tr.classList.add("is-deleted"); columns.forEach((column, i) => { const info = cellInfo(meta, row, column.key); const stored = row[column.key]; const pending = draft?.edit(key, column.key); const text = pending ? cellText(pending.value, column.unit) : texts[i]; const td = el("td", { text, attrs: { title: text } }); // 고친 칸 표시 — 저장 전(초안) · 저장했고 초기값과 다름(서버 changed_columns) if (pending) td.classList.add("is-draft"); else if (changed.includes(column.key)) td.classList.add("is-changed"); const canEdit = Boolean(draft) && info.editable && row["@id"] != null && !deleted; if (canEdit) td.classList.add("is-editable"); if (isPicked(key, false, column.key)) td.classList.add("is-selected"); td.addEventListener("click", () => { if (td.querySelector("input")) return; pick(td, { row: key, added: false, column: column.key }); if (!canEdit) return; startEdit(td, pending ? pending.value : stored, stored ?? sample(column.key), (value) => draft?.setEdit(key, column.key, value, stored), ); }); tr.append(td); }); return tr; } function addedRow(meta: MasterRows, columns: MasterColumn[], added: AddedRow) { const tr = el("tr", { className: "is-new", attrs: { title: L("Z01_MasterData_Row_New") } }); for (const column of columns) { const value = added.data[column.key]; const td = el("td", { text: cellText(value, column.unit) }); // 새 줄은 식으로 나오는 칸만 빼고 다 채움 — 열쇠·이름도 사람이 넣어야 줄이 섬. const canEdit = !meta.formula?.[column.key]; if (canEdit) td.classList.add("is-editable"); if (isPicked(added.tmp, true, column.key)) td.classList.add("is-selected"); td.addEventListener("click", () => { if (td.querySelector("input")) return; pick(td, { row: added.tmp, added: true, column: column.key }); if (!canEdit) return; startEdit( td, value, sample(column.key), (next) => draft?.setAdded(added.tmp, column.key, next), true, ); }); tr.append(td); } return tr; } function pick(td: HTMLElement, next: Picked): void { grid.querySelector("td.is-selected")?.classList.remove("is-selected"); td.classList.add("is-selected"); picked = next; showDetail(); drawActions(); } /** * 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 저장 전 고침 · 초기값과 다름 · 식과 값 넣은 식. * `failure` = 방금 고침이 안 들어간 까닭 — 토스트만 뜨고 사라지면 사람은 들어간 줄 앎(브레인 ③). */ function showDetail(failure?: string): void { const at = picked; const column = last?.columns.find((each) => each.key === at?.column); if (!last || !at || !column) return; const row = at.added ? (draft?.added.find((each) => each.tmp === at.row)?.data ?? {}) : (pickedRow() ?? last.rows[Number(at.row.slice(1))] ?? {}); const info = cellInfo(last, row, column.key); const lines: HTMLElement[] = [el("strong", { text: columnTitle(column) })]; const line = (text: string, warn = false) => { const span = el("span", { className: "z01-master__detail-line", text }); span.classList.toggle("is-warning", warn); return span; }; if (failure) lines.push(line(fill("Z01_MasterData_Edit_NotSaved", failure), true)); if (at.added) lines.push(line(L("Z01_MasterData_Row_New"))); else if (source?.edit) { lines.push( line( info.editable ? L("Z01_MasterData_Edit_Can") : `${L("Z01_MasterData_Edit_Locked")} — ${info.reason ?? L("Z01_MasterData_Edit_NotListed")}`, ), ); const pending = draft?.edit(at.row, column.key); if (pending) { const value = valueWithUnit(pending.value, column.unit); lines.push(line(fill("Z01_MasterData_Cell_Draft", value))); } if (changedColumns(row).includes(column.key)) lines.push(line(L("Z01_MasterData_Cell_Changed"))); if (row.is_added) lines.push(line(L("Z01_MasterData_Row_Added"))); } 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; } /** * 칸 고치기 — Enter·칸 나감 = 초안에 넣음(서버엔 안 감) · Esc = 그대로. * `typeOf` = 수로 읽을지 가르는 본보기 값 · `allowEmpty` = 비우면 칸을 뺌(새 줄). */ function startEdit( td: HTMLElement, current: unknown, typeOf: unknown, apply: (value: unknown) => void, allowEmpty = false, ): void { 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 = (keep: boolean): void => { if (finished) return; finished = true; const empty = allowEmpty && input.value.trim() === ""; const value = !keep ? current : empty ? null : parseCellInput(input.value, typeOf); if (value === undefined) { const why = fill("Z01_MasterData_Edit_Invalid", input.value); showToast(why, "error"); draw(); showDetail(why); return; } if (keep) apply(value); draw(); showDetail(); }; input.addEventListener("keydown", (event) => { if (event.key === "Enter") finish(true); if (event.key === "Escape") finish(false); }); input.addEventListener("blur", () => finish(true)); } function addRow(): void { draft?.addRow(); draw(); } function toggleDelete(): void { if (!draft || !picked) return; if (picked.added) { draft.removeAdded(picked.row); picked = null; detail.hidden = true; } else if (pickedRow()) { draft.toggleDelete(picked.row); } draw(); } function discard(): void { draft?.clear(); stale = false; void load(); } /** [저장] — 초안 한 벌을 보냄. 409 면 초안을 지키고 알림(다른 곳에서 먼저 저장함). */ async function save(): Promise { const edit = source?.edit; const active = draft; if (!edit || !active?.count) return; showLoadingOverlay(); try { await edit.save(active.payload()); active.clear(); showToast(L("Z01_MasterData_Saved"), "success"); void load(); } catch (error) { if (error instanceof ApiError && error.status === 409) stale = true; showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error"); drawActions(); } finally { hideLoadingOverlay(); } } /** [초기값으로] — 고른 줄을 seed 로(추가한 줄은 지워짐) · 바로 저장이라 한 번 물음. */ async function reset(): Promise { const edit = source?.edit; const row = pickedRow(); if (!edit || !row || draft?.count) return; const ok = await showConfirmDialog(L("Z01_MasterData_ResetConfirm"), L("Z01_MasterData_Reset")); if (!ok) return; showLoadingOverlay(); try { await edit.reset([String(row["@id"])]); showToast(L("Z01_MasterData_ResetDone"), "success"); void load(); } catch (error) { showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error"); } finally { hideLoadingOverlay(); } } function show(next: RowsSource): void { source = next; state.page = 1; state.sort = null; state.filters = {}; filterBox.replaceChildren(); title.textContent = next.title; titleKey.textContent = next.key; notice.hidden = true; banner.hidden = true; search.root.hidden = next.searchable === false; toolbar.hidden = false; pager.hidden = false; detail.hidden = true; last = null; picked = null; draft = null; stale = false; actions.hidden = true; grid.replaceChildren(); void load(); } return { root, show }; }