353 lines
18 KiB
JavaScript
353 lines
18 KiB
JavaScript
const EstReport = (() => {
|
||
let catalog = [];
|
||
function setting() {
|
||
const project = EstApp.state.project,
|
||
settings = project.printSettings || {};
|
||
return {
|
||
form: "current",
|
||
paper: "A4",
|
||
orientation: "landscape",
|
||
margin: 12,
|
||
font: 9,
|
||
company: "",
|
||
startPage: 1,
|
||
rowsPerPage: 30,
|
||
notes: "",
|
||
PrintGongMethod: settings.PrintGongMethod ?? 0,
|
||
PrintApplyField: settings.PrintApplyField ?? 0,
|
||
PrintSumBottom: settings.PrintSumBottom ?? 0,
|
||
...settings,
|
||
marginTop: settings.marginTop ?? settings.margin ?? 12,
|
||
marginBottom: settings.marginBottom ?? settings.margin ?? 12,
|
||
marginLeft: settings.marginLeft ?? settings.margin ?? 12,
|
||
marginRight: settings.marginRight ?? settings.margin ?? 12,
|
||
};
|
||
}
|
||
function preview(all = false) {
|
||
const { state, esc } = EstApp;
|
||
const settings = setting(),
|
||
form = catalog.find((row) => String(row.Index) === settings.form),
|
||
output = form ? EstForms.data(form.Index, state.project, state) : null;
|
||
const number = (value) =>
|
||
new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 12 }).format(Number(value) || 0);
|
||
const root = document.createElement("div");
|
||
if (output) root.innerHTML = EstForms.grid(output);
|
||
else if (form) {
|
||
const total = state.project.calculation?.total || 0;
|
||
root.innerHTML = `<div class="document-text">${esc(settings.notes)}</div>${[8, 9].includes(form.Index) ? `<h2>공사금액 ${number(total)}원</h2><p>${esc(settings.company)}</p>` : ""}`;
|
||
} else if (state.project.tables[state.view]) {
|
||
const rows = all
|
||
? state.project.tables[state.view].map((row, index) => ({ row, index }))
|
||
: EstApp.visibleRows();
|
||
const common = ["Code", "Name", "Size", "Unite"];
|
||
const columns = {
|
||
EstMainDat: [...common, "GN", "TU", "TC", "NC", "GC", "JC", "SC"],
|
||
HeadMainDat: [...common, "GN", "NU", "GU", "JU", "SU", "TU", "NC", "GC", "JC", "SC", "TC"],
|
||
CostMainDat: ["LineData"],
|
||
MachMainDat: [...common, "GN", "TU", "NC", "GC", "JC", "TC"],
|
||
Wonga: ["Gubun", "Name", "Youl", "VSusic", "Cost"],
|
||
};
|
||
root.innerHTML = EstViews.grid(
|
||
state.view,
|
||
rows,
|
||
columns[state.view] ||
|
||
EstModel.columns(state.view).filter(
|
||
(key) =>
|
||
![
|
||
"ValueIndex",
|
||
"SelectField",
|
||
"Ratio",
|
||
"NachalRatio",
|
||
"Diget",
|
||
"DigetIndex",
|
||
].includes(key),
|
||
),
|
||
);
|
||
} else root.innerHTML = EstApp.byId("work-content").innerHTML;
|
||
root
|
||
.querySelectorAll("button,.row-number,.business-tools,select#quantity-sheet")
|
||
.forEach((node) => node.remove());
|
||
root.querySelectorAll("input,textarea,select").forEach((node) => {
|
||
const value =
|
||
node.tagName === "SELECT"
|
||
? node.selectedOptions[0]?.textContent || ""
|
||
: node.type === "number" && node.value !== ""
|
||
? number(node.value)
|
||
: EstText.clean(EstText.encode(node.value));
|
||
if (node.tagName === "TEXTAREA") node.parentElement.classList.add("col-LineData");
|
||
if (node.type === "number") node.parentElement.classList.add("numeric");
|
||
node.replaceWith(document.createTextNode(value));
|
||
});
|
||
root
|
||
.querySelectorAll("[contenteditable]")
|
||
.forEach((node) => node.removeAttribute("contenteditable"));
|
||
const title = form?.Name2 || EstApp.titles[state.view] || state.view;
|
||
const detail =
|
||
output?.detail ||
|
||
(state.view === "quantity"
|
||
? state.project.quantitySheets?.find((sheet) => sheet.Code === state.quantitySheet)?.Name
|
||
: state.view === "progress"
|
||
? state.project.progress.find((round) => round.id === state.roundId)?.name
|
||
: state.group
|
||
? EstNative.groups(state.project.tables[state.view] || []).get(state.group)?.rows[0]
|
||
?.Name
|
||
: "");
|
||
const totals = [
|
||
"progress-summary",
|
||
"progress-costs",
|
||
"escalation-summary",
|
||
"escalation-costs",
|
||
].includes(form?.Index)
|
||
? null
|
||
: state.project.calculation;
|
||
const errors = [...(totals?.errors || []), ...(output?.errors || [])];
|
||
const source = `<!doctype html><html lang="ko"><head><meta charset="UTF-8"><title>${esc(state.project.name)} · ${esc(title)}</title>
|
||
<style>@page{size:${settings.paper} ${settings.orientation};margin:${settings.marginTop}mm ${settings.marginRight}mm ${settings.marginBottom}mm ${settings.marginLeft}mm}*{box-sizing:border-box}body{font:${settings.font}pt "Malgun Gothic",sans-serif;color:#111;margin:0}
|
||
h1{font-size:17pt;margin:0 0 4mm}h2{font-size:12pt;margin:0 0 3mm}.summary{margin:2mm 0 4mm}
|
||
table{width:100%;border-collapse:collapse;font-size:inherit}thead{display:table-header-group}tr{break-inside:avoid}
|
||
th,td{border:1px solid #777;padding:1.5mm;vertical-align:top;overflow-wrap:anywhere}th{background:#eee}
|
||
.heading,.level{font-weight:bold;background:#f4f4f4}.selected{background:transparent}
|
||
.numeric{text-align:right;font-variant-numeric:tabular-nums}
|
||
.col-LineData{font-family:"굴림체",Consolas,monospace;white-space:pre-wrap;min-width:130mm}
|
||
.error-row{outline:1px solid #a00}.errors{color:#900;white-space:pre-wrap}.muted{color:#555}
|
||
.document-text{white-space:pre-wrap;line-height:1.7;min-height:40mm}.page{break-after:page}.page:last-child{break-after:auto}.footer{text-align:right;margin-top:3mm}
|
||
</style></head><body><h1>${esc(state.project.name)}</h1><h2>${esc(title)}</h2>
|
||
${detail ? `<h2>${esc(detail)}</h2>` : ""}${totals ? `<div class="summary">순공사비 ${number(totals.direct?.TC)}원 · 도급액 ${number(totals.total)}원</div>` : ""}
|
||
${paginate(root, settings)}${output && settings.notes ? `<div class="document-text">${esc(settings.notes)}</div>` : ""}${errors.length ? `<p class="errors">계산 오류 ${errors.length}건: ${esc(errors.join(" / "))}</p>` : ""}</body></html>`;
|
||
EstApp.byId("print-frame").srcdoc = source;
|
||
}
|
||
function paginate(root, settings) {
|
||
const table = root.querySelector("table");
|
||
if (!table || root.querySelectorAll("table").length !== 1) return root.innerHTML;
|
||
const rows = [...table.querySelectorAll("tbody>tr")],
|
||
pages = [];
|
||
for (let at = 0; at < rows.length; at += settings.rowsPerPage) {
|
||
const copy = table.cloneNode(false);
|
||
copy.innerHTML = table.querySelector("thead")?.outerHTML || "";
|
||
const body = document.createElement("tbody");
|
||
rows.slice(at, at + settings.rowsPerPage).forEach((row) => body.append(row.cloneNode(true)));
|
||
copy.append(body);
|
||
pages.push(
|
||
`<section class="page">${copy.outerHTML}<div class="footer">${EstApp.esc(settings.company)} ${settings.startPage + pages.length}</div></section>`,
|
||
);
|
||
}
|
||
return pages.join("");
|
||
}
|
||
async function open() {
|
||
const defaults = await EstApp.api("/api/print-catalog");
|
||
const original = new Map(
|
||
(EstApp.state.project.printCatalog || []).map((row) => [row.Index, row]),
|
||
);
|
||
catalog = defaults.map((row) => ({
|
||
...row,
|
||
...original.get(row.Index),
|
||
}));
|
||
catalog.push(
|
||
{ Index: "progress-summary", Name: "기성 내역서", Name2: "기성 내역서" },
|
||
{ Index: "progress-costs", Name: "기성 원가계산서", Name2: "기성 원가계산서" },
|
||
{ Index: "escalation-summary", Name: "물가변동 내역서", Name2: "물가변동 내역서" },
|
||
{ Index: "escalation-costs", Name: "물가변동 원가계산서", Name2: "물가변동 원가계산서" },
|
||
{ Index: "annual-summary", Name: "연차 배분표", Name2: "연차 배분표" },
|
||
{ Index: "annual-items", Name: "연차별 내역서", Name2: "연차별 내역서" },
|
||
{ Index: "change-costs", Name: "설계변경 원가 증감표", Name2: "설계변경 원가 증감표" },
|
||
{ Index: "standard-usage", Name: "표준시장단가 사용 내역", Name2: "표준시장단가 사용 내역" },
|
||
{ Index: "standard-change", Name: "표준시장단가 설계변경", Name2: "표준시장단가 설계변경" },
|
||
{ Index: "estimate-usage", Name: "견적단가 사용 내역", Name2: "견적단가 사용 내역" },
|
||
{ Index: "estimate-change", Name: "견적단가 설계변경", Name2: "견적단가 설계변경" },
|
||
{ Index: "material-usage", Name: "자재단가 사용 내역", Name2: "자재단가 사용 내역" },
|
||
{ Index: "material-change", Name: "자재단가 설계변경", Name2: "자재단가 설계변경" },
|
||
{ Index: "labor-usage", Name: "노임단가 사용 내역", Name2: "노임단가 사용 내역" },
|
||
{ Index: "labor-change", Name: "노임단가 설계변경", Name2: "노임단가 설계변경" },
|
||
{ Index: "machine-usage", Name: "기계경비 사용 내역", Name2: "기계경비 사용 내역" },
|
||
{ Index: "machine-change", Name: "기계경비 설계변경", Name2: "기계경비 설계변경" },
|
||
);
|
||
const table = EstApp.state.project.tables[EstApp.state.view];
|
||
const s = setting(),
|
||
{ esc } = EstApp;
|
||
EstApp.showDialog(
|
||
"인쇄 미리보기",
|
||
`<div class="business-tools">
|
||
<label>서식 <select id="print-form"><option value="current">현재 화면</option>${catalog
|
||
.filter((row) => row.Index !== 151)
|
||
.map(
|
||
(row) =>
|
||
`<option value="${row.Index}" ${String(row.Index) === s.form ? "selected" : ""}>${esc(row.Name)}</option>`,
|
||
)
|
||
.join("")}</select></label>
|
||
<label>용지 <select id="print-paper">${["A4", "A3"].map((v) => `<option ${v === s.paper ? "selected" : ""}>${v}</option>`).join("")}</select></label>
|
||
<label>방향 <select id="print-orientation"><option value="landscape" ${s.orientation === "landscape" ? "selected" : ""}>가로</option><option value="portrait" ${s.orientation === "portrait" ? "selected" : ""}>세로</option></select></label>
|
||
${[
|
||
["marginTop", "위 여백(mm)", 0, 50],
|
||
["marginBottom", "아래 여백(mm)", 0, 50],
|
||
["marginLeft", "왼쪽 여백(mm)", 0, 50],
|
||
["marginRight", "오른쪽 여백(mm)", 0, 50],
|
||
["font", "글자(pt)", 6, 20],
|
||
["rowsPerPage", "페이지당 행", 1, 100],
|
||
["startPage", "시작 쪽", 1, 99999],
|
||
]
|
||
.map(
|
||
([key, label, min, max]) =>
|
||
`<label>${label}<input data-print="${key}" type="number" min="${min}" max="${max}" value="${s[key]}" style="width:65px"></label>`,
|
||
)
|
||
.join("")}
|
||
<label id="print-gong-wrap" ${["115", "116", "117", "118"].includes(String(s.form)) ? "" : "hidden"}>공람용 방법 <select id="print-gong"><option value="0" ${Number(s.PrintGongMethod) === 0 ? "selected" : ""}>수량만</option><option value="1" ${Number(s.PrintGongMethod) === 1 ? "selected" : ""}>단가만</option><option value="2" ${Number(s.PrintGongMethod) === 2 ? "selected" : ""}>수량·단가</option></select></label>
|
||
<label>업체명<input data-print="company" value="${esc(s.company)}"></label>
|
||
${table ? '<label>출력 범위 <select id="print-scope"><option value="current">현재 호표·검색 결과 전체 행</option><option value="all">현재 자료의 모든 호표·행</option></select></label>' : ""}
|
||
<button data-action="print-document">인쇄 / PDF 저장</button><button data-action="export-report-xlsx">서식 Excel 저장</button><span id="print-error" role="status"></span></div>
|
||
<label>문서 본문(표지·갑지·설명서·시방서·심사·공정 문서)<textarea data-print="notes" rows="3" style="width:100%">${esc(s.notes)}</textarea></label>
|
||
<iframe id="print-frame" title="인쇄할 문서" style="width:100%;height:65vh;border:1px solid #aaa"></iframe>`,
|
||
);
|
||
EstApp.byId("dialog-content")
|
||
.querySelectorAll("[data-print],#print-paper,#print-orientation,#print-form,#print-gong")
|
||
.forEach((node) =>
|
||
node.addEventListener("change", () => {
|
||
const key =
|
||
node.id === "print-gong" ? "PrintGongMethod" : node.dataset.print || node.id.slice(6),
|
||
value =
|
||
node.type === "number" || node.id === "print-gong" ? Number(node.value) : node.value;
|
||
try {
|
||
if (
|
||
node.type === "number" &&
|
||
(!Number.isFinite(value) ||
|
||
value < Number(node.min) ||
|
||
value > Number(node.max) ||
|
||
(["rowsPerPage", "startPage"].includes(key) && !Number.isInteger(value)))
|
||
)
|
||
throw Error("인쇄 설정 범위 오류");
|
||
EstApp.change(() => {
|
||
const next = { ...setting(), [key]: value };
|
||
if (key === "form") {
|
||
const found = catalog.find((row) => String(row.Index) === value);
|
||
const original = found && {
|
||
...found,
|
||
...EstApp.state.project.nativePrintChanges?.[value],
|
||
};
|
||
if (original) {
|
||
next.rowsPerPage = Math.max(
|
||
1,
|
||
Math.min(100, Number(original.PrintLineCount) || 30),
|
||
);
|
||
for (const side of ["Top", "Bottom", "Left", "Right"])
|
||
if (Number.isFinite(original[`Print${side}`]))
|
||
next[`margin${side}`] = Math.max(0, Math.min(50, original[`Print${side}`]));
|
||
if (Number.isFinite(original.PrintGongMethod))
|
||
next.PrintGongMethod = Number(original.PrintGongMethod);
|
||
if (Number.isFinite(original.PrintApplyField))
|
||
next.PrintApplyField = Number(original.PrintApplyField);
|
||
if (Number.isFinite(original.PrintSumBottom))
|
||
next.PrintSumBottom = Number(original.PrintSumBottom);
|
||
}
|
||
}
|
||
if (key === "notes") {
|
||
EstApp.state.project.documents ||= {};
|
||
EstApp.state.project.documents[next.form] = value;
|
||
}
|
||
if (
|
||
[
|
||
"marginTop",
|
||
"marginBottom",
|
||
"marginLeft",
|
||
"marginRight",
|
||
"rowsPerPage",
|
||
"PrintGongMethod",
|
||
"PrintApplyField",
|
||
"PrintSumBottom",
|
||
].includes(key) &&
|
||
next.form !== "current" &&
|
||
EstApp.state.project.printCatalog?.some((row) => String(row.Index) === next.form)
|
||
) {
|
||
EstApp.state.project.nativePrintChanges ||= {};
|
||
const changes = (EstApp.state.project.nativePrintChanges[next.form] ||= {});
|
||
if (key.startsWith("margin")) changes[`Print${key.slice(6)}`] = value;
|
||
else if (key === "rowsPerPage") changes.PrintLineCount = value;
|
||
else changes[key] = value;
|
||
}
|
||
if (key === "form") next.notes = EstApp.state.project.documents?.[value] || "";
|
||
EstApp.state.project.printSettings = next;
|
||
}, false);
|
||
if (key === "form") {
|
||
const content = EstApp.byId("dialog-content"),
|
||
current = EstApp.state.project.printSettings;
|
||
for (const name of [
|
||
"notes",
|
||
"marginTop",
|
||
"marginBottom",
|
||
"marginLeft",
|
||
"marginRight",
|
||
"rowsPerPage",
|
||
])
|
||
content.querySelector(`[data-print=${name}]`).value = current[name];
|
||
const gongWrap = content.querySelector("#print-gong-wrap");
|
||
if (gongWrap) {
|
||
gongWrap.hidden = !["115", "116", "117", "118"].includes(String(current.form));
|
||
content.querySelector("#print-gong").value = String(current.PrintGongMethod ?? 0);
|
||
}
|
||
}
|
||
preview(EstApp.byId("print-scope")?.value === "all");
|
||
EstApp.byId("print-error").textContent = "";
|
||
} catch (error) {
|
||
EstApp.byId("print-error").textContent = error.message;
|
||
node.value = setting()[key];
|
||
}
|
||
}),
|
||
);
|
||
EstApp.byId("print-scope")?.addEventListener("change", (event) =>
|
||
preview(event.target.value === "all"),
|
||
);
|
||
preview();
|
||
}
|
||
function print() {
|
||
const frame = EstApp.byId("print-frame");
|
||
if (!frame?.contentDocument?.body) throw Error("인쇄 미리보기가 준비되지 않았습니다.");
|
||
frame.contentWindow.focus();
|
||
frame.contentWindow.print();
|
||
}
|
||
async function exportExcelReport() {
|
||
const { state } = EstApp;
|
||
const form = catalog.find((row) => String(row.Index) === setting().form);
|
||
const output = form && EstForms.data(form.Index, state.project, state);
|
||
if (!output) throw Error("표 형식의 인쇄 서식을 선택하세요.");
|
||
if (!output.rows.length) throw Error("출력할 행이 없습니다.");
|
||
const used = new Set();
|
||
const columns = output.columns.map((key) => {
|
||
const base = EstForms.label(key);
|
||
let name = base,
|
||
suffix = 2;
|
||
while (used.has(name)) name = `${base} (${suffix++})`;
|
||
used.add(name);
|
||
return name;
|
||
});
|
||
const rows = output.rows.map((row) =>
|
||
output.columns.map((key) =>
|
||
key === "LineData" ? EstText.clean(row[key] || "") : (row[key] ?? ""),
|
||
),
|
||
);
|
||
const response = await fetch("/api/export-report-xlsx", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: state.project.name,
|
||
columns,
|
||
rows,
|
||
detail: [output.detail, setting().notes].filter(Boolean).join("\n"),
|
||
errors: output.errors || [],
|
||
}),
|
||
});
|
||
if (!response.ok) throw Error((await response.json()).error);
|
||
EstApp.download(
|
||
`${state.project.name} - ${form.Name}.xlsx`,
|
||
await response.blob(),
|
||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
);
|
||
}
|
||
async function exportExcel() {
|
||
try {
|
||
await exportExcelReport();
|
||
EstApp.byId("print-error").textContent = "";
|
||
} catch (error) {
|
||
EstApp.byId("print-error").textContent = error.message;
|
||
}
|
||
}
|
||
return { open, preview, print, exportExcel };
|
||
})();
|