/* ============================================================================= * 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`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림 * M02 단가산출 블록 줄(`sk` · 719-2) = 되돌림 [단가산출 값으로](셈 값 = 단가산출 값) · 곁 글 = 품셈 값(`poom`) * 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로) * 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`) * 엑셀 붙여넣기(705-2) = 표를 누르고 Ctrl+V → 서버가 붙인 글을 읽어 지금 호표 끝 고정 줄(덮개 `@add` · 캐시 · * [저장] 때 서버가 다시 셈) · 열 짝 = M02 와 같은 물음 · 기억 · 알림 누름 = 「이 공사 단가로 다시 잇기」(이 공사 단가 책) * ========================================================================== */ 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 { MapContextMenuItem } from "@ui/ui_template_context_menu"; 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"; import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu"; import { API_BASE_URL } from "@config/config_frontend"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_toast"; import { currentProjectId } from "../A00_Common/b_project_switch"; import { ADD } from "./B09_Estimation_Calc_Labor"; import { withCols } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Paste"; import { openRelink, type RelinkSource, } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink"; import { relinkRows, type PasteCols } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api"; /** 고칠 칸 → 덮개 칸 */ 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 }) : "빈칸"; /** 되돌림 값 이름 — 단가산출 블록 줄 = 단가산출 값 · 그 밖 = 품셈 값 */ const backName = (line: Row | undefined): string => (line?.sk ? "단가산출 값" : "품셈 값"); /** 단가산출에서 고친 줄의 품셈 값 곁 글 */ const poomText = (line: Row): string => line.poom ? `품셈 품 ${plain(line.poom.qty)} · 단가 ${plain(line.poom.단가)}` : ""; /** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 · 단가산출 줄 = 품셈 값 곁 */ function baseText(line: Row): string { const covered: string[] = line.covered || []; const poom = poomText(line); if (!covered.length) return poom ? `단가산출 고침 — ${poom}` : "같음"; const base = covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · "); return line.sk ? `단가산출 ${base}${poom ? ` (${poom})` : ""}` : base; } 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, raw: DataRow[], row: Row, block: Row, close: () => void, go: (key: string) => 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, 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; const poom = poomText(at.line); const back = backName(at.line); return `고친 칸 — ${back} ${plain(at.line.base?.[field])}${poom ? ` · ${poom}` : ""} · 우클릭 [${back}으로]`; }, "tr[data-i] td", ); attachRowMenu(table.root, (i) => { const at = lines[i]; const undo: MapContextMenuItem[] = at && (at.line.covered || []).length ? [[`${backName(at.line)}으로`, () => clearCovers(ctx, [at])], "-"] : []; return [...undo, ...foldMenu(ctx, raw, row, block, at?.line, go)]; }); const actions = [ ...(edited.length ? [ createButton({ label: `고친 칸 ${edited.length}줄 모두 ${backName((block.lines || []).find((l: Row) => l.sk))}으로`, 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( `품 · 수량 · 단가 칸을 두 번 눌러 고침 · 색 칸 = 고친 칸(이 공사만 · 새 단가 판 · [재적용] 뒤에도 남음) · 우클릭 [${backName((block.lines || []).find((l: Row) => l.sk))}으로] · [저장] 때 서버가 다시 셈`, "muted", ), ], }); card.classList.add("b09-fold"); if (block.cover) { table.root.tabIndex = 0; // 705-2 — 눌러 고르면 Ctrl+V 받음 card.addEventListener("paste", (ev) => { if ((ev.target as Element | null)?.closest("input, textarea")) return; const text = ev.clipboardData?.getData("text/plain") ?? ""; if (!text.trim()) return; ev.preventDefault(); void pasteFold(ctx, block.cover, text); }); } return card; } async function post(path: string, body: Row): Promise { const response = await fetch( `${API_BASE_URL}/projects/${currentProjectId()}/estimation/${path}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, ); const got = ((await response.json().catch(() => null)) as Row | null) ?? {}; if (!response.ok) throw new Error(typeof got.detail === "string" ? got.detail : `HTTP ${response.status}`); return got; } const COST_PART = { 노무비: "labor", 재료비: "material", 경비: "expense" } as const; /** 붙인 줄 다시 잇기(705-2) — 후보 = 서버(M02 이음 짝) · 단가 = 이 공사 단가 책 · 바꿈 = 캐시 + 서버 셈 */ function relinkFold(ctx: TabContext, cover: string, from: Row): RelinkSource { let now = from; // 붙인 뒤 캐시(ctx.user 는 그리기 전 값) — 바꿀 때마다 새 값 const added = (): Row[] => ((now.daega_cover || {})[cover] || {})[ADD] || []; const book = (kind: "노임" | "자재", key: string): number | null | undefined => (ctx.payload.inputs?.[kind === "노임" ? "labor" : "materials"] || []).find( (r: Row) => r.key === key, )?.price; return { title: "이 공사 단가로 다시 잇기 — 붙인 줄", priceLabel: "이 공사 단가", price: book, rows: () => relinkRows( { layer: "personal", owner: null }, { 문서: { 담은로직: added() .filter((ln) => ln.kind === "fixed") .map((ln) => ({ 고정: { 이름: ln.name, 규격: ln.spec, 단위: ln.unit, 단가: Object.fromEntries( Object.entries(COST_PART).map(([k, p]) => [k, ln.own?.[p] ?? 0]), ), 이음: { 노임: ln.link?.labor ?? null, 자재: ln.link?.material ?? null }, }, 수량: { 손: ln.qty }, })), }, }, ), apply: async (changes) => { const lines: Row[] = JSON.parse(JSON.stringify(added())); const back = []; for (const c of changes) { const ln = lines.find((x) => x.cid === `${ADD}|${c.줄}`); if (!ln) continue; back.push({ 키: "", 줄: c.줄, 노임: ln.link?.labor ?? null, 자재: ln.link?.material ?? null, }); ln.link = { ...(ln.link || {}) }; if ("노임" in c) ln.link.labor = c.노임 ?? null; if ("자재" in c) ln.link.material = c.자재 ?? null; } const all: Row = JSON.parse(JSON.stringify(now.daega_cover || {})); all[cover] = { ...(all[cover] || {}), [ADD]: lines }; const user = { ...now, daega_cover: all }; now = user; Object.assign(ctx.payload, await post("calc", { user })); // 새 이음 키 = 이 공사 단가 책 수요 ctx.setUser(user); return { back, note: `바뀐 줄 ${back.length} — [저장]으로 남김` }; }, }; } /** 붙여넣기(705-2) — 붙인 글 그대로 서버로 · 답 = 새 캐시 + 그 캐시로 셈한 원가 자료 */ async function pasteFold(ctx: TabContext, cover: string, text: string): Promise { showLoadingOverlay(); try { const got = await withCols( (cols: PasteCols | null) => post("daega/paste", { cover, 글: text, ...(cols ? { 열: cols } : {}), user: ctx.user, }) as Promise, ); if (!got) return; Object.assign(ctx.payload, got.payload); ctx.setUser(got.user); const won = Math.round(Number(got.엑셀) || 0).toLocaleString("ko-KR"); showToast( `고정 줄 ${got.줄} 붙임 · 엑셀 합계 ${won} · 이음 ${got.이음} · 후보 ${got.이음후보} — [저장]으로 남김 · 이 알림을 누르면 이 공사 단가로 다시 잇기`, "success", 9000, () => void openRelink(relinkFold(ctx, cover, got.user)), ); } catch (error) { showToast(`붙여넣기 실패 — ${error instanceof Error ? error.message : String(error)}`, "error"); } finally { hideLoadingOverlay(); } } /** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */ export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement { const host = document.createElement("div"); /** 어디 쓰였나 → 그 줄로(보이게 · 펼침) */ const go = (key: string): void => { const i = raw.findIndex((r) => String(r.key) === key); table.querySelector(`tr[data-i="${i}"]`)?.scrollIntoView({ block: "center" }); open = key; draw(); }; 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, raw, row, block, () => { open = null; draw(); }, go, ), ] : []), ); }; 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; }