304 lines
10 KiB
JavaScript
304 lines
10 KiB
JavaScript
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: "연차공사",
|
|
resources: "동원 집계",
|
|
schedule: "예정공정표",
|
|
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;
|
|
}
|
|
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]) => `<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$/i.test(file.name))
|
|
project = await api(
|
|
`/api/import?name=${encodeURIComponent(file.name.replace(/\.estx$/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 {
|
|
const saved = sessionStorage.getItem(CACHE);
|
|
setProject(saved ? JSON.parse(saved) : await api("/api/sample"));
|
|
EstActions.install();
|
|
EstQuantity.install();
|
|
} catch (error) {
|
|
byId("messages").hidden = false;
|
|
byId("messages").textContent = `시작 실패: ${error.message}`;
|
|
}
|
|
}
|
|
return {
|
|
state,
|
|
titles,
|
|
esc,
|
|
number,
|
|
byId,
|
|
api,
|
|
cache,
|
|
snapshot,
|
|
restore,
|
|
remember,
|
|
calculate,
|
|
change,
|
|
setProject,
|
|
navigate,
|
|
visibleRows,
|
|
render,
|
|
download,
|
|
save,
|
|
open,
|
|
showDialog,
|
|
initialize,
|
|
};
|
|
})();
|
|
document.addEventListener("DOMContentLoaded", () => EstApp.initialize());
|