/* ============================================================================= * M01_MasterData_UI_LogicLab_Equation.ts * M01 단가산출 로직 상세 「수식」 탭(PLAN 796-2 · 요구 655 · 656 · 657) — PPT 수식 꼴(결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이). * 그림 = 공통 수식 부품(`ui_template_equation*.ts`) · 서버 나무 = `POST /api/m01/logic/equation`(796-1) * 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만) * 배치(800-2) = 왼쪽 수식 영역(줄 구분 없이 식이 이어짐 + 소계 표) · 오른쪽 변수 정의 영역(고른 옵션 갈래의 변수만 · 값 · 단위 · DB 열쇠 없음) * 고침(800-3) = 수식 영역의 식 칸을 눌러 그 자리에서(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭) * · 새 기호를 쓰면 정의 영역에 그 기호 줄이 생겨 연결 지정(`_EquationDefs` · 고침 `연결`) · 이름 · 기호 · 첨자 고침 · 지움도 정의 줄에서 * · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인) * 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음) * 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게) * 페이지 갈래(`page` · 796-2b M02 단가산출 사본) = 같은 부품 · 길 · 고침 목록(문서 `수식고침`) · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createBanner } from "@ui/ui_template_banner"; import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; import { createEqChain } from "@ui/ui_template_equation_line"; import { createEqDefs, defSign, flashEqDef } from "@ui/ui_template_equation_defs"; import { createEqSum } from "@ui/ui_template_equation_sum"; import { eqText, type EqLine, type EqSumHooks, type EqNode, type EqTree, } from "@ui/ui_template_equation"; import { lineId } from "@ui/ui_template_equation_line"; import { el, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { createToolbar } from "@ui/ui_template_toolbar"; import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell"; import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values"; import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { ApiError, type LogicRow } from "./M01_MasterData_UI_Logic_Api"; import { draftRow, renameEditor, type Draft, type DefsHost, } from "./M01_MasterData_UI_LogicLab_EquationDefs"; import { openAddDialog } from "./M01_MasterData_UI_LogicLab_EquationVar"; import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise"; import { formulaEdit, inputsOf, unknownOf, type EqEdit } from "./logic/M01_Logic_Equation_Model"; import "./M01_MasterData_UI_LogicLab_Equation.css"; const TEXT = { Title: ["수식", "Equation"], Save: ["수식 저장", "Save equation"], Discard: ["수식 고침 버림", "Discard edits"], Add: ["변수 더함", "Add variable"], Edits: ["수식 고침 {n} 건 · 저장 안 함", "{n} equation edits · unsaved"], EditsPage: ["수식 고침 {n} 건 · 사본 위", "{n} equation edits · on copy"], DiscardPage: ["수식 원래로", "Back to copy"], Now: ["지금", "Now"], Before: ["고치기 전", "Before"], Blocked: [ "시트 · 시험 쪽에 저장 안 한 고침이 있음 — 먼저 [저장] 또는 [버림] 한 뒤 수식을 고침(수식은 저장본 기준)", "Unsaved edits elsewhere — save or discard first (showing saved version)", ], Options: ["옵션", "Options"], Wait: ["수식을 불러오는 중", "Loading equation"], Hint: [ "식을 눌러 바로 고침 · 키보드 + − × ÷ / ( )", "Click a formula to edit · keyboard + − × ÷ / ( )", ], Apply: ["연결 적용 · 식 넣기", "Apply links and formula"], Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"], Leave_Ok: ["버림", "Discard"], Saved: ["수식을 저장함", "Equation saved"], Total: ["계", "Total"], } as const satisfies Record; const t = (k: keyof typeof TEXT, n = 0): string => (TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", String(n)); interface Read { ok?: boolean; reason?: string; 수식?: EqTree; 되나?: boolean; 까닭?: string[]; 줄?: LogicRow; 전?: Record; 후?: Record; } async function post(path: string, body: unknown, signal?: AbortSignal): Promise { const r = await fetch(`${API_BASE_URL}/m01${path}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal, }); const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown }; if (!r.ok) throw new ApiError(r.status, data.detail ?? r.statusText); return data; } const CACHE = "m01_equation_edits"; const BIG = 12; // 고르기가 이보다 많으면 찾기형(입력 + 목록) const loadCache = (): Record => { try { return JSON.parse(sessionStorage.getItem(CACHE) ?? "{}") as Record; } catch { return {}; } }; /** 페이지 캐시 갈래(796-2b M02 단가산출 사본) — 길 · 고침 목록 · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] */ export interface EqPage { ask: ( suffix: "" | "/preview" | "/links", body: Record, signal?: AbortSignal, ) => Promise; edits: () => EqEdit[]; keep: (edits: EqEdit[]) => void; values: () => Record; setValue: (name: string, value: string | null) => void; /** 소계 표 덧단추 자리(796-2e M02 더한 기초 [연결 끊기]) */ sum?: EqSumHooks; } export interface LabEquationOptions { /** 시스템 관리자 — 고침 도구 */ admin: boolean; /** 시트 · 시험 쪽에 저장 안 한 고침이 있나 */ blocked: () => boolean; /** 저장 뒤 — 상세를 저장본으로 다시 엶 */ onSaved: () => void; /** 없으면 M01(길 `/m01/logic/equation` · 캐시 sessionStorage · [수식 저장]) */ page?: EqPage; } /** 식에 처음 쓴 새 기호 — 연결을 고르면 고침 `연결` + 그 식 고침을 한 벌로 보냄 */ interface PendingLink { line: EqLine; text: string; symbols: string[]; rows: Map; apply: HTMLElement; } export function createLabEquation(opt: LabEquationOptions) { const bar = el("div", { className: "m01-eq__bar" }); const optionsBar = el("div", { className: "m01-eq__options", attrs: { "data-eq-options": "" } }); const notice = el("div", { className: "m01-eq__notice" }); const body = el("div", { className: "m01-eq__body" }); const root = el("div", { className: "m01-eq", attrs: { "data-lab-equation": "" }, children: [bar, optionsBar, notice, body], }); let key = ""; let version = ""; let row: LogicRow | null = null; let rowNow: LogicRow | null = null; let eq: EqTree | null = null; let edits: EqEdit[] = []; let inputs: Record = {}; let before: Record | null = null; let why = ""; let editingLine: string | null = null; let defEditing: string | null = null; let pending: PendingLink | null = null; let defsBox: HTMLElement = el("div"); let ctl: AbortController | null = null; let timer = 0; let paintedBlocked = false; const open = new Set(); const drafts = new Map(); const canEdit = (): boolean => opt.admin && !opt.blocked(); const page = opt.page; const vals = (): Record => page?.values() ?? inputs; const ask = ( suffix: "" | "/preview" | "/links", body: Record, signal?: AbortSignal, ) => page ? page.ask(suffix, body, signal) : post(`/logic/equation${suffix}`, body, signal); const body0 = (): Record => ({ key, inputs: inputsOf(vals()), 고침: edits }); const persist = (): void => { if (page) return page.keep(edits); const all = loadCache(); if (edits.length) all[key] = edits; else delete all[key]; try { sessionStorage.setItem(CACHE, JSON.stringify(all)); } catch { /* 캐시가 막혀도 화면 안 고침은 남음 */ } }; /** 서버 셈 — 고침 목록이 있으면 미리 셈 · 없으면 읽기 · 옛 요청 끊음 */ async function refresh(): Promise { ctl?.abort(); const mine = (ctl = new AbortController()); try { const got = await ask(edits.length ? "/preview" : "", body0(), mine.signal); if (mine !== ctl) return; if (got.되나 === false || got.ok === false) { why = (got.까닭 ?? [got.reason ?? ""]).join(" · "); } else { why = ""; eq = got.수식 ?? null; rowNow = got.줄 ?? null; before = edits.length ? (got.전 ?? null) : null; } } catch (error) { if (mine.signal.aborted) return; why = error instanceof Error ? error.message : String(error); } paint(); } /** 고침 하나를 더해 보고 되면 굳힘 — 안 되면 까닭 목록 */ async function commit(list: EqEdit[]): Promise { const got = await ask("/preview", { ...body0(), 고침: [...edits, ...list], }).catch((error: unknown): Read => ({ 되나: false, 까닭: [error instanceof Error ? error.message : String(error)], })); if (!got.되나) return got.까닭 ?? []; edits = [...edits, ...list]; persist(); ctl?.abort(); why = ""; eq = got.수식 ?? eq; rowNow = got.줄 ?? rowNow; before = got.전 ?? before; paint(); drawOptions(); return []; } const askAgain = (): void => { window.clearTimeout(timer); timer = window.setTimeout(() => void refresh(), 150); }; // ── 옵션 가로 ────────────────────────────────────────────────────── function optionNode(spec: NonNullable[number], auto: unknown): HTMLElement { const item: ItemInput = { 이름: spec.이름, 단위: spec.단위, 고르기: spec.고르기, 범위: spec.범위, 자동값: auto, }; const label = createText(spec.이름 + (spec.단위 ? ` (${spec.단위})` : ""), "muted", 12); const set = (v: string | number | null): void => { if (page) return page.setValue(spec.이름, v === null || v === "" ? null : String(v)); if (v === null || v === "") delete inputs[spec.이름]; else inputs[spec.이름] = String(v); askAgain(); }; let control: HTMLElement; if ((spec.고르기?.length ?? 0) > BIG) { // 찾기형 — 입력 + 목록(칠수록 좁혀짐) const box = createInputField({ value: vals()[spec.이름] ?? "", compact: true, size: 24, placeholder: auto === null || auto === undefined ? "" : String(auto), }); const list = el("datalist", { attrs: { id: `m01-eq-list-${spec.이름}` }, children: (spec.고르기 ?? []).map((o) => el("option", { attrs: { value: String(o) } })), }); box.input.setAttribute("list", list.id); box.input.title = box.input.value || box.input.placeholder; box.input.addEventListener("change", () => { box.input.title = box.input.value; set(box.input.value.trim()); }); control = el("div", { children: [box.root, list] }); } else { const made = createValueControl(item, vals()[spec.이름] ?? null, (v) => set(v)); const tip = (): void => { const c = made.control; c.title = c instanceof HTMLSelectElement ? (c.selectedOptions[0]?.text ?? "") : c.value; }; made.control.addEventListener("change", tip); made.control.addEventListener("input", tip); tip(); control = el("div", { children: [made.control, made.error] }); } control.classList.add("m01-eq__ctl"); const cell = el("div", { className: "m01-eq__opt", children: [label, control] }); cell.dataset.eqOption = spec.이름; return cell; } function drawOptions(): void { const specs = (rowNow ?? row)?.입력 ?? []; const table = new Map((eq?.변수표 ?? []).map((v) => [v.열쇠, v])); optionsBar.replaceChildren( ...(specs.length ? [ createToolbar({ items: specs.map((s) => optionNode(s, table.get(s.이름)?.기본값 ?? null)), size: 24, label: t("Options"), }), ] : []), ); } // ── 바로 고침 · 연결 지정 ────────────────────────────────────────── const total = (sums: Record): string => ("계" in sums ? "계" : "결과"); const defsHost: DefsHost = { commit, links: (body) => ask("/links", body), onDropped: (k) => { if (page) page.setValue(k, null); else { delete inputs[k]; drawOptions(); } }, }; function buildDefs(): HTMLElement { const edit = canEdit(); const mids = (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠); return createEqDefs(eq?.정의 ?? [], { editing: defEditing, onEdit: edit ? (d) => { defEditing = defEditing === defSign(d) ? null : defSign(d); drawDefs(); } : undefined, editor: (d) => renameEditor(d, defsHost, () => { defEditing = null; drawDefs(); }), drafts: pending ? [...[...pending.rows.values()].map((d) => d.root), pending.apply] : [], head: edit ? [ createButton({ label: t("Add"), variant: "ghost", size: 24, iconStart: "plus", onClick: () => openAddDialog({ mids, commit }), }), ] : [], }); } function drawDefs(): void { const next = buildDefs(); defsBox.replaceWith(next); defsBox = next; } /** 식 검사가 「모르는 기호」 를 알려 오면 정의 영역에 그 기호 줄을 세움(같은 기호들이면 입력한 것을 지키며 글만 갱신) */ function setPending(line: EqLine, text: string, miss: string[]): void { if (!miss.length) { if (!pending) return; pending = null; return drawDefs(); } if (pending && pending.symbols.join("\u0000") === miss.join("\u0000")) { [pending.line, pending.text] = [line, text]; return; } const rows = new Map(miss.map((m) => [m, draftRow(m, defsHost)] as const)); const apply = el("div", { className: "m01-eq__def-actions", children: [createButton({ label: t("Apply"), size: 24, onClick: () => void applyPending() })], }); pending = { line, text, symbols: miss, rows, apply }; drawDefs(); defsBox.scrollTo({ top: 0 }); } async function applyPending(): Promise { const p = pending; if (!p) return; const list: EqEdit[] = []; let missing = false; for (const d of p.rows.values()) { const made = d.read(); d.say(typeof made === "string" ? made : ""); if (typeof made === "string") missing = true; else list.push(made); } if (missing) return; const bad = await commit([...list, formulaEdit(p.line, p.text)]); if (bad.length) return [...p.rows.values()].forEach((d) => d.say(bad.join(" · "))); drafts.delete(lineId(p.line)); [pending, editingLine] = [null, null]; paint(); } const check = (line: EqLine) => async (text: string, signal: AbortSignal): Promise => { const got = await ask( "/preview", { ...body0(), 고침: [...edits, formulaEdit(line, text)] }, signal, ); const miss = got.되나 ? [] : unknownOf(got.까닭 ?? []); setPending(line, text, miss); const now = got.후 ? eqText(got.후[total(got.후)]) : ""; return { 되나: !!got.되나, 까닭: miss.length ? [`모르는 기호 ${miss.join(" · ")} — 오른쪽 변수 정의에서 연결을 지정`] : (got.까닭 ?? []), 알림: got.되나 && now ? [`${t("Total")} ${now}`] : [], 식: got.되나 ? text : null, }; }; /** 수식 영역의 식 칸 자리에 들어가는 식 칸 — 바꾸면 미리 셈 · 되면 고침 목록에 굳힘 */ function editor(line: EqLine, id: string): HTMLElement { const shown = line.입력글 ?? line.식; const close = (): void => { [editingLine, pending] = [null, null]; paint(); }; const cell = createFormulaCell({ key: id, spot: id, shown, origin: shown, drafts, check: check(line), put: (text) => { if (text === null) return pending ? undefined : close(); void commit([formulaEdit(line, text)]).then((bad) => { if (bad.length) showToast(bad.join(" · "), "error"); else close(); }); }, className: "m01-eq__fx", }); const input = cell.querySelector("input"); // 안 바꾸고 다른 데를 누르면 닫음 · 바꾼 글이 있거나 연결 지정 중이면 그대로 cell.addEventListener("focusout", () => window.setTimeout(() => { if (editingLine === id && !pending && !cell.contains(document.activeElement)) { if ((input?.value.trim() ?? shown) === shown) close(); } }, 120), ); queueMicrotask(() => input?.focus()); return cell; } const showDef = (node: EqNode): void => flashEqDef(defsBox, node); // ── 그림 ────────────────────────────────────────────────────────── function drawBar(): void { const sums = eq?.합계 ?? {}; const mark = total(sums); const now = sums[mark]; const old = before?.[mark]; const diff = Number(now) - Number(old); const items: (HTMLElement | "divider" | "spacer")[] = [ createText(`${t("Now")} ${eqText(now) || "—"}`, "muted", 14), ]; if (before) { items.push(createText(`${t("Before")} ${eqText(old) || "—"}`, "muted", 14)); if (Number.isFinite(diff) && diff) items.push(createBadge(`${diff > 0 ? "+" : "−"}${eqText(Math.abs(diff))}`, "warning")); } if (canEdit()) { items.push("spacer"); if (edits.length) { items.push( createBadge(t(page ? "EditsPage" : "Edits", edits.length), "warning"), createButton({ label: t(page ? "DiscardPage" : "Discard"), variant: "ghost", size: 24, onClick: () => void discard(), }), ...(page ? [] : [createButton({ label: t("Save"), size: 24, onClick: () => void save() })]), ); } else items.push(createText(t("Hint"), "muted", 12)); } bar.replaceChildren(createToolbar({ items, size: 28, label: t("Title") })); } function paint(): void { paintedBlocked = opt.blocked(); drawBar(); notice.replaceChildren( ...(paintedBlocked && opt.admin ? [createBanner({ tone: "warning", text: t("Blocked") })] : []), ...(why ? [createBanner({ tone: "danger", text: why })] : []), ); if (!eq) { body.replaceChildren(createText(t("Wait"), "muted")); return; } const edit = canEdit(); const keys = eq.줄.filter((l) => l.갈래 === "호표").map((l) => l.열쇠); const sum = createEqSum(eq, open, page?.sum, keys); const left = el("div", { className: "m01-eq__left", children: [ createEqChain(eq.줄, { path: "", open, resultUnit: eq.결과단위, onVar: edit ? undefined : showDef, // 고칠 수 있으면 식 어디를 눌러도 그 자리 고침(변수 칩 포함) editing: editingLine, onEdit: edit ? (_line, id) => { editingLine = id; paint(); } : undefined, editor: edit ? editor : undefined, after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null), }), ...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계 ], }); defsBox = buildDefs(); body.replaceChildren(el("div", { className: "m01-eq__split", children: [left, defsBox] })); } async function save(): Promise { if (!canEdit() || !edits.length) return; showLoadingOverlay(); try { const done = await saveRevised((ok) => post("/logic/equation/save", { ...body0(), version, 개정확인: ok }), ); if (!done) return; // 개정 확인 취소 = 고침 그대로 [edits, editingLine] = [[], null]; persist(); showToast(t("Saved"), "success"); await refresh(); drawOptions(); opt.onSaved(); } catch (error) { const detail = error instanceof ApiError ? (error.detail as { 까닭?: string[] } | null) : null; showToast( detail?.까닭?.join(" · ") ?? (error instanceof Error ? error.message : String(error)), "error", ); } finally { hideLoadingOverlay(); } } async function discard(): Promise { edits = []; [editingLine, defEditing, pending] = [null, null, null]; drafts.clear(); persist(); await refresh(); drawOptions(); } return { root, /** 상세가 그릴 때마다 — 로직이 바뀔 때만 서버를 부름 */ draw: (nextKey: string, nextVersion: string, nextRow: LogicRow): void => { const moved = nextVersion !== version; // 판 · 페이지 문서 · 옵션 바뀜 = 다시 셈 version = nextVersion; row = nextRow; if (nextKey !== key) { [key, eq, rowNow, before, why, editingLine] = [nextKey, null, null, null, "", null]; [defEditing, pending] = [null, null]; inputs = {}; edits = page ? page.edits() : (loadCache()[nextKey] ?? []); open.clear(); drafts.clear(); paint(); drawOptions(); void refresh().then(drawOptions); } else if (moved) { if (page) edits = page.edits(); void refresh().then(drawOptions); } else if (opt.blocked() !== paintedBlocked) paint(); }, dirty: (): boolean => edits.length > 0, /** 나가기 전 — 저장 안 한 고침이 있으면 버림 물음 · 버리면 true */ canLeave: async (): Promise => { if (!edits.length) return true; if (!(await showConfirmDialog(t("Leave", edits.length), t("Leave_Ok")))) return false; await discard(); return true; }, }; }