- 화면 글 10 자리(8 파일) — 세월교 힌트 · B08 재료 엑셀 알림 · B09 내역 수기 줄 까닭(거울 둘) · 산근 쓰는 곳 · 단가 힌트 · 총괄 수기 입력 칸 · 로케일 둘 - 저장 키 · 주석 · 손료는 그대로 · 로케일 줄 수 그대로 - 걸린 시험 셋 글 맞춤 · 새 시험 test_171_1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
423 lines
16 KiB
TypeScript
423 lines
16 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)
|
||
* ========================================================================== */
|
||
|
||
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,
|
||
DEDUCT_KEY,
|
||
matched,
|
||
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 · 콘크리트(기계) · 모르터 1:3 단위수량 → 콘크리트소요",
|
||
};
|
||
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: ["자갈", "㎥당(㎥)", "수량(㎥)"],
|
||
};
|
||
|
||
/** 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}) — 자재수량 탭 그 열에 넣음`
|
||
: "";
|
||
}
|
||
|
||
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, 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 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, 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
|
||
? "할증 = 좌측 패널"
|
||
: row.key === DEDUCT_KEY
|
||
? "토공 잔토처리에서 뺌"
|
||
: lost.has(row.key)
|
||
? "2공구 맞춤 없음"
|
||
: "",
|
||
};
|
||
}),
|
||
total: null,
|
||
frozen: 2,
|
||
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 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(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 = note(`빈 칸 까닭 — ${why.join(" · ")}`);
|
||
reason.dataset.b08MaterialWhy = "";
|
||
notes.append(reason);
|
||
}
|
||
}
|
||
const concreteNote = note(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시
|
||
concreteNote.dataset.b08MaterialConcreteWhy = "";
|
||
if (sheet === "concrete") notes.append(concreteNote);
|
||
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 excel = note("");
|
||
excel.dataset.b08MaterialExcel = "";
|
||
const redraw = (): void => {
|
||
built = BUILD[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] }));
|
||
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();
|
||
}
|
||
};
|
||
}
|