/* ============================================================================= * B09_Estimation_UI_Tabs_Sangeun_Sheet.ts * B09 단가산출근거 탭(PLAN 719-4 · 719-3) — 산근 블록마다 카드 한 장 = 공용 「단가산출 계산서」(714-2 * `calcSheetRows` 묶음 꼴 · M01 「인력·재료·경비」 · M02 와 같은 꼴) + 품 · 단가 고침 표(702 꼴). * * 값 = 서버 풀이(`B09_Estimation_Sangeun`) + 이 공사 고친 칸 덮개(`sg_cover` · 짝 `…_Sangeun_Cover`) — * 화면은 받은 줄을 계산서 줄 꼴로 옮기기만(셈 없음) * 계산서 = 노무비 · 재료비 · 경비 묶음 → 공용 부품이 인력 · 재료 · 경비(장비) · 하위로 다시 묶음 · * 중기 줄 = 장비 한 줄(노 · 재 · 경) + 펼침(중기사용료 블록 손료 · 운전사 · 연료 · 잡품) · * 하위 산근 = 「산근 n호표」 한 줄 · 묶음 머리 = 기준단위 · 환산 근거(서버 줄 덧칸 `sheet`) * 고침(719-3) = [품 · 단가 고치기] 표에서 두 번 눌러 고침 → `sg_cover` 캐시 · 짝이 바로 다시 셈 · * [저장] 때 서버가 정본으로 다시 셈 · 고친 칸 색(`is-edited`) + 「원래 값」 칸 + 「이 공사에서 고침」 · * 우클릭 · 카드 단추 [단가산출 값으로] = 덮개 지움(셈 값 = 단가산출 값) · 빈칸 입력도 되돌림 * 옛 표 꼴(`buildSg`)은 곁 창(행 누름 팝업)에만 남음 * ========================================================================== */ import { createBanner } from "@ui/ui_template_banner"; import { createCard } from "@ui/ui_template_card"; import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import { cellColumn, createDataTable, fillViewport, type DataRow, } from "@ui/ui_template_data_table"; import { createButton, el } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { createText } from "@ui/ui_template_text"; import { calcSheetRows, type SheetGroup, type SheetLine } from "../A00_Common/calc_sheet"; import { formatNumber } from "../common_util/common_util_money"; import type { Row } from "./B09_Estimation_Calc_Base"; import { fields, lineId } from "./B09_Estimation_Sangeun_Cover"; import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs"; import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit"; const PARTS = ["labor", "material", "expense"] as const; const COST: Record = { labor: "노무비", material: "재료비", expense: "경비" }; export const TAG = "이 공사에서 고침"; const BACK = "단가산출 값"; const LABEL: Record = { qty: "품", labor: "노무비", material: "재료비", expense: "경비", }; const FIELD: Record = { qty: "qty", labor_price: "labor", material_price: "material", expense_price: "expense", }; const fmt = (v: unknown): string => (typeof v === "number" ? formatNumber(v) : "빈칸"); /* ── 순수 — 산근 블록 → 계산서 묶음 ── */ /** 줄이 쓰는 비목 */ export function partsOf(ln: Row): string[] { if (ln.kind === "labor") return ["labor"]; if (ln.kind === "machine" || ln.kind === "sangeun") return [...PARTS]; const own = Object.keys(ln.price || {}).filter((p) => (PARTS as readonly string[]).includes(p)); if (own.length) return own; const blank = PARTS.filter((p) => (ln[p] ?? null) === null); return blank.length ? blank : ["material"]; } /** 고친 줄 원래 값 글 — 「품 0.12 · 노무비 268,908」 */ export const baseText = (ln: Row): string => ((ln.covered || []) as string[]).map((k) => `${LABEL[k]} ${fmt(ln.base?.[k])}`).join(" · "); function lineText(ln: Row, part: string): string { const back = ln.covered?.length ? ` (원래 ${baseText(ln)})` : ""; if (ln.kind === "extra") return ln.unit === "%" ? `${String(ln.spec || "").replace("%", `${fmt(ln.qty)}%`)}${back} = ` : `${ln.spec || ""}${back} = `; return `${fmt(ln.price?.[part] ?? null)} * ${fmt(ln.qty)}${back} = `; } /** 산근 블록 → 공용 계산서 묶음(노무비 · 재료비 · 경비) · 계 */ export function sheetGroups( block: Row, machines: Row[], noOf: Record, ): SheetGroup[] { const byKey = new Map(machines.map((m) => [m.key as string, m])); return PARTS.map((part): SheetGroup => { const lines: SheetLine[] = []; for (const ln of (block.lines || []) as Row[]) { if (!partsOf(ln).includes(part)) continue; const sub = ln.kind === "sangeun" ? noOf[ln.ref?.key] : undefined; const name = `${ln.name || ""}${sub ? ` (산근 ${sub}호표)` : ""}${ln.covered?.length ? ` — ${TAG}` : ""}`; const m = ln.kind === "machine" ? byKey.get(ln.ref?.key) : undefined; lines.push({ 이름: name, 글: lineText(ln, part), 금액: ln[part] ?? null, ...(ln.kind === "machine" ? { 묶음: "경비", 장비: true } : {}), ...(ln.kind === "sangeun" ? { 묶음: "하위" } : {}), 쓰는입력: ln.sheet?.쓰는입력 ?? [], 기준단위: ln.sheet?.기준단위 ?? null, 환산근거: ln.sheet?.환산근거 ?? "", 펼침: m ? { 키: m.key, 이름: `${m.name}${m.spec ? ` ${m.spec}` : ""}`, 단위: m.unit || "시간", 줄: ((m.lines || []) as Row[]).map((x) => ({ 비목: COST[x.kind] ?? x.kind, 이름: x.name, 글: `${fmt(x.price)} * ${fmt(x.qty)}${x.unit === "%" ? "%" : ""} = `, 금액: x.amount ?? null, })), 계: m.sum ?? null, } : null, }); } return { 비목: COST[part], 소계: block[part] ?? null, 줄: lines }; }).filter((g) => g.줄.length); } /* ── 순수 끝 ── */ const opened = new Set(); // 고침 표 · 입력 펼친 블록(다시 그려도 그대로) let lastTop = 0; // 고친 뒤 다시 그려도 보던 자리 function setCover( ctx: TabContext, key: string, lid: string, field: string, value: number | null, ): void { const all: Row = JSON.parse(JSON.stringify(ctx.user.sg_cover || {})); const lines: Row = all[key] || {}; const cell: Row = { ...(lines[lid] || {}) }; if (value === null) delete cell[field]; else cell[field] = value; if (Object.keys(cell).length) lines[lid] = cell; else delete lines[lid]; if (Object.keys(lines).length) all[key] = lines; else delete all[key]; ctx.setUser({ ...ctx.user, sg_cover: all }); } function clearLines(ctx: TabContext, key: string, lids: string[]): void { const all: Row = JSON.parse(JSON.stringify(ctx.user.sg_cover || {})); for (const lid of lids) delete (all[key] || {})[lid]; if (all[key] && !Object.keys(all[key]).length) delete all[key]; ctx.setUser({ ...ctx.user, sg_cover: all }); } /** 품 · 단가 고침 표(702 꼴) */ function editTable(ctx: TabContext, block: Row): HTMLElement { const lines = (block.lines || []) as Row[]; const covered = (i: number, field: string | undefined): boolean => !!field && ((lines[i]?.covered || []) as string[]).includes(field); const rows: DataRow[] = lines.map((ln, n) => ({ key: lineId(n, ln), name: ln.name || "", spec: ln.spec || "", qty: ln.qty, unit: ln.unit || "", labor_price: partsOf(ln).includes("labor") ? (ln.price?.labor ?? null) : "", material_price: partsOf(ln).includes("material") ? (ln.price?.material ?? null) : "", expense_price: partsOf(ln).includes("expense") ? (ln.price?.expense ?? null) : "", amount: PARTS.some((p) => (ln[p] ?? null) === null) ? null : PARTS.reduce((s, p) => s + (ln[p] as number), 0), base: ln.covered?.length ? baseText(ln) : "", note: ln.covered?.length ? TAG : ln.why || "", })); 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", "원래 값", 160), text("note", "비고", 140), ]; const table = createDataTable({ columns, rows, total: { name: "총 계", amount: block.sum }, frozen: 1, empty: "", editOnDouble: true, onCellClick: () => undefined, // 줄 표식(data-i) — 두 번 눌러 고침 · 우클릭 · 풍선 자리 editable: (i, key) => !!FIELD[key] && fields(lines[i] || {}).includes(FIELD[key]), onEdit: (i, key, value) => { const ln = lines[i]; const field = FIELD[key]; const v = parseNumber(value); if (!ln || !field || v === undefined) return; const was = covered(i, field) ? ln.base?.[field] : field === "qty" ? ln.qty : ln.price?.[field]; if (v === null || v === was) { if (covered(i, field)) setCover(ctx, block.key, lineId(i, ln), field, null); return; } setCover(ctx, block.key, lineId(i, ln), field, v); }, cellClass: (i, key) => (covered(i, FIELD[key]) ? "is-edited" : null), }); table.root.dataset.b09SgEdit = block.key; attachHoverInfo( table.root, (cell) => { const field = FIELD[columns[cellColumn(cell as HTMLTableCellElement)]?.key ?? ""]; const i = Number(cell.parentElement?.dataset.i); if (!covered(i, field)) return null; return `${TAG} — ${BACK} ${fmt(lines[i].base?.[field])} · 우클릭 [${BACK}으로]`; }, "tr[data-i] td", ); attachRowMenu(table.root, (i): MapContextMenuItem[] => lines[i]?.covered?.length ? [[`${BACK}으로`, () => clearLines(ctx, block.key, [lineId(i, lines[i])])]] : [], ); return table.root; } function blockCard(ctx: TabContext, block: Row, noOf: Record): HTMLElement { let card: HTMLElement | null = null; const edited = ((block.lines || []) as Row[]) .map((ln, n) => (ln.covered?.length ? lineId(n, ln) : null)) .filter((x): x is string => !!x); const sheet = el("div", { className: "b09-sg-sheet", attrs: { "data-sg-sheet": block.key } }); const groups = sheetGroups(block, ctx.sheets.machine_blocks || [], noOf); const rows = groups.length ? calcSheetRows({ 묶음: groups, 계: block.sum ?? null, sumLabel: `계 (원/${block.unit || "식"})`, bundle: true, }) : [createText(block.why || "줄 없음", "warning")]; for (const r of rows) if (r.firstElementChild?.textContent?.includes(TAG)) r.classList.add("is-edited"); sheet.append(...rows); const heads = ((block.head || []) as string[]).slice(1).map((line) => createText(line, "hint")); const edit = opened.has(block.key) ? editTable(ctx, block) : null; heads.forEach((h) => (h.hidden = !opened.has(block.key))); const toggle = createButton({ label: opened.has(block.key) ? "고치기 닫기" : "품 · 단가 고치기", variant: "ghost", size: "sm", onClick: () => { if (opened.has(block.key)) opened.delete(block.key); else opened.add(block.key); card?.replaceWith(blockCard(ctx, block, noOf)); // 그 카드만 다시(값 · 캐시 그대로) }, }); const back = edited.length ? [ createButton({ label: `고친 칸 ${edited.length}줄 모두 ${BACK}으로`, variant: "ghost", size: "sm", onClick: () => clearLines(ctx, block.key, edited), }), ] : []; const meta = [ block.spec, `1 ${block.unit || "식"} 당`, block.source, (block.uses || []).length ? `쓰는 곳 ${(block.uses as string[]).join(" · ")}` : "", edited.length ? `${TAG} ${edited.length}줄` : "", ].filter(Boolean); card = createCard({ title: `제 ${block.no} 호표 — ${block.name || ""}`, actions: [...back, toggle], pad: 12, body: [ createText(meta.join(" · "), "muted"), ...heads, sheet, ...(edit ? [ edit, createText( `품 · 단가 칸을 두 번 눌러 고침 · 색 칸 = ${TAG}(이 공사만 · [업데이트] 뒤에도 남음) · 우클릭 [${BACK}으로] · [저장] 때 서버가 다시 셈`, "muted", ), ] : []), ], }); card.dataset.sgBlock = block.key; return card; } /** 단가산출근거 탭 판 — focus = 목록표에서 온 산근 키(그 카드로 스크롤) */ export function renderSangeunSheets(ctx: TabContext, focus?: string): HTMLElement[] { const blocks = (ctx.sheets.sg || []) as Row[]; if (!blocks.length) return [ createText(((ctx.sheets.sg_notes as string[]) || []).join(" · ") || "산근 없음", "muted", 14), ]; const noOf: Record = Object.fromEntries(blocks.map((b) => [b.key, b.no])); const orphans = (ctx.sheets.sg_orphans || []) as Row[]; const banner = orphans.length ? [ createBanner({ tone: "warning", text: `짝 잃은 고친 칸 ${orphans.length} — 로직이 바뀌어 줄을 못 찾음(덮개는 남음 · 값은 단가산출 값)`, }), ] : []; const list = el("div", { className: "b09-sg-sheets", children: blocks.map((b) => blockCard(ctx, b, noOf)), }); let placed = false; // 첫 높이 맞춤 뒤 한 번 — 목록표에서 온 블록 · 아니면 보던 자리 fillViewport(list, () => { if (placed) return; placed = true; const target = focus && list.querySelector(`[data-sg-block="${CSS.escape(focus)}"]`); if (target) target.scrollIntoView({ block: "start" }); else list.scrollTop = lastTop; }); list.addEventListener("scroll", () => (lastTop = list.scrollTop), { passive: true }); const notes = ((ctx.sheets.sg_notes as string[]) || []).join(" · "); return [...banner, list, createText(notes, "hint")]; }