diff --git a/apps/dm/components/LoginModal.tsx b/apps/dm/components/LoginModal.tsx index 086deedff..292718977 100644 --- a/apps/dm/components/LoginModal.tsx +++ b/apps/dm/components/LoginModal.tsx @@ -20,9 +20,24 @@ type FieldKey = "bizNo" | "workerNo" | "pw"; const REGISTER_HREF = "/apply/dmdco"; function isBizNoValid(v: string) { - return /^\d{10}$/.test(v); + return /^\d{10}$/.test(v.replace(/\D/g, "")); } +const getBusinessNumberDigits = (value: string) => + value.replace(/\D/g, "").slice(0, 10); + +const formatBusinessNumber = (value: string) => { + const normalized = value.replace(/[^\d-]/g, "").slice(0, 12); + const digits = getBusinessNumberDigits(normalized); + + if (normalized.endsWith("-") && digits.length === 3) return `${digits}-`; + if (normalized.endsWith("-") && digits.length === 5) + return `${digits.slice(0, 3)}-${digits.slice(3)}-`; + if (digits.length <= 3) return digits; + if (digits.length <= 5) return `${digits.slice(0, 3)}-${digits.slice(3)}`; + return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5)}`; +}; + function inputCls(opts: { hasError?: boolean; disabled?: boolean }) { const base = "h-[48px] w-full rounded-[4px] border px-5 text-[18px] outline-none"; @@ -102,7 +117,7 @@ export default function LoginModal({ const validate = () => { const next: Partial> = {}; - const b = bizNo.trim(); + const b = getBusinessNumberDigits(bizNo); const w = workerNo.trim(); const p = pw.trim(); @@ -122,23 +137,6 @@ export default function LoginModal({ return next; }; - const formatBusinessNumber = (value: string) => { - // 숫자만 추출 - const digitsOnly = value.replace(/\D/g, ""); - - // 10자리까지만 처리 - const limitedDigits = digitsOnly.slice(0, 10); - - // 3-2-5 형식으로 포맷팅 - if (limitedDigits.length <= 3) { - return limitedDigits; - } else if (limitedDigits.length <= 5) { - return `${limitedDigits.slice(0, 3)}-${limitedDigits.slice(3)}`; - } else { - return `${limitedDigits.slice(0, 3)}-${limitedDigits.slice(3, 5)}-${limitedDigits.slice(5)}`; - } - }; - const markTouched = (key: FieldKey) => setTouched((p) => ({ ...p, [key]: true })); @@ -154,8 +152,9 @@ export default function LoginModal({ try { setSubmitting(true); + const businessNumber = getBusinessNumberDigits(bizNo); const param = { - dmdInfoId: bizNo + (noWorker ? "0000" : workerNo), // 종사업자번호 해당없음 "0000"으로 채움 + dmdInfoId: businessNumber + (noWorker ? "0000" : workerNo), // 종사업자번호 해당없음 "0000"으로 채움 dmdAplyPswd: sha512.hex(pw), }; await actionLogin(param as DemandCompType); @@ -227,9 +226,9 @@ export default function LoginModal({ 사업자 등록번호 { - setBizNo(e.target.value.replace(/\D/g, "").slice(0, 10)); + setBizNo(formatBusinessNumber(e.target.value)); setErrors((p) => ({ ...p, bizNo: undefined })); }} onBlur={() => { diff --git a/apps/sn/.env.local b/apps/sn/.env.local index c603cdbb6..dce96bf40 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -18,4 +18,5 @@ DC_URL = https://apigateway.apps.rsonesoft.com # NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login # NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883 -NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr \ No newline at end of file +NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr +NEXT_PUBLIC_NODE_ENV = "development" \ No newline at end of file diff --git a/apps/sn/.env.mindone b/apps/sn/.env.mindone index da2d0fd5c..84202b57b 100644 --- a/apps/sn/.env.mindone +++ b/apps/sn/.env.mindone @@ -14,4 +14,5 @@ NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&ver SEWERAGE_API_URL = http://con.mind-one.co.kr:11083 DC_URL = https://apigateway.apps.rsonesoft.com NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login -NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr \ No newline at end of file +NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr +NEXT_PUBLIC_NODE_ENV = "development" \ No newline at end of file diff --git a/apps/sn/app/(with-base-layout)/oper/operStore/page.tsx b/apps/sn/app/(with-base-layout)/oper/operStore/page.tsx new file mode 100644 index 000000000..9145ac46b --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/operStore/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { SelectBox } from "@common/business_components/ui"; +import { Label } from "@common/components/ui"; + +import useOnsemiroAddrStore from "@/store/useOnsemiroAddrStore"; + +const DEFAULT_OPTION = { text: "전체", val: "0" }; + +export default function Page() { + const { govArea, city, sgg, fetchGovArea, fetchCity, fetchSgg } = useOnsemiroAddrStore(); + const [selectedGovArea, setSelectedGovArea] = useState("0"); + const [selectedCity, setSelectedCity] = useState("0"); + const [selectedSgg, setSelectedSgg] = useState("0"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + let mounted = true; + + const load = async () => { + setLoading(true); + setError(""); + try { + await Promise.all([fetchGovArea(), fetchCity(), fetchSgg()]); + } catch (nextError) { + if (!mounted) return; + setError(nextError instanceof Error ? nextError.message : "주소 공통코드를 조회하지 못했습니다."); + } finally { + if (mounted) setLoading(false); + } + }; + + void load(); + + return () => { + mounted = false; + }; + }, [fetchCity, fetchGovArea, fetchSgg]); + + useEffect(() => { + if (selectedCity === "0" || selectedSgg === "0") return; + if (selectedSgg.startsWith(selectedCity.slice(0, 2))) return; + setSelectedSgg("0"); + }, [selectedCity, selectedSgg]); + + const filteredSgg = useMemo(() => { + if (selectedCity === "0") return sgg; + const cityPrefix = selectedCity.slice(0, 2); + return sgg.filter((item) => String(item.val).startsWith(cityPrefix)); + }, [selectedCity, sgg]); + + return ( +
+
+

Onsemiro 주소 Store 테스트

+

+ `sewerage-service`의 환경청, 시도, 시군구 조회 결과를 확인하는 임시 화면입니다. +

+
+ +
+
+ + setSelectedGovArea(String(value))} + /> +
+
+ + setSelectedCity(String(value))} + /> +
+
+ + setSelectedSgg(String(value))} + /> +
+
+ +
+

로딩: {loading ? "Y" : "N"}

+

환경청 건수: {govArea.length}

+

시도 건수: {city.length}

+

시군구 건수: {sgg.length}

+

선택 환경청: {selectedGovArea}

+

선택 시도: {selectedCity}

+

선택 시군구: {selectedSgg}

+ {error ?

오류: {error}

: null} +
+
+ ); +} diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index d19be5150..43584ff6f 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -85,6 +85,7 @@ export const getData = async (url: string): Promise => { method: "GET", headers: withSiteIdHeaders(), }); + console.log(res); if (res.status === 401) { handleUnauthorizedResponse(); } diff --git a/apps/sn/app/store/useOnsemiroAddrStore.ts b/apps/sn/app/store/useOnsemiroAddrStore.ts new file mode 100644 index 000000000..f48f02af1 --- /dev/null +++ b/apps/sn/app/store/useOnsemiroAddrStore.ts @@ -0,0 +1,150 @@ +"use client"; + +import { create } from "zustand"; + +import { getRawJson } from "@/components/lib/api"; + +import type { SelectDataType } from "@common/business_components"; + +type OnsemiroAddrStoreState = { + govArea: SelectDataType[]; + city: SelectDataType[]; + sgg: SelectDataType[]; + fetchGovArea: () => Promise; + fetchCity: () => Promise; + fetchSgg: () => Promise; +}; + +/** 시도 SelectBox에 적용할 행정구역 표준 표시 순서다. */ +const CITY_SORT_ORDER = [ + "서울특별시", + "부산광역시", + "대구광역시", + "인천광역시", + "광주광역시", + "대전광역시", + "울산광역시", + "세종특별자치시", + "경기도", + "강원특별자치도", + "충청북도", + "충청남도", + "전북특별자치도", + "전라남도", + "경상북도", + "경상남도", + "제주특별자치도", +]; + +/** 공통 응답 래퍼와 배열 응답을 동일한 목록으로 정규화한다. */ +const toResponseList = (response: unknown) => { + if (Array.isArray(response)) return response; + if (response && typeof response === "object") { + const data = (response as { data?: unknown }).data; + if (Array.isArray(data)) return data; + } + return []; +}; + +/** ONSEMIRO 주소 원본 필드를 공통 SelectBox 옵션으로 변환한다. */ +const toSelectOption = (item: Record) => { + const textCandidate = [ + item.text, + item.name, + item.label, + item.govAreaNm, + item.govAreaName, + item.gugun, + item.sggNm, + item.sggName, + item.lclgvrnmntNm, + item.sido, + item.cityNm, + item.cityName, + item.korNm, + ].find((value) => typeof value === "string" && value.trim()); + + const valueCandidate = [ + item.val, + item.code, + item.id, + item.govAreaCd, + item.govAreaCode, + item.adstDstrCd, + item.sggCd, + item.sggCode, + item.lclgvrnmntCd, + item.cityCd, + item.cityCode, + ].find( + (value) => value !== null && value !== undefined && String(value).trim(), + ); + + if (!textCandidate || valueCandidate === undefined) return null; + + return { + text: String(textCandidate).trim(), + val: String(valueCandidate).trim(), + }; +}; + +/** 시도 옵션을 표준 순서로 정렬하고 나머지는 한글명으로 정렬한다. */ +const compareCityOptions = (left: SelectDataType, right: SelectDataType) => { + const leftIndex = CITY_SORT_ORDER.indexOf(left.text); + const rightIndex = CITY_SORT_ORDER.indexOf(right.text); + + if (leftIndex !== -1 && rightIndex !== -1) return leftIndex - rightIndex; + if (leftIndex !== -1) return -1; + if (rightIndex !== -1) return 1; + return left.text.localeCompare(right.text, "ko"); +}; + +/** 주소 API를 호출해 공통 SelectBox 옵션 목록을 만든다. */ +const loadSelectOptions = async (url: string) => { + const response = await getRawJson(url); + return toResponseList(response) + .map((item) => + item && typeof item === "object" + ? toSelectOption(item as Record) + : null, + ) + .filter((item): item is SelectDataType => Boolean(item)); +}; + +/** ONSEMIRO 환경청·시도·시군구 선택목록을 공유하는 상태 저장소다. */ +const useOnsemiroAddrStore = create((set) => ({ + govArea: [], + city: [], + sgg: [], + /** 환경청 선택목록을 조회해 한글명 순으로 저장한다. */ + fetchGovArea: async () => { + const govArea = await loadSelectOptions( + "/sewerage-service/api/v1/onsemiro/addr/govArea", + ); + set({ + govArea: govArea.sort((left, right) => + left.text.localeCompare(right.text, "ko"), + ), + }); + }, + /** 시도 선택목록을 조회해 표준 행정구역 순서로 저장한다. */ + fetchCity: async () => { + const city = await loadSelectOptions( + "/sewerage-service/api/v1/onsemiro/addr/city", + ); + set({ + city: city.sort(compareCityOptions), + }); + }, + /** 시군구 선택목록을 조회해 한글명 순으로 저장한다. */ + fetchSgg: async () => { + const sgg = await loadSelectOptions( + "/sewerage-service/api/v1/onsemiro/addr/sgg", + ); + set({ + sgg: sgg.sort((left, right) => left.text.localeCompare(right.text, "ko")), + }); + }, +})); + +export default useOnsemiroAddrStore; diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md index 80ea08299..1ad1e0c51 100644 --- a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md @@ -14,6 +14,8 @@ 4. 엑셀다운로드 후 두 시트 및 재업로드 확인 5. 등록 저장, 행 클릭 수정, 수정 화면 삭제 확인 6. 목록에 삭제 버튼이 없고 수정 하단 버튼 순서가 `목록`, `저장`, `삭제`인지 확인 +7. 등록·수정 화면에서 `시도,시군구 선택`에 필수 표시가 노출되고, 시도 미선택 저장 시 `시도는 필수입니다.`가 표시되는지 확인 +8. 시도만 선택하고 시군구를 선택하지 않아도 프론트 검증을 통과해 저장 요청이 전송되는지 확인 ## 선행조건 diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md index f97a4bd99..76b4a4478 100644 --- a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md @@ -15,3 +15,12 @@ - 실제 FE 파서를 실행해 총 517건, 정밀조사 128건, 정비사업 389건을 변환하고 대표 사업코드·연장 값을 대조했다. - 변경한 API·Excel 파서·목록 파일 대상 ESLint를 오류·경고 없이 통과했다. - SN `next dev` 프로세스가 실행 중이어서 앱 규칙에 따라 전체 `next build`는 실행하지 않았다. + +## 2026-08-05 + +- 변경한 API·Excel·타입·목록·폼 5개 파일의 ESLint를 오류·경고 없이 통과했다. +- SN 전체 TypeScript 검사는 저장소 설정의 `ignoreDeprecations` 값을 명령행에서 보정해 실행했다. 다른 기능의 기존 진단 43건은 남았지만 `features/obsltPpl` 진단은 0건이었다. +- 실제 작업지시 517건을 파싱해 두 시트 workbook으로 생성하고 다시 현행 파서로 읽는 왕복 검증을 통과했다: 정밀조사 128건, 정비사업 389건. +- 시트명과 B6 `순번`, D6 업무별 사업코드 헤더, B8 데이터 시작 위치를 자동 확인했다. +- SN 개발 서버가 3002 포트에서 실행 중이어서 앱 규칙에 따라 `next build`는 실행하지 않았다. +- 로그인·실 API 환경에서 조회조건 다운로드와 다운로드→수정→업로드 브라우저 검증은 남은 통합검증이다. diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/003-qa-SN-노후하수관로정비사업-최종확정예산사업팝업-검증계획.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/003-qa-SN-노후하수관로정비사업-최종확정예산사업팝업-검증계획.md new file mode 100644 index 000000000..282b188c3 --- /dev/null +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/003-qa-SN-노후하수관로정비사업-최종확정예산사업팝업-검증계획.md @@ -0,0 +1,67 @@ +# SN-노후하수관로정비사업 최종확정예산사업 팝업 FE 검증계획 + +## 1. 검증 기준 + +- 공통 `006` 사용자 수정본과 동기화한 FE `003` 작업계획을 기준으로 한다. +- 같은 화면 Modal이 아니라 공용 `/popup/[popupKey]` window popup 흐름을 검증한다. + +## 2. 부모 화면 + +- `/oper/obsltPplBiz`에 `최종확정예산사업` 버튼이 표시되는지 확인 +- 사업구분 미선택 시 안내만 표시되고 popup이 열리지 않는지 확인 +- `정비사업`, `정밀조사` 각각에서 popup query parameter가 정확한지 확인 +- popup 차단 시 사용자 안내가 표시되는지 확인 + +## 3. popup 등록과 초기조건 + +- `cfmtnBgt` popup key가 definitions와 registry에 등록되는지 확인 +- popup 제목·크기·가로 스크롤 확인 +- 현재연도 기준 전년도가 초기 선택되는지 확인 +- 정비사업: `하수관로정비% / 하수관로정비 / 노후하수관로정비%` 초기조건 확인 +- 정밀조사: `하수관로정비% / 노후하수관로정밀조사지원` 초기조건 확인 +- popup 재오픈과 초기화 시 부모 조건으로 복원되는지 확인 +- 초기 년도가 항상 현재년도의 전년도로 설정되는지 확인 + +## 4. 조회·주소·목록 + +- 환경청/시도/시군구/세부사업/내역사업/세부내역/년도/사업명 조건 확인 +- `구분` 조건이 없고 환경청 미선택 상태에서도 시도를 선택·조회할 수 있는지 확인 +- `조회 → 초기화 → 엑셀다운로드` 순서 확인 +- ONSEMIRO 전용 city/sgg/govArea 상태와 raw 응답→SelectBox 매핑 확인 +- 기존 operation-service `useOperStore` 주소상태 회귀 확인 +- 시도 변경 시 시군구 초기화와 종속 후보 확인 +- 화면에서 권역·단위유역·중권역이 제외되고 나머지 30개 열, 금액 우측정렬, 가로 스크롤이 정상인지 확인 +- 여러 페이지 선택 유지 및 검색·초기화 시 선택 제거 확인 + +## 5. 등록·부모 통신 + +- popup 내부에 사업구분 SelectBox가 없는지 확인 +- 선택 없음에서 `등록` 버튼 비활성화 확인 +- 부모에서 선택한 사업구분이 별도 변환 없이 복수 `SEQ_NO` 등록 요청에 포함되는지 확인 +- 같은 원본 재등록 시 저장하지 않고 이미 등록되었다는 안내가 표시되는지 확인 +- 성공 postMessage type과 등록·중복제외 payload 확인 +- 성공 시 popup close, 부모 알림, 기존 조회조건 유지 첫 페이지 재조회 확인 +- 실패 시 popup과 선택 유지, 부모 목록 미재조회 확인 +- popup 단순 종료 시 부모 목록 미재조회 확인 + +## 6. Excel + +- 적용 조회조건 전체를 1,000건 단위로 조회하고 파일 건수와 일치하는지 확인 +- 권역·단위유역·중권역을 제외한 30개 열·대표값 일치 +- 날짜 `yyyy-MM-dd`, 금액/보조율 숫자, 사업코드 문자열 확인 +- 파일명·시트명과 `초기화` 옆 버튼 위치 확인 + +## 7. 정적·회귀 검증 + +- 변경 TS/TSX ESLint 통과 +- `features/obsltPpl`, `features/cfmtnBgt`, `features/windowPopup`, `useOperStore` 대상 TypeScript 진단 0건 +- 사용자 승인 후 SN build +- 기존 조회조건 유지, 행 클릭 수정, 개별 등록, 업로드, 기존 Excel 다운로드 회귀 +- 기존 popup registry 전체 key가 정상 해석되는지 확인 + +## 8. 증적 + +- 캡처와 Network 증적은 `COMMON_DOCS_ROOT/작업내용/SN-노후하수관로정비사업/검증/`에 저장한다. +- 2026-08-06 대상 TypeScript 확인: 신규 `cfmtnBgt`, 부모 목록, ONSEMIRO 주소 store, popup registry/model 오류 없음. +- SN 전체 TypeScript는 기존 타 기능 오류가 남아 있어 전체 성공으로 판정하지 않는다. +- SN 전체 build는 제한시간 안에 완료되지 않아 실 API·브라우저 검증과 함께 후속 수행한다. diff --git a/apps/sn/docs/작업계획/SN-노후하수관로정비사업/002-fe-SN-노후하수관로정비사업-엑셀다운로드-유지보수계획.md b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/002-fe-SN-노후하수관로정비사업-엑셀다운로드-유지보수계획.md new file mode 100644 index 000000000..4293b6261 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/002-fe-SN-노후하수관로정비사업-엑셀다운로드-유지보수계획.md @@ -0,0 +1,78 @@ +# SN-노후하수관로정비사업 FE 엑셀 다운로드 유지보수 계획 + +## 목표 + +`/oper/obsltPplBiz`의 `엑셀다운로드`가 업로드와 동일한 시트명 및 셀 위치를 사용하도록 수정한다. 다운로드 파일을 편집한 후 현행 업로드 기능으로 다시 저장할 수 있어야 한다. + +- 모드: `maintenance` +- 상태: **구현 및 자동 검증 완료, 브라우저 통합검증 대기** +- 다운로드 생성 주체: FE + +공통 `005-de-공통-노후하수관로정비사업-미부여사업코드-유지보수계획.md`에 따라 사업코드 공란을 `{사업구분명}-{PK}`로 저장하는 FE·BE 선행 작업을 먼저 완료한다. + +선행 작업에서 `bizSeq` 영속·API 필드를 제거하므로 다운로드 B열 `순번`은 DB 값이 아니라 각 시트의 출력 순서로 생성한다. 업로드 파서는 B열 헤더 위치를 검증하되 순번 값을 저장 요청에 포함하지 않는다. + +## 현재 문제 + +- 현재 다운로드는 `노후하수관로` 한 시트의 1행 헤더로 생성된다. +- 현행 업로드 파서는 `정밀조사`, `정비사업` 두 시트와 B6:V7 헤더, B8 이후 데이터를 요구한다. +- 다운로드 파일과 업로드 계약이 달라 직접 재업로드할 수 없다. + +## FE 구현 계약 + +- SheetJS(`xlsx`)를 계속 사용한다. +- `정밀조사`, `정비사업` 두 시트를 순서대로 생성한다. +- `bizSe`가 `정밀조사`인 행은 `정밀조사` 시트, `정비사업`인 행은 `정비사업` 시트에 기록한다. +- 업로드가 검증하는 정확한 헤더 문자열을 B6:V7에 배치한다. +- 실제 데이터는 B8부터 V열까지 기록한다. +- 업로드와 같은 선, 색, 글꼴 등 시각적 스타일은 복제하지 않는다. +- 화면에 적용된 조회조건에 해당하는 활성 자료만 내려받는다. +- 조건에 해당하는 전체 건은 기존 목록 API에 같은 조회조건과 고정 정렬을 전달하고 페이지 단위로 반복 호출하여 수집한다. +- 선행 작업에서 업로드가 활성행 UPDATE/신규 INSERT 방식으로 바뀌므로, 필터 파일 재업로드 시 파일에 없는 활성행은 유지된다. +- 다운로드 파일명은 `노후하수관로 정밀조사 및 정비사업 현황.xlsx`를 유지한다. + +## 수정 파일 + +| 파일 | 변경 내용 | +| --- | --- | +| `src/features/obsltPpl/api/obslt-ppl-api.ts` | 적용 조회조건 전체 목록을 안정적으로 모으는 페이지 순회 함수 추가 | +| `src/features/obsltPpl/lib/obslt-ppl-excel.ts` | 업로드 계약과 공유되는 두 시트 workbook 생성 로직 추가 | +| `src/features/obsltPpl/ui/ObsltPplListPage.tsx` | 기존 단일 시트 생성 코드 제거 및 새 다운로드 흐름 연결 | + +기존 사용자 수정이 있는 `obslt-ppl-region.ts`, `ObsltPplFormPage.tsx`는 이 작업에서 수정하지 않는다. + +## 데이터 위치 계약 + +| Excel 위치 | 값 | +| --- | --- | +| B6:V7 | 업로드 검증 대상 헤더 | +| B8:V마지막행 | 21개 사업 데이터 필드 | +| 시트 1 | `정밀조사` | +| 시트 2 | `정비사업` | + +데이터 필드 순서는 현행 `parseSheet`의 B~V 매핑을 역방향으로 사용한다. DB PK인 `obslt_swage_ppl_biz_sn`은 다운로드 열에 포함하지 않고, 업로드 시에도 엑셀 순번을 PK로 사용하지 않는다. + +## 검증 계획 + +1. 변경 파일 ESLint와 대상 TypeScript 검증 +2. 두 시트명 및 B6:V7/B8 위치 자동 확인 +3. workbook 생성 후 `parseObsltPplWorkbook`으로 재파싱하는 왕복 검증 +4. 전체·정밀조사·정비사업 건수와 대표 필드 값 대조 +5. 브라우저에서 다운로드 → 일부 셀 수정 → 업로드 동작 확인 +6. 검색조건이 적용된 화면에서는 조건에 해당하는 전체 건만 포함되고, 재업로드 후 조건 밖 기존 활성행이 유지되는지 확인 + +## 제외 범위 + +- 업로드 파일과 동일한 선, 색상, 글꼴, 행 높이, 열 너비 등 스타일 복제 +- BE Controller·Service·Repository 변경 +- API 경로 또는 응답 계약 변경 +- DB DDL 및 데이터 변경 +- 기존 BE 다운로드 API 삭제 + +## 롤백 + +예정된 FE 3개 파일의 다운로드 관련 변경만 되돌린다. DB와 API 계약 변경이 없으므로 별도 데이터 롤백은 없다. + +## 수행 결과 + +사용자 승인 후 미부여 사업코드 선행 작업을 먼저 완료하고 다운로드를 구현했다. 원본 517건을 두 시트로 생성한 뒤 현행 업로드 파서로 다시 읽는 자동 왕복 검증을 통과했다. diff --git a/apps/sn/docs/작업계획/SN-노후하수관로정비사업/003-fe-SN-노후하수관로정비사업-최종확정예산사업팝업-작업계획.md b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/003-fe-SN-노후하수관로정비사업-최종확정예산사업팝업-작업계획.md new file mode 100644 index 000000000..d1e017c00 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/003-fe-SN-노후하수관로정비사업-최종확정예산사업팝업-작업계획.md @@ -0,0 +1,150 @@ +# SN-노후하수관로정비사업 최종확정예산사업 팝업 FE 작업계획 + +## 1. 목표와 기준 + +- 기준 문서: `COMMON_DOCS_ROOT/작업계획/SN-노후하수관로정비사업/006-de-공통-최종확정예산사업팝업-신규기능작업계획.md` +- 대상 부모 화면: `/oper/obsltPplBiz` +- 상태: **사용자 승인 구현 완료, 실 API·브라우저 검증 대기** +- 부모 화면에서 필수 사업구분을 먼저 선택한 경우에만 공용 브라우저 popup을 연다. +- popup에서 ONSEMIRO 최종확정예산을 조회하되 권역·단위유역·중권역을 제외한 30개 항목을 표시하고, 다중선택한 사업을 부모 업무로 일괄 등록한다. +- 등록 성공 메시지를 부모에 전달한 뒤 popup을 닫고 부모 목록을 기존 조회조건으로 다시 조회한다. + +## 2. 공통계획 변경사항 동기화 확인 + +| 항목 | 이전 FE 계획 | 사용자 수정 공통계획 반영 | +| --- | --- | --- | +| 팝업 방식 | 같은 화면의 `UiModal` | `app/(popupLayout)/popup/[popupKey]` 기반 window popup | +| popup 구현 위치 | `features/obsltPpl/ui/CfmtnBgtModal.tsx` | `features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx` | +| 사업구분 선택 | popup 내부 SelectBox | 부모 화면 조회조건에서 먼저 선택, 미선택 시 알림 후 popup 미오픈 | +| 등록 버튼 | `노후하수관로사업 등록` | popup의 `등록` 버튼 | +| 검색 초기값 | 최신연도 일반 조회 | 전년도 및 부모 업무별 세부사업·내역사업·세부내역 자동조건 | +| 지역 데이터 | 신규 옵션 API 직접 사용 | `useOperStore.ts`에 ONSEMIRO 주소 옵션을 추가하여 사용 | +| 완료 처리 | Modal state close | `postMessage`로 부모 알림·재조회 요청 후 `window.close()` | +| 기능 재사용 | 노후하수관로 전용 Modal | 부모 업무정보를 query parameter로 받는 공용 popup content | + +## 3. 예정 수정 파일 + +| 구분 | 예정 파일 | 변경 계획 | +| --- | --- | --- | +| 부모 연결 | `apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx` | 사업구분 필수검증, window popup open, 완료 메시지 수신, 기존 조건 재조회 | +| popup 본체 | `apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx` | 검색, 화면 30개 열, 다중선택, 등록, Excel 다운로드 | +| API | `apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts` | 코드·목록·선택등록 API | +| 타입 | `apps/sn/src/features/cfmtnBgt/model/cfmtn-bgt-types.ts` | 33개 조회필드, 검색·등록·응답, 부모 context 계약 | +| Excel | `apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts` | 적용 조회조건 전체 결과 XLSX 생성 | +| popup 정의 | `apps/sn/src/features/windowPopup/model/popupDefinitions.ts` | `cfmtnBgt` key, 제목, 대형 popup 크기, 필수 query parameter 정의 | +| popup 등록 | `apps/sn/src/features/windowPopup/registry.tsx` | `CfmtnBgtPopupContent` registry 연결 | +| 완료 메시지 | `apps/sn/src/features/windowPopup/model/types.ts` | 최종확정예산 등록 완료 message type 추가 | +| popup 메시지 | 기존 `features/windowPopup/lib/popupMessage.ts` 재사용 | 등록 결과 payload 전송 후 popup 닫기 | +| 운영 store | `apps/sn/app/store/useOperStore.ts` | 기존 operation-service 주소 상태를 유지하고 ONSEMIRO 전용 주소 상태·fetch 함수 추가 | + +## 4. 부모 화면 계약 + +- `최종확정예산사업` 버튼은 기존 목록 액션 영역에 추가한다. +- 버튼 클릭 시 현재 조회조건의 사업구분을 검사한다. +- 선택되지 않았으면 `사업구분을 먼저 선택해 주세요.` 알림을 표시하고 popup을 열지 않는다. +- 선택된 사업구분과 부모 식별정보를 `openWindowPopup("cfmtnBgt", params)`로 전달한다. +- popup 완료 메시지는 다른 popup 메시지와 구분되는 고유 type으로 수신한다. +- 성공 시 popup 응답의 등록·중복제외 건수를 부모 알림에 표시하고, 기존 적용 조회조건을 유지한 첫 페이지를 재조회한다. +- popup이 단순 종료되거나 등록에 실패하면 부모 목록은 재조회하지 않는다. + +## 5. popup query parameter와 재사용 계약 + +제안 parameter: + +| 이름 | 필수 | 설명 | +| --- | --- | --- | +| `parentType` | Y | 부모 업무 식별자, 노후하수관로는 `obsltPplBiz` | +| `bizSe` | Y | 부모가 선택한 사업구분 | +| `parentMessageType` | Y | 성공 시 부모가 수신할 popup message type | + +- popup은 `parentType`과 `bizSe`로 초기 검색조건과 등록 API를 결정한다. +- 다른 화면이 재사용할 때는 popup 본체를 복사하지 않고 부모별 초기조건·등록 adapter만 추가한다. +- 필수 parameter가 없으면 조회하지 않고 누락 안내를 표시한다. + +## 6. 검색 초기값 + +- 연도 기본값은 공통계획의 최신 명시인 **전년도**로 한다. + - 예: 현재가 2026년이면 2025년 +- 부모가 `obsltPplBiz`일 때: + +| 부모 사업구분 | 세부사업 초기조건 | 내역사업 초기조건 | 세부내역 초기조건 | +| --- | --- | --- | --- | +| `정비사업` | `하수관로정비%` | `하수관로정비` | `노후하수관로정비%` | +| `정밀조사` | `하수관로정비%` | `노후하수관로정밀조사지원` | 전체/미지정 | + +- `%` 조건은 화면 label에 `%`를 표시한다는 뜻이 아니라 BE code/options 조회에서 prefix 일치 항목을 선택하는 계약이다. +- popup을 닫았다 다시 열면 부모가 전달한 사업구분으로 초기조건을 다시 계산한다. +- 조회조건: 환경청, 시도, 시군구, 세부사업, 내역사업, 세부내역, 년도, 사업명 +- `구분` 조건은 만들지 않는다. 환경청은 선택조건이며, 환경청을 선택하지 않아도 시도를 독립적으로 선택할 수 있어야 한다. +- 버튼 순서: `조회`, `초기화`, `엑셀다운로드` +- `초기화`는 부모 업무에서 계산한 초기조건으로 되돌리고 결과와 선택을 지운다. +- 초기 년도는 현재년도 기준 전년도로 고정한다. + +## 7. ONSEMIRO 주소 store 계약 + +- 기존 `useOperStore`의 `govArea/city/sgg`와 `/operation-service/api/v1/addr/*` 호출은 다른 화면이 사용하므로 변경하거나 대체하지 않는다. +- 같은 store에 다음과 같은 ONSEMIRO 전용 상태를 별도로 추가한다. + - `onsemiroGovArea`, `onsemiroCity`, `onsemiroSgg` + - `fetchOnsemiroGovArea`, `fetchOnsemiroCity`, `fetchOnsemiroSgg` +- 신규 API: + - `/api/v1/onsemiro/addr/city` + - `/api/v1/onsemiro/addr/sgg` + - `/api/v1/onsemiro/addr/govArea` +- 현재 신규 Addr API 응답은 원본 camelCase 필드이므로 SelectBox 변환 시 다음을 사용한다. + +| 구분 | text | val | +| --- | --- | --- | +| 시도 | `sido` | `adstDstrCd` | +| 시군구 | `gugun` | `adstDstrCd` | +| 환경청/지자체 | `lclgvrnmntNm` | `lclgvrnmntCd` 또는 조회계약에서 확정한 환경청 코드 | + +- 시도 변경 시 `adstDstrCd` 앞자리 또는 BE가 제공하는 관계 기준으로 시군구 후보를 제한하고 기존 시군구 선택을 초기화한다. +- `govArea` 응답은 원본 테이블이 지자체 기준이므로 popup의 실제 환경청 선택 label/code가 맞는지 BE 조회결과로 최종 확인한다. + +## 8. 목록·선택·등록 계약 + +- 첫 열에 전체선택/행선택 checkbox를 제공한다. +- 페이지 이동 후에도 `SEQ_NO` Set을 유지한다. +- 조회·초기화로 적용조건이 바뀌면 선택 Set을 지운다. +- 화면 목록은 권역·단위유역·중권역을 제외한 30개 열과 목록 내부 가로 스크롤을 사용한다. +- popup 내부에는 사업구분 SelectBox를 두지 않는다. +- 선택행이 없으면 `등록` 버튼을 비활성화한다. +- 확인 문구는 부모가 전달한 사업구분을 사용한다. +- 등록 요청은 `SEQ_NO[]`만이 아니라 부모에서 전달한 `bizSe`를 함께 보낸다. +- 성공 시 등록·중복제외 건수를 popup message payload로 전달하고 닫는다. +- 실패 시 popup과 선택상태를 유지한다. + +## 9. Excel 계약 + +- FE에서 적용 조회조건 전체 결과를 1,000건 단위로 반복 조회해 생성한다. +- 파일명: `최종확정예산관리_{년도}.xlsx` +- 시트명: `최종확정예산관리` +- 열 순서: 화면과 동일하게 권역·단위유역·중권역을 제외한 30개 열 +- 날짜는 `apps/sn/src/features/common/util.ts`의 `formatDate()`를 사용해 `yyyy-MM-dd`로 표시한다. +- 금액/보조율은 숫자, 사업코드는 문자열로 출력한다. +- 버튼명은 `엑셀다운로드`이며 `초기화` 바로 오른쪽에 둔다. + +## 10. 검증 + +- 부모 사업구분 미선택 시 popup 미오픈과 알림 확인 +- `popupDefinitions → registry → CfmtnBgtPopupContent` 연결 확인 +- 부모별 전년도·사업구분별 초기조건 확인 +- ONSEMIRO 주소 store가 기존 operation-service 상태를 변경하지 않는지 확인 +- 여러 페이지 다중선택과 조건 변경 시 선택 초기화 확인 +- 성공 postMessage 수신, popup close, 부모 목록 조회조건 유지 재조회 확인 +- popup 재오픈 시 부모 조건으로 초기화 확인 +- 화면과 Excel 모두 권역·단위유역·중권역이 제외된 30개 열인지 확인하고 날짜·금액 포맷과 전체결과를 검증 +- 변경 파일 ESLint·대상 TypeScript 및 사용자 승인 후 SN build·브라우저 검증 + +## 11. 영향·롤백·확정 계약 + +- 영향: `features/obsltPpl`, 신규 `features/cfmtnBgt`, 기존 `features/windowPopup`, `app/store/useOperStore.ts` +- route 파일은 공용 `/popup/[popupKey]`를 재사용하고 신규 route를 만들지 않는다. +- 타 앱과 공통 package는 변경하지 않는다. +- 롤백: 신규 `cfmtnBgt` feature 제거, popup definition/registry/message와 부모 연결·store 전용 상태만 원복한다. +- 부모 화면에서 선택한 사업구분을 별도 명칭 변환 없이 등록 API에 전달한다. +- 같은 원본은 다시 등록하지 않고 이미 등록된 사업임을 안내한다. +- 초기 년도는 현재년도의 전년도로 설정하며 최신 보유연도 fallback은 적용하지 않는다. +- 규모·금액 15개 필드는 조회·Excel 전용으로 유지한다. +- 운영 ONSEMIRO의 `하수관로정비(312)` 코드는 현 계획대로 우선 구현하고 운영 확인 결과에 따라 후속 보정한다. +- `govArea` 응답에서 환경청 label/code로 사용할 필드는 구현 전 실제 응답으로 확인한다. diff --git a/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md index 88c913db3..150b1d9ae 100644 --- a/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md @@ -33,3 +33,18 @@ - `lib/obslt-ppl-excel.ts`에서 두 필수 시트, B6:V7 핵심 헤더, 필수 사업명, 순번·연장 숫자를 검증한다. - `노후하수관로 정밀조사 및 정비사업 현황_upload.xlsx`의 517개 행을 저장 요청 JSON으로 변환한 뒤 `/excel/upload`에 `application/json`으로 전송한다. - 기존 multipart `FormData` 전송과 `postMutiPart` 의존을 제거했다. + +## 2026-08-05 사업순서 제거 및 다운로드 위치 호환 + +- Excel B열 순번은 헤더 위치 검증만 유지하고 저장 JSON의 `bizSeq`에는 포함하지 않도록 변경했으며, 사업코드 공란은 빈 문자열로 BE에 전달한다. +- FE 모델·목록·등록/수정 폼에서 `bizSeq`를 제거했다. +- 화면의 확정 조회조건으로 목록 API를 500건씩 반복 조회하여 조건에 해당하는 전체 활성 자료를 수집한다. +- `bizSe`에 따라 `정밀조사`, `정비사업` 두 시트로 분리하고 업로드와 같은 B6:V7 헤더, B8:V 데이터 위치로 다운로드한다. +- B열 순번은 DB 값이 아니라 각 시트의 출력 순서로 생성하며 스타일·색상 복제는 적용하지 않았다. +- 기존 BE Excel 다운로드 API는 삭제하지 않았다. + +## 2026-08-07 등록·수정 화면 지역 필수 표시 + +- 지역 입력 행 제목을 `시군구 선택`에서 `시도,시군구 선택`으로 변경하고 필수 표시를 추가했다. +- 저장 전 시도 선택 여부를 검증하며, 미선택 시 `시도는 필수입니다.`를 표시한다. +- 시군구는 선택사항으로 유지하여 시도만 선택한 자료도 저장 요청할 수 있다. diff --git a/apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts b/apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts new file mode 100644 index 000000000..594d97ae0 --- /dev/null +++ b/apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts @@ -0,0 +1,28 @@ +import { getData, postRawJson } from "@/components/lib/api"; +import type { + CfmtnBgtCode, + CfmtnBgtPage, + CfmtnBgtRegistrationResult, +} from "../model/cfmtn-bgt-types"; + +/** 공통 응답 래퍼가 있으면 실제 data 값을 반환한다. */ +const unwrap = (v: unknown): T => + v && typeof v === "object" && "data" in v + ? (v as { data: T }).data + : (v as T); +/** 선택 연도의 최종확정예산 사업분류 코드를 조회한다. */ +export const getCfmtnBgtCodes = (year: string) => + getData( + `v1/onsemiro/cfmtnBgt/code?year=${encodeURIComponent(year)}`, + ) as Promise; +/** 검색조건과 페이지 정보로 최종확정예산사업 목록을 조회한다. */ +export const getCfmtnBgtPage = (params: URLSearchParams) => { + console.log("getCfmtnBgtPage 호출" + params); + return getData(`v1/onsemiro/cfmtnBgt?${params}`) as Promise; +} + +/** 선택한 최종확정예산사업을 부모 화면의 사업구분으로 일괄등록한다. */ +export const registerCfmtnBgt = async (bizSe: string, seqNos: number[]) => + unwrap( + await postRawJson("v1/obsltPplBiz/onsemiro/cfmtnBgt", { bizSe, seqNos }), + ); diff --git a/apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts b/apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts new file mode 100644 index 000000000..fd79debd7 --- /dev/null +++ b/apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts @@ -0,0 +1,135 @@ +import * as XLSX from "xlsx"; + +import type { CfmtnBgtRow } from "../model/cfmtn-bgt-types"; + +/** 화면 목록과 동일한 Excel 상위 다단 헤더다. */ +const TOP_HEADERS = [ + "순번", + "환경청", + "행정구역", + "", + "사업코드", + "사업명", + "사업구분", + "세부사업", + "내역사업", + "세부내역", + "사업진행상태", + "신규/계속", + "착수", + "준공", + "사업규모", + "", + "보조율\n(%)", + "총사업비", + "", + "", + "", + "", + "", + "전년까지사업비", + "년차 사업비", + "", + "", + "", + "", + "", +]; +/** 그룹별 하위 항목을 표시하는 Excel 두 번째 헤더다. */ +const SUB_HEADERS = [ + "", + "", + "시도", + "시군구", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "하수처리장", + "하수관로", + "", + "합계", + "국고", + "지방비", + "원인자부담금", + "환특융자", + "기타", + "", + "합계", + "국고", + "지방비", + "원인자부담금", + "환특융자", + "기타", +]; +const MONEY_COLUMN_INDEXES = [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]; +/** 조회 행을 Excel 열 순서에 맞는 셀 배열로 변환한다. */ +const values = (r: CfmtnBgtRow) => [ + r.rowNumber, + r.govArea, + r.sido, + r.gugun, + r.bizCd, + r.bizNm, + r.sourceBizSe, + r.untBzNm, + r.mevBzNm, + r.dtlBzNm, + r.bizStatus, + r.newContinuing, + r.startYear, + r.endYear, + r.treatmentPlantScale, + r.pipeScale, + r.aidRate, + r.totalBizAmount, + r.totalNational, + r.totalLocal, + r.totalBeneficiary, + r.totalLoan, + r.totalEtc, + r.previousBizAmount, + r.annualBizAmount, + r.annualNational, + r.annualLocal, + r.annualBeneficiary, + r.annualLoan, + r.annualEtc, +]; +/** 조회조건 전체 결과를 최종확정예산관리 XLSX 파일로 내려받는다. */ +export const downloadCfmtnBgtExcel = (rows: CfmtnBgtRow[], year: string) => { + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet([ + TOP_HEADERS, + SUB_HEADERS, + ...rows.map(values), + ]); + ws["!merges"] = [ + ...[0, 1, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 16, 23].map((column) => ({ + s: { r: 0, c: column }, + e: { r: 1, c: column }, + })), + { s: { r: 0, c: 2 }, e: { r: 0, c: 3 } }, + { s: { r: 0, c: 14 }, e: { r: 0, c: 15 } }, + { s: { r: 0, c: 17 }, e: { r: 0, c: 22 } }, + { s: { r: 0, c: 24 }, e: { r: 0, c: 29 } }, + ]; + rows.forEach((_, rowIndex) => { + MONEY_COLUMN_INDEXES.forEach((columnIndex) => { + const cellAddress = XLSX.utils.encode_cell({ + r: rowIndex + 2, + c: columnIndex, + }); + const cell = ws[cellAddress]; + if (cell && typeof cell.v === "number") cell.z = "#,##0"; + }); + }); + XLSX.utils.book_append_sheet(wb, ws, "최종확정예산관리"); + XLSX.writeFileXLSX(wb, `최종확정예산관리_${year}.xlsx`); +}; diff --git a/apps/sn/src/features/cfmtnBgt/model/cfmtn-bgt-types.ts b/apps/sn/src/features/cfmtnBgt/model/cfmtn-bgt-types.ts new file mode 100644 index 000000000..c3199c803 --- /dev/null +++ b/apps/sn/src/features/cfmtnBgt/model/cfmtn-bgt-types.ts @@ -0,0 +1,59 @@ +export type CfmtnBgtCode = { + year: string; // 기준연도 + untBzCd: string; // 세부사업 코드 + untBzNm: string; // 세부사업명 + mevBzNm: string; // 내역사업명 + dtlBzCd: string; // 세부내역 코드 + dtlBzNm: string; // 세부내역명 +}; +export type CfmtnBgtRow = { + rowNumber: number; // 화면 표시 순번 + seqNo: number; // 원본 사업 일련번호 + year: string; // 기준연도 + rvbsn: string | null; // 권역 + lgbsn: string | null; // 단위유역 + mdmbsn: string | null; // 중권역 + govArea: string | null; // 환경청 + sido: string | null; // 시도 + gugun: string | null; // 시군구 + bizCd: string | null; // 사업코드 + bizNm: string; // 사업명 + sourceBizSe: string | null; // 원본 사업구분- "하수도관리(균특)" + untBzCd: string | null; // 세부사업 코드 + untBzNm: string | null; // 세부사업명 + mevBzNm: string | null; // 내역사업명 + dtlBzCd: string | null; // 세부내역 코드 + dtlBzNm: string | null; // 세부내역명 + bizStatus: string; // 사업 진행상태 + newContinuing: string | null; // 신규/계속 구분 + startYear: string | null; // 착수연도 + endYear: string | null; // 준공연도 + treatmentPlantScale: string | null; // 하수처리장 규모 - EM_DT_BZ_OTLNE_CONF.BZ_SPEC + pipeScale: string | null; // 하수관로 규모 - EM_DT_BZ_OTLNE_CONF.PIPE_SPEC + aidRate: number; // 보조율 + totalBizAmount: number; // 총사업비 합계 + totalNational: number; // 총사업비 국고 + totalLocal: number; // 총사업비 지방비 + totalBeneficiary: number; // 총사업비 원인자부담금 + totalLoan: number; // 총사업비 환특융자 + totalEtc: number; // 총사업비 기타 + previousBizAmount: number; // 전년까지 사업비 + annualBizAmount: number; // 연차 사업비 합계 + annualNational: number; // 연차 사업비 국고 + annualLocal: number; // 연차 사업비 지방비 + annualBeneficiary: number; // 연차 사업비 원인자부담금 + annualLoan: number; // 연차 사업비 환특융자 + annualEtc: number; // 연차 사업비 기타 +}; +export type CfmtnBgtPage = { + content: CfmtnBgtRow[]; // 현재 페이지 목록 + totalElements: number; // 전체 건수 + totalPages: number; // 전체 페이지 수 + number: number; // 현재 페이지 번호 + size: number; // 페이지 크기 +}; +export type CfmtnBgtRegistrationResult = { + requestedCount: number; // 등록 요청 건수 + insertedCount: number; // 신규 등록 건수 + skippedCount: number; // 중복 제외 건수 +}; diff --git a/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx b/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx new file mode 100644 index 000000000..d60ee49c6 --- /dev/null +++ b/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx @@ -0,0 +1,568 @@ +/** + * onsemiro 최종확정예산사업 팝업 + */ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import useOnsemiroAddrStore from "@/app/store/useOnsemiroAddrStore"; +import { + SearchDiv, + SearchFormLeft, + SearchFormRight, + SelectBox, + UiTable, +} from "@common/business_components/ui"; +import ListDiv from "@common/business_components/ui/list_div"; +import { Button, Input, Label } from "@common/components/ui"; + +import { postPopupMessageAndClose } from "../../windowPopup/lib/popupMessage"; +import type { WindowPopupContentProps } from "../../windowPopup/model/types"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types"; +import { + getCfmtnBgtCodes, + getCfmtnBgtPage, + registerCfmtnBgt, +} from "../api/cfmtn-bgt-api"; +import { downloadCfmtnBgtExcel } from "../lib/cfmtn-bgt-excel"; +import type { + CfmtnBgtCode, + CfmtnBgtPage, + CfmtnBgtRow, +} from "../model/cfmtn-bgt-types"; + +import LoadingOverlay from "@/components/ui/LoadingBar"; + +/** 검색 SelectBox에서 공통으로 사용하는 전체 옵션이다. */ +const ALL = { text: "전체", val: "0" }; +const withAllOption = (items: { text: string; val: string }[]) => [ + ALL, + ...items.filter((item) => String(item.val) !== ALL.val), +]; +/** 화면 목록과 Excel 반복 조회에 적용할 페이지 크기다. */ +const PAGE_SIZE = 10; +const EXPORT_SIZE = 1000; +const ACTION_BUTTON_CLASS = + "rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white"; +/** 최종확정예산사업 표시 항목과 선택 열의 표 헤더 계약이다. */ +const TABLE_HEADLIST = [ + { id: "select", title: "선택", row: 0, rowSpan: 2, widthPercent: 4 }, + { id: "rowNumber", title: "순번", row: 0, rowSpan: 2, widthPercent: 4 }, + { id: "govArea", title: "환경청", row: 0, rowSpan: 2, widthPercent: 7 }, + { id: "A", title: "행정구역", row: 0, colSpan: 2, widthPercent: 12 }, + { id: "sido", title: "시도", row: 1, widthPercent: 6 }, + { id: "gugun", title: "시군구", row: 1, widthPercent: 6 }, + { id: "bizCd", title: "사업코드", row: 0, rowSpan: 2, widthPercent: 7 }, + { id: "bizNm", title: "사업명", row: 0, rowSpan: 2, widthPercent: 12 }, + { id: "sourceBizSe", title: "사업구분", row: 0, rowSpan: 2, widthPercent: 7 }, + { id: "untBzNm", title: "세부사업", row: 0, rowSpan: 2, widthPercent: 8 }, + { id: "mevBzNm", title: "내역사업", row: 0, rowSpan: 2, widthPercent: 8 }, + { id: "dtlBzNm", title: "세부내역", row: 0, rowSpan: 2, widthPercent: 8 }, + { + id: "bizStatus", + title: "사업진행상태", + row: 0, + rowSpan: 2, + widthPercent: 7, + }, + { + id: "newContinuing", + title: "신규/계속", + row: 0, + rowSpan: 2, + widthPercent: 7, + }, + { id: "startYear", title: "착수", row: 0, rowSpan: 2, widthPercent: 5 }, + { id: "endYear", title: "준공", row: 0, rowSpan: 2, widthPercent: 5 }, + { id: "B", title: "사업규모", row: 0, colSpan: 2, widthPercent: 16 }, + { id: "treatmentPlantScale", title: "하수처리장", row: 1, widthPercent: 8 }, + { id: "pipeScale", title: "하수관로", row: 1, widthPercent: 8 }, + { id: "aidRate", title: "보조율\n(%)", row: 0, rowSpan: 2, widthPercent: 6 }, + { id: "C", title: "총사업비", row: 0, colSpan: 6, widthPercent: 48 }, + { id: "totalBizAmount", title: "합계", row: 1, widthPercent: 8 }, + { id: "totalNational", title: "국고", row: 1, widthPercent: 8 }, + { id: "totalLocal", title: "지방비", row: 1, widthPercent: 8 }, + { id: "totalBeneficiary", title: "원인자부담금", row: 1, widthPercent: 8 }, + { id: "totalLoan", title: "환특융자", row: 1, widthPercent: 8 }, + { id: "totalEtc", title: "기타", row: 1, widthPercent: 8 }, + { + id: "previousBizAmount", + title: "전년까지\n사업비", + row: 0, + rowSpan: 2, + widthPercent: 8, + }, + { id: "D", title: "년차 사업비", row: 0, colSpan: 6, widthPercent: 48 }, + { id: "annualBizAmount", title: "합계", row: 1, widthPercent: 8 }, + { id: "annualNational", title: "국고", row: 1, widthPercent: 8 }, + { id: "annualLocal", title: "지방비", row: 1, widthPercent: 8 }, + { id: "annualBeneficiary", title: "원인자부담금", row: 1, widthPercent: 8 }, + { id: "annualLoan", title: "환특융자", row: 1, widthPercent: 8 }, + { id: "annualEtc", title: "기타", row: 1, widthPercent: 8 }, +]; +/** 조회 전과 오류 발생 시 사용할 빈 페이지 상태다. */ +const empty: CfmtnBgtPage = { + content: [], + totalElements: 0, + totalPages: 0, + number: 0, + size: PAGE_SIZE, +}; +/** 코드와 명칭이 같은 SelectBox 옵션의 중복을 제거한다. */ +const uniq = (items: { text: string; val: string }[]) => + Array.from(new Map(items.map((i) => [`${i.text}|${i.val}`, i])).values()); +type CfmtnBgtTableRow = CfmtnBgtRow & { select: number }; + +/** 최종확정예산사업 조회·다중선택·Excel·일괄등록 팝업을 렌더링한다. */ +export default function CfmtnBgtPopupContent({ + searchParams, +}: WindowPopupContentProps) { + // hydration 이후에만 현재년도 기반 검색조건과 팝업 UI를 표시한다. + const [isMounted, setIsMounted] = useState(false); + // 실행 프로필에 맞춘 초기 기준연도를 보관한다. + const [yearBase, setYearBase] = useState(""); + const bizSe = + typeof searchParams.bizSe === "string" ? searchParams.bizSe : ""; + const { govArea, city, sgg, fetchGovArea, fetchCity, fetchSgg } = + useOnsemiroAddrStore(); + /** 부모 사업구분에 맞는 팝업 초기 검색조건을 만든다. */ + const initial = () => ({ + year: "", + govArea: "", // 환경청 + sido: "", // 시도 + gugun: "", // 시군구 + untBzCd: "", // 세부사업 + mevBzNm: "", //내역사업 bizSe === "정비사업" ? "하수관로정비" : "노후하수관로정밀조사지원", + dtlBzCd: "", // 세부내역 + bizNm: "", // 사업명 + }); + // 입력 중 검색조건과 조회 코드·페이지·선택행·메시지 상태를 관리한다. + const [search, setSearch] = useState(initial); + // 선택 연도의 세부사업·내역사업·세부내역 코드를 보관한다. + const [codes, setCodes] = useState([]); + // 서버 페이징에 사용하는 0부터 시작하는 현재 페이지를 보관한다. + const [page, setPage] = useState(0); + // 현재 조회된 목록과 전체 건수 정보를 보관한다. + const [data, setData] = useState(empty); + // 페이지가 바뀌어도 유지할 선택 원본의 SEQ_NO 집합을 보관한다. + const [selected, setSelected] = useState>(new Set()); + // 조회·다운로드·등록 결과 및 오류 안내 문구를 보관한다. + const [notice, setNotice] = useState(""); + // 중복 요청을 막고 처리 중 버튼 상태를 제어한다. + const [loading, setLoading] = useState(false); + /** 현재년도부터 최근 6개 연도의 선택 옵션을 만든다. */ + const yearOptions = useMemo(() => { + if (!yearBase) return []; + const thisYear = new Date().getFullYear(); + return Array.from({ length: 6 }, (_, index) => { + const year = String(thisYear - index); + return { text: year, val: year }; + }); + }, [yearBase]); + // 브라우저 환경이 준비되면 프로필별 기준연도와 초기 조회연도를 설정한다. + useEffect(() => { + setIsMounted(true); + const resolvedYear = + process.env.NEXT_PUBLIC_NODE_ENV === "development" + ? "2024" + : String(new Date().getFullYear() - 1); + setYearBase(resolvedYear); + setSearch((current) => ({ + ...current, + year: current.year || resolvedYear, + })); + }, []); + // 팝업 검색조건에서 사용할 ONSEMIRO 주소 선택목록을 최초 조회한다. + useEffect(() => { + void fetchGovArea(); + void fetchCity(); + void fetchSgg(); + }, [fetchCity, fetchGovArea, fetchSgg]); + // 조회연도가 변경되면 해당 연도의 사업분류 코드를 다시 조회한다. + useEffect(() => { + if (!search.year) return; + void getCfmtnBgtCodes(search.year) + .then((result) => setCodes(Array.isArray(result) ? result : [])) + .catch((e) => + console.error(e instanceof Error ? e.message : "코드를 조회하지 못했습니다."), + ); + }, [search.year]); + /** 하수관로정비에 해당하는 세부사업 선택목록을 만든다. */ + const untOptions = useMemo( + () => + uniq( + codes + .filter((c) => c.untBzNm?.startsWith("하수관로정비")) + .map((c) => ({ text: c.untBzNm, val: c.untBzCd })), + ), + [codes], + ); + // 사업구분 기본조건에 맞는 첫 세부사업 코드를 자동 선택한다. + useEffect(() => { + const first = untOptions[0]; + if (!search.untBzCd && first) + setSearch((s) => ({ ...s, untBzCd: first.val })); + }, [search.untBzCd, untOptions]); + /** 선택 세부사업에 종속된 내역사업 선택목록을 만든다. */ + const mevOptions = useMemo( + () => + uniq( + codes + .filter((c) => !search.untBzCd || c.untBzCd === search.untBzCd) + .map((c) => ({ text: c.mevBzNm, val: c.mevBzNm })) + .filter((x) => x.text), + ), + [codes, search.untBzCd], + ); + /** 선택 사업구분과 내역사업에 종속된 세부내역 선택목록을 만든다. */ + const dtlOptions = useMemo( + () => + uniq( + codes + .filter( + (c) => + (!search.untBzCd || c.untBzCd === search.untBzCd) && + (!search.mevBzNm || c.mevBzNm === search.mevBzNm) && + (bizSe !== "정비사업" || + c.dtlBzNm?.startsWith("노후하수관로정비")), + ) + .map((c) => ({ text: c.dtlBzNm, val: c.dtlBzCd })) + .filter((x) => x.text), + ), + [bizSe, codes, search.mevBzNm, search.untBzCd], + ); + // 정비사업인 경우 노후하수관로정비에 해당하는 첫 세부내역을 자동 선택한다. + useEffect(() => { + const first = dtlOptions[0]; + if (bizSe === "정비사업" && !search.dtlBzCd && first) + setSearch((s) => ({ ...s, dtlBzCd: first.val })); + }, [bizSe, dtlOptions, search.dtlBzCd]); + // 시도 코드를 조회 API가 사용하는 시도명으로 변환한다. + const cityName = city.find((x) => String(x.val) === search.sido)?.text ?? ""; + // 선택 시도 코드의 앞 두 자리에 해당하는 시군구만 노출한다. + const sggOptions = sgg.filter( + (x) => !search.sido || String(x.val).startsWith(search.sido.slice(0, 2)), + ); + /** API 목록을 UiTable 선택 열이 포함된 화면 행으로 변환한다. */ + const rows = useMemo( + () => + (Array.isArray(data.content) ? data.content : []).map((row) => ({ + ...row, + select: row.seqNo, + })), + [data.content], + ); + /** 현재 검색조건을 목록 API의 URL 파라미터로 변환한다. */ + const params = (targetPage: number, size = PAGE_SIZE) => { + const p = new URLSearchParams({ + page: String(targetPage), + size: String(size), + year: search.year, + }); + if (search.govArea) + p.set( + "govArea", + govArea.find((x) => String(x.val) === search.govArea)?.text ?? + search.govArea, + ); + if (cityName) p.set("sido", cityName); + if (search.gugun && search.gugun !== "0") + p.set( + "gugun", + sggOptions.find((x) => String(x.val) === search.gugun)?.text ?? "", + ); + ["untBzCd", "mevBzNm", "dtlBzCd", "bizNm"].forEach((k) => { + const v = search[k as keyof typeof search]; + if (v) p.set(k, v); + }); + return p; + }; + /** 지정 페이지의 최종확정예산사업 목록을 조회한다. */ + const load = async (targetPage = 0) => { + setLoading(true); + try { + const result = await getCfmtnBgtPage(params(targetPage)); + setData( + result && + typeof result === "object" && + "totalElements" in result && + "content" in result + ? (result as CfmtnBgtPage) + : empty, + ); + setPage(targetPage); + setNotice(""); + } catch (e) { + console.error(e instanceof Error ? e.message : "조회하지 못했습니다."); + setData(empty); + } finally { + setLoading(false); + } + }; + /** 검색조건과 조회결과 및 선택상태를 부모 사업구분의 초기값으로 되돌린다. */ + const reset = () => { + setSearch(initial()); + setData(empty); + setSelected(new Set()); + setPage(0); + setNotice(""); + }; + /** 전체 결과를 1,000건씩 순회 조회하여 Excel로 내려받는다. */ + const download = async () => { + setLoading(true); + try { + let p = 0, + total = 1; + const rows: CfmtnBgtRow[] = []; + while (p < total) { + const result = await getCfmtnBgtPage(params(p, EXPORT_SIZE)); + const safeResult = + result && + typeof result === "object" && + "content" in result && + Array.isArray((result as CfmtnBgtPage).content) && + "totalPages" in result + ? (result as CfmtnBgtPage) + : empty; + rows.push(...safeResult.content); + total = safeResult.totalPages; + p++; + } + downloadCfmtnBgtExcel(rows, search.year); + } catch (e) { + console.error(e instanceof Error ? e.message : "엑셀을 내려받지 못했습니다."); + } finally { + setLoading(false); + } + }; + /** 선택한 원본 사업을 확인 후 노후하수관로 사업으로 일괄등록한다. */ + const register = async () => { + if (!selected.size) return; + if (!confirm(`선택한 ${selected.size}건을 ${bizSe}으로 등록하시겠습니까?`)) + return; + setLoading(true); + try { + const r = await registerCfmtnBgt(bizSe, [...selected]); + postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.CFMTN_BGT_REGISTERED, { + insertedCount: String(r.insertedCount), + skippedCount: String(r.skippedCount), + }); + } catch (e) { + console.error(e instanceof Error ? e.message : "등록하지 못했습니다."); + } finally { + setLoading(false); + } + }; + if (!isMounted) return null; + return ( + <> + + + +
+
+
+ + setSearch((s) => ({ ...s, govArea: String(v) })) + } + /> +
+
+ + setSearch((s) => ({ ...s, sido: String(v), gugun: "0" })) + } + /> +
+
+ + setSearch((s) => ({ ...s, gugun: String(v) })) + } + /> +
+
+
+
+ + setSearch((s) => ({ + ...s, + untBzCd: String(v), + dtlBzCd: "0", + })) + } + /> +
+
+ + setSearch((s) => ({ + ...s, + mevBzNm: String(v), + dtlBzCd: "0", + })) + } + /> +
+
+ + setSearch((s) => ({ ...s, dtlBzCd: String(v) })) + } + /> +
+
+ + setSearch((s) => ({ ...s, year: String(v) })) + } + /> +
+
+ + + setSearch((s) => ({ ...s, bizNm: e.target.value })) + } + /> +
+
+
+
+ +
+ + + + +
+
+
+ {notice && ( +
+ {notice} +
+ )} + +
+
+

+ 최종확정예산사업{" "} + + ({data.totalElements.toLocaleString("ko-KR")}건 / 선택{" "} + {selected.size.toLocaleString("ko-KR")}건) + +

+
+ + headName="" + headlist={TABLE_HEADLIST} + tableData={rows} + pageSize={PAGE_SIZE} + total={data.totalElements} + curPage={page + 1} + cellClick={undefined} + onPageChange={(nextPage) => void load(nextPage - 1)} + widthFixed + noOuterMargin + renderCell={(row, headId) => { + if (headId !== "select") return undefined; + return ( + + setSelected((prev) => { + const next = new Set(prev); + if (event.target.checked) next.add(row.seqNo); + else next.delete(row.seqNo); + return next; + }) + } + /> + ); + }} + /> +
+
+ + ); +} diff --git a/apps/sn/src/features/common/util.ts b/apps/sn/src/features/common/util.ts new file mode 100644 index 000000000..7686cafc1 --- /dev/null +++ b/apps/sn/src/features/common/util.ts @@ -0,0 +1,17 @@ +import moment from "moment"; + +/** 8자리 또는 구분자가 섞인 날짜 문자열을 YYYY-MM-DD 형식으로 정규화한다. */ +export const formatDate = (value?: string | null) => { + if (!value) return ""; + const normalized = String(value).trim(); + if (!normalized) return ""; + + const digits = normalized.replace(/\D/g, ""); + if (digits.length === 8) { + const parsedDate = moment(digits, "YYYYMMDD", true); + return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized; + } + + const parsedDate = moment(normalized); + return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized; +}; diff --git a/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts b/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts index 31d4bf208..c073867ae 100644 --- a/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts +++ b/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts @@ -7,13 +7,13 @@ import type { import { deleteData, - getBlobData, getData, postRawJson, putData, } from "@/components/lib/api"; const API_PATH = "v1/obsltPplBiz"; +const EXCEL_DOWNLOAD_PAGE_SIZE = 500; /** 공통 응답 래퍼가 있는 JSON 응답에서 data를 안전하게 꺼낸다. */ const unwrapData = (body: unknown): T => { @@ -27,6 +27,25 @@ const unwrapData = (body: unknown): T => { export const getObsltPplBusinesses = async (params: URLSearchParams): Promise => getData(`${API_PATH}?${params.toString()}`) as Promise; +/** 적용된 조회조건을 유지하며 모든 페이지를 순회해 Excel 다운로드 대상 전체를 모은다. */ +export const getAllObsltPplBusinesses = async (searchParams: URLSearchParams): Promise => { + const rows: ObsltPplBusiness[] = []; + let page = 0; + let totalPages = 1; + + while (page < totalPages) { + const params = new URLSearchParams(searchParams); + params.set("page", String(page)); + params.set("size", String(EXCEL_DOWNLOAD_PAGE_SIZE)); + params.set("sort", "obsltSwagePplBizSn,asc"); + const result = await getObsltPplBusinesses(params); + rows.push(...result.content); + totalPages = result.totalPages; + page += 1; + } + return rows; +}; + /** 수정 화면에서 사업 상세 한 건을 조회한다. */ export const getObsltPplBusiness = async (id: string): Promise => getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise; @@ -49,12 +68,8 @@ export const deleteObsltPplBusiness = async (id: string): Promise => { await deleteData(`${API_PATH}/${encodeURIComponent(id)}`); }; -/** 브라우저에서 파싱한 두 시트 JSON을 활성 스냅샷으로 교체 저장한다. */ +/** 브라우저에서 파싱한 두 시트 JSON을 활성행 UPDATE·신규 INSERT 방식으로 저장한다. */ export const uploadObsltPplExcel = async ( rows: ObsltPplBusinessSaveRequest[], ): Promise => unwrapData(await postRawJson(`${API_PATH}/excel/upload`, rows)); - -/** 현재 활성 자료를 원본과 호환되는 xlsx로 내려받는다. */ -export const downloadObsltPplExcel = async (): Promise => - getBlobData(`${API_PATH}/excel/download`); diff --git a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts index f54ac38b9..c4e7da426 100644 --- a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts +++ b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts @@ -1,6 +1,7 @@ import * as XLSX from "xlsx"; -import type { ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types"; +import { formatDate } from "../../common/util"; +import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types"; type BusinessType = ObsltPplBusinessSaveRequest["bizSe"]; @@ -32,6 +33,7 @@ const SHEET_CONTRACTS: SheetContract[] = [ const DATA_START_ROW_INDEX = 7; const FIRST_DATA_COLUMN_INDEX = 1; const DATA_COLUMN_COUNT = 21; +const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx"; /** 줄바꿈과 공백이 포함된 병합 헤더를 비교 가능한 문자열로 정규화한다. */ const normalizeHeader = (value: string) => value.replace(/\s+/g, "").trim(); @@ -80,17 +82,6 @@ const requiredText = (value: string, sheetName: string, rowNumber: number, field return normalized; }; -/** 순번 셀을 안전한 정수로 변환한다. */ -const optionalInteger = (value: string, sheetName: string, rowNumber: number): number | null => { - const normalized = optionalText(value)?.replace(/,/g, ""); - if (!normalized) return null; - const parsed = Number(normalized); - if (!Number.isSafeInteger(parsed)) { - throw new Error(`${sheetName} 시트 ${rowNumber}행의 순번 값이 정수가 아닙니다.`); - } - return parsed; -}; - /** 연장 셀의 콤마와 km 단위를 제거하고 유한한 숫자로 변환한다. */ const optionalDecimal = (value: string, sheetName: string, rowNumber: number, fieldName: string): number | null => { const normalized = optionalText(value)?.replace(/,/g, "").replace(/km/gi, "").trim(); @@ -134,35 +125,33 @@ const parseSheet = (sheet: XLSX.WorkSheet, contract: SheetContract): ObsltPplBus const values = Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) => cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset), ); + const valueAt = (index: number) => values[index] ?? ""; const rowNumber = rowIndex + 1; - const bizSeq = optionalInteger(values[0], contract.sheetName, rowNumber); - const originalBizCd = optionalText(values[2]); - const fallbackType = contract.sheetName === "정밀조사" ? "S" : "M"; + const originalBizCd = optionalText(valueAt(2)); result.push({ bizSe: contract.sheetName, - bizSeq, - prcisExmnCycl: optionalText(values[1]), - bizCd: originalBizCd ?? `UNASSIGNED-${fallbackType}-${bizSeq ?? rowNumber}`, + prcisExmnCycl: optionalText(valueAt(1)), + bizCd: originalBizCd ?? "", sggCd: null, - ctpvNm: optionalText(values[3]), - sigunNm: optionalText(values[4]), - envoNm: optionalText(values[5]), - bizNm: requiredText(values[6], contract.sheetName, rowNumber, "사업명"), - prplPrst: optionalText(values[7]), - bgngYr: yearText(values[8]), - cmcnYr: yearText(values[9]), - planPrlg: optionalDecimal(values[10], contract.sheetName, rowNumber, "계획연장(km)"), - frhfyrExpcPrlg: optionalDecimal(values[11], contract.sheetName, rowNumber, "상반기(예상)연장(km)"), - rmndPrlg: optionalDecimal(values[12], contract.sheetName, rowNumber, "잔여연장(km)"), - evdncDcmntSbmsnStts: optionalText(values[13]), - bgngYmd: dateText(values[14]), - cmcnYmd: dateText(values[15]), - bizPrplDlyRsnCn: optionalText(values[16]), - bizPicNm: optionalText(values[17]), - bizPicCnpl: optionalText(values[18]), - rmrkCn: optionalText(values[19]), - pbcpOpnnCn: optionalText(values[20]), + ctpvNm: optionalText(valueAt(3)), + sigunNm: optionalText(valueAt(4)), + envoNm: optionalText(valueAt(5)), + bizNm: requiredText(valueAt(6), contract.sheetName, rowNumber, "사업명"), + prplPrst: optionalText(valueAt(7)), + bgngYr: yearText(valueAt(8)), + cmcnYr: yearText(valueAt(9)), + planPrlg: optionalDecimal(valueAt(10), contract.sheetName, rowNumber, "계획연장(km)"), + frhfyrExpcPrlg: optionalDecimal(valueAt(11), contract.sheetName, rowNumber, "상반기(예상)연장(km)"), + rmndPrlg: optionalDecimal(valueAt(12), contract.sheetName, rowNumber, "잔여연장(km)"), + evdncDcmntSbmsnStts: optionalText(valueAt(13)), + bgngYmd: dateText(valueAt(14)), + cmcnYmd: dateText(valueAt(15)), + bizPrplDlyRsnCn: optionalText(valueAt(16)), + bizPicNm: optionalText(valueAt(17)), + bizPicCnpl: optionalText(valueAt(18)), + rmrkCn: optionalText(valueAt(19)), + pbcpOpnnCn: optionalText(valueAt(20)), }); } return result; @@ -185,3 +174,67 @@ export const parseObsltPplExcel = async (file: File): Promise { + const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array(22).fill("")); + const topHeaders = [ + "순번", "정밀조사차수", contract.businessCodeHeader, "시도", "시군", "환경청", "사업명", + "추진현황", "착수년도", "준공(예정)년도", contract.extensionHeader, "", "", "", + contract.progressHeader, "", "사업추진부진사유", "사업담당자(지자체)", "", "비고", "", + ]; + const bottomHeaders = [ + "", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)", + "잔여연장(km)", "증빙서류제출여부", contract.startDateHeader, "준공(예정)일자(yyyy.mm.dd)", + "", "이름", "연락처", "", "공단의견", + ]; + topHeaders.forEach((value, index) => { rows[5]![index + FIRST_DATA_COLUMN_INDEX] = value; }); + bottomHeaders.forEach((value, index) => { rows[6]![index + FIRST_DATA_COLUMN_INDEX] = value; }); + return rows; +}; + +/** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~V에 업무값을 배치한다. */ +const downloadDataRow = (row: ObsltPplBusiness, index: number): Array => [ + "", + index + 1, + row.prcisExmnCycl ?? "", + row.bizCd ?? "", + row.ctpvNm ?? "", + row.sigunNm ?? "", + row.envoNm ?? "", + row.bizNm ?? "", + row.prplPrst ?? "", + row.bgngYr ?? "", + row.cmcnYr ?? "", + row.planPrlg ?? "", + row.frhfyrExpcPrlg ?? "", + row.rmndPrlg ?? "", + row.evdncDcmntSbmsnStts ?? "", + formatDate(row.bgngYmd), + formatDate(row.cmcnYmd), + row.bizPrplDlyRsnCn ?? "", + row.bizPicNm ?? "", + row.bizPicCnpl ?? "", + row.rmrkCn ?? "", + row.pbcpOpnnCn ?? "", +]; + +/** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */ +export const buildObsltPplWorkbook = (businesses: ObsltPplBusiness[]): XLSX.WorkBook => { + const workbook = XLSX.utils.book_new(); + SHEET_CONTRACTS.forEach((contract) => { + const data = businesses.filter((row) => row.bizSe === contract.sheetName); + const worksheet = XLSX.utils.aoa_to_sheet([ + ...downloadHeaderRows(contract), + ...data.map(downloadDataRow), + ]); + worksheet["!cols"] = Array.from({ length: 22 }, (_, index) => ({ wch: index === 7 ? 30 : 16 })); + XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName); + }); + return workbook; +}; + +/** 생성한 두 시트 workbook을 브라우저 다운로드로 저장한다. */ +export const downloadObsltPplExcel = (businesses: ObsltPplBusiness[]) => { + XLSX.writeFileXLSX(buildObsltPplWorkbook(businesses), DOWNLOAD_FILENAME); +}; diff --git a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts index a841ca4be..2fc38854e 100644 --- a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts +++ b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts @@ -4,6 +4,15 @@ type RegionOption = { }; const normalizeRegionText = (value?: string | null) => String(value ?? "").replace(/\s+/g, " ").trim(); +export const OBSLT_PPL_ENV_OFFICES = [ + { code: "G2101", name: "한강유역환경청" }, + { code: "G2102", name: "원주지방환경청" }, + { code: "G2201", name: "낙동강유역환경청" }, + { code: "G2202", name: "대구지방환경청" }, + { code: "G2301", name: "금강유역환경청" }, + { code: "G2302", name: "전북지방환경청" }, + { code: "G2501", name: "영산강유역환경청" }, +] as const; /** 시군구명에서 상위 시만 대표명으로 추출한다. */ export const extractObsltPplRepresentativeSigunName = (value?: string | null) => { @@ -19,6 +28,13 @@ export const normalizeObsltPplSigunName = (value?: string | null) => { return representative || normalizeRegionText(value); }; +/** 저장 가능한 환경청 명칭인지 검사한다. */ +export const isValidObsltPplEnvOfficeName = (value?: string | null) => { + const normalizedValue = normalizeRegionText(value); + if (!normalizedValue) return true; + return OBSLT_PPL_ENV_OFFICES.some((office) => office.name === normalizedValue); +}; + /** 선택된 시도 기준으로 대표 시군구 한 건씩만 노출할 옵션 목록을 만든다. */ export const buildObsltPplRepresentativeSggOptions = ( sggOptions: RegionOption[], diff --git a/apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts b/apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts index 8bca2f992..dd991d1bf 100644 --- a/apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts +++ b/apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts @@ -2,7 +2,6 @@ export interface ObsltPplBusiness { obsltSwagePplBizSn: number; bizSe: "정밀조사" | "정비사업"; - bizSeq?: number | null; prcisExmnCycl?: string | null; bizCd: string; sggCd?: string | null; @@ -43,7 +42,7 @@ export interface ObsltPplPage { size: number; } -/** Excel 교체 업로드 결과 건수를 표시한다. */ +/** Excel UPDATE·INSERT 업로드 결과 건수를 표시한다. */ export interface ObsltPplImportResult { importedCount: number; precisionSurveyCount: number; diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx b/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx index ed776d2e5..4c03555d5 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx +++ b/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx @@ -1,11 +1,22 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; + +import moment from "moment"; import { SelectBox } from "@common/business_components/ui"; -import { Button, Input, Table, TableBody, TableCell, TableHead, TableRow } from "@common/components/ui"; +import { + Button, + Input, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components/ui"; +import { formatDate } from "../../common/util"; import { createObsltPplBusiness, deleteObsltPplBusiness, @@ -16,42 +27,100 @@ import { buildObsltPplRepresentativeSggOptions, findObsltPplCtpvCodeByName, findObsltPplRepresentativeSggCode, + isValidObsltPplEnvOfficeName, normalizeObsltPplSigunName, + OBSLT_PPL_ENV_OFFICES, } from "../lib/obslt-ppl-region"; -import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types"; +import type { + ObsltPplBusiness, + ObsltPplBusinessSaveRequest, +} from "../model/obslt-ppl-types"; import LoadingOverlay from "@/components/ui/LoadingBar"; import useStore from "@/store/useStore"; type FormKey = keyof ObsltPplBusinessSaveRequest; type FormState = Record; +type FieldErrorKey = FormKey | "region"; const REGION_FORM_KEYS: FormKey[] = ["sggCd", "ctpvNm", "sigunNm"]; - -/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */ -const FIELDS: Array<{ key: FormKey; label: string; type?: "number" | "textarea"; required?: boolean; placeholder?: string }> = [ - { key: "bizSe", label: "사업구분", required: true }, { key: "bizSeq", label: "순번", type: "number" }, - { key: "prcisExmnCycl", label: "정밀조사차수" }, { key: "bizCd", label: "사업코드", required: true }, - { key: "envoNm", label: "환경청" }, { key: "bizNm", label: "사업명", required: true }, - { key: "prplPrst", label: "추진현황" }, { key: "bgngYr", label: "착수년도", placeholder: "YYYY" }, - { key: "cmcnYr", label: "준공(예정)년도", placeholder: "YYYY" }, { key: "planPrlg", label: "계획연장(km)", type: "number" }, - { key: "frhfyrExpcPrlg", label: "상반기(예상)연장(km)", type: "number" }, { key: "rmndPrlg", label: "잔여연장(km)", type: "number" }, - { key: "evdncDcmntSbmsnStts", label: "증빙서류 제출여부" }, { key: "bgngYmd", label: "착수(착공) 예정일자", placeholder: "YYYYMMDD" }, - { key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYYMMDD" }, { key: "bizPrplDlyRsnCn", label: "사업추진 부진사유", type: "textarea" }, - { key: "bizPicNm", label: "담당자 이름" }, { key: "bizPicCnpl", label: "담당자 연락처" }, - { key: "rmrkCn", label: "비고", type: "textarea" }, { key: "pbcpOpnnCn", label: "공단의견", type: "textarea" }, +const BIZ_SE_OPTIONS = [ + { text: "정밀조사", val: "정밀조사" }, + { text: "정비사업", val: "정비사업" }, ]; -const NUMBER_KEYS = new Set(["bizSeq", "planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]); +/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */ +const FIELDS: Array<{ + key: FormKey; + label: string; + type?: "number" | "textarea"; + required?: boolean; + placeholder?: string; +}> = [ + { key: "bizSe", label: "사업구분", required: true }, + { key: "prcisExmnCycl", label: "정밀조사차수" }, + { key: "bizCd", label: "사업코드", required: true }, + { key: "envoNm", label: "환경청" }, + { key: "bizNm", label: "사업명", required: true }, + { key: "prplPrst", label: "추진현황" }, + { key: "bgngYr", label: "착수년도", placeholder: "YYYY" }, + { key: "cmcnYr", label: "준공(예정)년도", placeholder: "YYYY" }, + { key: "planPrlg", label: "계획연장(km)", type: "number" }, + { key: "frhfyrExpcPrlg", label: "상반기(예상)연장(km)", type: "number" }, + { key: "rmndPrlg", label: "잔여연장(km)", type: "number" }, + { key: "evdncDcmntSbmsnStts", label: "증빙서류 제출여부" }, + { key: "bgngYmd", label: "착수(착공) 예정일자", placeholder: "YYYY-MM-DD" }, + { key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYY-MM-DD" }, + { key: "bizPrplDlyRsnCn", label: "사업추진 부진사유" }, + { key: "bizPicNm", label: "담당자 이름" }, + { key: "bizPicCnpl", label: "담당자 연락처" }, + { key: "rmrkCn", label: "비고", }, + { key: "pbcpOpnnCn", label: "공단의견", }, +]; + +const NUMBER_KEYS = new Set([ + "planPrlg", + "frhfyrExpcPrlg", + "rmndPrlg", +]); +const DATE_KEYS = new Set(["bgngYmd", "cmcnYmd"]); + +/** 날짜 입력을 YYYY-MM-DD 형식으로 보정하고, 올바른 위치의 직접 입력 하이픈은 유지한다. */ +const formatDateInput = (value: string) => { + const normalized = value.replace(/[^\d-]/g, "").slice(0, 10); + const digits = value.replace(/\D/g, "").slice(0, 8); + if (normalized.endsWith("-") && digits.length === 4) return `${digits}-`; + if (normalized.endsWith("-") && digits.length === 6) + return `${digits.slice(0, 4)}-${digits.slice(4)}-`; + if (digits.length <= 4) return digits; + if (digits.length <= 6) return `${digits.slice(0, 4)}-${digits.slice(4)}`; + return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}`; +}; + +/** 선택 입력인 일자가 비어 있거나 실제 달력에 존재하는 YYYY-MM-DD인지 확인한다. */ +const isValidDateInput = (value: string) => + !value.trim() || + (/^\d{4}-\d{2}-\d{2}$/.test(value) && + moment(value, "YYYY-MM-DD", true).isValid()); + +/** API의 기존 날짜가 날짜형이면 화면 형식으로 바꾸고, 비정상 원문은 숨기지 않고 검증 대상으로 남긴다. */ +const formatStoredDate = (value: string) => formatDate(value) || value; /** 모든 입력을 제어 컴포넌트로 유지하기 위한 빈 폼 값을 생성한다. */ -const createEmptyForm = (): FormState => Object.fromEntries( - [...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => [key, key === "bizSe" ? "정밀조사" : ""]), -) as FormState; +const createEmptyForm = (): FormState => + Object.fromEntries( + [...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => [ + key, + key === "bizSe" ? "정밀조사" : "", + ]), + ) as FormState; /** API 상세값을 빈 문자열 기반 화면 폼 값으로 변환한다. */ const toForm = (business: ObsltPplBusiness): FormState => { const form = createEmptyForm(); - FIELDS.forEach(({ key }) => { form[key] = String(business[key] ?? ""); }); + FIELDS.forEach(({ key }) => { + const value = String(business[key] ?? ""); + form[key] = DATE_KEYS.has(key) ? formatStoredDate(value) : value; + }); form.sggCd = String(business.sggCd ?? ""); form.ctpvNm = String(business.ctpvNm ?? ""); form.sigunNm = normalizeObsltPplSigunName(business.sigunNm); @@ -59,27 +128,44 @@ const toForm = (business: ObsltPplBusiness): FormState => { }; /** 숫자 입력의 빈 문자열은 null로, 나머지는 trim 문자열로 저장 요청을 만든다. */ -const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => Object.fromEntries( - [...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => { - const value = form[key].trim(); - return [key, NUMBER_KEYS.has(key) ? (value === "" ? null : Number(value)) : (value === "" ? null : value)]; - }), -) as unknown as ObsltPplBusinessSaveRequest; +const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => + Object.fromEntries( + [...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => { + const value = form[key].trim(); + if (NUMBER_KEYS.has(key)) + return [key, value === "" ? null : Number(value)]; + if (DATE_KEYS.has(key)) + return [key, value === "" ? null : value.replace(/\D/g, "")]; + return [key, value === "" ? null : value]; + }), + ) as unknown as ObsltPplBusinessSaveRequest; /** 배너 등록 화면의 표형 폼 패턴을 적용한 별도 등록·수정 화면이다. */ export function ObsltPplFormPage({ id }: { id?: string }) { const router = useRouter(); + const searchParams = useSearchParams(); const isModify = Boolean(id); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); const [form, setForm] = useState(createEmptyForm); const [loading, setLoading] = useState(isModify); const [notice, setNotice] = useState(""); + const [fieldErrors, setFieldErrors] = useState< + Partial> + >({}); const [ctpvCd, setCtpvCd] = useState("0"); const [sggCd, setSggCd] = useState("0"); + const listQueryString = searchParams.toString(); + const listHref = `/oper/obsltPplBiz${listQueryString ? `?${listQueryString}` : ""}`; - const ctpvOptions = useMemo(() => [{ text: "전체", val: "0", hidden: true }, ...ctpv], [ctpv]); + const ctpvOptions = useMemo( + () => [{ text: "전체", val: "0", hidden: true }, ...ctpv], + [ctpv], + ); const representativeSggOptions = useMemo( - () => [{ text: "전체", val: "0", hidden: true }, ...buildObsltPplRepresentativeSggOptions(sgg, ctpvCd)], + () => [ + { text: "전체", val: "0", hidden: true }, + ...buildObsltPplRepresentativeSggOptions(sgg, ctpvCd), + ], [ctpvCd, sgg], ); @@ -93,7 +179,12 @@ export function ObsltPplFormPage({ id }: { id?: string }) { const resolvedCtpvCd = findObsltPplCtpvCodeByName(ctpv, form.ctpvNm); const nextCtpvCd = resolvedCtpvCd !== "0" ? resolvedCtpvCd : ctpvCd; - const nextSggCd = findObsltPplRepresentativeSggCode(sgg, nextCtpvCd, form.sigunNm, form.sggCd); + const nextSggCd = findObsltPplRepresentativeSggCode( + sgg, + nextCtpvCd, + form.sigunNm, + form.sggCd, + ); if (nextCtpvCd !== ctpvCd) setCtpvCd(nextCtpvCd); if (nextSggCd !== sggCd) setSggCd(nextSggCd); @@ -102,61 +193,123 @@ export function ObsltPplFormPage({ id }: { id?: string }) { useEffect(() => { if (!id) return; setLoading(true); - getObsltPplBusiness(id).then((data) => setForm(toForm(data))) - .catch((error) => setNotice(error instanceof Error ? error.message : "상세정보를 조회하지 못했습니다.")) + getObsltPplBusiness(id) + .then((data) => setForm(toForm(data))) + .catch((error) => + setNotice( + error instanceof Error + ? error.message + : "상세정보를 조회하지 못했습니다.", + ), + ) .finally(() => setLoading(false)); }, [id]); + const clearFieldError = (key: FieldErrorKey) => { + setFieldErrors((current) => { + if (!current[key]) return current; + const next = { ...current }; + delete next[key]; + return next; + }); + }; + /** 필수값과 숫자 형식을 화면에서 먼저 확인한 뒤 등록 또는 수정 API를 호출한다. */ const handleSave = async () => { - if (!form.bizSe || !form.bizCd.trim() || !form.bizNm.trim()) { setNotice("사업구분, 사업코드, 사업명은 필수입니다."); return; } - if (ctpvCd === "0" || sggCd === "0" || !form.ctpvNm.trim() || !form.sigunNm.trim()) { setNotice("시도와 시군구를 선택해주세요."); return; } - if ([...NUMBER_KEYS].some((key) => form[key] && !Number.isFinite(Number(form[key])))) { setNotice("순번과 물량은 숫자로 입력해주세요."); return; } + const nextErrors: Partial> = {}; + if (!form.bizSe) nextErrors.bizSe = "사업구분은 필수입니다."; + if (!form.bizCd.trim()) nextErrors.bizCd = "사업코드는 필수입니다."; + if (!form.bizNm.trim()) nextErrors.bizNm = "사업명은 필수입니다."; + if (!form.ctpvNm.trim() || ctpvCd === "0") + nextErrors.region = "시도는 필수입니다."; + [...NUMBER_KEYS].forEach((key) => { + if (form[key] && !Number.isFinite(Number(form[key]))) { + nextErrors[key] = "숫자로 입력해주세요."; + } + }); + [...DATE_KEYS].forEach((key) => { + if (!isValidDateInput(form[key])) { + nextErrors[key] = "YYYY-MM-DD 형식의 유효한 날짜로 입력해주세요."; + } + }); + if (!isValidObsltPplEnvOfficeName(form.envoNm)) { + nextErrors.envoNm = `${OBSLT_PPL_ENV_OFFICES.map((office) => office.name).join(", ")} 중 하나여야 합니다.`; + } + if (Object.keys(nextErrors).length > 0) { + setFieldErrors(nextErrors); + setNotice(""); + return; + } + setFieldErrors({}); setLoading(true); try { if (id) await updateObsltPplBusiness(id, toRequest(form)); else await createObsltPplBusiness(toRequest(form)); window.alert(isModify ? "수정되었습니다." : "등록되었습니다."); - router.push("/oper/obsltPplBiz"); - } catch (error) { setNotice(error instanceof Error ? error.message : "저장하지 못했습니다."); } - finally { setLoading(false); } + router.push(listHref); + } catch (error) { + setNotice( + error instanceof Error ? error.message : "저장하지 못했습니다.", + ); + } finally { + setLoading(false); + } }; /** 사용자 확인 후 현재 사업을 논리삭제하고 목록으로 이동한다. */ const handleDelete = async () => { if (!id || !window.confirm("삭제하시겠습니까?")) return; setLoading(true); - try { await deleteObsltPplBusiness(id); window.alert("삭제되었습니다."); router.push("/oper/obsltPplBiz"); } - catch (error) { setNotice(error instanceof Error ? error.message : "삭제하지 못했습니다."); } - finally { setLoading(false); } + try { + await deleteObsltPplBusiness(id); + window.alert("삭제되었습니다."); + router.push(listHref); + } catch (error) { + setNotice( + error instanceof Error ? error.message : "삭제하지 못했습니다.", + ); + } finally { + setLoading(false); + } }; return (
-

노후하수관로 사업 {isModify ? "수정" : "등록"}

+
+

+ 노후하수관로 사업 {isModify ? "수정" : "등록"} +

+
- + + + + - 시군구 선택 + 시도,시군구 선택
{ const nextCtpvCd = String(value); + clearFieldError("region"); setCtpvCd(nextCtpvCd); setSggCd("0"); setForm((current) => ({ ...current, - ctpvNm: ctpv.find((option) => String(option.val) === nextCtpvCd)?.text ?? "", + ctpvNm: + ctpv.find( + (option) => String(option.val) === nextCtpvCd, + )?.text ?? "", sigunNm: "", sggCd: "", })); @@ -166,12 +319,15 @@ export function ObsltPplFormPage({ id }: { id?: string }) { label="시군구" className="" labelClass="w-20 whitespace-nowrap text-[1.3rem] text-gray-600" - selectClass="w-[220px] rounded-lg bg-white" + selectClass="h-11 w-[220px] rounded-lg bg-white" selectArray={representativeSggOptions} value={sggCd} onSelectValue={(value) => { const nextSggCd = String(value); - const selectedOption = representativeSggOptions.find((option) => String(option.val) === nextSggCd); + const selectedOption = representativeSggOptions.find( + (option) => String(option.val) === nextSggCd, + ); + clearFieldError("region"); setSggCd(nextSggCd); setForm((current) => ({ ...current, @@ -181,29 +337,94 @@ export function ObsltPplFormPage({ id }: { id?: string }) { }} />
+ {fieldErrors.region && ( +

+ {fieldErrors.region} +

+ )}
{FIELDS.map((field) => ( - {field.label} + + {field.label} + {field.key === "bizSe" ? ( - + { + clearFieldError("bizSe"); + setForm({ ...form, bizSe: String(value) }); + }} + /> ) : field.type === "textarea" ? ( -