/* ============================================================================= * B08_Quantity_UI_SummaryGrid.ts * 토공집계표·운반거리 그리드 (PLAN 8-11·8-3). * * 토공집계표 열은 거창 실무 시트 그대로 — 구분·공종·규격·단위·계·비고. * 비고에는 **설계자가 정한 값만** 남는다(반영률을 바꿨을 때·비율 합이 100 이 아닐 때). * 기본값 그대로면 비워 둔다 — 안내가 매번 뜨면 잡음이 된다. * * ⚠ 무대(소운반 20m)는 집계에는 오르되 **내역 줄이 아니다**(품셈 1-2-7). 그 줄에 * 「내역 제외」를 붙여 화면에서도 보이게 한다 — 규칙이 코드에만 있으면 잊힌다. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { attachProvenance, markProvenanceCell, type ProvenanceSheet, } from "@ui/ui_template_provenance"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** 줄 하나의 칸에 열 키를 차례대로 심는다. * 이 세 표는 칸을 `textCell()` 로 줄지어 붙이므로 **다 지은 뒤에 차례로 짚는 것**이 * 가장 적게 고치는 길이다. `override` 는 **그 칸만 열 등급을 이기는** 자리다 — * 같은 열이라도 줄마다 성격이 갈리는 것(내역 제외 줄·값을 못 세운 줄)을 위한 것이다. */ function markRow( tr: HTMLTableRowElement, keys: readonly (string | null)[], override?: Record, ): void { [...tr.children].forEach((cell, index) => { const key = keys[index]; if (key) markProvenanceCell(cell as HTMLElement, key, override?.[index]); }); } /** 열 차례 — 비고는 사유 글이라 사전을 안 붙인다(`null`). */ const SUMMARY_KEYS = ["group", "item", "spec", "unit", "amount", null] as const; const HAUL_KEYS = ["equipment", "ground", "volume_m3", "average_distance_m", "legs", null] as const; const PREPARATION_KEYS = ["group", "item", "unit", "amount", "status", null] as const; export interface SummaryRow { group: string; item: string; spec: string; unit: string; amount: number; note: string; in_bill: boolean; } export interface SummaryTable { columns: string[]; rows: SummaryRow[]; rock_classes: string[]; } export interface HaulRow { equipment: string; ground: string; volume_m3: number; average_distance_m: number; legs: number; in_bill: boolean; } export interface HaulTable { rows: HaulRow[]; legs: { equipment: string; ground: string; volume_m3: number; distance_m: number; from_m: number; to_m: number; }[]; bill_row_count: number; } /** 운반수단 표기 — 서버 키를 실무 시트 문구로. */ const HAUL_LABELS: Record = { free_haul: "무대(종방향유용토)", dozer: "도자운반", dump_truck: "덤프운반", }; function num(value: number | undefined, digits: number): string { if (value === undefined || value === null || Number.isNaN(value) || value === 0) 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; } /** 토공집계표 — 실무 시트와 같은 여섯 열. */ export function renderSummaryGrid(table: SummaryTable, sheet?: ProvenanceSheet): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; const scroller = document.createElement("div"); scroller.className = "b08-grid__scroll"; const element = document.createElement("table"); element.className = "b08-grid__table b08-grid__table--summary"; const head = document.createElement("thead"); const headRow = document.createElement("tr"); for (const label of table.columns) { const th = document.createElement("th"); th.textContent = label; headRow.append(th); } head.append(headRow); const body = document.createElement("tbody"); let lastGroup = ""; for (const row of table.rows) { const tr = document.createElement("tr"); // 같은 구분이 이어지면 한 번만 적는다 — 실무 시트가 그렇게 병합해 둔다. tr.append(textCell(row.group === lastGroup ? "" : row.group, "b08-grid__station")); lastGroup = row.group; tr.append(textCell(row.item)); tr.append(textCell(row.spec)); tr.append(textCell(row.unit, "b08-grid__unit")); tr.append(textCell(num(row.amount, row.unit === "㎥" ? 2 : 1))); const note = textCell(row.note, "b08-grid__note"); if (!row.in_bill) { const tag = document.createElement("span"); tag.className = "b08-grid__tag"; tag.textContent = L("B08_Quantity_Haul_Excluded"); note.prepend(tag); } tr.append(note); // ⚠ 무대(소운반 20m)처럼 **집계에는 오르되 내역 줄이 아닌** 줄은 「계」가 // 최종이 아니라 **제외**임(품셀 1-2-7). 못 세운 것과 뜻이 정반대라 칸 등급을 갈라 준다. markRow(tr, SUMMARY_KEYS, row.in_bill ? undefined : { 4: "excluded" }); body.append(tr); } element.append(head, body); attachProvenance(element, sheet); scroller.append(element); wrap.append(scroller); return wrap; } /** 운반거리 — 내역 줄(가중평균)과 근거 줄을 나눠 보인다. */ export function renderHaulGrid( table: HaulTable, available: boolean, sheet?: ProvenanceSheet, ): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; if (!available || !table.rows.length) { const message = document.createElement("p"); message.className = "b08-quantity__message"; message.textContent = L("B08_Quantity_Haul_Missing"); wrap.append(message); return wrap; } const caption = document.createElement("p"); caption.className = "b08-grid__caption"; caption.textContent = `내역 줄 ${table.bill_row_count}개 · 근거 구간 ${table.legs.length}개 · 토량 가중평균`; wrap.append(caption); const scroller = document.createElement("div"); scroller.className = "b08-grid__scroll"; const element = document.createElement("table"); element.className = "b08-grid__table b08-grid__table--summary"; const head = document.createElement("thead"); const headRow = document.createElement("tr"); for (const label of [ "운반수단", "지반유형", "토량(㎥)", "평균운반거리(m)", "근거 구간", "비고", ]) { const th = document.createElement("th"); th.textContent = label; headRow.append(th); } head.append(headRow); const body = document.createElement("tbody"); for (const row of table.rows) { const tr = document.createElement("tr"); tr.append(textCell(HAUL_LABELS[row.equipment] ?? row.equipment, "b08-grid__station")); tr.append(textCell(row.ground)); tr.append(textCell(num(row.volume_m3, 2))); tr.append(textCell(num(row.average_distance_m, 2))); tr.append(textCell(String(row.legs))); const note = textCell("", "b08-grid__note"); if (!row.in_bill) { const tag = document.createElement("span"); tag.className = "b08-grid__tag"; tag.textContent = L("B08_Quantity_Haul_Excluded"); note.append(tag); // 왜 빠지는지 같이 적는다 — 「제외」만 있으면 빠뜨린 것으로 오해된다. note.append(document.createTextNode(" 품셈 1-2-7 소운반 20m 이내는 품에 포함")); } tr.append(note); // 내역 줄이 안 되는 줄은 토량·거리 둘 다 「제외」임 — 값은 검산에만 쓴다. markRow(tr, HAUL_KEYS, row.in_bill ? undefined : { 2: "excluded", 3: "excluded" }); body.append(tr); } element.append(head, body); attachProvenance(element, sheet); scroller.append(element); wrap.append(scroller); return wrap; } export interface PreparationRow { group: string; item: string; unit: string; amount: number | null; status: string; reason: string; reference_amount?: number; work_item_code: string | null; /** 수동 단가로 선 금액 수 — 「미확정 N건」·빨간 테두리(임목폐기물 처리). */ unconfirmed?: number; } export interface PreparationTable { columns: string[]; rows: PreparationRow[]; /** 입력하면 서는 줄 수 — 「근거 없음」(`pending_count`)과 갈라 셈(2026-09-14). */ input_count?: number; pending_count: number; row_count: number; } /** 준비공·사방공 — **못 서는 줄도 보인다.** * * 빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. 그래서 값이 없는 줄도 * 상태와 사유를 달아 그대로 세운다. */ export function renderPreparationGrid( table: PreparationTable, sheet?: ProvenanceSheet, ): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; const caption = document.createElement("p"); caption.className = "b08-grid__caption"; caption.textContent = `${table.row_count}줄 · 입력이 필요한 줄 ${table.input_count ?? 0}개 · 값을 낼 근거가 없는 줄 ${table.pending_count}개`; const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0); if (unconfirmed) { const badge = document.createElement("span"); badge.style.cssText = "margin-left:8px;padding:0 6px;border-radius:8px;color:#fff;background:var(--color-danger,#d9534f);font-size:12px"; badge.textContent = `미확정 ${unconfirmed}건`; caption.append(badge); } wrap.append(caption); const scroller = document.createElement("div"); scroller.className = "b08-grid__scroll"; const element = document.createElement("table"); element.className = "b08-grid__table b08-grid__table--summary"; const head = document.createElement("thead"); const headRow = document.createElement("tr"); for (const label of table.columns) { const th = document.createElement("th"); th.textContent = label; headRow.append(th); } head.append(headRow); const body = document.createElement("tbody"); let lastGroup = ""; for (const row of table.rows) { const tr = document.createElement("tr"); tr.append(textCell(row.group === lastGroup ? "" : row.group, "b08-grid__station")); lastGroup = row.group; tr.append(textCell(row.item)); tr.append(textCell(row.unit, "b08-grid__unit")); // 값이 없으면 빈칸이 아니라 「-」 — 빈칸이면 0 으로 오해된다. tr.append(textCell(row.amount === null ? "-" : num(row.amount, 2))); tr.append(textCell(row.status)); const note = textCell(row.reason.replace(/\*\*/g, ""), "b08-grid__note"); if (row.reference_amount) { note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`)); } // 수동 단가로 선 금액이 든 줄 — 빨간 테두리(미확정). if (row.unconfirmed) note.style.outline = "2px solid var(--color-danger, #d9534f)"; tr.append(note); // 값을 못 세운 줄의 「수량」은 **막힘** — 근거가 오면 채워질 자리라 제외과 갈라 보인다. markRow(tr, PREPARATION_KEYS, row.amount === null ? { 3: "blocked" } : undefined); body.append(tr); } element.append(head, body); attachProvenance(element, sheet); scroller.append(element); wrap.append(scroller); return wrap; }