Files

445 lines
16 KiB
JavaScript

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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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]) => `<span>${label} <b>${number(n)}</b></span>`)
.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 가져오기 확인",
`<ul>${project.importWarnings.map((w) => `<li>${esc(w)}</li>`).join("")}</ul>`,
);
}
function showDialog(title, html) {
byId("dialog-title").textContent = title;
byId("dialog-content").innerHTML = html;
byId("dialog").showModal();
}
async function initialize() {
try {
let saved = await loadFromStorage(CACHE);
if (!saved && parentTabId) {
// 복제 탭의 경우 부모 탭의 캐시 데이터를 복제하여 초기 상태로 상속
const parentSaved = await loadFromStorage(`est-web-v2-working-${parentTabId}`);
if (parentSaved) {
saved = parentSaved;
await saveToStorage(CACHE, saved);
}
}
if (!saved) saved = await loadFromStorage(LEGACY_CACHE);
setProject(saved ? JSON.parse(saved) : await api("/api/sample"));
byId("business-search").addEventListener("input", (event) => {
state.query = event.target.value;
render();
});
EstActions.install();
EstQuantity.install();
EstReview.install();
EstEstimate.install();
EstBasicPrices.install();
EstMachine.install();
} catch (error) {
byId("messages").hidden = false;
byId("messages").textContent = `시작 실패: ${error.message}`;
}
}
return {
state,
titles,
esc,
number,
uid,
byId,
api,
cache,
snapshot,
restore,
remember,
calculate,
change,
setProject,
navigate,
visibleRows,
render,
download,
save,
open,
showDialog,
initialize,
parentTabId,
currentTabId,
};
})();
document.addEventListener("DOMContentLoaded", () => EstApp.initialize());