Files
Aislo/B02_ProjRegister/B02_ProjRegister_UI_Page.ts
T
eomsangdonandClaude Opus 5 4e4bfa2354 feat(B02/B03/B04/B05): 계획노선 사용 범위 · 절단 여유 3m · 배수유역도 줌·측점 표기
- 계획노선 사용 범위: B02 등록에 시작·종료 누가거리 두 칸 추가, B01 수정 모달에서도
  변경. projects.route_start_m·route_end_m 신설(015_route_range.sql).
  load_design_route 가 범위 절단 → 서피스 트림 순서로 적용. 시작 >= 종료는 화면·서버
  양쪽에서 차단. 비우면 전 구간으로 종전과 같음.
- 서피스 절단 여유 기본값 30m → 3m (SURFACE_ROUTE_EDGE_TRIM_M).
- B04 지도·B05 배수유역도 줌 상한을 「화면 폭 20m」 기준으로 계산(고정 8배·16배 폐지).
  4배를 넘으면 배경 그림 흐림 보간 해제.
- 계획선 위 측점 눈금·번호 표기(측점번호+잔여거리). 관 마커와 겹치면 반대쪽으로 밀고,
  되꺾임 구간에서 라벨이 겹치면 건너뜀. 그리기 코드는 두 화면 공용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 19:08:11 +09:00

326 lines
12 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 {
fetchCompanyAssets,
fetchCompanyMembers,
fetchDashboardMe,
fetchUserCompany,
} from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { createAssetField } from "../B01_Dashboard/B01_Dashboard_UI_AssetPicker";
import { openAddMemberModal } from "../B01_Dashboard/B01_Dashboard_UI_Modals";
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,
});
// 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시).
// 둘 다 비우면 전 구간을 쓴다.
const routeStartField = createInputField({
label: "노선 시작 누가거리 (m)",
placeholder: "비우면 처음부터",
type: "number",
min: 0,
});
const routeEndField = createInputField({
label: "노선 종료 누가거리 (m)",
placeholder: "비우면 끝까지",
type: "number",
min: 0,
});
const memoField = createInputField({
label: L("B02_Proj_Field_Memo"),
placeholder: L("B02_Proj_Field_Memo_Placeholder"),
type: "text",
});
// 도면 표제란·표지 값 — 프로젝트 수정 모달과 같은 항목을 등록 때부터 받는다
// (2026-09-02 사용자 지시). 비워 두면 도면에 빈칸으로 나간다.
const clientOrgField = createInputField({ label: "시행청 (도면 표제란)" });
const projectNumberField = createInputField({
label: "연도·기번 (표지)",
placeholder: "예: 2026년 간선임도(기번3-울진.대흥)",
});
const workAmountField = createInputField({ label: "사업량 (표지)", placeholder: "예: L=2.14km" });
const designDateField = createInputField({ label: "설계일자 (도면 표제란)", type: "date" });
const NEW_MEMBER = "__new__";
const person = (label: string) =>
createSelectField({ label, options: [{ value: "", text: "(미지정)" }] });
const pmField = person("과업책임자 (도면 표제란)");
const fieldLeadField = person("분야별책임자 (도면 표제란)");
const designerField = person("설계자 (도면 표제란)");
const personSelects = [pmField.select, fieldLeadField.select, designerField.select];
// 로고 칸은 회사 자산을 받아야 세울 수 있어 자리만 먼저 잡는다.
const logoSlot = document.createElement("div");
let logoValue: () => number | null = () => null;
void (async () => {
const [me, company] = await Promise.all([
fetchDashboardMe(),
fetchUserCompany().catch(() => null),
]);
const [members, assets] = await Promise.all([
fetchCompanyMembers().catch(() => []),
fetchCompanyAssets().catch(() => []),
]);
const memberText = (member: (typeof members)[number]) =>
member.position ? `${member.name} (${member.position})` : member.name;
const addOption = (select: HTMLSelectElement, value: string, text: string) => {
const option = document.createElement("option");
option.value = value;
option.textContent = text;
select.append(option);
};
for (const select of personSelects) {
for (const member of members) addOption(select, String(member.id), memberText(member));
// 계정을 만드는 것은 회사 관리자 권한이라 일반 사용자에게는 보이지 않는다.
if (me.role !== "USER") addOption(select, NEW_MEMBER, "+ 신규 등록…");
}
for (const select of personSelects) {
let last = select.value;
select.addEventListener("change", () => {
if (select.value !== NEW_MEMBER) {
last = select.value;
return;
}
select.value = last;
openAddMemberModal((member) => {
for (const other of personSelects) {
const option = document.createElement("option");
option.value = String(member.id);
option.textContent = memberText(member);
other.insertBefore(option, other.options[other.options.length - 1]);
}
select.value = String(member.id);
last = select.value;
});
});
}
if (company) {
// 회사 대표 로고가 기본값 — 프로젝트마다 다른 로고를 쓰면 여기서 바꾼다.
const logo = createAssetField(
"회사 로고 (도면 표제란)",
"LOGO",
assets,
company.logo_asset_id ?? null,
company.id,
me,
);
logoValue = logo.value;
logoSlot.append(logo.root);
}
})();
const submitBtn = createButton({
label: L("B02_Proj_Submit"),
variant: "filled",
onClick: onB02_Proj_Submit_Click,
});
submitBtn.classList.add("b02-proj__submit");
const idOrNull = (select: HTMLSelectElement): number | null =>
select.value && select.value !== NEW_MEMBER ? Number(select.value) : null;
async function onB02_Proj_Submit_Click(): Promise<void> {
nameField.setError();
regionField.setError();
yearField.setError();
lengthField.setError();
routeStartField.setError();
routeEndField.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;
}
const routeStart = isBlank(routeStartField.input.value)
? null
: Number.parseFloat(routeStartField.input.value);
const routeEnd = isBlank(routeEndField.input.value)
? null
: Number.parseFloat(routeEndField.input.value);
if (routeStart !== null && (!Number.isFinite(routeStart) || routeStart < 0)) {
routeStartField.setError(L("Common_Validation_NumberRange"));
hasError = true;
}
if (routeEnd !== null && (!Number.isFinite(routeEnd) || routeEnd < 0)) {
routeEndField.setError(L("Common_Validation_NumberRange"));
hasError = true;
}
// 시작이 종료보다 뒤면 남는 구간이 없다 — 서버도 같은 규칙으로 막는다.
if (routeStart !== null && routeEnd !== null && routeStart >= routeEnd) {
routeEndField.setError("종료 누가거리는 시작보다 커야 합니다.");
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,
route_start_m: routeStart,
route_end_m: routeEnd,
memo: memoField.input.value.trim() || null,
client_org: clientOrgField.input.value.trim() || null,
project_number: projectNumberField.input.value.trim() || null,
work_amount: workAmountField.input.value.trim() || null,
design_date: designDateField.input.value || null,
pm_user_id: idOrNull(pmField.select),
field_lead_user_id: idOrNull(fieldLeadField.select),
designer_user_id: idOrNull(designerField.select),
logo_asset_id: logoValue(),
}),
});
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,
routeStartField.root,
routeEndField.root,
memoField.root,
clientOrgField.root,
projectNumberField.root,
workAmountField.root,
designDateField.root,
pmField.root,
fieldLeadField.root,
designerField.root,
logoSlot,
);
const card = createCard({ body: [grid, submitBtn], raised: true });
page.append(header, card);
root.append(page);
}