Files
Aislo/Z01_MasterData/Z01_MasterData_UI_Rows.ts
T

340 lines
13 KiB
TypeScript

/* =============================================================================
* 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<string, unknown>;
export interface RowsSource {
title: string;
key: string;
/** 표 위 알림 한 줄(요율 — 법이 정한 값) */
notice?: string;
load: (query: RowsQuery) => Promise<MasterRows>;
/** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */
save?: (rowId: string, values: Row) => Promise<Row>;
}
export interface RowsView {
root: HTMLElement;
show: (source: RowsSource) => void;
}
/** 사전 문구의 {value} 자리에 값을 넣음. */
function fill(key: Parameters<typeof L>[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<typeof L>[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<void> {
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<void> {
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 };
}