- 공용 표는 칸 누름(onCellClick)이 있어야 줄에 data-i 를 붙임 — 빈 칸 누름을 줌 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
9.4 KiB
TypeScript
237 lines
9.4 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Tabs_Bill_Fold.ts
|
|
* B09 설계내역서 줄 펼침(PLAN 702-1 · 702-2) — 내역 줄 누름 = 표 아래에 그 줄의 일위대가 구성.
|
|
*
|
|
* 값 = 일위대가표와 같은 셈(`sheets.daega_blocks` · `B09_Estimation_Calc_Labor`) — 펼친 값 = 일위대가표 값
|
|
* 줄 = 호표 줄(품명 · 규격 · 품 · 수량 · 단위 · 노 · 재 · 경 단가 · 금액 · 품셈 값) · 부른 대가 = 그 아래 「ㄴ」(3단까지)
|
|
* 고친 칸 덮개(702-2) = 품 · 수량 · 단가 칸 두 번 눌러 고침 → 사용자 값 `daega_cover`
|
|
* {블록 cover: {줄 cid: {qty · labor · material · expense}}} · 캐시에 바로 · 셈 짝이 바로 다시 셈 ·
|
|
* [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4)
|
|
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
|
|
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
|
|
* ========================================================================== */
|
|
|
|
import { createButton } from "@ui/ui_template_elements";
|
|
import { createCard } from "@ui/ui_template_card";
|
|
import { createText } from "@ui/ui_template_text";
|
|
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
|
import { cellColumn, createDataTable, type DataRow } from "@ui/ui_template_data_table";
|
|
import type { Row } from "./B09_Estimation_Calc_Base";
|
|
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
|
|
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
|
|
|
/** 고칠 칸 → 덮개 칸 */
|
|
const FIELD: Record<string, string> = {
|
|
qty: "qty",
|
|
labor_price: "labor",
|
|
material_price: "material",
|
|
expense_price: "expense",
|
|
};
|
|
const LABEL: Record<string, string> = { qty: "품", labor: "노", material: "재", expense: "경" };
|
|
/** 부른 대가 펼침 깊이 */
|
|
const DEPTH = 3;
|
|
|
|
/** 펼친 줄 — 덮개 자리(블록 cover · 줄 cid) + 셈 줄 */
|
|
interface FoldLine {
|
|
cover: string | null;
|
|
line: Row;
|
|
level: number;
|
|
}
|
|
|
|
/** 지금 펼친 내역 줄 키(다시 그려도 그대로) */
|
|
let open: string | null = null;
|
|
|
|
const plain = (v: unknown): string =>
|
|
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸";
|
|
|
|
/** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 */
|
|
function baseText(line: Row): string {
|
|
const covered: string[] = line.covered || [];
|
|
if (!covered.length) return "같음";
|
|
return covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · ");
|
|
}
|
|
|
|
function linesOf(block: Row, blocks: Map<string, Row>, level: number, trail: string[]): FoldLine[] {
|
|
const out: FoldLine[] = [];
|
|
for (const line of block.lines || []) {
|
|
out.push({ cover: block.cover ?? null, line, level });
|
|
const sub = line.kind === "daega" ? blocks.get(line.key) : undefined;
|
|
if (sub && level < DEPTH && !trail.includes(sub.id))
|
|
out.push(...linesOf(sub, blocks, level + 1, [...trail, sub.id]));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 덮개 한 칸 고침 · value null = 그 칸 품셈 값으로 */
|
|
function setCover(ctx: TabContext, at: FoldLine, field: string, value: number | null): void {
|
|
if (!at.cover) return;
|
|
const all: Row = JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
|
|
const lines: Row = all[at.cover] || {};
|
|
const cell: Row = { ...(lines[at.line.cid] || {}) };
|
|
if (value === null) delete cell[field];
|
|
else cell[field] = value;
|
|
if (Object.keys(cell).length) lines[at.line.cid] = cell;
|
|
else delete lines[at.line.cid];
|
|
if (Object.keys(lines).length) all[at.cover] = lines;
|
|
else delete all[at.cover];
|
|
ctx.setUser({ ...ctx.user, daega_cover: all });
|
|
}
|
|
|
|
/** 줄 하나 덮개 모두 지움 · 여럿이면 한 번에(다시 그림 한 번) */
|
|
function clearCovers(ctx: TabContext, ats: FoldLine[]): void {
|
|
const all: Row = JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
|
|
for (const at of ats) {
|
|
if (!at.cover || !all[at.cover]) continue;
|
|
delete all[at.cover][at.line.cid];
|
|
if (!Object.keys(all[at.cover]).length) delete all[at.cover];
|
|
}
|
|
ctx.setUser({ ...ctx.user, daega_cover: all });
|
|
}
|
|
|
|
const covered = (at: FoldLine | undefined, field: string): boolean =>
|
|
!!at && (at.line.covered || []).includes(field);
|
|
|
|
function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTMLElement {
|
|
const blocks = new Map<string, Row>(
|
|
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.key), b]),
|
|
);
|
|
const lines = linesOf(block, blocks, 1, [block.id]);
|
|
const rows: DataRow[] = lines.map(({ line, level }) => ({
|
|
key: line.cid,
|
|
name: `${level > 1 ? "ㄴ " : ""}${line.name || ""}`,
|
|
spec: line.spec || "",
|
|
qty: line.qty,
|
|
unit: line.unit || "",
|
|
labor_price: line.prices?.labor,
|
|
material_price: line.prices?.material,
|
|
expense_price: line.prices?.expense,
|
|
amount: line.amount,
|
|
base: baseText(line),
|
|
note: line.ref_no || line.why || "",
|
|
}));
|
|
const edited = lines.filter((at) => (at.line.covered || []).length);
|
|
const columns = [
|
|
text("name", "품명", 200),
|
|
text("spec", "규격", 140),
|
|
qty("qty", "품 · 수량"),
|
|
text("unit", "단위", 48),
|
|
money("labor_price", "노무비", ["단가"], 1),
|
|
money("material_price", "재료비", ["단가"], 1),
|
|
money("expense_price", "경비", ["단가"], 1),
|
|
money("amount", "금액", undefined, 1),
|
|
text("base", "품셈 값", 150),
|
|
text("note", "비고", 120),
|
|
];
|
|
const table = createDataTable({
|
|
columns,
|
|
rows,
|
|
total: { name: "합 계", amount: block.sum, note: block.blank ? `빈 줄 ${block.blank}` : "" },
|
|
frozen: 1,
|
|
empty: "",
|
|
editOnDouble: true,
|
|
onCellClick: () => undefined, // 줄 번호 표식(data-i) — 두 번 눌러 고침 · 우클릭 · 풍선 자리
|
|
editable: (i, key) => {
|
|
const at = lines[i];
|
|
if (!at?.cover || !(key in FIELD)) return false;
|
|
return at.line.kind !== "pct" || key === "qty"; // % 줄 = 율만
|
|
},
|
|
onEdit: (i, key, value) => {
|
|
const at = lines[i];
|
|
const field = FIELD[key];
|
|
const v = parseNumber(value);
|
|
if (!at || !field || v === undefined) return;
|
|
const base = covered(at, field)
|
|
? at.line.base?.[field]
|
|
: field === "qty"
|
|
? at.line.qty
|
|
: at.line.prices?.[field];
|
|
if (v === null || v === base) {
|
|
if (covered(at, field)) setCover(ctx, at, field, null); // 빈칸 · 셈 값 = 품셈 값으로
|
|
return;
|
|
}
|
|
setCover(ctx, at, field, v);
|
|
},
|
|
cellClass: (i, key) => (covered(lines[i], FIELD[key]) ? "is-edited" : null),
|
|
level: { key: "name", of: (i) => lines[i]?.level },
|
|
});
|
|
table.root.dataset.b09Fold = "";
|
|
attachHoverInfo(
|
|
table.root,
|
|
(cell) => {
|
|
const key = columns[cellColumn(cell as HTMLTableCellElement)]?.key ?? "";
|
|
const at = lines[Number(cell.parentElement?.dataset.i)];
|
|
const field = FIELD[key];
|
|
if (!at || !field || !covered(at, field)) return null;
|
|
return `고친 칸 — 품셈 값 ${plain(at.line.base?.[field])} · 우클릭 [품셈 값으로]`;
|
|
},
|
|
"tr[data-i] td",
|
|
);
|
|
attachRowMenu(table.root, (i) => {
|
|
const at = lines[i];
|
|
return at && (at.line.covered || []).length
|
|
? [["품셈 값으로", () => clearCovers(ctx, [at])]]
|
|
: [];
|
|
});
|
|
const actions = [
|
|
...(edited.length
|
|
? [
|
|
createButton({
|
|
label: `고친 칸 ${edited.length}줄 모두 품셈 값으로`,
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => clearCovers(ctx, edited),
|
|
}),
|
|
]
|
|
: []),
|
|
createButton({ label: "접기", variant: "ghost", size: "sm", onClick: close }),
|
|
];
|
|
const card = createCard({
|
|
title: `${row.name || ""} — 일위대가 구성 (대가${block.no} ${block.name || ""})`,
|
|
actions,
|
|
pad: 12,
|
|
body: [
|
|
table.root,
|
|
createText(
|
|
"품 · 수량 · 단가 칸을 두 번 눌러 고침 · 색 칸 = 고친 칸(이 공사만 · 새 단가 판 · [재적용] 뒤에도 남음) · 우클릭 [품셈 값으로] · [저장] 때 서버가 다시 셈",
|
|
"muted",
|
|
),
|
|
],
|
|
});
|
|
card.classList.add("b09-fold");
|
|
return card;
|
|
}
|
|
|
|
/** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */
|
|
export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
|
|
const host = document.createElement("div");
|
|
const draw = (): void => {
|
|
const row = raw.find((r) => r.key === open) as Row | undefined;
|
|
const ref: Row | undefined = row?.ref;
|
|
const block =
|
|
ref?.tab === "dg"
|
|
? (ctx.sheets.daega_blocks || []).find((b: Row) => String(b.key) === String(ref.key))
|
|
: undefined;
|
|
host.replaceChildren(
|
|
...(row && block
|
|
? [
|
|
foldCard(ctx, row, block, () => {
|
|
open = null;
|
|
draw();
|
|
}),
|
|
]
|
|
: []),
|
|
);
|
|
};
|
|
table.addEventListener("click", (event) => {
|
|
const target = event.target as Element;
|
|
if (target.closest("input, textarea")) return;
|
|
const tr = target.closest<HTMLElement>("tr[data-i]");
|
|
if (!tr || !table.contains(tr)) return;
|
|
const row = raw[Number(tr.dataset.i)] as Row | undefined;
|
|
open = row?.ref?.tab === "dg" ? String(row.key) : null;
|
|
draw();
|
|
});
|
|
draw();
|
|
return host;
|
|
}
|