652 lines
26 KiB
JavaScript
652 lines
26 KiB
JavaScript
const EstExchange = (() => {
|
|
const decodeExcelText = (value) =>
|
|
value.replace(/_x([\da-f]{4})_/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
function xmlText(buffer) {
|
|
const bytes = new Uint8Array(buffer);
|
|
if (bytes[0] === 0xff && bytes[1] === 0xfe)
|
|
return new TextDecoder("utf-16le", { fatal: true }).decode(bytes);
|
|
if (bytes[0] === 0xfe && bytes[1] === 0xff)
|
|
return new TextDecoder("utf-16be", { fatal: true }).decode(bytes);
|
|
const header = new TextDecoder("ascii").decode(bytes.slice(0, 250)),
|
|
declared = header.match(/encoding\s*=\s*["']([^"']+)["']/i)?.[1]?.toLowerCase() || "utf-8",
|
|
encoding = ["cp949", "ks_c_5601-1987", "euc-kr", "ms949"].includes(declared)
|
|
? "euc-kr"
|
|
: declared;
|
|
if (!["utf-8", "utf8", "euc-kr", "utf-16", "utf-16le", "utf-16be"].includes(encoding))
|
|
throw Error("지원하지 않는 XML 인코딩: " + encoding);
|
|
return new TextDecoder(encoding, { fatal: true }).decode(bytes);
|
|
}
|
|
function parse(source) {
|
|
if (/<!DOCTYPE|<!ENTITY/i.test(source)) throw Error("XML 외부 선언은 허용하지 않습니다.");
|
|
const doc = new DOMParser().parseFromString(source, "application/xml");
|
|
if (doc.querySelector("parsererror")) throw Error("XML 구문 오류");
|
|
return doc;
|
|
}
|
|
function read(files) {
|
|
const workbook = parse(files["xl/workbook.xml"]),
|
|
rels = parse(files["xl/_rels/workbook.xml.rels"]);
|
|
const shared = files["xl/sharedStrings.xml"]
|
|
? [...parse(files["xl/sharedStrings.xml"]).getElementsByTagNameNS("*", "si")].map((si) =>
|
|
[...si.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join(""),
|
|
)
|
|
: [];
|
|
const tables = {};
|
|
for (const sheet of workbook.getElementsByTagNameNS("*", "sheet")) {
|
|
const name = sheet.getAttribute("name");
|
|
if (
|
|
(!EstNative.empty().tables[name] &&
|
|
![
|
|
"Quantities",
|
|
"Schedule",
|
|
"ExcelQuantityLinks",
|
|
"QDocuments",
|
|
"QLines",
|
|
"QItems",
|
|
"Review",
|
|
"Annual",
|
|
"ProgressDrafts",
|
|
"ProgressInputs",
|
|
"Escalation",
|
|
"EscalationSettings",
|
|
].includes(name)) ||
|
|
name.startsWith("Cad")
|
|
)
|
|
continue;
|
|
const id = sheet.getAttributeNS(
|
|
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
|
"id",
|
|
),
|
|
rel = [...rels.getElementsByTagNameNS("*", "Relationship")].find(
|
|
(r) => r.getAttribute("Id") === id,
|
|
),
|
|
target = rel?.getAttribute("Target") || "";
|
|
const path = target.startsWith("/") ? target.slice(1) : "xl/" + target;
|
|
if (!files[path]) throw Error(`Excel 시트 파일 없음: ${name}`);
|
|
const rows = [...parse(files[path]).getElementsByTagNameNS("*", "row")]
|
|
.map((row) => {
|
|
const values = [];
|
|
for (const cell of row.getElementsByTagNameNS("*", "c")) {
|
|
const address = cell.getAttribute("r") || "",
|
|
letters = address.match(/^[A-Z]+/)?.[0];
|
|
if (!letters) throw Error("Excel 셀 주소 오류");
|
|
const at = [...letters].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
|
|
if (at >= 16384) throw Error("Excel 열 범위 오류");
|
|
if (cell.getElementsByTagNameNS("*", "f").length)
|
|
throw Error(`${name} ${address}: 수식은 Excel에서 값으로 붙여넣은 뒤 가져오세요.`);
|
|
const type = cell.getAttribute("t"),
|
|
v = cell.getElementsByTagNameNS("*", "v")[0]?.textContent;
|
|
let value;
|
|
if (type === "s") {
|
|
value = shared[Number(v)];
|
|
if (value == null) throw Error("Excel 공유 문자열 오류");
|
|
} else if (type === "inlineStr")
|
|
value = [...cell.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join("");
|
|
else if (type === "b") value = v === "1";
|
|
else if (type === "e") throw Error(`${name} ${address}: Excel 오류값`);
|
|
else if (type === "str") value = v || "";
|
|
else if (v != null) {
|
|
value = Number(v);
|
|
if (!Number.isFinite(value)) throw Error("Excel 숫자 오류");
|
|
}
|
|
values[at] = typeof value === "string" ? decodeExcelText(value) : value;
|
|
}
|
|
return values;
|
|
})
|
|
.filter((row) => row.some((v) => v != null));
|
|
const headers = rows.shift() || [];
|
|
if (
|
|
!headers.length ||
|
|
headers.some(
|
|
(k) =>
|
|
typeof k !== "string" ||
|
|
!/^[A-Za-z][A-Za-z0-9_]*$/.test(k) ||
|
|
["constructor", "prototype"].includes(k),
|
|
) ||
|
|
new Set(headers).size !== headers.length
|
|
)
|
|
throw Error(`${name}: 필드명 행이 올바르지 않습니다.`);
|
|
tables[name] = rows.map((values) =>
|
|
Object.fromEntries(headers.map((k, i) => [k, values[i] ?? null])),
|
|
);
|
|
}
|
|
if (!Object.keys(tables).length)
|
|
throw Error("가져올 EST 표 이름이 없습니다. 이 앱에서 내보낸 Excel 표를 사용하세요.");
|
|
return tables;
|
|
}
|
|
function quantitySheets(project, tables) {
|
|
const names = ["QDocuments", "QLines", "QItems"];
|
|
if (!names.some((name) => Object.hasOwn(tables, name))) return null;
|
|
if (!names.every((name) => Object.hasOwn(tables, name)))
|
|
throw Error("수량산출서는 QDocuments·QLines·QItems 시트가 모두 필요합니다.");
|
|
const old = new Map((project.quantitySheets || []).map((sheet) => [sheet.Code, sheet]));
|
|
const docs = new Map();
|
|
for (const row of tables.QDocuments) {
|
|
if (typeof row.Code !== "string" || !row.Code || docs.has(row.Code))
|
|
throw Error("수량산출서 코드가 비어 있거나 중복됩니다.");
|
|
if (row.Name != null && typeof row.Name !== "string") throw Error("수량산출서 이름 오류");
|
|
const digits = row.digits ?? 2,
|
|
roundMode = row.roundMode ?? 2;
|
|
if (
|
|
!Number.isInteger(digits) ||
|
|
digits < 0 ||
|
|
digits > 12 ||
|
|
!Number.isInteger(roundMode) ||
|
|
roundMode < 0 ||
|
|
roundMode > 4
|
|
)
|
|
throw Error("수량산출서 자릿수·반올림 방식 오류");
|
|
docs.set(row.Code, {
|
|
...old.get(row.Code),
|
|
Code: row.Code,
|
|
Name: row.Name ?? "",
|
|
rows: [],
|
|
items: [],
|
|
options: { ...old.get(row.Code)?.options, digits, roundMode },
|
|
});
|
|
}
|
|
function grouped(rows) {
|
|
const groups = new Map(),
|
|
positions = new Set();
|
|
for (const row of rows) {
|
|
if (!docs.has(row.DocumentCode)) throw Error(`수량산출서 코드 없음: ${row.DocumentCode}`);
|
|
if (!Number.isInteger(row.Position) || row.Position < 1)
|
|
throw Error("수량산출서 행 순서 오류");
|
|
const key = JSON.stringify([row.DocumentCode, row.Position]);
|
|
if (positions.has(key)) throw Error("수량산출서 행 순서 중복");
|
|
positions.add(key);
|
|
if (!groups.has(row.DocumentCode)) groups.set(row.DocumentCode, []);
|
|
groups.get(row.DocumentCode).push(row);
|
|
}
|
|
for (const group of groups.values()) group.sort((a, b) => a.Position - b.Position);
|
|
return groups;
|
|
}
|
|
const lines = grouped(tables.QLines),
|
|
items = grouped(tables.QItems);
|
|
for (const [code, doc] of docs) {
|
|
doc.rows = (lines.get(code) || []).map((row) => {
|
|
if (row.LineData != null && typeof row.LineData !== "string")
|
|
throw Error("산출근거 문자 오류");
|
|
let markers;
|
|
try {
|
|
markers = JSON.parse(row.Markers || "[]");
|
|
} catch {
|
|
throw Error("수량 결과 표지 형식 오류");
|
|
}
|
|
if (!Array.isArray(markers) || markers.some((value) => typeof value !== "string"))
|
|
throw Error("수량 결과 표지 형식 오류");
|
|
return { LineData: row.LineData ?? "", markers };
|
|
});
|
|
const prior = new Map((old.get(code)?.items || []).map((item) => [item.Code, item]));
|
|
const seen = new Set();
|
|
doc.items = (items.get(code) || []).map((row) => {
|
|
if (typeof row.Code !== "string" || !row.Code || seen.has(row.Code))
|
|
throw Error("수량 항목 코드가 비어 있거나 중복됩니다.");
|
|
seen.add(row.Code);
|
|
for (const key of ["Name", "Size", "Unite", "TargetCode", "TargetKey"])
|
|
if (row[key] != null && typeof row[key] !== "string")
|
|
throw Error(`수량 항목 ${key} 형식 오류`);
|
|
const previous = prior.get(row.Code),
|
|
next = { ...previous, Code: row.Code };
|
|
for (const key of ["Name", "Size", "Unite"]) {
|
|
if (row[key] != null) next[key] = row[key];
|
|
else if (!previous || previous[key] != null) next[key] = "";
|
|
}
|
|
if (row.GN != null) {
|
|
if (typeof row.GN !== "number" || !Number.isFinite(row.GN))
|
|
throw Error("수량 항목 수치 오류");
|
|
next.GN = row.GN;
|
|
} else if (!previous || previous.GN != null) next.GN = 0;
|
|
if (row.TargetCode != null) {
|
|
if (
|
|
!previous ||
|
|
Object.hasOwn(previous, "TargetCode") ||
|
|
row.TargetCode !== previous.LinkCode
|
|
)
|
|
next.TargetCode = row.TargetCode;
|
|
} else if (previous && (previous.TargetCode ?? previous.LinkCode)) next.TargetCode = "";
|
|
if (row.TargetKey != null) next.TargetKey = row.TargetKey;
|
|
else if (previous?.TargetKey) next.TargetKey = "";
|
|
return next;
|
|
});
|
|
}
|
|
return [...docs.values()];
|
|
}
|
|
function apply(project, tables) {
|
|
const next = structuredClone(project);
|
|
const sheets = quantitySheets(next, tables);
|
|
if (sheets) next.quantitySheets = sheets;
|
|
if (Object.hasOwn(tables, "ProgressDrafts") || Object.hasOwn(tables, "ProgressInputs")) {
|
|
if (!Object.hasOwn(tables, "ProgressDrafts") || !Object.hasOwn(tables, "ProgressInputs"))
|
|
throw Error("기성 Excel에는 ProgressDrafts와 ProgressInputs 시트가 모두 필요합니다.");
|
|
const old = next.progress || [],
|
|
locked = old.filter((round) => round.locked);
|
|
if (old.slice(0, locked.length).some((round) => !round.locked))
|
|
throw Error("확정 기성 회차 순서 오류");
|
|
const previous = new Map(
|
|
old.filter((round) => !round.locked).map((round) => [round.id, round]),
|
|
);
|
|
const ids = new Set(locked.map((round) => round.id)),
|
|
drafts = new Map();
|
|
for (const row of tables.ProgressDrafts) {
|
|
if (typeof row.id !== "string" || !row.id || ids.has(row.id))
|
|
throw Error("기성 회차 ID가 없거나 중복됩니다.");
|
|
ids.add(row.id);
|
|
const round = {
|
|
...previous.get(row.id),
|
|
id: row.id,
|
|
name: row.name ?? "",
|
|
locked: false,
|
|
approved: row.approved ?? false,
|
|
separateComponents: row.separateComponents ?? true,
|
|
inputs: Object.create(null),
|
|
};
|
|
delete round.contract;
|
|
delete round.result;
|
|
if (row.amountDigits == null) delete round.amountDigits;
|
|
else round.amountDigits = row.amountDigits;
|
|
drafts.set(row.id, round);
|
|
}
|
|
const inputFields = [
|
|
"GumMode",
|
|
"Gum",
|
|
"GumNC",
|
|
"GumGC",
|
|
"GumJC",
|
|
"GumSC",
|
|
"SaMode",
|
|
"Sa",
|
|
"SaNC",
|
|
"SaGC",
|
|
"SaJC",
|
|
"SaSC",
|
|
"QuantityDigits",
|
|
"AmountDigits",
|
|
];
|
|
for (const row of tables.ProgressInputs) {
|
|
const round = drafts.get(row.RoundId);
|
|
if (
|
|
!round ||
|
|
typeof row.RowKey !== "string" ||
|
|
!row.RowKey ||
|
|
Object.hasOwn(round.inputs, row.RowKey)
|
|
)
|
|
throw Error("기성 입력의 회차·행 식별자가 없거나 중복됩니다.");
|
|
const prior = previous.get(row.RoundId)?.inputs?.[row.RowKey] || {};
|
|
const input = Object.fromEntries(
|
|
Object.entries(prior).filter(([key]) => !inputFields.includes(key)),
|
|
);
|
|
for (const field of inputFields) if (row[field] != null) input[field] = row[field];
|
|
round.inputs[row.RowKey] = input;
|
|
}
|
|
next.progress = [...locked, ...drafts.values()];
|
|
}
|
|
for (const [table, rows] of Object.entries(tables)) {
|
|
if (
|
|
[
|
|
"QDocuments",
|
|
"QLines",
|
|
"QItems",
|
|
"ProgressDrafts",
|
|
"ProgressInputs",
|
|
"Escalation",
|
|
"EscalationSettings",
|
|
"ExcelQuantityLinks",
|
|
].includes(table)
|
|
)
|
|
continue;
|
|
if (table === "Annual") {
|
|
next.annual = rows.map((row) => {
|
|
if (typeof row.name !== "string" || typeof row.formula !== "string")
|
|
throw Error("연차 이름·수식 형식 오류");
|
|
return { name: row.name, formula: row.formula };
|
|
});
|
|
continue;
|
|
}
|
|
if (table === "Review") {
|
|
const edits = {};
|
|
for (const row of rows) {
|
|
if (
|
|
typeof row.RowKey !== "string" ||
|
|
!row.RowKey.startsWith("row:") ||
|
|
Object.hasOwn(edits, row.RowKey)
|
|
)
|
|
throw Error("계약심사 내역 식별자가 없거나 중복됩니다.");
|
|
const edit = {};
|
|
for (const field of ["GN", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC", "Reason"])
|
|
if (row[field] != null) edit[field] = row[field];
|
|
edits[row.RowKey] = edit;
|
|
}
|
|
next.contractReview = { edits };
|
|
EstReview.validate(next);
|
|
continue;
|
|
}
|
|
if (table === "Quantities") {
|
|
const seen = new Set();
|
|
next.quantities = rows.map((row) => {
|
|
if (typeof row.Code !== "string" || !row.Code || seen.has(row.Code))
|
|
throw Error("수식표 코드가 비어 있거나 중복됩니다.");
|
|
seen.add(row.Code);
|
|
for (const key of ["Name", "TargetCode", "TargetKey", "formula"])
|
|
if (row[key] != null && typeof row[key] !== "string")
|
|
throw Error(`수식표 ${key} 형식 오류`);
|
|
const digits = row.digits ?? 2;
|
|
if (!Number.isInteger(digits) || digits < 0 || digits > 6)
|
|
throw Error("수식표 소수자리는 0~6입니다.");
|
|
return {
|
|
Code: row.Code,
|
|
Name: row.Name ?? "",
|
|
TargetCode: row.TargetCode ?? "",
|
|
TargetKey: row.TargetKey ?? "",
|
|
formula: row.formula ?? "0",
|
|
digits,
|
|
};
|
|
});
|
|
continue;
|
|
}
|
|
if (table === "Schedule") {
|
|
next.schedule = rows.map((row) => ({
|
|
id: row.id,
|
|
SourceKey: row.SourceKey ?? "",
|
|
Code: row.Code ?? "",
|
|
Name: row.Name ?? "",
|
|
start: row.start ?? "",
|
|
end: row.end ?? "",
|
|
weight: row.weight ?? 0,
|
|
progress: row.progress ?? 0,
|
|
}));
|
|
EstSchedule.validate(next);
|
|
continue;
|
|
}
|
|
const old = next.tables[table],
|
|
byId = new Map(old.filter((r) => r.PkID != null).map((r) => [r.PkID, r]));
|
|
const keys = rows[0] ? Object.keys(rows[0]) : [];
|
|
if (
|
|
!keys.includes("PkID") &&
|
|
old.some((r) => Object.values(r).some((v) => v && typeof v === "object"))
|
|
)
|
|
throw Error(`${table}: 바이너리를 보존하려면 PkID 열이 필요합니다.`);
|
|
const seen = new Set();
|
|
next.tables[table] = rows.map((row) => {
|
|
if (row.PkID != null) {
|
|
if (seen.has(row.PkID)) throw Error(`${table}: PkID 중복`);
|
|
seen.add(row.PkID);
|
|
}
|
|
const previous = byId.get(row.PkID);
|
|
// Keep original blobs and fields deliberately omitted from the workbook.
|
|
const merged = { ...previous, ...row };
|
|
if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(table)) {
|
|
if (
|
|
previous &&
|
|
row.TC !== previous.TC &&
|
|
(row.InputTC ?? previous.InputTC) === previous.InputTC
|
|
) {
|
|
merged.InputTC = row.TC;
|
|
merged.ValueIndex = 4;
|
|
}
|
|
for (const component of ["NC", "GC", "JC", "SC"])
|
|
if (
|
|
previous &&
|
|
row[component] !== previous[component] &&
|
|
(row["Input" + component] ?? previous["Input" + component]) ===
|
|
previous["Input" + component]
|
|
)
|
|
merged["Input" + component] = row[component];
|
|
if (
|
|
!previous ||
|
|
Object.keys(row).some((key) => (row[key] ?? null) !== (previous[key] ?? null))
|
|
)
|
|
next.options.SourceRefresh = true;
|
|
}
|
|
for (const key of keys)
|
|
if (previous?.[key] && typeof previous[key] === "object") merged[key] = previous[key];
|
|
if (Object.hasOwn(row, "Code") && (previous?.Code ?? "") !== (row.Code ?? "") && previous)
|
|
throw Error("Excel에서 기존 코드를 바꿀 수 없습니다. 앱의 코드 편집을 사용하세요.");
|
|
return merged;
|
|
});
|
|
}
|
|
if (Object.hasOwn(tables, "Escalation")) {
|
|
const targets = new Map();
|
|
next.tables.EstMainDat.forEach((row, index) => {
|
|
if (
|
|
Number(row.Level || 0) > 0 ||
|
|
[54, 55, 57, 58, 59, 62, 67].includes(Number(row.LinkIndex || 0))
|
|
)
|
|
return;
|
|
const key = EstBusiness.rowKey(row, index),
|
|
matches = targets.get(key) || [];
|
|
matches.push(row);
|
|
targets.set(key, matches);
|
|
});
|
|
const seen = new Set();
|
|
const fields = [
|
|
"ANU",
|
|
"AGU",
|
|
"AJU",
|
|
"ASU",
|
|
"EscPlanned",
|
|
"EscActual",
|
|
"EscProgress",
|
|
"EscAuto",
|
|
"EscManualQuantity",
|
|
"EscConstruction",
|
|
"EscPS",
|
|
"EscBefore",
|
|
];
|
|
for (const row of tables.Escalation) {
|
|
const matches = targets.get(row.RowKey) || [];
|
|
if (typeof row.RowKey !== "string" || seen.has(row.RowKey) || matches.length !== 1)
|
|
throw Error(`물가변동 내역이 없거나 중복됩니다: ${row.RowKey}`);
|
|
seen.add(row.RowKey);
|
|
for (const field of fields) {
|
|
if (!Object.hasOwn(row, field)) continue;
|
|
if (row[field] == null) delete matches[0][field];
|
|
else {
|
|
if (
|
|
field === "EscAuto"
|
|
? typeof row[field] !== "boolean"
|
|
: typeof row[field] !== "number" || !Number.isFinite(row[field])
|
|
)
|
|
throw Error(`물가변동 ${field} 형식 오류`);
|
|
matches[0][field] = row[field];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (Object.hasOwn(tables, "ExcelQuantityLinks")) {
|
|
next.excelQuantityLinks = tables.ExcelQuantityLinks.map((row) => ({
|
|
target: row.target,
|
|
file: row.file,
|
|
sheet: row.sheet,
|
|
cell: row.cell,
|
|
}));
|
|
EstModel.validate(next);
|
|
for (const link of next.excelQuantityLinks) {
|
|
const rows = next.tables.EstMainDat.filter((row) => row.PkID === link.target);
|
|
if (rows.length !== 1 || !EstQuantity.excelTarget(rows[0]))
|
|
throw Error(`Excel 연결 내역이 없거나 중복되었거나 상세 행이 아닙니다: ${link.target}`);
|
|
}
|
|
}
|
|
if (Object.hasOwn(tables, "EstMainDat"))
|
|
for (const link of project.excelQuantityLinks || [])
|
|
if (
|
|
(next.excelQuantityLinks || []).some((item) => item.target === link.target) &&
|
|
!project.tables.EstMainDat.some((row) => row.PkID === link.target) &&
|
|
next.tables.EstMainDat.some((row) => row.PkID === link.target)
|
|
)
|
|
throw Error(`삭제된 Excel 연결 내역의 식별자를 다시 사용할 수 없습니다: ${link.target}`);
|
|
if (Object.hasOwn(tables, "EscalationSettings")) {
|
|
const rows = tables.EscalationSettings;
|
|
if (
|
|
rows.length !== 1 ||
|
|
typeof rows[0].EscDesignTotal !== "number" ||
|
|
!Number.isFinite(rows[0].EscDesignTotal) ||
|
|
rows[0].EscDesignTotal < 0
|
|
)
|
|
throw Error("물가변동 조사 도급액 형식 오류");
|
|
next.options.EscDesignTotal = rows[0].EscDesignTotal;
|
|
}
|
|
if (Object.hasOwn(tables, "Review")) {
|
|
const targets = new Map();
|
|
next.tables.EstMainDat.forEach((row, index) => {
|
|
if (!EstReview.editable(row)) return;
|
|
const key = EstReview.rowKey(row, index),
|
|
matches = targets.get(key) || [];
|
|
matches.push(row);
|
|
targets.set(key, matches);
|
|
});
|
|
for (const [key, edit] of Object.entries(next.contractReview.edits)) {
|
|
const matches = targets.get(key) || [];
|
|
if (matches.length !== 1) throw Error(`심사 내역이 없거나 중복됩니다: ${key}`);
|
|
if (
|
|
Object.keys(edit).some(
|
|
(field) => field !== "Reason" && !EstReview.allowed(matches[0]).includes(field),
|
|
)
|
|
)
|
|
throw Error(`해당 내역에 사용할 수 없는 심사 입력: ${key}`);
|
|
}
|
|
}
|
|
if (Object.hasOwn(tables, "ProgressDrafts")) {
|
|
const counts = new Map();
|
|
next.tables.EstMainDat.forEach((row, index) => {
|
|
const key = EstBusiness.rowKey(row, index);
|
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
});
|
|
for (const round of next.progress.filter((item) => !item.locked))
|
|
for (const key of Object.keys(round.inputs))
|
|
if (counts.get(key) !== 1) throw Error(`기성 입력 내역이 없거나 중복됩니다: ${key}`);
|
|
}
|
|
return EstModel.validate(next);
|
|
}
|
|
async function exportExcel() {
|
|
document.activeElement?.blur();
|
|
const response = await fetch("/api/export-xlsx", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(EstApp.state.project),
|
|
});
|
|
if (!response.ok) throw Error((await response.json()).error);
|
|
EstApp.download(
|
|
`${EstApp.state.project.name}.xlsx`,
|
|
await response.blob(),
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
);
|
|
}
|
|
async function exportNative() {
|
|
document.activeElement?.blur();
|
|
const response = await fetch("/api/export-estx", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(EstApp.state.project),
|
|
});
|
|
if (!response.ok) throw Error((await response.json()).error);
|
|
EstApp.download(
|
|
`${EstApp.state.project.name}_edited.estx`,
|
|
await response.blob(),
|
|
"application/octet-stream",
|
|
);
|
|
}
|
|
async function exportJdl() {
|
|
document.activeElement?.blur();
|
|
const project = EstApp.state.project,
|
|
out = await EstApp.api("/api/export-jdl", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(project),
|
|
});
|
|
if (!confirm(out.warnings.slice(0, 15).join("\n") + "\nJDL 기본 표로 내보낼까요?")) return;
|
|
EstApp.download(
|
|
`${project.name}.jdl`,
|
|
Uint8Array.from(atob(out.data), (c) => c.charCodeAt(0)),
|
|
"application/octet-stream",
|
|
);
|
|
}
|
|
function install() {
|
|
const jdlInput = document.createElement("input");
|
|
jdlInput.type = "file";
|
|
jdlInput.accept = ".jdl";
|
|
jdlInput.hidden = true;
|
|
jdlInput.id = "jdl-file";
|
|
document.body.append(jdlInput);
|
|
jdlInput.addEventListener("change", async () => {
|
|
const file = jdlInput.files[0],
|
|
key = EstApp.state.documentKey,
|
|
revision = EstApp.state.revision;
|
|
if (!file) return;
|
|
try {
|
|
const next = await EstApp.api("/api/import-jdl", {
|
|
method: "POST",
|
|
body: await file.arrayBuffer(),
|
|
});
|
|
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
|
|
throw Error("작업이 변경되었습니다. 다시 불러오세요.");
|
|
if (confirm("현재 작업 대신 JDL 내역서를 여시겠습니까?")) EstApp.setProject(next);
|
|
} catch (error) {
|
|
EstApp.state.notice = error.message;
|
|
EstApp.render();
|
|
} finally {
|
|
jdlInput.value = "";
|
|
}
|
|
});
|
|
const xmlInput = document.createElement("input");
|
|
xmlInput.type = "file";
|
|
xmlInput.accept = ".xml";
|
|
xmlInput.hidden = true;
|
|
xmlInput.id = "xml-file";
|
|
document.body.append(xmlInput);
|
|
xmlInput.addEventListener("change", async () => {
|
|
const file = xmlInput.files[0],
|
|
key = EstApp.state.documentKey,
|
|
revision = EstApp.state.revision;
|
|
if (!file) return;
|
|
try {
|
|
const next = EstC3r.read(xmlText(await file.arrayBuffer()));
|
|
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
|
|
throw Error("작업이 변경되었습니다. 다시 불러오세요.");
|
|
if (confirm("현재 작업 대신 XML 내역서를 여시겠습니까?")) EstApp.setProject(next);
|
|
} catch (error) {
|
|
EstApp.state.notice = error.message;
|
|
EstApp.render();
|
|
} finally {
|
|
xmlInput.value = "";
|
|
}
|
|
});
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = ".xlsx";
|
|
input.hidden = true;
|
|
input.id = "excel-file";
|
|
document.body.append(input);
|
|
input.addEventListener("change", async () => {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
const key = EstApp.state.documentKey,
|
|
revision = EstApp.state.revision;
|
|
try {
|
|
const files = await EstApp.api("/api/import-xlsx", {
|
|
method: "POST",
|
|
body: await file.arrayBuffer(),
|
|
}),
|
|
tables = read(files);
|
|
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
|
|
throw Error("작업이 변경되었습니다. Excel을 다시 불러오세요.");
|
|
const next = apply(EstApp.state.project, tables);
|
|
if (
|
|
!confirm(
|
|
`${Object.entries(tables)
|
|
.map(([name, rows]) => `${EstApp.titles[name] || name}: ${rows.length}행`)
|
|
.join("\n")}\n현재 작업의 해당 표를 교체합니다.`,
|
|
)
|
|
)
|
|
return;
|
|
EstApp.change(() => (EstApp.state.project = next));
|
|
} catch (error) {
|
|
EstApp.state.notice = error.message;
|
|
EstApp.render();
|
|
} finally {
|
|
input.value = "";
|
|
}
|
|
});
|
|
}
|
|
return { read, apply, decodeExcelText, exportExcel, exportNative, exportJdl, install };
|
|
})();
|