Files
Aislo/B08_Quantity/B08_Quantity_UI_ConversionFactors.ts
T
eomsangdonandClaude Opus 5 648bc52813 fix(B08): 좌측 산출조건 패널을 B03~B07 공통 상자 틀로 통일하고 짤림 제거
개발용 「확정 없이 다음으로」 줄이 공용 `ui-sidebar-actions` 를 쓰고 패널 맨 위에
있어, 공용 코드(`splitSidebarActions`)가 **첫 번째** 그 클래스를 패널 바닥 액션 줄로
집고 스크롤 영역을 그 줄 안에 가둠. 그 아래 조건 칸과 진짜 [저장]·[확정] 줄이
`overflow: hidden` 밖으로 밀려 잘리고 스크롤바도 안 생기던 것을 바로잡음.

- 개발용 줄 단추 묶음을 `b08-quantity__dev-actions` 로 바꿔 공용 클래스에서 뗌
- 조건 칸을 `ui-collapsible ui-sidebar-section` 상자로 묶는 `groupPanelSections` 신설
  (새 파일 `B08_Quantity_UI_SidePanel_Sections.ts` — 본문이 이미 700줄을 넘김)
- 토량환산계수 구획도 제 제목으로 접히게 `ui-collapsible` 부여
- 표를 못 받은 경우(`table === null`) `concrete_placing` 접근에서 터져 페이지가
  통째로 백지가 되던 것을 `?.` 로 막음

자체검증(ORCA 내장 브라우저 5173, 모듈 직접 로드) — 스크롤 래퍼가 패널 직계로 돌아옴
(`b08-quantity__panel ui-sidebar-fill`) · `ui-sidebar-actions` 1개만 남음 ·
[저장]·[확정] 줄 바닥 920px = 패널 바닥 920px = 창 높이 920px 로 잘림 없음 ·
상자 7개(제목 6 + 무제목 1) · 「구조물·사토」 제목 클릭에 511px → 42px → 511px 로
접힘·펼침 확인 · `tsc --noEmit` 통과 · `pytest -q` 1294 passed.

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

204 lines
7.9 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");
// 제 제목을 제 안에 들고 있어 이 구획 자신이 공용 접기 컨테이너가 된다(B03~B07 과 같은 틀).
box.className = "b08-quantity__factors ui-collapsible";
const title = document.createElement("div");
title.className = "b08-quantity__field ui-collapsible__title";
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;
}