/* ============================================================================= * 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 나눔 …) + 금액 「—」 * 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 { 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 } from "@ui/ui_template_input"; import { DocError, rename, varId, 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 { askJob, copyBadges, copyTools, type CopyJob, type CopyMark, type CopyOptions, type JobReply, } from "./formula_doc_copy"; import { buildDefs, type Pick } from "./formula_doc_defs"; import { editLine, editLit, editMoney, ensureVar, splitLine, tidyText } from "./formula_doc_edit"; import { buildLines, type EditAt } from "./formula_doc_lines"; import { buildOptions } from "./formula_doc_options"; import { reach } from "./formula_doc_render"; 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 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 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; waiting = true; void call<{ 셈: DocAnswer; 연결: DocRecord; 부름?: Docs }>( "/preview", cp ? { 문서: doc, 끊음: cut } : { 문서: doc, 입력: usable() }, ctl.signal, ) .then((got) => { if (mine !== seq) return; cache.merge(got.연결); calls = { ...calls, ...(got.부름 ?? {}) }; cache.merge({ 문서: cut }); 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(); }; // ── 고침(`formula_doc_edit.ts`) ── const onMoney = (i: number, patch: Partial | null): void => change((d) => { editMoney(d, i, patch); if (patch && "이름" in patch && !d.금액?.[i]?.단가) auto = { kind: "price", i }; }); 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 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")); if (cp) bits.push(...copyBadges(mark, opt.admin)); const tools = opt.admin ? [ ...(cp ? copyTools(true, (job) => void runJob(job)) : []), 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: (i, t) => change((d) => editLine(d, i, t)), onExpr: (i, t) => change((d) => (d.식![i].식 = tidyText(t))), onMoney, onLit: (w, i, at, v) => change((d) => editLit(d, w, i, at, v)), 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)); 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; 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, }); grid.replaceChildren( el("div", { className: "fdoc__colhead", text: ft("Sym_Head") }), el("div", { className: "fdoc__colhead", text: ft("Val_Head") }), ...lines, 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.문서); 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 = body(), done = ft("Saved")): Promise => { if (!doc) return; 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); fresh = false; 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 => { if (!doc || !dirty()) return; back(); 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(); [cut, calls, mark, savedCut, version] = [{}, {}, undefined, "{}", ""]; draw(); if (!next) { const why = cp ? ft("No_Key") : ft("Need_Save"); head.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: why })); return; } const mine = ++seq; try { const got = await call(cp ? "" : `?key=${encodeURIComponent(next)}`); if (mine !== seq || key !== next) return; take(got); fresh = !!got.새; const draft = drafts()[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 => { 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() }), }; }