/* ============================================================================= * B08_Quantity_Material_Tables.ts * 자재 보조 탭 셋(PLAN 121-1) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3). * 화면(`B08_Quantity_UI_Material`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. * * `loadMaterials` = GET …/quantity/materials + 캐시(`b08-materials`) + 좌측 패널 할증 → 짝 계산 * `materialTable` = 보조 탭 한 장의 열 · 줄 · 계 · 고칠 칸 · 고친 칸 자리 * `materialOptions` = 그 장의 공용 표 옵션(화면은 고치기 · 높이만 더함) * 값 없는 줄은 숨김(143-3) · 콘크리트소요는 5줄 늘 보임(163-5) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table"; import { readState } from "../A00_Common/b_page_state"; import { computeMaterialSheets, DEDUCT_KEY, MIX_FIELDS, type Cells, type MaterialOverrides, type MaterialSheets, type SummarySource, } from "./B08_Quantity_Material_Calc"; import { liveSurcharge } from "./B08_Quantity_UI_Options"; export type MaterialSheetId = "material" | "concrete" | "quantity"; export interface Payload { source: SummarySource; overrides: MaterialOverrides; /** 좌측 패널 할증 저장본(기본 채움) */ surcharge_pct: Cells; /** 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) */ notes?: string[]; sheets: MaterialSheets; } const BASIS = "구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)"; /** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(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); export 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; } /** 저장본 + 캐시(조작 중 값) — calc = 지금 좌측 패널 할증으로 세 표를 다시 셈 */ export async function loadMaterials(projectId: string): Promise<{ payload: Payload; overrides: MaterialOverrides; calc: (payload: Payload, overrides: MaterialOverrides) => MaterialSheets; }> { const payload = await request(`/projects/${projectId}/quantity/materials`); const draft = readState("b08-materials", projectId); return { payload, overrides: draft ?? payload.overrides, calc: (payload, overrides) => computeMaterialSheets( payload.source, overrides, liveSurcharge(projectId, payload.surcharge_pct), ), }; } /** 숫자 열 — 최대 3 자리 · 끝 0 지움(고친 값 자리가 줄마다 다름) */ const numberColumn = (column: DataColumn): DataColumn => ({ kind: "number", decimals: 3, trim: true, ...column, }); export 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]; /** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */ mergeSame?: string[]; } 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)], }; } export 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 = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침 const rowsOut: DataRow[] = keep.map((at) => { const row = rows[at]; const out: DataRow = { name: row.name, // 위 줄과 같으면 세로 병합(201-1) 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, mergeSame: ["name"], 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 lost = new Set(s.quantity.unmatched); 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) => { const row = rows[at]; return { group: row.group, // 위 줄과 같으면 세로 병합(201-1) spec: row.spec, unit: row.unit, qty: row.qty, surcharge: row.surcharge, amount: row.amount, note: row.panel ? "할증 = 좌측 패널" : row.key === DEDUCT_KEY ? "토공 잔토처리에서 뺌" : lost.has(row.key) ? "2공구 맞춤 없음" : "", }; }), total: null, frozen: 2, mergeSame: ["group"], editable: (i, key) => key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY, target: (i) => [o.surcharge, rows[keep[i]].key, null], }; } const BUILD: Record Table> = { material: totalTable, concrete: concreteTable, quantity: quantityTable, }; export const materialTable = ( sheet: MaterialSheetId, s: MaterialSheets, o: MaterialOverrides, ): Table => BUILD[sheet](s, o); /** 공용 표 옵션 — 고치기 · 높이는 화면이 더함 */ export const materialOptions = (built: Table): DataTableOptions => ({ columns: built.columns, rows: built.rows, total: built.total, frozen: built.frozen, mergeSame: built.mergeSame, empty: "", caption: BASIS, });