/* ============================================================================= * M01_MasterData_UI_Test_B.ts * 로직 사용성 테스트 컨테이너 — 방식 B: 호표 + 옆 설명 카드 (PLAN.md 1-2) * * 접속 계약: export function render(host, logicKey) 하나. * 읽기 + 시험 계산만 — 저장 없음 · 정본 로직 안 고침. 로직 읽기·시험 계산은 지금 * 로직 화면이 쓰는 Store·API(fetchLogic · runCalc · elements · table)를 그대로 * 씀 — 새 엔진·새 계산 없음(만약·찾기 식 자체는 서버가 풂 · 여기선 찾기(...) 가 * 가리키는 원문 표를 다시 읽어 「걸린 줄」만 강조). * * 줄을 누르면 옆 카드 — 원문 표 미리보기(걸린 줄 강조) · 적용된 할증 · 쉬운 말 * 풀이(식을 문장으로, 안 되면 원문 식 그대로) · 출처. 입력을 바꾸면 호표와 * 카드가 같이 바뀜(자동 계산 — [계산] 버튼 없음). * ========================================================================== */ import { createSelectField, el } from "@ui/ui_template_elements"; import { fetchLogic, runCalc, type CalcAnswer, type CalcLine, type HoLine, type LogicOne, } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { buildNote, relatedFinds, type EvalCtx } from "./M01_MasterData_UI_Logic_Note"; import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick"; import { guideLines, plainReason, tt } from "./M01_MasterData_UI_Test_Text"; /* ── 화면 상태 ────────────────────────────────────────────────────────── */ interface State { one: LogicOne; values: Record; answer: CalcAnswer | null; selected: number; calcSeq: number; swaps: Swaps; picker: HTMLElement; } export function render(host: HTMLElement, logicKey: string): void { host.replaceChildren(el("p", { className: "m01-logic__muted", text: "불러오는 중…" })); void fetchLogic(logicKey) .then((one) => { const swaps: Swaps = new Map(); const state: State = { one, // 고르기 칸은 첫 값으로 시작 — 방식 C 와 같게(바로 계산이 돎) values: Object.fromEntries( (one.logic.입력 ?? []) .filter((spec) => spec.고르기?.length) .map((spec) => [spec.이름, String(spec.고르기?.[0])]), ), answer: null, selected: 0, calcSeq: 0, swaps, picker: pickPanel(one, swaps, () => void recalc(host, state)), }; 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: swappedRow(state.one.logic, state.swaps) ?? 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", placeholder: tt("Enter_Value") }, }); 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.이름}` }), el("ul", { className: "m01-logic__muted", children: guideLines("B").map((t) => el("li", { text: t })), }), inputWrap, ...(hasPickable(state.one.logic) ? [ el("details", { attrs: { open: "" }, children: [el("summary", { text: tt("Mat_Title") }), state.picker], }), ] : []), buildHoTable(host, state), ); if (state.answer && !state.answer.ok) { main.append( el("div", { className: "m01-logic__reasons", children: [ el("strong", { text: "멈춤" }), el("div", { text: plainReason(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;"; 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, // 하위 로직이 여러 줄로 풀린 경우 — 호표 줄 수보다 많은 줄도 빠짐없이 보임 ...(lines ?? []).slice((state.one.logic.호표 ?? []).length).map((calc) => el("tr", { children: [ el("td", { text: calc.이름 }), el("td", { text: "" }), el("td", { text: `${formatNumber(calc.수량)} ${calc.단위}` }), el("td", { className: "m01-logic__money", text: formatNumber(calc.금액) }), ], }), ), ...(state.answer?.ok && state.answer.sums ? [ el("tr", { attrs: { "data-total": String(state.answer.sums["계"] ?? 0) }, children: [ el("td", { text: tt("Sum_Total") }), el("td", { text: "" }), el("td", { text: "" }), el("td", { text: formatNumber(state.answer.sums["계"] ?? 0) }), ], }), ] : []), ], }), ], }); } function buildCard(side: HTMLElement, state: State): void { 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.비목 ?? "-" }), ], }), ); 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]) : "(입력 필요)"}`, }), ), ); } // 쉬운 말 풀이 · 원문 표 미리보기 · 고급(식 그대로) — 흐름 그림과 같은 카드 const note = el("div"); side.append(note); buildNote(note, { expr: ho.수량, middles: state.one.logic.중간 ?? [], ctx }); }