From 8d80e1f6809bdcf6a52a07303f5575feffca4de4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 12:41:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EC=84=9C):=20=EC=A4=84=20=ED=8E=BC=EC=B9=A8=20=E2=80=94=20?= =?UTF-8?q?=EC=A4=84=20=EB=88=84=EB=A5=B4=EB=A9=B4=20=ED=91=9C=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=20=C2=B7=20=EC=85=80=EC=97=90=EC=84=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=9C=20=EC=B9=B8=20=EB=8D=AE=EA=B0=9C=20=C2=B7=20=ED=92=88?= =?UTF-8?q?=EC=85=88=20=EA=B0=92=20=EA=B3=81=20=C2=B7=20[=ED=92=88?= =?UTF-8?q?=EC=85=88=20=EA=B0=92=EC=9C=BC=EB=A1=9C]=20(PLAN=20702-1=20?= =?UTF-8?q?=C2=B7=20702-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 파일 B09_Estimation_UI_Tabs_Bill_Fold.ts — 일위대가(dg) 연결 줄만 · 값 = 일위대가표와 같은 셈(daega_blocks) · 부른 대가 3단까지 「ㄴ」 - 칸 = 품명 · 규격 · 품·수량 · 단위 · 노 · 재 · 경 단가 · 금액 · 품셈 값 · 비고 · 합계 줄 - 품 · 수량 · 단가 두 번 눌러 고침 → 캐시 daega_cover 즉시 · 짝 셈 다시 · 고친 칸 색(is-edited) · 풍선 = 품셈 값 · 우클릭 · [모두 품셈 값으로] · 빈칸 입력 = 되돌림 - 고리 = UI_Tabs_Bill fold 한 줄 · BillTable.fold 형 · Shell 표 아래 자리 + computeLabor 에 덮개 넘김 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_UI_Shell.ts | 4 +- B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 2 + .../B09_Estimation_UI_Tabs_Bill_Edit.ts | 2 + .../B09_Estimation_UI_Tabs_Bill_Fold.ts | 235 ++++++++++++++++++ 4 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 4eeb511d8..e03f541e6 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -100,6 +100,7 @@ export function computeSheets(payload: Row, user: Row): Row { payload.inputs.daega, { ...computeBase(payload.inputs, user), gaps: [] }, sg, + user.daega_cover, // 702-2 고친 칸 덮개 ); const books = { daega: labor.daega, sg_list: kept.sg_list, sg_hand: kept.sg_hand }; const base = computeEstimation( @@ -264,7 +265,8 @@ export function renderB09Estimation(root: HTMLElement): void { // 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임 if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`; const foot = hint ?? level?.guide; - panel.replaceChildren(table.root, ...(foot ? [foot] : [])); + const fold = bill?.fold?.(table.root); // 702-1 줄 펼침 = 표 아래 + panel.replaceChildren(table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])); if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 7ae93caf2..fac5b923f 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -24,6 +24,7 @@ import { openLinkMenu, type BillTable, } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { attachBillFold } from "./B09_Estimation_UI_Tabs_Bill_Fold"; const TEXT_KEYS = ["name", "spec", "unit"]; /** 폴더 줄 수식 칸 — 자손 합(셈) */ @@ -126,6 +127,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { }, menu: (i) => billMenu(ctx, raw, i), drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before), + fold: (table) => attachBillFold(ctx, raw, table), // 702-1 줄 펼침 · 고친 칸 덮개 onCell: (i, key) => { if (key === "connect") openLinkMenu(ctx, raw, i); }, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 99243bdb4..ca6bf6e32 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -32,6 +32,8 @@ export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[]; /** 끌어 놓기(204) — 줄 index 를 parent(null = 맨 위) 아래 before 앞(null = 끝)으로 · 못 하면 null */ drop?: (index: number, parent: string | null, before: string | null) => (() => void) | null; + /** 줄 펼침(702-1) — 표 root 를 받아 표 아래 자리를 돌려줌 */ + fold?: (table: HTMLElement) => HTMLElement; }; const NEW_NAME = "새 줄"; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts new file mode 100644 index 000000000..4bf765137 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts @@ -0,0 +1,235 @@ +/* ============================================================================= + * 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 = { + qty: "qty", + labor_price: "labor", + material_price: "material", + expense_price: "expense", +}; +const LABEL: Record = { 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, 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( + (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, + 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("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; +}