/* ============================================================================= * 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; 값칸: Record; 줄: Record[]; } const tables = new Map>(); function loadTable(key: string): Promise { const hit = tables.get(key); if (hit) return hit; const groups = key[0] === "C" ? ["계수", "소요량"] : ["소요량", "계수"]; const job = (async (): Promise => { 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; 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, ): Promise { 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(); 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; /** 마지막으로 누른 줄이 미리 보기만 한 「설계 때 정함」 조건 */ 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, ): Promise { 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; }, finish: (value: string | null) => void, ): Promise { 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 => ({ ...base, ...states[now].tryValues }); const refresh = async (): Promise => { 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(); }