614 lines
29 KiB
JavaScript
614 lines
29 KiB
JavaScript
// WPTools bookmarks retain the original quantity sheet's result identities.
|
||
const EstQuantity = (() => {
|
||
function entities(text) {
|
||
return String(text)
|
||
.replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => {
|
||
const code = n[0].toLowerCase() === "x" ? parseInt(n.slice(1), 16) : Number(n);
|
||
return code <= 0x10ffff ? String.fromCodePoint(code) : "";
|
||
})
|
||
.replace(/"/g, '"')
|
||
.replace(/'|'/g, "'")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/ /g, " ")
|
||
.replace(/&/g, "&");
|
||
}
|
||
function fromWpt(code, name, text, items, options) {
|
||
const tags = new Map(),
|
||
rows = [];
|
||
const superscripts = new Set(
|
||
[...text.matchAll(/<cs\b[^>]*\bnr=(\d+)[^>]*>/gi)]
|
||
.filter((m) => (Number(m[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0)
|
||
.map((m) => m[1]),
|
||
);
|
||
for (const match of text.matchAll(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)) {
|
||
const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1];
|
||
if (name) tags.set(match[1], name);
|
||
}
|
||
for (const match of text.matchAll(/<div\b[^>]*>([\s\S]*?)<\/div>/gi)) {
|
||
const markers = [];
|
||
let raised = false;
|
||
const tag = (name) =>
|
||
/^r\d+$/.test(name) || /^L\d+$/.test(name)
|
||
? "\x12"
|
||
: /^N\d+$/.test(name)
|
||
? "\x0e"
|
||
: /^S\d+$/.test(name)
|
||
? "\x13"
|
||
: "";
|
||
let body = match[1].replace(/<c\s+nr=(\d+)\s*\/>/gi, (_, id) => {
|
||
const next = superscripts.has(id),
|
||
value = next && !raised ? "^(" : !next && raised ? ")" : "";
|
||
raised = next;
|
||
return value;
|
||
});
|
||
if (raised) body += ")";
|
||
body = body
|
||
.replace(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => {
|
||
const name = tags.get(id) || "";
|
||
if (tag(name) === "\x12") markers.push(name);
|
||
return tag(name);
|
||
})
|
||
.replace(/<end\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => tag(tags.get(id) || ""));
|
||
body = entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, ""));
|
||
if (body.trim()) rows.push({ LineData: body, markers });
|
||
}
|
||
return { Code: code, Name: name, rows, items, options };
|
||
}
|
||
function calculate(sheet) {
|
||
const markerErrors = sheet.rows.flatMap((row, index) => {
|
||
const count = [...String(row.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)].length;
|
||
return count === (row.markers || []).length
|
||
? []
|
||
: [`${index + 1}행: 결과 표지 수가 다릅니다.`];
|
||
});
|
||
const input = [
|
||
{ Index: 1, Code: sheet.Code, LineData: "" },
|
||
...sheet.rows.map((row) => {
|
||
const ResultFormats = (row.markers || []).map((name) => {
|
||
const override = name.match(/^[LrG]\d{6}([123])(\d)$/);
|
||
return {
|
||
digits: override ? Number(override[2]) : (sheet.options?.digits ?? 2),
|
||
method: override ? Number(override[1]) : (sheet.options?.roundMode ?? 2),
|
||
};
|
||
});
|
||
const format = ResultFormats[0] || {
|
||
digits: sheet.options?.digits ?? 2,
|
||
method: sheet.options?.roundMode ?? 2,
|
||
};
|
||
return {
|
||
...row,
|
||
LineData: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴/g, ""),
|
||
Index: 0,
|
||
SCheck: 0,
|
||
LinkIndex: 0,
|
||
Precision: format.digits,
|
||
RoundMode: format.method,
|
||
DigetIndex: format.method,
|
||
Diget: format.digits + 1,
|
||
ResultFormats,
|
||
};
|
||
}),
|
||
];
|
||
const result = EstText.calculate(input),
|
||
values = new Map(),
|
||
checks = [];
|
||
result.rows.slice(1).forEach((row, index) => {
|
||
const actual = [...String(row.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)];
|
||
const stored = [...String(sheet.rows[index].LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)];
|
||
(sheet.rows[index].markers || []).forEach((name, part) => {
|
||
if (!actual[part]) return;
|
||
const value = Number(actual[part][1].replaceAll(",", ""));
|
||
values.set(name, value);
|
||
checks.push({
|
||
name,
|
||
stored: Number(stored[part]?.[1].replaceAll(",", "")),
|
||
calculated: value,
|
||
});
|
||
});
|
||
let part = 0;
|
||
row.LineData = sheet.rows[index].LineData.replace(
|
||
/\x12([-\d.,]+)\x12/g,
|
||
(whole) => actual[part++]?.[0] || whole,
|
||
);
|
||
});
|
||
return {
|
||
rows: result.rows.slice(1),
|
||
items: sheet.items.map((item) => ({ ...item, GN: values.get(item.Code) ?? item.GN })),
|
||
checks,
|
||
errors: [...result.errors, ...markerErrors],
|
||
};
|
||
}
|
||
function manual(items) {
|
||
const values = Object.create(null),
|
||
codes = new Set(),
|
||
errors = [];
|
||
const rows = items.map((row) => {
|
||
try {
|
||
if (!row.Code || codes.has(row.Code)) throw Error("산출코드가 비어 있거나 중복됩니다.");
|
||
codes.add(row.Code);
|
||
const GN = EstMath.floating(
|
||
EstExpression.evaluate(row.formula || "0", (key) => values[key]),
|
||
row.digits ?? 2,
|
||
1,
|
||
);
|
||
values[row.Code] = GN;
|
||
return { ...row, GN };
|
||
} catch (error) {
|
||
errors.push(`${row.Code || "수량"}: ${error.message}`);
|
||
return { ...row, GN: null, Error: error.message };
|
||
}
|
||
});
|
||
return { rows, errors };
|
||
}
|
||
function estimateTargets(project) {
|
||
const byKey = new Map(),
|
||
byCode = new Map();
|
||
project.tables.EstMainDat.forEach((row, index) => {
|
||
if (Number(row.Level)) return;
|
||
const add = (map, key) => {
|
||
const entries = map.get(key) || [];
|
||
entries.push(index);
|
||
map.set(key, entries);
|
||
};
|
||
if (row.PkID != null) add(byKey, `id:${row.PkID}`);
|
||
add(byCode, row.Code);
|
||
});
|
||
return { byKey, byCode };
|
||
}
|
||
function totals(project, selected, allowEmpty = false, targets = estimateTargets(project)) {
|
||
let items;
|
||
if (selected && selected !== "manual") {
|
||
const sheet = project.quantitySheets?.find((s) => s.Code === selected);
|
||
if (!sheet) throw Error("선택한 수량산출서가 없습니다.");
|
||
const output = calculate(sheet);
|
||
if (output.errors.length) throw Error(output.errors.join(" / "));
|
||
items = output.items;
|
||
} else {
|
||
const output = manual(project.quantities || []);
|
||
if (output.errors.length) throw Error(output.errors.join(" / "));
|
||
items = output.rows;
|
||
}
|
||
const { byKey, byCode } = targets;
|
||
const quantities = new Map();
|
||
for (const item of items) {
|
||
const code = item.TargetCode ?? item.LinkCode;
|
||
if (!code && !item.TargetKey) continue;
|
||
const targets = (item.TargetKey ? byKey.get(item.TargetKey) : byCode.get(code)) || [];
|
||
if (!targets.length) throw Error(`연결 내역 없음: ${item.TargetKey || code}`);
|
||
if (targets.length > 1) throw Error(`연결 내역 중복: ${item.TargetKey || code}`);
|
||
const index = targets[0];
|
||
quantities.set(index, (quantities.get(index) || 0) + Number(item.GN || 0));
|
||
}
|
||
if (!quantities.size && !allowEmpty) throw Error("연결할 내역코드를 먼저 입력하세요.");
|
||
return quantities;
|
||
}
|
||
function allTotals(project) {
|
||
const combined = new Map();
|
||
const codes = (project.quantitySheets || []).map((sheet) => sheet.Code);
|
||
if (
|
||
codes.some((code) => typeof code !== "string" || !code || code === "manual") ||
|
||
new Set(codes).size !== codes.length
|
||
)
|
||
throw Error("수량산출서 코드가 없거나 중복됩니다.");
|
||
const targets = estimateTargets(project);
|
||
for (const selected of ["manual", ...codes])
|
||
for (const [index, value] of totals(project, selected, true, targets))
|
||
combined.set(index, (combined.get(index) || 0) + value);
|
||
if (!combined.size) throw Error("연결할 내역코드를 먼저 입력하세요.");
|
||
return combined;
|
||
}
|
||
function applyTotals(project, quantities) {
|
||
for (const [index, value] of quantities) project.tables.EstMainDat[index].GN = value;
|
||
}
|
||
function targetSelect(project, item, section, index) {
|
||
const { esc } = EstApp;
|
||
const selected = item.TargetKey
|
||
? `<option value="${esc(item.TargetKey)}" selected>${esc(item.TargetCode || "연결 내역")} · ${esc(item.TargetKey)}</option>`
|
||
: "";
|
||
return `<select data-quantity-select="${section}" data-index="${index}" aria-label="연결 내역 행"><option value="">내역 행 선택</option>${selected}</select>`;
|
||
}
|
||
function newCode(items) {
|
||
const codes = new Set(items.map((row) => row.Code));
|
||
for (const row of items)
|
||
for (const match of String(row.formula || "").matchAll(/\[([^\]]+)\]/g)) codes.add(match[1]);
|
||
let index = 1;
|
||
while (codes.has(`Q${index}`)) index++;
|
||
return `Q${index}`;
|
||
}
|
||
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><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) => {
|
||
if (!text || /<!DOCTYPE|<!ENTITY/i.test(text)) throw Error("Excel XML 형식 오류");
|
||
const doc = new DOMParser().parseFromString(text, "application/xml");
|
||
if (doc.querySelector("parsererror")) throw Error("Excel XML 형식 오류");
|
||
return doc;
|
||
};
|
||
address = address.trim().toUpperCase();
|
||
if (!validExcelAddress(address)) throw Error("Excel 셀 주소를 확인하세요. 예: B12");
|
||
const workbook = parse(files["xl/workbook.xml"]),
|
||
sheet = [...workbook.getElementsByTagNameNS("*", "sheet")].find(
|
||
(s) => s.getAttribute("name") === name,
|
||
);
|
||
if (!sheet) throw Error(`Excel 시트가 없습니다: ${name}`);
|
||
const id = sheet.getAttributeNS(
|
||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||
"id",
|
||
),
|
||
rels = parse(files["xl/_rels/workbook.xml.rels"]),
|
||
rel = [...rels.getElementsByTagNameNS("*", "Relationship")].find(
|
||
(r) => r.getAttribute("Id") === id,
|
||
),
|
||
target = rel?.getAttribute("Target") || "",
|
||
path = target.startsWith("/") ? target.slice(1) : `xl/${target}`;
|
||
const doc = parse(files[path]),
|
||
cell = [...doc.getElementsByTagNameNS("*", "c")].find((c) => c.getAttribute("r") === address);
|
||
if (!cell || (cell.getAttribute("t") && cell.getAttribute("t") !== "n"))
|
||
throw Error(`${name} ${address}: 숫자 셀이 아닙니다.`);
|
||
const value = cell.getElementsByTagNameNS("*", "v")[0]?.textContent;
|
||
if (
|
||
value == null ||
|
||
!value.trim() ||
|
||
!Number.isFinite(Number(value)) ||
|
||
Math.abs(Number(value)) > Number.MAX_SAFE_INTEGER
|
||
)
|
||
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) => (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) {
|
||
state.quantitySheet ||= state.project.quantitySheets[0]?.Code;
|
||
const sheet =
|
||
state.project.quantitySheets.find((s) => s.Code === state.quantitySheet) ||
|
||
state.project.quantitySheets[0];
|
||
state.quantitySheet = sheet.Code;
|
||
const output = calculate(sheet),
|
||
{ esc, number, byId } = EstApp;
|
||
byId("work-content").innerHTML =
|
||
selector(state) +
|
||
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
||
}
|
||
function install() {
|
||
document.addEventListener("focusin", (event) => {
|
||
const node = event.target;
|
||
if (node.dataset.quantitySelect === undefined || node.dataset.quantityOptionsLoaded) return;
|
||
const value = node.value;
|
||
const { esc, state } = EstApp;
|
||
node.innerHTML =
|
||
'<option value="">내역 행 선택</option>' +
|
||
state.project.tables.EstMainDat.filter((row) => row.PkID != null && !Number(row.Level))
|
||
.map((row) => {
|
||
const key = `id:${row.PkID}`;
|
||
return `<option value="${esc(key)}">${esc(row.Name || "내역")} · ${esc(row.Code || "코드 없음")} · ${esc(row.PkID)}</option>`;
|
||
})
|
||
.join("");
|
||
node.value = value;
|
||
node.dataset.quantityOptionsLoaded = "1";
|
||
});
|
||
document.addEventListener("change", (event) => {
|
||
const node = event.target,
|
||
{ state } = EstApp;
|
||
if (node.id === "quantity-sheet-choice") {
|
||
state.quantitySheet = node.value;
|
||
EstApp.render();
|
||
return;
|
||
}
|
||
if (node.dataset.quantitySelect !== undefined) {
|
||
const item =
|
||
node.dataset.quantitySelect === "manual"
|
||
? state.project.quantities[Number(node.dataset.index)]
|
||
: state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet)?.items[
|
||
Number(node.dataset.index)
|
||
];
|
||
if (!item) return;
|
||
const row = state.project.tables.EstMainDat.find(
|
||
(r) => r.PkID != null && `id:${r.PkID}` === node.value && !Number(r.Level),
|
||
);
|
||
EstApp.change(() => {
|
||
item.TargetKey = row ? node.value : "";
|
||
item.TargetCode = row?.Code || "";
|
||
}, false);
|
||
return;
|
||
}
|
||
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
|
||
if (!sheet) return;
|
||
if (node.dataset.quantityName !== undefined)
|
||
EstApp.change(() => (sheet.Name = node.value), false);
|
||
if (["Name", "Size", "Unite"].includes(node.dataset.quantityItem))
|
||
EstApp.change(
|
||
() => (sheet.items[Number(node.dataset.index)][node.dataset.quantityItem] = node.value),
|
||
false,
|
||
);
|
||
if (node.dataset.quantityLine !== undefined)
|
||
EstApp.change(
|
||
() =>
|
||
(sheet.rows[Number(node.dataset.quantityLine)].LineData = EstText.encode(node.value)),
|
||
false,
|
||
);
|
||
if (node.dataset.quantityTarget !== undefined)
|
||
EstApp.change(() => {
|
||
const item = sheet.items[Number(node.dataset.quantityTarget)];
|
||
item.TargetCode = node.value;
|
||
item.TargetKey = "";
|
||
}, false);
|
||
});
|
||
document.addEventListener("click", (event) => {
|
||
const { state } = EstApp;
|
||
try {
|
||
if (event.target.closest("[data-quantity-excel]")) {
|
||
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 ||
|
||
input.files[0] !== file
|
||
)
|
||
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,
|
||
fileInput = document.getElementById("quantity-excel-file"),
|
||
file = fileInput.files[0],
|
||
name = document.getElementById("quantity-excel-sheet").value.trim(),
|
||
address = document.getElementById("quantity-excel-cell").value,
|
||
selected = document.getElementById("quantity-excel-target").value,
|
||
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(),
|
||
}),
|
||
value = excelCell(files, name, address);
|
||
if (
|
||
state.project !== project ||
|
||
state.revision !== revision ||
|
||
document.getElementById("quantity-excel-file") !== fileInput ||
|
||
fileInput.files[0] !== file ||
|
||
document.getElementById("quantity-excel-sheet")?.value.trim() !== name ||
|
||
document.getElementById("quantity-excel-cell")?.value !== address ||
|
||
document.getElementById("quantity-excel-target")?.value !== selected ||
|
||
project.tables.EstMainDat[Number(selected)] !== row
|
||
)
|
||
throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요.");
|
||
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");
|
||
if (output) output.textContent = error.message;
|
||
});
|
||
return;
|
||
}
|
||
if (event.target.closest("[data-quantity-new]")) {
|
||
EstApp.change(() => {
|
||
const code = `Q${EstApp.uid()}`;
|
||
state.project.quantitySheets ||= [];
|
||
state.project.quantitySheets.push({
|
||
Code: code,
|
||
Name: "새 수량산출서",
|
||
rows: [],
|
||
items: [],
|
||
options: { digits: 2, roundMode: 2 },
|
||
});
|
||
state.quantitySheet = code;
|
||
}, false);
|
||
return;
|
||
}
|
||
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
|
||
const addItem = event.target.closest("[data-quantity-add-item]");
|
||
if (addItem && sheet) {
|
||
EstApp.change(() => {
|
||
const codes = new Set(sheet.items.map((it) => it.Code));
|
||
let index = 0;
|
||
while (codes.has(`r${String(index).padStart(6, "0")}`)) index++;
|
||
const code = `r${String(index).padStart(6, "0")}`;
|
||
sheet.items.push({
|
||
Code: code,
|
||
Name: "새 수량항목",
|
||
Size: "",
|
||
Unite: "",
|
||
GN: 0,
|
||
TargetCode: "",
|
||
});
|
||
}, false);
|
||
return;
|
||
}
|
||
const deleteItem = event.target.closest("[data-quantity-delete-item]");
|
||
if (deleteItem && sheet) {
|
||
const itemIdx = Number(deleteItem.dataset.quantityDeleteItem);
|
||
const targetItem = sheet.items[itemIdx];
|
||
if (!targetItem) return;
|
||
const code = targetItem.Code;
|
||
const referringRows = sheet.rows.filter((row) => (row.markers || []).includes(code));
|
||
if (referringRows.length > 0) {
|
||
throw Error(
|
||
`산출 행에서 참조 중인 수량 항목(${targetItem.Name || code})은 직접 삭제할 수 없습니다. 연결된 산출 행을 삭제하거나, 미참조 항목만 삭제할 수 있습니다.`,
|
||
);
|
||
}
|
||
if (confirm("이 수량 항목을 삭제하시겠습니까?"))
|
||
EstApp.change(() => {
|
||
sheet.items.splice(itemIdx, 1);
|
||
}, false);
|
||
return;
|
||
}
|
||
const addRow = event.target.closest("[data-quantity-add]");
|
||
if (addRow && sheet) {
|
||
EstApp.change(() => {
|
||
const code = `r${EstApp.uid()}`;
|
||
const at =
|
||
addRow.dataset.index === undefined
|
||
? sheet.rows.length
|
||
: Number(addRow.dataset.index) + 1;
|
||
sheet.rows.splice(at, 0, { LineData: "1 = \x120\x12", markers: [code] });
|
||
sheet.items.push({ Code: code, Name: "새 항목", Size: "", GN: 0, TargetCode: "" });
|
||
}, false);
|
||
return;
|
||
}
|
||
const moveRow = event.target.closest("[data-quantity-move]");
|
||
if (moveRow && sheet) {
|
||
const index = Number(moveRow.dataset.index),
|
||
next = index + Number(moveRow.dataset.quantityMove);
|
||
if (index >= 0 && index < sheet.rows.length && next >= 0 && next < sheet.rows.length)
|
||
EstApp.change(() => {
|
||
[sheet.rows[index], sheet.rows[next]] = [sheet.rows[next], sheet.rows[index]];
|
||
}, false);
|
||
return;
|
||
}
|
||
const deleteRow = event.target.closest("[data-quantity-delete-row]");
|
||
if (deleteRow && sheet) {
|
||
if (confirm("이 산출 행과 연결된 수량 항목을 삭제하시겠습니까?"))
|
||
EstApp.change(() => {
|
||
const [removed] = sheet.rows.splice(Number(deleteRow.dataset.quantityDeleteRow), 1);
|
||
const remaining = new Set(sheet.rows.flatMap((row) => row.markers || []));
|
||
sheet.items = sheet.items.filter(
|
||
(item) => !removed.markers.includes(item.Code) || remaining.has(item.Code),
|
||
);
|
||
}, false);
|
||
return;
|
||
}
|
||
if (event.target.closest("[data-quantity-delete-sheet]") && sheet) {
|
||
if (confirm("이 수량산출서를 삭제하시겠습니까?"))
|
||
EstApp.change(() => {
|
||
state.project.quantitySheets = state.project.quantitySheets.filter(
|
||
(item) => item !== sheet,
|
||
);
|
||
state.quantitySheet = state.project.quantitySheets[0]?.Code || "manual";
|
||
}, false);
|
||
return;
|
||
}
|
||
if (!event.target.closest("[data-quantity-apply]")) return;
|
||
const quantities = totals(state.project, state.quantitySheet);
|
||
EstApp.change(() => applyTotals(state.project, quantities));
|
||
} catch (error) {
|
||
EstApp.state.notice = error.message;
|
||
EstApp.render();
|
||
}
|
||
});
|
||
}
|
||
return {
|
||
entities,
|
||
fromWpt,
|
||
calculate,
|
||
manual,
|
||
totals,
|
||
allTotals,
|
||
applyTotals,
|
||
excelCell,
|
||
excelTarget,
|
||
validExcelAddress,
|
||
excelUpdates,
|
||
targetSelect,
|
||
newCode,
|
||
selector,
|
||
render,
|
||
install,
|
||
};
|
||
})();
|
||
if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity;
|