/* ============================================================================= * 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 창 재사용 * ========================================================================== */ 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"]; 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); } /** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종) */ function slot(rows: DataRow[], i: number, below: boolean): { section: string; after: string } { const row = rows[i]; const section = String(row.section); if (row.kind === "title") return { section, after: `sec:${section}` }; if (below) return { section, after: String(row.key) }; const prev = rows[i - 1]; const same = prev && prev.kind !== "title" && prev.section === section; return { section, after: same ? String(prev.key) : `sec:${section}` }; } function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void { const bill = billOf(ctx.user); const key = `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const at = slot(rows, i, below); bill.rows.push({ key, ...at, name: NEW_NAME, spec: "", unit: "", qty: null, ref: null }); put(ctx, bill); } /** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 옮김 */ 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; put(ctx, bill); } /** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */ 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.이름)); } /** 줄 우클릭 메뉴 — 위 · 아래 줄 추가 · (손 줄) 연결 · 연결 풀기 · 지우기 · (자동 줄) 수량 자동으로 */ 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.hand) { const ref: Row | null = bill.rows.find((h) => h.key === key)?.ref ?? null; items.push("-", { label: "연결", children: [ ["일위대가 호표", () => pickHopyo(ctx, key, "dg")], ["단가산출 호표", () => pickHopyo(ctx, key, "sg")], ["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)], ], }); if (ref?.tab === "logic") items.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]); if (ref) items.push(["연결 풀기", () => setRef(ctx, key, null)]); 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; /** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */ export function attachRowMenu( root: HTMLElement, menu: (index: number) => MapContextMenuItem[], ): void { shared ??= createMapContextMenu("b09-estimation"); const ctxMenu = shared; if (!ctxMenu.element.isConnected) { ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) document.body.append(ctxMenu.element); } root.addEventListener("contextmenu", (event) => { const target = event.target as Element; if (target.closest("input")) return; 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); }); }