/* ============================================================================= * 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 { createGeneralLayout } from "@ui/ui_template_general_layout"; import { isBlank } from "@util/common_util_validate"; import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name"; 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 { // 입력 필드 // 프로젝트명은 사업연도 + 사업지역 + 임도종류로 **자동으로 채워지되 고칠 수 있는 칸**이다 // (2026-09-06 사용자 지시 — 종전에는 미리보기 줄이었다). 사용자가 사이나 끝에 글을 넣어도 // 위 세 항목을 다시 바꾸면 그 조각만 갈아 끼워 손댄 글이 살아남는다. const nameField = createInputField({ label: "프로젝트명", placeholder: "예: 2026 가리왕산 간선임도 1구간", 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, }); // 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (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: "시행청 (도면 표제란)" }); // 「연도·기번」·「사업량」 칸은 없앴다 (2026-09-06 사용자 확정) — 프로젝트명·노선 연장과 // 같은 값이라 도면 표지에는 그 둘에서 끌어 쓴다. const designDateField = createInputField({ label: "설계일자 (도면 표제란)", type: "date" }); 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]; // 로고 칸은 두지 않는다 (2026-09-06 사용자 지시) — 프로젝트가 이미 회사에 매여 있어 // 도면은 회사 로고를 그대로 쓴다(표제란 조회가 `COALESCE(프로젝트, 회사)`). void (async () => { const me = await fetchDashboardMe(); const members = await fetchCompanyMembers().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)); // 담당자 기본값은 만든 사람 (2026-09-06 사용자 확정). 계정을 그 자리에서 만드는 // 「신규 등록…」은 없앴다 — 팀원으로 등록한 뒤 고른다. if (members.some((member) => member.id === me.id)) select.value = String(me.id); } })(); const roadTypeText = (): string => roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? ""; /** 이름 조합기 — 글자마다 주인을 기억해 사용자가 사이에 글을 넣어도 연동이 안 끊긴다. */ const nameComposer = createNameComposer(); const nameParts = (): NameParts => ({ year: yearField.input.value.trim(), region: regionField.input.value.trim(), type: roadTypeText(), }); nameField.input.addEventListener("input", () => { nameComposer.edit(nameField.input.value); }); const syncName = (): void => { nameField.input.value = nameComposer.update(nameParts()); }; const composedName = (): string => nameField.input.value.trim(); const routeLength = (): number | null => { const start = isBlank(routeStartField.input.value) ? null : Number.parseFloat(routeStartField.input.value); const end = isBlank(routeEndField.input.value) ? null : Number.parseFloat(routeEndField.input.value); if (end === null || !Number.isFinite(end)) return null; const from = start !== null && Number.isFinite(start) ? start : 0; return end > from ? end - from : null; }; const refresh = (): void => { syncName(); const length = routeLength(); routeEndField.root.querySelector(".ui-field__label")!.textContent = length === null ? "노선 종료 누가거리 (m)" : `노선 종료 누가거리 (m) — 연장 ${length.toFixed(1)}m`; }; // 이름 칸 자신은 여기서 제외한다 — 스스로 고치는 도중에 값을 되돌리면 안 된다. for (const field of [regionField, yearField, routeStartField, routeEndField]) { field.input.addEventListener("input", refresh); } roadTypeField.select.addEventListener("change", refresh); refresh(); const submitBtn = createButton({ label: L("B02_Proj_Submit"), variant: "filled", onClick: onB02_Proj_Submit_Click, }); const cancelBtn = createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", onClick: function onB02_Proj_Cancel_Click() { navigateTo(ROUTES.B01_ACCOUNT); }, }); const idOrNull = (select: HTMLSelectElement): number | null => select.value ? Number(select.value) : null; async function onB02_Proj_Submit_Click(): Promise { nameField.setError(); regionField.setError(); yearField.setError(); routeStartField.setError(); routeEndField.setError(); // 1차 유효성: 필수값 검사 let hasError = false; const projectYear = Number.parseInt(yearField.input.value, 10); // 예상 연장은 따로 받지 않는다 — 노선 구간 길이가 곧 연장이다 (2026-09-06 사용자 확정). const estimatedLength = routeLength(); 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; } 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: composedName(), 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, // 표지 값은 프로젝트명·연장에서 끌어 쓴다 (2026-09-06 사용자 확정). project_number: composedName(), work_amount: estimatedLength === null ? null : `L=${(estimatedLength / 1000).toFixed(2)}km`, 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), }), }); 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( yearField.root, regionField.root, roadTypeField.root, nameField.root, routeStartField.root, routeEndField.root, clientOrgField.root, designDateField.root, pmField.root, fieldLeadField.root, designerField.root, // 비고는 맨 끝 (2026-09-06 사용자 지시). memoField.root, ); // 제목·버튼 줄은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — 다른 화면과 같은 // 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다. const actions = document.createElement("div"); actions.className = "ui-general-block__actions b02-proj__actions"; actions.append(cancelBtn, submitBtn); const card = createCard({ body: [grid, actions], raised: true }); const layout = createGeneralLayout({ pageClass: "b02-proj", title: L("B02_Proj_Title"), subtitle: L("B02_Proj_Subtitle"), content: card, }); root.append(layout.root); }