Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* 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 → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음
|
||
* ========================================================================== */
|
||
|
||
import { API_BASE_URL } from "@config/config_frontend";
|
||
import {
|
||
createButton,
|
||
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 {
|
||
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;
|
||
sheets: MaterialSheets;
|
||
}
|
||
|
||
const BASIS =
|
||
"구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)";
|
||
const HINTS: Record<MaterialSheetId, string> = {
|
||
material:
|
||
"합계 = 올림(수량 × (1 + 할증)) · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널",
|
||
concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음",
|
||
quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1",
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
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<string, Cells> | Cells, string, string | null];
|
||
}
|
||
|
||
function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||
const { materials, rows, total } = s.quantity;
|
||
const rowsOut: DataRow[] = rows.flatMap((row) => {
|
||
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[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 rowsOut: DataRow[] = rows.map((row, i) => {
|
||
const out: DataRow = {
|
||
name: i > 0 && rows[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[i].key, key.slice(4)],
|
||
};
|
||
}
|
||
|
||
function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||
const { rows } = s.material;
|
||
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: rows.map((row, i) => ({
|
||
group: i > 0 && rows[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[i].panel,
|
||
target: (i) => [o.surcharge, rows[i].key, null],
|
||
};
|
||
}
|
||
|
||
const BUILD: Record<MaterialSheetId, (s: MaterialSheets, o: MaterialOverrides) => Table> = {
|
||
material: totalTable,
|
||
concrete: concreteTable,
|
||
quantity: quantityTable,
|
||
};
|
||
|
||
/** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
|
||
export async function renderMaterialSheet(
|
||
host: HTMLElement,
|
||
projectId: string,
|
||
sheet: MaterialSheetId,
|
||
): Promise<void> {
|
||
host.replaceChildren(note("자재 집계표를 불러오는 중…"));
|
||
let payload: Payload;
|
||
try {
|
||
payload = await request<Payload>(`/projects/${projectId}/quantity/materials`);
|
||
} catch (error) {
|
||
host.replaceChildren(note(`자재 집계표를 불러오지 못했습니다 — ${String(error)}`));
|
||
return;
|
||
}
|
||
const draft = readState<MaterialOverrides>("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]));
|
||
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<string, Cells>)[row] = {
|
||
...(table_ as Record<string, Cells>)[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(toolbar, table.root);
|
||
onOptionsChange(host, () => {
|
||
sheets = calc();
|
||
redraw();
|
||
});
|
||
|
||
/** [저장] — 사용자 값을 서버로 · 서버가 다시 셈한 표로 그림 · 캐시 비움 */
|
||
const saveAll = async (): Promise<void> => {
|
||
showLoadingOverlay();
|
||
try {
|
||
payload = await request<Payload>(`/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();
|
||
}
|
||
};
|
||
}
|