- 새 파일 = 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
262 lines
9.1 KiB
TypeScript
262 lines
9.1 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
});
|