Files
Aislo/B08_Quantity/B08_Quantity_Material_Tables.ts
T
eomsangdonandClaude Opus 5.5 5e25f418cc refactor(b08): 탭 표 자료 읽기 · 표 짜기를 화면 없는 파일로 꺼냄 — 구조물 집계표 · 자재 · 토공 · 수리 입력 · 거리이정표 · 화면은 같은 함수를 씀 · 동작 그대로 (302-3)
- 새 파일 = B08_Quantity_Summary_Table · Material_Tables · Earthwork_Tables · Hydro_Table · Milestone_Table
- 화면 UI 파일 = 불러오기 · 그리기만 · 옛 B08 시험 = 옮긴 글 자리 따라 읽는 파일 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-13 08:03:23 +09:00

262 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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 = <T>(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<T>(path: string, init: RequestInit = {}): Promise<T> {
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<Payload>(`/projects/${projectId}/quantity/materials`);
const draft = readState<MaterialOverrides>("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<string, Cells> | 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<MaterialSheetId, (s: MaterialSheets, o: MaterialOverrides) => 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,
});