feat(est): reproduce native tables and calculations
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
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,
|
||||
};
|
||||
const titles = {
|
||||
EstMainDat: "내역서",
|
||||
HeadMainDat: "일위대가",
|
||||
CostMainDat: "단가산출",
|
||||
MachMainDat: "기계경비",
|
||||
EstiMateDat: "견적단가",
|
||||
SilJukDat: "표준시장단가",
|
||||
StuffcDat: "자재단가",
|
||||
LaborcDat: "노임단가",
|
||||
MachGijunDat: "기계 기준표",
|
||||
Wonga: "원가계산서",
|
||||
quantity: "수량산출",
|
||||
change: "설계변경",
|
||||
progress: "기성고",
|
||||
escalation: "물가변동",
|
||||
annual: "연차공사",
|
||||
resources: "동원 집계",
|
||||
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() {
|
||||
try {
|
||||
sessionStorage.setItem(CACHE, JSON.stringify(state.project));
|
||||
} catch {
|
||||
state.notice = "임시 저장 용량이 부족합니다. 저장 버튼으로 작업을 보관하세요.";
|
||||
}
|
||||
}
|
||||
function remember() {
|
||||
const serialized = JSON.stringify(state.project);
|
||||
if (serialized.length < 10 * 1024 * 1024) {
|
||||
state.history.push(serialized);
|
||||
if (state.history.length > 8) state.history.shift();
|
||||
}
|
||||
state.future = [];
|
||||
}
|
||||
function calculate() {
|
||||
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.history = [];
|
||||
state.future = [];
|
||||
state.selection.clear();
|
||||
state.group = "";
|
||||
state.page = 0;
|
||||
calculate();
|
||||
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(" / ");
|
||||
byId("status").textContent =
|
||||
`${state.project.name} · ${titles[state.view]} · ${state.project.tables[state.view]?.length || 0}행 · ${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);
|
||||
}
|
||||
async function save(asNew = false) {
|
||||
if (asNew) delete state.project.id;
|
||||
const result = await api("/api/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(state.project),
|
||||
});
|
||||
state.project = result;
|
||||
cache();
|
||||
state.notice = "저장했습니다. 이 앱 폴더의 data에도 작업이 보관됩니다.";
|
||||
render();
|
||||
download(`${state.project.name || "내역서"}.estweb.json`, JSON.stringify(result, null, 2));
|
||||
}
|
||||
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();
|
||||
} catch (error) {
|
||||
byId("messages").hidden = false;
|
||||
byId("messages").textContent = `시작 실패: ${error.message}`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
state,
|
||||
titles,
|
||||
esc,
|
||||
number,
|
||||
byId,
|
||||
api,
|
||||
cache,
|
||||
remember,
|
||||
calculate,
|
||||
change,
|
||||
setProject,
|
||||
navigate,
|
||||
visibleRows,
|
||||
render,
|
||||
download,
|
||||
save,
|
||||
open,
|
||||
showDialog,
|
||||
initialize,
|
||||
};
|
||||
})();
|
||||
document.addEventListener("DOMContentLoaded", () => EstApp.initialize());
|
||||
Reference in New Issue
Block a user