/* ============================================================================= * B08_Quantity_UI_StructureSheet_UnitPrice.ts * 구조물도 장 아래 **일위대가 표**(미리보기) — PLAN 3장 하단 ①②③. * * ⚠ 값을 셈하지 않음 — 서버(`…/structure-sheets/unit-price`)가 B09 단가표로 낸 금액을 적기만. * ⚠ 장 조회와 따로 받음 — 단가표 첫 조립이 십여 초라 표가 늦게 차도 위 수량표는 먼저 보임. * ⚠ 막힌 줄은 0 이 아니라 까닭을 적고, 하나라도 있으면 합계 앞에 「미완」. * ⚠ 수동 단가 줄은 금액 칸 빨간 테두리 + 머리 「미확정 N건」 배지(PLAN 확정 ⑦). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula"; import { unitPriceEditor, type UnitPriceEditorData, } from "./B08_Quantity_UI_StructureSheet_UnitPriceEdit"; interface UnitPriceRow { seq: number; name: string; spec: string; ref_code: string; unit: string; quantity: number | null; skipped: boolean; reason: string; material?: number; labor?: number; expense?: number; total?: number; manual?: boolean; manual_source?: string; manual_entered_at?: string; } interface UnitPriceTable { code: string; name: string; unit: string; rows: UnitPriceRow[]; material: number; labor: number; expense: number; total: number; blocked: number; complete: boolean; unconfirmed: number; } /** 금액 칸 — 0.1원 자리까지(금액란 규칙). 못 푼 줄은 빈칸. */ function won(value: number | undefined): string { return value === undefined ? "" : num(value, 1); } /** 장 아래 일위대가 칸 — 받는 동안 안내를 두고, 오면 표로 갈음. 저장 뒤엔 다시 받음. */ export function unitPriceSection( projectId: string, sheetKey: string, sheetRows: { no: number; name: string; unit: string }[], ): HTMLElement { const wrap = el("div", "b08-grid"); const load = async (): Promise => { wrap.replaceChildren( el("p", "b08-grid__caption", "일위대가(미리보기) 불러오는 중… 단가표 첫 조립은 십여 초"), ); try { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/unit-price?sheet_key=${encodeURIComponent(sheetKey)}`, { credentials: "include" }, ); const payload = (await response.json().catch(() => ({}))) as { unit_price?: UnitPriceTable | null; editor?: UnitPriceEditorData; message?: string; }; if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); const table = payload.unit_price; const editor = payload.editor; if (!table || !editor) { wrap.replaceChildren(el("p", "b08-grid__caption", "이 양식에는 일위대가 줄이 아직 없음")); return; } const open = el("button", "", editor.edited ? "줄 고치기 (고친 조합)" : "줄 고치기"); open.type = "button"; open.style.alignSelf = "flex-start"; const view = table.rows.length ? render(table) : [el("p", "b08-grid__caption", "일위대가 줄이 아직 없음 — [줄 고치기]로 더함")]; open.addEventListener("click", () => { open.disabled = true; const panel = unitPriceEditor({ projectId, sheetKey, sheetRows, data: editor, onSaved: () => void load(), onClose: () => { panel.remove(); open.disabled = false; }, }); wrap.append(panel); }); wrap.replaceChildren(...view, open); } catch (error) { wrap.replaceChildren( el( "p", "b08-grid__caption b08-grid__caption--warn", `일위대가를 불러오지 못함 — ${error instanceof Error ? error.message : ""}`, ), ); } }; void load(); return wrap; } function render(table: UnitPriceTable): HTMLElement[] { const total = table.complete ? `${num(table.total, 0)}원` : `미완 — 막힌 줄 ${table.blocked} · 선 줄만 ${num(table.total, 0)}원`; const head = el( "p", "b08-sheet__head", `일위대가 ${table.code} · ${table.unit}당 ${total} (미리보기 — 내역 금액은 원가계산이 셈 · 하위 일위대가는 5단까지 풀고 더 깊거나 돌면 막힘)`, ); if (table.unconfirmed) { head.append(el("span", "b08-unit__badge", `미확정 ${table.unconfirmed}건`)); } const scroller = el("div", "b08-grid__scroll"); const grid = el("table", "b08-grid__table b08-grid__table--summary"); const headRow = document.createElement("tr"); for (const label of [ "공종·자원", "코드", "수량", "단위", "재료비", "노무비", "경비", "합계", "비고", ]) { headRow.append(el("th", "", label)); } const thead = document.createElement("thead"); thead.append(headRow); const tbody = document.createElement("tbody"); for (const row of table.rows) { const tr = document.createElement("tr"); const manual = row.manual ? `수동 단가 — ${row.manual_source || "출처 없음"} · ${row.manual_entered_at ?? ""}` : ""; const note = row.skipped ? `안 섬 — ${row.reason}` : row.reason ? `⚠ ${row.reason}` : manual; // 단가표 이름에 갈래가 이미 들었거나 규격 칸이 코드 자체면 겹쳐 적지 않음. const spec = row.spec && !row.name.includes(row.spec) && !row.ref_code.includes(row.spec) ? row.spec : ""; const money = [row.material, row.labor, row.expense, row.total].map((value) => { const cell = el("td", row.manual ? "b08-unit__manual" : "", won(value)); if (row.manual) cell.title = manual; return cell; }); tr.append( el("td", "", spec ? `${row.name} (${spec})` : row.name), el("td", "", row.ref_code), el("td", "", row.quantity === null ? "" : num(row.quantity, 3)), el("td", "", row.unit), ...money, el("td", "", note), ); tbody.append(tr); } const foot = document.createElement("tr"); foot.append( el("td", "", "계"), el("td", "", ""), el("td", "", ""), el("td", "", ""), el("td", "", won(table.material)), el("td", "", won(table.labor)), el("td", "", won(table.expense)), el("td", "", table.complete ? num(table.total, 0) : `미완 ${num(table.total, 0)}`), el("td", "", ""), ); tbody.append(foot); grid.append(thead, tbody); scroller.append(grid); return [head, scroller]; }