Files
Aislo/tools/est-test/native-excel-view.js
T
eomsangdonandClaude Opus 5.5 5e6c2988fc feat(est-test): 엑셀형 산출서 편집 동작과 조달청 표준공사코드 적용
- 엑셀형 산출서: 범위 고르기, 행·열 삽입/삭제(수식 참조·병합·수량 연결 이동, 지운 칸 #REF!), 잘라내기·복사·붙여넣기(상대 참조), 내용지우기, 굵게·기울임·밑줄·정렬, 병합·분리, 대각선, 다시 계산, 품명·규격·단위로 지정, 원본 .xls 병합 정보 읽기
- 조달청 표준공사코드 적용(원본 TfrmCodeChange): 7종 탭, 코드변경·검사 색·변경취소·적용(참조 행 포함, 재계산 없음), 코드일괄변경(.estx), 자동변경, 다산코드정리, 사용자정의코드, 검색. 조달청 목록은 사용자가 고른 PPS_*.dat 를 /api/pps-table 로 읽음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:44:54 +09:00

297 lines
14 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-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", "분리"),
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"
? 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.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",
]);
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 {
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 };
})();