/* ============================================================================= * M01_MasterData_UI_Logic_Flow.ts * 일위대가 로직 기본 보기 — 흐름 그림. 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계 * * 상자를 누르면 값·출처 · 원문 표 미리보기 · 쉬운 말 풀이(`…_Logic_Note.ts`) · 부르는 로직 펼치기. * [고치기] 를 켜면 상자 안에서 바로 고치고 줄을 더하거나 지움 — 자체 로직만(정본은 「본떠 만들기」 뒤). * 값은 모두 시험 계산(`POST /calc`)에서 옴 — 여기서 새로 셈하지 않음. * 뼈대(어느 상자가 어디에) = `M01_MasterData_UI_Logic_Flow_Model.ts`. * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { fetchLogic, runCalc, type CalcAnswer, type HoLine, type LogicOne, type LogicRow, type NamedFormula, } from "./M01_MasterData_UI_Logic_Api"; import { buildFlow, isMoney, type FlowBox, type FlowColumn, type FlowKind, } from "./M01_MasterData_UI_Logic_Flow_Model"; import { buildNote } from "./M01_MasterData_UI_Logic_Note"; import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick"; import { guideLines, plainReason, tx, type TextKey } from "./M01_MasterData_UI_Logic_Text"; import "./M01_MasterData_UI_Logic_Flow_Style.css"; const title = (kind: FlowKind): string => tx(`Col_${kind}` as TextKey); /** 자체 로직인지 — 자체 파일이거나 키가 `GX…` · 아직 저장 안 한 새 줄(키 "")도 고칠 수 있음 */ export const isOwnLogic = (file: string, row: LogicRow): boolean => file.startsWith("로직_자체") || (row.키 ?? "").startsWith("GX") || (row.키 ?? "") === ""; export interface FlowContext { one: LogicOne; /** 지금 보는 줄 — 고친 것이 있으면 그 줄(`one.logic` 과 다른 객체일 수 있음) */ row: LogicRow; /** 상자에서 고칠 수 있는지 — 자체 로직만 */ editable: boolean; /** 고친 뒤 — 화면이 캐시에 담음 */ onChange?: () => void; /** 「본떠 만들기」 — 정본 로직일 때만 */ onCopy?: () => void; } /** 흐름 그림 하나를 `host` 에 그림 — 로직 키 하나(어느 로직이 와도 돎 · 읽기 전용) */ export function render(host: HTMLElement, logicKey: string): void { host.replaceChildren(el("p", { className: "m01c__muted", text: tx("Load_Failed") })); void fetchLogic(logicKey) .then((one) => mountFlow(host, { one, row: one.logic, editable: false })) .catch((error: unknown) => { host.replaceChildren( el("p", { className: "m01c__bad", text: error instanceof Error ? error.message : tx("Load_Failed"), }), ); }); } export function mountFlow(host: HTMLElement, ctx: FlowContext): void { const one = ctx.one; const row = ctx.row; 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, logic: row }, swaps, () => run()); const open = new Set(); const subs = new Map(); let answer: CalcAnswer | null = null; let editing = false; let timer: number | undefined; const rest = el("div", { className: "m01c__rest" }); const stopped = el("p", { className: "m01c__bad", attrs: { hidden: "" } }); /** 고친 줄 — 화면에 알리고 계산을 다시(값 칸은 글쇠 자리를 지켜 늦춰 그림) */ const touched = (): void => { ctx.onChange?.(); later(); }; /* ── 상자 ─────────────────────────────────────────────────────────── */ const boxView = (box: FlowBox): HTMLElement => { const node = el("details", { className: `m01c__box${box.bad ? " m01c__box--bad" : ""}`, children: [ el("summary", { children: [ el("span", { className: "m01c__label", text: box.label }), el("span", { className: "m01c__value", text: box.value }), ...(box.note ? [el("span", { className: "m01c__note", text: box.note })] : []), ], }), el("dl", { className: "m01c__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 (editing) node.append(...editView(box.id)); const expr = formulaOf(box); if (expr) node.append(noteView(expr)); if (box.logic) node.append(subView(box.logic)); return node; }; /** 이 상자가 풀어 볼 식 — 호표 줄의 수량 · 중간 값·덧줄의 식 */ const formulaOf = (box: FlowBox): string => { const [kind, at] = box.id.split(":"); const i = Number(at); if (kind === "수량") return (row.호표 ?? [])[i]?.수량 ?? ""; if (kind === "중간") return (row.중간 ?? [])[i]?.식 ?? ""; if (kind === "덧줄") return (row.덧줄 ?? [])[i]?.식 ?? ""; return ""; }; /** 쉬운 말 풀이 + 원문 표 미리보기 — 펼칠 때 한 번만 채움(표는 서버에서 읽음) */ const noteView = (expr: string): HTMLElement => { const body = el("div", { className: "m01c__note-body" }); const node = el("details", { className: "m01c__sub", children: [el("summary", { text: tx("Note_Plain") }), body], }); node.addEventListener("toggle", () => { if (!node.open || body.childElementCount) return; buildNote(body, { expr, middles: row.중간 ?? [], ctx: { values, middle: answer?.ok ? (answer.middle ?? {}) : {} }, }); }); return node; }; /** 로직이 부르는 로직 — 펼칠 때 한 번 읽어 그 호표를 상자 안에 보임 */ const subView = (key: string): HTMLElement => { const body = el("div", { className: "m01c__sub-body" }); const node = el("details", { className: "m01c__sub", children: [el("summary", { text: `${tx("Flow_Sub")} · ${key}` }), body], }); const fill = (sub: LogicRow): void => { body.replaceChildren( el("p", { className: "m01c__muted", text: `${sub.이름} (${sub.결과단위}) · ${sub.출처}` }), el("p", { className: "m01c__muted", text: tx("Flow_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: "m01c__muted", text: tx("Note_Searching") })); void fetchLogic(key) .then((deep) => { subs.set(key, deep.logic); fill(deep.logic); }) .catch(() => body.replaceChildren(el("p", { className: "m01c__bad", text: tx("Load_Failed") })), ); }); return node; }; /* ── 상자에서 고치기 ──────────────────────────────────────────────── */ /** 글 칸 하나 — 고치면 그 자리에서 줄을 바꾸고 계산만 다시(다시 그려도 글쇠 자리가 남게 `data-at`) */ const editBox = (at: string, label: string, value: string, set: (v: string) => void) => { const input = el("input", { className: "m01c__input", attrs: { type: "text", "data-at": at }, }); input.value = value; input.addEventListener("input", () => { set(input.value); touched(); }); return el("label", { className: "m01c__edit", children: [el("span", { className: "m01c__muted", text: label }), input], }); }; const dropButton = (onClick: () => void): HTMLElement => createButton({ label: tx("DeleteRow"), variant: "ghost", onClick: () => { onClick(); ctx.onChange?.(); draw(); }, }); const editView = (id: string): HTMLElement[] => { const [kind, at] = id.split(":"); const i = Number(at); if (kind === "입력") { const spec = (row.입력 ?? [])[i]; if (!spec) return []; return [ el("div", { className: "m01c__edits", children: [ editBox(id + ":1", tx("Head_Name"), spec.이름, (v) => (spec.이름 = v)), editBox(id + ":2", tx("Inputs_Unit"), spec.단위 ?? "", (v) => { if (v.trim()) spec.단위 = v; else delete spec.단위; }), editBox(id + ":3", tx("Inputs_Choices"), (spec.고르기 ?? []).join(", "), (v) => { const items = v .split(",") .map((x) => x.trim()) .filter(Boolean); // 모두 수면 수로 — 글 "35" 로 적으면 표의 35 와 안 맞음 if (items.length) spec.고르기 = items.every((x) => !Number.isNaN(Number(x))) ? items.map(Number) : items; else delete spec.고르기; }), dropButton(() => (row.입력 ?? []).splice(i, 1)), ], }), ]; } if (kind === "수량") { const line = (row.호표 ?? [])[i]; if (!line) return []; return [ el("div", { className: "m01c__edits", children: [ editBox(id + ":1", tx("Ho_Element"), line.요소, (v) => (line.요소 = v)), editBox(id + ":2", tx("Ho_Name"), line.이름 ?? "", (v) => (line.이름 = v)), editBox(id + ":3", tx("Ho_Unit"), line.단위 ?? "", (v) => (line.단위 = v)), editBox(id + ":4", tx("Edit_Qty"), line.수량, (v) => (line.수량 = v)), dropButton(() => (row.호표 ?? []).splice(i, 1)), ], }), ]; } const list: NamedFormula[] | undefined = kind === "중간" ? row.중간 : kind === "덧줄" ? row.덧줄 : undefined; if (!list?.[i]) return []; const item = list[i]; return [ el("div", { className: "m01c__edits", children: [ editBox(id + ":1", tx("Head_Name"), item.이름, (v) => (item.이름 = v)), editBox(id + ":2", tx("Formula"), item.식, (v) => (item.식 = v)), dropButton(() => list.splice(i, 1)), ], }), ]; }; /** 칸 아래 「줄 더하기」 — 고치기를 켰을 때만 */ const addButton = (kind: FlowKind): HTMLElement | null => { if (!editing) return null; const add = (label: string, push: () => void): HTMLElement => createButton({ label, variant: "ghost", onClick: () => { push(); ctx.onChange?.(); draw(); }, }); if (kind === "입력") return add(tx("Edit_AddInput"), () => void (row.입력 ??= []).push({ 이름: "" })); if (kind === "중간") return add(tx("Edit_AddMiddle"), () => void (row.중간 ??= []).push({ 이름: "", 식: "" })); if (kind === "수량") return add( tx("Edit_AddHo"), () => void (row.호표 ??= []).push({ 종류: "인력", 요소: "", 이름: "", 단위: "인", 수량: "", 비목: "노무비", } as HoLine), ); if (kind === "덧줄") return add( tx("Edit_AddExtra"), () => void (row.덧줄 ??= []).push({ 이름: "", 식: "", 비목: "재료비", 출처: "" }), ); return null; }; /* ── 칸·다시 그리기 ───────────────────────────────────────────────── */ const columnView = (column: FlowColumn): HTMLElement => { const head = el("h4", { className: "m01c__col-head", text: title(column.kind) }); // 상자가 많으면 비목으로 묶어 접음 — 줄이 많은 로직도 한 화면에(비목 없는 줄은 「그 밖」) const costOf = (box: FlowBox): string => box.cost ?? tx("Flow_Other"); const costs = [...new Set(column.boxes.map(costOf))]; const body = column.boxes.length > 6 && costs.length > 1 && !editing ? costs.map((cost) => el("details", { className: "m01c__group", attrs: { open: "" }, children: [ el("summary", { text: cost }), ...column.boxes.filter((b) => costOf(b) === cost).map(boxView), ], }), ) : column.boxes.map(boxView); const add = addButton(column.kind); return el("section", { className: "m01c__col", children: [head, ...body, ...(add ? [add] : [])], }); }; /** 다시 그려도 적던 칸으로 돌아감 — `data-at` 이 같은 칸에 글쇠와 자리를 되돌림 */ const keepFocus = (paint: () => void): void => { const was = document.activeElement as HTMLInputElement | null; const at = was?.dataset?.at; const start = at ? was?.selectionStart : null; paint(); if (!at) return; const next = host.querySelector(`[data-at="${CSS.escape(at)}"]`); if (!next) return; next.focus(); if (start !== null && start !== undefined) next.setSelectionRange(start, start); }; /** 고치기를 켜면 비어 있어 빠진 칸(표 찾기·덧줄)도 세움 — 거기에 줄을 더할 수 있게 */ const withEmpty = (columns: FlowColumn[]): FlowColumn[] => { if (!editing || !isMoney(row)) return columns; const out = [...columns]; const put = (kind: FlowKind, before: FlowKind): void => { if (out.some((c) => c.kind === kind)) return; const at = out.findIndex((c) => c.kind === before); out.splice(at < 0 ? out.length : at, 0, { kind, boxes: [] }); }; put("중간", "수량"); put("덧줄", "비목"); return out; }; const redraw = (): void => { keepFocus(() => { const columns = withEmpty(buildFlow(row, answer, one.prices, values)); const nodes: HTMLElement[] = []; for (const column of columns.slice(1)) { nodes.push(el("span", { className: "m01c__arrow", text: "›" }), columnView(column)); } rest.replaceChildren(...nodes); }); const reason = answer && !answer.ok ? answer.reason : ""; stopped.textContent = reason ? `${tx("Calc_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); const draft = swapped ?? (ctx.editable ? row : undefined); void runCalc({ key: row.키, inputs, ...(draft ? { row: draft, file: one.file } : {}) }) .then((got) => { answer = got; redraw(); }) .catch((error: unknown) => { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); }); }; const later = (): void => { window.clearTimeout(timer); timer = window.setTimeout(run, 250); }; /* ── 틀 ───────────────────────────────────────────────────────────── */ const bar = el("div", { className: "m01c__edit-bar" }); const fillBar = (): void => { if (ctx.editable) { bar.replaceChildren( createButton({ label: editing ? tx("Edit_Off") : tx("Edit_On"), variant: editing ? "filled" : "ghost", onClick: () => { editing = !editing; draw(); }, }), ); return; } bar.replaceChildren( el("span", { className: "m01c__muted", text: tx("Edit_OwnOnly") }), createButton({ label: tx("Edit_Copy"), variant: "ghost", onClick: () => (ctx.onCopy ? ctx.onCopy() : showToast(tx("Edit_CopySoon"), "info")), }), ); }; const draw = (): void => { fillBar(); host.replaceChildren( el("div", { className: "m01c", children: [ el("div", { className: "m01c__head", children: [ el("h3", { text: `${row.원문번호} ${row.이름}` }), el("span", { className: "m01c__muted", text: `${row.결과단위} · ${row.출처}` }), bar, ], }), ...(one.reasons.length ? [el("p", { className: "m01c__bad", text: one.reasons.join(" · ") })] : []), el("ul", { className: "m01c__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: "m01c__flow", children: [inputColumn(), rest] }), ], }), ); redraw(); }; /** * 설계 값 칸 — `draw()` 에서만 다시 세움(계산 때마다 다시 그리는 오른쪽과 달리 * 적던 값과 글쇠 자리가 안 날아감). 고치기를 켜면 칸 이름·단위·고르기도 여기서 고침. */ const inputColumn = (): HTMLElement => { const fields = (row.입력 ?? []).map((spec, i) => { 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; later(); }, }).root; } else { const box = el("input", { className: "m01c__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; later(); }); control = box; } return el("label", { className: "m01c__field", children: [ el("span", { className: "m01c__label", text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, }), control, ...(editing ? editView(`입력:${i}`) : []), ], }); }); const add = addButton("입력"); return el("section", { className: "m01c__col m01c__col--input", children: [ el("h4", { className: "m01c__col-head", text: title("입력") }), ...(fields.length ? fields : [el("p", { className: "m01c__muted", text: tx("Flow_NoInputs") })]), ...(add ? [add] : []), ], }); }; draw(); run(); }