Files
Aislo/tools/est-test/native-excel-view.js
T

328 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 웹 편집 화면 — 셀 고르기(Shift 로 범위)·수식줄 편집·다시 계산(btnReCalc),
// 셀 합(우·상·좌·하 btnSum*), 수량·품명·규격·단위로 지정(popSetMullang·popSetName·popSetSize·popSetUnite)·지정 해제(popSetFree),
// 잘라내기·복사·붙여넣기·내용지우기, 행·열 삽입/삭제, 굵게·기울임·밑줄·정렬, 병합·분리, 대각선(native-excel-edit.cjs).
// 수량 지정 = 셀 ↔ 항목 연결(sheet.excelLinks) — 항목 수량이 그 셀 값(산출서 자리수)을 따른다.
// 품명·규격·단위 지정은 그때 셀 글자를 항목에 적는다. 원본 파일 속 셀↔항목 대응은 찾지 못해, 가져올 때 값이 같은 셀
// 하나(합계 수식·아래쪽 우선)를 「추정」 연결로 둔다. 편집한 엑셀형 산출서는 .xls 로 다시 쓰지 못하므로 ESTX 저장을 막는다.
// 되돌리기(actUndo)는 앱 전체 취소(↶)를 쓴다.
const EstExcelView = (() => {
const L = (n) => EstExcelSheet.letters(n);
const DIAG = {
left: "linear-gradient(to bottom right,transparent 49%,#555 50%,transparent 51%)",
right: "linear-gradient(to top right,transparent 49%,#555 50%,transparent 51%)",
};
const range = (sel) => ({
r1: Math.min(sel.row, sel.row2 ?? sel.row),
r2: Math.max(sel.row, sel.row2 ?? sel.row),
c1: Math.min(sel.col, sel.col2 ?? sel.col),
c2: Math.max(sel.col, sel.col2 ?? sel.col),
});
const btn = (action, label, extra = "") =>
`<button data-action="${action}"${extra ? ` ${extra}` : ""}>${label}</button>`;
function toolbar(sheet) {
const items = (sheet.items || [])
.map(
(i) =>
`<option value="${EstApp.esc(i.Code)}">${EstApp.esc(i.Code)} ${EstApp.esc(i.Name ?? "")} ${EstApp.esc(i.Size ?? "")}</option>`,
)
.join("");
return [
btn("excel-recalc", "다시 계산"),
btn("excel-print", "미리보기·인쇄"),
btn("excel-cut", "잘라내기"),
btn("excel-copy", "복사"),
btn("excel-paste", "붙여넣기"),
btn("excel-clear", "내용지우기"),
btn("excel-insert", "행 삽입", 'data-axis="row"'),
btn("excel-insert", "열 삽입", 'data-axis="col"'),
btn("excel-delete", "행 삭제", 'data-axis="row"'),
btn("excel-delete", "열 삭제", 'data-axis="col"'),
btn("excel-style", "<b>B</b>", 'data-name="b" title="굵게"'),
btn("excel-style", "<i>I</i>", 'data-name="i" title="기울임"'),
btn("excel-style", "<u>U</u>", 'data-name="u" title="밑줄"'),
btn("excel-style", "왼쪽", 'data-name="align" data-value="left"'),
btn("excel-style", "가운데", 'data-name="align" data-value="center"'),
btn("excel-style", "오른쪽", 'data-name="align" data-value="right"'),
btn("excel-merge", "병합"),
btn("excel-split", "분리"),
`<select data-excel-digits aria-label="셀 서식 소수자리"><option value="">셀 서식(소수자리)</option>${[0, 1, 2, 3, 4].map((d) => `<option value="${d}">소수 ${d}자리</option>`).join("")}<option value="-">서식 해제</option></select>`,
btn("excel-style", "왼쪽대각선", 'data-name="diag" data-value="left"'),
btn("excel-style", "오른쪽대각선", 'data-name="diag" data-value="right"'),
btn("excel-style", "대각선 삭제", 'data-name="diag" data-value=""'),
btn("excel-sum", "우측 합", 'data-dir="right"'),
btn("excel-sum", "위 합", 'data-dir="up"'),
btn("excel-sum", "좌측 합", 'data-dir="left"'),
btn("excel-sum", "아래 합", 'data-dir="down"'),
`<select data-excel-item aria-label="수량 항목">${items}</select>`,
btn("excel-link", "수량으로 지정"),
btn("excel-field", "품명으로 지정", 'data-field="Name"'),
btn("excel-field", "규격으로 지정", 'data-field="Size"'),
btn("excel-field", "단위로 지정", 'data-field="Unite"'),
btn("excel-unlink", "지정 해제"),
].join("");
}
function render(sheet, state) {
if (!sheet?.excel?.length) return "";
const { esc, number } = EstApp,
sel = state.excelCell?.sheet === sheet.Code ? state.excelCell : null,
links = sheet.excelLinks || {};
const linked = new Map(
Object.entries(links).map(([code, l]) => [`${l.n},${l.row},${l.col}`, { code, ...l }]),
);
return sheet.excel
.map(({ name, cells, merges = [] }, n) => {
const keys = Object.keys(cells).map((k) => k.split(",").map(Number));
const rows = Math.max(20, ...keys.map(([r]) => r + 2), ...merges.map((m) => m.r2 + 2)),
cols = Math.min(
40,
Math.max(8, ...keys.map(([, c]) => c + 2), ...merges.map((m) => m.c2 + 2)),
);
const here = sel && sel.n === n,
box = here ? range(sel) : null;
const raw = here ? cells[`${sel.row},${sel.col}`] : null;
const bar = here
? `${L(sel.col)}${sel.row + 1}${box.r2 > box.r1 || box.c2 > box.c1 ? `:${L(box.c2)}${box.r2 + 1}` : ""} <input data-excel-bar style="width:50%" value="${esc(raw ? (typeof raw.formula === "string" ? `=${raw.formula}` : (raw.value ?? "")) : "")}" aria-label="수식줄"><br>${toolbar(sheet)}`
: "셀을 누르면 수식줄에서 고칩니다. Shift 로 범위를 고릅니다.";
const covered = new Set(),
spans = new Map();
for (const m of merges) {
spans.set(`${m.r1},${m.c1}`, m);
for (let r = m.r1; r <= m.r2; r++)
for (let c = m.c1; c <= m.c2; c++)
if (r !== m.r1 || c !== m.c1) covered.add(`${r},${c}`);
}
const body = Array.from({ length: rows }, (_, r) => {
const tds = Array.from({ length: cols }, (_, c) => {
const k = `${r},${c}`;
if (covered.has(k)) return "";
const cell = cells[k],
link = linked.get(`${n},${r},${c}`),
m = spans.get(k),
chosen = box && r >= box.r1 && r <= box.r2 && c >= box.c1 && c <= box.c2,
s = cell?.style || {};
const value =
cell == null
? ""
: typeof cell.value === "number"
? s.digits != null && s.digits !== ""
? cell.value.toLocaleString("ko-KR", {
minimumFractionDigits: Number(s.digits),
maximumFractionDigits: Number(s.digits),
})
: number(cell.value)
: esc(cell.value);
const style = [
cell?.formula || s.b ? "font-weight:bold" : "",
s.i ? "font-style:italic" : "",
s.u ? "text-decoration:underline" : "",
s.align ? `text-align:${s.align}` : "",
s.diag ? `background-image:${DIAG[s.diag]}` : "",
link ? "background-color:#e6f4ea" : chosen ? "background-color:#e8f0fb" : "",
here && sel.row === r && sel.col === c ? "outline:2px solid #316ea5" : "",
]
.filter(Boolean)
.join(";");
const title = [
typeof cell?.formula === "string" ? `=${cell.formula}` : "",
link ? `수량: ${link.code}${link.inferred ? " (추정 연결)" : ""}` : "",
]
.filter(Boolean)
.join(" · ");
const span = m
? `${m.r2 > m.r1 ? ` rowspan="${m.r2 - m.r1 + 1}"` : ""}${m.c2 > m.c1 ? ` colspan="${m.c2 - m.c1 + 1}"` : ""}`
: "";
return `<td data-excel-pick="${n},${r},${c}"${span}${style ? ` style="${style}"` : ""}${title ? ` title="${esc(title)}"` : ""}>${value}</td>`;
}).join("");
return `<tr><th>${r + 1}</th>${tds}</tr>`;
}).join("");
return `<h4>엑셀형 산출서 · ${esc(name)}</h4><div class="business-tools">${bar}</div><div style="overflow:auto;max-height:420px"><table class="sheet" style="font-size:11px"><thead><tr><th></th>${Array.from({ length: cols }, (_, c) => `<th>${L(c)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div>`;
})
.join("");
}
function sheetOf(state) {
return state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
}
function setCell(sheet, n, row, col, text) {
const cells = sheet.excel[n].cells,
k = `${row},${col}`,
t = String(text ?? ""),
style = cells[k]?.style;
if (t === "" && !style) delete cells[k];
else if (t.startsWith("=")) cells[k] = { value: 0, formula: t.slice(1) };
else {
const v = Number(t.replaceAll(",", ""));
cells[k] = { value: t.trim() !== "" && Number.isFinite(v) ? v : t };
}
if (style && cells[k]) cells[k].style = style;
sheet.excelEdited = true;
return EstExcelSheet.recalc(cells);
}
function linksOf(sheet, n) {
const out = {};
for (const [code, l] of Object.entries(sheet.excelLinks || {})) if (l.n === n) out[code] = l;
return out;
}
function install() {
document.addEventListener("click", (event) => {
const td = event.target.closest("[data-excel-pick]");
if (!td) return;
const [n, row, col] = td.dataset.excelPick.split(",").map(Number),
state = EstApp.state,
old = state.excelCell;
state.excelCell =
event.shiftKey && old && old.sheet === state.quantitySheet && old.n === n
? { ...old, row2: row, col2: col }
: { sheet: state.quantitySheet, n, row, col };
EstApp.render();
});
document.addEventListener("change", (event) => {
if (event.target.dataset.excelDigits !== undefined) {
const v = event.target.value,
state = EstApp.state,
sheet = sheetOf(state),
sel = state.excelCell;
if (!v || !sheet?.excel || !sel) return;
EstApp.change(() => {
EstExcelEdit.style(sheet.excel[sel.n], range(sel), "digits", v === "-" ? "" : v);
sheet.excelEdited = true;
}, false);
return;
}
if (event.target.dataset.excelBar === undefined) return;
const state = EstApp.state,
sel = state.excelCell,
value = event.target.value;
let errors = [];
EstApp.change(() => {
errors = setCell(sheetOf(state), sel.n, sel.row, sel.col, value);
}, false);
if (errors.length) state.notice = errors.slice(0, 3).join(" / ");
});
document.addEventListener("keydown", (event) => {
const state = EstApp.state;
if (!state.excelCell || event.target.closest?.("input,textarea,select")) return;
const keyAction = event.ctrlKey
? { c: "excel-copy", x: "excel-cut", v: "excel-paste" }[event.key.toLowerCase()]
: event.key === "Delete"
? "excel-clear"
: null;
if (!keyAction || !sheetOf(state)?.excel) return;
event.preventDefault();
try {
handle(keyAction, state, { dataset: {} });
} catch (error) {
state.notice = error.message;
EstApp.render();
}
});
}
const names = new Set([
"excel-sum",
"excel-link",
"excel-unlink",
"excel-field",
"excel-recalc",
"excel-cut",
"excel-copy",
"excel-paste",
"excel-clear",
"excel-insert",
"excel-delete",
"excel-style",
"excel-merge",
"excel-split",
"excel-print",
]);
function handle(name, state, node) {
if (!names.has(name)) return false;
const sheet = sheetOf(state),
sel = state.excelCell;
if (!sheet?.excel || !sel) throw Error("엑셀형 산출서에서 셀을 고르세요.");
const page = sheet.excel[sel.n],
box = range(sel),
edit = (fn) =>
EstApp.change(() => {
const errors = fn() || [];
sheet.excelEdited = true;
if (errors.length) state.notice = errors.slice(0, 3).join(" / ");
}, false);
if (name === "excel-sum") {
const formula = EstExcelSheet.sumFormula(page.cells, sel.row, sel.col, node.dataset.dir);
EstApp.change(() => setCell(sheet, sel.n, sel.row, sel.col, `=${formula}`), false);
} else if (name === "excel-recalc") edit(() => EstExcelSheet.recalc(page.cells));
else if (name === "excel-copy" || name === "excel-cut") {
state.excelClip = EstExcelEdit.copyRange(page, box);
if (name === "excel-cut") edit(() => EstExcelEdit.clearRange(page, box));
else state.notice = `${state.excelClip.rows}×${state.excelClip.cols} 칸을 복사했습니다.`;
} else if (name === "excel-paste") {
if (!state.excelClip) throw Error("복사한 셀이 없습니다.");
edit(() => EstExcelEdit.pasteRange(page, state.excelClip, box.r1, box.c1));
} else if (name === "excel-clear") edit(() => EstExcelEdit.clearRange(page, box, true));
else if (name === "excel-insert" || name === "excel-delete") {
const axis = node.dataset.axis,
at = axis === "row" ? box.r1 : box.c1,
count = axis === "row" ? box.r2 - box.r1 + 1 : box.c2 - box.c1 + 1;
if (
name === "excel-delete" &&
!confirm(`${axis === "row" ? "행" : "열"} ${count}개를 삭제하시겠습니까?`)
)
return true;
edit(() =>
EstExcelEdit.shift(
page,
axis,
at,
name === "excel-insert" ? count : -count,
linksOf(sheet, sel.n),
),
);
state.excelCell = { sheet: sel.sheet, n: sel.n, row: box.r1, col: box.c1 };
} else if (name === "excel-style")
edit(() => EstExcelEdit.style(page, box, node.dataset.name, node.dataset.value));
else if (name === "excel-merge") edit(() => EstExcelEdit.merge(page, box));
else if (name === "excel-split") edit(() => EstExcelEdit.split(page, box));
else if (name === "excel-print") {
// 미리보기·인쇄(tbsPreView·tbsPrint): 고른 시트 격자만 새 창에
const html = render({ ...sheet, excel: [page], excelLinks: {} }, { excelCell: null })
.replace(/<div class="business-tools">[\s\S]*?<\/div>/, "")
.replace(/ data-excel-pick="[^"]*"/g, "");
const win = open("", "_blank");
if (!win) throw Error("새 창을 열 수 없습니다. 팝업 차단을 확인하세요.");
win.document.write(
`<!doctype html><meta charset="utf-8"><title>${EstApp.esc(sheet.Name || page.name)}</title><style>table{border-collapse:collapse;font:10pt sans-serif}td,th{border:1px solid #888;padding:2px 4px}th{background:#eee}</style>${html}<script>print()<\/script>`,
);
win.document.close();
} else {
const code = document.querySelector("[data-excel-item]")?.value,
item = sheet.items?.find((i) => i.Code === code);
if (name === "excel-field") {
if (!item) throw Error("항목을 고르세요.");
const v = page.cells[`${sel.row},${sel.col}`]?.value;
if (v == null || v === "") throw Error("빈 셀입니다.");
edit(() => {
item[node.dataset.field] = String(v);
});
return true;
}
EstApp.change(() => {
sheet.excelLinks ||= {};
for (const [k, l] of Object.entries(sheet.excelLinks))
if (l.n === sel.n && l.row === sel.row && l.col === sel.col) delete sheet.excelLinks[k];
if (name === "excel-link") {
if (!code) throw Error("수량 항목을 고르세요.");
sheet.excelLinks[code] = { n: sel.n, row: sel.row, col: sel.col };
}
}, false);
}
return true;
}
if (typeof document !== "undefined" && document.addEventListener) install();
return { render, setCell, handle };
})();