Files
Aislo/B08_Quantity/B08_Quantity_UI_ConversionFactors.ts
T
eomsangdonandClaude Opus 5 465dbb955d feat(B08): 토량환산계수를 프로젝트가 고를 수 있게 열되 기본값은 불변
- 기본값 정의처는 config_system_design 한 곳 그대로. 고른 값은 프로젝트 설정
  quantity.conversion_factors_override 에 얹고, earthwork_conversion_factors() 한 함수가
  기본값 위에 얹어 풀어 냄.
- 그 함수를 거치는 자리 여섯 — 토적표·운반표·기초단가(B08), 유토곡선 컨텍스트·
  배분 계산·[저장] 재계산(B06). 상수를 직접 드는 자리를 없앰.
- 산출 조건 패널에 갈래별 계수 칸 신설 — 기본값·품셈 범위를 함께 보이고,
  「유토곡선·운반표·기초단가에도 같이 닿음」 안내 한 줄. 범위 밖은 막지 않고 사유를 받음.
- 품셈 체적변화율 범위를 서버 상수로 두고 화면에 내려보냄(프론트에 다시 적지 않음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
2026-09-12 13:38:00 +09:00

203 lines
7.8 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_ConversionFactors.ts
* 산출 조건 패널의 「토량환산계수(다짐)」 칸 — 고를 수 있게 열어 둔 자리.
*
* 왜 고르게 하나 (오솔길 대조 06절 3번)
* 품셈 체적변화율표가 암종마다 **범위**를 주고 「토질 시험하여 적용함을 원칙」이라 한다.
* 즉 정답 숫자가 하나가 아니다. 경쟁사(오솔길)가 전 구간 1.0 을 쓰는 것도 풍화암·연암
* 범위의 하한이라 틀린 값이 아니다. 그래서 **값을 못 박지 않고 범위를 보이며 고르게** 한다.
*
* ⚠ 기본값은 건드리지 않는다
* 정의처는 서버 `config_system_design.EARTHWORK_CONVERSION_FACTORS` 한 곳이다. 화면은
* 고른 값만 `conversion_factors_override` 로 보내고, 안 고른 갈래는 **키 자체를 안 보낸다** —
* 그래야 나중에 정본이 바뀌어도 옛 프로젝트가 따라온다.
*
* ⚠ 범위 밖을 막지 않는다
* 토질시험 값일 수 있다. 막는 대신 **사유를 적게** 하고, 그 사유가 정본에 함께 남는다.
*
* ⚠ 이 계수는 토적표만 쓰는 것이 아니다
* 유토곡선(B06) · 운반표 · 기초단가가 같은 값을 읽는다. 패널에 그 사실을 한 줄 보인다 —
* 안 보이면 「토적표만 바뀌겠지」로 읽힌다.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import type { ConversionFactorChoice, PumsemRange } from "./B08_Quantity_UI_EarthworkGrid";
/** locale 헬퍼 — 페이지 쪽과 같은 모양으로 둔다(문구는 `ui_template_locale_b2`). */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 화면이 들고 있는 고른 값 — `compacted` 가 `null` 이면 「안 고름」이라 저장에서 빠진다. */
export interface FactorDraft {
compacted: number | null;
reason: string;
}
/** 서버 키 → 사람이 읽는 이름. 모르는 키는 **지어내지 않고** 그대로 보인다. */
const GROUND_LABELS: Record<string, string> = {
soil: "토사",
ripping_rock: "리핑암",
blasting_rock: "발파암",
};
function groundLabel(kind: string): string {
return GROUND_LABELS[kind] ?? kind;
}
function hint(text: string): HTMLElement {
const row = document.createElement("p");
row.className = "b08-quantity__hint";
row.textContent = text;
return row;
}
/** 범위 밖인가 — 서버가 준 범위로 판정한다. 범위를 모르면 **밖이라고 하지 않는다.** */
function outOfRange(value: number, range: [number, number] | null): boolean {
if (!range) return false;
return value < range[0] || value > range[1];
}
/**
* 갈래 한 줄 — 값 칸 + 기본값·품셈 범위 안내 + (범위 밖일 때만) 사유 칸.
*
* 값을 비우면 「안 고름」으로 돌아가 기본값이 선다. 그 되돌리는 길이 있어야
* 한 번 넣은 값이 영영 남지 않는다.
*/
function factorRow(
kind: string,
choice: ConversionFactorChoice,
draft: Record<string, FactorDraft>,
onChange: () => void,
): HTMLElement {
const box = document.createElement("div");
box.className = "b08-quantity__factor";
const row = document.createElement("label");
row.className = "b08-quantity__field";
const name = document.createElement("span");
name.textContent = groundLabel(kind);
const input = document.createElement("input");
input.type = "number";
input.className = "b08-quantity__input";
input.min = "0";
input.step = "0.01";
input.placeholder = String(choice.default);
const current = draft[kind]?.compacted;
input.value = current === null || current === undefined ? "" : String(current);
row.append(name, input);
box.append(row);
const range = (choice.range ?? null) as [number, number] | null;
box.append(
hint(
`${L("B08_Quantity_Factor_Default")} ${choice.default}` +
(range
? ` · ${L("B08_Quantity_Factor_Range")} ${range[0].toFixed(2)}~${range[1].toFixed(2)}`
: ""),
),
);
// 사유 칸은 **범위 밖일 때만** 선다 — 늘 띄우면 채우지 않아도 되는 칸으로 읽힌다.
const reasonRow = document.createElement("label");
reasonRow.className = "b08-quantity__field";
const reasonName = document.createElement("span");
reasonName.textContent = L("B08_Quantity_Factor_Reason");
const reasonInput = document.createElement("input");
reasonInput.type = "text";
reasonInput.className = "b08-quantity__input";
reasonInput.value = draft[kind]?.reason ?? "";
reasonRow.append(reasonName, reasonInput);
const warning = hint(L("B08_Quantity_Factor_OutOfRange"));
warning.classList.add("b08-quantity__hint--warn");
const sync = (): void => {
const value = input.value.trim() === "" ? null : Number(input.value);
const outside = value !== null && Number.isFinite(value) && outOfRange(value, range);
warning.hidden = !outside;
reasonRow.hidden = !outside;
};
input.addEventListener("input", () => {
const raw = input.value.trim();
const value = raw === "" ? null : Number(raw);
draft[kind] = {
compacted: value !== null && Number.isFinite(value) ? value : null,
reason: draft[kind]?.reason ?? "",
};
sync();
onChange();
});
reasonInput.addEventListener("input", () => {
draft[kind] = {
compacted: draft[kind]?.compacted ?? null,
reason: reasonInput.value,
};
onChange();
});
box.append(warning, reasonRow);
sync();
return box;
}
/**
* 「토량환산계수(다짐)」 구획 전체. 서버가 준 갈래만 그린다 — 갈래 수를 화면에 안 박는다.
*
* `choices` 가 없으면(옛 응답) **아무것도 그리지 않는다** — 빈 칸을 지어내지 않는다.
*/
export function renderConversionFactorFields(
choices: Record<string, ConversionFactorChoice> | undefined,
pumsem: PumsemRange[] | undefined,
draft: Record<string, FactorDraft>,
onChange: () => void,
): HTMLElement | null {
const entries = Object.entries(choices ?? {});
if (!entries.length) return null;
const box = document.createElement("div");
box.className = "b08-quantity__factors";
const title = document.createElement("div");
title.className = "b08-quantity__field";
const titleName = document.createElement("span");
titleName.textContent = L("B08_Quantity_Side_Factors");
title.append(titleName);
box.append(title);
// 어디까지 닿는 값인지 먼저 보인다 — 토적표만 바뀌는 줄 알면 함부로 고친다.
box.append(hint(L("B08_Quantity_Factor_Reach")));
for (const [kind, choice] of entries) {
box.append(factorRow(kind, choice, draft, onChange));
}
// 품셈 암종별 범위 — 서버가 내려 준 값을 그대로 보인다(화면에 다시 적지 않는다).
if (pumsem?.length) {
box.append(
hint(
`${L("B08_Quantity_Factor_Pumsem")} — ` +
pumsem
.map((item) => `${item.name} ${item.min.toFixed(2)}~${item.max.toFixed(2)}`)
.join(" · "),
),
);
}
return box;
}
/** 저장 몸통에 실을 모양 — **고른 갈래만** 담는다. 빈 dict 는 「전부 기본값」이다. */
export function conversionOverridePayload(
draft: Record<string, FactorDraft>,
): Record<string, { compacted: number; reason?: string }> {
const payload: Record<string, { compacted: number; reason?: string }> = {};
for (const [kind, entry] of Object.entries(draft)) {
if (entry.compacted === null || !Number.isFinite(entry.compacted) || entry.compacted <= 0) {
continue;
}
payload[kind] = entry.reason.trim()
? { compacted: entry.compacted, reason: entry.reason.trim() }
: { compacted: entry.compacted };
}
return payload;
}