/* ============================================================================= * M01_MasterData_UI_Logic_CView.ts * 로직 테스트 — 방식 C(흐름 그림). 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계 * * 읽기 + 시험 계산만(저장 없음 · 정본 로직은 건드리지 않음) — 로직 화면이 쓰는 같은 길 * (`fetchLogic` · `runCalc`)을 그대로 씀. 상자를 누르면 값과 출처 · 로직이 로직을 부르는 * 줄은 상자 안에서 펼침 · 설계 값을 바꾸면 흐름의 값이 바로 바뀜. * 뼈대(어느 상자가 어디에) = `M01_MasterData_UI_Logic_CView_Model.ts`. * ========================================================================== */ import { createSelectField, el, showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { fetchLogic, runCalc, type CalcAnswer, type LogicOne, type LogicRow, } from "./M01_MasterData_UI_Logic_Api"; import { buildFlow, type FlowBox, type FlowColumn, type FlowKind, } from "./M01_MasterData_UI_Logic_CView_Model"; import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick"; import { guideLines, plainReason, tx } from "./M01_MasterData_UI_Logic_Text"; import "./M01_MasterData_UI_Logic_CView_Style.css"; const TEXT = { Loading: ["불러오는 중", "Loading"], Failed: ["불러오지 못함", "Load failed"], Stopped: ["멈춤", "Stopped"], NoInputs: ["받을 값 없음", "No inputs"], Other: ["그 밖", "Other"], Sub: ["이 로직이 부르는 로직", "Logic called here"], SubLines: ["호표", "Unit-cost lines"], Col_입력: ["설계 값", "Design values"], Col_중간: ["표 찾기", "Table lookup"], Col_수량: ["수량", "Qty"], Col_단가: ["× 단가", "× Unit price"], Col_덧줄: ["할증·덧줄", "Extra lines"], Col_비목: ["비목 합계", "Cost items"], Col_계: ["계", "Total"], } as const satisfies Record; const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]; const title = (kind: FlowKind): string => tc(`Col_${kind}` as keyof typeof TEXT); /** 흐름 그림 하나를 `host` 에 그림 — 로직 키 하나(어느 로직이 와도 돎) */ export function render(host: HTMLElement, logicKey: string): void { host.replaceChildren(el("p", { className: "m01v__muted", text: tc("Loading") })); void fetchLogic(logicKey) .then((one) => mount(host, one)) .catch((error: unknown) => { host.replaceChildren( el("p", { className: "m01v__bad", text: error instanceof Error ? error.message : tc("Failed"), }), ); }); } function mount(host: HTMLElement, one: LogicOne): void { const row = one.logic; const values: Record = {}; // 고르기 칸은 첫 값으로 시작 — 설계자가 바로 흐름을 보게(수 칸은 비워 둠) for (const spec of row.입력 ?? []) { if (spec.고르기?.length) values[spec.이름] = String(spec.고르기[0]); } const swaps: Swaps = new Map(); const picker = pickPanel(one, swaps, () => run()); const open = new Set(); const subs = new Map(); let answer: CalcAnswer | null = null; let timer: number | undefined; const rest = el("div", { className: "m01v__rest" }); const stopped = el("p", { className: "m01v__bad", attrs: { hidden: "" } }); const boxView = (box: FlowBox): HTMLElement => { const node = el("details", { className: `m01v__box${box.bad ? " m01v__box--bad" : ""}`, children: [ el("summary", { children: [ el("span", { className: "m01v__label", text: box.label }), el("span", { className: "m01v__value", text: box.value }), ...(box.note ? [el("span", { className: "m01v__note", text: box.note })] : []), ], }), el("dl", { className: "m01v__detail", children: box.detail.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: v })]), }), ], }); if (open.has(box.id)) node.open = true; node.addEventListener("toggle", () => (node.open ? open.add(box.id) : open.delete(box.id))); if (box.logic) node.append(subView(box.logic)); return node; }; /** 로직이 부르는 로직 — 펼칠 때 한 번 읽어 그 호표를 상자 안에 보임 */ const subView = (key: string): HTMLElement => { const body = el("div", { className: "m01v__sub-body" }); const node = el("details", { className: "m01v__sub", children: [el("summary", { text: `${tc("Sub")} · ${key}` }), body], }); const fill = (sub: LogicRow): void => { body.replaceChildren( el("p", { className: "m01v__muted", text: `${sub.이름} (${sub.결과단위}) · ${sub.출처}` }), el("p", { className: "m01v__muted", text: tc("SubLines") }), el("ul", { children: (sub.호표 ?? []).map((item) => el("li", { text: `${item.이름 ?? item.요소} · ${item.수량}` }), ), }), ); }; node.addEventListener("toggle", () => { if (!node.open || body.childElementCount) return; const had = subs.get(key); if (had) { fill(had); return; } body.replaceChildren(el("p", { className: "m01v__muted", text: tc("Loading") })); void fetchLogic(key) .then((deep) => { subs.set(key, deep.logic); fill(deep.logic); }) .catch(() => body.replaceChildren(el("p", { className: "m01v__bad", text: tc("Failed") }))); }); return node; }; const columnView = (column: FlowColumn): HTMLElement => { const head = el("h4", { className: "m01v__col-head", text: title(column.kind) }); // 상자가 많으면 비목으로 묶어 접음 — 줄이 많은 로직도 한 화면에(비목 없는 줄은 「그 밖」) const costOf = (box: FlowBox): string => box.cost ?? tc("Other"); const costs = [...new Set(column.boxes.map(costOf))]; const body = column.boxes.length > 6 && costs.length > 1 ? costs.map((cost) => el("details", { className: "m01v__group", attrs: { open: "" }, children: [ el("summary", { text: cost }), ...column.boxes.filter((b) => costOf(b) === cost).map(boxView), ], }), ) : column.boxes.map(boxView); return el("section", { className: "m01v__col", children: [head, ...body] }); }; const redraw = (): void => { const columns = buildFlow(row, answer, one.prices, values); const nodes: HTMLElement[] = []; for (const column of columns.slice(1)) { nodes.push(el("span", { className: "m01v__arrow", text: "›" }), columnView(column)); } rest.replaceChildren(...nodes); const reason = answer && !answer.ok ? answer.reason : ""; stopped.textContent = reason ? `${tc("Stopped")} — ${plainReason(reason)}` : ""; stopped.hidden = !reason; }; const run = (): void => { const inputs: Record = {}; for (const spec of row.입력 ?? []) { const raw = (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); } const swapped = swappedRow(row, swaps); void runCalc({ key: row.키, inputs, ...(swapped ? { row: swapped, file: one.file } : {}) }) .then((got) => { answer = got; redraw(); }) .catch((error: unknown) => { showToast(error instanceof Error ? error.message : tc("Failed"), "error"); }); }; const later = (): void => { window.clearTimeout(timer); timer = window.setTimeout(run, 250); }; host.replaceChildren( el("div", { className: "m01v", children: [ el("div", { className: "m01v__head", children: [ el("h3", { text: `${row.원문번호} ${row.이름}` }), el("span", { className: "m01v__muted", text: `${row.결과단위} · ${row.출처}` }), ], }), ...(one.reasons.length ? [el("p", { className: "m01v__bad", text: one.reasons.join(" · ") })] : []), el("ul", { className: "m01v__muted", children: guideLines().map((t) => el("li", { text: t })), }), stopped, ...(hasPickable(row) ? [ el("details", { attrs: { open: "" }, children: [el("summary", { text: tx("Mat_Title") }), picker], }), ] : []), el("div", { className: "m01v__flow", children: [inputColumn(row, values, later), rest], }), ], }), ); redraw(); run(); } /** 설계 값 칸 — 한 번만 세움(다시 그려도 적던 값·글쇠 자리가 안 날아감) */ function inputColumn( row: LogicRow, values: Record, onChange: () => void, ): HTMLElement { const fields = (row.입력 ?? []).map((spec) => { let control: HTMLElement; if (spec.고르기?.length) { control = createSelectField({ options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), value: values[spec.이름] ?? "", compact: true, onChange: (v) => { values[spec.이름] = v; onChange(); }, }).root; } else { const box = el("input", { className: "m01v__input", attrs: { type: "text", inputmode: "decimal" }, }); box.placeholder = spec.범위 ? `${spec.범위[0]} ∼ ${spec.범위[1]}` : tx("Enter_Value"); box.value = values[spec.이름] ?? ""; box.addEventListener("input", () => { values[spec.이름] = box.value; onChange(); }); control = box; } return el("label", { className: "m01v__field", children: [ el("span", { className: "m01v__label", text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, }), control, ], }); }); return el("section", { className: "m01v__col m01v__col--input", children: [ el("h4", { className: "m01v__col-head", text: title("입력") }), ...(fields.length ? fields : [el("p", { className: "m01v__muted", text: tc("NoInputs") })]), ], }); }