Files
Aislo/B02_ProjRegister/B02_ProjRegister_UI_Page.ts
T
eomsangdonandClaude Fable 5 8812657022 feat(B05/B02): 표시 정보 정비 + 설계속도 축 도입
- 종단 상단 표시줄을 법정 판정 3항목으로 축소: 최대 기울기/상한(별표2 Ⅰ.2.라),
  절·성토 불균형(Ⅰ.1.나.(4)(다)), 종단곡선 필요 n곳(Ⅰ.2.마 — 대수차 5% 초과인데
  곡선이 빠진 변화점). 변화점·종단곡선 개수·기본 곡선길이 L·중복 경고칩은 제거.
- [초기선 복원] → [편집 되돌리기] — 실제 동작은 화면 편집 델타 삭제이지 저장 지점
  복귀가 아니다. 툴팁에 동작과 [초기화]와의 차이를 적었다.
- 3D 뷰포트 안내 라벨 삭제(로딩 완료 후 남던 조작법 문구).
- 배수유역 버튼 정비: 유역 다시 나누기 / 선택한 관 삭제 / 배관 배치 초기화 +
  각 툴팁에 실제 동작 명시(선택 삭제는 툴팁 자체가 없었다).
- 유토곡선 요약 12칩 → 5칩(절토·성토·잉여|부족·운반·검산). 토질 3종 내역·다짐
  환산·블록 수·장거리 운반·사토는 해당 칩 툴팁으로. B05·B06 공용 함수라 동시 반영.
- 설계속도 축 도입(임도는 속도를 낼 수 없는 노선 — 기본 20km/h):
  · 임도 종류는 프로젝트 등록값(projects.road_type)을 읽어 B05가 읽기 전용 표시
    (main→간선임도, fire→산불진화임도, 그 외→작업임도). 화면에서 고치지 않는다.
  · 설계속도 선택(간선·산불진화 20/30/40, 작업 20 고정) 신설 — 종단기울기 상한과
    종단곡선 반경이 등급이 아니라 이 값으로 정해진다(지식DB 설계제원_총괄 §6·§7).
  · B02 임도 종류 선택지를 현행 3종으로 정리(지선 폐지·계류보전은 사방 구분).
  · 백엔드: ROUTE_GRADE_CLASSES 3종+branch 호환, selectable_design_speeds 신설,
    resolve_design_speed 신설, legal_grade_limit_pct·resolve_grade_options에
    설계속도 인자, sections/context가 road_type 제공(B06도 저장값 승계).
  · 계획선 정책은 그릴 때마다 현재 기준으로 동기화 — 저장분의 옛 상한이 남지 않는다.
- 검증: pytest 125 통과(설계속도 12건 신규), tsc, 헤드 브라우저 — 진입 시 상한 9%,
  40km/h 전환 시 7% 즉시 반영, 원복 9%, 안내문 '간선임도 · 설계속도 20km/h ·
  일반지형', 유토곡선 5칩, 유역 버튼 새 이름·툴팁, 3D 라벨 빈 문자열 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:46:03 +09:00

182 lines
6.2 KiB
TypeScript

/* =============================================================================
* B02_ProjRegister_UI_Page.ts
* 로그인 후 02: 프로젝트 등록 (신규 임도 설계 프로젝트 기본 정보 입력)
*
* 제약 준수 (frontend.md):
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지, §3).
* - 색상/간격은 theme.css 변수 + 공통 컴포넌트만 사용 (§1, §2).
* - 이벤트 핸들러는 onB02_[기능]_[액션] 명명 (§4).
* - 백엔드 전송 전 1차 유효성 검사 (§4).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
createCard,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { isBlank } from "@util/common_util_validate";
import { navigateTo } from "../A00_Common/router";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import "./B02_ProjRegister_UI_Style.css";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderB02ProjRegister(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "b02-proj";
const header = document.createElement("div");
header.className = "b02-proj__header";
const title = document.createElement("h1");
title.className = "b02-proj__title";
title.textContent = L("B02_Proj_Title");
const subtitle = document.createElement("p");
subtitle.className = "b02-proj__subtitle";
subtitle.textContent = L("B02_Proj_Subtitle");
header.append(title, subtitle);
// 입력 필드
const nameField = createInputField({
label: L("B02_Proj_Field_Name"),
placeholder: L("B02_Proj_Field_Name_Placeholder"),
required: true,
});
const regionField = createInputField({
label: L("B02_Proj_Field_Region"),
placeholder: L("B02_Proj_Field_Region_Placeholder"),
required: true,
});
// 임도 종류 3종 — 현행 규칙 기준(지선임도 폐지, 계류보전은 사방사업 구분이라 제외).
// 이 값이 B05 계획선의 법정 기준(설계속도 선택지·종단기울기 상한)을 정한다
// (2026-08-19 사용자 확정).
const roadTypeField = createSelectField({
label: L("B02_Proj_Field_RoadType"),
options: [
{ value: "main", text: L("B02_Proj_RoadType_Main") },
{ value: "fire", text: L("B02_Proj_RoadType_Fire") },
{ value: "work", text: L("B02_Proj_RoadType_Work") },
],
});
const currentYear = new Date().getFullYear();
const yearField = createInputField({
label: L("B02_Proj_Field_Year"),
type: "number",
value: String(currentYear),
min: 2000,
max: currentYear + 5,
});
const lengthField = createInputField({
label: L("B02_Proj_Field_Length"),
placeholder: L("B02_Proj_Field_Length_Placeholder"),
type: "number",
min: 0,
});
const memoField = createInputField({
label: L("B02_Proj_Field_Memo"),
placeholder: L("B02_Proj_Field_Memo_Placeholder"),
type: "text",
});
const submitBtn = createButton({
label: L("B02_Proj_Submit"),
variant: "filled",
onClick: onB02_Proj_Submit_Click,
});
submitBtn.classList.add("b02-proj__submit");
async function onB02_Proj_Submit_Click(): Promise<void> {
nameField.setError();
regionField.setError();
yearField.setError();
lengthField.setError();
// 1차 유효성: 필수값 검사
let hasError = false;
const projectYear = Number.parseInt(yearField.input.value, 10);
const estimatedLength = isBlank(lengthField.input.value)
? null
: Number.parseFloat(lengthField.input.value);
if (isBlank(nameField.input.value)) {
nameField.setError(L("B02_Proj_Error_Required"));
hasError = true;
}
if (isBlank(regionField.input.value)) {
regionField.setError(L("B02_Proj_Error_Required"));
hasError = true;
}
if (!Number.isInteger(projectYear) || projectYear < 2000 || projectYear > 2100) {
yearField.setError(L("Common_Validation_NumberRange"));
hasError = true;
}
if (estimatedLength !== null && (!Number.isFinite(estimatedLength) || estimatedLength < 0)) {
lengthField.setError(L("Common_Validation_NumberRange"));
hasError = true;
}
if (hasError) return;
showLoadingOverlay();
try {
const response = await fetch(`${API_BASE_URL}/b02/project`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
name: nameField.input.value.trim(),
region: regionField.input.value.trim(),
road_type: roadTypeField.select.value,
project_year: projectYear,
estimated_length_m: estimatedLength,
memo: memoField.input.value.trim() || null,
}),
});
if (!response.ok) {
let detail = L("Common_Status_Error");
try {
const errorBody = (await response.json()) as { detail?: string; message?: string };
detail = errorBody.detail ?? errorBody.message ?? detail;
} catch {
detail = `${detail} (${response.status})`;
}
throw new Error(detail);
}
const data = (await response.json()) as { project_id: string };
localStorage.setItem(CURRENT_PROJECT_ID_KEY, data.project_id);
showToast(L("B02_Proj_Success"), "success");
navigateTo(ROUTES.B03_FILE_INPUT);
} catch (error) {
const message = error instanceof Error ? error.message : L("Common_Status_Error");
showToast(message, "error");
} finally {
hideLoadingOverlay();
}
}
const grid = document.createElement("div");
grid.className = "b02-proj__grid";
grid.append(
nameField.root,
regionField.root,
roadTypeField.root,
yearField.root,
lengthField.root,
memoField.root,
);
const card = createCard({ body: [grid, submitBtn], raised: true });
page.append(header, card);
root.append(page);
}