feat(est): persist and refresh Excel quantity links

This commit is contained in:
2026-11-07 15:56:04 +09:00
parent aac94d6508
commit fd6e2e6933
7 changed files with 165 additions and 12 deletions
+106 -10
View File
@@ -210,7 +210,15 @@ const EstQuantity = (() => {
}
function selector(state) {
const { esc } = EstApp;
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${(state.project.quantitySheets || []).map((sheet) => `<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`).join("")}</select></label><button data-quantity-new>새 산출서</button><button data-quantity-excel>Excel 셀 수량 반영</button></div>`;
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${(state.project.quantitySheets || []).map((sheet) => `<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`).join("")}</select></label><button data-quantity-new>새 산출서</button><button data-quantity-excel>Excel 셀 수량 연결</button><button data-quantity-excel-links>Excel 연결 관리</button></div>`;
}
function validExcelAddress(address) {
const parts = typeof address === "string" && address.match(/^([A-Z]{1,3})([1-9]\d*)$/);
return Boolean(
parts &&
[...parts[1]].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) <= 16384 &&
Number(parts[2]) <= 1048576,
);
}
function excelCell(files, name, address) {
const parse = (text) => {
@@ -220,13 +228,7 @@ const EstQuantity = (() => {
return doc;
};
address = address.trim().toUpperCase();
const parts = address.match(/^([A-Z]{1,3})([1-9]\d*)$/);
if (
!parts ||
[...parts[1]].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) > 16384 ||
Number(parts[2]) > 1048576
)
throw Error("Excel 셀 주소를 확인하세요. 예: B12");
if (!validExcelAddress(address)) throw Error("Excel 셀 주소를 확인하세요. 예: B12");
const workbook = parse(files["xl/workbook.xml"]),
sheet = [...workbook.getElementsByTagNameNS("*", "sheet")].find(
(s) => s.getAttribute("name") === name,
@@ -256,12 +258,43 @@ const EstQuantity = (() => {
throw Error(`${name} ${address}: 저장된 숫자 값이 없습니다.`);
return Number(value);
}
function excelTarget(row) {
return (
row &&
!Number(row.Level) &&
![51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(Number(row.LinkIndex)) &&
!["%", "%"].includes(row.Unite)
);
}
function excelDialog() {
const { state, esc, showDialog } = EstApp;
const rows = state.project.tables.EstMainDat;
showDialog(
"Excel 셀 수량 반영",
`<div class="form-fields"><label>Excel 파일 <input type="file" accept=".xlsx" id="quantity-excel-file"></label><label>시트 이름 <input id="quantity-excel-sheet"></label><label>셀 주소 <input id="quantity-excel-cell" placeholder="B12"></label><label>내역 행 <select id="quantity-excel-target"><option value="">내역 행 선택</option>${rows.map((row, index) => (!Number(row.Level) && ![51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(Number(row.LinkIndex)) && !["%", "%"].includes(row.Unite) ? `<option value="${index}">${esc(row.Code || "코드 없음")} · ${esc(row.Name || "내역")} · ${index + 1}행</option>` : "")).join("")}</select></label><button data-quantity-excel-apply>수량 반영</button><span id="quantity-excel-error" role="alert"></span></div><p>저장된 숫자 값을 한 번 반영합니다. Excel에서 값이 바뀌면 다시 반영하세요.</p>`,
`<div class="form-fields"><label>Excel 파일 <input type="file" accept=".xlsx" id="quantity-excel-file"></label><label>시트 이름 <input id="quantity-excel-sheet"></label><label>셀 주소 <input id="quantity-excel-cell" placeholder="B12"></label><label>내역 행 <select id="quantity-excel-target"><option value="">내역 행 선택</option>${rows.map((row, index) => (excelTarget(row) ? `<option value="${index}">${esc(row.Code || "코드 없음")} · ${esc(row.Name || "내역")} · ${index + 1}행</option>` : "")).join("")}</select></label><button data-quantity-excel-apply>연결 및 수량 반영</button><span id="quantity-excel-error" role="alert"></span></div><p>파일명·시트·셀 연결은 작업 JSON에 저장합니다. 저장된 숫자 값을 사용하므로 Excel 파일이 바뀌면 연결 관리에서 다시 선택하세요.</p>`,
);
}
function excelUpdates(project, filename, files) {
const links = (project.excelQuantityLinks || []).filter((link) => link.file === filename);
if (!links.length) throw Error("선택한 파일에 연결된 내역이 없습니다.");
return links.map((link) => {
const rows = project.tables.EstMainDat.filter((row) => row.PkID === link.target);
if (rows.length !== 1 || !excelTarget(rows[0]))
throw Error(`연결 내역이 없거나 중복되었거나 상세 행이 아닙니다: ${link.target}`);
return { row: rows[0], value: excelCell(files, link.sheet, link.cell) };
});
}
function excelLinksDialog() {
const { state, esc, showDialog } = EstApp,
links = state.project.excelQuantityLinks || [];
showDialog(
"Excel 수량 연결 관리",
`<p>연결된 파일을 다시 선택하면 해당 파일의 모든 연결을 갱신합니다.</p><div class="form-fields"><label>Excel 파일 <input type="file" accept=".xlsx" id="quantity-excel-refresh-file"></label><button data-quantity-excel-refresh>연결 수량 갱신</button><span id="quantity-excel-links-error" role="alert"></span></div><table class="sheet"><thead><tr><th>파일</th><th>시트</th><th>셀</th><th>내역</th><th>작업</th></tr></thead><tbody>${links
.map((link, index) => {
const row = state.project.tables.EstMainDat.find((r) => r.PkID === link.target);
return `<tr><td>${esc(link.file)}</td><td>${esc(link.sheet)}</td><td>${esc(link.cell)}</td><td>${esc(row?.Name || link.target)}</td><td><button data-quantity-excel-remove="${index}">연결 해제</button></td></tr>`;
})
.join("")}</tbody></table>`,
);
}
function render(state) {
@@ -330,6 +363,50 @@ const EstQuantity = (() => {
excelDialog();
return;
}
if (event.target.closest("[data-quantity-excel-links]")) {
excelLinksDialog();
return;
}
const removeLink = event.target.closest("[data-quantity-excel-remove]");
if (removeLink) {
EstApp.change(
() =>
state.project.excelQuantityLinks.splice(
Number(removeLink.dataset.quantityExcelRemove),
1,
),
false,
);
document.getElementById("dialog").close();
excelLinksDialog();
return;
}
if (event.target.closest("[data-quantity-excel-refresh]")) {
const project = state.project,
revision = state.revision,
input = document.getElementById("quantity-excel-refresh-file"),
file = input.files[0];
(async () => {
if (!file) throw Error("Excel 파일을 선택하세요.");
const files = await EstApp.api("/api/import-xlsx", {
method: "POST",
body: await file.arrayBuffer(),
});
if (
state.project !== project ||
state.revision !== revision ||
document.getElementById("quantity-excel-refresh-file") !== input
)
throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요.");
const updates = excelUpdates(project, file.name, files);
EstApp.change(() => updates.forEach(({ row, value }) => (row.GN = value)));
document.getElementById("dialog").close();
})().catch((error) => {
const output = document.getElementById("quantity-excel-links-error");
if (output) output.textContent = error.message;
});
return;
}
if (event.target.closest("[data-quantity-excel-apply]")) {
const project = state.project,
revision = state.revision,
@@ -341,6 +418,11 @@ const EstQuantity = (() => {
row = selected === "" ? null : project.tables.EstMainDat[Number(selected)];
(async () => {
if (!file || !name || !row) throw Error("파일·시트·셀·내역 행을 선택하세요.");
if (
!Number.isSafeInteger(row.PkID) ||
project.tables.EstMainDat.filter((item) => item.PkID === row.PkID).length !== 1
)
throw Error("내역 행 식별자가 없거나 중복되어 연결할 수 없습니다.");
const files = await EstApp.api("/api/import-xlsx", {
method: "POST",
body: await file.arrayBuffer(),
@@ -353,7 +435,19 @@ const EstQuantity = (() => {
project.tables.EstMainDat[Number(selected)] !== row
)
throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요.");
EstApp.change(() => (row.GN = value));
EstApp.change(() => {
row.GN = value;
project.excelQuantityLinks ||= [];
project.excelQuantityLinks = project.excelQuantityLinks.filter(
(link) => link.target !== row.PkID,
);
project.excelQuantityLinks.push({
target: row.PkID,
file: file.name,
sheet: name,
cell: address.trim().toUpperCase(),
});
});
document.getElementById("dialog").close();
})().catch((error) => {
const output = document.getElementById("quantity-excel-error");
@@ -441,6 +535,8 @@ const EstQuantity = (() => {
allTotals,
applyTotals,
excelCell,
validExcelAddress,
excelUpdates,
targetSelect,
newCode,
selector,