- 새 파일 = 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
221 lines
9.2 KiB
TypeScript
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();
|
|
}
|
|
};
|
|
}
|