328 lines
15 KiB
JavaScript
328 lines
15 KiB
JavaScript
// 원본 엑셀형 수량산출서(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 };
|
||
})();
|