/* ============================================================================= * line_stack_screen.ts * 단가산출 「줄 쌓기」 탭(PLAN 804-2 · 요구 670~677) — 공용(M01 단가산출 기초 · M02 단가산출 = 길 앞머리만 바꿈) * 위 = 옵션 가로(`formula_doc_options.ts`) · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지(층 쓰기) * `page` 있음(M01 804-7 · M02 804-8) = 저장 · 되돌림 단추 없음 — 페이지 [저장] · [되돌림]이 `save` · `discard` 를 부름 · * 고침이 바뀌면 `page()` · `page` 없음(일위대가 줄 모달 등) = 위에 [고침 버림] · [수식 저장] · * `tools` 있음(808-1) = 화면 단추를 페이지 머리 칸에 * 가운데 = 왼쪽 줄 쌓기 편집(`line_stack_edit.ts`) | 오른쪽 산출근거 출력(`line_stack_out.ts` · 기계 시간당 경비 펼침) * 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 · 화면 아래 고정 · 요구 687-3) * 정본 = 수식 문서(탭을 바꾸면 다시 읽음) · 줄 쌓기 차림 = 문서 칸 `쌓기` · 804-6 옛 수식 문서 탭 걷음 = 이 화면이 「수식」 탭 * 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음) * 고침 = 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음 * M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더 * [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; import { el, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { blankDoc, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc"; import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; import { importView, type Basis } from "../common_util/common_util_formula_doc_import"; import { createSegment } from "@ui/ui_template_segment"; import type { DocTable } from "../common_util/common_util_formula_doc_table"; import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache"; import { lineMap } from "../common_util/common_util_formula_doc_view"; import { docOf, stackOf, type StackDoc, type StackItem, } from "../common_util/common_util_line_stack"; import { askJob, copyBadges, copyTools, type CopyJob, type CopyMark, type CopyOptions, type JobReply, } from "./formula_doc_copy"; import { docFoot } from "./formula_doc_foot"; import { buildOptions } from "./formula_doc_options"; import { buildSourced } from "./line_stack_sourced"; import { ft } from "./formula_doc_text"; import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy"; import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { buildEdit } from "./line_stack_edit"; import { buildOut } from "./line_stack_out"; import { lt } from "./line_stack_text"; import "./line_stack.css"; export interface StackScreenOptions { /** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links */ base: string; admin: boolean; /** sessionStorage 열쇠 */ cache: string; copy?: CopyOptions; /** M02 SK 층(804-4) — 줄 쌓기 다른 단가산출 · 일위대가 복사 길 · 없으면(M01) 복사 없음 */ scope?: { layer: Layer; owner: string | null }; /** 페이지가 저장 · 되돌림을 맡음(M01 804-7 · M02 804-8) — 고침 있음이 바뀔 때 부름 · 화면 위 [고침 버림] · [수식 저장] 없음 */ page?: () => void; /** 페이지 머리 칸(808-1 · 요구 701-3) — 있으면 [단가 갱신] · [재생성] 등 화면 단추를 여기에(화면 위 단추 줄 대신) */ tools?: HTMLElement; } type Docs = Record; interface Loaded { 문서: StackDoc; 셈: DocAnswer; 연결: DocRecord; 새?: boolean; 끊음?: Docs; 사본?: CopyMark; 판?: string; } const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; export function createStackScreen(opt: StackScreenOptions) { const head = el("div", { className: "lstk__head" }); const left = el("div", { className: "lstk__left" }); const right = el("div", { className: "lstk__right" }); const foot = el("div", { className: "lstk__foot" }); const root = el("div", { className: "lstk", attrs: { "data-line-stack": "" }, children: [head, el("div", { className: "lstk__areas", children: [left, right] }), foot], }); // 산출근거 고정 높이(요구 683) — 스크롤 조상(없으면 창) 높이 · 넓을 때만 고정(line_stack.css) const fit = (): void => { let p = root.parentElement; while (p && !/(auto|scroll)/.test(getComputedStyle(p).overflowY)) p = p.parentElement; const view = (p?.clientHeight ?? innerHeight) - foot.offsetHeight; root.style.setProperty("--lstk-view", `${Math.max(240, view)}px`); }; window.addEventListener("resize", fit); const cp = opt.copy; let key = ""; let shown: string | null = null; // 연 키(같은 키 다시 그림 = 안 읽음 · 탭 바꿈 = force) let saved = ""; let doc: StackDoc | null = null; let answer: DocAnswer | null = null; let cache = createDocCache(); let given: Record = {}; let cut: Docs = {}; let mark: CopyMark | undefined; let version = ""; let seq = 0; let asked = ""; let ctl: AbortController | null = null; const tables = new Map>(); // 표 고르기(문서 열 때 비움) const opened = new Set(); // 산출근거 펼친 자리 let paired = ""; // 짝 강조 자리(679 — 항목 `k` · 줄 `k.j`) const blocks = new Set(); // 풀린 공식 묶음 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; }; // ── 캐시 ── 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 { /* 캐시가 막혀도 화면 안 고침은 남음 */ } opt.page?.(); }; // ── 셈 ── 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; const basis = mark?.단가기준; // 가져온 식(807-1) — 서버 셈과 같은 보기 answer = calcDoc(basis ? importView(doc, basis) : 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<{ 연결: DocRecord }>( "/preview", cp ? { 문서: doc, 끊음: cut } : { 문서: key ? doc : { ...doc, 키: "_새" }, 입력: usable() }, ctl.signal, ) .then((got) => { if (mine !== seq) return; cache.merge(got.연결); cache.merge({ 문서: cut }); recalc(); draw(); }) .catch(() => undefined); }; const subDoc = (k: string): FormulaDoc | null => { try { return cache.links.doc(k) ?? null; } catch { if (cache.takeMissing()) ask(); return null; } }; /** 표 하나(`/table`) — 받은 표는 계산기 캐시에도 */ 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 change = (fn: (d: StackDoc, items: StackItem[]) => void): void => { if (!doc) return; const items = stackOf(doc); fn(doc, items); doc = docOf(doc, items); keep(); recalc(); draw(); }; /** 짝 강조 — 왼쪽 카드 · 오른쪽 줄(같은 자리) · from = 누른 쪽(반대쪽을 보이는 자리로) */ const pair = (loc: string, from?: "left" | "right"): void => { paired = loc; for (const x of root.querySelectorAll(".is-pair")) x.classList.remove("is-pair"); if (!loc) return; const sel = `[data-loc="${CSS.escape(loc)}"]`; const l = left.querySelector(sel); const r = [...right.querySelectorAll(sel)]; l?.classList.add("is-pair"); r.forEach((x) => x.classList.add("is-pair")); if (from === "right") l?.scrollIntoView({ block: "nearest", behavior: "smooth" }); if (from === "left") r[0]?.scrollIntoView({ block: "nearest", behavior: "smooth" }); }; /** 복사해 펼침(804-4) — 서버 조각 → 새 항목(고침 = 캐시 · [저장] 때 서버가 다시 셈) */ const takeIn = async (src: string): Promise => { try { const got = await call(`/take?src=${encodeURIComponent(src)}`); change((d, items) => addTaken(d, items, got)); } catch (e) { showToast(`${lt("Take_Failed")} — ${e instanceof Error ? e.message : ""}`, "error"); } }; const takeCtx = () => cp && opt.scope && key ? { ...opt.scope, me: key, take: (src: string) => void takeIn(src) } : undefined; // ── 그림 ── const edit = (): boolean => opt.admin && (!!key || !cp); const draw = (): void => { if (!doc || !answer) { [head, left, right, foot].forEach((x) => x.replaceChildren()); opt.tools?.replaceChildren(); return; } const setOption = (k: string, v: DocVar, x: unknown): void => { const at = doc?.변수?.indexOf(v) ?? -1; if (cp) return change((d) => at >= 0 && (d.변수![at].연결 = { ...v.연결!, 값: x })); given[k] = x; recalc(); draw(); }; const options = [ ...buildOptions(doc, given, !!cp && !opt.admin, setOption), ...buildSourced(doc, !!cp && !opt.admin, setOption), // 가져온 식 값 · 추정 칸(810-3) ]; const busy = dirty(); const tools = [ ...(mark?.단가기준 && opt.page ? [basisPick(mark.단가기준)] : []), // 모달 = 머리 판 단추(793-3b) ...(cp && edit() && key ? copyTools(true, (job) => void runJob(job)) : []), ...(edit() && key && !opt.page ? [ createButton({ label: ft("Discard"), variant: "ghost", size: 28, disabled: !busy, onClick: () => void discard(), }), createButton({ label: ft("Save"), variant: "filled", size: 28, disabled: !busy, onClick: () => void save(), }), ] : []), ]; head.replaceChildren( ...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []), el("div", { className: "lstk__bar", children: [ el("div", { className: "lstk__badges", children: [ ...(busy ? [createBadge(ft("Dirty"), "warning")] : []), ...(cp ? copyBadges(mark, opt.admin) : []), ], }), el("div", { className: "lstk__tools", children: opt.tools ? [] : tools }), ], }), ); opt.tools?.replaceChildren(...tools); const items = stackOf(doc); root.dataset.edit = edit() ? "1" : ""; left.replaceChildren( ...(edit() ? [ buildEdit({ doc, answer, items, change, table: tableOf, subDoc, name: (k) => cache.names.get(k) ?? "", open: blocks, pair: (loc) => pair(loc, "left"), take: takeCtx(), before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined, }), ] : []), ); right.replaceChildren( items.length || doc.결과 ? buildOut(doc, answer, { cache, open: opened, toggle: (at) => { if (opened.has(at)) opened.delete(at); else opened.add(at); draw(); }, miss: ask, pair: (loc) => pair(loc, "right"), }) : el("p", { className: "ui-text ui-text--muted", text: lt("Empty") }), ); foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)])); pair(paired); fit(); }; const take = (got: Loaded): void => { cache.merge(got.연결); saved = JSON.stringify(got.문서); doc = clone(got.문서); cut = clone(got.끊음 ?? {}); mark = got.사본; version = got.판 ?? ""; }; /** 사본 [단가 갱신] · [재생성] — 미리 셈 → 알림 → 저장(고친 것 먼저 저장 · 버림) */ 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(); if (await askJob(job, got, cache.names)) await save({ 할일: job, 판: version }, ft("Job_Done")); }; /** 가져온 식 [엑셀 단가 그대로] · [우리 단가로](807-1) — 저장(서버가 그 기준으로 다시 셈) */ const basisPick = (now: Basis): HTMLElement => { const seg = createSegment({ options: [ { value: "엑셀", label: ft("Basis_Excel") }, { value: "우리", label: ft("Basis_Ours") }, ], value: now, size: 28, onChange: (v) => { if (v === now) return; if (!edit() || dirty()) { seg.set(now); return void showToast(ft("Dirty_First"), "warning"); } void save({ 단가기준: v, 판: version }, ft("Job_Done")).then((ok) => ok || seg.set(now)); }, }); seg.root.dataset.importBasis = now; return seg.root; }; let loading: Promise = Promise.resolve(); const save = async (job?: Record, done = ft("Saved")): Promise => { await loading; // 새 로직 저장 뒤 여는 중이면 다 읽고(캐시 이어 받음) if (!doc || (!job && !dirty())) return true; const send = job ?? (cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc }); const was = saved; 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.문서) !== was) throw e; return call("/save", { ...send, 판: (version = now.판 ?? "") }); }); take(got); keep(); recalc(); draw(); if (cp && got.판) cp.onSaved?.(got.판); if (done) showToast(done, "success"); return true; } catch (e) { showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error"); return false; } finally { hideLoadingOverlay(); } }; const discard = async (): Promise => { if (!doc || !dirty()) return void draw(); doc = JSON.parse(saved) as StackDoc; keep(); recalc(); draw(); }; /** 문서 열기 — 서버 · 캐시에서 다시 읽음(수식 탭 고침 · 저장을 이어 받음) */ const open = async (next: string): Promise => { const was = key; shown = next; if (next !== key) { paired = ""; opened.clear(); blocks.clear(); given = {}; } key = next; doc = answer = null; asked = ""; cache = createDocCache(); tables.clear(); [cut, mark, version] = [{}, undefined, ""]; draw(); if (!next) { if (cp) return void right.replaceChildren( el("p", { className: "ui-text ui-text--muted", text: lt("Unsaved_New") }), ); doc = (drafts()[""] as StackDoc | undefined) ?? blankDoc(); saved = JSON.stringify(blankDoc()); 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)); } if (all[next]) doc = clone(all[next] as StackDoc); recalc(); draw(); opt.page?.(); // 캐시에서 이어 받은 고침 — 페이지 [저장] · [되돌림] 켬 } 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 (!(key in drafts())) return true; // 다른 화면에서 이미 버림 if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false; doc = JSON.parse(saved) as StackDoc; keep(); return true; }; return { root, /** 로직 · SK 키로 그림(빈 키 = 저장 전 새 것) — 같은 키는 그대로 · `force` = 다시 읽음(탭 바꿈) */ draw: (next: string, force = false): void => { if (force || next !== shown) loading = open(next); }, canLeave, dirty, /** 페이지 [저장](804-7) — 고침 있으면 서버 정본 저장 · 알림은 페이지가(`done` 빈 글) · 실패 = false */ save: (): Promise => save(undefined, ""), /** 페이지 [되돌림] — 저장 뒤 상태로(캐시 지움) */ discard: (): Promise => discard(), /** 시험 · 확인용 */ state: () => ({ doc, answer, dirty: dirty(), items: doc ? stackOf(doc) : [] }), }; }