Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
340 lines
13 KiB
TypeScript
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 };
|
|
}
|