Files
Aislo/M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts
T
eomsangdonandClaude Opus 5.5 0acffdc341 fix(M01): 표 보고 고르기 — 설계 때 정하는 조건 줄 누름에 고정 안내 한 줄(34-3c)
- 설계 값에 묶인 조건이 있는 줄을 누르면 「이 값은 설계 때 정함 — 늘 이 줄로 하려면 [이 값으로 고정]」
- 누름 한 번으로 그 조건들을 누른 줄 값으로 고정 · 안내 사라짐
- 줄 수 700 아래로 맞춤(표 머리 짜기 줄임)
- 시험 6 통과 · typecheck 통과 · ORCA GF000219 줄 35·깬잡석·골쌓기 → 고정 한 번 → 새 식 세 조건 고정 · 87,395 / 132,133

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 09:47:26 +09:00

697 lines
24 KiB
TypeScript

/* =============================================================================
* M01_EasyCalc_Table.ts
* 쉬운 단가산출 2단계 — 표 보고 고르기(설계.md 1절 7 · 3절 `openEasyTable`)
* 잠금 카드 수량 식 속 `찾기(표, 조건=값 …).값칸` 을 읽어 그 표를 그대로 보이고,
* 줄 · 칸을 눌러 조건 값 · 값 칸을 바꾼 새 식을 돌려줌(취소 null).
*
* 식 읽기 = 엔진 문법(`master_formula._Parser` 의 find · args · field) 그대로 화면에서 —
* 서버에 찾기 마디를 돌려주는 길이 없어서. 걸린 줄 · 금액은 서버(`/text` 표 조각 `참조.줄` · `/calc`).
* 화면 글에 식 · 키 안 보임 — 표 이름 · 칸 이름 · 값만.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { fetchTable } from "../M01_MasterData_Api_Fetch";
import {
fetchAuto,
runCalc,
runText,
searchElements,
type HoLine,
type LogicRow,
type NamedFormula,
} from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import { toRow, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model";
import "./M01_EasyCalc_Table.css";
const TEXT = {
Title: ["표 보고 고르기", "Pick from the table"],
Close: ["닫기", "Close"],
Cannot: ["이 줄은 지금 화면에서 바꿀 수 없음", "This line can't be changed here yet"],
NoTable: ["표를 불러오지 못함", "Couldn't load the table"],
Loading: ["불러오는 중", "Loading"],
Hint: [
"줄을 누르면 그 줄 값으로 정함 · 노란 줄 = 지금 쓰는 줄 · 굵은 칸 = 수량을 가져오는 칸",
"Tap a row to use it · yellow = row in use · bold = column the quantity comes from",
],
Conds: ["어느 줄을 쓸까", "Which row"],
Design: ["설계 때 정함", "Set at design"],
Fix: ["이 값으로 고정", "Fix this value"],
AsIs: ["지금대로", "As is"],
NotYet: ["(아직 없음)", "(none yet)"],
Amount: ["이 줄 금액", "This line"],
Total: ["합계", "Total"],
Wait: ["계산 중", "Calculating"],
Stopped: ["계산 멈춤", "Calculation stopped"],
DesignHint: [
"이 값은 설계 때 정함 — 늘 이 줄로 하려면",
"This value is set at design — to always use this row,",
],
Mismatch: [
"고른 줄이 이 조건으로는 안 걸림 — 조건을 다시 보세요",
"The picked row isn't matched by these conditions",
],
Use: ["이걸로", "Use this"],
Cancel: ["취소", "Cancel"],
Which: ["표 고르기", "Choose table"],
} as const;
const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex as 0 | 1];
/* ── 식 읽기 · 짜기(DOM 없음) ─────────────────────────────────────────── */
export interface FindArg {
/** 표 조건 칸 이름 */
key: string;
/** `=` 오른쪽 식 글 그대로 */
rhs: string;
}
export interface FindCall {
/** 식 안 자리 — `찾기` 첫 글자부터 값 칸 끝까지 */
start: number;
end: number;
table: string;
args: FindArg[];
col: string;
}
const NAME = /[\p{L}\p{N}_]/u;
const isName = (ch: string | undefined): boolean => !!ch && NAME.test(ch);
class Reader {
i = 0;
constructor(readonly s: string) {}
ws(): void {
while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
}
take(ch: string): boolean {
this.ws();
if (this.s[this.i] !== ch) return false;
this.i++;
return true;
}
name(): string | null {
this.ws();
const start = this.i;
while (isName(this.s[this.i])) this.i++;
return this.i > start ? this.s.slice(start, this.i) : null;
}
quoted(): string | null {
const end = this.s.indexOf("'", this.i);
if (end < 0) return null;
const text = this.s.slice(this.i, end);
this.i = end + 1;
return text;
}
/** 괄호 · 따옴표 밖 `,` · `)` 앞까지 */
until(head: boolean): string | null {
const start = this.i;
let depth = 0;
while (this.i < this.s.length) {
const ch = this.s[this.i];
if (ch === "'" && !head) {
const end = this.s.indexOf("'", this.i + 1);
if (end < 0) return null;
this.i = end + 1;
continue;
}
if (ch === "(" || ch === "{") depth++;
else if (ch === ")" || ch === "}") {
if (depth === 0) break;
depth--;
} else if (ch === "," && depth === 0) break;
this.i++;
}
return this.i < this.s.length ? this.s.slice(start, this.i).trim() : null;
}
}
/** 한 자리의 찾기 — 못 읽으면 null */
function readOne(s: string, at: number): FindCall | null {
const r = new Reader(s);
r.i = at + 2;
if (!r.take("(")) return null;
r.ws();
const table = r.until(true);
if (!table || !/^[A-Za-z]{2}\d+$/.test(table)) return null; // {자리} 낀 키는 화면에서 안 다룸
const args: FindArg[] = [];
while (r.take(",")) {
r.ws();
const key = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name();
if (!key || !r.take("=")) return null;
r.ws();
const rhs = r.until(false);
if (!rhs) return null;
args.push({ key, rhs });
}
if (!r.take(")") || !r.take(".")) return null;
r.ws();
const col = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name();
if (!col) return null;
return { start: at, end: r.i, table, args, col };
}
/** 식 속 찾기 전부(바깥 것만 · 차례대로) — 하나라도 못 읽으면 null · 없으면 [] */
export function readFinds(formula: string): FindCall[] | null {
const out: FindCall[] = [];
let i = 0;
while (i < formula.length) {
const ch = formula[i];
if (ch === "'") {
const end = formula.indexOf("'", i + 1);
if (end < 0) return null;
i = end + 1;
continue;
}
if (formula.startsWith("찾기", i) && !isName(formula[i - 1]) && !isName(formula[i + 2])) {
const call = readOne(formula, i);
if (!call) return null;
out.push(call);
i = call.end;
continue;
}
i++;
}
return out;
}
const plain = (word: string): string => (/^[\p{L}\p{N}_]+$/u.test(word) ? word : `'${word}'`);
/** 찾기 하나를 글로 — 마스터 꼴 `찾기(표, 가=나, 다=라).칸` */
export function writeFind(table: string, args: FindArg[], col: string): string {
const parts = [table, ...args.map((a) => `${plain(a.key)}=${a.rhs}`)];
return `찾기(${parts.join(", ")}).${plain(col)}`;
}
/** 식의 찾기들을 갈아 끼움 — `swaps` 의 차례 = `readFinds` 차례 · null 은 그대로 */
export function replaceFinds(formula: string, calls: FindCall[], swaps: (string | null)[]): string {
let out = formula;
for (let k = calls.length - 1; k >= 0; k--) {
const text = swaps[k];
if (text !== null && text !== undefined)
out = out.slice(0, calls[k].start) + text + out.slice(calls[k].end);
}
return out;
}
/** 표 칸 값 → 식 글(수 · 따옴표 글) — 못 쓰면 null */
export function literal(value: unknown, rule = ""): string | null {
if (typeof value === "number") return String(value);
if (typeof value === "string") return value.includes("'") ? null : `'${value}'`;
if (Array.isArray(value)) {
const n = rangeNumber(value as [number | null, number | null], rule);
return n === null ? null : String(n);
}
return null;
}
/** 범위 칸 [아래, 위] 안에 드는 수 하나 — 엔진 `_match` 의 범위규칙대로 */
export function rangeNumber(cell: [number | null, number | null], rule: string): number | null {
const [low, high] = cell;
const kind = (rule || "초과·이하").split(" · ")[0].split(" (")[0];
const round = (n: number): number => Number(n.toFixed(6));
if (low !== null && high !== null) return round((low + high) / 2);
if (high !== null) {
if (kind !== "이상·미만") return high;
return round(high > 1 ? high - 1 : high / 2);
}
if (low !== null) return kind.startsWith("이상") ? low : low + 1;
return null;
}
/** 식 글이 따옴표 글 · 수면 그 값 */
export function literalValue(rhs: string): string | number | null {
const q = rhs.match(/^'([^']*)'$/);
if (q) return q[1];
return /^-?\d+(\.\d+)?$/.test(rhs) ? Number(rhs) : null;
}
/* ── 표 · 서버 ──────────────────────────────────────────────────────── */
type Cell = string | number | [number | null, number | null] | null | undefined;
interface EasyTable {
이름?: string;
기준?: string;
범위규칙?: string;
조건: Record<string, string>;
값칸: Record<string, string>;
줄: Record<string, Cell>[];
}
const tables = new Map<string, Promise<EasyTable | null>>();
function loadTable(key: string): Promise<EasyTable | null> {
const hit = tables.get(key);
if (hit) return hit;
const groups = key[0] === "C" ? ["계수", "소요량"] : ["소요량", "계수"];
const job = (async (): Promise<EasyTable | null> => {
for (const group of groups) {
const found = await searchElements(group, key).catch(() => null);
const file = found?.items.find((x) => x.ref === key)?.file;
if (!file) continue;
const got = await fetchTable(file, key).catch(() => null);
if (got?.table) return got.table as unknown as EasyTable;
}
return null;
})();
tables.set(key, job);
return job;
}
/** 카드 줄을 새 식으로 — 호표면 `수량` · 덧붙임이면 `식` */
function cardLine(card: EasyCard, formula: string): HoLine | NamedFormula {
const raw = (card.raw ?? {}) as Record<string, unknown>;
if (card.source?.part === "덧줄") return { ...raw, 이름: card.name, 식: formula } as NamedFormula;
return {
종류: "인력",
요소: String(card.element ?? ""),
이름: card.name,
단위: card.unit,
...raw,
수량: formula,
} as HoLine;
}
/** 이 카드 하나만 든 시험 줄 — `/text` 답에서 그 줄 표 조각을 바로 찾으려고 */
function probeRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow {
const line = cardLine(card, formula);
if (card.source?.part === "덧줄") return { ...doc.row, 키: "", 덧줄: [line as NamedFormula] };
return { ...doc.row, 키: "", 호표: [line as HoLine], 덧줄: [] };
}
/** 로직 전체 줄 — 카드 식만 바꿈 · Model 이 아직이면 원래 줄의 그 자리만 */
function fullRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow {
try {
const cards = doc.cards.map((c) => (c.id === card.id ? { ...c, qtyFormula: formula } : c));
return { ...toRow({ ...doc, cards }), 키: "" };
} catch {
const row: LogicRow = { ...doc.row, 키: "" };
const at = card.source?.index ?? -1;
if (card.source?.part === "덧줄" && row.덧줄?.[at])
row.덧줄 = row.덧줄.map((x, i) => (i === at ? { ...x, 식: formula } : x));
else if (row.호표?.[at])
row.호표 = row.호표.map((x, i) => (i === at ? { ...x, 수량: formula } : x));
return row;
}
}
interface Preview {
ok: boolean;
/** 찾기 차례 → 걸린 줄 차례(못 찾으면 null) */
hits: (number | null)[];
line: number | null;
total: number | null;
}
async function preview(
card: EasyCard,
doc: EasyDoc,
formula: string,
calls: FindCall[],
inputs: Record<string, unknown>,
): Promise<Preview> {
const [text, calc] = await Promise.all([
runText({ key: "", inputs, row: probeRow(card, doc, formula) }).catch(() => null),
runCalc({ key: "", inputs, row: fullRow(card, doc, formula) }).catch(() => null),
]);
const lines = text?.ok
? text.groups.flatMap((g) => g.줄).filter((l) => l.이름 === card.name)
: [];
const pieces = (lines[0]?.조각 ?? []).filter((p) => p.kind === "표");
const seen = new Map<string, number>();
const hits = calls.map((c) => {
const nth = seen.get(c.table) ?? 0;
seen.set(c.table, nth + 1);
const piece = pieces.filter((p) => p.참조?.키 === c.table)[nth];
return typeof piece?.참조?.줄 === "number" ? piece.참조.줄 : null;
});
const sum = lines.reduce((s, l) => s + (Number(l.금액) || 0), 0);
return {
ok: !!text?.ok && !!calc?.ok,
hits,
line: lines.length ? sum : null,
total: calc?.ok && calc.sums ? (calc.sums["계"] ?? null) : null,
};
}
/* ── 창 ─────────────────────────────────────────────────────────────── */
type Mode = "design" | "fix" | "asis";
interface CondState {
key: string;
/** 원래 식 글 · 새로 더한 조건은 null */
orig: string | null;
mode: Mode;
/** 설계 값 이름 — 있으면 「설계 때 정함」 고를 수 있음 */
input: string | null;
fix: Cell;
}
interface CallState {
conds: CondState[];
col: string;
picked: number | null;
/** 「설계 때 정함」 조건을 고른 줄 값으로 미리 봄 */
tryValues: Record<string, unknown>;
/** 마지막으로 누른 줄이 미리 보기만 한 「설계 때 정함」 조건 */
designHint: string[];
}
const showCell = (v: Cell): string => {
if (v === null || v === undefined) return "";
if (Array.isArray(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`;
return String(v);
};
function startState(call: FindCall, doc: EasyDoc): CallState {
const names = new Set(doc.inputs.map((x) => x.이름));
const conds = call.args.map((a): CondState => {
const input = names.has(a.rhs) ? a.rhs : names.has(a.key) ? a.key : null;
const lit = literalValue(a.rhs);
const mode: Mode = names.has(a.rhs) ? "design" : lit !== null ? "fix" : "asis";
return { key: a.key, orig: a.rhs, mode, input, fix: lit };
});
return { conds, col: call.col, picked: null, tryValues: {}, designHint: [] };
}
function stateArgs(st: CallState, table: EasyTable | null): FindArg[] | null {
const out: FindArg[] = [];
for (const c of st.conds) {
if (c.mode === "design" && c.input) out.push({ key: c.key, rhs: c.input });
else if (c.mode === "fix") {
const rhs = literal(c.fix, table?.범위규칙);
if (rhs === null) return null;
out.push({ key: c.key, rhs });
} else if (c.orig !== null) out.push({ key: c.key, rhs: c.orig });
}
return out;
}
/** 이 창 모양 그대로의 새 식 — 바뀐 것이 없으면 원래 식 */
function compose(
formula: string,
calls: FindCall[],
states: CallState[],
tbl: (EasyTable | null)[],
) {
const swaps = calls.map((call, k) => {
const args = stateArgs(states[k], tbl[k]);
if (!args) return null;
const same =
states[k].col === call.col &&
args.length === call.args.length &&
args.every((a, i) => a.key === call.args[i].key && a.rhs === call.args[i].rhs);
return same ? null : writeFind(call.table, args, states[k].col);
});
return replaceFinds(formula, calls, swaps);
}
/** 잠금 카드 수량을 표에서 골라 바꿈 — 새 식 · 취소 null(설계.md 3절) · trial 없으면 자동값 */
export function openEasyTable(
card: EasyCard,
doc: EasyDoc,
trial?: Record<string, unknown>,
): Promise<string | null> {
const formula = card.qtyFormula ?? "";
const calls = readFinds(formula);
return new Promise((resolve) => {
let done = false;
const finish = (value: string | null): void => {
if (done) return;
done = true;
resolve(value);
};
openModal({
title: tt("Title"),
closeLabel: tt("Close"),
dialogClass: "m01-easytable",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
if (!calls || !calls.length) {
body.append(el("p", { className: "m01-easytable__note", text: tt("Cannot") }));
return;
}
void mountTable(body, { card, doc, formula, calls, trial }, (v) => {
finish(v);
close();
});
},
});
});
}
async function mountTable(
body: HTMLElement,
ctx: {
card: EasyCard;
doc: EasyDoc;
formula: string;
calls: FindCall[];
trial?: Record<string, unknown>;
},
finish: (value: string | null) => void,
): Promise<void> {
const { card, doc, formula, calls } = ctx;
body.append(el("p", { className: "m01-easytable__note", text: tt("Loading") }));
const [base, tbl] = await Promise.all([
ctx.trial ?? (doc.key ? fetchAuto(doc.key) : Promise.resolve({})),
Promise.all(calls.map((c) => loadTable(c.table))),
]);
const states = calls.map((c) => startState(c, doc));
let now = 0;
let last: Preview = { ok: false, hits: calls.map(() => null), line: null, total: null };
let seq = 0;
let busy = false;
const which = el("div", { className: "m01-easytable__which" });
const head = el("div", { className: "m01-easytable__head" });
const condBox = el("div", { className: "m01-easytable__conds" });
const grid = el("div", { className: "m01-easytable__grid" });
const money = el("div", { className: "m01-easytable__money" });
const warn = el("p", { className: "m01-easytable__warn" });
const hint = el("p", { className: "m01-easytable__hint" });
const use = createButton({ label: tt("Use"), onClick: () => finish(newFormula()) });
const cancel = createButton({
label: tt("Cancel"),
variant: "ghost",
onClick: () => finish(null),
});
const newFormula = (): string => compose(formula, calls, states, tbl);
const inputs = (): Record<string, unknown> => ({ ...base, ...states[now].tryValues });
const refresh = async (): Promise<void> => {
const mine = ++seq;
busy = true;
paint();
const got = await preview(card, doc, newFormula(), calls, inputs());
if (mine !== seq) return;
last = got;
busy = false;
paint();
};
const pickRow = (idx: number, col: string | null): void => {
const table = tbl[now];
const st = states[now];
const row = table?.줄[idx];
if (!table || !row) return;
st.designHint = [];
for (const key of Object.keys(table.조건)) {
if (row[key] === undefined) continue;
let c = st.conds.find((x) => x.key === key);
if (!c) {
const input = doc.inputs.some((x) => x.이름 === key) ? key : null;
c = { key, orig: null, mode: "fix", input, fix: null };
st.conds.push(c);
}
if (c.mode === "design" && c.input) {
const n = Array.isArray(row[key])
? rangeNumber(row[key] as [number | null, number | null], table.범위규칙 ?? "")
: row[key];
st.tryValues[c.input] = n;
st.designHint.push(key);
} else {
c.mode = "fix";
c.fix = row[key];
}
}
if (col) st.col = col;
st.picked = idx;
void refresh();
};
const setMode = (c: CondState, mode: Mode): void => {
if (mode === "fix" && (c.fix === null || c.fix === undefined)) {
const hit = last.hits[now];
const row = hit === null ? null : tbl[now]?.줄[hit];
c.fix = row?.[c.key] ?? (c.input ? (inputs()[c.input] as Cell) : null);
}
c.mode = mode;
states[now].designHint = states[now].designHint.filter((k) => k !== c.key);
void refresh();
};
/** 누른 줄의 「설계 때 정함」 조건을 한 번에 그 줄 값으로 고정 */
const fixPicked = (): void => {
const st = states[now];
const row = st.picked === null ? null : tbl[now]?.줄[st.picked];
if (!row) return;
for (const c of st.conds)
if (st.designHint.includes(c.key)) {
c.mode = "fix";
c.fix = row[c.key];
}
st.designHint = [];
void refresh();
};
const paintHint = (): void => {
const st = states[now];
hint.replaceChildren(
...(st.picked !== null && st.designHint.length
? [
el("span", { text: `${tt("DesignHint")} ` }),
createButton({ label: tt("Fix"), size: "sm", onClick: fixPicked }),
]
: []),
);
};
const paintConds = (): void => {
const st = states[now];
condBox.replaceChildren(
el("h4", { text: tt("Conds") }),
...st.conds.map((c) => {
const opts: [Mode, string][] = [];
if (c.input) opts.push(["design", tt("Design")]);
if (c.orig !== null && c.orig !== c.input && literalValue(c.orig) === null)
opts.push(["asis", tt("AsIs")]);
opts.push(["fix", tt("Fix")]);
const hit = last.hits[now];
const used = hit === null ? "" : showCell(tbl[now]?.줄[hit]?.[c.key]);
const value =
c.mode === "fix"
? showCell(c.fix)
: c.mode === "design" && c.input
? showCell(inputs()[c.input] as Cell) || used
: used;
return el("div", {
className: "m01-easytable__cond",
children: [
el("span", { className: "m01-easytable__cond-name", text: c.key }),
el("span", { className: "m01-easytable__cond-value", text: value || tt("NotYet") }),
el("span", {
className: "m01-easytable__seg",
children: opts.map(([mode, label]) => {
const b = createButton({
label,
size: "sm",
variant: c.mode === mode ? "filled" : "ghost",
onClick: () => setMode(c, mode),
});
b.setAttribute("aria-pressed", String(c.mode === mode));
return b;
}),
}),
],
});
}),
);
};
const paintGrid = (): void => {
const table = tbl[now];
if (!table) {
grid.replaceChildren(el("p", { className: "m01-easytable__note", text: tt("NoTable") }));
return;
}
const st = states[now];
const conds = Object.keys(table.조건);
const vals = Object.keys(table.값칸);
const hit = last.hits[now];
const th = (text: string, cls = ""): HTMLElement => el("th", { className: cls, text });
const rows = table.줄.map((row, idx) => {
const tr = el("tr", {
className: `${idx === hit && !busy ? "is-hit" : ""} ${idx === st.picked ? "is-picked" : ""}`,
children: [
...conds.map((k) => el("td", { text: showCell(row[k]) })),
...vals.map((k) =>
el("td", {
className: `is-value${k === st.col ? " is-col" : ""}`,
text: showCell(row[k]),
attrs: { "data-col": k },
}),
),
],
});
tr.addEventListener("click", (ev) => {
const cell = (ev.target as HTMLElement).closest("td");
pickRow(idx, cell?.dataset.col ?? null);
});
return tr;
});
const unit = (k: string): string => (table.값칸[k] ? `${k} (${table.값칸[k]})` : k);
const top = [
...conds.map((k) => th(k)),
...vals.map((k) => th(unit(k), k === st.col ? "is-col" : "")),
];
const thead = el("thead", { children: [el("tr", { children: top })] });
grid.replaceChildren(el("table", { children: [thead, el("tbody", { children: rows })] }));
};
const paint = (): void => {
const table = tbl[now];
which.replaceChildren(
...(calls.length > 1
? calls.map((_c, k) =>
createButton({
label: tbl[k]?.이름 ?? `${tt("Which")} ${k + 1}`,
size: "sm",
variant: k === now ? "filled" : "ghost",
onClick: () => {
now = k;
void refresh();
},
}),
)
: []),
);
head.replaceChildren(
el("strong", { text: [table?.이름, table?.기준].filter(Boolean).join(" · ") }),
el("span", { className: "m01-easytable__note", text: tt("Hint") }),
);
paintConds();
paintHint();
paintGrid();
const picked = states[now].picked;
const mismatch = !busy && picked !== null && last.hits[now] !== picked;
warn.textContent = mismatch ? tt("Mismatch") : "";
const fmt = (v: number | null): string => (v === null ? "—" : `${formatMoney(v)} 원`);
money.replaceChildren(
el("span", { text: `${tt("Amount")} ${fmt(last.line)}` }),
el("strong", { text: `${tt("Total")} ${fmt(last.total)}` }),
el("span", {
className: "m01-easytable__note",
text: busy ? tt("Wait") : last.ok ? "" : tt("Stopped"),
}),
);
money.classList.toggle("is-busy", busy);
use.disabled = busy || mismatch || !last.ok;
};
body.replaceChildren(
which,
head,
condBox,
hint,
grid,
money,
warn,
el("div", { className: "m01-easytable__actions", children: [cancel, use] }),
);
await refresh();
}