feat(est): reproduce native tables and calculations

This commit is contained in:
2026-11-05 06:07:05 +09:00
parent 646eab6245
commit 60ccedcd9a
30 changed files with 5249 additions and 22 deletions
+241
View File
@@ -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) => ({ "&": "&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;
}
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());