Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
541 lines
19 KiB
TypeScript
541 lines
19 KiB
TypeScript
/* =============================================================================
|
||
* 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<LbCalc["at"]["part"], never>;
|
||
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<LbCalc["kind"], TextKey> = {
|
||
값: "KValue",
|
||
곱나눔: "KMul",
|
||
할증: "KRaise",
|
||
더하기: "KAdd",
|
||
조건목록: "KCond",
|
||
최소최대: "KMinMax",
|
||
올림: "KRound",
|
||
단계: "KSteps",
|
||
};
|
||
const CARD_KEY: Record<EasyCard["kind"], TextKey> = {
|
||
person: "Person",
|
||
material: "Material",
|
||
machine: "Machine",
|
||
logic: "Logic",
|
||
percent: "Percent",
|
||
locked: "Locked",
|
||
};
|
||
const USE_KEY: Record<Part, TextKey> = {
|
||
중간: "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<HTMLElement, string>();
|
||
|
||
/** 이름이 글 안에 따로 있는지 — 영문 이름은 낱말 경계로 */
|
||
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<string, unknown>;
|
||
}
|
||
|
||
/** 격자 보기 — 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<GridRow> & { 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("찾기(");
|
||
}),
|
||
],
|
||
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<HTMLElement>("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();
|
||
}
|