/* ============================================================================= * M01_MasterData_UI_LogicLab_Formula.ts * 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음) * 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이 * 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게 * 714-3 — 줄이 쓰는 입력(표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 → * 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8) * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api"; import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Formula.css"; /** 로직 열자마자 값 없이 부른 미리보기 응답은 `계산됨` 을 false 로 표시 — [계산] 을 거친 응답은 안 붙어 true 취급 */ export type PreviewTextAnswer = TextAnswer & { 계산됨?: boolean }; export interface FormulaContext { text: PreviewTextAnswer | null; /** 714-3 — 시험 계산 입력 · 지금 값 · 고친 뒤 할 일(값 넣고 다시 셈) · 없으면 단추 없음 */ inputs?: LogicInput[]; values?: Record; onInput?: (name: string, value: string) => void; } /* ── 순수 셈: 줄이 쓰는 입력 ── */ /** 줄의 조각에서 시험 입력 이름 → 그 줄이 쓴 값 — 표 찾기 행 열쇠 · 이름 조각(`값` 이 붙은 이름) · 차례 = 처음 나온 차례 */ export function usedInputs( line: Pick, names: string[], ): [string, string][] { const out = new Map(); for (const p of [...(line.조각 ?? []), ...(line.요소조각 ?? [])]) { const row = p.kind === "표" ? p.참조?.행 : null; if (row && typeof row === "object") for (const [k, v] of Object.entries(row)) if (names.includes(k) && !out.has(k)) out.set(k, String(v)); if (p.kind === "값" && p.값 !== undefined && names.includes(p.글) && !out.has(p.글)) out.set(p.글, String(p.값)); } return [...out]; } /* ── 순수 셈 끝 ── */ /** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` */ function inputChip(spec: LogicInput, shown: string, onInput: (v: string) => void): HTMLElement { const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } }); const chip = createButton({ label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`, variant: "ghost", size: 24, onClick: (ev) => { ev.stopPropagation(); // 줄 누름(밝히기 고정)과 안 겹치게 let over = false; // Enter 뒤 blur · 고름 뒤 blur 가 두 번 부르지 않게 const done = (v: string | null): void => { if (over) return; over = true; if (v !== null && v.trim() !== "" && v !== shown) onInput(v.trim()); else wrap.replaceChildren(chip); }; let field: HTMLElement; if (spec.고르기?.length) { const pick = createSelectField({ options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), value: shown, size: 24, ariaLabel: spec.이름, onChange: (v) => done(v), }); pick.select.addEventListener("blur", () => done(null)); field = pick.root; } else { const box = createInputField({ value: shown, size: 24 }); box.input.setAttribute("aria-label", spec.이름); box.input.addEventListener("keydown", (k) => { if (k.isComposing) return; if (k.key === "Enter") done(box.input.value); if (k.key === "Escape") (k.preventDefault(), done(null)); }); box.input.addEventListener("blur", () => done(box.input.value)); field = box.root; } field.addEventListener("click", (e) => e.stopPropagation()); wrap.replaceChildren(field); field.querySelector("select, input")?.focus(); }, }); chip.title = tl("Formula_Var"); wrap.append(chip); return wrap; } const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text }); export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { const text = ctx.text; if (!text) { host.replaceChildren(muted(tl("Formula_Slot"))); return; } if (!text.ok) { host.replaceChildren( el("div", { className: "ui-banner ui-banner--danger m01-logic__reasons", children: [ el("strong", { text: tx("Calc_Stopped") }), el("div", { text: safeReason(text.reason) }), ], }), ); return; } const heads = el("div", { className: "m01lab-fx__head", children: [ el("strong", { text: tl("Formula_Left") }), el("strong", { text: tl("Formula_Right") }), ], }); const rows: HTMLElement[] = []; const specs = ctx.onInput ? (ctx.inputs ?? []) : []; const vars = (line: TextLine): HTMLElement[] => { const used = usedInputs( line, specs.map((x) => x.이름), ); if (!used.length || !ctx.onInput) return []; const onInput = ctx.onInput; return [ el("div", { className: "m01lab-fx__vars", children: used.map(([name, was]) => inputChip( specs.find((x) => x.이름 === name)!, ctx.values?.[name] || was, (v) => onInput(name, v), ), ), }), ]; }; const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false const light = (pair: string, on: boolean): void => rows.forEach((r) => r.dataset.pair === pair && r.classList.toggle("is-on", on)); const blocks = text.groups.flatMap((g) => [ el("h4", { className: "m01lab__group m01lab-fx__wide", text: g.비목 }), ...g.줄.map((line, i) => { const pair = String(line.짝 ?? `${g.비목}${i}`); const row = el("div", { className: `m01lab-fx__row m01lab__text${line.까닭 ? " m01lab__text--off" : ""}`, attrs: { "data-text-line": line.이름, "data-pair": pair }, children: [ el("div", { className: "m01lab-fx__cell", text: line.이름글 ?? line.이름 }), el("div", { className: "m01lab-fx__cell", children: calculated ? [ el("div", { text: `${line.이름} : ${line.글}` }), ...vars(line), ...(line.까닭 ? [ el("div", { className: "ui-text ui-text--muted", text: safeReason(line.까닭), }), ] : []), ] : [el("div", { className: "ui-text ui-text--muted", text: tl("Formula_Slot") })], }), ], }); row.addEventListener("mouseenter", () => light(pair, true)); row.addEventListener("mouseleave", () => light(pair, false)); row.addEventListener("click", () => { const pinned = row.classList.toggle("is-pin"); rows.forEach((r) => r !== row && r.classList.remove("is-pin")); if (!pinned) row.classList.remove("is-pin"); }); rows.push(row); return row; }), el("p", { className: "m01lab__subtotal-text m01lab-fx__wide", attrs: { "data-text-sub": String(g.소계) }, text: `${g.비목} ${tl("Subtotal")} ${formatMoney(g.소계)}${g.끝수 ? ` (${g.끝수})` : ""}`, }), ]); blocks.unshift(heads); blocks.push( el("p", { className: "m01lab__total m01lab-fx__wide", attrs: { "data-text-sum": String(text.계) }, text: `${tl("Total")} ${formatMoney(text.계)}`, }), ); host.replaceChildren(el("div", { className: "m01lab-fx", children: blocks })); }