const EstApp = (() => { const 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": "표준시장단가 설계변경", drawing: "도면·문서", }; const esc = (value) => String(value ?? "").replace( /[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch], ); const number = (value) => new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }).format(Number(value) || 0); function uid() { 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)}`; } 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; } function cache(serialized) { try { sessionStorage.setItem(CACHE, serialized ?? JSON.stringify(state.project)); } catch { state.notice = "임시 저장 용량이 부족합니다. 저장 버튼으로 작업을 보관하세요."; } } 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.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; 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 가져오기 확인", `