const EstApp = (() => { function uid() { if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { const bytes = crypto.getRandomValues(new Uint8Array(16)); bytes[6] = (bytes[6] & 15) | 64; bytes[8] = (bytes[8] & 63) | 128; const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0, v = c === "x" ? r : (r & 3) | 8; return v.toString(16); }); } // 탭별 인스턴스 격리 식별자: // 1) 같은 탭 내 새로고침 시: sessionStorage 및 window.name이 보존되므로 동일 인스턴스로 복원 // 2) 탭 복제(Duplicate tab) 시: Chromium 브라우저는 sessionStorage를 그대로 복제하지만 window.name은 빈 문자열("")로 리셋됨. // 따라서 window.name과 sessionStorage가 불일치하면 복제된 새 탭으로 감지하고 새 tabId를 발급하여 이전 탭의 캐시를 복제한 후 분리 격리함. const TAB_KEY = "est-tab-instance-id"; const storage = typeof sessionStorage !== "undefined" ? sessionStorage : null; const win = typeof window !== "undefined" ? window : { name: "" }; let currentTabId = storage ? storage.getItem(TAB_KEY) : null; const isDuplicateTab = currentTabId && (!win.name || win.name !== currentTabId); const parentTabId = isDuplicateTab ? currentTabId : null; if (!currentTabId || isDuplicateTab) { currentTabId = uid(); if (storage) storage.setItem(TAB_KEY, currentTabId); } win.name = currentTabId; const CACHE = `est-web-v2-working-${currentTabId}`; const LEGACY_CACHE = "est-web-v2-working"; const state = { project: null, view: "EstMainDat", group: "", selection: new Set(), query: "", page: 0, history: [], future: [], notice: "", business: null, revision: 0, documentKey: 0, }; let saveQueue = Promise.resolve(); const titles = { EstMainDat: "내역서", HeadMainDat: "일위대가", CostMainDat: "단가산출", MachMainDat: "기계경비", EstiMateDat: "견적단가", SilJukDat: "표준시장단가", StuffcDat: "자재단가", LaborcDat: "노임단가", MachGijunDat: "기계 기준표", Wonga: "원가계산서", quantity: "수량산출", change: "설계변경", progress: "기성고", escalation: "물가변동", annual: "연차공사", review: "계약심사", resources: "동원 집계", schedule: "예정공정표", issues: "자료 오류 검색", attachments: "첨부파일", standard: "표준시장단가 사용 내역", "standard-change": "표준시장단가 설계변경", "estimate-usage": "견적단가 사용 내역", "estimate-change": "견적단가 설계변경", "material-usage": "자재단가 사용 내역", "material-change": "자재단가 설계변경", "labor-usage": "노임단가 사용 내역", "labor-change": "노임단가 설계변경", "machine-usage": "기계경비 사용 내역", "machine-change": "기계경비 설계변경", drawing: "도면·문서", }; const esc = (value) => String(value ?? "").replace( /[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch], ); const number = (value) => new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }).format(Number(value) || 0); const byId = (id) => document.getElementById(id); async function api(url, options = {}) { const response = await fetch(url, options), data = await response.json(); if (!response.ok) throw Error(data.error || `요청 실패: ${response.status}`); return data; } let cacheQueue = Promise.resolve(); function openIdb() { return new Promise((resolve) => { if (!globalThis.indexedDB) return resolve(null); const req = indexedDB.open("est-independent-cache", 1); req.onupgradeneeded = () => req.result.createObjectStore("cache"); req.onsuccess = () => resolve(req.result); req.onerror = () => resolve(null); }); } let idbPromise = null; function getIdb() { idbPromise ||= openIdb(); return idbPromise; } function waitForTx(tx) { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || Error("IndexedDB 트랜잭션 실패")); tx.onabort = () => reject(Error("IndexedDB 트랜잭션 중단")); }); } async function saveToStorage(key, value) { try { sessionStorage.setItem(key, value); const db = await getIdb(); if (db) { const tx = db.transaction("cache", "readwrite"); tx.objectStore("cache").delete(key); await waitForTx(tx).catch(() => {}); } return true; } catch { // sessionStorage 할당 초과(약 5~10MB) 시 IndexedDB로 자동 전환 보관 try { const db = await getIdb(); if (db) { const tx = db.transaction("cache", "readwrite"); tx.objectStore("cache").put(value, key); await waitForTx(tx); sessionStorage.removeItem(key); return true; } } catch (err) { console.warn("IndexedDB 저장 실패:", err); } return false; } } async function loadFromStorage(key) { const fromSession = sessionStorage.getItem(key); if (fromSession) return fromSession; try { const db = await getIdb(); if (db) { return new Promise((resolve) => { const tx = db.transaction("cache", "readonly"); const req = tx.objectStore("cache").get(key); req.onsuccess = () => resolve(req.result || null); req.onerror = () => resolve(null); }); } } catch {} return null; } function cache(serialized) { const payload = serialized ?? JSON.stringify(state.project); const op = cacheQueue .catch(() => {}) .then(async () => { const ok = await saveToStorage(CACHE, payload); if (!ok) { state.notice = "임시 저장 공간을 사용할 수 없습니다. 저장 버튼으로 작업을 보관하세요."; } }); cacheQueue = op; return op; } function snapshot() { const serialized = JSON.stringify(state.project); if (serialized.length < 10 * 1024 * 1024 || !globalThis.CompressionStream) return serialized; return new Response(new Blob([serialized]).stream().pipeThrough(new CompressionStream("gzip"))) .blob() .catch(() => serialized); } async function restore(snapshot) { const saved = await snapshot; return JSON.parse( typeof saved === "string" ? saved : await new Response(saved.stream().pipeThrough(new DecompressionStream("gzip"))).text(), ); } function remember() { state.revision++; state.history.push(snapshot()); if (state.history.length > 8) state.history.shift(); state.future = []; } function calculate() { state.revision++; try { state.project = EstNative.calculate(EstModel.validate(state.project)); } catch (error) { state.notice = error.message; } } function change(mutator, recalculate = true) { remember(); mutator(state.project); if (recalculate && state.project.options.AutoCalc !== false) calculate(); cache(); render(); } function setProject(project) { state.project = EstModel.legacy(project); state.documentKey++; state.revision++; state.history = []; state.future = []; state.selection.clear(); state.group = ""; state.page = 0; state.progressPage = state.escalationPage = state.changePage = 0; state.quantitySheet = state.project.quantitySheets?.[0]?.Code || "manual"; state.roundId = state.project.progress.find((round) => round.id === state.roundId)?.id || state.project.progress[0]?.id; if (state.project.options.AutoCalc !== false) calculate(); else state.project.calculation ||= { errors: [], warnings: [], direct: state.project.tables.EstMainDat.find((row) => Number(row.LinkIndex) === 54) || {}, total: state.project.tables.Wonga.find((row) => Number(row.LinkIndex) === 55)?.Cost || 0, }; cache(); render(); } function navigate(view, group = "") { state.view = view; state.group = group; state.selection.clear(); state.query = ""; state.page = 0; state.business = null; render(); } function visibleRows() { const rows = state.project.tables[state.view] || []; let list = rows.map((row, index) => ({ row, index })); if (["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view)) { const groups = EstNative.groups(rows); if (!state.group || !groups.has(state.group)) state.group = groups.keys().next().value || ""; const group = groups.get(state.group); list = group ? list.slice(group.start, group.start + group.rows.length) : []; } if (state.query) { const q = state.query.toLowerCase(); list = list.filter(({ row }) => Object.values(row).some((v) => typeof v === "string" && v.toLowerCase().includes(q)), ); } return list; } function render() { if (!state.project) return; if (state.query !== state.previousBusinessQuery) { state.progressPage = state.escalationPage = state.changePage = 0; state.previousBusinessQuery = state.query; } const active = document.activeElement, scroll = byId("work-content")?.scrollLeft || 0; const focus = active?.dataset.table ? { table: active.dataset.table, row: active.dataset.row, field: active.dataset.field } : active?.id ? { id: active.id } : null; const caret = active?.type === "text" || active?.tagName === "TEXTAREA" ? active.selectionStart : null; byId("project-name").value = state.project.name; byId("automatic").checked = state.project.options.AutoCalc !== false; byId("refresh-sources").checked = Boolean(state.project.options.SourceRefresh); byId("view-title").textContent = titles[state.view] || state.view; document .querySelectorAll("[data-view]") .forEach((button) => button.classList.toggle("active", button.dataset.view === state.view)); const calculated = state.project.calculation || { direct: {}, total: 0, errors: [] }; byId("totals").innerHTML = [ ["노무", calculated.direct?.NC], ["재료", calculated.direct?.GC], ["경비", calculated.direct?.JC], ["실적", calculated.direct?.SC], ["순공사비", calculated.direct?.TC], ["도급액", calculated.total], ] .map(([label, n]) => `${label} ${number(n)}`) .join(""); const errors = calculated.errors || []; byId("messages").hidden = !errors.length && !state.notice; byId("messages").textContent = state.notice || errors.join(" / "); const rowCount = state.project.tables[state.view]?.length ?? (state.view === "quantity" ? (state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet)?.items .length ?? state.project.quantities.length) : 0); byId("status").textContent = `${state.project.name} · ${titles[state.view]} · ${rowCount}행 · ${errors.length ? `계산 오류 ${errors.length}건` : "계산 오류 없음"}`; EstViews.render(state); byId("work-content").scrollLeft = scroll; const next = focus?.table ? document.querySelector( `[data-table="${focus.table}"][data-row="${focus.row}"][data-field="${focus.field}"]`, ) : focus?.id ? byId(focus.id) : null; if (next) { next.focus({ preventScroll: true }); if (caret !== null && next.setSelectionRange) next.setSelectionRange(caret, caret); } } function download(name, text, type = "application/json") { const a = document.createElement("a"), blob = new Blob([text], { type }); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); } function save(asNew = false) { document.activeElement?.blur?.(); if (asNew) { delete state.project.id; state.documentKey++; } state.project.id ||= [...crypto.getRandomValues(new Uint8Array(12))] .map((value) => value.toString(16).padStart(2, "0")) .join(""); const documentKey = state.documentKey, revision = state.revision, body = JSON.stringify(state.project); cache(body); const operation = saveQueue .catch(() => {}) .then(async () => { const result = await api("/api/save", { method: "POST", headers: { "Content-Type": "application/json" }, body, }); if (state.documentKey === documentKey) { if (state.revision === revision) state.project = result; cache(); state.notice = state.revision === revision ? "저장했습니다. 이 앱 폴더의 data에도 작업이 보관됩니다." : "요청 시점의 작업을 저장했습니다. 이후 입력은 다시 저장하세요."; render(); } download(`${result.name || "내역서"}.estweb.json`, JSON.stringify(result, null, 2)); }); saveQueue = operation; return operation; } async function open(file) { if (!file) return; if ( state.project && !confirm("현재 작업을 새 파일로 바꾸시겠습니까? 저장하지 않은 입력은 사라집니다.") ) return; let project; if (/\.(estx|ebkx)$/i.test(file.name)) project = await api( `/api/import?name=${encodeURIComponent(file.name.replace(/\.(estx|ebkx)$/i, ""))}`, { method: "POST", body: await file.arrayBuffer() }, ); else project = JSON.parse(await file.text()); setProject(project); if (project.importWarnings?.length) showDialog( "ESTX 가져오기 확인", `