/* ============================================================================= * B09_Estimation_UI_Tabs_Bill_Edit.ts * B09 설계내역서 손질(PLAN 166-2) — 우클릭 메뉴 · 손 줄 더하기 · 지우기 · 연결 고르기 · 수량 덮어쓰기. * * 저장 = 사용자 값 `user.bill`(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시 * qty{줄 키: 수} = 자동 줄 수량 덮어쓰기(빈칸 · [수량 자동으로] = 키 지움 → B08 값) * rows[] = 손 줄 {key hand:… · section · after(앞 줄 키 · 제목 = sec:공종) · 명칭 · 규격 · 단위 · 수량 · ref} * 연결 = 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) * 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) · 로직 고르기 · 입력값 = M02 창 재사용 * 레벨(182) = 손 줄 kind(group = 묶음 줄 · 이름만) · parent(묶음 손 줄 키) — 하위 줄 추가 · 묶음 줄로 · * 항목으로 · 들여쓰기 · 내어쓰기 · 손 묶음 깊이 2 · 「연결 ▾」 칸 = 우클릭 [연결] 과 같은 메뉴 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; import { createMapContextMenu, type MapContextMenu, type MapContextMenuItem, } from "@ui/ui_template_context_menu"; import { showToast } from "@ui/ui_template_elements"; import { createFindList } from "@ui/ui_template_find_list"; import { openModal } from "@ui/ui_template_modal"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; import type { Row } from "./B09_Estimation_Calc_Base"; import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs"; /** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */ export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[] }; const NEW_NAME = "새 줄"; const TEXT_KEYS = ["name", "spec", "unit"]; /** 182 손 묶음 깊이 — 묶음 안 묶음까지 */ const MAX_GROUP = 2; interface Bill { qty: Record; rows: Row[]; } function billOf(user: Row): Bill { const bill: Row = user.bill || {}; return { qty: { ...(bill.qty || {}) }, rows: (bill.rows || []).map((r: Row) => ({ ...r })) }; } function put(ctx: TabContext, bill: Bill): void { ctx.setUser({ ...ctx.user, bill }); } /** 손 줄 고칠 칸 — 명칭 · 규격 · 단위 · 수량 */ export const handEditable = (key: string): boolean => key === "qty" || TEXT_KEYS.includes(key); /** 수량 고침 — 손 줄 = 그 줄 수량 · 자동 줄 = 덮어쓰기(빈칸 = 자동으로) */ export function editQty(ctx: TabContext, row: DataRow, value: number | null): void { const bill = billOf(ctx.user); const key = String(row.key); if (row.hand) { const own = bill.rows.find((h) => h.key === key); if (!own) return; own.qty = value; } else if (value === null) delete bill.qty[key]; else bill.qty[key] = value; put(ctx, bill); } /** 손 줄 글 칸(명칭 · 규격 · 단위) */ export function editText(ctx: TabContext, key: string, field: string, value: string): void { const bill = billOf(ctx.user); const own = bill.rows.find((h) => h.key === key); if (!own || !TEXT_KEYS.includes(field)) return; own[field] = value.trim(); put(ctx, bill); } const lv = (row: DataRow | undefined): number => Number(row?.level ?? 0); /** 줄 i 자손 끝(다음 줄 번호) — 더 깊은 레벨 줄이 이어지는 데까지 */ function subtreeEnd(rows: DataRow[], i: number): number { let k = i + 1; while (k < rows.length && lv(rows[k]) > lv(rows[i])) k += 1; return k; } /** 부모 바꿈 — 없음 = 공종 바로 아래(키 지움) */ function reparent(h: Row, parent: unknown): void { if (parent) h.parent = parent; else delete h.parent; } /** 손 묶음 깊이 지킴 — 묶음 줄 위 묶음 ≤ 1 · 항목 위 묶음 ≤ 2(도는 부모는 셈이 끊음 · 여기선 멈춤) */ function fits(bill: Bill): boolean { const byKey = new Map(bill.rows.map((h) => [String(h.key), h])); return bill.rows.every((h) => { let n = 0; const seen = new Set([h.key]); let at = byKey.get(String(h.parent ?? "")); while (at && at.kind === "group" && !seen.has(at.key)) { seen.add(at.key); n += 1; at = byKey.get(String(at.parent ?? "")); } return n <= (h.kind === "group" ? MAX_GROUP - 1 : MAX_GROUP); }); } /** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종 · 아래 = 자손 끝) · 부모 = 그 줄 부모 */ function slot(rows: DataRow[], i: number, below: boolean): Row { const row = rows[i]; const section = String(row.section); if (row.kind === "title") return { section, after: `sec:${section}` }; const parent = row.hand && row.parent ? { parent: String(row.parent) } : {}; if (below) return { section, after: String(rows[subtreeEnd(rows, i) - 1].key), ...parent }; const prev = rows[i - 1]; const same = prev && prev.kind !== "title" && prev.section === section; return { section, after: same ? String(prev.key) : `sec:${section}`, ...parent }; } function newRow(ctx: TabContext, at: Row): void { const bill = billOf(ctx.user); const key = `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; bill.rows.push({ key, ...at, name: NEW_NAME, spec: "", unit: "", qty: null, ref: null }); put(ctx, bill); } function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void { newRow(ctx, slot(rows, i, below)); } /** 묶음 줄 아래 하위 줄 — 자리 = 마지막 자손 뒤 */ function addChild(ctx: TabContext, rows: DataRow[], i: number): void { const after = String(rows[subtreeEnd(rows, i) - 1].key); newRow(ctx, { section: String(rows[i].section), after, parent: String(rows[i].key) }); } /** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 · 자식은 지운 줄 부모로 옮김 */ function removeRow(ctx: TabContext, key: string): void { const bill = billOf(ctx.user); const gone = bill.rows.find((h) => h.key === key); if (!gone) return; bill.rows = bill.rows.filter((h) => h.key !== key); for (const h of bill.rows) if (h.after === key) h.after = gone.after; for (const h of bill.rows) if (h.parent === key) reparent(h, gone.parent); put(ctx, bill); } /** 레벨 바꿈 — 깊이 한도 안이면 [동작] · 넘으면 null(메뉴에 안 보임) */ function levelAction( ctx: TabContext, key: string, change: (own: Row, bill: Bill) => void, ): (() => void) | null { const bill = billOf(ctx.user); const own = bill.rows.find((h) => h.key === key); if (!own) return null; change(own, bill); return fits(bill) ? () => put(ctx, bill) : null; } /** 들여쓰기 대상 — 바로 위 같은 레벨 형제가 같은 공종 묶음 줄이면 그 줄 */ function indentTarget(rows: DataRow[], i: number): DataRow | null { const row = rows[i]; for (let j = i - 1; j >= 0; j -= 1) { const r = rows[j]; if (lv(r) > lv(row)) continue; return lv(r) === lv(row) && r.kind === "group" && r.section === row.section ? r : null; } return null; } /** 레벨 메뉴 — 묶음 줄로 · 항목으로 · 들여쓰기 → · 내어쓰기 ←(막내만 · 자리 안 어긋남) */ function levelMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] { const row = rows[i]; const key = String(row.key); const out: MapContextMenuItem[] = []; const add = (label: string, action: (() => void) | null): void => { if (action) out.push([label, action]); }; if (row.kind === "group") add( "항목으로 바꾸기", levelAction(ctx, key, (own, bill) => { delete own.kind; for (const h of bill.rows) if (h.parent === key) reparent(h, own.parent); }), ); else add( "묶음 줄로 바꾸기", levelAction(ctx, key, (own) => Object.assign(own, { kind: "group", qty: null, ref: null })), ); const target = indentTarget(rows, i); if (target) add( "들여쓰기 →", levelAction(ctx, key, (own) => reparent(own, String(target.key))), ); if (row.parent && lv(rows[subtreeEnd(rows, i)]) < lv(row)) add( "내어쓰기 ←", levelAction(ctx, key, (own, bill) => reparent(own, bill.rows.find((h) => h.key === row.parent)?.parent), ), ); return out; } /** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */ function setRef(ctx: TabContext, key: string, ref: Row | null, from?: Row): void { const bill = billOf(ctx.user); const own = bill.rows.find((h) => h.key === key); if (!own) return; own.ref = ref; if (from && (!own.name || own.name === NEW_NAME)) { own.name = from.name || own.name; own.spec = own.spec || from.spec || ""; own.unit = own.unit || from.unit || ""; } put(ctx, bill); } /** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */ function pickHopyo(ctx: TabContext, key: string, tab: "dg" | "sg"): void { const listing: Row[] = (tab === "dg" ? ctx.sheets.daega : ctx.sheets.sg_list) || []; if (!listing.length) return showToast("고를 호표 없음 — 연결된 구조물 조합 사본이 없음", "error"); const label = (r: Row): string => (tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`); openModal({ title: tab === "dg" ? "일위대가 호표 고르기" : "단가산출 호표 고르기", closeLabel: "닫기", mount: (body, close) => { const finder = createFindList({ placeholder: "호표 · 명칭 · 규격 찾기" }); const find = (): void => { const q = finder.search.value.trim().toLowerCase(); const found = listing.filter( (r) => !q || `${label(r)} ${r.name} ${r.spec}`.toLowerCase().includes(q), ); finder.hint.textContent = `${found.length} / ${listing.length}`; finder.setRows( found.map((r) => ({ key: String(r.key), tag: label(r), name: String(r.name || ""), sub: String(r.spec || ""), unit: String(r.unit || ""), onPick: () => { close(); setRef(ctx, key, { tab, key: r.key }, r); }, })), ); }; finder.search.addEventListener("input", find); find(); body.append(finder.root); requestAnimationFrame(() => finder.search.focus()); }, }); } /** M01 로직 → 입력값 창 → 손 줄 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */ function askInputs(ctx: TabContext, key: string, code: string, name: string, old?: Row): void { fetchLogicInputs([code]).then( (got) => { const logic = got.로직[0] ?? null; openLaborInput({ title: `${code} ${name}`.trim(), logic, values: old?.given || {}, memo: null, rate: old?.rate ?? null, readOnly: false, okLabel: "연결", notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 [저장] 뒤 서버가 셈", done: (next) => { const unit = String(logic?.결과단위 || "") .replace(/^원\//, "") .replace(/^[\d.]+/, ""); const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 }; setRef(ctx, key, { ...ref, picks: old?.picks ?? null }, { name, unit }); showToast("연결함 — 새 산근 단가는 [저장] 뒤에 채워짐", "success"); }, }); }, (error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"), ); } function pickLogic(ctx: TabContext, key: string): void { openLogicPicker((logic) => askInputs(ctx, key, logic.키, logic.이름)); } type Leaf = [string, () => void]; /** 손 항목 줄 연결 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] */ function linkItems(ctx: TabContext, key: string): [Leaf[], Leaf[]] { const ref: Row | null = billOf(ctx.user).rows.find((h) => h.key === key)?.ref ?? null; const more: Leaf[] = []; if (ref?.tab === "logic") more.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]); if (ref) more.push(["연결 풀기", () => setRef(ctx, key, null)]); const picks: Leaf[] = [ ["일위대가 호표", () => pickHopyo(ctx, key, "dg")], ["단가산출 호표", () => pickHopyo(ctx, key, "sg")], ["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)], ]; return [picks, more]; } /** 줄 우클릭 메뉴 — 위 · 아래(· 하위) 줄 추가 · (손 줄) 연결 · 연결 풀기 · 레벨 · 지우기 · (자동 줄) 수량 자동으로 */ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] { const row = rows[i]; if (!row) return []; const key = String(row.key); const bill = billOf(ctx.user); const items: MapContextMenuItem[] = []; if (row.kind !== "title") items.push(["위에 줄 추가", () => addRow(ctx, rows, i, false)]); items.push(["아래에 줄 추가", () => addRow(ctx, rows, i, true)]); if (row.kind === "group") items.push(["아래에 하위 줄 추가", () => addChild(ctx, rows, i)]); if (row.hand) { if (row.kind !== "group") { const [picks, more] = linkItems(ctx, key); items.push("-", { label: "연결", children: picks }, ...more); } items.push("-", ...levelMenu(ctx, rows, i)); items.push("-", ["줄 지우기", () => removeRow(ctx, key)]); } else if (row.kind === "item" && key in bill.qty) { items.push("-", [ "수량 자동으로 되돌리기", () => { delete bill.qty[key]; put(ctx, bill); }, ]); } return items; } let shared: MapContextMenu | null = null; /** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */ let lastCell: Element | null = null; /** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */ export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void { const row = rows[i]; const box = lastCell?.getBoundingClientRect(); if (!shared || !box || !row?.hand || row.kind !== "item") return; const [picks, more] = linkItems(ctx, String(row.key)); 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); }); }