From f00627270f3831fa5f35a5d5630fb4fdcf040824 Mon Sep 17 00:00:00 2001 From: ljpark Date: Thu, 6 Aug 2026 18:40:19 +0900 Subject: [PATCH] =?UTF-8?q?sn:=20=EC=B5=9C=EC=A2=85=ED=99=95=EC=A0=95?= =?UTF-8?q?=EC=98=88=EC=82=B0=EC=82=AC=EC=97=85=20popup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/.env.local | 3 +- apps/sn/.env.mindone | 3 +- .../oper/operStore/page.tsx | 112 +++++ apps/sn/app/store/useOnsemiroAddrStore.ts | 141 ++++++ ...SN-노후하수관로정비사업-최종확정예산사업팝업-검증계획.md | 67 +++ ...SN-노후하수관로정비사업-최종확정예산사업팝업-작업계획.md | 150 +++++++ .../features/cfmtnBgt/api/cfmtn-bgt-api.ts | 21 + .../features/cfmtnBgt/lib/cfmtn-bgt-excel.ts | 82 ++++ .../cfmtnBgt/model/cfmtn-bgt-types.ts | 59 +++ .../cfmtnBgt/ui/CfmtnBgtPopupContent.tsx | 411 ++++++++++++++++++ .../features/obsltPpl/ui/ObsltPplListPage.tsx | 254 ++++++++--- .../windowPopup/model/popupDefinitions.ts | 7 + .../src/features/windowPopup/model/types.ts | 7 +- apps/sn/src/features/windowPopup/registry.tsx | 18 +- 14 files changed, 1270 insertions(+), 65 deletions(-) create mode 100644 apps/sn/app/(with-base-layout)/oper/operStore/page.tsx create mode 100644 apps/sn/app/store/useOnsemiroAddrStore.ts create mode 100644 apps/sn/docs/작업검증/SN-노후하수관로정비사업/003-qa-SN-노후하수관로정비사업-최종확정예산사업팝업-검증계획.md create mode 100644 apps/sn/docs/작업계획/SN-노후하수관로정비사업/003-fe-SN-노후하수관로정비사업-최종확정예산사업팝업-작업계획.md create mode 100644 apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts create mode 100644 apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts create mode 100644 apps/sn/src/features/cfmtnBgt/model/cfmtn-bgt-types.ts create mode 100644 apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx 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/store/useOnsemiroAddrStore.ts b/apps/sn/app/store/useOnsemiroAddrStore.ts new file mode 100644 index 000000000..d727d90f0 --- /dev/null +++ b/apps/sn/app/store/useOnsemiroAddrStore.ts @@ -0,0 +1,141 @@ +"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; +}; + +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 []; +}; + +const toSelectOption = (item: Record) => { + const textCandidate = [ + item.text, + item.name, + item.label, + item.govAreaNm, + item.govAreaName, + item.lclgvrnmntNm, + item.sido, + item.gugun, + item.cityNm, + item.cityName, + item.sggNm, + item.sggName, + item.korNm, + ].find((value) => typeof value === "string" && value.trim()); + + const valueCandidate = [ + item.val, + item.code, + item.id, + item.govAreaCd, + item.govAreaCode, + item.lclgvrnmntCd, + item.adstDstrCd, + item.cityCd, + item.cityCode, + item.sggCd, + item.sggCode, + ].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"); +}; + +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)); +}; + +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-노후하수관로정비사업/003-qa-SN-노후하수관로정비사업-최종확정예산사업팝업-검증계획.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/003-qa-SN-노후하수관로정비사업-최종확정예산사업팝업-검증계획.md new file mode 100644 index 000000000..2b67195ce --- /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` 주소상태 회귀 확인 +- 시도 변경 시 시군구 초기화와 종속 후보 확인 +- A~AG 33개 열, 다단 헤더, 금액 우측정렬, 가로 스크롤 확인 +- 여러 페이지 선택 유지 및 검색·초기화 시 선택 제거 확인 + +## 5. 등록·부모 통신 + +- popup 내부에 사업구분 SelectBox가 없는지 확인 +- 선택 없음에서 `등록` 버튼 비활성화 확인 +- 부모에서 선택한 사업구분이 별도 변환 없이 복수 `SEQ_NO` 등록 요청에 포함되는지 확인 +- 같은 원본 재등록 시 저장하지 않고 이미 등록되었다는 안내가 표시되는지 확인 +- 성공 postMessage type과 등록·중복제외 payload 확인 +- 성공 시 popup close, 부모 알림, 기존 조회조건 유지 첫 페이지 재조회 확인 +- 실패 시 popup과 선택 유지, 부모 목록 미재조회 확인 +- popup 단순 종료 시 부모 목록 미재조회 확인 + +## 6. Excel + +- 적용 조회조건 전체를 1,000건 단위로 조회하고 파일 건수와 일치하는지 확인 +- A~AG 33개 열·대표값 일치 +- 날짜 `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-노후하수관로정비사업/003-fe-SN-노후하수관로정비사업-최종확정예산사업팝업-작업계획.md b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/003-fe-SN-노후하수관로정비사업-최종확정예산사업팝업-작업계획.md new file mode 100644 index 000000000..0233711c4 --- /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 최종확정예산 33개 항목을 조회·다중선택하고 부모 업무로 일괄 등록한다. +- 등록 성공 메시지를 부모에 전달한 뒤 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` | 검색, 33개 열, 다중선택, 등록, 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을 지운다. +- 33개 열은 다단 헤더와 목록 내부 가로 스크롤을 사용한다. +- popup 내부에는 사업구분 SelectBox를 두지 않는다. +- 선택행이 없으면 `등록` 버튼을 비활성화한다. +- 확인 문구는 부모가 전달한 사업구분을 사용한다. +- 등록 요청은 `SEQ_NO[]`만이 아니라 부모에서 전달한 `bizSe`를 함께 보낸다. +- 성공 시 등록·중복제외 건수를 popup message payload로 전달하고 닫는다. +- 실패 시 popup과 선택상태를 유지한다. + +## 9. Excel 계약 + +- FE에서 적용 조회조건 전체 결과를 1,000건 단위로 반복 조회해 생성한다. +- 파일명: `최종확정예산관리_{년도}.xlsx` +- 시트명: `최종확정예산관리` +- 열 순서: 원본 Excel A~AG 33개 열 +- 날짜는 `apps/sn/src/features/common/util.ts`의 `formatDate()`를 사용해 `yyyy-MM-dd`로 표시한다. +- 금액/보조율은 숫자, 사업코드는 문자열로 출력한다. +- 버튼명은 `엑셀다운로드`이며 `초기화` 바로 오른쪽에 둔다. + +## 10. 검증 + +- 부모 사업구분 미선택 시 popup 미오픈과 알림 확인 +- `popupDefinitions → registry → CfmtnBgtPopupContent` 연결 확인 +- 부모별 전년도·사업구분별 초기조건 확인 +- ONSEMIRO 주소 store가 기존 operation-service 상태를 변경하지 않는지 확인 +- 여러 페이지 다중선택과 조건 변경 시 선택 초기화 확인 +- 성공 postMessage 수신, popup close, 부모 목록 조회조건 유지 재조회 확인 +- popup 재오픈 시 부모 조건으로 초기화 확인 +- 33개 열 가로 스크롤, 날짜·금액 포맷, Excel 전체결과 확인 +- 변경 파일 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/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..b21e46869 --- /dev/null +++ b/apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts @@ -0,0 +1,21 @@ +import { getData, postRawJson } from "@/components/lib/api"; +import type { + CfmtnBgtCode, + CfmtnBgtPage, + CfmtnBgtRegistrationResult, +} from "../model/cfmtn-bgt-types"; + +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) => + 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..52d098211 --- /dev/null +++ b/apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts @@ -0,0 +1,82 @@ +import * as XLSX from "xlsx"; +import type { CfmtnBgtRow } from "../model/cfmtn-bgt-types"; + +const HEADERS = [ + "순번", + "권역", + "단위유역", + "중권역", + "환경청", + "시도", + "시군구", + "사업코드", + "사업명", + "사업구분", + "세부사업", + "내역사업", + "세부내역", + "사업진행상태", + "신규/계속", + "착수", + "준공", + "사업규모_하수처리장", + "사업규모_하수관로", + "보조율(%)", + "총사업비_합계", + "총사업비_국고", + "총사업비_지방비", + "총사업비_원인자부담금", + "총사업비_환특융자", + "총사업비_기타", + "전년까지사업비", + "년차사업비_합계", + "년차사업비_국고", + "년차사업비_지방비", + "년차사업비_원인자부담금", + "년차사업비_환특융자", + "년차사업비_기타", +]; +const values = (r: CfmtnBgtRow) => [ + r.rowNumber, + r.rvbsn, + r.lgbsn, + r.mdmbsn, + 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, +]; +export const downloadCfmtnBgtExcel = (rows: CfmtnBgtRow[], year: string) => { + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet( + wb, + XLSX.utils.aoa_to_sheet([HEADERS, ...rows.map(values)]), + "최종확정예산관리", + ); + 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..6a8bf5ec1 --- /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; + pipeScale: string | null; + 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..37612f40c --- /dev/null +++ b/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx @@ -0,0 +1,411 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Button, Input } from "@common/components/ui"; +import { SelectBox } from "@common/business_components/ui"; +import useOnsemiroAddrStore from "@/app/store/useOnsemiroAddrStore"; +import type { WindowPopupContentProps } from "../../windowPopup/model/types"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types"; +import { postPopupMessageAndClose } from "../../windowPopup/lib/popupMessage"; +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"; + +const ALL = { text: "전체", val: "0" }; +const PAGE_SIZE = 20; +const EXPORT_SIZE = 1000; +const yearDefault = + process.env.NEXT_PUBLIC_NODE_ENV === "development" + ? "2024" + : String(new Date().getFullYear() - 1); +const YEAR_OPTIONS = Array.from({ length: 6 }, (_, index) => { + const year = String(Number(yearDefault) - index); + return { text: year, val: year }; +}); +const COLS: [keyof CfmtnBgtRow, string][] = [ + ["rowNumber", "순번"], + ["rvbsn", "권역"], + ["lgbsn", "단위유역"], + ["mdmbsn", "중권역"], + ["govArea", "환경청"], + ["sido", "시도"], + ["gugun", "시군구"], + ["bizCd", "사업코드"], + ["bizNm", "사업명"], + ["sourceBizSe", "사업구분"], + ["untBzNm", "세부사업"], + ["mevBzNm", "내역사업"], + ["dtlBzNm", "세부내역"], + ["bizStatus", "진행상태"], + ["newContinuing", "신규/계속"], + ["startYear", "착수"], + ["endYear", "준공"], + ["treatmentPlantScale", "처리장규모"], + ["pipeScale", "관로규모"], + ["aidRate", "보조율"], + ["totalBizAmount", "총사업비 합계"], + ["totalNational", "총사업비 국고"], + ["totalLocal", "총사업비 지방비"], + ["totalBeneficiary", "총사업비 원인자"], + ["totalLoan", "총사업비 융자"], + ["totalEtc", "총사업비 기타"], + ["previousBizAmount", "전년까지사업비"], + ["annualBizAmount", "년차 합계"], + ["annualNational", "년차 국고"], + ["annualLocal", "년차 지방비"], + ["annualBeneficiary", "년차 원인자"], + ["annualLoan", "년차 융자"], + ["annualEtc", "년차 기타"], +]; +const empty: CfmtnBgtPage = { + content: [], + totalElements: 0, + totalPages: 0, + number: 0, + size: PAGE_SIZE, +}; +const uniq = (items: { text: string; val: string }[]) => + Array.from(new Map(items.map((i) => [`${i.text}|${i.val}`, i])).values()); + +export default function CfmtnBgtPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const bizSe = + typeof searchParams.bizSe === "string" ? searchParams.bizSe : ""; + const { govArea, city, sgg, fetchGovArea, fetchCity, fetchSgg } = + useOnsemiroAddrStore(); + const initial = () => ({ + year: yearDefault, + govArea: "", + sido: "", + gugun: "", + untBzCd: "", + mevBzNm: bizSe === "정비사업" ? "하수관로정비" : "노후하수관로정밀조사지원", + dtlBzCd: "", + bizNm: "", + }); + const [search, setSearch] = useState(initial); + const [codes, setCodes] = useState([]); + const [page, setPage] = useState(0); + const [data, setData] = useState(empty); + const [selected, setSelected] = useState>(new Set()); + const [notice, setNotice] = useState(""); + const [loading, setLoading] = useState(false); + useEffect(() => { + void fetchGovArea(); + void fetchCity(); + void fetchSgg(); + }, [fetchCity, fetchGovArea, fetchSgg]); + useEffect(() => { + void getCfmtnBgtCodes(search.year) + .then(setCodes) + .catch((e) => + setNotice( + 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]); + 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)), + ); + 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) + 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 { + setData(await getCfmtnBgtPage(params(targetPage))); + setPage(targetPage); + setNotice(""); + } catch (e) { + setNotice(e instanceof Error ? e.message : "조회하지 못했습니다."); + setData(empty); + } finally { + setLoading(false); + } + }; + const reset = () => { + setSearch(initial()); + setData(empty); + setSelected(new Set()); + setPage(0); + setNotice(""); + }; + 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)); + rows.push(...result.content); + total = result.totalPages; + p++; + } + downloadCfmtnBgtExcel(rows, search.year); + } catch (e) { + setNotice(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) { + setNotice(e instanceof Error ? e.message : "등록하지 못했습니다."); + } finally { + setLoading(false); + } + }; + return ( +
+
+ + setSearch((s) => ({ ...s, govArea: String(v) })) + } + /> + + setSearch((s) => ({ ...s, sido: String(v), gugun: "" })) + } + /> + setSearch((s) => ({ ...s, gugun: String(v) }))} + /> + + setSearch((s) => ({ ...s, untBzCd: String(v), dtlBzCd: "" })) + } + /> + + setSearch((s) => ({ ...s, mevBzNm: String(v), dtlBzCd: "" })) + } + /> + + setSearch((s) => ({ ...s, dtlBzCd: String(v) })) + } + /> + setSearch((s) => ({ ...s, year: String(v) }))} + /> + setSearch((s) => ({ ...s, bizNm: e.target.value }))} + /> + + + +
+ {notice &&
{notice}
} +
+ + 총 {data.totalElements.toLocaleString()}건 / 선택 {selected.size}건 + + +
+
+ + + + + {COLS.map(([k, t]) => ( + + ))} + + + + {data.content.map((r) => ( + + + {COLS.map(([k]) => ( + + ))} + + ))} + +
+ 0 && + data.content.every((r) => selected.has(r.seqNo)) + } + onChange={(e) => + setSelected((prev) => { + const n = new Set(prev); + data.content.forEach((r) => + e.target.checked ? n.add(r.seqNo) : n.delete(r.seqNo), + ); + return n; + }) + } + /> + + {t} +
+ + setSelected((prev) => { + const n = new Set(prev); + e.target.checked ? n.add(r.seqNo) : n.delete(r.seqNo); + return n; + }) + } + /> + + {typeof r[k] === "number" + ? (r[k] as number).toLocaleString() + : String(r[k] ?? "")} +
+
+
+ + + {page + 1} / {Math.max(data.totalPages, 1)} + + +
+
+ ); +} diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx index c6cec90b0..f422b59a1 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx +++ b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx @@ -3,7 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, UiTable } from "@common/business_components/ui"; +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"; @@ -13,12 +19,20 @@ import { uploadObsltPplExcel, } from "../api/obslt-ppl-api"; import { formatDate } from "../../common/util"; -import { downloadObsltPplExcel, parseObsltPplExcel } from "../lib/obslt-ppl-excel"; -import { buildObsltPplRepresentativeSggOptions, normalizeObsltPplSigunName } from "../lib/obslt-ppl-region"; +import { + downloadObsltPplExcel, + parseObsltPplExcel, +} from "../lib/obslt-ppl-excel"; +import { + buildObsltPplRepresentativeSggOptions, + normalizeObsltPplSigunName, +} from "../lib/obslt-ppl-region"; import type { ObsltPplBusiness, ObsltPplPage } from "../model/obslt-ppl-types"; import LoadingOverlay from "@/components/ui/LoadingBar"; import useStore from "@/store/useStore"; +import { openWindowPopup } from "../../windowPopup/lib/openWindowPopup"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types"; const DEFAULT_LIST_SIZE = 10; const ACTION_BUTTON_CLASS = @@ -33,7 +47,12 @@ const ALL_REGION_OPTION = { text: "전체", val: "0" }; type ObsltPplTableRow = ObsltPplBusiness & { rowNumber: number; }; -type ObsltPplSearch = { bizSe: string; ctpvNm: string; sigunNm: string; bizNm: string }; +type ObsltPplSearch = { + bizSe: string; + ctpvNm: string; + sigunNm: string; + bizNm: string; +}; /** 조회조건·지역 선택 코드·페이지를 목록 복귀에 사용할 URL query로 만든다. */ const createListQueryString = ( @@ -102,13 +121,18 @@ export function ObsltPplListPage() { const searchParams = useSearchParams(); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); const fileInputRef = useRef(null); - const initialSearch = useMemo(() => ({ - bizSe: searchParams.get("bizSe") ?? "", - ctpvNm: searchParams.get("ctpvNm") ?? "", - sigunNm: searchParams.get("sigunNm") ?? "", - bizNm: searchParams.get("bizNm") ?? "", - }), [searchParams]); - const [page, setPage] = useState(() => Math.max(Number(searchParams.get("page")) || 1, 1)); + const initialSearch = useMemo( + () => ({ + bizSe: searchParams.get("bizSe") ?? "", + ctpvNm: searchParams.get("ctpvNm") ?? "", + sigunNm: searchParams.get("sigunNm") ?? "", + bizNm: searchParams.get("bizNm") ?? "", + }), + [searchParams], + ); + const [page, setPage] = useState(() => + Math.max(Number(searchParams.get("page")) || 1, 1), + ); const [data, setData] = useState(EMPTY_PAGE); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); @@ -117,10 +141,15 @@ export function ObsltPplListPage() { // 선택박스의 코드와 API 검색에 사용하는 행정구역명을 함께 관리한다. const [ctpvCd, setCtpvCd] = useState(() => searchParams.get("ctpvCd") ?? "0"); const [sggCd, setSggCd] = useState(() => searchParams.get("sggCd") ?? "0"); - const [appliedCtpvCd, setAppliedCtpvCd] = useState(() => searchParams.get("ctpvCd") ?? "0"); - const [appliedSggCd, setAppliedSggCd] = useState(() => searchParams.get("sggCd") ?? "0"); + const [appliedCtpvCd, setAppliedCtpvCd] = useState( + () => searchParams.get("ctpvCd") ?? "0", + ); + const [appliedSggCd, setAppliedSggCd] = useState( + () => searchParams.get("sggCd") ?? "0", + ); const [search, setSearch] = useState(initialSearch); - const [appliedSearch, setAppliedSearch] = useState(initialSearch); + const [appliedSearch, setAppliedSearch] = + useState(initialSearch); /** 현재 선택한 시도에 속한 시군구만 선택 후보로 제공한다. */ const availableSgg = useMemo(() => { @@ -134,38 +163,81 @@ export function ObsltPplListPage() { }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); /** 확정 검색조건과 페이지를 서버 요청 파라미터로 변환해 목록을 갱신한다. */ - const load = useCallback(async (targetPage = page, targetSearch = appliedSearch) => { - const params = new URLSearchParams({ - page: String(Math.max(targetPage - 1, 0)), - size: String(DEFAULT_LIST_SIZE), - sort: "obsltSwagePplBizSn,desc", - }); - Object.entries(targetSearch).forEach(([key, value]) => { - if (value.trim()) params.set(key, value.trim()); - }); + const load = useCallback( + async (targetPage = page, targetSearch = appliedSearch) => { + const params = new URLSearchParams({ + page: String(Math.max(targetPage - 1, 0)), + size: String(DEFAULT_LIST_SIZE), + sort: "obsltSwagePplBizSn,desc", + }); + Object.entries(targetSearch).forEach(([key, value]) => { + if (value.trim()) params.set(key, value.trim()); + }); - setLoading(true); - try { - const result = await getObsltPplBusinesses(params); - setData(result); - setRows(result.content.map((row, index) => ({ - ...formatBusinessForDisplay(row), - rowNumber: result.totalElements - ((targetPage - 1) * DEFAULT_LIST_SIZE + index), - }))); - } catch (error) { - setNotice(error instanceof Error ? error.message : "목록을 조회하지 못했습니다."); - setData(EMPTY_PAGE); - setRows([]); - } finally { - setLoading(false); - setResetPageNo(false); - } - }, [appliedSearch, page]); + setLoading(true); + try { + const result = await getObsltPplBusinesses(params); + setData(result); + setRows( + result.content.map((row, index) => ({ + ...formatBusinessForDisplay(row), + rowNumber: + result.totalElements - + ((targetPage - 1) * DEFAULT_LIST_SIZE + index), + })), + ); + } catch (error) { + setNotice( + error instanceof Error + ? error.message + : "목록을 조회하지 못했습니다.", + ); + setData(EMPTY_PAGE); + setRows([]); + } finally { + setLoading(false); + setResetPageNo(false); + } + }, + [appliedSearch, page], + ); useEffect(() => { void load(); }, [load]); + /** 팝업 등록 완료 메시지를 받으면 기존 적용 조회조건을 유지한 채 첫 페이지를 재조회한다. */ + useEffect(() => { + const receivePopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.CFMTN_BGT_REGISTERED) + return; + const inserted = Number(event.data?.payload?.insertedCount ?? 0); + const skipped = Number(event.data?.payload?.skippedCount ?? 0); + setNotice( + `최종확정예산사업 ${inserted.toLocaleString()}건을 등록했습니다.${skipped ? ` 이미 등록된 ${skipped.toLocaleString()}건은 제외했습니다.` : ""}`, + ); + setPage(1); + setResetPageNo(true); + void load(1, appliedSearch); + }; + window.addEventListener("message", receivePopupMessage); + return () => window.removeEventListener("message", receivePopupMessage); + }, [appliedSearch, load]); + + /** 부모 화면의 필수 사업구분을 전달하여 최종확정예산사업 선택 팝업을 연다. */ + const handleOpenCfmtnBgt = () => { + if (search.bizSe !== "정밀조사" && search.bizSe !== "정비사업") { + setNotice("사업구분에서 정밀조사 또는 정비사업을 먼저 선택해 주세요."); + return; + } + const popup = openWindowPopup("cfmtnBgt", { bizSe: search.bizSe }); + if (!popup) + setNotice( + "팝업이 차단되었습니다. 브라우저의 팝업 허용 설정을 확인해 주세요.", + ); + }; + /** 선택한 xlsx를 브라우저에서 검증·파싱한 뒤 JSON으로 교체 업로드한다. */ const handleUpload = async (file?: File) => { if (!file) return; @@ -185,7 +257,9 @@ export function ObsltPplListPage() { setPage(1); await load(1, appliedSearch); } catch (error) { - setNotice(error instanceof Error ? error.message : "업로드하지 못했습니다."); + setNotice( + error instanceof Error ? error.message : "업로드하지 못했습니다.", + ); } finally { setLoading(false); if (fileInputRef.current) fileInputRef.current.value = ""; @@ -203,7 +277,9 @@ export function ObsltPplListPage() { const exportRows = await getAllObsltPplBusinesses(params); downloadObsltPplExcel(exportRows); } catch (error) { - setNotice(error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다."); + setNotice( + error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.", + ); } finally { setLoading(false); } @@ -218,7 +294,9 @@ export function ObsltPplListPage() { setAppliedSggCd(sggCd); setResetPageNo(true); const queryString = createListQueryString(1, nextSearch, ctpvCd, sggCd); - router.replace(`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`, { scroll: false }); + router.replace(`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`, { + scroll: false, + }); if (page === 1) { await load(1, nextSearch); return; @@ -241,7 +319,12 @@ export function ObsltPplListPage() { selectClass="w-[120px] rounded-lg bg-white" selectArray={BIZ_SE_OPTIONS} value={search.bizSe} - onSelectValue={(value) => setSearch((current) => ({ ...current, bizSe: String(value) }))} + onSelectValue={(value) => + setSearch((current) => ({ + ...current, + bizSe: String(value), + })) + } />
@@ -258,7 +341,9 @@ export function ObsltPplListPage() { setSggCd("0"); setSearch((current) => ({ ...current, - ctpvNm: ctpv.find((option) => String(option.val) === nextCtpvCd)?.text ?? "", + ctpvNm: + ctpv.find((option) => String(option.val) === nextCtpvCd) + ?.text ?? "", sigunNm: "", })); }} @@ -275,7 +360,9 @@ export function ObsltPplListPage() { onSelectValue={(value) => { const nextSggCd = String(value); setSggCd(nextSggCd); - const selectedOption = availableSgg.find((option) => String(option.val) === nextSggCd); + const selectedOption = availableSgg.find( + (option) => String(option.val) === nextSggCd, + ); setSearch((current) => ({ ...current, sigunNm: selectedOption?.text ?? "", @@ -288,7 +375,12 @@ export function ObsltPplListPage() { setSearch((current) => ({ ...current, bizNm: event.target.value }))} + onChange={(event) => + setSearch((current) => ({ + ...current, + bizNm: event.target.value, + })) + } onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); @@ -302,19 +394,37 @@ export function ObsltPplListPage() {
- + void handleUpload(event.target.files?.[0])} /> -