sn: 노후하수관로정비사업 - 팝업

This commit is contained in:
2026-08-07 15:57:14 +09:00
parent f00627270f
commit 4eaac5dbf1
9 changed files with 516 additions and 305 deletions
+1
View File
@@ -85,6 +85,7 @@ export const getData = async (url: string): Promise<any> => {
method: "GET",
headers: withSiteIdHeaders(),
});
console.log(res);
if (res.status === 401) {
handleUnauthorizedResponse();
}
@@ -15,6 +15,7 @@ type OnsemiroAddrStoreState = {
fetchSgg: () => Promise<void>;
};
/** 시도 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<string, unknown>) => {
const textCandidate = [
item.text,
@@ -85,6 +88,7 @@ const toSelectOption = (item: Record<string, unknown>) => {
};
};
/** 시도 옵션을 표준 순서로 정렬하고 나머지는 한글명으로 정렬한다. */
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<OnsemiroAddrStoreState>((set) => ({
govArea: [],
city: [],
sgg: [],
/** 환경청 선택목록을 조회해 한글명 순으로 저장한다. */
fetchGovArea: async () => {
const govArea = await loadSelectOptions(
"/sewerage-service/api/v1/onsemiro/addr/govArea",
@@ -120,6 +127,7 @@ const useOnsemiroAddrStore = create<OnsemiroAddrStoreState>((set) => ({
),
});
},
/** 시도 선택목록을 조회해 표준 행정구역 순서로 저장한다. */
fetchCity: async () => {
const city = await loadSelectOptions(
"/sewerage-service/api/v1/onsemiro/addr/city",
@@ -128,6 +136,7 @@ const useOnsemiroAddrStore = create<OnsemiroAddrStoreState>((set) => ({
city: city.sort(compareCityOptions),
});
},
/** 시군구 선택목록을 조회해 한글명 순으로 저장한다. */
fetchSgg: async () => {
const sgg = await loadSelectOptions(
"/sewerage-service/api/v1/onsemiro/addr/sgg",
@@ -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`, 금액/보조율 숫자, 사업코드 문자열 확인
- 파일명·시트명과 `초기화` 옆 버튼 위치 확인
@@ -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. 영향·롤백·확정 계약
@@ -5,16 +5,23 @@ import type {
CfmtnBgtRegistrationResult,
} from "../model/cfmtn-bgt-types";
/** 공통 응답 래퍼가 있으면 실제 data 값을 반환한다. */
const unwrap = <T>(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<CfmtnBgtCode[]>;
export const getCfmtnBgtPage = (params: URLSearchParams) =>
getData(`v1/onsemiro/cfmtnBgt?${params}`) as Promise<CfmtnBgtPage>;
/** 검색조건과 페이지 정보로 최종확정예산사업 목록을 조회한다. */
export const getCfmtnBgtPage = (params: URLSearchParams) => {
console.log("getCfmtnBgtPage 호출" + params);
return getData(`v1/onsemiro/cfmtnBgt?${params}`) as Promise<CfmtnBgtPage>;
}
/** 선택한 최종확정예산사업을 부모 화면의 사업구분으로 일괄등록한다. */
export const registerCfmtnBgt = async (bizSe: string, seqNos: number[]) =>
unwrap<CfmtnBgtRegistrationResult>(
await postRawJson("v1/obsltPplBiz/onsemiro/cfmtnBgt", { bizSe, seqNos }),
@@ -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`);
};
@@ -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; // 중복 제외 건수
};
@@ -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<CfmtnBgtCode[]>([]);
// 서버 페이징에 사용하는 0부터 시작하는 현재 페이지를 보관한다.
const [page, setPage] = useState(0);
// 현재 조회된 목록과 전체 건수 정보를 보관한다.
const [data, setData] = useState(empty);
// 페이지가 바뀌어도 유지할 선택 원본의 SEQ_NO 집합을 보관한다.
const [selected, setSelected] = useState<Set<number>>(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<CfmtnBgtTableRow[]>(
() =>
(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 (
<div className="min-w-[1300px] p-5 text-sm">
<div className="mb-3 flex flex-wrap items-end gap-3">
<>
<LoadingOverlay isLoading={loading} />
<SearchDiv>
<SearchFormLeft>
<div className="flex flex-col gap-3">
<div className="flex items-center space-x-2">
<div className="flex items-center">
<SelectBox
label="환경청"
className=""
selectClass="w-40"
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[ALL, ...govArea]}
value={search.govArea}
onSelectValue={(v) =>
setSearch((s) => ({ ...s, govArea: String(v) }))
}
/>
</div>
<div className="flex items-center">
<SelectBox
label="시도"
className=""
selectClass="w-40"
labelClass="w-16 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[...city]}
value={search.sido}
onSelectValue={(v) =>
setSearch((s) => ({ ...s, sido: String(v), gugun: "" }))
setSearch((s) => ({ ...s, sido: String(v), gugun: "0" }))
}
/>
</div>
<div className="flex items-center">
<SelectBox
label="시군구"
className=""
selectClass="w-40"
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[ALL, ...sggOptions]}
value={search.gugun}
onSelectValue={(v) => setSearch((s) => ({ ...s, gugun: String(v) }))}
onSelectValue={(v) =>
setSearch((s) => ({ ...s, gugun: String(v) }))
}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center">
<SelectBox
label="세부사업"
className=""
selectClass="w-48"
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[180px] rounded-lg bg-white"
selectArray={[ALL, ...untOptions]}
value={search.untBzCd}
onSelectValue={(v) =>
setSearch((s) => ({ ...s, untBzCd: String(v), dtlBzCd: "" }))
setSearch((s) => ({
...s,
untBzCd: String(v),
dtlBzCd: "0",
}))
}
/>
</div>
<div className="flex items-center">
<SelectBox
label="내역사업"
className=""
selectClass="w-48"
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[180px] rounded-lg bg-white"
selectArray={[ALL, ...mevOptions]}
value={search.mevBzNm}
onSelectValue={(v) =>
setSearch((s) => ({ ...s, mevBzNm: String(v), dtlBzCd: "" }))
setSearch((s) => ({
...s,
mevBzNm: String(v),
dtlBzCd: "0",
}))
}
/>
</div>
<div className="flex items-center">
<SelectBox
label="세부내역"
className=""
selectClass="w-52"
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[200px] rounded-lg bg-white"
selectArray={[ALL, ...dtlOptions]}
value={search.dtlBzCd}
onSelectValue={(v) =>
setSearch((s) => ({ ...s, dtlBzCd: String(v) }))
}
/>
</div>
<div className="flex items-center">
<SelectBox
label="년도"
className=""
selectClass="w-24"
selectArray={YEAR_OPTIONS}
labelClass="w-16 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[60px] rounded-lg bg-white"
selectArray={yearOptions}
value={search.year}
onSelectValue={(v) => setSearch((s) => ({ ...s, year: String(v) }))}
onSelectValue={(v) =>
setSearch((s) => ({ ...s, year: String(v) }))
}
/>
<Input
placeholder="사업명"
value={search.bizNm}
onChange={(e) => setSearch((s) => ({ ...s, bizNm: e.target.value }))}
/>
<Button onClick={() => void load(0)}>조회</Button>
<Button onClick={reset}>초기화</Button>
<Button onClick={() => void download()}>엑셀다운로드</Button>
</div>
{notice && <div className="mb-2 text-red-600">{notice}</div>}
<div className="mb-2 flex justify-between">
<span>
총 {data.totalElements.toLocaleString()}건 / 선택 {selected.size}건
</span>
<div className="flex items-center">
<Label className="w-20 text-xl text-gray-600">사업명</Label>
<Input
className="w-[250px] !text-2xl"
value={search.bizNm}
onChange={(e) =>
setSearch((s) => ({ ...s, bizNm: e.target.value }))
}
/>
</div>
</div>
</div>
</SearchFormLeft>
<SearchFormRight className="ml-auto flex shrink-0 items-center justify-end px-4">
<div className="flex items-center gap-2">
<Button
type="button"
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={() => void load(0)}
>
조회
</Button>
<Button
type="button"
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={reset}
>
초기화
</Button>
<Button
type="button"
className={ACTION_BUTTON_CLASS}
size="sm"
disabled={!selected.size || loading}
onClick={() => void register()}
>
등록
</Button>
</div>
<div className="overflow-x-auto border">
<table className="min-w-[4200px] border-collapse">
<thead>
<tr>
<th className="border p-2">
<input
type="checkbox"
checked={
data.content.length > 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;
})
}
/>
</th>
{COLS.map(([k, t]) => (
<th
key={String(k)}
className="whitespace-nowrap border bg-gray-100 p-2"
>
{t}
</th>
))}
</tr>
</thead>
<tbody>
{data.content.map((r) => (
<tr key={r.seqNo}>
<td className="border p-2 text-center">
<input
type="checkbox"
checked={selected.has(r.seqNo)}
onChange={(e) =>
setSelected((prev) => {
const n = new Set(prev);
e.target.checked ? n.add(r.seqNo) : n.delete(r.seqNo);
return n;
})
}
/>
</td>
{COLS.map(([k]) => (
<td key={String(k)} className="whitespace-nowrap border p-2">
{typeof r[k] === "number"
? (r[k] as number).toLocaleString()
: String(r[k] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 flex justify-center gap-2">
<Button disabled={page <= 0} onClick={() => void load(page - 1)}>
이전
</Button>
<span>
{page + 1} / {Math.max(data.totalPages, 1)}
</span>
<Button
disabled={page + 1 >= data.totalPages}
onClick={() => void load(page + 1)}
type="button"
className="rounded-sm bg-green-600 px-4 py-2 text-sm font-medium !text-white hover:bg-green-700"
size="sm"
onClick={() => void download()}
>
다음
엑셀다운로드
</Button>
</div>
</SearchFormRight>
</SearchDiv>
{notice && (
<div
role="status"
className="mx-[1.2rem] mt-4 rounded border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-700"
>
{notice}
</div>
)}
<ListDiv className="overflow-x-auto">
<div className="w-[180%]">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">
최종확정예산사업{" "}
<span className="text-base text-gray-500">
({data.totalElements.toLocaleString("ko-KR")}건 / 선택{" "}
{selected.size.toLocaleString("ko-KR")}건)
</span>
</h3>
</div>
<UiTable<CfmtnBgtTableRow>
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 (
<input
type="checkbox"
checked={selected.has(row.seqNo)}
onChange={(event) =>
setSelected((prev) => {
const next = new Set(prev);
if (event.target.checked) next.add(row.seqNo);
else next.delete(row.seqNo);
return next;
})
}
/>
);
}}
/>
</div>
</ListDiv>
</>
);
}
@@ -460,7 +460,7 @@ export function ObsltPplListPage() {
)}
<ListDiv className="overflow-x-auto">
<div className="w-[100%]">
<div className="w-[130%]">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">
노후하수관로 정밀조사 및 정비사업 현황{" "}