Files
Aislo/tools/est-test/native-quantity-table.js
T

321 lines
14 KiB
JavaScript

// 워드형 수량산출서 표 화면·편집 — 칸 고르기(Shift 로 범위), 칸 글·수식(=…) 고치기, 방향 셀 합(ActionAddRight·Left·Up·Down),
// 수량으로 지정·지정 해제(칸 수량 표지 → 수량 항목), 셀수직병합·셀분리(btnCellMergeVer·btnCellSplitV), 표 만들기(mnuTableCreate).
// 계산·표 자료는 native-quantity-wpt.cjs. 표를 고친 산출서는 원본 WPT 로 다시 쓰지 못하므로 ESTX 저장을 막는다(tableEdited).
const EstQuantityTable = (() => {
const W = () => EstQuantityWpt;
function sheetOf(state) {
return state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
}
const PAGE = 45; // 웹 산출서 한 쪽 줄 수(원본은 편집기 쪽 배치)
function paraStyle(row) {
const p = row.para || {};
return [
p.align && `text-align:${p.align}`,
p.indent && `padding-left:${p.indent * 8}px`,
row.title && "font-weight:bold;font-size:20px;color:#800000",
p.bold && "font-weight:bold",
p.size && `font-size:${p.size}px`,
p.color && `color:${p.color}`,
]
.filter(Boolean)
.join(";");
}
// 테두리선(popFormatOutLine)·산출근거 양식(popFormatGungu): 화면에만 그림(원본도 글로 넣지 않음)
function banner(sheet) {
return sheet.gunguForm
? `<table class="sheet" style="${sheet.outline ? "border:2px solid #333;" : ""}margin:4px 0"><tr><th>공 종</th><th>산 출 내 역</th><th>수 량</th></tr></table>`
: sheet.outline
? '<p class="business-note">테두리선 켬(인쇄 쪽 둘레)</p>'
: "";
}
function html(sheet, index, state) {
const { esc } = EstApp;
if (sheet.rows[index].image)
return `<img src="${esc(sheet.rows[index].image)}" alt="산출서 그림" style="max-width:600px;max-height:400px">`;
const table = sheet.rows[index].table,
sel = state.qtCell?.sheet === sheet.Code && state.qtCell.row === index ? state.qtCell : null;
W().recalc(table);
const inBox = (r, c) =>
sel &&
r >= Math.min(sel.r, sel.r2 ?? sel.r) &&
r <= Math.max(sel.r, sel.r2 ?? sel.r) &&
c >= Math.min(sel.c, sel.c2 ?? sel.c) &&
c <= Math.max(sel.c, sel.c2 ?? sel.c);
const cur = sel ? table[sel.r]?.[sel.c] : null;
const tools = sel
? `<div class="business-tools">${W().letters(sel.c)}${sel.r + 1} <input data-qt-bar value="${esc(cur?.formula ? `=${cur.formula}` : (cur?.text ?? ""))}" aria-label="칸 글·수식" style="width:30%">${[
["right", "우측 합"],
["left", "좌측 합"],
["up", "위 합"],
["down", "아래 합"],
]
.map(([d, l]) => `<button data-action="qt-sum" data-dir="${d}">${l}</button>`)
.join(
"",
)}<button data-action="qt-mark">수량으로 지정</button><button data-action="qt-free">지정 해제</button><button data-action="qt-vmerge">셀수직병합</button><button data-action="qt-vsplit">셀분리</button><button data-action="qt-style">표 모양</button></div>`
: "";
const body = table
.map(
(row, r) =>
`<tr>${row
.map((cell, c) =>
cell.vmerge
? ""
: `<td data-qt="${index},${r},${c}"${cell.vtop ? ` rowspan="${cell.vtop}"` : ""} style="${[
cell.mark ? "background:#e6f4ea" : "",
inBox(r, c) ? "outline:2px solid #316ea5" : "",
cell.formula ? "font-weight:bold" : "",
cell.name ? "color:#1d4ed8" : "",
]
.filter(Boolean)
.join(
";",
)}"${cell.mark || cell.formula ? ` title="${esc([cell.mark && `수량: ${cell.mark}`, cell.formula && `=${cell.formula}`].filter(Boolean).join(" · "))}"` : ""}>${esc(cell.text) || "&nbsp;"}</td>`,
)
.join("")}</tr>`,
)
.join("");
const border = sheet.rows[index].tableBorder ?? 1;
return `${tools}<table class="sheet" style="font-size:12px;${border === 1 ? "" : `border:${border}px solid #000`}">${body}</table>`;
}
function edit(state, fn) {
const sheet = sheetOf(state),
sel = state.qtCell;
if (!sheet || !sel || sel.sheet !== sheet.Code) throw Error("표에서 칸을 고르세요.");
const table = sheet.rows[sel.row]?.table;
if (!table) throw Error("표에서 칸을 고르세요.");
EstApp.change(() => {
fn(table, sel, sheet);
W().recalc(table);
sheet.tableEdited = true;
}, false);
}
function handle(name, state, node) {
if (name === "qt-sum")
edit(state, (t, s) => {
t[s.r][s.c].formula = W().sumFormula(t, s.r, s.c, node.dataset.dir);
});
else if (name === "qt-mark")
edit(state, (t, s, sheet) => {
const cell = t[s.r][s.c];
if (cell.mark) throw Error("이미 수량으로 지정된 칸입니다.");
const code = EstQuantity.nextItemCode(sheet);
cell.mark = code;
const name = t[s.r].find((c) => c.name)?.text ?? "";
sheet.items.push({
Code: code,
Name: name,
Size: t[s.r].find((c) => c.size)?.text ?? "",
GN: 0,
});
});
else if (name === "qt-free")
edit(state, (t, s, sheet) => {
const cell = t[s.r][s.c];
if (!cell.mark) throw Error("수량으로 지정된 칸이 아닙니다.");
const item = sheet.items.find((i) => i.Code === cell.mark);
if (item?.TargetCode || item?.TargetKey)
throw Error("내역에 연결된 수량 항목입니다. 연결 내역코드를 먼저 비우세요.");
sheet.items = sheet.items.filter((i) => i.Code !== cell.mark);
delete cell.mark;
});
else if (name === "qt-vmerge")
edit(state, (t, s) =>
W().vmerge(t, Math.min(s.r, s.r2 ?? s.r), Math.max(s.r, s.r2 ?? s.r), s.c),
);
else if (name === "qt-vsplit") edit(state, (t, s) => W().vsplit(t, s.r, s.c));
else if (name === "qt-create") {
const sheet = sheetOf(state);
if (!sheet) throw Error("수량산출서를 고르세요.");
const rows = Number(prompt("줄 수", "3")),
cols = Number(prompt("칸 수", "5"));
if (!rows || !cols) return true;
const at = Number(node.dataset.index);
EstApp.change(() => {
sheet.rows.splice(Number.isInteger(at) ? at + 1 : sheet.rows.length, 0, {
LineData: "",
markers: [],
table: W().create(rows, cols),
});
sheet.tableEdited = true;
}, false);
} else if (name === "qt-style")
edit(state, (t, s, sheet) => {
const w = Number(prompt("표 테두리 굵기(0~5)", String(sheet.rows[s.row].tableBorder ?? 1)));
if (!(w >= 0 && w <= 5)) throw Error("테두리 굵기는 0~5 입니다.");
sheet.rows[s.row].tableBorder = w;
});
else if (name.startsWith("qt-page") || name === "qt-para" || name === "qt-image")
lineTool(name, state, Number(node.dataset.index));
else if (name === "qt-outline" || name === "qt-gungu-form") {
const sheet = sheetOf(state);
if (!sheet) throw Error("수량산출서를 고르세요.");
const key = name === "qt-outline" ? "outline" : "gunguForm";
EstApp.change(() => {
sheet[key] = !sheet[key];
sheet.tableEdited = true;
}, false);
} else if (name === "qt-mul-load" || name === "qt-mul-take") mulLoad(name, state, node);
else if (name === "qt-print") print(state);
else return false;
return true;
}
// 문단(btnsetPara·mnuFormatPara)·그림 넣기(btnJPGInput)·한페이지 삽입/삭제(popPageInsert·popPageDel)
function lineTool(name, state, index) {
const sheet = sheetOf(state);
if (!sheet?.rows?.[index]) throw Error("산출 줄을 고르세요.");
const change = (fn) =>
EstApp.change(() => {
fn();
sheet.tableEdited = true;
}, false);
if (name === "qt-para") {
const align = prompt("정렬(left·center·right)", sheet.rows[index].para?.align || "left");
if (align == null) return;
if (!["left", "center", "right"].includes(align))
throw Error("정렬은 left·center·right 입니다.");
const indent = Number(prompt("들여쓰기(칸)", String(sheet.rows[index].para?.indent ?? 0)));
if (!(indent >= 0 && indent <= 40)) throw Error("들여쓰기는 0~40 칸입니다.");
// 글자모양(mnuFormatFont): 크기·굵게·색
const size =
Number(prompt("글자 크기(px, 0 = 기본)", String(sheet.rows[index].para?.size ?? 0))) || 0,
bold = confirm("굵게 할까요?"),
color =
prompt("글자색(#RRGGBB, 비우면 기본)", sheet.rows[index].para?.color || "")?.trim() || "";
if (color && !/^#[0-9a-f]{6}$/i.test(color)) throw Error("글자색은 #RRGGBB 꼴입니다.");
change(
() =>
(sheet.rows[index].para = {
align,
indent,
...(size && { size }),
...(bold && { bold }),
...(color && { color }),
}),
);
} else if (name === "qt-image") {
const picker = document.createElement("input");
picker.type = "file";
picker.accept = "image/*";
picker.addEventListener("change", () => {
const file = picker.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () =>
change(() =>
sheet.rows.splice(index + 1, 0, { LineData: "", markers: [], image: reader.result }),
);
reader.readAsDataURL(file);
});
picker.click();
} else if (name === "qt-page-insert")
change(() =>
sheet.rows.splice(
index + 1,
0,
...Array.from({ length: PAGE }, () => ({ LineData: " ", markers: [] })),
),
);
else if (name === "qt-page-delete") {
const start = Math.floor(index / PAGE) * PAGE,
rows = sheet.rows.slice(start, start + PAGE);
if (rows.some((r) => r.markers?.length || r.table?.some((x) => x.some((c) => c.mark))))
throw Error("수량으로 지정된 줄이 있는 쪽은 지울 수 없습니다. 먼저 지정 해제하세요.");
if (rows.some((r) => r.image) && !confirm("그림이 있는 쪽입니다. 지우시겠습니까?")) return;
if (!confirm(`${start + 1}~${start + rows.length}줄(한 쪽)을 지우시겠습니까?`)) return;
change(() => sheet.rows.splice(start, PAGE));
}
}
// 수량 불러오기(btnMulLoad): Main Data(원본 main.estx) 수량산출서 목록에서 골라 새 산출서로(새 코드, 본문 그대로)
async function mulLoad(name, state, node) {
const { esc } = EstApp;
if (name === "qt-mul-load") {
state.mulLoad ||= (await EstApp.api("/api/main-data?part=quantity")).quantitySheets;
EstApp.showDialog(
"수량 불러오기",
`<div style="max-height:420px;overflow:auto">${state.mulLoad.map((s, i) => `<div><button data-action="qt-mul-take" data-i="${i}">가져오기</button> ${esc(s.Code)} ${esc(s.Name)}${s.excel ? " (엑셀형)" : ""}</div>`).join("")}</div>`,
);
return;
}
const src = state.mulLoad[Number(node.dataset.i)];
EstApp.change((project) => {
const copy = structuredClone(src);
copy.Code = `Q${EstApp.uid()}`;
for (const item of copy.items || []) delete item.TargetCode;
(project.quantitySheets ||= []).push(copy);
state.quantitySheet = copy.Code;
}, false);
EstApp.byId("dialog").close();
state.notice = `「${src.Name}」 산출서를 불러왔습니다.`;
}
// 수량산출서 인쇄(btnPrint → TfrmMulPrintRange): 현재 산출서 또는 모두(산출서마다 간지 고르기), 산출 줄·표 그대로
function print(state) {
const sheets = state.project.quantitySheets || [];
if (!sheets.length) throw Error("수량산출서가 없습니다.");
const all = confirm("모든 수량산출서를 인쇄할까요? (취소 = 현재 산출서만)"),
gangi = all && confirm("산출서마다 간지를 넣을까요?"),
list = all ? sheets : [sheetOf(state)].filter(Boolean),
{ esc } = EstApp;
const body = list
.map((sheet) => {
const rows = sheet.rows
.map((r) =>
r.image
? `<img src="${esc(r.image)}" style="max-width:100%">`
: r.table
? `<table>${r.table.map((tr) => `<tr>${tr.map((c) => (c.vmerge ? "" : `<td${c.vtop ? ` rowspan="${c.vtop}"` : ""}>${esc(c.text)}</td>`)).join("")}</tr>`).join("")}</table>`
: `<div style="white-space:pre-wrap;${paraStyle(r)}">${esc(EstText.clean(r.LineData || ""))}</div>`,
)
.join("");
return `${gangi ? `<section class="gangi"><div>${esc(sheet.Name)}</div></section>` : ""}<section class="sheet"><h2>${esc(sheet.Name)} ${esc(sheet.Size || "")}</h2>${rows}</section>`;
})
.join("");
const win = open("", "_blank");
if (!win) throw Error("새 창을 열 수 없습니다. 팝업 차단을 확인하세요.");
win.document.write(
`<!doctype html><meta charset="utf-8"><title>수량산출서</title><style>body{font:10pt "Malgun Gothic",sans-serif}table{border-collapse:collapse}td{border:1px solid #777;padding:2px 4px}.sheet{break-after:page}.gangi{break-after:page;height:250mm;display:flex;align-items:center;justify-content:center;font-size:28pt;font-weight:bold}</style>${body}<script>print()<\/script>`,
);
win.document.close();
}
function install() {
document.addEventListener("click", (event) => {
const td = event.target.closest("[data-qt]");
if (!td) return;
const [row, r, c] = td.dataset.qt.split(",").map(Number),
state = EstApp.state,
old = state.qtCell;
state.qtCell =
event.shiftKey && old && old.row === row && old.sheet === state.quantitySheet
? { ...old, r2: r, c2: c }
: { sheet: state.quantitySheet, row, r, c };
EstApp.render();
});
document.addEventListener("change", (event) => {
if (event.target.dataset.qtBar === undefined) return;
const v = event.target.value,
state = EstApp.state;
try {
edit(state, (t, s) => {
const cell = t[s.r][s.c];
if (v.trim().startsWith("=")) cell.formula = v.trim().slice(1);
else {
delete cell.formula;
cell.text = v;
}
});
} catch (error) {
state.notice = error.message;
EstApp.render();
}
});
}
if (typeof document !== "undefined" && document.addEventListener) install();
return { html, banner, paraStyle, handle };
})();