Files
Aislo/B08_Quantity/B08_Quantity_UI_Material.ts
T
eomsangdonandClaude Opus 5.5 22a54abe99 feat(B08): 자재 셋 값 없는 줄 숨김 · 구조물 탭 표 넷 공용 카드 안(143-3)
- 총괄 · 콘크리트 · 자재수량 — 수량 · 금액 · 손 단위수량 없는 줄 숨김(화면만 · 고친 칸은 원래 줄 자리로 저장)
- 구조물 집계표 · 자재 셋 — 공용 카드 안(토공 표와 같은 꼴)
- 시험 — 새 test_143_3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-09 14:59:58 +09:00

345 lines
12 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_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<MaterialSheetId, string> = {
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 = <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);
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 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<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]} · ${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<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(createCard({ body: [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();
}
};
}