diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index b4b766f25..de8a813ff 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 index d727d90f0..30f3647f2 100644 --- a/apps/sn/app/store/useOnsemiroAddrStore.ts +++ b/apps/sn/app/store/useOnsemiroAddrStore.ts @@ -15,6 +15,7 @@ type OnsemiroAddrStoreState = { fetchSgg: () => Promise; }; +/** 시도 SelectBox에 적용할 행정구역 표준 표시 순서다. */ const CITY_SORT_ORDER = [ "서울특별시", "부산광역시", @@ -35,6 +36,7 @@ const CITY_SORT_ORDER = [ "제주특별자치도", ]; +/** 공통 응답 래퍼와 배열 응답을 동일한 목록으로 정규화한다. */ const toResponseList = (response: unknown) => { if (Array.isArray(response)) return response; if (response && typeof response === "object") { @@ -44,6 +46,7 @@ const toResponseList = (response: unknown) => { return []; }; +/** ONSEMIRO 주소 원본 필드를 공통 SelectBox 옵션으로 변환한다. */ const toSelectOption = (item: Record) => { const textCandidate = [ item.text, @@ -85,6 +88,7 @@ const toSelectOption = (item: Record) => { }; }; +/** 시도 옵션을 표준 순서로 정렬하고 나머지는 한글명으로 정렬한다. */ const compareCityOptions = (left: SelectDataType, right: SelectDataType) => { const leftIndex = CITY_SORT_ORDER.indexOf(left.text); const rightIndex = CITY_SORT_ORDER.indexOf(right.text); @@ -95,6 +99,7 @@ const compareCityOptions = (left: SelectDataType, right: SelectDataType) => { return left.text.localeCompare(right.text, "ko"); }; +/** 주소 API를 호출해 공통 SelectBox 옵션 목록을 만든다. */ const loadSelectOptions = async (url: string) => { const response = await getRawJson(url); return toResponseList(response) @@ -106,10 +111,12 @@ const loadSelectOptions = async (url: string) => { .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", @@ -120,6 +127,7 @@ const useOnsemiroAddrStore = create((set) => ({ ), }); }, + /** 시도 선택목록을 조회해 표준 행정구역 순서로 저장한다. */ fetchCity: async () => { const city = await loadSelectOptions( "/sewerage-service/api/v1/onsemiro/addr/city", @@ -128,6 +136,7 @@ const useOnsemiroAddrStore = create((set) => ({ city: city.sort(compareCityOptions), }); }, + /** 시군구 선택목록을 조회해 한글명 순으로 저장한다. */ fetchSgg: async () => { const sgg = await loadSelectOptions( "/sewerage-service/api/v1/onsemiro/addr/sgg", diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/003-qa-SN-노후하수관로정비사업-최종확정예산사업팝업-검증계획.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/003-qa-SN-노후하수관로정비사업-최종확정예산사업팝업-검증계획.md index 2b67195ce..282b188c3 100644 --- a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/003-qa-SN-노후하수관로정비사업-최종확정예산사업팝업-검증계획.md +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/003-qa-SN-노후하수관로정비사업-최종확정예산사업팝업-검증계획.md @@ -30,7 +30,7 @@ - ONSEMIRO 전용 city/sgg/govArea 상태와 raw 응답→SelectBox 매핑 확인 - 기존 operation-service `useOperStore` 주소상태 회귀 확인 - 시도 변경 시 시군구 초기화와 종속 후보 확인 -- A~AG 33개 열, 다단 헤더, 금액 우측정렬, 가로 스크롤 확인 +- 화면에서 권역·단위유역·중권역이 제외되고 나머지 30개 열, 금액 우측정렬, 가로 스크롤이 정상인지 확인 - 여러 페이지 선택 유지 및 검색·초기화 시 선택 제거 확인 ## 5. 등록·부모 통신 @@ -47,7 +47,7 @@ ## 6. Excel - 적용 조회조건 전체를 1,000건 단위로 조회하고 파일 건수와 일치하는지 확인 -- A~AG 33개 열·대표값 일치 +- 권역·단위유역·중권역을 제외한 30개 열·대표값 일치 - 날짜 `yyyy-MM-dd`, 금액/보조율 숫자, 사업코드 문자열 확인 - 파일명·시트명과 `초기화` 옆 버튼 위치 확인 diff --git a/apps/sn/docs/작업계획/SN-노후하수관로정비사업/003-fe-SN-노후하수관로정비사업-최종확정예산사업팝업-작업계획.md b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/003-fe-SN-노후하수관로정비사업-최종확정예산사업팝업-작업계획.md index 0233711c4..d1e017c00 100644 --- a/apps/sn/docs/작업계획/SN-노후하수관로정비사업/003-fe-SN-노후하수관로정비사업-최종확정예산사업팝업-작업계획.md +++ b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/003-fe-SN-노후하수관로정비사업-최종확정예산사업팝업-작업계획.md @@ -6,7 +6,7 @@ - 대상 부모 화면: `/oper/obsltPplBiz` - 상태: **사용자 승인 구현 완료, 실 API·브라우저 검증 대기** - 부모 화면에서 필수 사업구분을 먼저 선택한 경우에만 공용 브라우저 popup을 연다. -- popup에서 ONSEMIRO 최종확정예산 33개 항목을 조회·다중선택하고 부모 업무로 일괄 등록한다. +- popup에서 ONSEMIRO 최종확정예산을 조회하되 권역·단위유역·중권역을 제외한 30개 항목을 표시하고, 다중선택한 사업을 부모 업무로 일괄 등록한다. - 등록 성공 메시지를 부모에 전달한 뒤 popup을 닫고 부모 목록을 기존 조회조건으로 다시 조회한다. ## 2. 공통계획 변경사항 동기화 확인 @@ -27,7 +27,7 @@ | 구분 | 예정 파일 | 변경 계획 | | --- | --- | --- | | 부모 연결 | `apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx` | 사업구분 필수검증, window popup open, 완료 메시지 수신, 기존 조건 재조회 | -| popup 본체 | `apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx` | 검색, 33개 열, 다중선택, 등록, Excel 다운로드 | +| 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 생성 | @@ -106,7 +106,7 @@ - 첫 열에 전체선택/행선택 checkbox를 제공한다. - 페이지 이동 후에도 `SEQ_NO` Set을 유지한다. - 조회·초기화로 적용조건이 바뀌면 선택 Set을 지운다. -- 33개 열은 다단 헤더와 목록 내부 가로 스크롤을 사용한다. +- 화면 목록은 권역·단위유역·중권역을 제외한 30개 열과 목록 내부 가로 스크롤을 사용한다. - popup 내부에는 사업구분 SelectBox를 두지 않는다. - 선택행이 없으면 `등록` 버튼을 비활성화한다. - 확인 문구는 부모가 전달한 사업구분을 사용한다. @@ -119,7 +119,7 @@ - FE에서 적용 조회조건 전체 결과를 1,000건 단위로 반복 조회해 생성한다. - 파일명: `최종확정예산관리_{년도}.xlsx` - 시트명: `최종확정예산관리` -- 열 순서: 원본 Excel A~AG 33개 열 +- 열 순서: 화면과 동일하게 권역·단위유역·중권역을 제외한 30개 열 - 날짜는 `apps/sn/src/features/common/util.ts`의 `formatDate()`를 사용해 `yyyy-MM-dd`로 표시한다. - 금액/보조율은 숫자, 사업코드는 문자열로 출력한다. - 버튼명은 `엑셀다운로드`이며 `초기화` 바로 오른쪽에 둔다. @@ -133,7 +133,7 @@ - 여러 페이지 다중선택과 조건 변경 시 선택 초기화 확인 - 성공 postMessage 수신, popup close, 부모 목록 조회조건 유지 재조회 확인 - popup 재오픈 시 부모 조건으로 초기화 확인 -- 33개 열 가로 스크롤, 날짜·금액 포맷, Excel 전체결과 확인 +- 화면과 Excel 모두 권역·단위유역·중권역이 제외된 30개 열인지 확인하고 날짜·금액 포맷과 전체결과를 검증 - 변경 파일 ESLint·대상 TypeScript 및 사용자 승인 후 SN build·브라우저 검증 ## 11. 영향·롤백·확정 계약 diff --git a/apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts b/apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts index b21e46869..594d97ae0 100644 --- a/apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts +++ b/apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts @@ -5,16 +5,23 @@ import type { 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) => - getData(`v1/onsemiro/cfmtnBgt?${params}`) 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 index 52d098211..750e6c941 100644 --- a/apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts +++ b/apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts @@ -1,14 +1,13 @@ import * as XLSX from "xlsx"; + import type { CfmtnBgtRow } from "../model/cfmtn-bgt-types"; -const HEADERS = [ +/** 화면 목록과 동일한 Excel 상위 다단 헤더다. */ +const TOP_HEADERS = [ "순번", - "권역", - "단위유역", - "중권역", "환경청", - "시도", - "시군구", + "행정구역", + "", "사업코드", "사업명", "사업구분", @@ -19,28 +18,59 @@ const HEADERS = [ "신규/계속", "착수", "준공", - "사업규모_하수처리장", - "사업규모_하수관로", - "보조율(%)", - "총사업비_합계", - "총사업비_국고", - "총사업비_지방비", - "총사업비_원인자부담금", - "총사업비_환특융자", - "총사업비_기타", + "사업규모", + "", + "보조율\n(%)", + "총사업비", + "", + "", + "", + "", + "", "전년까지사업비", - "년차사업비_합계", - "년차사업비_국고", - "년차사업비_지방비", - "년차사업비_원인자부담금", - "년차사업비_환특융자", - "년차사업비_기타", + "년차 사업비", + "", + "", + "", + "", + "", ]; +/** 그룹별 하위 항목을 표시하는 Excel 두 번째 헤더다. */ +const SUB_HEADERS = [ + "", + "", + "시도", + "시군구", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "하수처리장", + "하수관로", + "", + "합계", + "국고", + "지방비", + "원인자부담금", + "환특융자", + "기타", + "", + "합계", + "국고", + "지방비", + "원인자부담금", + "환특융자", + "기타", +]; +/** 조회 행을 Excel 열 순서에 맞는 셀 배열로 변환한다. */ const values = (r: CfmtnBgtRow) => [ r.rowNumber, - r.rvbsn, - r.lgbsn, - r.mdmbsn, r.govArea, r.sido, r.gugun, @@ -71,12 +101,24 @@ const values = (r: CfmtnBgtRow) => [ r.annualLoan, r.annualEtc, ]; +/** 조회조건 전체 결과를 최종확정예산관리 XLSX 파일로 내려받는다. */ 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)]), - "최종확정예산관리", - ); + 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 } }, + ]; + 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 index 6a8bf5ec1..c3199c803 100644 --- a/apps/sn/src/features/cfmtnBgt/model/cfmtn-bgt-types.ts +++ b/apps/sn/src/features/cfmtnBgt/model/cfmtn-bgt-types.ts @@ -1,59 +1,59 @@ export type CfmtnBgtCode = { - year: string; - untBzCd: string; - untBzNm: string; - mevBzNm: string; - dtlBzCd: string; - dtlBzNm: string; + 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; + 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; + content: CfmtnBgtRow[]; // 현재 페이지 목록 + totalElements: number; // 전체 건수 + totalPages: number; // 전체 페이지 수 + number: number; // 현재 페이지 번호 + size: number; // 페이지 크기 }; export type CfmtnBgtRegistrationResult = { - requestedCount: number; - insertedCount: number; - skippedCount: number; + 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 index 37612f40c..bef714e40 100644 --- a/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx +++ b/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx @@ -1,12 +1,24 @@ +/** + * onsemiro 최종확정예산사업 팝업 + */ "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 { + 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 { postPopupMessageAndClose } from "../../windowPopup/lib/popupMessage"; import { getCfmtnBgtCodes, getCfmtnBgtPage, @@ -19,52 +31,72 @@ import type { CfmtnBgtRow, } from "../model/cfmtn-bgt-types"; +import LoadingOverlay from "@/components/ui/LoadingBar"; + +/** 검색 SelectBox에서 공통으로 사용하는 전체 옵션이다. */ const ALL = { text: "전체", val: "0" }; -const PAGE_SIZE = 20; +/** 화면 목록과 Excel 반복 조회에 적용할 페이지 크기다. */ +const PAGE_SIZE = 10; 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 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, @@ -72,47 +104,85 @@ const empty: CfmtnBgtPage = { 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: yearDefault, + year: "", govArea: "", sido: "", gugun: "", untBzCd: "", - mevBzNm: bizSe === "정비사업" ? "하수관로정비" : "노후하수관로정밀조사지원", + 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 []; + return Array.from({ length: 6 }, (_, index) => { + const year = String(Number(yearBase) - 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(setCodes) + .then((result) => setCodes(Array.isArray(result) ? result : [])) .catch((e) => - setNotice( - e instanceof Error ? e.message : "코드를 조회하지 못했습니다.", - ), + console.error(e instanceof Error ? e.message : "코드를 조회하지 못했습니다."), ); }, [search.year]); + /** 하수관로정비에 해당하는 세부사업 선택목록을 만든다. */ const untOptions = useMemo( () => uniq( @@ -122,11 +192,13 @@ export default function CfmtnBgtPopupContent({ ), [codes], ); + // 사업구분 기본조건에 맞는 첫 세부사업 코드를 자동 선택한다. useEffect(() => { const first = untOptions[0]; if (!search.untBzCd && first) setSearch((s) => ({ ...s, untBzCd: first.val })); }, [search.untBzCd, untOptions]); + /** 선택 세부사업에 종속된 내역사업 선택목록을 만든다. */ const mevOptions = useMemo( () => uniq( @@ -137,6 +209,7 @@ export default function CfmtnBgtPopupContent({ ), [codes, search.untBzCd], ); + /** 선택 사업구분과 내역사업에 종속된 세부내역 선택목록을 만든다. */ const dtlOptions = useMemo( () => uniq( @@ -153,15 +226,28 @@ export default function CfmtnBgtPopupContent({ ), [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), @@ -186,19 +272,29 @@ export default function CfmtnBgtPopupContent({ }); return p; }; + /** 지정 페이지의 최종확정예산사업 목록을 조회한다. */ const load = async (targetPage = 0) => { setLoading(true); try { - setData(await getCfmtnBgtPage(params(targetPage))); + 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) { - setNotice(e instanceof Error ? e.message : "조회하지 못했습니다."); + console.error(e instanceof Error ? e.message : "조회하지 못했습니다."); setData(empty); } finally { setLoading(false); } }; + /** 검색조건과 조회결과 및 선택상태를 부모 사업구분의 초기값으로 되돌린다. */ const reset = () => { setSearch(initial()); setData(empty); @@ -206,6 +302,7 @@ export default function CfmtnBgtPopupContent({ setPage(0); setNotice(""); }; + /** 전체 결과를 1,000건씩 순회 조회하여 Excel로 내려받는다. */ const download = async () => { setLoading(true); try { @@ -214,17 +311,26 @@ export default function CfmtnBgtPopupContent({ const rows: CfmtnBgtRow[] = []; while (p < total) { const result = await getCfmtnBgtPage(params(p, EXPORT_SIZE)); - rows.push(...result.content); - total = result.totalPages; + 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) { - setNotice(e instanceof Error ? e.message : "엑셀을 내려받지 못했습니다."); + console.error(e instanceof Error ? e.message : "엑셀을 내려받지 못했습니다."); } finally { setLoading(false); } }; + /** 선택한 원본 사업을 확인 후 노후하수관로 사업으로 일괄등록한다. */ const register = async () => { if (!selected.size) return; if (!confirm(`선택한 ${selected.size}건을 ${bizSe}으로 등록하시겠습니까?`)) @@ -237,175 +343,221 @@ export default function CfmtnBgtPopupContent({ skippedCount: String(r.skippedCount), }); } catch (e) { - setNotice(e instanceof Error ? e.message : "등록하지 못했습니다."); + 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: "" })) - } - /> - 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}건 - - + + + +
+ + + {notice && ( +
- 등록 - -
-
- - - - - {COLS.map(([k, t]) => ( - - ))} - - - - {data.content.map((r) => ( - - - {COLS.map(([k]) => ( - - ))} - - ))} - -
+ {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 ( 0 && - data.content.every((r) => selected.has(r.seqNo)) - } - onChange={(e) => + checked={selected.has(row.seqNo)} + onChange={(event) => setSelected((prev) => { - const n = new Set(prev); - data.content.forEach((r) => - e.target.checked ? n.add(r.seqNo) : n.delete(r.seqNo), - ); - return n; + const next = new Set(prev); + if (event.target.checked) next.add(row.seqNo); + else next.delete(row.seqNo); + return next; }) } /> -
- {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 f422b59a1..a7e228ddf 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx +++ b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx @@ -460,7 +460,7 @@ export function ObsltPplListPage() { )} -
+

노후하수관로 정밀조사 및 정비사업 현황{" "}