Files
Aislo/B08_Quantity/B08_Quantity_UI_Material.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

221 lines
9.2 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 → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음
* 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3) ·
* 콘크리트소요는 5줄 늘 보임(수량 빈칸 · 배합 늘 고침) + 콘크리트 전용 빈 까닭 한 줄(163-5)
* 콘크리트(기계) · 모르터 1:3 열 = 늘 보임 · 손 단위수량 → 콘크리트소요(160-4) · 엑셀 손값과 다른
* 배합 · 할증은 표 아래 알림 한 줄만 · 맞춤 없는 사본 자재 · 채집석 공제 = 총괄 끝 줄(160-3 · 5)
* 자료 읽기 · 표 짜기 = `B08_Quantity_Material_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3)
* ========================================================================== */
import {
createButton,
createCard,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createDataTable } from "@ui/ui_template_data_table";
import { clearState, writeState } from "../A00_Common/b_page_state";
import {
blankUnitRows,
matched,
MIX_FIELDS,
type Cells,
type MaterialSheets,
type SummarySource,
} from "./B08_Quantity_Material_Calc";
import {
loadMaterials,
materialOptions,
materialTable,
MIX_LABEL,
request,
type MaterialSheetId,
type Payload,
} from "./B08_Quantity_Material_Tables";
import { footNote } from "./B08_Quantity_UI_Foot";
import { onOptionsChange } from "./B08_Quantity_UI_Options";
export type { MaterialSheetId };
const HINTS: Record<MaterialSheetId, string> = {
material: "할증 %는 칸에서 고침 · 모래 · 자갈 · 시멘트는 좌측 패널",
concrete: "㎥당 배합은 칸에서 고침",
quantity: "단위수량은 칸에 수기 입력",
};
const HIDDEN = "값 없는 줄은 숨김";
function note(text: string): HTMLElement {
const p = document.createElement("p");
p.className = "ui-general-block__empty";
p.textContent = text;
return p;
}
/** 글 → 값 · 빈칸 = 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;
}
/** 2공구 엑셀 손값(조사 I) — 지금 값(품셈 근거 · 124)과 다르면 표 아래 알림 한 줄만(160-4) */
const EXCEL_MIX: Record<string, Cells> = {
c25: { cement: 346, sand: 0.51, gravel: 0.59 },
c40: { cement: 323, sand: 0.48, gravel: 0.65 },
c40_plain: { cement: 312, sand: 0.46, gravel: 0.7 },
m12: { cement: 680, sand: 0.98 },
m13: { cement: 510, sand: 1.1 },
};
const EXCEL_SURCHARGE: Cells = { sand: 6, gravel: 4, cement: 2, mixed_aggregate: 2 };
function excelDiffs(s: MaterialSheets): string {
const out: string[] = [];
for (const row of s.concrete.rows)
for (const f of MIX_FIELDS) {
const want = EXCEL_MIX[row.key]?.[f];
if (want != null && row.per[f] !== want)
out.push(
`${row.name} ${row.spec} ${MIX_LABEL[f][0]} ${row.per[f] ?? "빈칸"}(엑셀 ${want})`,
);
}
for (const row of s.material.rows) {
const want = EXCEL_SURCHARGE[row.key];
if (want != null && (row.surcharge ?? 0) !== want)
out.push(`${row.spec} 할증 ${row.surcharge ?? 0}%(엑셀 ${want}%)`);
}
return out.length ? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")}` : "";
}
/** 콘크리트소요 원천 = 콘크리트(기계) · 모르터 1:3 열 + 그 둘에 맞는 사본 자재 열(짝 계산 `matched` 그대로) */
const CONCRETE_SOURCES = ["concrete_machine", "mortar_1_3"];
/** 콘크리트소요가 다 빈칸일 때 까닭 한 줄(163-5) — 원천 단위수량이 빈 집계표 줄을 사본 자재 유무로 가름 */
function concreteWhy(s: MaterialSheets, source: SummarySource): string {
if (s.concrete.rows.some((row) => row.qty != null)) return "";
const keys = new Set(
s.quantity.materials
.filter(
(m) =>
CONCRETE_SOURCES.includes(m.key) ||
CONCRETE_SOURCES.includes(matched(m.name, m.spec, m.unit) ?? ""),
)
.map((m) => m.key),
);
const blank = s.quantity.rows.filter((row) => [...keys].every((k) => row.units[k] == null));
const copied = blank.filter((row) => source.snapshot?.units?.[row.id]).length;
return blank.length
? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 빈 ${blank.length}줄(사본에 없음 ${copied} · 미연결 ${blank.length - copied}) — 자재수량 탭에서 넣음`
: "";
}
/** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
export async function renderMaterialSheet(
host: HTMLElement,
projectId: string,
sheet: MaterialSheetId,
): Promise<void> {
host.replaceChildren(note("자재 집계표를 불러오는 중…"));
let loaded: Awaited<ReturnType<typeof loadMaterials>>;
try {
loaded = await loadMaterials(projectId);
} catch (error) {
host.replaceChildren(note(`자재 집계표를 불러오지 못했습니다 — ${String(error)}`));
return;
}
let { payload, overrides } = loaded;
const calc = (): MaterialSheets => loaded.calc(payload, overrides);
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(footNote(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`));
if (sheet !== "quantity") {
// 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로
const blank = blankUnitRows(sheets);
const why = [
...(blank ? [`단위수량 빈 줄 ${blank}개 — 자재수량 탭에서 넣음`] : []),
...(payload.notes ?? []),
];
if (why.length) {
const reason = footNote(`빈 칸 까닭 — ${why.join(" · ")}`);
reason.dataset.b08MaterialWhy = "";
notes.append(reason);
}
}
const concreteNote = footNote(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시
concreteNote.dataset.b08MaterialConcreteWhy = "";
if (sheet === "concrete") notes.append(concreteNote);
toolbar.append(save);
let built = materialTable(sheet, sheets, overrides);
const table = createDataTable({
...materialOptions(built),
fillViewport: true,
editable: (i, key) => built.editable(i, key),
editOnDouble: true, // 243-1 값은 더블클릭 · Enter · F2 로만 고침
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 excel = footNote("");
excel.dataset.b08MaterialExcel = "";
const redraw = (): void => {
built = materialTable(sheet, sheets, overrides);
table.setRows(built.rows, built.total);
excel.textContent = sheet === "quantity" ? "" : excelDiffs(sheets);
concreteNote.textContent = concreteWhy(sheets, payload.source);
concreteNote.hidden = !concreteNote.textContent;
};
redraw();
host.replaceChildren(createCard({ body: [toolbar, table.root, excel, notes] })); // 202-1 안내 · 경고 = 맨 아래
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();
}
};
}