Files
Aislo/Z01_MasterData/Z01_MasterData_UI_Rows.ts
T
eomsangdonandClaude Opus 5 feab848e69 feat(Z01): 기초단가 표 [저장] 방식 교체 · 공종 편집 화면 신설 — PLAN 1-4
- 기초단가 표: 칸 나갈 때 즉시 저장 없앰 · 고친 칸·새 줄·지울 줄은 초안(sessionStorage z01-draft:base:{kind})에 쌓고 [저장] 한 번에 보냄 · [고친 것 버리기]·[줄 추가]·[줄 삭제]·[초기값으로] · 409 면 초안 지키고 알림 · 서버가 revision 을 줄 때만 고치기 단추 섬
- 옛 덮개 길(칸 단위 PUT · 고친 것 모아 보기 · 덮개 판정 표시)과 그 사전 글자 뺌 — 계약 1-0 에서 서버 쪽 삭제
- 공종 편집(Z01_MasterData_UI_WorkItem.ts · _WorkItem_Rows.ts): 왼쪽 목록(상태별 개수·검색) / 머리 / 구성 줄 표(갈래별 · 자원 찾기 = 기초단가 검색) / 품셈 원문 표+[주] 읽기 전용 / [저장]·[초기값으로]·[검수 완료] · 초안 열쇠 z01-draft:work_item:{key}
- 공종 API(sub_laptop_3 e1c71c3e) 모양에 맞춤 — 줄은 id + 계약 칸 열하나만 보냄(extra=forbid) · 저장·초기값 응답(새 GET 모양)으로 바로 다시 그림 · 저장된 고침(changed_columns·is_added) 표시
- 초안 순수 함수 Z01_MasterData_UI_Draft.ts + 시험 test_z01_master_draft.py

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145cr1DMC5Ft8fouQSXtp69
2026-09-18 23:55:45 +09:00

616 lines
23 KiB
TypeScript

/* =============================================================================
* 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<string, unknown>;
type LocaleKey = Parameters<typeof L>[0];
export interface RowsSource {
title: string;
key: string;
/** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표 */
searchable?: boolean;
load: (query: RowsQuery) => Promise<MasterRows>;
/** 있으면 기초단가 — 초안에 쌓고 [저장] 한 번에 보냄 · [초기값으로]는 바로 저장 */
edit?: {
scope: string;
save: (body: BaseSave) => Promise<unknown>;
reset: (rowKeys: string[]) => Promise<unknown>;
};
}
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<string, string>,
};
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<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 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<void> {
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<void> {
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<void> {
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 };
}