diff --git a/M01_MasterData/M01_MasterData_UI_Test_B.ts b/M01_MasterData/M01_MasterData_UI_Test_B.ts new file mode 100644 index 00000000..294ee19d --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Test_B.ts @@ -0,0 +1,453 @@ +/* ============================================================================= + * M01_MasterData_UI_Test_B.ts + * 로직 사용성 테스트 컨테이너 — 방식 B: 호표 + 옆 설명 카드 (PLAN.md 1-2) + * + * 접속 계약: export function render(host, logicKey) 하나. + * 읽기 + 시험 계산만 — 저장 없음 · 정본 로직 안 고침. 로직 읽기·시험 계산은 지금 + * 로직 화면이 쓰는 Store·API(fetchLogic · runCalc · elements · table)를 그대로 + * 씀 — 새 엔진·새 계산 없음(만약·찾기 식 자체는 서버가 풂 · 여기선 찾기(...) 가 + * 가리키는 원문 표를 다시 읽어 「걸린 줄」만 강조). + * + * 줄을 누르면 옆 카드 — 원문 표 미리보기(걸린 줄 강조) · 적용된 할증 · 쉬운 말 + * 풀이(식을 문장으로, 안 되면 원문 식 그대로) · 출처. 입력을 바꾸면 호표와 + * 카드가 같이 바뀜(자동 계산 — [계산] 버튼 없음). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import { + fetchLogic, + runCalc, + type CalcAnswer, + type CalcLine, + type ElementBrief, + type HoLine, + type LogicOne, + type NamedFormula, +} from "./M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; + +/* ── 원문 표(소요량·계수) 미리보기 — /elements 로 파일을 찾고 /table 로 통째 읽음 ── */ + +interface TableRow { + file: string; + 키: string; + 이름?: string; + 원문번호?: string; + 출처?: string; + 조건?: Record; + 값칸?: Record; + 줄?: Record[]; +} + +const tableCache = new Map>(); + +async function getJson(path: string): Promise { + try { + const res = await fetch(`${API_BASE_URL}/m01${path}`); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } +} + +function loadTable(key: string): Promise { + const cached = tableCache.get(key); + if (cached) return cached; + const job = (async (): Promise => { + for (const group of ["소요량", "계수"]) { + const found = await getJson<{ items: ElementBrief[] }>( + `/elements?group=${encodeURIComponent(group)}&q=${encodeURIComponent(key)}&limit=5`, + ); + const file = found?.items?.find((i) => i.ref === key)?.file; + if (!file) continue; + const got = await getJson<{ table: TableRow }>( + `/table?file=${encodeURIComponent(file)}&key=${encodeURIComponent(key)}`, + ); + if (got?.table) return { ...got.table, file }; + } + return null; + })(); + tableCache.set(key, job); + return job; +} + +/* ── 식 속 찾기(표, 조건...).칸 뽑기 ────────────────────────────────────── */ + +interface FindCall { + table: string; + conds: [string, string][]; + col: string; +} + +const FIND_RE = /찾기\(\s*([A-Za-z0-9_]+)\s*,([^)]*)\)\s*\.\s*([A-Za-z0-9_가-힣]+)/g; + +function splitConds(raw: string): [string, string][] { + return raw + .split(",") + .map((p) => p.trim()) + .filter(Boolean) + .map((p): [string, string] => { + const i = p.indexOf("="); + return i < 0 ? [p, p] : [p.slice(0, i).trim(), p.slice(i + 1).trim()]; + }); +} + +function findCalls(expr: string): FindCall[] { + const out: FindCall[] = []; + for (const m of expr.matchAll(FIND_RE)) { + out.push({ table: m[1], conds: splitConds(m[2]), col: m[3] }); + } + return out; +} + +/** 식 안에 이름이 낱말로 나오는지 — 한글엔 \b 가 안 먹어 앞뒤 글자를 직접 봄 */ +function mentions(expr: string, name: string): boolean { + if (!name) return false; + const idx = expr.indexOf(name); + if (idx < 0) return false; + const isWord = (c: string | undefined) => !!c && /[A-Za-z0-9_가-힣]/.test(c); + return !isWord(expr[idx - 1]) && !isWord(expr[idx + name.length]); +} + +interface RelatedFind { + source: string; // "이 줄" 또는 물려 쓰는 중간 이름 + find: FindCall; +} + +/** 이 줄 식이 기대는 찾기(...) — 자기 식 + (한 단계) 물려 쓰는 중간 식 */ +function relatedFinds(expr: string, middles: NamedFormula[]): RelatedFind[] { + const out: RelatedFind[] = findCalls(expr).map((find) => ({ source: "이 줄", find })); + for (const m of middles) { + if (!mentions(expr, m.이름)) continue; + for (const find of findCalls(m.식)) out.push({ source: m.이름, find }); + } + return out; +} + +/* ── 조건 값 풀기 · 표 줄 맞히기 ───────────────────────────────────────── */ + +interface EvalCtx { + values: Record; + middle: Record; +} + +function resolveIdent(name: string, ctx: EvalCtx): string | number | undefined { + const quoted = name.match(/^["'](.*)["']$/); + if (quoted) return quoted[1]; + if (/^-?\d+(\.\d+)?$/.test(name)) return Number(name); + if (ctx.values[name] !== undefined && ctx.values[name] !== "") return ctx.values[name]; + if (ctx.middle[name] !== undefined) return ctx.middle[name] as number; + return undefined; +} + +function sameValue(a: unknown, b: unknown): boolean { + if (a === undefined || a === null || b === undefined) return false; + const na = Number(a); + const nb = Number(b); + if (!Number.isNaN(na) && !Number.isNaN(nb)) return na === nb; + return String(a) === String(b); +} + +/** 지금 조건 값으로 찾기() 가 고를 줄 — 못 맞히면 null(값을 아직 안 골랐거나 후보 여럿) */ +function matchRow( + table: TableRow, + conds: [string, string][], + ctx: EvalCtx, +): Record | null { + for (const row of table.줄 ?? []) { + if (conds.every(([col, rhs]) => sameValue(row[col], resolveIdent(rhs, ctx)))) return row; + } + return null; +} + +/* ── 쉬운 말 풀이 — 찾기()/만약() 을 문장으로, 안 되면 식 그대로 ─────────── */ + +function splitTop(s: string): string[] { + const out: string[] = []; + let depth = 0; + let cur = ""; + for (const ch of s) { + if (ch === "(") depth++; + if (ch === ")") depth--; + if (ch === "," && depth === 0) { + out.push(cur); + cur = ""; + } else cur += ch; + } + out.push(cur); + return out; +} + +function expandIf(expr: string): string { + const at = expr.indexOf("만약("); + if (at < 0) return expr; + let depth = 0; + let j = at + 2; // "만약(" 의 '(' 자리 + for (; j < expr.length; j++) { + if (expr[j] === "(") depth++; + else if (expr[j] === ")") { + depth--; + if (depth === 0) { + j++; + break; + } + } + } + const [cond, then, els] = splitTop(expr.slice(at + 3, j - 1)).map((p) => p.trim()); + const said = `(${expandIf(cond)} 이면 ${expandIf(then)}, 아니면 ${expandIf(els ?? "")})`; + return expandIf(expr.slice(0, at) + said + expr.slice(j)); +} + +function explain(expr: string): string { + const withFind = expr.replace(FIND_RE, (_all, table: string, conds: string, col: string) => { + const cs = splitConds(conds) + .map(([, rhs]) => rhs) + .join(" · "); + return `[${table} 표에서 ${cs} 에 맞는 「${col}」]`; + }); + return expandIf(withFind).trim() || expr; +} + +/* ── 화면 상태 ────────────────────────────────────────────────────────── */ + +interface State { + one: LogicOne; + values: Record; + answer: CalcAnswer | null; + selected: number; + calcSeq: number; +} + +export function render(host: HTMLElement, logicKey: string): void { + host.replaceChildren(el("p", { className: "m01-logic__muted", text: "불러오는 중…" })); + void fetchLogic(logicKey) + .then((one) => { + const state: State = { one, values: {}, answer: null, selected: 0, calcSeq: 0 }; + paint(host, state); + void recalc(host, state); + }) + .catch((error) => { + host.replaceChildren( + el("p", { className: "m01-logic__reasons", text: String(error?.message ?? error) }), + ); + }); +} + +async function recalc(host: HTMLElement, state: State): Promise { + const seq = ++state.calcSeq; + const inputs: Record = {}; + for (const spec of state.one.logic.입력 ?? []) { + const raw = (state.values[spec.이름] ?? "").trim(); + if (raw === "") continue; + const option = spec.고르기?.find((o) => String(o) === raw); + inputs[spec.이름] = + option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw); + } + try { + const answer = await runCalc({ + key: state.one.logic.키, + inputs, + row: state.one.logic, + file: state.one.file, + }); + if (seq !== state.calcSeq) return; // 최신 응답만 채택 + state.answer = answer; + } catch (error) { + if (seq !== state.calcSeq) return; + state.answer = { ok: false, reason: error instanceof Error ? error.message : String(error) }; + } + paint(host, state); +} + +/* ── 그리기 ───────────────────────────────────────────────────────────── */ + +function paint(host: HTMLElement, state: State): void { + const row = state.one.logic; + + const inputWrap = el("div", { className: "m01-logic__fields" }); + inputWrap.style.cssText = "display:flex;flex-wrap:wrap;gap:12px;margin-bottom:12px;"; + for (const spec of row.입력 ?? []) { + let control: HTMLElement; + if (spec.고르기?.length) { + const pick = createSelectField({ + options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o || "(선택)" })), + value: state.values[spec.이름] ?? "", + compact: true, + onChange: (v) => { + state.values[spec.이름] = v; + void recalc(host, state); + }, + }); + control = pick.root; + } else { + const box = el("input", { + className: "m01-logic__input", + attrs: { type: "text", inputmode: "decimal" }, + }); + box.value = state.values[spec.이름] ?? ""; + let timer: ReturnType | undefined; + box.addEventListener("input", () => { + state.values[spec.이름] = box.value; + clearTimeout(timer); + timer = setTimeout(() => void recalc(host, state), 300); + }); + control = box; + } + const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름; + inputWrap.append( + el("label", { + className: "m01-logic__field", + children: [el("span", { text: label }), control], + }), + ); + } + + const main = el("div", {}); + main.style.cssText = "flex:1 1 60%;min-width:0;"; + main.append( + el("h3", { text: `${row.원문번호} ${row.이름}` }), + inputWrap, + buildHoTable(host, state), + ); + if (state.answer && !state.answer.ok) { + main.append( + el("div", { + className: "m01-logic__reasons", + children: [el("strong", { text: "멈춤" }), el("div", { text: state.answer.reason })], + }), + ); + } + + const side = el("div", { className: "m01-logic__side" }); + side.style.cssText = + "flex:1 1 36%;min-width:280px;border:1px solid var(--ui-border,#ddd);border-radius:8px;padding:12px;"; + void buildCard(side, state); + + const layout = el("div", {}); + layout.style.cssText = "display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap;"; + layout.append(main, side); + host.replaceChildren(layout); +} + +function buildHoTable(host: HTMLElement, state: State): HTMLElement { + const lines: CalcLine[] | null = state.answer?.ok ? (state.answer.lines ?? null) : null; + const rows = (state.one.logic.호표 ?? []).map((ho, i) => { + const calc = lines?.[i]; + const tr = el("tr", { + className: i === state.selected ? "m01-logic__row is-selected" : "m01-logic__row", + children: [ + el("td", { text: ho.이름 ?? ho.요소 }), + el("td", { text: ho.규격 ?? "" }), + el("td", { text: calc ? `${formatNumber(calc.수량)} ${calc.단위}` : (ho.단위 ?? "") }), + el("td", { className: "m01-logic__money", text: calc ? formatNumber(calc.금액) : "" }), + ], + }); + tr.style.cssText = + "cursor:pointer;" + (i === state.selected ? "background:var(--ui-accent-bg,#eef4ff);" : ""); + tr.addEventListener("click", () => { + state.selected = i; + paint(host, state); + }); + return tr; + }); + return el("table", { + className: "m01-logic__grid", + children: [ + el("thead", { + children: [ + el("tr", { + children: ["이름", "규격", "수량", "금액"].map((h) => el("th", { text: h })), + }), + ], + }), + el("tbody", { children: rows }), + ], + }); +} + +async function buildCard(side: HTMLElement, state: State): Promise { + const ho: HoLine | undefined = (state.one.logic.호표 ?? [])[state.selected]; + if (!ho) { + side.replaceChildren(el("p", { className: "m01-logic__muted", text: "호표 줄이 없음" })); + return; + } + const lines = state.answer?.ok ? (state.answer.lines ?? []) : []; + const calc = lines[state.selected]; + const middle = state.answer?.ok ? (state.answer.middle ?? {}) : {}; + const ctx: EvalCtx = { values: state.values, middle }; + const related = relatedFinds(ho.수량, state.one.logic.중간 ?? []); + + side.replaceChildren( + el("h4", { text: `이 수량은 어디서 왔나 — ${ho.이름 ?? ho.요소}` }), + el("dl", { + className: "m01-logic__sums", + children: [ + el("dt", { text: "지금 값" }), + el("dd", { + text: calc + ? `${formatNumber(calc.수량)} ${calc.단위} · ${formatNumber(calc.금액)}원` + : "값을 넣어야 계산됨", + }), + el("dt", { text: "출처" }), + el("dd", { text: calc?.출처 ?? ho.비목 ?? "-" }), + ], + }), + el("p", { text: explain(ho.수량) }), + ); + + const surcharge = related.filter((r) => r.source !== "이 줄" && r.source.includes("할증")); + if (surcharge.length) { + side.append( + el("h4", { text: "적용된 할증" }), + ...surcharge.map((r) => + el("p", { + text: `${r.source} = ${middle[r.source] !== undefined ? formatNumber(middle[r.source]) : "(입력 필요)"}`, + }), + ), + ); + } + + side.append(el("h4", { text: "원문 표 미리보기" })); + const previewHost = el("div", { text: "찾는 중…" }); + side.append(previewHost); + const previews = await Promise.all( + related.map(async (r) => ({ r, table: await loadTable(r.find.table) })), + ); + previewHost.replaceChildren( + ...previews.flatMap(({ r, table }) => { + if (!table) + return [el("p", { className: "m01-logic__muted", text: `${r.find.table} 표 못 찾음` })]; + const hit = matchRow(table, r.find.conds, ctx); + const cols = [...Object.keys(table.조건 ?? {}), ...Object.keys(table.값칸 ?? {})]; + const bodyRows = (table.줄 ?? []).map((line) => { + const isHit = hit === line; + const tr = el("tr", { + children: cols.map((c) => el("td", { text: formatNumber(line[c]) })), + }); + if (isHit) tr.style.cssText = "background:var(--ui-accent-bg,#fff3cd);font-weight:600;"; + return tr; + }); + return [ + el("p", { + className: "m01-logic__muted", + text: `${r.source} · ${table.원문번호 ?? table.키} ${table.이름 ?? ""} (${table.출처 ?? ""})`, + }), + el("table", { + className: "m01-logic__grid", + children: [ + el("thead", { + children: [el("tr", { children: cols.map((c) => el("th", { text: c })) })], + }), + el("tbody", { children: bodyRows }), + ], + }), + ]; + }), + ); + + const advanced = el("details", {}); + advanced.append(el("summary", { text: "고급 — 식 그대로" }), el("pre", { text: ho.수량 })); + side.append(advanced); +}