From 271e15c80d2ccca1b5727afbf13bbbf046d79f73 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:48:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(m01-lb):=20=EB=A1=9C=EC=A7=81=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=20=ED=8C=90=20=EA=B2=A9=EC=9E=90=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0(Grid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엑셀처럼 줄 · 칸으로 보는 가운데 격자 — 고르기 · 숫자 칸 · 줄 누르면 앞 줄 칠함 · 표준은 읽기만. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- M01_MasterData/LogicBuilder/M01_LB_Grid.css | 109 ++++ M01_MasterData/LogicBuilder/M01_LB_Grid.ts | 541 ++++++++++++++++++++ 2 files changed, 650 insertions(+) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Grid.css create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Grid.ts diff --git a/M01_MasterData/LogicBuilder/M01_LB_Grid.css b/M01_MasterData/LogicBuilder/M01_LB_Grid.css new file mode 100644 index 000000000..03a75d0cb --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Grid.css @@ -0,0 +1,109 @@ +.m01-lbgrid__scroll { + max-width: 100%; + max-height: 70vh; + overflow: auto; +} + +.m01-lbgrid__table { + width: 100%; + min-width: 720px; + border-collapse: separate; + border-spacing: 0; + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-lbgrid__table th, +.m01-lbgrid__table td { + padding: var(--spacing-4) var(--spacing-8); + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.m01-lbgrid__table th:first-child, +.m01-lbgrid__table td:first-child { + border-left: 1px solid var(--color-border); +} + +.m01-lbgrid__table thead th { + position: sticky; + top: 0; + z-index: 1; + border-top: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: 600; + white-space: nowrap; +} + +.m01-lbgrid__row { + cursor: pointer; +} + +.m01-lbgrid__row--first td { + border-top: 2px solid var(--color-border); +} + +.m01-lbgrid__row--sub .m01-lbgrid__name { + padding-left: var(--spacing-16); + color: var(--color-text-secondary); +} + +.m01-lbgrid__step { + color: var(--color-text-secondary); + font-weight: 600; + white-space: nowrap; +} + +.m01-lbgrid__table td.m01-lbgrid__num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.m01-lbgrid__src { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +/* 고른 줄 = 굵은 테두리 · 앞 줄 = 연한 칠 + 왼쪽 막대(색만으로 뜻을 안 전함) */ +.m01-lbgrid__row--on td { + background: color-mix(in srgb, var(--color-primary) 18%, transparent); + font-weight: 600; +} + +.m01-lbgrid__row--pre td { + background: color-mix(in srgb, var(--color-warning) 28%, transparent); +} + +.m01-lbgrid__row--pre td:first-child { + box-shadow: inset 4px 0 0 var(--color-warning); +} + +.m01-lbgrid__input { + box-sizing: border-box; + width: 96px; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); + text-align: right; +} + +.m01-lbgrid__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m01-lbgrid__note, +.m01-lbgrid__hint { + margin: var(--spacing-4) 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Grid.ts b/M01_MasterData/LogicBuilder/M01_LB_Grid.ts new file mode 100644 index 000000000..2e69ecd3b --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Grid.ts @@ -0,0 +1,541 @@ +/* ============================================================================= + * M01_LB_Grid.ts + * 로직 만들기 판 — 가운데 격자 보기(엑셀처럼 줄 · 칸). 문장 보기(Steps)와 같은 LbLogic · 같은 on.change + * 열 = 단계 · 이름 · 무엇으로 · 값 · 단위 · 출처 / 줄 = 입력값 · 표 조회(+조건) · 계산(+조각) · 항목 · 결과값 + * 칸은 고르기 · 숫자 칸만 · 줄을 누르면 그 줄이 쓰는 줄(앞 줄)을 칠함 · 표준은 읽기만 + * 모양 = docs/raw/M01_쉬운단가/로직판_A_정함.md · 로직판_A_설계초안.md 7-1. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import type { CalcAnswer } from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { safeReason } from "../M01_MasterData_UI_Logic_Text"; +import { updateCard, type EasyCard } from "../EasyCalc/M01_EasyCalc_Model"; +import { + setCalc, + setCond, + type LbCalc, + type LbExpr, + type LbLogic, + type LbRef, +} from "./M01_LB_Blocks"; +import "./M01_LB_Grid.css"; + +const TEXT = { + Step1: ["① 입력값", "① Input"], + Step2: ["② 표 조회", "② Table lookup"], + Step3: ["③ 계산", "③ Calculation"], + Step4: ["④ 항목 연결", "④ Items"], + Step5: ["⑤ 결과값", "⑤ Result"], + HStep: ["단계", "Step"], + HName: ["이름", "Name"], + HHow: ["무엇으로", "From"], + HValue: ["값", "Value"], + HUnit: ["단위", "Unit"], + HSource: ["출처", "Source"], + Busy: ["계산 중", "Calculating"], + Stopped: ["계산하지 못했어요", "Could not calculate"], + ReadOnly: [ + "표준은 읽기만 — 「복사 편집」 뒤에 고칠 수 있어요", + "Standard is read-only — use “Copy and edit” first", + ], + Hint: ["줄을 누르면 그 줄이 쓰는 줄이 칠해져요", "Click a row to paint the rows it uses"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Cant: ["지금은 이 칸을 고칠 수 없어요", "This cell cannot be changed now"], + Typed: ["숫자 칸", "Number"], + Choice: ["고르기", "Choice"], + Range: ["범위", "Range"], + FromInput: ["입력값", "Input"], + FromCalc: ["계산", "Calc"], + FromLookup: ["표 조회", "Lookup"], + FromFixed: ["고정값", "Fixed"], + FromLogic: ["다른 일위대가 결과", "Other unit-price result"], + FromPeople: ["사람 품값 합", "People cost total"], + FromNone: ["무관", "Any"], + Own: ["직접 쓴 계산", "Written by hand"], + Cond: ["조건", "Condition"], + Table: ["표", "Table"], + Number: ["숫자", "Number"], + Calc: ["계산", "Calculation"], + Sum: ["항목 합계", "Item total"], + UseName: ["계산 이름", "Calc name"], + UseQty: ["항목 수량", "Item amount"], + UseRate: ["항목 비율", "Item rate"], + UseResult: ["결과값", "Result"], + Won: ["원", "KRW"], + Unnamed: ["수량 계산", "Amount calc"], + Price: ["단가", "Unit price"], + Result: ["결과", "Result"], + KValue: ["값", "Value"], + KMul: ["곱하기 · 나누기", "Multiply / divide"], + KRaise: ["할증", "Surcharge"], + KAdd: ["더하기", "Add"], + KCond: ["조건 목록", "Conditions"], + KMinMax: ["최소 · 최대", "Min / max"], + KRound: ["올림 · 버림", "Round"], + KSteps: ["여러 단계", "Several steps"], + Person: ["사람", "Person"], + Material: ["재료", "Material"], + Machine: ["기계", "Machine"], + Logic: ["다른 일위대가", "Other unit price"], + Percent: ["덧붙임", "Add-on %"], + Locked: ["그 밖 줄", "Other line"], + Base: ["바탕", "Base"], + Rate: ["율(%)", "Rate (%)"], + First: ["처음", "First"], + Times: ["곱하기", "times"], + Divide: ["나누기", "divided by"], + Plus: ["더하기", "plus"], + Minus: ["빼기", "minus"], +} as const; + +type TextKey = keyof typeof TEXT; +const tg = (key: TextKey): string => TEXT[key][currentLanguageIndex]; + +type Part = Exclude; +type Ref = LbRef | LbExpr; +type Cell = string | HTMLElement; + +interface GridRow { + id: string; + step: string; + sub: boolean; + name: Cell; + how: Cell; + value: Cell; + unit: string; + source: string; + /** 이 줄이 쓰는 줄 번호 */ + uses: string[]; +} + +const KIND_KEY: Record = { + 값: "KValue", + 곱나눔: "KMul", + 할증: "KRaise", + 더하기: "KAdd", + 조건목록: "KCond", + 최소최대: "KMinMax", + 올림: "KRound", + 단계: "KSteps", +}; +const CARD_KEY: Record = { + person: "Person", + material: "Material", + machine: "Machine", + logic: "Logic", + percent: "Percent", + locked: "Locked", +}; +const USE_KEY: Record = { + 중간: "UseName", + 수량: "UseQty", + 비율: "UseRate", + 결과: "UseResult", +}; + +const num = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); +const show = (v: unknown): string => + typeof v === "number" + ? num(v) + : v === undefined || v === null || typeof v === "object" + ? "" + : String(v); + +/** 선택된 줄 — 다시 그려도 남도록 칸(host)마다 기억 */ +const picked = new WeakMap(); + +/** 이름이 글 안에 따로 있는지 — 영문 이름은 낱말 경계로 */ +function mentions(text: string, name: string): boolean { + if (!name) return false; + if (!/^\w+$/.test(name)) return text.includes(name); + return new RegExp(`(^|[^A-Za-z0-9_])${name}($|[^A-Za-z0-9_])`).test(text); +} + +export interface GridOptions { + /** 입력값 칸에 보일 예시값 — 없으면 계산 답에서 찾음 */ + trial?: Record; +} + +/** 격자 보기 — on.change 로 고친 LbLogic 을 알림(읽기만이면 안 알림) */ +export function renderGrid( + host: HTMLElement, + lb: LbLogic, + ans: CalcAnswer | null, + on: { change(lb: LbLogic): void }, + opts: GridOptions = {}, +): void { + const readOnly = lb.doc.isBase; + const okAns = ans && ans.ok ? ans : null; + const middle = okAns?.middle ?? {}; + const lines = okAns?.lines ?? []; + const msg = el("p", { className: "m01-lbgrid__msg" }); + const calcIndex = (name: string): number => lb.calcs.findIndex((c) => c.name === name); + const apply = (make: () => LbLogic): void => { + if (readOnly) return; + msg.textContent = ""; + try { + on.change(make()); + } catch { + msg.textContent = tg("Cant"); + } + }; + + /* ---- 값 · 줄 번호 ---- */ + const rowOf = (r: Ref): string[] => { + if (r.k === "input") return [`in:${r.name}`]; + if (r.k === "lookup") return [`lk:${r.id}`]; + if (r.k === "calc") return calcIndex(r.name) >= 0 ? [`ca:${calcIndex(r.name)}`] : []; + if (r.k === "expr") return textRows(r.text); + return []; + }; + const textRows = (text: string, self = -1): string[] => [ + ...lb.inputs.filter((i) => mentions(text, i.이름)).map((i) => `in:${i.이름}`), + ...lb.calcs.flatMap((c, i) => + i !== self && c.name && mentions(text, c.name) ? [`ca:${i}`] : [], + ), + ]; + const lookupsAt = (part: Part | "요소", index: number): string[] => + lb.lookups + .filter((l) => l.at.some((a) => a.part === part && a.index === index)) + .map((l) => `lk:${l.id}`); + const lineOf = (card: EasyCard) => + lines.find((l) => l.이름 === card.name && l.단위 === card.unit); + const lookupValue = (id: string): unknown => { + const solo = lb.calcs.find( + (c) => c.parts.length === 1 && c.parts[0].k === "lookup" && c.parts[0].id === id, + ); + if (solo?.name && middle[solo.name] !== undefined) return middle[solo.name]; + const at = lb.lookups + .find((l) => l.id === id) + ?.at.find((a) => a.part === "수량" && a.start === 0); + const card = lb.doc.cards.find( + (c) => c.source?.part === "호표" && c.source.index === at?.index, + ); + const line = card && lineOf(card); + return line && card?.qtyFormula && at && at.end === card.qtyFormula.length ? line.수량 : ""; + }; + const valueOf = (r: Ref): unknown => { + if (r.k === "input") return opts.trial?.[r.name] ?? middle[r.name]; + if (r.k === "fixed") return r.value; + if (r.k === "calc") return middle[r.name]; + if (r.k === "lookup") return lookupValue(r.id); + if (r.k === "cost") return okAns?.sums?.[r.name === "사람줄합" ? "노무비" : r.name]; + return ""; + }; + const unitOf = (r: Ref): string => + r.k === "input" ? (lb.inputs.find((i) => i.이름 === r.name)?.단위 ?? "") : ""; + + /* ---- 칸 만들기 ---- */ + const numberCell = (value: unknown, make: (n: number) => LbLogic): HTMLElement => { + const box = el("input", { + className: "m01-lbgrid__input", + attrs: { type: "text", inputmode: "decimal", "aria-label": tg("Number") }, + }); + box.value = show(value); + box.readOnly = readOnly; + box.addEventListener("click", (e) => e.stopPropagation()); + box.addEventListener("change", () => { + const n = Number(box.value.trim()); + if (box.value.trim() === "" || Number.isNaN(n)) { + msg.textContent = tg("Bad"); + box.value = show(value); + return; + } + apply(() => make(n)); + }); + return box; + }; + + const refLabel = (r: Ref): string => { + if (r.k === "input") return `${tg("FromInput")} · ${r.name}`; + if (r.k === "calc") return `${tg("FromCalc")} · ${r.name}`; + if (r.k === "lookup") { + const l = lb.lookups.find((x) => x.id === r.id); + return `${tg("FromLookup")} · ${l ? `${l.table} ${l.col}` : r.id}`; + } + if (r.k === "fixed") return tg("FromFixed"); + if (r.k === "logic") return tg("FromLogic"); + if (r.k === "cost") return r.name === "사람줄합" ? tg("FromPeople") : r.name; + return tg("Own"); + }; + const refKey = (r: Ref): string => + r.k === "input" + ? `input:${r.name}` + : r.k === "calc" + ? `calc:${r.name}` + : r.k === "lookup" + ? `lookup:${r.id}` + : r.k === "fixed" + ? "fixed" + : r.k === "logic" + ? `logic:${r.key}` + : r.k === "cost" + ? `cost:${r.name}` + : "expr"; + + /** 고를 수 있는 곳 — 입력값 · (앞선) 계산 · 표 조회 · 지금 값 */ + const refSelect = ( + cur: Ref, + pool: Ref[], + none: boolean, + pick: (r: Ref | null) => LbLogic, + ): HTMLElement => { + const all = [...pool]; + if (!all.some((r) => refKey(r) === refKey(cur))) all.unshift(cur); + const options = [ + ...(none ? [{ value: "none", text: tg("FromNone") }] : []), + ...all.map((r) => ({ value: refKey(r), text: refLabel(r) })), + ]; + const field = createSelectField({ + options, + value: refKey(cur), + compact: true, + ariaLabel: refLabel(cur), + disabled: readOnly || cur.k === "expr", + onChange: (v) => { + if (v === refKey(cur)) return; + apply(() => pick(v === "none" ? null : (all.find((r) => refKey(r) === v) ?? cur))); + }, + }); + field.root.addEventListener("click", (e) => e.stopPropagation()); + return field.root; + }; + const inputRefs: Ref[] = lb.inputs.map((i) => ({ k: "input", name: i.이름 })); + const calcRefs = (before: number): Ref[] => + lb.calcs.flatMap((c, i): Ref[] => (c.name && i < before ? [{ k: "calc", name: c.name }] : [])); + const lookupRefs: Ref[] = lb.lookups.map((l) => ({ k: "lookup", id: l.id })); + + /* ---- 줄 모으기 ---- */ + const rows: GridRow[] = []; + const add = (r: Partial & { id: string; name: Cell }): GridRow => { + const row: GridRow = { + step: "", + sub: false, + how: "", + value: "", + unit: "", + source: "", + uses: [], + ...r, + }; + rows.push(row); + return row; + }; + + lb.inputs.forEach((inp, i) => { + add({ + id: `in:${inp.이름}`, + step: i === 0 ? tg("Step1") : "", + name: inp.이름, + how: inp.고르기?.length ? tg("Choice") : tg("Typed"), + value: show(valueOf({ k: "input", name: inp.이름 })), + unit: inp.단위 ?? "", + source: inp.고르기?.length + ? inp.고르기.join(" · ") + : inp.범위 + ? `${tg("Range")} ${inp.범위[0]}∼${inp.범위[1]}` + : "", + }); + }); + + lb.lookups.forEach((lk, i) => { + const head = add({ + id: `lk:${lk.id}`, + step: i === 0 ? tg("Step2") : "", + name: lk.col, + how: tg("FromLookup"), + value: show(lookupValue(lk.id)), + source: `${tg("Table")} ${lk.table}`, + }); + lk.conds.forEach((cd) => { + const from = cd.from; + const fixedNum = from.k === "fixed" && typeof from.value === "number"; + const subRow = add({ + id: `lc:${lk.id}:${cd.col}`, + sub: true, + name: `${tg("Cond")} · ${cd.col}`, + how: refSelect( + from, + [ + ...inputRefs, + ...calcRefs(lb.calcs.length).filter((r) => { + const c = lb.calcs.find((x) => x.name === (r as { name: string }).name); + return c && !c.parts.some((p) => p.k === "lookup") && !c.text.includes("찾기("); + }), + ...lookupRefs.filter((r) => r.k === "lookup" && r.id !== lk.id), + ], + true, + (r) => setCond(lb, lk.id, cd.col, r), + ), + value: + fixedNum && from.k === "fixed" + ? numberCell(from.value, (n) => setCond(lb, lk.id, cd.col, { k: "fixed", value: n })) + : show(valueOf(from)), + unit: unitOf(from), + uses: rowOf(from), + }); + head.uses.push(...subRow.uses); + }); + }); + + lb.calcs.forEach((c, i) => { + const first = i === 0; + const own = lookupsAt(c.at.part, c.at.index); + const head = add({ + id: `ca:${i}`, + step: first ? tg("Step3") : "", + name: c.name ?? `${tg("Unnamed")} ${i + 1}`, + how: tg(KIND_KEY[c.kind]), + value: show(c.name ? middle[c.name] : c.at.part === "수량" ? lines[c.at.index]?.수량 : ""), + source: tg(USE_KEY[c.at.part]), + uses: [...textRows(c.text, i), ...own], + }); + if (!["값", "곱나눔", "할증", "더하기"].includes(c.kind)) return; + c.parts.forEach((p, pi) => { + const label = + c.kind === "할증" + ? tg(pi === 0 ? "Base" : "Rate") + : c.kind === "값" + ? tg("KValue") + : pi === 0 + ? tg("First") + : tg( + ({ "*": "Times", "/": "Divide", "+": "Plus", "-": "Minus" } as const)[ + c.ops?.[pi - 1] ?? (c.kind === "더하기" ? "+" : "*") + ], + ); + const fixedNum = p.k === "fixed" && typeof p.value === "number"; + const setPart = (r: Ref | null): LbLogic => { + const parts = c.parts.slice(); + if (r) parts[pi] = r; + return setCalc(lb, c, { parts }); + }; + const sub = add({ + id: `cp:${i}:${pi}`, + sub: true, + name: label, + how: refSelect(p, [...inputRefs, ...calcRefs(i), ...lookupRefs], false, setPart), + value: + fixedNum && p.k === "fixed" + ? numberCell(p.value, (n) => setPart({ k: "fixed", value: n })) + : show(valueOf(p)), + unit: unitOf(p), + uses: rowOf(p), + }); + head.uses.push(...sub.uses); + }); + }); + + lb.doc.cards.forEach((card, i) => { + const sp = card.source; + const parts: Part[] = sp?.part === "덧줄" ? ["비율"] : ["수량"]; + const viaLookup = sp + ? parts + .flatMap((p) => lookupsAt(p, sp.index)) + .concat(sp.part === "호표" ? lookupsAt("요소", sp.index) : []) + : []; + const viaCalc = sp + ? lb.calcs.flatMap((c, ci) => + parts.includes(c.at.part) && c.at.index === sp.index ? [`ca:${ci}`] : [], + ) + : []; + const line = lineOf(card); + const how = card.qty !== null ? tg("Number") : viaLookup.length ? tg("FromLookup") : tg("Calc"); + add({ + id: `it:${card.id}`, + step: i === 0 ? tg("Step4") : "", + name: card.spec ? `${card.name} ${card.spec}` : card.name, + how: tg(CARD_KEY[card.kind]), + value: + card.qty !== null + ? numberCell(card.qty, (n) => ({ ...lb, doc: updateCard(lb.doc, card.id, { qty: n }) })) + : show(line?.수량), + unit: card.unit, + source: `${how}${line ? ` · ${formatMoney(line.금액)} ${tg("Won")}` : ""}`, + uses: [...viaLookup, ...viaCalc, ...(card.qtyFormula ? textRows(card.qtyFormula) : [])], + }); + }); + + const res = lb.result; + add({ + id: "res", + step: tg("Step5"), + name: tg(res.kind === "금액" ? "Price" : "Result"), + how: res.kind, + value: + res.kind === "금액" + ? okAns + ? formatMoney(okAns.sums?.["계"] ?? 0) + : "" + : show(okAns?.result), + unit: res.unit, + source: res.kind === "금액" ? tg("Sum") : tg("Calc"), + uses: + res.kind === "금액" + ? lb.doc.cards.map((c) => `it:${c.id}`) + : [ + ...textRows(res.calc.text), + ...lb.calcs.flatMap((c, i) => (c.at.part === "결과" ? [`ca:${i}`] : [])), + ], + }); + + /* ---- 그리기 ---- */ + const tbody = el("tbody"); + const paint = (): void => { + const sel = picked.get(host); + const uses = new Set(rows.find((r) => r.id === sel)?.uses ?? []); + tbody.querySelectorAll("tr").forEach((tr) => { + const id = tr.dataset.row ?? ""; + tr.classList.toggle("m01-lbgrid__row--on", id === sel); + tr.classList.toggle("m01-lbgrid__row--pre", uses.has(id)); + }); + }; + const td = (c: Cell, className = ""): HTMLElement => + el("td", { className, ...(typeof c === "string" ? { text: c } : { children: [c] }) }); + rows.forEach((r) => { + const tr = el("tr", { + className: `m01-lbgrid__row${r.sub ? " m01-lbgrid__row--sub" : ""}${r.step ? " m01-lbgrid__row--first" : ""}`, + attrs: { "data-row": r.id }, + children: [ + td(r.step, "m01-lbgrid__step"), + td(r.name, "m01-lbgrid__name"), + td(r.how), + td(r.value, "m01-lbgrid__num"), + td(r.unit), + td(r.source, "m01-lbgrid__src"), + ], + }); + tr.addEventListener("click", () => { + if (picked.get(host) === r.id) picked.delete(host); + else picked.set(host, r.id); + paint(); + }); + tbody.append(tr); + }); + const heads: TextKey[] = ["HStep", "HName", "HHow", "HValue", "HUnit", "HSource"]; + const status = + ans && !ans.ok ? `${tg("Stopped")} — ${safeReason(ans.reason)}` : ans ? "" : tg("Busy"); + host.replaceChildren( + ...(readOnly ? [el("p", { className: "m01-lbgrid__note", text: tg("ReadOnly") })] : []), + ...(status ? [el("p", { className: "m01-lbgrid__note", text: status })] : []), + el("div", { + className: "m01-lbgrid__scroll", + children: [ + el("table", { + className: "m01-lbgrid__table", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((h) => el("th", { text: tg(h) })) })], + }), + tbody, + ], + }), + ], + }), + msg, + el("p", { className: "m01-lbgrid__hint", text: tg("Hint") }), + ); + paint(); +}