/* ============================================================================= * 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("찾기("); }), ], 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(); }