/* ============================================================================= * B09_Estimation_UI_Tabs_Bill_Edit.ts * B09 설계내역서 손질(PLAN 166-2 · 182 · 184-3) — 모든 줄 우클릭 메뉴 · 칸 고침 · 연결 · 차례 · 레벨. * * 저장 = 사용자 값 `user.bill` v2(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시 * {v: 2, rows: [표 차례 {key · parent(없음 = 맨 위) · 고친 칸만}], gone: [지운 자동 줄 키]} * 자동 줄 = 키 · 부모 + 고친 칸(name · spec · unit · qty · ref · null = 일부러 빈칸 · 없음 = 원천) * 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위 묶음 · cost direct * 첫 손질 = 보이는 줄의 키 · 부모 · 차례만 v2 로 굳힘(옛 꼴 qty · 손 줄 칸은 옮김 · 자동 값은 안 옮김) * 계약 = `Calc_Bill.py` 머리 — 셈 출력 줄 parent · auto(기본 로직 줄) · auto_{칸}(고친 칸의 자동 값) · level · * 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비) * 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기 * 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; import { createMapContextMenu, type MapContextMenu, type MapContextMenuItem, } from "@ui/ui_template_context_menu"; import type { Row } from "./B09_Estimation_Calc_Base"; import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs"; import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; /** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */ export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[] }; const NEW_NAME = "새 줄"; const NEW_SECTION = "새 공종"; /** 고칠 칸 — 자동 줄은 덮개 */ const FIELDS = ["name", "spec", "unit", "qty", "ref"]; /** 깊이 한도 — 1 = 맨 위 공종 */ const MAX_LEVEL = 5; interface Bill { v: 2; rows: Row[]; gone: string[]; } /** 손질 한 번 = 굳힌 덮개 + 보이는 줄(키 → 셈 출력 줄) */ interface Work { bill: Bill; shown: Map; } /** 옮기기 · 지우기 · 연결 없는 줄 — 조달수수료 · 관급자재대 제목 */ const fixed = (key: string): boolean => key === "fee" || key === "sec:G"; /** 연결 ▾ 있는 줄 — 항목 줄(자재 = 대비표 단가 · 조달수수료 = 율 빼고) */ export const linkable = (row: DataRow | undefined): boolean => row?.kind === "item" && !fixed(String(row.key)) && !String(row.key).startsWith("mat:"); const find = (list: Row[], key: string): number => list.findIndex((e) => e.key === key); const pull = (list: Row[], key: string): Row => list.splice(find(list, key), 1)[0]; const same = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); /** 옛 꼴(166-2 · 182) 줄 → v2 칸 — 자동 줄 = 수량 덮어쓰기 · 손 줄 = 모든 칸 */ function oldEntry(row: DataRow, bill: Row): Row { const key = String(row.key); if (row.auto) return key in (bill.qty || {}) ? { qty: bill.qty[key] } : {}; const hand: Row = (bill.rows || []).find((h: Row) => h.key === key) || {}; const out: Row = row.kind === "item" ? {} : { kind: "group" }; for (const f of FIELDS) if (f in hand) out[f] = hand[f]; return out; } /** 굳힘 — v2 면 그대로 + 덮개에 없는 보이는 줄(새 자동 줄)을 보이는 앞 줄 뒤에 · 옛 꼴이면 보이는 줄로 */ function freeze(user: Row, rows: DataRow[]): Work { const bill: Row = user.bill || {}; const v2 = bill.v === 2; const list: Row[] = v2 ? (bill.rows || []).map((e: Row) => ({ ...e })) : []; const gone: string[] = v2 ? [...(bill.gone || [])] : []; const shown = new Map(rows.map((r) => [String(r.key), r])); let prev = -1; for (const row of rows) { const key = String(row.key); if (key === "fee") continue; // 셈이 관급 묶음 끝에 둠 let at = find(list, key); if (at < 0 && !gone.includes(key)) { const entry: Row = { key, ...(row.parent ? { parent: row.parent } : {}) }; list.splice(prev + 1, 0, v2 ? entry : { ...entry, ...oldEntry(row, bill) }); at = prev + 1; } if (at >= 0) prev = at; } return { bill: { v: 2, rows: list, gone }, shown }; } function put(ctx: TabContext, bill: Bill): void { ctx.setUser({ ...ctx.user, bill }); } /** 위로 올라가는 줄 — [자기, 부모, …, 맨 위] */ function chain(list: Row[], key: string): Row[] { const out: Row[] = []; let at: Row | undefined = list[find(list, key)]; while (at && out.length <= list.length) { out.push(at); at = at.parent ? list[find(list, at.parent)] : undefined; } return out; } /** 차례 바로잡음 — 부모 먼저 · 자손 이어서(목록 차례 = 형제 차례) · 못 찾는 부모 · 돎 = 맨 위 */ function tidy(list: Row[]): Row[] { const keys = new Set(list.map((e) => e.key)); for (const e of list) if (!e.parent || !keys.has(e.parent)) delete e.parent; for (const e of list) if (chain(list, e.key).length > list.length) delete e.parent; const out: Row[] = []; // ponytail: 형제 찾기 O(n²) — 내역 줄 수(수백)면 충분 const walk = (parent?: string): void => { for (const e of list) if (e.parent === parent) { out.push(e); walk(e.key); } }; walk(); return out; } /** 맨 위 묶음 돈 갈래 — direct(직접비) | supply(관급자재대) */ function costOf(w: Work, list: Row[], key: string): string { const top = chain(list, key).at(-1); return top?.cost ?? w.shown.get(String(top?.key))?.cost ?? "direct"; } /** 바꾼 뒤 지킬 것 — 깊이 5 · 자재 줄 돈 갈래 그대로 */ function fits(w: Work, before: Row[]): boolean { return w.bill.rows.every( (e) => chain(w.bill.rows, e.key).length <= MAX_LEVEL && (!String(e.key).startsWith("mat:") || costOf(w, w.bill.rows, e.key) === costOf(w, before, e.key)), ); } /** 손질 시도 — 지킬 것 안이면 [동작] · 아니면 null(메뉴에 안 보임) */ function attempt( ctx: TabContext, rows: DataRow[], change: (w: Work) => boolean | void, ): (() => void) | null { const w = freeze(ctx.user, rows); const before = w.bill.rows.map((e) => ({ ...e })); if (change(w) === false) return null; w.bill.rows = tidy(w.bill.rows); return fits(w, before) ? () => put(ctx, w.bill) : null; } const entryOf = (w: Work, key: string): Row | undefined => w.bill.rows[find(w.bill.rows, key)]; const isGroup = (w: Work, key: string): boolean => (entryOf(w, key)?.kind ?? w.shown.get(key)?.kind ?? "item") !== "item"; /** 보이는 형제(차례대로) */ const siblings = (w: Work, parent: unknown): Row[] => w.bill.rows.filter((e) => (e.parent ?? null) === (parent ?? null) && w.shown.has(e.key)); const newKey = (): string => `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const newItem = (parent?: string): Row => ({ key: newKey(), ...(parent ? { parent } : {}), name: NEW_NAME, spec: "", unit: "", qty: null, ref: null, }); /** 칸 고침 — 자동 줄은 덮개(자동 값과 같으면 덮개 지움) · 새 줄은 그 칸 */ export function editCell( ctx: TabContext, rows: DataRow[], i: number, field: string, value: unknown, ): void { const row = rows[i]; if (!row || !FIELDS.includes(field)) return; const w = freeze(ctx.user, rows); const e = entryOf(w, String(row.key)); if (!e) return; const auto = `auto_${field}` in row ? row[`auto_${field}`] : row[field]; if (row.auto && same(auto, value)) delete e[field]; else e[field] = value; put(ctx, w.bill); } /** 연결 적용 — 새 줄이 「새 줄」 · 빈 이름이면 고른 호표 명칭 · 규격 · 단위를 채움 */ function applyRef(ctx: TabContext, rows: DataRow[], i: number): ApplyRef { return (ref, from) => { const row = rows[i]; const w = freeze(ctx.user, rows); const e = entryOf(w, String(row?.key)); if (!row || !e) return; e.ref = ref; if (from && !row.auto && (!e.name || e.name === NEW_NAME)) { e.name = from.name || e.name; e.spec = e.spec || from.spec || ""; e.unit = e.unit || from.unit || ""; } put(ctx, w.bill); }; } /** 줄 연결 메뉴 — 지금 연결 = 덮개(로직 입력값 등) 있으면 덮개 · 없으면 셈 줄 */ function linkFor(ctx: TabContext, rows: DataRow[], i: number) { const row = rows[i]; const e = entryOf(freeze(ctx.user, rows), String(row.key)); const ref: Row | null = e && "ref" in e ? e.ref : (row.ref ?? null); return linkItems(ctx, ref, applyRef(ctx, rows, i)); } /** 줄 우클릭 메뉴 — 더함 · 연결 · 옮김 · 레벨 · 자동값으로 · 지움(모든 줄 · 지킬 것 안만) */ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] { const row = rows[i]; if (!row) return []; const key = String(row.key); const parts: MapContextMenuItem[][] = [[], [], [], []]; const add = (part: number, label: string, change: (w: Work, e: Row) => boolean | void): void => { const run = attempt(ctx, rows, (w) => { // 조달수수료 = 차례에 없음(셈이 관급자재대 끝에 둠) — 부모만 빌림 const e = entryOf(w, key) ?? (key === "fee" ? { key, parent: row.parent } : undefined); return e ? change(w, e) : false; }); if (run) parts[part].push([label, run]); }; // 더함 — 위 · 아래(같은 부모) · 하위(묶음) · 새 공종(맨 위 · 관급자재대 앞) add(0, "위에 줄 추가", (w, e) => { if (!e.parent) return false; const at = find(w.bill.rows, key); w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, newItem(e.parent)); }); add(0, "아래에 줄 추가", (w, e) => { if (!e.parent || key === "fee") return false; w.bill.rows.splice(find(w.bill.rows, key) + 1, 0, newItem(e.parent)); }); add(0, "아래에 하위 줄 추가", (w) => { if (!isGroup(w, key)) return false; w.bill.rows.push(newItem(key)); }); add(0, "새 공종 추가(1단)", (w, e) => { const list = w.bill.rows; const from = find(list, key) < 0 ? String(e.parent) : key; const top = chain(list, from).at(-1)!; const at = find(list, top.key) + (costOf(w, list, from) === "supply" ? 0 : 1); list.splice(at, 0, { key: newKey(), kind: "group", cost: "direct", name: NEW_SECTION }); }); if (linkable(row)) { const [picks, more] = linkFor(ctx, rows, i); parts[1].push({ label: "연결", children: picks }, ...more); } // 옮김 · 레벨 — 같은 부모 형제와 자손째 if (!fixed(key)) { add(2, "위로 ↑", (w, e) => { const sib = siblings(w, e.parent); const k = sib.indexOf(e); if (k < 1) return false; pull(w.bill.rows, key); w.bill.rows.splice(find(w.bill.rows, sib[k - 1].key), 0, e); }); add(2, "아래로 ↓", (w, e) => { const sib = siblings(w, e.parent); const k = sib.indexOf(e); if (k < 0 || k + 1 >= sib.length) return false; pull(w.bill.rows, key); w.bill.rows.splice(find(w.bill.rows, sib[k + 1].key) + 1, 0, e); }); add(2, "들여쓰기 →", (w, e) => { const sib = siblings(w, e.parent); const up = sib[sib.indexOf(e) - 1]; if (!up || !isGroup(w, up.key)) return false; e.parent = up.key; w.bill.rows.push(pull(w.bill.rows, key)); }); add(2, "내어쓰기 ←", (w, e) => { const up = e.parent ? entryOf(w, e.parent) : undefined; if (!up?.parent) return false; // 맨 위로는 안 냄(새 공종 = 메뉴) e.parent = up.parent; pull(w.bill.rows, key); w.bill.rows.splice(find(w.bill.rows, up.key) + 1, 0, e); }); } if (!row.auto && row.kind === "item") add(2, "묶음 줄로 바꾸기", (_w, e) => { Object.assign(e, { kind: "group", qty: null, ref: null }); }); if (!row.auto && row.kind !== "item") add(2, "항목으로 바꾸기", (w, e) => { if (!e.parent) return false; delete e.kind; for (const c of w.bill.rows) if (c.parent === key) c.parent = e.parent; }); // 자동값으로 — 그 줄 고친 칸 지움(차례 · 부모는 그대로) if (row.auto && FIELDS.some((f) => `auto_${f}` in row)) add(3, "자동값으로 되돌리기", (_w, e) => { for (const f of FIELDS) delete e[f]; }); // 지움 — 자식은 지운 줄 부모로 · 자동 줄 = gone · 자식 있는 맨 위 묶음은 못 지움 if (!fixed(key) && !key.startsWith("mat:")) add(3, "줄 지우기", (w, e) => { const list = w.bill.rows; if (!e.parent && list.some((c) => c.parent === key)) return false; for (const c of list) if (c.parent === key) c.parent = e.parent; pull(list, key); if (row.auto) w.bill.gone.push(key); }); const out: MapContextMenuItem[] = []; for (const part of parts) if (part.length) out.push(...(out.length ? ["-" as const] : []), ...part); return out; } let shared: MapContextMenu | null = null; /** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */ let lastCell: Element | null = null; /** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */ export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void { const box = lastCell?.getBoundingClientRect(); if (!shared || !box || !linkable(rows[i])) return; const [picks, more] = linkFor(ctx, rows, i); shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks); } /** B09 메뉴 한 벌(우클릭 · 183-3 칸 고르기 같이 씀) — 화면 기준 자리 */ function sharedMenu(): MapContextMenu { shared ??= createMapContextMenu("b09-estimation"); if (!shared.element.isConnected) { shared.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) document.body.append(shared.element); } return shared; } /** 화면 자리(x · y)에 B09 메뉴를 엶(183-3 칸 고르기) */ export function openMenuAt(x: number, y: number, items: MapContextMenuItem[]): void { if (items.length) sharedMenu().open(x, y, items); } /** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위에서도 줄 메뉴 */ export function attachRowMenu( root: HTMLElement, menu: (index: number) => MapContextMenuItem[], ): void { const ctxMenu = sharedMenu(); // 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶 root.addEventListener( "click", (event) => (lastCell = (event.target as Element).closest("td")), true, ); root.addEventListener("contextmenu", (event) => { const target = event.target as Element; const row = target.closest("tr[data-i]"); if (!row || !root.contains(row)) return; event.preventDefault(); const items = menu(Number(row.dataset.i)); if (items.length) ctxMenu.open(event.clientX, event.clientY, items); }); }