Files

353 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
})();