445 lines
16 KiB
JavaScript
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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());
|