/* ============================================================================= * formula_doc_screen.ts * 단가산출 기초 수식 문서 화면(PLAN 802-3 · 요구 660~665 · 요구서_664 3 장) — 공용(M01 · 뒤에 M02 = 길 앞머리만 바꿈) * 위 = 옵션 가로(선택 · 입력 기호 · 바꾸면 바로 다시 그림 · 문서는 안 바뀜) · 결과/계 · [수식 저장] · [고침 버림](관리자) * 가운데 = 격자 두 칸 — 왼쪽 기호 식 · 오른쪽 값 식(`formula_doc_lines.ts`) · 오른쪽 아래 기호 정의(`formula_doc_defs.ts`) * 빈 문서에서 시작 — [+ 식 줄] 에 `Q = 3600 × q × K × f × E / Cm` 침 → 새 기호마다 「정의 필요」 → 연결 고름 * 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 연결 값은 서버 기록 캐시(`common_util_formula_doc_cache.ts`) * · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음) → 캐시에 더해 다시 셈 * 고침 = 캐시(sessionStorage `opt.cache`)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈 · 파일에 씀) * 무엇을 지워도 오류 없음 — 표지(정의 필요 · 순환 · 0 나눔 …) + 금액 「—」 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; import { eqText } from "@ui/ui_template_equation"; import { editNumber } from "@ui/ui_template_equation_edit"; import { el, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { DocError, goodId, rename, splitId, tidy, varId, type DocVar, type FormulaDoc, type Link, type MoneyLine, } from "../common_util/common_util_formula_doc"; import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache"; import type { DocTable } from "../common_util/common_util_formula_doc_table"; import { buildDefs, type Pick } from "./formula_doc_defs"; import { buildLines, type EditAt } from "./formula_doc_lines"; import { evalNode, reach, swapLit, tryParse } from "./formula_doc_render"; import { ft } from "./formula_doc_text"; import "./formula_doc.css"; export interface DocScreenOptions { /** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links · GET /table */ base: string; admin: boolean; /** sessionStorage 열쇠 */ cache: string; } interface Loaded { 문서: FormulaDoc; 셈: DocAnswer; 연결: DocRecord; 새?: boolean; } const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; /** 머리 = 식 — 첫 「=」(비교 ==, <=, >=, != 빼고) */ export function splitLine(text: string): [string | null, string] { const m = /^([^=<>!]*?)\s*(?!=])=(?!=)\s*([\s\S]*)$/.exec(text); if (!m) return [null, text.trim()]; return [m[1].trim(), m[2].trim()]; } export function createDocScreen(opt: DocScreenOptions) { const head = el("div", { className: "fdoc__head" }); const marks = el("div", { className: "fdoc__marks" }); const grid = el("div", { className: "fdoc__grid" }); const root = el("div", { className: "fdoc ui-eq", attrs: { "data-formula-doc": "" }, children: [head, marks, grid], }); let key = ""; let saved = ""; let fresh = false; let doc: FormulaDoc | null = null; let answer: DocAnswer | null = null; let cache = createDocCache(); let given: Record = {}; let openDef: string | null = null; let auto: EditAt = null; let showOff = false; // 선택으로 꺼진 금액 줄(고침 모드) let seq = 0; let asked = ""; let ctl: AbortController | null = null; let waiting = false; const tables = new Map>(); const call = async (path: string, body?: unknown, signal?: AbortSignal): Promise => { const r = await fetch(`${API_BASE_URL}${opt.base}${path}`, { method: body === undefined ? "GET" : "POST", credentials: "include", headers: { "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal, }); const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown }; if (!r.ok) throw new Error(String(data.detail ?? r.statusText)); return data; }; // ── 캐시(sessionStorage) ── const drafts = (): Record => { try { return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record; } catch { return {}; } }; const dirty = (): boolean => !!doc && JSON.stringify(doc) !== saved; const keep = (): void => { const all = drafts(); if (dirty()) all[key] = doc!; else delete all[key]; try { sessionStorage.setItem(opt.cache, JSON.stringify(all)); } catch { /* 캐시가 막혀도 화면 안 고침은 남음 */ } }; // ── 셈 ── const usable = (): Record => { const out: Record = {}; for (const v of doc?.변수 ?? []) { const k = v.열쇠; if (!k || !(k in given)) continue; const kind = v.연결?.갈래; if (kind === "입력") out[k] = given[k]; if (kind === "선택" && (v.연결?.목록 ?? []).some((x) => String(x) === String(given[k]))) out[k] = (v.연결!.목록 ?? []).find((x) => String(x) === String(given[k])); } return out; }; const recalc = (): void => { if (!doc) return; answer = calcDoc(doc, cache.links, usable()); if (cache.takeMissing()) ask(); }; /** 캐시에 없는 연결 — 뒤에서 서버 기록을 받아 더함(같은 문서 · 입력은 한 번만) */ const ask = (): void => { const snap = JSON.stringify([doc, usable()]); if (snap === asked) return; asked = snap; ctl?.abort(); ctl = new AbortController(); const mine = ++seq; waiting = true; void call<{ 셈: DocAnswer; 연결: DocRecord }>( "/preview", { 문서: doc, 입력: usable() }, ctl.signal, ) .then((got) => { if (mine !== seq) return; cache.merge(got.연결); waiting = false; recalc(); draw(); }) .catch(() => { if (mine === seq) waiting = false; }); }; const change = (fn: (d: FormulaDoc) => void): void => { if (!doc) return; fn(doc); keep(); recalc(); draw(); }; // ── 변수 ── const ensure = (d: FormulaDoc, id: string): DocVar => { const have = (d.변수 ??= []).find((v) => varId(v) === id); if (have) return have; const [기호, 아래, 위] = splitId(id); const v: DocVar = { 열쇠: id, 기호, 아래, 위, 이름: "", 단위: "", 연결: { 갈래: "식" } }; d.변수.push(v); return v; }; const tidyText = (t: string): string => (t.trim() ? tidy(t.trim()) : ""); const onLine = (i: number, text: string): void => change((d) => { const lines = (d.식 ??= []); const line = lines[i]; if (!line) return; if (!text.trim()) { lines.splice(i, 1); return; } const [h, body] = splitLine(text); const old = String(line.기호 ?? ""); if (h !== null && h !== old) { line.기호 = h; const was = (d.변수 ?? []).find((v) => varId(v) === old && (v.연결?.갈래 ?? "식") === "식"); if (was && goodId(h) && !(d.변수 ?? []).some((v) => varId(v) === h)) { const [기호, 아래, 위] = splitId(h); Object.assign(was, { 기호, 아래, 위 }); } } line.식 = tidyText(body); }); const onMoney = (i: number, patch: Partial | null): void => change((d) => { const lines = (d.금액 ??= []); if (!patch) { lines.splice(i, 1); return; } const line = lines[i]; for (const [k, v] of Object.entries(patch)) { const text = typeof v === "string" && (k === "단가" || k === "수량") ? tidyText(v) : v; if (k === "수량" && text === "") delete line.수량; else (line as Record)[k] = text; } if ("이름" in patch && !line.단가) auto = { kind: "price", i }; }); const onLit = (target: "line" | "price" | "qty", i: number, at: number, value: number): void => change((d) => { if (target === "line") d.식![i].식 = swapLit(String(d.식![i].식 ?? ""), at, value); else { const line = d.금액![i]; const slot = target === "price" ? "단가" : "수량"; line[slot] = swapLit(String(line[slot] ?? ""), at, value); } }); const onPickVar = (id: string, num?: HTMLElement): void => { const v = doc?.변수?.find((x) => varId(x) === id); if (opt.admin && num && v?.연결?.갈래 === "숫자") { editNumber(num, (t) => { const n = Number(t.replace(/,/g, "")); change((d) => (ensure(d, id).연결 = { ...v.연결, 값: Number.isFinite(n) ? n : t })); }); return; } openDef = id; draw(); grid.querySelector(`[data-def="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" }); }; const tableOf = (k: string): Promise => { if (!tables.has(k)) tables.set( k, call(`/table?key=${encodeURIComponent(k)}`) .then((t) => { if (t.끊김) return null; cache.putTable(k, t); return t; }) .catch(() => null), ); return tables.get(k)!; }; const subDoc = (k: string): FormulaDoc | null => { try { return cache.links.doc(k) ?? null; } catch { cache.takeMissing(); return null; } }; /** 단가산출 연결 펼침 — 그 문서의 식 · 값 식(읽기만) */ const subBlock = (id: string): HTMLElement | null => { const link = doc?.변수?.find((v) => varId(v) === id)?.연결; if (link?.갈래 !== "단가산출" || !link.키 || link.키.includes("{")) return null; const sub = subDoc(link.키); if (!sub) return null; const inputs: Record = {}; for (const [k, t] of Object.entries(link.입력 ?? {})) { const n = tryParse(t); const v = n ? evalNode(n, answer?.값 ?? {}) : undefined; if (v !== undefined) inputs[k] = v; } const got = calcDoc(sub, cache.links, inputs); cache.takeMissing(); const box = el("div", { className: "fdoc__grid fdoc__sub" }); box.append( ...buildLines({ admin: false, doc: sub, answer: got, vars: new Map((sub.변수 ?? []).map((v) => [varId(v), v])), marks: new Map(), auto: null, onLine: () => undefined, onExpr: () => undefined, onMoney: () => undefined, onLit: () => undefined, onPickVar: () => undefined, onAddLine: () => undefined, onAddMoney: () => undefined, onGroupName: () => undefined, onRedraw: () => undefined, }), ); if (sub.결과) { const r = got.결과; box.append( el("div", { className: "fdoc__cell fdoc__total", text: ft("Result") }), el("div", { className: "fdoc__cell fdoc__total", text: typeof r === "number" ? `${eqText(r)} ${sub.결과단위 ?? ""}` : "—", }), ); } return box; }; // ── 그림 ── const optionBar = (): HTMLElement[] => { const out: HTMLElement[] = []; for (const v of doc?.변수 ?? []) { const kind = v.연결?.갈래; const k = v.열쇠; if (!k || (kind !== "선택" && kind !== "입력")) continue; const label = v.이름 || varId(v); const now = k in given ? given[k] : v.연결?.값; const set = (x: unknown): void => { given[k] = x; recalc(); draw(); }; if (kind === "선택") { const list = (v.연결?.목록 ?? []).map(String); const f = createSelectField({ label, options: list.map((x) => ({ value: x, text: x })), value: String(now ?? ""), compact: true, size: 28, onChange: (x) => set(x), }); f.root.dataset.option = k; out.push(f.root); } else { const f = createInputField({ label, value: now === undefined || now === null ? "" : String(now), compact: true, size: 28, unit: v.단위 || undefined, }); f.input.addEventListener("change", () => { const t = f.input.value.trim(); const n = Number(t.replace(/,/g, "")); set(t !== "" && Number.isFinite(n) ? n : t); }); f.root.dataset.option = k; out.push(f.root); } } return out; }; const headLine = (): HTMLElement => { const a = answer; const bits: (HTMLElement | string)[] = []; if (doc?.결과) { const r = a?.결과; bits.push( el("strong", { text: ft("Result") }), el("span", { className: "fdoc__headval", attrs: { "data-total": "" }, text: typeof r === "number" ? `${eqText(r)} ${doc.결과단위 ?? ""}` : "—", }), ); } else { const total = a?.계; bits.push( el("strong", { text: a?.머리 === "합계" ? ft("Total") : ft("Sum") }), el("span", { className: "fdoc__headval", attrs: { "data-total": "" }, text: typeof total === "number" ? `${eqText(total)} ${doc?.결과단위 ?? ""}` : "—", }), ); } if (opt.admin && doc && !(doc.금액 ?? []).length) { const f = createInputField({ placeholder: ft("Result_Sym"), value: doc.결과 ?? "", compact: true, size: 24, }); f.input.addEventListener("change", () => change((d) => { const t = f.input.value.trim(); if (t) d.결과 = t; else delete d.결과; }), ); f.root.classList.add("fdoc__resultsym"); bits.push(f.root); } if (waiting) bits.push(el("span", { className: "ui-text ui-text--muted", text: ft("Loading") })); if (fresh && !dirty()) bits.push(el("span", { className: "ui-text ui-text--muted", text: ft("Fresh") })); if (dirty()) bits.push(createBadge(ft("Dirty"), "warning")); const tools = opt.admin ? [ createButton({ label: ft("Discard"), variant: "ghost", size: 28, disabled: !dirty(), onClick: () => void discard(), }), createButton({ label: ft("Save"), variant: "filled", size: 28, disabled: !dirty(), onClick: () => void save(), }), ] : []; return el("div", { className: "fdoc__sum", children: [ el("div", { className: "fdoc__sumleft", children: bits }), el("div", { className: "fdoc__tools", children: tools }), ], }); }; const markBox = (): void => { const list = answer?.표지 ?? []; marks.replaceChildren(); if (!list.length) return; const count = new Map(); for (const m of list) count.set(m.갈래, (count.get(m.갈래) ?? 0) + 1); marks.append( ...[...count].map(([k, n]) => createBadge(`${k} ${n}`, k === "빈 문서" ? "neutral" : "warning"), ), ...list .slice(0, 4) .map((m) => el("span", { className: "ui-text ui-text--muted fdoc__marktext", text: m.글 })), ); }; const draw = (): void => { if (!doc || !answer) { head.replaceChildren(); marks.replaceChildren(); grid.replaceChildren(); return; } const vals = answer.값 ?? {}; const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v])); const lineSet = new Set((doc.식 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean)); const shown = reach(doc, vals, showOff); const markMap = new Map(); for (const m of answer.표지 ?? []) { const at = m.기호 || (m as { 자리?: string }).자리; if (at && !markMap.has(at)) markMap.set(at, m.글); } const options = optionBar(); head.replaceChildren( ...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []), headLine(), ...(opt.admin ? [el("p", { className: "ui-text ui-text--muted fdoc__hint", text: ft("Hint") })] : []), ); markBox(); const lines = buildLines({ admin: opt.admin, showOff, onShowOff: () => { showOff = !showOff; draw(); }, doc, answer, vars, marks: markMap, auto, onLine, onExpr: (i, t) => change((d) => (d.식![i].식 = tidyText(t))), onMoney, onLit, onPickVar, onAddLine: () => { auto = { kind: "line", i: (doc!.식 ?? []).length }; change((d) => (d.식 ??= []).push({ 기호: "", 식: "" })); }, onAddMoney: (g) => { auto = { kind: "name", i: (doc!.금액 ?? []).length }; change((d) => (d.금액 ??= []).push({ 이름: "", 비목: "노무비", 단가: "", ...(g ? { 묶음: g } : {}) }), ); }, onGroupName: (old, now) => change((d) => (d.금액 ?? []).forEach((l) => { if ((l.묶음 ?? "") !== old) return; if (now) l.묶음 = now; else delete l.묶음; }), ), onRedraw: () => redraw(), }); auto = null; const defs = buildDefs({ admin: opt.admin, vars, names: cache.names, lines: lineSet, shown, unused: [...vars.keys()].filter((id) => !shown.includes(id)), need: new Set( shown.filter((id) => !lineSet.has(id) && (vars.get(id)?.연결?.갈래 ?? "식") === "식"), ), open: openDef, onOpen: (id) => { openDef = id; draw(); }, setLink: (id, link: Link) => change((d) => { const clean = Object.fromEntries(Object.entries(link).filter(([, x]) => x !== undefined)); ensure(d, id).연결 = clean as Link; }), setVar: (id, patch) => change((d) => Object.assign(ensure(d, id), patch)), rename: (id, sign, low, up) => { const copy = clone(doc!); try { rename(copy, id, sign, low, up); } catch (e) { if (e instanceof DocError) return ft("Bad_Sign"); throw e; } openDef = varId({ 기호: sign, 아래: low, 위: up }); change((d) => Object.assign(d, copy)); return null; }, dropVar: (id) => { openDef = null; change((d) => { d.변수 = (d.변수 ?? []).filter((v) => varId(v) !== id); d.식 = (d.식 ?? []).filter((l) => l.기호 !== id); }); }, makeLine: (id) => { if (lineSet.has(id)) return; auto = { kind: "line", i: (doc!.식 ?? []).length }; change((d) => (d.식 ??= []).push({ 기호: id, 식: "" })); }, ask: (kind, words, group) => call<{ 후보: Pick[]; 더있음: boolean }>("/links", { 갈래: kind, 검색: words, 분류: group }), table: tableOf, subInputs: (k) => (subDoc(k)?.변수 ?? []) .filter((v) => ["선택", "입력", "숫자"].includes(v.연결?.갈래 ?? "") && v.열쇠) .map((v) => v.열쇠!), sub: subBlock, }); grid.replaceChildren( el("div", { className: "fdoc__colhead", text: ft("Sym_Head") }), el("div", { className: "fdoc__colhead", text: ft("Val_Head") }), ...lines, el("div"), defs, ); }; /** 고침 취소 — 빈 새 줄은 걷고 다시 그림 */ const redraw = (): void => { if (!doc) return; const before = JSON.stringify(doc); doc.식 = (doc.식 ?? []).filter((l) => String(l.기호 ?? "") || String(l.식 ?? "")); doc.금액 = (doc.금액 ?? []).filter((l) => l.이름 || l.단가 || l.수량); if (JSON.stringify(doc) !== before) { keep(); recalc(); } draw(); }; const take = (got: Loaded): void => { cache.merge(got.연결); saved = JSON.stringify(got.문서); doc = clone(got.문서); }; const save = async (): Promise => { if (!doc) return; showLoadingOverlay(); try { const got = await call("/save", { 문서: doc }); take(got); fresh = false; keep(); recalc(); draw(); showToast(ft("Saved"), "success"); } catch (e) { showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error"); } finally { hideLoadingOverlay(); } }; const discard = async (): Promise => { if (!doc || !dirty()) return; doc = JSON.parse(saved) as FormulaDoc; openDef = null; keep(); recalc(); draw(); }; /** 문서 열기 — 같은 키면 그대로(다시 그림만) */ const open = async (next: string): Promise => { if (next === key && doc) return; key = next; doc = answer = null; given = {}; showOff = false; openDef = null; asked = ""; cache = createDocCache(); tables.clear(); draw(); if (!next) { head.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: ft("Need_Save") })); return; } const mine = ++seq; try { const got = await call(`?key=${encodeURIComponent(next)}`); if (mine !== seq || key !== next) return; take(got); fresh = !!got.새; const draft = drafts()[next]; if (draft) doc = clone(draft); recalc(); draw(); } catch (e) { if (mine === seq) head.replaceChildren( el("p", { className: "ui-text ui-text--muted", text: `${ft("Load_Failed")} — ${e instanceof Error ? e.message : ""}`, }), ); } }; const canLeave = async (): Promise => { if (!dirty()) return true; if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false; doc = JSON.parse(saved) as FormulaDoc; keep(); return true; }; return { root, /** 로직 키로 그림(빈 키 = 비움) — 다른 인자는 옛 수식 탭 꼴 호환 */ draw: (next: string): void => void open(next), canLeave, /** 시험 · 확인용 — 지금 문서 · 셈 */ state: () => ({ doc, answer, dirty: dirty() }), }; }