/* ============================================================================= * 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) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createCard, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { blankUnitRows, computeMaterialSheets, MIX_FIELDS, type Cells, type MaterialOverrides, type MaterialSheets, type SummarySource, } from "./B08_Quantity_Material_Calc"; import { liveSurcharge, onOptionsChange } from "./B08_Quantity_UI_Options"; export type MaterialSheetId = "material" | "concrete" | "quantity"; interface Payload { source: SummarySource; overrides: MaterialOverrides; /** 좌측 패널 할증 저장본(기본 채움) */ surcharge_pct: Cells; /** 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) */ notes?: string[]; sheets: MaterialSheets; } const BASIS = "구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)"; const HINTS: Record = { material: "합계 = 끝수(수량 × (1 + 할증)) — 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 올림 · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널", concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음", quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1", }; const HIDDEN = "값 없는 줄은 숨김"; /** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */ const shown = (rows: readonly T[], has: (row: T) => boolean): number[] => rows.flatMap((row, i) => (has(row) ? [i] : [])); const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null); async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, }); const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; if (!response.ok) { const detail = body?.detail; throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); } return body as T; } function note(text: string): HTMLElement { const p = document.createElement("p"); p.className = "ui-general-block__empty"; p.textContent = text; return p; } /** 숫자 열 — 최대 3 자리 · 끝 0 지움(고친 값 자리가 줄마다 다름) */ const numberColumn = (column: DataColumn): DataColumn => ({ kind: "number", decimals: 3, trim: true, ...column, }); /** 글 → 값 · 빈칸 = 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; } interface Table { columns: DataColumn[]; rows: DataRow[]; total: DataRow | null; frozen: number; editable: (index: number, key: string) => boolean; /** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */ target: (index: number, key: string) => [Record | Cells, string, string | null]; } function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { const { materials, rows, total } = s.quantity; const keep = shown(rows, (row) => row.qty != null || anyValue(row.units)); const rowsOut: DataRow[] = keep.flatMap((at) => { const row = rows[at]; const unit: DataRow = { name: row.name, spec: row.spec, qty: row.qty, unit: row.unit, kind: "단위수량", }; const amount: DataRow = { kind: "수량" }; for (const m of materials) { unit[`m:${m.key}`] = row.units[m.key]; amount[`m:${m.key}`] = row.amounts[m.key]; } return [unit, amount]; }); const totalRow: DataRow = { name: "계", kind: "수량" }; for (const m of materials) totalRow[`m:${m.key}`] = total[m.key]; return { columns: [ { key: "name", label: "구분", width: 120 }, { key: "spec", label: "규격", width: 120 }, numberColumn({ key: "qty", label: "수량", width: 72 }), { key: "unit", label: "단위", width: 56 }, { key: "kind", label: "구분", width: 72 }, ...materials.map((m) => numberColumn({ key: `m:${m.key}`, label: m.unit, group: m.spec ? [m.name, m.spec] : [m.name], title: [m.name, m.spec, m.unit].filter(Boolean).join(" › "), width: 76, }), ), ], rows: rowsOut, total: totalRow, frozen: 5, editable: (i, key) => i % 2 === 0 && key.startsWith("m:"), target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)], }; } const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = { cement: ["시멘트", "㎥당(kg)", "수량(kg)"], sand: ["모래", "㎥당(㎥)", "수량(㎥)"], gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"], }; function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { const { rows, total } = s.concrete; const keep = shown(rows, (row) => row.qty != null || anyValue(row.amounts)); const rowsOut: DataRow[] = keep.map((at, i) => { const row = rows[at]; const out: DataRow = { name: i > 0 && rows[keep[i - 1]].name === row.name ? "" : row.name, spec: row.spec, qty: row.qty, }; for (const f of MIX_FIELDS) { out[`per:${f}`] = row.per[f]; out[`amt:${f}`] = row.amounts[f]; } return out; }); const totalRow: DataRow = { name: "계" }; for (const f of MIX_FIELDS) totalRow[`amt:${f}`] = total[f]; return { columns: [ { key: "name", label: "구분", width: 88 }, { key: "spec", label: "규격", width: 110 }, numberColumn({ key: "qty", label: "수량(㎥)", width: 80 }), ...MIX_FIELDS.flatMap((f) => [ numberColumn({ key: `per:${f}`, label: MIX_LABEL[f][1], group: [MIX_LABEL[f][0]], width: 84, }), numberColumn({ key: `amt:${f}`, label: MIX_LABEL[f][2], group: [MIX_LABEL[f][0]], width: 96, }), ]), ], rows: rowsOut, total: totalRow, frozen: 2, editable: (_i, key) => key.startsWith("per:"), target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)], }; } function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { const { rows } = s.material; const keep = shown(rows, (row) => row.qty != null || row.amount != null); return { columns: [ { key: "group", label: "구분", width: 96 }, { key: "spec", label: "규격", width: 120 }, { key: "unit", label: "단위", width: 56 }, numberColumn({ key: "qty", label: "수량", width: 96 }), numberColumn({ key: "surcharge", label: "할증(%)", width: 72 }), numberColumn({ key: "amount", label: "합계", width: 96 }), { key: "note", label: "비고", width: 96 }, ], rows: keep.map((at, i) => { const row = rows[at]; return { group: i > 0 && rows[keep[i - 1]].group === row.group ? "" : row.group, spec: row.spec, unit: row.unit, qty: row.qty, surcharge: row.surcharge, amount: row.amount, note: row.panel ? "할증 = 좌측 패널" : "", }; }), total: null, frozen: 2, editable: (i, key) => key === "surcharge" && !rows[keep[i]].panel, target: (i) => [o.surcharge, rows[keep[i]].key, null], }; } const BUILD: Record Table> = { material: totalTable, concrete: concreteTable, quantity: quantityTable, }; /** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ export async function renderMaterialSheet( host: HTMLElement, projectId: string, sheet: MaterialSheetId, ): Promise { host.replaceChildren(note("자재 집계표를 불러오는 중…")); let payload: Payload; try { payload = await request(`/projects/${projectId}/quantity/materials`); } catch (error) { host.replaceChildren(note(`자재 집계표를 불러오지 못했습니다 — ${String(error)}`)); return; } const draft = readState("b08-materials", projectId); let overrides: MaterialOverrides = draft ?? payload.overrides; const calc = (): MaterialSheets => computeMaterialSheets( payload.source, overrides, liveSurcharge(projectId, payload.surcharge_pct), ); 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(note(BASIS), note(`${HINTS[sheet]} · ${HIDDEN}`)); if (sheet !== "quantity") { // 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로 const blank = blankUnitRows(sheets); const why = [ ...(blank ? [`단위수량 빈 줄 ${blank}개 — 자재수량 탭에서 넣음`] : []), ...(payload.notes ?? []), ]; if (why.length) { const reason = note(`빈 칸 까닭 — ${why.join(" · ")}`); reason.dataset.b08MaterialWhy = ""; notes.append(reason); } } toolbar.append(notes, save); let built = BUILD[sheet](sheets, overrides); const table = createDataTable({ columns: built.columns, rows: built.rows, total: built.total, frozen: built.frozen, empty: "", caption: BASIS, editable: (i, key) => built.editable(i, key), 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 redraw = (): void => { built = BUILD[sheet](sheets, overrides); table.setRows(built.rows, built.total); }; host.replaceChildren(createCard({ body: [toolbar, table.root] })); 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(); } }; }