/* ============================================================================= * 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 { 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); }