/* ============================================================================= * B09_Estimation_UI_Tab_Bill.ts * B09 설계내역서 탭 — STmate `wBoq` 본 (PLAN 12장 1차: 표시 + 들어가기) * * - 열은 실무 설계내역서 그대로: 공종 · 명칭 · 규격 · 수량 · 단위 · 합계/노무비/재료비/경비(단가·금액) · 비고. * - 머리글 줄은 ▸/▾ 로 접고, 「레벨」 고르개로 한 번에 접음(STmate `소계~총대계` 고르기의 우리 꼴). * - ⭐ 줄을 누르면 그 줄 호표(일위대가) → 호표 안 줄을 누르면 단가산출근거로 들어감(브레인 판정 핵심). * - 수동 단가로 선 줄 = 빨간 테두리 + 「미확정 N건」. * - ⚠ 금액·합계는 서버가 실은 값 — 여기서 더하지 않음(머리글 줄 금액도 서버 합). * ========================================================================== */ import { createButton, showToast } from "@ui/ui_template_elements"; import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; import { L, el, hint, linkButton, moneyCells, numberCell, quantity, sheetHead, sheetTable, unconfirmedBadge, won, } from "./B09_Estimation_UI_Sheet"; import { loadBill, type BillDto, type BillRowDto } from "./B09_Estimation_UI_Store"; /** 접힌 머리글 줄(공종 번호) — 탭을 오가도 남음. */ const collapsed = new Set(); let levelLimit = 0; // 0 = 모두 function rowMoney(row: BillRowDto): { unit: [string, string, string, string] | null; amount: [string, string, string, string] | null; } { const amount: [string, string, string, string] | null = row.amount_krw === null ? null : [row.amount_krw, row.labor_krw, row.material_krw, row.expense_krw]; if (row.is_group || row.unit_price_krw === null) return { unit: null, amount }; return { unit: [ row.unit_price_krw, row.unit_labor_krw ?? "", row.unit_material_krw ?? "", row.unit_expense_krw ?? "", ], amount, }; } function isHidden(row: BillRowDto, rows: BillRowDto[]): boolean { if (levelLimit > 0 && row.level > levelLimit) return true; for (const group of rows) { if ( group.is_group && collapsed.has(group.item_no) && row.item_no.startsWith(`${group.item_no}-`) ) { return true; } } return false; } function noteCell(row: BillRowDto, bill: BillDto, ctx: B09TabContext): HTMLTableCellElement { const cell = el("td", "b09s-note"); const sheet = bill.unit_price_sheet.entries.find((entry) => entry.code === row.price_code); if (sheet) cell.append(linkButton(sheet.label, () => ctx.open("unit_price", sheet.code))); const basis = bill.price_basis.entries.filter((entry) => (entry.unit_price_codes ?? []).includes(row.price_code), ); for (const entry of basis) { cell.append( linkButton(`${L("B09_Sheet_Basis_Short")} ${entry.number}`, () => ctx.open("price_basis", entry.code), ), ); } if (row.unconfirmed > 0) cell.append(unconfirmedBadge(row.unconfirmed)); // 비고 글 — 단산 번호는 위 단추로 보였으니 그 조각만 뺌. const rest = row.notes .map((note) => note.text) .filter((text) => text && text !== row.price_basis_label) .join(" / "); if (rest) cell.append(el("span", "", rest)); return cell; } function drawRows(tbody: HTMLElement, bill: BillDto, ctx: B09TabContext, redraw: () => void): void { for (const row of bill.rows) { if (isHidden(row, bill.rows)) continue; const tr = el("tr", row.is_group ? "is-group" : ""); const numberTd = el("td"); if (row.is_group) { const toggle = el("button", "b09s-toggle", collapsed.has(row.item_no) ? "▸" : "▾"); toggle.type = "button"; toggle.addEventListener("click", (event) => { event.stopPropagation(); if (collapsed.has(row.item_no)) collapsed.delete(row.item_no); else collapsed.add(row.item_no); redraw(); }); numberTd.append(toggle); } numberTd.append(document.createTextNode(row.item_no)); const name = el("td", "", row.name); name.style.paddingLeft = `${6 + Math.max(0, row.level - 1) * 12}px`; const { unit, amount } = rowMoney(row); tr.append( numberTd, name, el("td", "", row.spec), numberCell( row.is_group ? "" : quantity(row.quantity_shown ?? row.quantity, row.quantity_digits), ), el("td", "", row.unit), ...moneyCells(unit, amount), ); if (row.is_group) { tr.append(el("td")); } else { tr.append(noteCell(row, bill, ctx)); if (row.unconfirmed > 0) tr.classList.add("is-manual"); if (row.price_code) { tr.classList.add("is-clickable"); tr.title = L("B09_Sheet_Open_UnitPrice"); tr.addEventListener("click", () => ctx.open("unit_price", row.price_code)); } } tbody.append(tr); } } function levelPicker(bill: BillDto, redraw: () => void): HTMLElement { const select = el("select", "ui-input"); const deepest = Math.max(1, ...bill.rows.map((row) => row.level)); const all = el("option", "", L("B09_Sheet_Level_All")); all.value = "0"; select.append(all); for (let level = 1; level < deepest; level += 1) { const option = el("option", "", `${level}${L("B09_Sheet_Level_Upto")}`); option.value = String(level); select.append(option); } select.value = String(levelLimit); select.addEventListener("change", () => { levelLimit = Number(select.value); redraw(); }); const label = el("label", "b09s-bar", L("B09_Sheet_Level")); label.append(select); return label; } function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void { const redraw = (): void => { ctx.body.replaceChildren(); drawBill(ctx, bill, reload); }; const bar = el("div", "b09s-bar"); bar.append( createButton({ label: L("B09_Sheet_Reload"), variant: "ghost", onClick: reload }), levelPicker(bill, redraw), el( "span", "", `${L("B09_Sheet_BodyTotal")} ${won(bill.summary.body_total_krw)}${L("B09_Sheet_Won")}`, ), ); if (bill.summary.unconfirmed_count > 0) bar.append(unconfirmedBadge(bill.summary.unconfirmed_count)); ctx.body.append(bar); const { wrap, tbody } = sheetTable( sheetHead([ L("B09_Sheet_Col_ItemNo"), L("B09_Sheet_Col_Name"), L("B09_Sheet_Col_Spec"), L("B09_Sheet_Col_Quantity"), L("B09_Sheet_Col_Unit"), ]), ); drawRows(tbody, bill, ctx, redraw); const sum = el("tr", "is-sum"); sum.append(el("td"), el("td", "", L("B09_Sheet_BodyTotal")), el("td"), el("td"), el("td")); sum.append(...moneyCells(null, [bill.summary.body_total_krw, "", "", ""]), el("td")); tbody.append(sum); ctx.body.append(wrap); // 금액을 못 세운 줄 — 0 으로 안 때우고 이름째 보임(서버 `missing`). if (bill.summary.missing.length > 0) { const details = el("details"); details.append( el( "summary", "b09s-hint b09s-hint--warn", `${L("B09_Sheet_Missing")} ${bill.summary.missing.length}${L("B09_Sheet_Count")}`, ), ); for (const item of bill.summary.missing) { details.append(hint(`${item.name} — ${item.reason}`)); } ctx.body.append(details); } for (const note of bill.summary.notes) ctx.body.append(hint(note)); } export const billTab: B09Tab = { key: "boq", label: () => L("B09_Estimation_Tab_Boq"), render(ctx) { if (!ctx.projectId) { ctx.body.append(hint(L("B09_Sheet_NoProject"))); return; } const projectId = ctx.projectId; const show = (force: boolean): void => { ctx.body.replaceChildren(hint(L("B09_Sheet_Loading"))); loadBill(projectId, force) .then((bill) => { ctx.body.replaceChildren(); drawBill(ctx, bill, () => show(true)); }) .catch((error: Error) => { ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); showToast(L("B09_Sheet_LoadFailed"), "error"); }); }; show(false); }, };