sn: 노후하수관로정비사업 - 팝업
This commit is contained in:
@@ -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">
|
||||
노후하수관로 정밀조사 및 정비사업 현황{" "}
|
||||
|
||||
Reference in New Issue
Block a user