/* ============================================================================= * formula_doc_screen.ts * 단가산출 기초 수식 문서 화면(PLAN 802-3 · 803-2a · 요구 660~667 · 요구서_664 3 장) — 공용(M01 · M02 = 길 앞머리만 바꿈) * 위 = 옵션 가로(선택 · 입력 기호 · 바꾸면 바로 다시 그림 · 문서는 안 바뀜) · [고침 버림] · [수식 저장](관리자) — 설명 글 없음 * 가운데 = 세 영역 가로 — 기호 식(하나의 수식 영역 · 줄바꿈) | 값 식(같은 높이) | 기호 정의(`formula_doc_defs.ts`) * 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 — 단가산출 상세 탭 부품 · `formula_doc_foot.ts`) * 기호 식 줄 누름 = 그 자리 편집기(`formula_doc_math.ts`) · 편집 중엔 영역 위 수식 도구 줄 · 빈 문서 = 세 영역 빈 화면 * 빈 문서에서 시작 — 영역을 눌러 `Q = 3600 × q × K × f × E / Cm` 침 → 새 기호마다 「정의 필요」 → 연결 고름 * 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 연결 값은 서버 기록 캐시(`common_util_formula_doc_cache.ts`) * · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음) → 캐시에 더해 다시 셈 * 고침 = 캐시(sessionStorage `opt.cache`)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈 · 파일에 씀) * 무엇을 지워도 오류 없음 — 표지(정의 필요 · 순환 · 0 나눔 …) + 금액 「—」 * M02 사본(`opt.copy` · 802-4b-2) — 길 앞머리 = SK 마다(`/m02/layers/{층}/sangeun/{키}/doc`) · 옵션 = 문서 값(SK 고른 값 · 저장) · * [단가 갱신] · [재생성] · 「M01 새 판 있음」(`formula_doc_copy.ts`) · 단가산출 연결 끊기 · 다시 잇기(`formula_doc_sub.ts`) · * 저장 몸 {문서, 끊음, 판} · `admin` = 층 쓰기 권한(없으면 읽기만 · 옵션도 잠금) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; import { editNumber } from "@ui/ui_template_equation_edit"; import { el, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { DocError, blankDoc, rename, varId, type FormulaDoc, type Link, } 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 { askJob, copyBadges, copyTools, type CopyJob, type CopyMark, type CopyOptions, type JobReply, } from "./formula_doc_copy"; import { buildDefs, type Pick } from "./formula_doc_defs"; import { editLit, ensureVar, splitLine, sweep } from "./formula_doc_edit"; import { docFoot } from "./formula_doc_foot"; import { buildLines, offLines } from "./formula_doc_lines"; import { mathToolbar } from "./formula_doc_math"; import { buildOptions } from "./formula_doc_options"; import { reach } from "./formula_doc_render"; import { createRows } from "./formula_doc_rows"; import { subBlock } from "./formula_doc_sub"; import { ft } from "./formula_doc_text"; export { splitLine }; 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; /** M02 사본(SK) — 길 앞머리 · 판 · 끊음 · 할일 */ copy?: CopyOptions; } type Docs = Record; interface Loaded { 문서: FormulaDoc; 셈: DocAnswer; 연결: DocRecord; 새?: boolean; 부름?: Docs; 끊음?: Docs; 사본?: CopyMark; 판?: string; } const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; export function createDocScreen(opt: DocScreenOptions) { const head = el("div", { className: "fdoc__head" }); const grid = el("div", { className: "fdoc__grid" }); const side = el("div", { className: "fdoc__side" }); const foot = el("div", { className: "fdoc__footslot" }); const root = el("div", { className: "fdoc ui-eq", attrs: { "data-formula-doc": "" }, children: [head, el("div", { className: "fdoc__areas", children: [grid, side] }), foot], }); let key = ""; let saved = ""; let doc: FormulaDoc | null = null; let answer: DocAnswer | null = null; let cache = createDocCache(); let given: Record = {}; let openDef: string | null = null; let showOff = false; // 선택으로 꺼진 금액 줄(고침 모드) let seq = 0; let asked = ""; let ctl: AbortController | null = null; const tables = new Map>(); const rows = createRows({ doc: () => doc, change: (fn) => change(fn), settle: () => { keep(); recalc(); }, draw: () => draw(), root, }); const cp = opt.copy; let cut: Docs = {}; // 사본 — 끊은 단가산출 문서(이 사본 안 · 고칠 수 있음) let savedCut = "{}"; let calls: Docs = {}; // 사본 — 부름 문서(다시 잇기 = 이 문서로) let mark: CopyMark | undefined; let version = ""; const call = async (path: string, body?: unknown, signal?: AbortSignal): Promise => { const r = await fetch(`${API_BASE_URL}${cp ? cp.route(key, path) : 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) { const why = data.detail; throw Object.assign(new Error(typeof why === "string" ? why : r.statusText), { status: r.status, }); } 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 || JSON.stringify(cut) !== savedCut); const cutKey = (): string => `${key}#끊음`; const keep = (): void => { const all = drafts(); if (dirty()) all[key] = doc!; else delete all[key]; if (cp && dirty()) all[cutKey()] = cut as unknown as FormulaDoc; else delete all[cutKey()]; 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; void call<{ 셈: DocAnswer; 연결: DocRecord; 부름?: Docs }>( "/preview", cp ? { 문서: doc, 끊음: cut } : { 문서: key ? doc : { ...doc, 키: "_새" }, 입력: usable() }, ctl.signal, ) .then((got) => { if (mine !== seq) return; cache.merge(got.연결); calls = { ...calls, ...(got.부름 ?? {}) }; cache.merge({ 문서: cut }); recalc(); draw(); }) .catch(() => undefined); }; const change = (fn: (d: FormulaDoc) => void): void => { if (!doc) return; fn(doc); keep(); recalc(); draw(); }; 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) => (ensureVar(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 changeCut = (k: string, fn: (d: FormulaDoc) => void): void => { if (!cut[k]) return; fn(cut[k]); cache.merge({ 문서: { [k]: cut[k] } }); keep(); recalc(); draw(); }; const onCut = (k: string): void => { const from = subDoc(k); if (!from) return; cut = { ...cut, [k]: clone(from) }; changeCut(k, () => undefined); }; const onJoin = async (k: string): Promise => { if (!(await showConfirmDialog(ft("Ask_Relink"), ft("Relink")))) return; const { [k]: _gone, ...rest } = cut; cut = rest; if (calls[k]) cache.merge({ 문서: { [k]: calls[k] } }); else asked = ""; // 부름 문서를 아직 모름 — 뒤에서 서버에 물음 keep(); recalc(); draw(); }; const sub = (id: string): HTMLElement | null => subBlock(id, { doc, answer, cache, ...(cp ? { copy: { edit: opt.admin, has: (k: string) => k in calls || k in cut, cut: (k: string) => cut[k], onCut, onJoin: (k: string) => void onJoin(k), change: changeCut, }, } : {}), }); // ── 그림 ── /** 옵션 가로 — M01 = 화면 값만(`given`) · 사본 = 문서 값(SK 고른 값 · 저장) */ const optionBar = (): HTMLElement[] => buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => { const at = doc?.변수?.indexOf(v) ?? -1; // 옵션 열쇠 ≠ 기호 — 그 정의 자리에 값 if (cp) return change((d) => at >= 0 && (d.변수![at].연결 = { ...v.연결!, 값: x })); given[k] = x; recalc(); draw(); }); /** 위 도구 줄 — 결과 기호(금액 줄 없을 때) · 고침 표 · 사본 표 · [꺼진 줄] · [고침 버림] · [수식 저장] */ const toolRow = (edit: boolean): HTMLElement => { const bits: HTMLElement[] = []; if (edit && 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 (dirty()) bits.push(createBadge(ft("Dirty"), "warning")); if (cp) bits.push(...copyBadges(mark, opt.admin)); const off = doc && answer ? offLines(doc, answer).size : 0; const busy = dirty() || !!rows.editing(); // 편집 중 누름 — 편집기 커서를 안 뺏음(저장 · 버림이 편집 중 글을 먼저 받음) const hold = (b: HTMLButtonElement): HTMLButtonElement => { b.addEventListener("mousedown", (ev) => ev.preventDefault()); return b; }; const tools = edit ? [ ...(off ? [ createButton({ label: `${showOff ? ft("Hide_Off") : ft("Show_Off")} ${off}`, variant: "ghost", size: 28, onClick: () => { showOff = !showOff; draw(); }, }), ] : []), ...(cp ? copyTools(true, (job) => void runJob(job)) : []), hold( createButton({ label: ft("Discard"), variant: "ghost", size: 28, disabled: !busy, onClick: () => void discard(), }), ), hold( createButton({ label: ft("Save"), variant: "filled", size: 28, disabled: !busy, 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 draw = (): void => { rows.beforeDraw(); if (!doc || !answer) { [head, grid, side, foot].forEach((x) => x.replaceChildren()); return; } // M01 저장 전 새 로직(키 없음)도 고침 — [수식 저장] · [고침 버림]은 키가 생긴 뒤(페이지 [저장] → 새 키로 이어 감) // M02 저장 전 새 SK = 자리 표시(저장하면 M01 사본) — 읽기만 const edit = opt.admin && (!!key || !cp); 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 })] : []), toolRow(edit && !!key), ); const { cells, order } = buildLines({ admin: edit, showOff, doc, answer, vars, marks: markMap, editing: rows.editing(), mount: rows.mount, onEditAt: rows.editAt, onLit: (w, i, at, v) => change((d) => editLit(d, w, i, at, v)), onPickVar, onGroupName: (old, now) => change((d) => (d.금액 ?? []).forEach((l) => { if ((l.묶음 ?? "") !== old) return; if (now) l.묶음 = now; else delete l.묶음; }), ), onRedraw: () => redraw(), }); rows.setOrder(order); const defs = buildDefs({ admin: edit, 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)); ensureVar(d, id).연결 = clean as Link; }), setVar: (id, patch) => change((d) => Object.assign(ensureVar(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; const i = (doc!.식 ?? []).length; change((d) => (d.식 ??= []).push({ 기호: id, 식: "" })); rows.editAt({ kind: "line", i }); }, 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, }); const symHead = el("div", { className: "fdoc__colhead fdoc__symhead", children: [ el("span", { text: ft("Sym_Head") }), ...(rows.editing() ? [mathToolbar(rows.editingMoney() ? rows.splitGroup : undefined)] : []), ], }); grid.replaceChildren( symHead, el("div", { className: "fdoc__colhead", text: ft("Val_Head") }), ...cells, ); side.replaceChildren(defs); foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)])); }; /** 고침 취소 — 빈 새 줄은 걷고 다시 그림 */ const redraw = (): void => { if (!doc) return; const before = JSON.stringify(doc); sweep(doc); if (JSON.stringify(doc) !== before) { keep(); recalc(); } draw(); }; const take = (got: Loaded): void => { cache.merge(got.연결); saved = JSON.stringify(got.문서); doc = clone(got.문서); if (!cp) return; calls = got.부름 ?? {}; cut = clone(got.끊음 ?? {}); savedCut = JSON.stringify(cut); mark = got.사본; version = got.판 ?? ""; }; const body = (): Record => cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc }; /** 사본 [단가 갱신] · [재생성] — 미리 셈 → 알림 → 저장(고친 것 먼저 저장 · 버림) */ const runJob = async (job: CopyJob): Promise => { if (!doc || !cp) return; if (dirty()) return void showToast(ft("Dirty_First"), "warning"); showLoadingOverlay(); let got: JobReply; try { got = await call("/preview", { 할일: job }); } catch (e) { hideLoadingOverlay(); return void showToast( `${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error", ); } hideLoadingOverlay(); const names = new Map(Object.entries({ ...calls, ...cut }).map(([k, d]) => [k, d.이름 ?? ""])); if (await askJob(job, got, names)) await save({ 할일: job, 판: version }, ft("Job_Done")); }; const save = async (send?: Record, done = ft("Saved")): Promise => { if (!doc) return; rows.flush(); if (!send && !dirty()) return void draw(); send ??= body(); showLoadingOverlay(); try { const got = await call("/save", send).catch(async (e: { status?: number }) => { // 사본 판 바뀜(페이지 [저장] 등) — 수식 사본이 그대로면 새 판으로 한 번 더 if (!cp || e.status !== 409) throw e; const now = await call(""); if ( JSON.stringify([now.문서, now.끊음 ?? {}]) !== JSON.stringify([JSON.parse(saved), JSON.parse(savedCut)]) ) throw e; return call("/save", { ...send, 판: (version = now.판 ?? "") }); }); take(got); keep(); recalc(); draw(); if (cp && got.판) cp.onSaved?.(got.판); showToast(done, "success"); } catch (e) { showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error"); } finally { hideLoadingOverlay(); } }; const back = (): void => { doc = JSON.parse(saved) as FormulaDoc; cut = JSON.parse(savedCut) as Docs; cache.merge({ 문서: { ...calls, ...cut } }); }; const discard = async (): Promise => { rows.reset(); if (!doc || !dirty()) return void draw(); back(); openDef = null; keep(); recalc(); draw(); }; /** 문서 열기 — 같은 키면 그대로(다시 그림만) */ const open = async (next: string): Promise => { if (next === key && doc) return; rows.reset(); const was = key; key = next; doc = answer = null; given = {}; showOff = false; openDef = null; asked = ""; cache = createDocCache(); tables.clear(); [cut, calls, mark, savedCut, version] = [{}, {}, undefined, "{}", ""]; draw(); if (!next) { // 저장 전 새 로직 — 세 영역 빈 화면(누르면 입력 · 고친 것은 캐시 "" 열쇠) doc = blankDoc(); saved = JSON.stringify(doc); keep(); // 옛 새 로직 고침(「""」 열쇠) 걷음 recalc(); return draw(); } const mine = ++seq; try { const got = await call(cp ? "" : `?key=${encodeURIComponent(next)}`); if (mine !== seq || key !== next) return; take(got); const all = drafts(); // 저장 전 새 로직에서 고친 문서 — 페이지 [저장]으로 생긴 새 키(빈 문서)로 이어 감 if (!was && got.새 && all[""] && !all[next]) { all[next] = { ...all[""], 키: next, 이름: got.문서.이름, 결과단위: got.문서.결과단위 }; delete all[""]; sessionStorage.setItem(opt.cache, JSON.stringify(all)); } const draft = all[next]; if (draft) doc = clone(draft); const kept = cp ? (drafts()[`${next}#끊음`] as unknown as Docs | undefined) : undefined; if (kept) cache.merge({ 문서: (cut = clone(kept)) }); 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 => { rows.flush(); if (!dirty()) return true; if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false; back(); keep(); return true; }; return { root, /** 로직 키로 그림(빈 키 = 비움) — 다른 인자는 옛 수식 탭 꼴 호환 */ draw: (next: string): void => void open(next), canLeave, /** 시험 · 확인용 — 지금 문서 · 셈 */ state: () => ({ doc, answer, dirty: dirty() }), }; }