- 잠금 카드 수량 식 속 표 찾기를 엔진 문법대로 읽음(따옴표 · 괄호 · 겹친 찾기) · 못 읽는 꼴은 한 줄 안내 - 표를 그대로(머리 · 줄) · 지금 시험값으로 걸린 줄 노랑(서버 /text 표 조각 줄 차례) · 값 칸 굵게 - 줄 누르면 그 줄 값으로 · 조건마다 「설계 때 정함 / 이 값으로 고정」 · 범위 칸은 범위규칙대로 안쪽 수 - 바뀐 식으로 이 줄 금액 · 합계 미리 보임(/text · /calc) · 고른 줄이 안 걸리면 [이걸로] 막음 - 시험 test_m01_easycalc_table.py 6 통과 · typecheck 통과 - ORCA: GF000219 석공 줄 35 · 깬잡석 고정 → 87,395 원(서버 87,395.1) · 합계 132,133 · GC000129 범위 줄 5~8 → 나무높이 6.5 · 541,940 → 709,080 · 값 칸 · 취소 · 닫기 null Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
677 lines
23 KiB
TypeScript
677 lines
23 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"],
|
|
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>;
|
|
}
|
|
|
|
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: {} };
|
|
}
|
|
|
|
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 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;
|
|
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;
|
|
} 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;
|
|
void refresh();
|
|
};
|
|
|
|
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" : "",
|
|
].join(" "),
|
|
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;
|
|
});
|
|
grid.replaceChildren(
|
|
el("table", {
|
|
children: [
|
|
el("thead", {
|
|
children: [
|
|
el("tr", {
|
|
children: [
|
|
...conds.map((k) => th(k)),
|
|
...vals.map((k) =>
|
|
th(
|
|
`${k}${table.값칸[k] ? ` (${table.값칸[k]})` : ""}`,
|
|
k === st.col ? "is-col" : "",
|
|
),
|
|
),
|
|
],
|
|
}),
|
|
],
|
|
}),
|
|
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();
|
|
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,
|
|
grid,
|
|
money,
|
|
warn,
|
|
el("div", { className: "m01-easytable__actions", children: [cancel, use] }),
|
|
);
|
|
await refresh();
|
|
}
|