/* ============================================================================= * B08_Quantity_UI_MaterialGrid.ts * 자재총괄표·구조물 원단위 그리드 (PLAN 8-2·8-6·8-7). * * 자재총괄 열은 순수량·할증률·합계 셋이고 **금액이 없다** — 금액은 B09 몫이다. * * ⚠ 「모르는 값」을 빈칸으로 두지 않는다. 할증률 미확보·관급구분 미분류·설치주체 미지정은 * 모두 화면에 **글자로** 뜬다. 0 % 나 빈칸으로 두면 「할증 없음」과 구별이 안 되고, * 설치 주체를 못 정한 채 넘어가면 B09 안전관리비가 조용히 틀린다. * * ⚠ 반올림은 여기서만 한다(PLAN 8-16 표기 자리 ≠ 계산 자리). 서버가 준 값은 전정밀이다. * ========================================================================== */ export interface MaterialRow { name: string; unit: string; net_amount: number; surcharge_pct: number | null; total_amount: number; supply: string; supply_label: string; install_by: string | null; install_by_label: string; note: string; sources: string[]; } export interface MaterialTable { columns: string[]; rows: MaterialRow[]; surcharge_applied: boolean; surcharge_dataset: { effective_date: string; source: Record }; missing_rate_materials: string[]; missing_supply_materials: string[]; missing_install_by_materials: string[]; double_count_warnings: string[]; skipped_by_destination: Record; row_count: number; } export interface UnitQuantityStructure { structure_id: string | null; type_id: string; name: string; length_m: number; height_m: number; notes: string[]; components: { name: string; unit: string; amount: number; destination: string; basis: string; }[]; } export interface MaterialResponse { unit_quantity: { structures: UnitQuantityStructure[]; totals: { name: string; unit: string; amount: number; destination: string }[]; surcharge_applied: boolean; mix_components_found: string[]; structure_count: number; }; material: MaterialTable; skipped_structures: string[]; structure_count: number; } /** 성분이 어디로 가는지 — 화면에서도 보이게 한다. 규칙이 코드에만 있으면 잊힌다. */ const DESTINATION_LABELS: Record = { earthwork: "토공 합산", material: "자재총괄", unit_price: "일위대가", }; function num(value: number | null | undefined, digits: number): string { if (value === undefined || value === null || Number.isNaN(value)) return ""; return value.toLocaleString("ko-KR", { minimumFractionDigits: digits, maximumFractionDigits: digits, }); } function textCell(text: string, className?: string): HTMLTableCellElement { const td = document.createElement("td"); td.textContent = text; if (className) td.className = className; return td; } function headRow(labels: string[]): HTMLTableSectionElement { const head = document.createElement("thead"); const tr = document.createElement("tr"); for (const label of labels) { const th = document.createElement("th"); th.textContent = label; tr.append(th); } head.append(tr); return head; } /** 못 정한 값 안내 — 목록이 있을 때만 뜬다. 매번 뜨면 잡음이 된다. */ function warning(title: string, items: string[]): HTMLElement | null { if (!items.length) return null; const element = document.createElement("p"); element.className = "b08-grid__caption b08-grid__caption--warn"; element.textContent = `${title}: ${items.join(" · ")}`; return element; } /** 자재총괄표 — 할증이 붙는 유일한 자리. */ export function renderMaterialGrid(table: MaterialTable): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; const caption = document.createElement("p"); caption.className = "b08-grid__caption"; const edition = table.surcharge_dataset?.effective_date || "판 미상"; caption.textContent = `자재 ${table.row_count}종 · 할증률 ${edition} 판 적용 · 금액은 원가계산(B09)에서`; wrap.append(caption); for (const notice of [ warning("⚠ 중복 할증 위험", table.double_count_warnings), warning("할증률 미확보", table.missing_rate_materials), warning("관급구분 미분류", table.missing_supply_materials), warning("설치 주체 미지정(관급)", table.missing_install_by_materials), ]) { if (notice) wrap.append(notice); } if (!table.rows.length) { const empty = document.createElement("p"); empty.className = "b08-quantity__message"; empty.textContent = "구조물에서 나온 자재가 없음 — 구조물을 먼저 배치할 것"; wrap.append(empty); return wrap; } const scroller = document.createElement("div"); scroller.className = "b08-grid__scroll"; const element = document.createElement("table"); element.className = "b08-grid__table b08-grid__table--summary"; element.append(headRow(table.columns)); const body = document.createElement("tbody"); for (const row of table.rows) { const tr = document.createElement("tr"); tr.append(textCell(row.name, "b08-grid__station")); tr.append(textCell(row.unit, "b08-grid__unit")); tr.append(textCell(num(row.net_amount, 2))); // 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다. tr.append(textCell(row.surcharge_pct === null ? "-" : num(row.surcharge_pct, 0))); tr.append(textCell(num(row.total_amount, 2))); tr.append(textCell(row.supply_label)); tr.append(textCell(row.install_by_label)); tr.append(textCell(row.note, "b08-grid__note")); body.append(tr); } element.append(body); scroller.append(element); wrap.append(scroller); return wrap; } /** 구조물 원단위 — 치수에서 성분까지. 성분마다 갈 곳을 적는다. */ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; const unit = response.unit_quantity; const caption = document.createElement("p"); caption.className = "b08-grid__caption"; caption.textContent = `구조물 ${unit.structure_count}개 · 치수는 구조물 정본(B05)에서 · 할증 전 값`; wrap.append(caption); // ㉢ 배합이 섞였으면 화면에도 뜬다 — 코드 검사만으로는 사람이 모른다. const mixed = warning("⚠ 배합 성분이 섞였음(B09 일위대가와 이중계상)", unit.mix_components_found); if (mixed) wrap.append(mixed); const skipped = warning("건너뛴 구조물", response.skipped_structures); if (skipped) wrap.append(skipped); if (!unit.structures.length) { const empty = document.createElement("p"); empty.className = "b08-quantity__message"; empty.textContent = "배치된 구조물이 없음"; wrap.append(empty); return wrap; } const scroller = document.createElement("div"); scroller.className = "b08-grid__scroll"; const element = document.createElement("table"); element.className = "b08-grid__table b08-grid__table--summary"; element.append(headRow(["구조물", "규격", "성분", "단위", "수량", "갈 곳", "근거"])); const body = document.createElement("tbody"); for (const structure of unit.structures) { const spec = `H=${num(structure.height_m, 1)} · L=${num(structure.length_m, 1)}m`; if (!structure.components.length) { const tr = document.createElement("tr"); tr.append(textCell(structure.name, "b08-grid__station")); tr.append(textCell(spec)); const note = textCell(structure.notes.join(" · "), "b08-grid__note"); note.colSpan = 5; tr.append(note); body.append(tr); continue; } let first = true; for (const component of structure.components) { const tr = document.createElement("tr"); // 같은 구조물이 이어지면 이름을 한 번만 적는다 — 실무 시트가 그렇게 병합해 둔다. tr.append(textCell(first ? structure.name : "", "b08-grid__station")); tr.append(textCell(first ? spec : "")); first = false; tr.append(textCell(component.name)); tr.append(textCell(component.unit, "b08-grid__unit")); tr.append(textCell(num(component.amount, 3))); tr.append(textCell(DESTINATION_LABELS[component.destination] ?? component.destination)); tr.append(textCell(component.basis, "b08-grid__note")); body.append(tr); } } element.append(body); scroller.append(element); wrap.append(scroller); return wrap; }