/* ============================================================================= * B08_Quantity_UI_Material.ts * 구조물 탭 보조 탭 셋(PLAN 121-1) — 총괄자재수량집계표 · 콘크리트소요자재집계표 · 자재수량집계표. * 실무 엑셀(4.2 수량산출(2공구).xlsx) 꼴 · 공용 표(`createDataTable` · 다단 머리 · 합계 · 고정 열). * * 자료 = GET …/quantity/materials(구조물 집계표 계 · 저장된 사용자 값 · 서버가 셈한 세 표) * 고칠 칸 = 단위수량 · ㎥당 배합 · 할증 % — 조작 = 캐시(`b08-materials`) + 짝 계산(`Material_Calc.ts`)으로 바로 * 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 패널 조작에 바로 다시 셈) * [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음 * 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3) · * 콘크리트소요는 5줄 늘 보임(수량 빈칸 · 배합 늘 고침) + 콘크리트 전용 빈 까닭 한 줄(163-5) * 콘크리트(기계) · 모르터 1:3 열 = 늘 보임 · 손 단위수량 → 콘크리트소요(160-4) · 엑셀 손값과 다른 * 배합 · 할증은 표 아래 알림 한 줄만 · 맞춤 없는 사본 자재 · 채집석 공제 = 총괄 끝 줄(160-3 · 5) * 자료 읽기 · 표 짜기 = `B08_Quantity_Material_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ import { createButton, createCard, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createDataTable } from "@ui/ui_template_data_table"; import { clearState, writeState } from "../A00_Common/b_page_state"; import { blankUnitRows, matched, MIX_FIELDS, type Cells, type MaterialSheets, type SummarySource, } from "./B08_Quantity_Material_Calc"; import { loadMaterials, materialOptions, materialTable, MIX_LABEL, request, type MaterialSheetId, type Payload, } from "./B08_Quantity_Material_Tables"; import { footNote } from "./B08_Quantity_UI_Foot"; import { onOptionsChange } from "./B08_Quantity_UI_Options"; export type { MaterialSheetId }; const HINTS: Record = { material: "할증 %는 칸에서 고침 · 모래 · 자갈 · 시멘트는 좌측 패널", concrete: "㎥당 배합은 칸에서 고침", quantity: "단위수량은 칸에 수기 입력", }; const HIDDEN = "값 없는 줄은 숨김"; function note(text: string): HTMLElement { const p = document.createElement("p"); p.className = "ui-general-block__empty"; p.textContent = text; return p; } /** 글 → 값 · 빈칸 = null(일부러 비움) · 수가 아니면 undefined(무시) */ function parse(text: string): number | null | undefined { const t = text.replace(/,/g, "").trim(); if (!t) return null; const v = Number(t); return Number.isFinite(v) ? v : undefined; } /** 2공구 엑셀 손값(조사 I) — 지금 값(품셈 근거 · 124)과 다르면 표 아래 알림 한 줄만(160-4) */ const EXCEL_MIX: Record = { c25: { cement: 346, sand: 0.51, gravel: 0.59 }, c40: { cement: 323, sand: 0.48, gravel: 0.65 }, c40_plain: { cement: 312, sand: 0.46, gravel: 0.7 }, m12: { cement: 680, sand: 0.98 }, m13: { cement: 510, sand: 1.1 }, }; const EXCEL_SURCHARGE: Cells = { sand: 6, gravel: 4, cement: 2, mixed_aggregate: 2 }; function excelDiffs(s: MaterialSheets): string { const out: string[] = []; for (const row of s.concrete.rows) for (const f of MIX_FIELDS) { const want = EXCEL_MIX[row.key]?.[f]; if (want != null && row.per[f] !== want) out.push( `${row.name} ${row.spec} ${MIX_LABEL[f][0]} ${row.per[f] ?? "빈칸"}(엑셀 ${want})`, ); } for (const row of s.material.rows) { const want = EXCEL_SURCHARGE[row.key]; if (want != null && (row.surcharge ?? 0) !== want) out.push(`${row.spec} 할증 ${row.surcharge ?? 0}%(엑셀 ${want}%)`); } return out.length ? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")}` : ""; } /** 콘크리트소요 원천 = 콘크리트(기계) · 모르터 1:3 열 + 그 둘에 맞는 사본 자재 열(짝 계산 `matched` 그대로) */ const CONCRETE_SOURCES = ["concrete_machine", "mortar_1_3"]; /** 콘크리트소요가 다 빈칸일 때 까닭 한 줄(163-5) — 원천 단위수량이 빈 집계표 줄을 사본 자재 유무로 가름 */ function concreteWhy(s: MaterialSheets, source: SummarySource): string { if (s.concrete.rows.some((row) => row.qty != null)) return ""; const keys = new Set( s.quantity.materials .filter( (m) => CONCRETE_SOURCES.includes(m.key) || CONCRETE_SOURCES.includes(matched(m.name, m.spec, m.unit) ?? ""), ) .map((m) => m.key), ); const blank = s.quantity.rows.filter((row) => [...keys].every((k) => row.units[k] == null)); const copied = blank.filter((row) => source.snapshot?.units?.[row.id]).length; return blank.length ? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 빈 ${blank.length}줄(사본에 없음 ${copied} · 미연결 ${blank.length - copied}) — 자재수량 탭에서 넣음` : ""; } /** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ export async function renderMaterialSheet( host: HTMLElement, projectId: string, sheet: MaterialSheetId, ): Promise { host.replaceChildren(note("자재 집계표를 불러오는 중…")); let loaded: Awaited>; try { loaded = await loadMaterials(projectId); } catch (error) { host.replaceChildren(note(`자재 집계표를 불러오지 못했습니다 — ${String(error)}`)); return; } let { payload, overrides } = loaded; const calc = (): MaterialSheets => loaded.calc(payload, overrides); let sheets = calc(); const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveAll() }); save.dataset.b08MaterialSave = ""; const toolbar = document.createElement("div"); toolbar.className = "b08-material__toolbar"; const notes = document.createElement("div"); notes.append(footNote(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`)); if (sheet !== "quantity") { // 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로 const blank = blankUnitRows(sheets); const why = [ ...(blank ? [`단위수량 빈 줄 ${blank}개 — 자재수량 탭에서 넣음`] : []), ...(payload.notes ?? []), ]; if (why.length) { const reason = footNote(`빈 칸 까닭 — ${why.join(" · ")}`); reason.dataset.b08MaterialWhy = ""; notes.append(reason); } } const concreteNote = footNote(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시 concreteNote.dataset.b08MaterialConcreteWhy = ""; if (sheet === "concrete") notes.append(concreteNote); toolbar.append(save); let built = materialTable(sheet, sheets, overrides); const table = createDataTable({ ...materialOptions(built), fillViewport: true, editable: (i, key) => built.editable(i, key), editOnDouble: true, // 243-1 값은 더블클릭 · Enter · F2 로만 고침 onEdit: (i, key, text) => { const value = parse(text); if (value !== undefined) { const [table_, row, cell] = built.target(i, key); if (cell === null) (table_ as Cells)[row] = value; else (table_ as Record)[row] = { ...(table_ as Record)[row], [cell]: value, }; writeState("b08-materials", overrides, projectId); sheets = calc(); } redraw(); }, }); table.root.dataset.b08Material = sheet; const excel = footNote(""); excel.dataset.b08MaterialExcel = ""; const redraw = (): void => { built = materialTable(sheet, sheets, overrides); table.setRows(built.rows, built.total); excel.textContent = sheet === "quantity" ? "" : excelDiffs(sheets); concreteNote.textContent = concreteWhy(sheets, payload.source); concreteNote.hidden = !concreteNote.textContent; }; redraw(); host.replaceChildren(createCard({ body: [toolbar, table.root, excel, notes] })); // 202-1 안내 · 경고 = 맨 아래 onOptionsChange(host, () => { sheets = calc(); redraw(); }); /** [저장] — 사용자 값을 서버로 · 서버가 다시 셈한 표로 그림 · 캐시 비움 */ const saveAll = async (): Promise => { showLoadingOverlay(); try { payload = await request(`/projects/${projectId}/quantity/materials`, { method: "PUT", body: JSON.stringify({ overrides }), }); overrides = payload.overrides; sheets = calc(); // 서버 정본 + 아직 저장 안 한 좌측 패널 할증 clearState("b08-materials", projectId); redraw(); showToast("자재 집계표를 저장했습니다.", "success"); } catch (error) { showToast(`저장하지 못했습니다 — ${String(error)}`, "error"); } finally { hideLoadingOverlay(); } }; }