Files
Aislo/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts
T
eomsangdonandClaude Opus 5 c7d917864c feat(B08): 암 시공법 칸 · 관급구분 표 입력 · 산출 크기 요약
- 암 시공법(ripping/blasting)을 갈래 비율 칸 아래에 붙임. 갈래 이름만으로는
  품셈 공종(암절취 FP-09-04 / 발파암 FP-09-05)을 못 고르던 자리. 기본은
  「안 정함」이고, 안 정하면 인계에서 사유와 함께 드러남. 비율 미입력 상태의
  「암」 한 줄에도 칸을 냄.
- 관급/사급은 표 안에서 줄마다 고름. 관급 줄에만 설치주체가 열리고 사급으로
  되돌리면 잠기며 비워짐. 만진 줄은 표시가 남음.
- 산출 요약(최소·중앙·최대)을 원단위·자재총괄·인계에 붙임. 단위별로 갈라 냄 —
  값이 있기만 하면 시험이 못 잡는 자릿수 어긋남을 사람이 훑게 하는 장치.

화면에서 걸려 고친 것 둘
- 관급을 골라도 설치주체 칸이 잠긴 채 남던 것. 고른 즉시 열고 닫게 함.
- 한 번 고른 시공법을 되돌릴 길이 없던 것. 설정 저장이 병합이라 빈 값을 보내도
  옛 값이 남았음. `save_section(replace_keys=…)` 로 되돌릴 수 있어야 하는 칸만
  통째로 갈아 끼움. 나머지는 그대로 병합.

앞 커밋에서 온 타입 오류 2건도 고침 (`slopeColumnCount` 미사용,
`variant: "outlined"` 는 없는 값). 앞선 확인에서 npx 가 엉뚱한 패키지를 실행해
「오류 없음」으로 잘못 봤음 — tsc 는 config/node_modules 것으로 부를 것.

검증 — 새 시험 5건 + 인계 28건 통과, 전체 509 passed. tsc 오류 0.
화면 실조작으로 시공법 저장·되돌리기, 관급/설치주체 저장·되돌리기 확인 후
검증으로 바꾼 값은 원래대로 복원.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 00:18:00 +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_MaterialGrid.ts
* 자재총괄표·구조물 원단위 그리드 (PLAN 8-2·8-6·8-7).
*
* 자재총괄 열은 순수량·할증률·합계 셋이고 **금액이 없다** — 금액은 B09 몫이다.
*
* ⚠ 「모르는 값」을 빈칸으로 두지 않는다. 할증률 미확보·관급구분 미분류·설치주체 미지정은
* 모두 화면에 **글자로** 뜬다. 0 % 나 빈칸으로 두면 「할증 없음」과 구별이 안 되고,
* 설치 주체를 못 정한 채 넘어가면 B09 안전관리비가 조용히 틀린다.
*
* ⚠ 반올림은 여기서만 한다(PLAN 8-16 표기 자리 ≠ 계산 자리). 서버가 준 값은 전정밀이다.
* ========================================================================== */
export interface MaterialRow {
name: string;
unit: string;
net_amount: number;
surcharge_pct: number | null;
total_amount: number;
supply: string;
supply_label: string;
install_by: string | null;
install_by_label: string;
note: string;
sources: string[];
}
export interface MaterialTable {
columns: string[];
rows: MaterialRow[];
surcharge_applied: boolean;
surcharge_dataset: { effective_date: string; source: Record<string, unknown> };
missing_rate_materials: string[];
missing_supply_materials: string[];
missing_install_by_materials: string[];
amount_spread: Record<string, { min: number; median: number; max: number; count: number }>;
double_count_warnings: string[];
skipped_by_destination: Record<string, number>;
row_count: number;
}
export interface UnitQuantityStructure {
structure_id: string | null;
type_id: string;
name: string;
length_m: number;
height_m: number;
notes: string[];
components: {
name: string;
unit: string;
amount: number;
destination: string;
basis: string;
}[];
}
export interface MaterialResponse {
unit_quantity: {
structures: UnitQuantityStructure[];
totals: { name: string; unit: string; amount: number; destination: string }[];
surcharge_applied: boolean;
mix_components_found: string[];
structure_count: number;
};
material: MaterialTable;
skipped_structures: string[];
structure_count: number;
}
/** 성분이 어디로 가는지 — 화면에서도 보이게 한다. 규칙이 코드에만 있으면 잊힌다. */
const DESTINATION_LABELS: Record<string, string> = {
earthwork: "토공 합산",
material: "자재총괄",
unit_price: "일위대가",
};
function num(value: number | null | undefined, digits: number): string {
if (value === undefined || value === null || Number.isNaN(value)) return "";
return value.toLocaleString("ko-KR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
function textCell(text: string, className?: string): HTMLTableCellElement {
const td = document.createElement("td");
td.textContent = text;
if (className) td.className = className;
return td;
}
function headRow(labels: string[]): HTMLTableSectionElement {
const head = document.createElement("thead");
const tr = document.createElement("tr");
for (const label of labels) {
const th = document.createElement("th");
th.textContent = label;
tr.append(th);
}
head.append(tr);
return head;
}
/** 못 정한 값 안내 — 목록이 있을 때만 뜬다. 매번 뜨면 잡음이 된다. */
function warning(title: string, items: string[]): HTMLElement | null {
if (!items.length) return null;
const element = document.createElement("p");
element.className = "b08-grid__caption b08-grid__caption--warn";
element.textContent = `${title}: ${items.join(" · ")}`;
return element;
}
/** 관급/사급 고르는 칸의 보기. **값은 영문 키, 표기는 한글**(B09 와 같은 낱말). */
const SUPPLY_OPTIONS = [
{ value: "unknown", label: "미분류" },
{ value: "contractor_supplied", label: "사급" },
{ value: "owner_supplied", label: "관급" },
];
const INSTALL_BY_OPTIONS = [
{ value: "", label: "미지정" },
{ value: "contractor", label: "도급자설치" },
{ value: "owner", label: "관 직접설치" },
];
export interface SupplyChoice {
supply: string;
install_by: string | null;
}
export interface MaterialGridOptions {
/** 저장 전 변경분 — 고른 값은 여기 쌓이고 [저장]에서만 정본으로 간다. */
choices: Record<string, SupplyChoice>;
onChange: () => void;
}
/** 표 안의 고르는 칸. 바꾼 줄은 **표시가 남는다** — 무엇을 만졌는지 보여야 한다. */
function choiceCell(
value: string,
options: { value: string; label: string }[],
disabled: boolean,
onChange: (value: string) => void,
): HTMLTableCellElement {
const td = document.createElement("td");
const select = document.createElement("select");
select.className = "b08-grid__select";
for (const option of options) {
const element = document.createElement("option");
element.value = option.value;
element.textContent = option.label;
select.append(element);
}
select.value = value;
select.disabled = disabled;
select.addEventListener("change", () => {
onChange(select.value);
td.classList.add("is-changed");
});
td.append(select);
return td;
}
/** 값의 크기 요약 — 자릿수가 어긋난 것은 사람이 훑어야 보인다. */
function spreadLine(spread: MaterialTable["amount_spread"], title: string): HTMLElement | null {
const units = Object.keys(spread || {});
if (!units.length) return null;
const element = document.createElement("p");
element.className = "b08-grid__caption";
element.textContent =
title +
" " +
units
.map((unit) => {
const s = spread[unit];
return `${unit} 최소 ${num(s.min, 2)} · 중앙 ${num(s.median, 2)} · 최대 ${num(s.max, 2)}`;
})
.join(" / ");
return element;
}
/** 자재총괄표 — 할증이 붙는 유일한 자리. */
export function renderMaterialGrid(
table: MaterialTable,
options?: MaterialGridOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
const caption = document.createElement("p");
caption.className = "b08-grid__caption";
const edition = table.surcharge_dataset?.effective_date || "판 미상";
caption.textContent = `자재 ${table.row_count}종 · 할증률 ${edition} 판 적용 · 금액은 원가계산(B09)에서`;
wrap.append(caption);
const spread = spreadLine(table.amount_spread, "물량 크기:");
if (spread) wrap.append(spread);
for (const notice of [
warning("⚠ 중복 할증 위험", table.double_count_warnings),
warning("할증률 미확보", table.missing_rate_materials),
warning("관급구분 미분류", table.missing_supply_materials),
warning("설치 주체 미지정(관급)", table.missing_install_by_materials),
]) {
if (notice) wrap.append(notice);
}
if (!table.rows.length) {
const empty = document.createElement("p");
empty.className = "b08-quantity__message";
empty.textContent = "구조물에서 나온 자재가 없음 — 구조물을 먼저 배치할 것";
wrap.append(empty);
return wrap;
}
const scroller = document.createElement("div");
scroller.className = "b08-grid__scroll";
const element = document.createElement("table");
element.className = "b08-grid__table b08-grid__table--summary";
element.append(headRow(table.columns));
const body = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
tr.append(textCell(row.name, "b08-grid__station"));
tr.append(textCell(row.unit, "b08-grid__unit"));
tr.append(textCell(num(row.net_amount, 2)));
// 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다.
tr.append(textCell(row.surcharge_pct === null ? "" : num(row.surcharge_pct, 0)));
tr.append(textCell(num(row.total_amount, 2)));
if (options) {
// 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정).
const chosen = options.choices[row.name] ?? {
supply: row.supply,
install_by: row.install_by,
};
const installCell = choiceCell(
chosen.install_by ?? "",
INSTALL_BY_OPTIONS,
chosen.supply !== "owner_supplied", // 관급 줄에만 고를 수 있다
(value) => {
const current = options.choices[row.name] ?? chosen;
options.choices[row.name] = { supply: current.supply, install_by: value || null };
options.onChange();
},
);
tr.append(
choiceCell(chosen.supply, SUPPLY_OPTIONS, false, (value) => {
const current = options.choices[row.name] ?? chosen;
const next = {
// 사급으로 되돌리면 설치 주체는 뜻을 잃으므로 비운다.
supply: value,
install_by: value === "owner_supplied" ? (current.install_by ?? null) : null,
};
options.choices[row.name] = next;
// ⚠ 표를 다시 그리지 않으므로 **여기서 바로 열고 닫는다** — 안 그러면 관급을 골라도
// 설치 주체 칸이 잠긴 채 남아 사용자가 못 정한다(만들고 화면에서 걸린 자리).
const select = installCell.querySelector("select") as HTMLSelectElement | null;
if (select) {
select.disabled = value !== "owner_supplied";
select.value = next.install_by ?? "";
}
options.onChange();
}),
);
tr.append(installCell);
} else {
tr.append(textCell(row.supply_label));
tr.append(textCell(row.install_by_label));
}
tr.append(textCell(row.note, "b08-grid__note"));
body.append(tr);
}
element.append(body);
scroller.append(element);
wrap.append(scroller);
return wrap;
}
/** 구조물 원단위 — 치수에서 성분까지. 성분마다 갈 곳을 적는다. */
export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
const unit = response.unit_quantity;
const caption = document.createElement("p");
caption.className = "b08-grid__caption";
caption.textContent = `구조물 ${unit.structure_count}개 · 치수는 구조물 정본(B05)에서 · 할증 전 값`;
wrap.append(caption);
// ㉢ 배합이 섞였으면 화면에도 뜬다 — 코드 검사만으로는 사람이 모른다.
const mixed = warning("⚠ 배합 성분이 섞였음(B09 일위대가와 이중계상)", unit.mix_components_found);
if (mixed) wrap.append(mixed);
const skipped = warning("건너뛴 구조물", response.skipped_structures);
if (skipped) wrap.append(skipped);
if (!unit.structures.length) {
const empty = document.createElement("p");
empty.className = "b08-quantity__message";
empty.textContent = "배치된 구조물이 없음";
wrap.append(empty);
return wrap;
}
const scroller = document.createElement("div");
scroller.className = "b08-grid__scroll";
const element = document.createElement("table");
element.className = "b08-grid__table b08-grid__table--summary";
element.append(headRow(["구조물", "규격", "성분", "단위", "수량", "갈 곳", "근거"]));
const body = document.createElement("tbody");
for (const structure of unit.structures) {
const spec = `H=${num(structure.height_m, 1)} · L=${num(structure.length_m, 1)}m`;
if (!structure.components.length) {
const tr = document.createElement("tr");
tr.append(textCell(structure.name, "b08-grid__station"));
tr.append(textCell(spec));
const note = textCell(structure.notes.join(" · "), "b08-grid__note");
note.colSpan = 5;
tr.append(note);
body.append(tr);
continue;
}
let first = true;
for (const component of structure.components) {
const tr = document.createElement("tr");
// 같은 구조물이 이어지면 이름을 한 번만 적는다 — 실무 시트가 그렇게 병합해 둔다.
tr.append(textCell(first ? structure.name : "", "b08-grid__station"));
tr.append(textCell(first ? spec : ""));
first = false;
tr.append(textCell(component.name));
tr.append(textCell(component.unit, "b08-grid__unit"));
tr.append(textCell(num(component.amount, 3)));
tr.append(textCell(DESTINATION_LABELS[component.destination] ?? component.destination));
tr.append(textCell(component.basis, "b08-grid__note"));
body.append(tr);
}
}
element.append(body);
scroller.append(element);
wrap.append(scroller);
return wrap;
}