Merge branch 'iipbs/sn/dev_ljp' into iipbs/sn/dev

This commit is contained in:
2026-08-07 15:58:28 +09:00
24 changed files with 1814 additions and 170 deletions
+2 -1
View File
@@ -18,4 +18,5 @@ DC_URL = https://apigateway.apps.rsonesoft.com
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
NEXT_PUBLIC_NODE_ENV = "development"
+2 -1
View File
@@ -14,4 +14,5 @@ NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&ver
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
DC_URL = https://apigateway.apps.rsonesoft.com
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
NEXT_PUBLIC_NODE_ENV = "development"
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { SelectBox } from "@common/business_components/ui";
import { Label } from "@common/components/ui";
import useOnsemiroAddrStore from "@/store/useOnsemiroAddrStore";
const DEFAULT_OPTION = { text: "전체", val: "0" };
export default function Page() {
const { govArea, city, sgg, fetchGovArea, fetchCity, fetchSgg } = useOnsemiroAddrStore();
const [selectedGovArea, setSelectedGovArea] = useState("0");
const [selectedCity, setSelectedCity] = useState("0");
const [selectedSgg, setSelectedSgg] = useState("0");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let mounted = true;
const load = async () => {
setLoading(true);
setError("");
try {
await Promise.all([fetchGovArea(), fetchCity(), fetchSgg()]);
} catch (nextError) {
if (!mounted) return;
setError(nextError instanceof Error ? nextError.message : "주소 공통코드를 조회하지 못했습니다.");
} finally {
if (mounted) setLoading(false);
}
};
void load();
return () => {
mounted = false;
};
}, [fetchCity, fetchGovArea, fetchSgg]);
useEffect(() => {
if (selectedCity === "0" || selectedSgg === "0") return;
if (selectedSgg.startsWith(selectedCity.slice(0, 2))) return;
setSelectedSgg("0");
}, [selectedCity, selectedSgg]);
const filteredSgg = useMemo(() => {
if (selectedCity === "0") return sgg;
const cityPrefix = selectedCity.slice(0, 2);
return sgg.filter((item) => String(item.val).startsWith(cityPrefix));
}, [selectedCity, sgg]);
return (
<section className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold">Onsemiro 주소 Store 테스트</h1>
<p className="mt-2 text-sm text-gray-600">
`sewerage-service`의 환경청, 시도, 시군구 조회 결과를 확인하는 임시 화면입니다.
</p>
</div>
<div className="grid gap-4 rounded-lg border bg-white p-6 md:grid-cols-3">
<div>
<Label className="mb-2 block text-base text-gray-600">환경청</Label>
<SelectBox
label=""
className="w-full"
selectClass="w-full rounded-lg bg-white"
selectArray={[DEFAULT_OPTION, ...govArea]}
value={selectedGovArea}
onSelectValue={(value) => setSelectedGovArea(String(value))}
/>
</div>
<div>
<Label className="mb-2 block text-base text-gray-600">시도</Label>
<SelectBox
label=""
className="w-full"
selectClass="w-full rounded-lg bg-white"
selectArray={[DEFAULT_OPTION, ...city]}
value={selectedCity}
onSelectValue={(value) => setSelectedCity(String(value))}
/>
</div>
<div>
<Label className="mb-2 block text-base text-gray-600">시군구</Label>
<SelectBox
label=""
className="w-full"
selectClass="w-full rounded-lg bg-white"
selectArray={[DEFAULT_OPTION, ...filteredSgg]}
value={selectedSgg}
onSelectValue={(value) => setSelectedSgg(String(value))}
/>
</div>
</div>
<div className="rounded-lg border bg-slate-50 p-4 text-sm">
<p>로딩: {loading ? "Y" : "N"}</p>
<p>환경청 건수: {govArea.length}</p>
<p>시도 건수: {city.length}</p>
<p>시군구 건수: {sgg.length}</p>
<p>선택 환경청: {selectedGovArea}</p>
<p>선택 시도: {selectedCity}</p>
<p>선택 시군구: {selectedSgg}</p>
{error ? <p className="text-red-600">오류: {error}</p> : null}
</div>
</section>
);
}
+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();
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { create } from "zustand";
import { getRawJson } from "@/components/lib/api";
import type { SelectDataType } from "@common/business_components";
type OnsemiroAddrStoreState = {
govArea: SelectDataType[];
city: SelectDataType[];
sgg: SelectDataType[];
fetchGovArea: () => Promise<void>;
fetchCity: () => Promise<void>;
fetchSgg: () => Promise<void>;
};
/** 시도 SelectBox에 적용할 행정구역 표준 표시 순서다. */
const CITY_SORT_ORDER = [
"서울특별시",
"부산광역시",
"대구광역시",
"인천광역시",
"광주광역시",
"대전광역시",
"울산광역시",
"세종특별자치시",
"경기도",
"강원특별자치도",
"충청북도",
"충청남도",
"전북특별자치도",
"전라남도",
"경상북도",
"경상남도",
"제주특별자치도",
];
/** 공통 응답 래퍼와 배열 응답을 동일한 목록으로 정규화한다. */
const toResponseList = (response: unknown) => {
if (Array.isArray(response)) return response;
if (response && typeof response === "object") {
const data = (response as { data?: unknown }).data;
if (Array.isArray(data)) return data;
}
return [];
};
/** ONSEMIRO 주소 원본 필드를 공통 SelectBox 옵션으로 변환한다. */
const toSelectOption = (item: Record<string, unknown>) => {
const textCandidate = [
item.text,
item.name,
item.label,
item.govAreaNm,
item.govAreaName,
item.lclgvrnmntNm,
item.sido,
item.gugun,
item.cityNm,
item.cityName,
item.sggNm,
item.sggName,
item.korNm,
].find((value) => typeof value === "string" && value.trim());
const valueCandidate = [
item.val,
item.code,
item.id,
item.govAreaCd,
item.govAreaCode,
item.lclgvrnmntCd,
item.adstDstrCd,
item.cityCd,
item.cityCode,
item.sggCd,
item.sggCode,
].find(
(value) => value !== null && value !== undefined && String(value).trim(),
);
if (!textCandidate || valueCandidate === undefined) return null;
return {
text: String(textCandidate).trim(),
val: String(valueCandidate).trim(),
};
};
/** 시도 옵션을 표준 순서로 정렬하고 나머지는 한글명으로 정렬한다. */
const compareCityOptions = (left: SelectDataType, right: SelectDataType) => {
const leftIndex = CITY_SORT_ORDER.indexOf(left.text);
const rightIndex = CITY_SORT_ORDER.indexOf(right.text);
if (leftIndex !== -1 && rightIndex !== -1) return leftIndex - rightIndex;
if (leftIndex !== -1) return -1;
if (rightIndex !== -1) return 1;
return left.text.localeCompare(right.text, "ko");
};
/** 주소 API를 호출해 공통 SelectBox 옵션 목록을 만든다. */
const loadSelectOptions = async (url: string) => {
const response = await getRawJson(url);
return toResponseList(response)
.map((item) =>
item && typeof item === "object"
? toSelectOption(item as Record<string, unknown>)
: null,
)
.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",
);
set({
govArea: govArea.sort((left, right) =>
left.text.localeCompare(right.text, "ko"),
),
});
},
/** 시도 선택목록을 조회해 표준 행정구역 순서로 저장한다. */
fetchCity: async () => {
const city = await loadSelectOptions(
"/sewerage-service/api/v1/onsemiro/addr/city",
);
set({
city: city.sort(compareCityOptions),
});
},
/** 시군구 선택목록을 조회해 한글명 순으로 저장한다. */
fetchSgg: async () => {
const sgg = await loadSelectOptions(
"/sewerage-service/api/v1/onsemiro/addr/sgg",
);
set({
sgg: sgg.sort((left, right) => left.text.localeCompare(right.text, "ko")),
});
},
}));
export default useOnsemiroAddrStore;
@@ -15,3 +15,12 @@
- 실제 FE 파서를 실행해 총 517건, 정밀조사 128건, 정비사업 389건을 변환하고 대표 사업코드·연장 값을 대조했다.
- 변경한 API·Excel 파서·목록 파일 대상 ESLint를 오류·경고 없이 통과했다.
- SN `next dev` 프로세스가 실행 중이어서 앱 규칙에 따라 전체 `next build`는 실행하지 않았다.
## 2026-08-05
- 변경한 API·Excel·타입·목록·폼 5개 파일의 ESLint를 오류·경고 없이 통과했다.
- SN 전체 TypeScript 검사는 저장소 설정의 `ignoreDeprecations` 값을 명령행에서 보정해 실행했다. 다른 기능의 기존 진단 43건은 남았지만 `features/obsltPpl` 진단은 0건이었다.
- 실제 작업지시 517건을 파싱해 두 시트 workbook으로 생성하고 다시 현행 파서로 읽는 왕복 검증을 통과했다: 정밀조사 128건, 정비사업 389건.
- 시트명과 B6 `순번`, D6 업무별 사업코드 헤더, B8 데이터 시작 위치를 자동 확인했다.
- SN 개발 서버가 3002 포트에서 실행 중이어서 앱 규칙에 따라 `next build`는 실행하지 않았다.
- 로그인·실 API 환경에서 조회조건 다운로드와 다운로드→수정→업로드 브라우저 검증은 남은 통합검증이다.
@@ -0,0 +1,67 @@
# SN-노후하수관로정비사업 최종확정예산사업 팝업 FE 검증계획
## 1. 검증 기준
- 공통 `006` 사용자 수정본과 동기화한 FE `003` 작업계획을 기준으로 한다.
- 같은 화면 Modal이 아니라 공용 `/popup/[popupKey]` window popup 흐름을 검증한다.
## 2. 부모 화면
- `/oper/obsltPplBiz`에 `최종확정예산사업` 버튼이 표시되는지 확인
- 사업구분 미선택 시 안내만 표시되고 popup이 열리지 않는지 확인
- `정비사업`, `정밀조사` 각각에서 popup query parameter가 정확한지 확인
- popup 차단 시 사용자 안내가 표시되는지 확인
## 3. popup 등록과 초기조건
- `cfmtnBgt` popup key가 definitions와 registry에 등록되는지 확인
- popup 제목·크기·가로 스크롤 확인
- 현재연도 기준 전년도가 초기 선택되는지 확인
- 정비사업: `하수관로정비% / 하수관로정비 / 노후하수관로정비%` 초기조건 확인
- 정밀조사: `하수관로정비% / 노후하수관로정밀조사지원` 초기조건 확인
- popup 재오픈과 초기화 시 부모 조건으로 복원되는지 확인
- 초기 년도가 항상 현재년도의 전년도로 설정되는지 확인
## 4. 조회·주소·목록
- 환경청/시도/시군구/세부사업/내역사업/세부내역/년도/사업명 조건 확인
- `구분` 조건이 없고 환경청 미선택 상태에서도 시도를 선택·조회할 수 있는지 확인
- `조회 → 초기화 → 엑셀다운로드` 순서 확인
- ONSEMIRO 전용 city/sgg/govArea 상태와 raw 응답→SelectBox 매핑 확인
- 기존 operation-service `useOperStore` 주소상태 회귀 확인
- 시도 변경 시 시군구 초기화와 종속 후보 확인
- 화면에서 권역·단위유역·중권역이 제외되고 나머지 30개 열, 금액 우측정렬, 가로 스크롤이 정상인지 확인
- 여러 페이지 선택 유지 및 검색·초기화 시 선택 제거 확인
## 5. 등록·부모 통신
- popup 내부에 사업구분 SelectBox가 없는지 확인
- 선택 없음에서 `등록` 버튼 비활성화 확인
- 부모에서 선택한 사업구분이 별도 변환 없이 복수 `SEQ_NO` 등록 요청에 포함되는지 확인
- 같은 원본 재등록 시 저장하지 않고 이미 등록되었다는 안내가 표시되는지 확인
- 성공 postMessage type과 등록·중복제외 payload 확인
- 성공 시 popup close, 부모 알림, 기존 조회조건 유지 첫 페이지 재조회 확인
- 실패 시 popup과 선택 유지, 부모 목록 미재조회 확인
- popup 단순 종료 시 부모 목록 미재조회 확인
## 6. Excel
- 적용 조회조건 전체를 1,000건 단위로 조회하고 파일 건수와 일치하는지 확인
- 권역·단위유역·중권역을 제외한 30개 열·대표값 일치
- 날짜 `yyyy-MM-dd`, 금액/보조율 숫자, 사업코드 문자열 확인
- 파일명·시트명과 `초기화` 옆 버튼 위치 확인
## 7. 정적·회귀 검증
- 변경 TS/TSX ESLint 통과
- `features/obsltPpl`, `features/cfmtnBgt`, `features/windowPopup`, `useOperStore` 대상 TypeScript 진단 0건
- 사용자 승인 후 SN build
- 기존 조회조건 유지, 행 클릭 수정, 개별 등록, 업로드, 기존 Excel 다운로드 회귀
- 기존 popup registry 전체 key가 정상 해석되는지 확인
## 8. 증적
- 캡처와 Network 증적은 `COMMON_DOCS_ROOT/작업내용/SN-노후하수관로정비사업/검증/`에 저장한다.
- 2026-08-06 대상 TypeScript 확인: 신규 `cfmtnBgt`, 부모 목록, ONSEMIRO 주소 store, popup registry/model 오류 없음.
- SN 전체 TypeScript는 기존 타 기능 오류가 남아 있어 전체 성공으로 판정하지 않는다.
- SN 전체 build는 제한시간 안에 완료되지 않아 실 API·브라우저 검증과 함께 후속 수행한다.
@@ -0,0 +1,78 @@
# SN-노후하수관로정비사업 FE 엑셀 다운로드 유지보수 계획
## 목표
`/oper/obsltPplBiz`의 `엑셀다운로드`가 업로드와 동일한 시트명 및 셀 위치를 사용하도록 수정한다. 다운로드 파일을 편집한 후 현행 업로드 기능으로 다시 저장할 수 있어야 한다.
- 모드: `maintenance`
- 상태: **구현 및 자동 검증 완료, 브라우저 통합검증 대기**
- 다운로드 생성 주체: FE
공통 `005-de-공통-노후하수관로정비사업-미부여사업코드-유지보수계획.md`에 따라 사업코드 공란을 `{사업구분명}-{PK}`로 저장하는 FE·BE 선행 작업을 먼저 완료한다.
선행 작업에서 `bizSeq` 영속·API 필드를 제거하므로 다운로드 B열 `순번`은 DB 값이 아니라 각 시트의 출력 순서로 생성한다. 업로드 파서는 B열 헤더 위치를 검증하되 순번 값을 저장 요청에 포함하지 않는다.
## 현재 문제
- 현재 다운로드는 `노후하수관로` 한 시트의 1행 헤더로 생성된다.
- 현행 업로드 파서는 `정밀조사`, `정비사업` 두 시트와 B6:V7 헤더, B8 이후 데이터를 요구한다.
- 다운로드 파일과 업로드 계약이 달라 직접 재업로드할 수 없다.
## FE 구현 계약
- SheetJS(`xlsx`)를 계속 사용한다.
- `정밀조사`, `정비사업` 두 시트를 순서대로 생성한다.
- `bizSe`가 `정밀조사`인 행은 `정밀조사` 시트, `정비사업`인 행은 `정비사업` 시트에 기록한다.
- 업로드가 검증하는 정확한 헤더 문자열을 B6:V7에 배치한다.
- 실제 데이터는 B8부터 V열까지 기록한다.
- 업로드와 같은 선, 색, 글꼴 등 시각적 스타일은 복제하지 않는다.
- 화면에 적용된 조회조건에 해당하는 활성 자료만 내려받는다.
- 조건에 해당하는 전체 건은 기존 목록 API에 같은 조회조건과 고정 정렬을 전달하고 페이지 단위로 반복 호출하여 수집한다.
- 선행 작업에서 업로드가 활성행 UPDATE/신규 INSERT 방식으로 바뀌므로, 필터 파일 재업로드 시 파일에 없는 활성행은 유지된다.
- 다운로드 파일명은 `노후하수관로 정밀조사 및 정비사업 현황.xlsx`를 유지한다.
## 수정 파일
| 파일 | 변경 내용 |
| --- | --- |
| `src/features/obsltPpl/api/obslt-ppl-api.ts` | 적용 조회조건 전체 목록을 안정적으로 모으는 페이지 순회 함수 추가 |
| `src/features/obsltPpl/lib/obslt-ppl-excel.ts` | 업로드 계약과 공유되는 두 시트 workbook 생성 로직 추가 |
| `src/features/obsltPpl/ui/ObsltPplListPage.tsx` | 기존 단일 시트 생성 코드 제거 및 새 다운로드 흐름 연결 |
기존 사용자 수정이 있는 `obslt-ppl-region.ts`, `ObsltPplFormPage.tsx`는 이 작업에서 수정하지 않는다.
## 데이터 위치 계약
| Excel 위치 | 값 |
| --- | --- |
| B6:V7 | 업로드 검증 대상 헤더 |
| B8:V마지막행 | 21개 사업 데이터 필드 |
| 시트 1 | `정밀조사` |
| 시트 2 | `정비사업` |
데이터 필드 순서는 현행 `parseSheet`의 B~V 매핑을 역방향으로 사용한다. DB PK인 `obslt_swage_ppl_biz_sn`은 다운로드 열에 포함하지 않고, 업로드 시에도 엑셀 순번을 PK로 사용하지 않는다.
## 검증 계획
1. 변경 파일 ESLint와 대상 TypeScript 검증
2. 두 시트명 및 B6:V7/B8 위치 자동 확인
3. workbook 생성 후 `parseObsltPplWorkbook`으로 재파싱하는 왕복 검증
4. 전체·정밀조사·정비사업 건수와 대표 필드 값 대조
5. 브라우저에서 다운로드 → 일부 셀 수정 → 업로드 동작 확인
6. 검색조건이 적용된 화면에서는 조건에 해당하는 전체 건만 포함되고, 재업로드 후 조건 밖 기존 활성행이 유지되는지 확인
## 제외 범위
- 업로드 파일과 동일한 선, 색상, 글꼴, 행 높이, 열 너비 등 스타일 복제
- BE Controller·Service·Repository 변경
- API 경로 또는 응답 계약 변경
- DB DDL 및 데이터 변경
- 기존 BE 다운로드 API 삭제
## 롤백
예정된 FE 3개 파일의 다운로드 관련 변경만 되돌린다. DB와 API 계약 변경이 없으므로 별도 데이터 롤백은 없다.
## 수행 결과
사용자 승인 후 미부여 사업코드 선행 작업을 먼저 완료하고 다운로드를 구현했다. 원본 517건을 두 시트로 생성한 뒤 현행 업로드 파서로 다시 읽는 자동 왕복 검증을 통과했다.
@@ -0,0 +1,150 @@
# SN-노후하수관로정비사업 최종확정예산사업 팝업 FE 작업계획
## 1. 목표와 기준
- 기준 문서: `COMMON_DOCS_ROOT/작업계획/SN-노후하수관로정비사업/006-de-공통-최종확정예산사업팝업-신규기능작업계획.md`
- 대상 부모 화면: `/oper/obsltPplBiz`
- 상태: **사용자 승인 구현 완료, 실 API·브라우저 검증 대기**
- 부모 화면에서 필수 사업구분을 먼저 선택한 경우에만 공용 브라우저 popup을 연다.
- popup에서 ONSEMIRO 최종확정예산을 조회하되 권역·단위유역·중권역을 제외한 30개 항목을 표시하고, 다중선택한 사업을 부모 업무로 일괄 등록한다.
- 등록 성공 메시지를 부모에 전달한 뒤 popup을 닫고 부모 목록을 기존 조회조건으로 다시 조회한다.
## 2. 공통계획 변경사항 동기화 확인
| 항목 | 이전 FE 계획 | 사용자 수정 공통계획 반영 |
| --- | --- | --- |
| 팝업 방식 | 같은 화면의 `UiModal` | `app/(popupLayout)/popup/[popupKey]` 기반 window popup |
| popup 구현 위치 | `features/obsltPpl/ui/CfmtnBgtModal.tsx` | `features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx` |
| 사업구분 선택 | popup 내부 SelectBox | 부모 화면 조회조건에서 먼저 선택, 미선택 시 알림 후 popup 미오픈 |
| 등록 버튼 | `노후하수관로사업 등록` | popup의 `등록` 버튼 |
| 검색 초기값 | 최신연도 일반 조회 | 전년도 및 부모 업무별 세부사업·내역사업·세부내역 자동조건 |
| 지역 데이터 | 신규 옵션 API 직접 사용 | `useOperStore.ts`에 ONSEMIRO 주소 옵션을 추가하여 사용 |
| 완료 처리 | Modal state close | `postMessage`로 부모 알림·재조회 요청 후 `window.close()` |
| 기능 재사용 | 노후하수관로 전용 Modal | 부모 업무정보를 query parameter로 받는 공용 popup content |
## 3. 예정 수정 파일
| 구분 | 예정 파일 | 변경 계획 |
| --- | --- | --- |
| 부모 연결 | `apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx` | 사업구분 필수검증, window popup open, 완료 메시지 수신, 기존 조건 재조회 |
| popup 본체 | `apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx` | 검색, 화면 30개 열, 다중선택, 등록, Excel 다운로드 |
| API | `apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts` | 코드·목록·선택등록 API |
| 타입 | `apps/sn/src/features/cfmtnBgt/model/cfmtn-bgt-types.ts` | 33개 조회필드, 검색·등록·응답, 부모 context 계약 |
| Excel | `apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts` | 적용 조회조건 전체 결과 XLSX 생성 |
| popup 정의 | `apps/sn/src/features/windowPopup/model/popupDefinitions.ts` | `cfmtnBgt` key, 제목, 대형 popup 크기, 필수 query parameter 정의 |
| popup 등록 | `apps/sn/src/features/windowPopup/registry.tsx` | `CfmtnBgtPopupContent` registry 연결 |
| 완료 메시지 | `apps/sn/src/features/windowPopup/model/types.ts` | 최종확정예산 등록 완료 message type 추가 |
| popup 메시지 | 기존 `features/windowPopup/lib/popupMessage.ts` 재사용 | 등록 결과 payload 전송 후 popup 닫기 |
| 운영 store | `apps/sn/app/store/useOperStore.ts` | 기존 operation-service 주소 상태를 유지하고 ONSEMIRO 전용 주소 상태·fetch 함수 추가 |
## 4. 부모 화면 계약
- `최종확정예산사업` 버튼은 기존 목록 액션 영역에 추가한다.
- 버튼 클릭 시 현재 조회조건의 사업구분을 검사한다.
- 선택되지 않았으면 `사업구분을 먼저 선택해 주세요.` 알림을 표시하고 popup을 열지 않는다.
- 선택된 사업구분과 부모 식별정보를 `openWindowPopup("cfmtnBgt", params)`로 전달한다.
- popup 완료 메시지는 다른 popup 메시지와 구분되는 고유 type으로 수신한다.
- 성공 시 popup 응답의 등록·중복제외 건수를 부모 알림에 표시하고, 기존 적용 조회조건을 유지한 첫 페이지를 재조회한다.
- popup이 단순 종료되거나 등록에 실패하면 부모 목록은 재조회하지 않는다.
## 5. popup query parameter와 재사용 계약
제안 parameter:
| 이름 | 필수 | 설명 |
| --- | --- | --- |
| `parentType` | Y | 부모 업무 식별자, 노후하수관로는 `obsltPplBiz` |
| `bizSe` | Y | 부모가 선택한 사업구분 |
| `parentMessageType` | Y | 성공 시 부모가 수신할 popup message type |
- popup은 `parentType`과 `bizSe`로 초기 검색조건과 등록 API를 결정한다.
- 다른 화면이 재사용할 때는 popup 본체를 복사하지 않고 부모별 초기조건·등록 adapter만 추가한다.
- 필수 parameter가 없으면 조회하지 않고 누락 안내를 표시한다.
## 6. 검색 초기값
- 연도 기본값은 공통계획의 최신 명시인 **전년도**로 한다.
- 예: 현재가 2026년이면 2025년
- 부모가 `obsltPplBiz`일 때:
| 부모 사업구분 | 세부사업 초기조건 | 내역사업 초기조건 | 세부내역 초기조건 |
| --- | --- | --- | --- |
| `정비사업` | `하수관로정비%` | `하수관로정비` | `노후하수관로정비%` |
| `정밀조사` | `하수관로정비%` | `노후하수관로정밀조사지원` | 전체/미지정 |
- `%` 조건은 화면 label에 `%`를 표시한다는 뜻이 아니라 BE code/options 조회에서 prefix 일치 항목을 선택하는 계약이다.
- popup을 닫았다 다시 열면 부모가 전달한 사업구분으로 초기조건을 다시 계산한다.
- 조회조건: 환경청, 시도, 시군구, 세부사업, 내역사업, 세부내역, 년도, 사업명
- `구분` 조건은 만들지 않는다. 환경청은 선택조건이며, 환경청을 선택하지 않아도 시도를 독립적으로 선택할 수 있어야 한다.
- 버튼 순서: `조회`, `초기화`, `엑셀다운로드`
- `초기화`는 부모 업무에서 계산한 초기조건으로 되돌리고 결과와 선택을 지운다.
- 초기 년도는 현재년도 기준 전년도로 고정한다.
## 7. ONSEMIRO 주소 store 계약
- 기존 `useOperStore`의 `govArea/city/sgg`와 `/operation-service/api/v1/addr/*` 호출은 다른 화면이 사용하므로 변경하거나 대체하지 않는다.
- 같은 store에 다음과 같은 ONSEMIRO 전용 상태를 별도로 추가한다.
- `onsemiroGovArea`, `onsemiroCity`, `onsemiroSgg`
- `fetchOnsemiroGovArea`, `fetchOnsemiroCity`, `fetchOnsemiroSgg`
- 신규 API:
- `/api/v1/onsemiro/addr/city`
- `/api/v1/onsemiro/addr/sgg`
- `/api/v1/onsemiro/addr/govArea`
- 현재 신규 Addr API 응답은 원본 camelCase 필드이므로 SelectBox 변환 시 다음을 사용한다.
| 구분 | text | val |
| --- | --- | --- |
| 시도 | `sido` | `adstDstrCd` |
| 시군구 | `gugun` | `adstDstrCd` |
| 환경청/지자체 | `lclgvrnmntNm` | `lclgvrnmntCd` 또는 조회계약에서 확정한 환경청 코드 |
- 시도 변경 시 `adstDstrCd` 앞자리 또는 BE가 제공하는 관계 기준으로 시군구 후보를 제한하고 기존 시군구 선택을 초기화한다.
- `govArea` 응답은 원본 테이블이 지자체 기준이므로 popup의 실제 환경청 선택 label/code가 맞는지 BE 조회결과로 최종 확인한다.
## 8. 목록·선택·등록 계약
- 첫 열에 전체선택/행선택 checkbox를 제공한다.
- 페이지 이동 후에도 `SEQ_NO` Set을 유지한다.
- 조회·초기화로 적용조건이 바뀌면 선택 Set을 지운다.
- 화면 목록은 권역·단위유역·중권역을 제외한 30개 열과 목록 내부 가로 스크롤을 사용한다.
- popup 내부에는 사업구분 SelectBox를 두지 않는다.
- 선택행이 없으면 `등록` 버튼을 비활성화한다.
- 확인 문구는 부모가 전달한 사업구분을 사용한다.
- 등록 요청은 `SEQ_NO[]`만이 아니라 부모에서 전달한 `bizSe`를 함께 보낸다.
- 성공 시 등록·중복제외 건수를 popup message payload로 전달하고 닫는다.
- 실패 시 popup과 선택상태를 유지한다.
## 9. Excel 계약
- FE에서 적용 조회조건 전체 결과를 1,000건 단위로 반복 조회해 생성한다.
- 파일명: `최종확정예산관리_{년도}.xlsx`
- 시트명: `최종확정예산관리`
- 열 순서: 화면과 동일하게 권역·단위유역·중권역을 제외한 30개 열
- 날짜는 `apps/sn/src/features/common/util.ts`의 `formatDate()`를 사용해 `yyyy-MM-dd`로 표시한다.
- 금액/보조율은 숫자, 사업코드는 문자열로 출력한다.
- 버튼명은 `엑셀다운로드`이며 `초기화` 바로 오른쪽에 둔다.
## 10. 검증
- 부모 사업구분 미선택 시 popup 미오픈과 알림 확인
- `popupDefinitions → registry → CfmtnBgtPopupContent` 연결 확인
- 부모별 전년도·사업구분별 초기조건 확인
- ONSEMIRO 주소 store가 기존 operation-service 상태를 변경하지 않는지 확인
- 여러 페이지 다중선택과 조건 변경 시 선택 초기화 확인
- 성공 postMessage 수신, popup close, 부모 목록 조회조건 유지 재조회 확인
- popup 재오픈 시 부모 조건으로 초기화 확인
- 화면과 Excel 모두 권역·단위유역·중권역이 제외된 30개 열인지 확인하고 날짜·금액 포맷과 전체결과를 검증
- 변경 파일 ESLint·대상 TypeScript 및 사용자 승인 후 SN build·브라우저 검증
## 11. 영향·롤백·확정 계약
- 영향: `features/obsltPpl`, 신규 `features/cfmtnBgt`, 기존 `features/windowPopup`, `app/store/useOperStore.ts`
- route 파일은 공용 `/popup/[popupKey]`를 재사용하고 신규 route를 만들지 않는다.
- 타 앱과 공통 package는 변경하지 않는다.
- 롤백: 신규 `cfmtnBgt` feature 제거, popup definition/registry/message와 부모 연결·store 전용 상태만 원복한다.
- 부모 화면에서 선택한 사업구분을 별도 명칭 변환 없이 등록 API에 전달한다.
- 같은 원본은 다시 등록하지 않고 이미 등록된 사업임을 안내한다.
- 초기 년도는 현재년도의 전년도로 설정하며 최신 보유연도 fallback은 적용하지 않는다.
- 규모·금액 15개 필드는 조회·Excel 전용으로 유지한다.
- 운영 ONSEMIRO의 `하수관로정비(312)` 코드는 현 계획대로 우선 구현하고 운영 확인 결과에 따라 후속 보정한다.
- `govArea` 응답에서 환경청 label/code로 사용할 필드는 구현 전 실제 응답으로 확인한다.
@@ -33,3 +33,12 @@
- `lib/obslt-ppl-excel.ts`에서 두 필수 시트, B6:V7 핵심 헤더, 필수 사업명, 순번·연장 숫자를 검증한다.
- `노후하수관로 정밀조사 및 정비사업 현황_upload.xlsx`의 517개 행을 저장 요청 JSON으로 변환한 뒤 `/excel/upload`에 `application/json`으로 전송한다.
- 기존 multipart `FormData` 전송과 `postMutiPart` 의존을 제거했다.
## 2026-08-05 사업순서 제거 및 다운로드 위치 호환
- Excel B열 순번은 헤더 위치 검증만 유지하고 저장 JSON의 `bizSeq`에는 포함하지 않도록 변경했으며, 사업코드 공란은 빈 문자열로 BE에 전달한다.
- FE 모델·목록·등록/수정 폼에서 `bizSeq`를 제거했다.
- 화면의 확정 조회조건으로 목록 API를 500건씩 반복 조회하여 조건에 해당하는 전체 활성 자료를 수집한다.
- `bizSe`에 따라 `정밀조사`, `정비사업` 두 시트로 분리하고 업로드와 같은 B6:V7 헤더, B8:V 데이터 위치로 다운로드한다.
- B열 순번은 DB 값이 아니라 각 시트의 출력 순서로 생성하며 스타일·색상 복제는 적용하지 않았다.
- 기존 BE Excel 다운로드 API는 삭제하지 않았다.
@@ -0,0 +1,28 @@
import { getData, postRawJson } from "@/components/lib/api";
import type {
CfmtnBgtCode,
CfmtnBgtPage,
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) => {
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 }),
);
@@ -0,0 +1,124 @@
import * as XLSX from "xlsx";
import type { CfmtnBgtRow } from "../model/cfmtn-bgt-types";
/** 화면 목록과 동일한 Excel 상위 다단 헤더다. */
const TOP_HEADERS = [
"순번",
"환경청",
"행정구역",
"",
"사업코드",
"사업명",
"사업구분",
"세부사업",
"내역사업",
"세부내역",
"사업진행상태",
"신규/계속",
"착수",
"준공",
"사업규모",
"",
"보조율\n(%)",
"총사업비",
"",
"",
"",
"",
"",
"전년까지사업비",
"년차 사업비",
"",
"",
"",
"",
"",
];
/** 그룹별 하위 항목을 표시하는 Excel 두 번째 헤더다. */
const SUB_HEADERS = [
"",
"",
"시도",
"시군구",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"하수처리장",
"하수관로",
"",
"합계",
"국고",
"지방비",
"원인자부담금",
"환특융자",
"기타",
"",
"합계",
"국고",
"지방비",
"원인자부담금",
"환특융자",
"기타",
];
/** 조회 행을 Excel 열 순서에 맞는 셀 배열로 변환한다. */
const values = (r: CfmtnBgtRow) => [
r.rowNumber,
r.govArea,
r.sido,
r.gugun,
r.bizCd,
r.bizNm,
r.sourceBizSe,
r.untBzNm,
r.mevBzNm,
r.dtlBzNm,
r.bizStatus,
r.newContinuing,
r.startYear,
r.endYear,
r.treatmentPlantScale,
r.pipeScale,
r.aidRate,
r.totalBizAmount,
r.totalNational,
r.totalLocal,
r.totalBeneficiary,
r.totalLoan,
r.totalEtc,
r.previousBizAmount,
r.annualBizAmount,
r.annualNational,
r.annualLocal,
r.annualBeneficiary,
r.annualLoan,
r.annualEtc,
];
/** 조회조건 전체 결과를 최종확정예산관리 XLSX 파일로 내려받는다. */
export const downloadCfmtnBgtExcel = (rows: CfmtnBgtRow[], year: string) => {
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet([
TOP_HEADERS,
SUB_HEADERS,
...rows.map(values),
]);
ws["!merges"] = [
...[0, 1, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 16, 23].map((column) => ({
s: { r: 0, c: column },
e: { r: 1, c: column },
})),
{ s: { r: 0, c: 2 }, e: { r: 0, c: 3 } },
{ s: { r: 0, c: 14 }, e: { r: 0, c: 15 } },
{ s: { r: 0, c: 17 }, e: { r: 0, c: 22 } },
{ s: { r: 0, c: 24 }, e: { r: 0, c: 29 } },
];
XLSX.utils.book_append_sheet(wb, ws, "최종확정예산관리");
XLSX.writeFileXLSX(wb, `최종확정예산관리_${year}.xlsx`);
};
@@ -0,0 +1,59 @@
export type CfmtnBgtCode = {
year: string; // 기준연도
untBzCd: string; // 세부사업 코드
untBzNm: string; // 세부사업명
mevBzNm: string; // 내역사업명
dtlBzCd: string; // 세부내역 코드
dtlBzNm: string; // 세부내역명
};
export type CfmtnBgtRow = {
rowNumber: number; // 화면 표시 순번
seqNo: number; // 원본 사업 일련번호
year: string; // 기준연도
rvbsn: string | null; // 권역
lgbsn: string | null; // 단위유역
mdmbsn: string | null; // 중권역
govArea: string | null; // 환경청
sido: string | null; // 시도
gugun: string | null; // 시군구
bizCd: string | null; // 사업코드
bizNm: string; // 사업명
sourceBizSe: string | null; // 원본 사업구분- "하수도관리(균특)"
untBzCd: string | null; // 세부사업 코드
untBzNm: string | null; // 세부사업명
mevBzNm: string | null; // 내역사업명
dtlBzCd: string | null; // 세부내역 코드
dtlBzNm: string | null; // 세부내역명
bizStatus: string; // 사업 진행상태
newContinuing: string | null; // 신규/계속 구분
startYear: string | null; // 착수연도
endYear: string | null; // 준공연도
treatmentPlantScale: string | null; // 하수처리장 규모 - EM_DT_BZ_OTLNE_CONF.BZ_SPEC
pipeScale: string | null; // 하수관로 규모 - EM_DT_BZ_OTLNE_CONF.PIPE_SPEC
aidRate: number; // 보조율
totalBizAmount: number; // 총사업비 합계
totalNational: number; // 총사업비 국고
totalLocal: number; // 총사업비 지방비
totalBeneficiary: number; // 총사업비 원인자부담금
totalLoan: number; // 총사업비 환특융자
totalEtc: number; // 총사업비 기타
previousBizAmount: number; // 전년까지 사업비
annualBizAmount: number; // 연차 사업비 합계
annualNational: number; // 연차 사업비 국고
annualLocal: number; // 연차 사업비 지방비
annualBeneficiary: number; // 연차 사업비 원인자부담금
annualLoan: number; // 연차 사업비 환특융자
annualEtc: number; // 연차 사업비 기타
};
export type CfmtnBgtPage = {
content: CfmtnBgtRow[]; // 현재 페이지 목록
totalElements: number; // 전체 건수
totalPages: number; // 전체 페이지 수
number: number; // 현재 페이지 번호
size: number; // 페이지 크기
};
export type CfmtnBgtRegistrationResult = {
requestedCount: number; // 등록 요청 건수
insertedCount: number; // 신규 등록 건수
skippedCount: number; // 중복 제외 건수
};
@@ -0,0 +1,563 @@
/**
* onsemiro 최종확정예산사업 팝업
*/
"use client";
import { useEffect, useMemo, useState } from "react";
import useOnsemiroAddrStore from "@/app/store/useOnsemiroAddrStore";
import {
SearchDiv,
SearchFormLeft,
SearchFormRight,
SelectBox,
UiTable,
} from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { Button, Input, Label } from "@common/components/ui";
import { postPopupMessageAndClose } from "../../windowPopup/lib/popupMessage";
import type { WindowPopupContentProps } from "../../windowPopup/model/types";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types";
import {
getCfmtnBgtCodes,
getCfmtnBgtPage,
registerCfmtnBgt,
} from "../api/cfmtn-bgt-api";
import { downloadCfmtnBgtExcel } from "../lib/cfmtn-bgt-excel";
import type {
CfmtnBgtCode,
CfmtnBgtPage,
CfmtnBgtRow,
} from "../model/cfmtn-bgt-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
/** 검색 SelectBox에서 공통으로 사용하는 전체 옵션이다. */
const ALL = { text: "전체", val: "0" };
/** 화면 목록과 Excel 반복 조회에 적용할 페이지 크기다. */
const PAGE_SIZE = 10;
const EXPORT_SIZE = 1000;
const ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
/** 최종확정예산사업 표시 항목과 선택 열의 표 헤더 계약이다. */
const TABLE_HEADLIST = [
{ id: "select", title: "선택", row: 0, rowSpan: 2, widthPercent: 4 },
{ id: "rowNumber", title: "순번", row: 0, rowSpan: 2, widthPercent: 4 },
{ id: "govArea", title: "환경청", row: 0, rowSpan: 2, widthPercent: 7 },
{ id: "A", title: "행정구역", row: 0, colSpan: 2, widthPercent: 12 },
{ id: "sido", title: "시도", row: 1, widthPercent: 6 },
{ id: "gugun", title: "시군구", row: 1, widthPercent: 6 },
{ id: "bizCd", title: "사업코드", row: 0, rowSpan: 2, widthPercent: 7 },
{ id: "bizNm", title: "사업명", row: 0, rowSpan: 2, widthPercent: 12 },
{ id: "sourceBizSe", title: "사업구분", row: 0, rowSpan: 2, widthPercent: 7 },
{ id: "untBzNm", title: "세부사업", row: 0, rowSpan: 2, widthPercent: 8 },
{ id: "mevBzNm", title: "내역사업", row: 0, rowSpan: 2, widthPercent: 8 },
{ id: "dtlBzNm", title: "세부내역", row: 0, rowSpan: 2, widthPercent: 8 },
{
id: "bizStatus",
title: "사업진행상태",
row: 0,
rowSpan: 2,
widthPercent: 7,
},
{
id: "newContinuing",
title: "신규/계속",
row: 0,
rowSpan: 2,
widthPercent: 7,
},
{ id: "startYear", title: "착수", row: 0, rowSpan: 2, widthPercent: 5 },
{ id: "endYear", title: "준공", row: 0, rowSpan: 2, widthPercent: 5 },
{ id: "B", title: "사업규모", row: 0, colSpan: 2, widthPercent: 16 },
{ id: "treatmentPlantScale", title: "하수처리장", row: 1, widthPercent: 8 },
{ id: "pipeScale", title: "하수관로", row: 1, widthPercent: 8 },
{ id: "aidRate", title: "보조율\n(%)", row: 0, rowSpan: 2, widthPercent: 6 },
{ id: "C", title: "총사업비", row: 0, colSpan: 6, widthPercent: 48 },
{ id: "totalBizAmount", title: "합계", row: 1, widthPercent: 8 },
{ id: "totalNational", title: "국고", row: 1, widthPercent: 8 },
{ id: "totalLocal", title: "지방비", row: 1, widthPercent: 8 },
{ id: "totalBeneficiary", title: "원인자부담금", row: 1, widthPercent: 8 },
{ id: "totalLoan", title: "환특융자", row: 1, widthPercent: 8 },
{ id: "totalEtc", title: "기타", row: 1, widthPercent: 8 },
{
id: "previousBizAmount",
title: "전년까지\n사업비",
row: 0,
rowSpan: 2,
widthPercent: 8,
},
{ id: "D", title: "년차 사업비", row: 0, colSpan: 6, widthPercent: 48 },
{ id: "annualBizAmount", title: "합계", row: 1, widthPercent: 8 },
{ id: "annualNational", title: "국고", row: 1, widthPercent: 8 },
{ id: "annualLocal", title: "지방비", row: 1, widthPercent: 8 },
{ id: "annualBeneficiary", title: "원인자부담금", row: 1, widthPercent: 8 },
{ id: "annualLoan", title: "환특융자", row: 1, widthPercent: 8 },
{ id: "annualEtc", title: "기타", row: 1, widthPercent: 8 },
];
/** 조회 전과 오류 발생 시 사용할 빈 페이지 상태다. */
const empty: CfmtnBgtPage = {
content: [],
totalElements: 0,
totalPages: 0,
number: 0,
size: PAGE_SIZE,
};
/** 코드와 명칭이 같은 SelectBox 옵션의 중복을 제거한다. */
const uniq = (items: { text: string; val: string }[]) =>
Array.from(new Map(items.map((i) => [`${i.text}|${i.val}`, i])).values());
type CfmtnBgtTableRow = CfmtnBgtRow & { select: number };
/** 최종확정예산사업 조회·다중선택·Excel·일괄등록 팝업을 렌더링한다. */
export default function CfmtnBgtPopupContent({
searchParams,
}: WindowPopupContentProps) {
// hydration 이후에만 현재년도 기반 검색조건과 팝업 UI를 표시한다.
const [isMounted, setIsMounted] = useState(false);
// 실행 프로필에 맞춘 초기 기준연도를 보관한다.
const [yearBase, setYearBase] = useState("");
const bizSe =
typeof searchParams.bizSe === "string" ? searchParams.bizSe : "";
const { govArea, city, sgg, fetchGovArea, fetchCity, fetchSgg } =
useOnsemiroAddrStore();
/** 부모 사업구분에 맞는 팝업 초기 검색조건을 만든다. */
const initial = () => ({
year: "",
govArea: "",
sido: "",
gugun: "",
untBzCd: "",
mevBzNm: "", //bizSe === "정비사업" ? "하수관로정비" : "노후하수관로정밀조사지원",
dtlBzCd: "",
bizNm: "",
});
// 입력 중 검색조건과 조회 코드·페이지·선택행·메시지 상태를 관리한다.
const [search, setSearch] = useState(initial);
// 선택 연도의 세부사업·내역사업·세부내역 코드를 보관한다.
const [codes, setCodes] = useState<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((result) => setCodes(Array.isArray(result) ? result : []))
.catch((e) =>
console.error(e instanceof Error ? e.message : "코드를 조회하지 못했습니다."),
);
}, [search.year]);
/** 하수관로정비에 해당하는 세부사업 선택목록을 만든다. */
const untOptions = useMemo(
() =>
uniq(
codes
.filter((c) => c.untBzNm?.startsWith("하수관로정비"))
.map((c) => ({ text: c.untBzNm, val: c.untBzCd })),
),
[codes],
);
// 사업구분 기본조건에 맞는 첫 세부사업 코드를 자동 선택한다.
useEffect(() => {
const first = untOptions[0];
if (!search.untBzCd && first)
setSearch((s) => ({ ...s, untBzCd: first.val }));
}, [search.untBzCd, untOptions]);
/** 선택 세부사업에 종속된 내역사업 선택목록을 만든다. */
const mevOptions = useMemo(
() =>
uniq(
codes
.filter((c) => !search.untBzCd || c.untBzCd === search.untBzCd)
.map((c) => ({ text: c.mevBzNm, val: c.mevBzNm }))
.filter((x) => x.text),
),
[codes, search.untBzCd],
);
/** 선택 사업구분과 내역사업에 종속된 세부내역 선택목록을 만든다. */
const dtlOptions = useMemo(
() =>
uniq(
codes
.filter(
(c) =>
(!search.untBzCd || c.untBzCd === search.untBzCd) &&
(!search.mevBzNm || c.mevBzNm === search.mevBzNm) &&
(bizSe !== "정비사업" ||
c.dtlBzNm?.startsWith("노후하수관로정비")),
)
.map((c) => ({ text: c.dtlBzNm, val: c.dtlBzCd }))
.filter((x) => x.text),
),
[bizSe, codes, search.mevBzNm, search.untBzCd],
);
// 정비사업인 경우 노후하수관로정비에 해당하는 첫 세부내역을 자동 선택한다.
useEffect(() => {
const first = dtlOptions[0];
if (bizSe === "정비사업" && !search.dtlBzCd && first)
setSearch((s) => ({ ...s, dtlBzCd: first.val }));
}, [bizSe, dtlOptions, search.dtlBzCd]);
// 시도 코드를 조회 API가 사용하는 시도명으로 변환한다.
const cityName = city.find((x) => String(x.val) === search.sido)?.text ?? "";
// 선택 시도 코드의 앞 두 자리에 해당하는 시군구만 노출한다.
const sggOptions = sgg.filter(
(x) => !search.sido || String(x.val).startsWith(search.sido.slice(0, 2)),
);
/** API 목록을 UiTable 선택 열이 포함된 화면 행으로 변환한다. */
const rows = useMemo<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),
size: String(size),
year: search.year,
});
if (search.govArea)
p.set(
"govArea",
govArea.find((x) => String(x.val) === search.govArea)?.text ??
search.govArea,
);
if (cityName) p.set("sido", cityName);
if (search.gugun)
p.set(
"gugun",
sggOptions.find((x) => String(x.val) === search.gugun)?.text ?? "",
);
["untBzCd", "mevBzNm", "dtlBzCd", "bizNm"].forEach((k) => {
const v = search[k as keyof typeof search];
if (v) p.set(k, v);
});
return p;
};
/** 지정 페이지의 최종확정예산사업 목록을 조회한다. */
const load = async (targetPage = 0) => {
setLoading(true);
try {
const result = await getCfmtnBgtPage(params(targetPage));
setData(
result &&
typeof result === "object" &&
"totalElements" in result &&
"content" in result
? (result as CfmtnBgtPage)
: empty,
);
setPage(targetPage);
setNotice("");
} catch (e) {
console.error(e instanceof Error ? e.message : "조회하지 못했습니다.");
setData(empty);
} finally {
setLoading(false);
}
};
/** 검색조건과 조회결과 및 선택상태를 부모 사업구분의 초기값으로 되돌린다. */
const reset = () => {
setSearch(initial());
setData(empty);
setSelected(new Set());
setPage(0);
setNotice("");
};
/** 전체 결과를 1,000건씩 순회 조회하여 Excel로 내려받는다. */
const download = async () => {
setLoading(true);
try {
let p = 0,
total = 1;
const rows: CfmtnBgtRow[] = [];
while (p < total) {
const result = await getCfmtnBgtPage(params(p, EXPORT_SIZE));
const safeResult =
result &&
typeof result === "object" &&
"content" in result &&
Array.isArray((result as CfmtnBgtPage).content) &&
"totalPages" in result
? (result as CfmtnBgtPage)
: empty;
rows.push(...safeResult.content);
total = safeResult.totalPages;
p++;
}
downloadCfmtnBgtExcel(rows, search.year);
} catch (e) {
console.error(e instanceof Error ? e.message : "엑셀을 내려받지 못했습니다.");
} finally {
setLoading(false);
}
};
/** 선택한 원본 사업을 확인 후 노후하수관로 사업으로 일괄등록한다. */
const register = async () => {
if (!selected.size) return;
if (!confirm(`선택한 ${selected.size}건을 ${bizSe}으로 등록하시겠습니까?`))
return;
setLoading(true);
try {
const r = await registerCfmtnBgt(bizSe, [...selected]);
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.CFMTN_BGT_REGISTERED, {
insertedCount: String(r.insertedCount),
skippedCount: String(r.skippedCount),
});
} catch (e) {
console.error(e instanceof Error ? e.message : "등록하지 못했습니다.");
} finally {
setLoading(false);
}
};
if (!isMounted) return null;
return (
<>
<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=""
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=""
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: "0" }))
}
/>
</div>
<div className="flex items-center">
<SelectBox
label="시군구"
className=""
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) }))
}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center">
<SelectBox
label="세부사업"
className=""
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: "0",
}))
}
/>
</div>
<div className="flex items-center">
<SelectBox
label="내역사업"
className=""
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: "0",
}))
}
/>
</div>
<div className="flex items-center">
<SelectBox
label="세부내역"
className=""
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=""
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) }))
}
/>
</div>
<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>
<Button
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>
</>
);
}
+17
View File
@@ -0,0 +1,17 @@
import moment from "moment";
/** 8자리 또는 구분자가 섞인 날짜 문자열을 YYYY-MM-DD 형식으로 정규화한다. */
export const formatDate = (value?: string | null) => {
if (!value) return "";
const normalized = String(value).trim();
if (!normalized) return "";
const digits = normalized.replace(/\D/g, "");
if (digits.length === 8) {
const parsedDate = moment(digits, "YYYYMMDD", true);
return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized;
}
const parsedDate = moment(normalized);
return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized;
};
@@ -7,13 +7,13 @@ import type {
import {
deleteData,
getBlobData,
getData,
postRawJson,
putData,
} from "@/components/lib/api";
const API_PATH = "v1/obsltPplBiz";
const EXCEL_DOWNLOAD_PAGE_SIZE = 500;
/** 공통 응답 래퍼가 있는 JSON 응답에서 data를 안전하게 꺼낸다. */
const unwrapData = <T>(body: unknown): T => {
@@ -27,6 +27,25 @@ const unwrapData = <T>(body: unknown): T => {
export const getObsltPplBusinesses = async (params: URLSearchParams): Promise<ObsltPplPage> =>
getData(`${API_PATH}?${params.toString()}`) as Promise<ObsltPplPage>;
/** 적용된 조회조건을 유지하며 모든 페이지를 순회해 Excel 다운로드 대상 전체를 모은다. */
export const getAllObsltPplBusinesses = async (searchParams: URLSearchParams): Promise<ObsltPplBusiness[]> => {
const rows: ObsltPplBusiness[] = [];
let page = 0;
let totalPages = 1;
while (page < totalPages) {
const params = new URLSearchParams(searchParams);
params.set("page", String(page));
params.set("size", String(EXCEL_DOWNLOAD_PAGE_SIZE));
params.set("sort", "obsltSwagePplBizSn,asc");
const result = await getObsltPplBusinesses(params);
rows.push(...result.content);
totalPages = result.totalPages;
page += 1;
}
return rows;
};
/** 수정 화면에서 사업 상세 한 건을 조회한다. */
export const getObsltPplBusiness = async (id: string): Promise<ObsltPplBusiness> =>
getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise<ObsltPplBusiness>;
@@ -49,12 +68,8 @@ export const deleteObsltPplBusiness = async (id: string): Promise<void> => {
await deleteData(`${API_PATH}/${encodeURIComponent(id)}`);
};
/** 브라우저에서 파싱한 두 시트 JSON을 활성 스냅샷으로 교체 저장한다. */
/** 브라우저에서 파싱한 두 시트 JSON을 활성행 UPDATE·신규 INSERT 방식으로 저장한다. */
export const uploadObsltPplExcel = async (
rows: ObsltPplBusinessSaveRequest[],
): Promise<ObsltPplImportResult> =>
unwrapData<ObsltPplImportResult>(await postRawJson(`${API_PATH}/excel/upload`, rows));
/** 현재 활성 자료를 원본과 호환되는 xlsx로 내려받는다. */
export const downloadObsltPplExcel = async (): Promise<Blob> =>
getBlobData(`${API_PATH}/excel/download`);
@@ -1,6 +1,7 @@
import * as XLSX from "xlsx";
import type { ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
import { formatDate } from "../../common/util";
import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
type BusinessType = ObsltPplBusinessSaveRequest["bizSe"];
@@ -32,6 +33,7 @@ const SHEET_CONTRACTS: SheetContract[] = [
const DATA_START_ROW_INDEX = 7;
const FIRST_DATA_COLUMN_INDEX = 1;
const DATA_COLUMN_COUNT = 21;
const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx";
/** 줄바꿈과 공백이 포함된 병합 헤더를 비교 가능한 문자열로 정규화한다. */
const normalizeHeader = (value: string) => value.replace(/\s+/g, "").trim();
@@ -80,17 +82,6 @@ const requiredText = (value: string, sheetName: string, rowNumber: number, field
return normalized;
};
/** 순번 셀을 안전한 정수로 변환한다. */
const optionalInteger = (value: string, sheetName: string, rowNumber: number): number | null => {
const normalized = optionalText(value)?.replace(/,/g, "");
if (!normalized) return null;
const parsed = Number(normalized);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${sheetName} 시트 ${rowNumber}행의 순번 값이 정수가 아닙니다.`);
}
return parsed;
};
/** 연장 셀의 콤마와 km 단위를 제거하고 유한한 숫자로 변환한다. */
const optionalDecimal = (value: string, sheetName: string, rowNumber: number, fieldName: string): number | null => {
const normalized = optionalText(value)?.replace(/,/g, "").replace(/km/gi, "").trim();
@@ -134,35 +125,33 @@ const parseSheet = (sheet: XLSX.WorkSheet, contract: SheetContract): ObsltPplBus
const values = Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) =>
cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset),
);
const valueAt = (index: number) => values[index] ?? "";
const rowNumber = rowIndex + 1;
const bizSeq = optionalInteger(values[0], contract.sheetName, rowNumber);
const originalBizCd = optionalText(values[2]);
const fallbackType = contract.sheetName === "정밀조사" ? "S" : "M";
const originalBizCd = optionalText(valueAt(2));
result.push({
bizSe: contract.sheetName,
bizSeq,
prcisExmnCycl: optionalText(values[1]),
bizCd: originalBizCd ?? `UNASSIGNED-${fallbackType}-${bizSeq ?? rowNumber}`,
prcisExmnCycl: optionalText(valueAt(1)),
bizCd: originalBizCd ?? "",
sggCd: null,
ctpvNm: optionalText(values[3]),
sigunNm: optionalText(values[4]),
envoNm: optionalText(values[5]),
bizNm: requiredText(values[6], contract.sheetName, rowNumber, "사업명"),
prplPrst: optionalText(values[7]),
bgngYr: yearText(values[8]),
cmcnYr: yearText(values[9]),
planPrlg: optionalDecimal(values[10], contract.sheetName, rowNumber, "계획연장(km)"),
frhfyrExpcPrlg: optionalDecimal(values[11], contract.sheetName, rowNumber, "상반기(예상)연장(km)"),
rmndPrlg: optionalDecimal(values[12], contract.sheetName, rowNumber, "잔여연장(km)"),
evdncDcmntSbmsnStts: optionalText(values[13]),
bgngYmd: dateText(values[14]),
cmcnYmd: dateText(values[15]),
bizPrplDlyRsnCn: optionalText(values[16]),
bizPicNm: optionalText(values[17]),
bizPicCnpl: optionalText(values[18]),
rmrkCn: optionalText(values[19]),
pbcpOpnnCn: optionalText(values[20]),
ctpvNm: optionalText(valueAt(3)),
sigunNm: optionalText(valueAt(4)),
envoNm: optionalText(valueAt(5)),
bizNm: requiredText(valueAt(6), contract.sheetName, rowNumber, "사업명"),
prplPrst: optionalText(valueAt(7)),
bgngYr: yearText(valueAt(8)),
cmcnYr: yearText(valueAt(9)),
planPrlg: optionalDecimal(valueAt(10), contract.sheetName, rowNumber, "계획연장(km)"),
frhfyrExpcPrlg: optionalDecimal(valueAt(11), contract.sheetName, rowNumber, "상반기(예상)연장(km)"),
rmndPrlg: optionalDecimal(valueAt(12), contract.sheetName, rowNumber, "잔여연장(km)"),
evdncDcmntSbmsnStts: optionalText(valueAt(13)),
bgngYmd: dateText(valueAt(14)),
cmcnYmd: dateText(valueAt(15)),
bizPrplDlyRsnCn: optionalText(valueAt(16)),
bizPicNm: optionalText(valueAt(17)),
bizPicCnpl: optionalText(valueAt(18)),
rmrkCn: optionalText(valueAt(19)),
pbcpOpnnCn: optionalText(valueAt(20)),
});
}
return result;
@@ -185,3 +174,67 @@ export const parseObsltPplExcel = async (file: File): Promise<ObsltPplBusinessSa
if (!file.name.toLowerCase().endsWith(".xlsx")) throw new Error("xlsx 파일만 업로드할 수 있습니다.");
return parseObsltPplWorkbook(await file.arrayBuffer());
};
/** 업로드 검증과 정확히 같은 B6:V7 위치에 시트별 헤더를 배치한다. */
const downloadHeaderRows = (contract: SheetContract) => {
const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array<string | number>(22).fill(""));
const topHeaders = [
"순번", "정밀조사차수", contract.businessCodeHeader, "시도", "시군", "환경청", "사업명",
"추진현황", "착수년도", "준공(예정)년도", contract.extensionHeader, "", "", "",
contract.progressHeader, "", "사업추진부진사유", "사업담당자(지자체)", "", "비고", "",
];
const bottomHeaders = [
"", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)",
"잔여연장(km)", "증빙서류제출여부", contract.startDateHeader, "준공(예정)일자(yyyy.mm.dd)",
"", "이름", "연락처", "", "공단의견",
];
topHeaders.forEach((value, index) => { rows[5]![index + FIRST_DATA_COLUMN_INDEX] = value; });
bottomHeaders.forEach((value, index) => { rows[6]![index + FIRST_DATA_COLUMN_INDEX] = value; });
return rows;
};
/** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~V에 업무값을 배치한다. */
const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | number> => [
"",
index + 1,
row.prcisExmnCycl ?? "",
row.bizCd ?? "",
row.ctpvNm ?? "",
row.sigunNm ?? "",
row.envoNm ?? "",
row.bizNm ?? "",
row.prplPrst ?? "",
row.bgngYr ?? "",
row.cmcnYr ?? "",
row.planPrlg ?? "",
row.frhfyrExpcPrlg ?? "",
row.rmndPrlg ?? "",
row.evdncDcmntSbmsnStts ?? "",
formatDate(row.bgngYmd),
formatDate(row.cmcnYmd),
row.bizPrplDlyRsnCn ?? "",
row.bizPicNm ?? "",
row.bizPicCnpl ?? "",
row.rmrkCn ?? "",
row.pbcpOpnnCn ?? "",
];
/** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */
export const buildObsltPplWorkbook = (businesses: ObsltPplBusiness[]): XLSX.WorkBook => {
const workbook = XLSX.utils.book_new();
SHEET_CONTRACTS.forEach((contract) => {
const data = businesses.filter((row) => row.bizSe === contract.sheetName);
const worksheet = XLSX.utils.aoa_to_sheet([
...downloadHeaderRows(contract),
...data.map(downloadDataRow),
]);
worksheet["!cols"] = Array.from({ length: 22 }, (_, index) => ({ wch: index === 7 ? 30 : 16 }));
XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName);
});
return workbook;
};
/** 생성한 두 시트 workbook을 브라우저 다운로드로 저장한다. */
export const downloadObsltPplExcel = (businesses: ObsltPplBusiness[]) => {
XLSX.writeFileXLSX(buildObsltPplWorkbook(businesses), DOWNLOAD_FILENAME);
};
@@ -4,6 +4,15 @@ type RegionOption = {
};
const normalizeRegionText = (value?: string | null) => String(value ?? "").replace(/\s+/g, " ").trim();
export const OBSLT_PPL_ENV_OFFICES = [
{ code: "G2101", name: "한강유역환경청" },
{ code: "G2102", name: "원주지방환경청" },
{ code: "G2201", name: "낙동강유역환경청" },
{ code: "G2202", name: "대구지방환경청" },
{ code: "G2301", name: "금강유역환경청" },
{ code: "G2302", name: "전북지방환경청" },
{ code: "G2501", name: "영산강유역환경청" },
] as const;
/** 시군구명에서 상위 시만 대표명으로 추출한다. */
export const extractObsltPplRepresentativeSigunName = (value?: string | null) => {
@@ -19,6 +28,13 @@ export const normalizeObsltPplSigunName = (value?: string | null) => {
return representative || normalizeRegionText(value);
};
/** 저장 가능한 환경청 명칭인지 검사한다. */
export const isValidObsltPplEnvOfficeName = (value?: string | null) => {
const normalizedValue = normalizeRegionText(value);
if (!normalizedValue) return true;
return OBSLT_PPL_ENV_OFFICES.some((office) => office.name === normalizedValue);
};
/** 선택된 시도 기준으로 대표 시군구 한 건씩만 노출할 옵션 목록을 만든다. */
export const buildObsltPplRepresentativeSggOptions = (
sggOptions: RegionOption[],
@@ -2,7 +2,6 @@
export interface ObsltPplBusiness {
obsltSwagePplBizSn: number;
bizSe: "정밀조사" | "정비사업";
bizSeq?: number | null;
prcisExmnCycl?: string | null;
bizCd: string;
sggCd?: string | null;
@@ -43,7 +42,7 @@ export interface ObsltPplPage {
size: number;
}
/** Excel 교체 업로드 결과 건수를 표시한다. */
/** Excel UPDATE·INSERT 업로드 결과 건수를 표시한다. */
export interface ObsltPplImportResult {
importedCount: number;
precisionSurveyCount: number;
@@ -1,11 +1,14 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import moment from "moment";
import { SelectBox } from "@common/business_components/ui";
import { Button, Input, Table, TableBody, TableCell, TableHead, TableRow } from "@common/components/ui";
import { formatDate } from "../../common/util";
import {
createObsltPplBusiness,
deleteObsltPplBusiness,
@@ -16,7 +19,9 @@ import {
buildObsltPplRepresentativeSggOptions,
findObsltPplCtpvCodeByName,
findObsltPplRepresentativeSggCode,
isValidObsltPplEnvOfficeName,
normalizeObsltPplSigunName,
OBSLT_PPL_ENV_OFFICES,
} from "../lib/obslt-ppl-region";
import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
@@ -29,19 +34,36 @@ const REGION_FORM_KEYS: FormKey[] = ["sggCd", "ctpvNm", "sigunNm"];
/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */
const FIELDS: Array<{ key: FormKey; label: string; type?: "number" | "textarea"; required?: boolean; placeholder?: string }> = [
{ key: "bizSe", label: "사업구분", required: true }, { key: "bizSeq", label: "순번", type: "number" },
{ key: "bizSe", label: "사업구분", required: true },
{ key: "prcisExmnCycl", label: "정밀조사차수" }, { key: "bizCd", label: "사업코드", required: true },
{ key: "envoNm", label: "환경청" }, { key: "bizNm", label: "사업명", required: true },
{ key: "prplPrst", label: "추진현황" }, { key: "bgngYr", label: "착수년도", placeholder: "YYYY" },
{ key: "cmcnYr", label: "준공(예정)년도", placeholder: "YYYY" }, { key: "planPrlg", label: "계획연장(km)", type: "number" },
{ key: "frhfyrExpcPrlg", label: "상반기(예상)연장(km)", type: "number" }, { key: "rmndPrlg", label: "잔여연장(km)", type: "number" },
{ key: "evdncDcmntSbmsnStts", label: "증빙서류 제출여부" }, { key: "bgngYmd", label: "착수(착공) 예정일자", placeholder: "YYYYMMDD" },
{ key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYYMMDD" }, { key: "bizPrplDlyRsnCn", label: "사업추진 부진사유", type: "textarea" },
{ key: "evdncDcmntSbmsnStts", label: "증빙서류 제출여부" }, { key: "bgngYmd", label: "착수(착공) 예정일자", placeholder: "YYYY-MM-DD" },
{ key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYY-MM-DD" }, { key: "bizPrplDlyRsnCn", label: "사업추진 부진사유", type: "textarea" },
{ key: "bizPicNm", label: "담당자 이름" }, { key: "bizPicCnpl", label: "담당자 연락처" },
{ key: "rmrkCn", label: "비고", type: "textarea" }, { key: "pbcpOpnnCn", label: "공단의견", type: "textarea" },
];
const NUMBER_KEYS = new Set<FormKey>(["bizSeq", "planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]);
const NUMBER_KEYS = new Set<FormKey>(["planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]);
const DATE_KEYS = new Set<FormKey>(["bgngYmd", "cmcnYmd"]);
/** 숫자만 받아 입력 길이에 맞춰 YYYY-MM-DD 형식의 하이픈을 자동으로 표시한다. */
const formatDateInput = (value: string) => {
const digits = value.replace(/\D/g, "").slice(0, 8);
if (digits.length <= 4) return digits;
if (digits.length <= 6) return `${digits.slice(0, 4)}-${digits.slice(4)}`;
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}`;
};
/** 선택 입력인 일자가 비어 있거나 실제 달력에 존재하는 YYYY-MM-DD인지 확인한다. */
const isValidDateInput = (value: string) =>
!value.trim() || (/^\d{4}-\d{2}-\d{2}$/.test(value) && moment(value, "YYYY-MM-DD", true).isValid());
/** API의 기존 날짜가 날짜형이면 화면 형식으로 바꾸고, 비정상 원문은 숨기지 않고 검증 대상으로 남긴다. */
const formatStoredDate = (value: string) =>
formatDate(value) || value;
/** 모든 입력을 제어 컴포넌트로 유지하기 위한 빈 폼 값을 생성한다. */
const createEmptyForm = (): FormState => Object.fromEntries(
@@ -51,7 +73,10 @@ const createEmptyForm = (): FormState => Object.fromEntries(
/** API 상세값을 빈 문자열 기반 화면 폼 값으로 변환한다. */
const toForm = (business: ObsltPplBusiness): FormState => {
const form = createEmptyForm();
FIELDS.forEach(({ key }) => { form[key] = String(business[key] ?? ""); });
FIELDS.forEach(({ key }) => {
const value = String(business[key] ?? "");
form[key] = DATE_KEYS.has(key) ? formatStoredDate(value) : value;
});
form.sggCd = String(business.sggCd ?? "");
form.ctpvNm = String(business.ctpvNm ?? "");
form.sigunNm = normalizeObsltPplSigunName(business.sigunNm);
@@ -62,13 +87,16 @@ const toForm = (business: ObsltPplBusiness): FormState => {
const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => Object.fromEntries(
[...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => {
const value = form[key].trim();
return [key, NUMBER_KEYS.has(key) ? (value === "" ? null : Number(value)) : (value === "" ? null : value)];
if (NUMBER_KEYS.has(key)) return [key, value === "" ? null : Number(value)];
if (DATE_KEYS.has(key)) return [key, value === "" ? null : value.replace(/\D/g, "")];
return [key, value === "" ? null : value];
}),
) as unknown as ObsltPplBusinessSaveRequest;
/** 배너 등록 화면의 표형 폼 패턴을 적용한 별도 등록·수정 화면이다. */
export function ObsltPplFormPage({ id }: { id?: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const isModify = Boolean(id);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [form, setForm] = useState<FormState>(createEmptyForm);
@@ -76,6 +104,8 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
const [notice, setNotice] = useState("");
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
const listQueryString = searchParams.toString();
const listHref = `/oper/obsltPplBiz${listQueryString ? `?${listQueryString}` : ""}`;
const ctpvOptions = useMemo(() => [{ text: "전체", val: "0", hidden: true }, ...ctpv], [ctpv]);
const representativeSggOptions = useMemo(
@@ -111,13 +141,21 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
const handleSave = async () => {
if (!form.bizSe || !form.bizCd.trim() || !form.bizNm.trim()) { setNotice("사업구분, 사업코드, 사업명은 필수입니다."); return; }
if (ctpvCd === "0" || sggCd === "0" || !form.ctpvNm.trim() || !form.sigunNm.trim()) { setNotice("시도와 시군구를 선택해주세요."); return; }
if ([...NUMBER_KEYS].some((key) => form[key] && !Number.isFinite(Number(form[key])))) { setNotice("순번과 물량은 숫자로 입력해주세요."); return; }
if ([...NUMBER_KEYS].some((key) => form[key] && !Number.isFinite(Number(form[key])))) { setNotice("연장 값은 숫자로 입력해주세요."); return; }
if ([...DATE_KEYS].some((key) => !isValidDateInput(form[key]))) {
setNotice("일자는 YYYY-MM-DD 형식의 유효한 날짜로 입력해주세요.");
return;
}
if (!isValidObsltPplEnvOfficeName(form.envoNm)) {
setNotice(`환경청은 ${OBSLT_PPL_ENV_OFFICES.map((office) => office.name).join(", ")} 중 하나여야 합니다.`);
return;
}
setLoading(true);
try {
if (id) await updateObsltPplBusiness(id, toRequest(form));
else await createObsltPplBusiness(toRequest(form));
window.alert(isModify ? "수정되었습니다." : "등록되었습니다.");
router.push("/oper/obsltPplBiz");
router.push(listHref);
} catch (error) { setNotice(error instanceof Error ? error.message : "저장하지 못했습니다."); }
finally { setLoading(false); }
};
@@ -126,7 +164,7 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
const handleDelete = async () => {
if (!id || !window.confirm("삭제하시겠습니까?")) return;
setLoading(true);
try { await deleteObsltPplBusiness(id); window.alert("삭제되었습니다."); router.push("/oper/obsltPplBiz"); }
try { await deleteObsltPplBusiness(id); window.alert("삭제되었습니다."); router.push(listHref); }
catch (error) { setNotice(error instanceof Error ? error.message : "삭제하지 못했습니다."); }
finally { setLoading(false); }
};
@@ -192,7 +230,18 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
) : field.type === "textarea" ? (
<textarea className="min-h-24 w-full rounded border bg-white p-3 text-[1.3rem]" value={form[field.key]} placeholder={field.placeholder} onChange={(e) => setForm({ ...form, [field.key]: e.target.value })} />
) : (
<Input className="!text-[1.3rem]" type={field.type === "number" ? "number" : "text"} step={field.type === "number" ? "any" : undefined} value={form[field.key]} placeholder={field.placeholder} onChange={(e) => setForm({ ...form, [field.key]: e.target.value })} />
<Input
className="!text-[1.3rem]"
type={field.type === "number" ? "number" : "text"}
step={field.type === "number" ? "any" : undefined}
maxLength={DATE_KEYS.has(field.key) ? 10 : undefined}
value={form[field.key]}
placeholder={field.placeholder}
onChange={(e) => setForm({
...form,
[field.key]: DATE_KEYS.has(field.key) ? formatDateInput(e.target.value) : e.target.value,
})}
/>
)}
</TableCell>
</TableRow>
@@ -201,7 +250,7 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
</Table>
{notice && <p className="mt-3 text-sm text-red-600" role="alert">{notice}</p>}
<div className="mt-5 flex justify-end gap-2">
<Button size="sm" onClick={() => router.push("/oper/obsltPplBiz")}>목록</Button>
<Button size="sm" onClick={() => router.push(listHref)}>목록</Button>
<Button size="sm" className="rounded-sm bg-blue-600 px-4 py-2 !text-white hover:bg-blue-700" onClick={() => void handleSave()}>저장</Button>
{isModify && <Button size="sm" className="rounded-sm bg-red-600 px-4 py-2 !text-white hover:bg-red-700" onClick={() => void handleDelete()}>삭제</Button>}
</div>
@@ -1,23 +1,38 @@
"use client";
import moment from "moment";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, UiTable } from "@common/business_components/ui";
import {
SearchDiv,
SearchFormLeft,
SearchFormRight,
SelectBox,
UiTable,
} from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { Button, Input, Label } from "@common/components/ui";
import {
getAllObsltPplBusinesses,
getObsltPplBusinesses,
uploadObsltPplExcel,
} from "../api/obslt-ppl-api";
import { parseObsltPplExcel } from "../lib/obslt-ppl-excel";
import { buildObsltPplRepresentativeSggOptions, normalizeObsltPplSigunName } from "../lib/obslt-ppl-region";
import { formatDate } from "../../common/util";
import {
downloadObsltPplExcel,
parseObsltPplExcel,
} from "../lib/obslt-ppl-excel";
import {
buildObsltPplRepresentativeSggOptions,
normalizeObsltPplSigunName,
} from "../lib/obslt-ppl-region";
import type { ObsltPplBusiness, ObsltPplPage } from "../model/obslt-ppl-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { openWindowPopup } from "../../windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types";
const DEFAULT_LIST_SIZE = 10;
const ACTION_BUTTON_CLASS =
@@ -32,11 +47,33 @@ const ALL_REGION_OPTION = { text: "전체", val: "0" };
type ObsltPplTableRow = ObsltPplBusiness & {
rowNumber: number;
};
type ObsltPplSearch = {
bizSe: string;
ctpvNm: string;
sigunNm: string;
bizNm: string;
};
/** 조회조건·지역 선택 코드·페이지를 목록 복귀에 사용할 URL query로 만든다. */
const createListQueryString = (
page: number,
search: ObsltPplSearch,
ctpvCd: string,
sggCd: string,
) => {
const params = new URLSearchParams();
if (page > 1) params.set("page", String(page));
Object.entries(search).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
if (ctpvCd !== "0") params.set("ctpvCd", ctpvCd);
if (sggCd !== "0") params.set("sggCd", sggCd);
return params.toString();
};
const TABLE_HEADLIST = [
{ id: "rowNumber", title: "NO", widthPercent: 4 },
{ id: "bizSe", title: "사업구분", widthPercent: 6 },
{ id: "bizSeq", title: "순번", widthPercent: 5 },
{ id: "prcisExmnCycl", title: "정밀조사차수", widthPercent: 6 },
{ id: "bizCd", title: "사업코드", widthPercent: 7 },
//{ id: "sggCd", title: "시군구코드", widthPercent: 6 },
@@ -70,71 +107,49 @@ const EMPTY_PAGE: ObsltPplPage = {
const DATE_FIELD_SET = new Set<keyof ObsltPplBusiness>(["bgngYmd", "cmcnYmd"]);
/** 8자리 또는 구분자가 섞인 날짜 문자열을 화면·다운로드 공통 형식으로 정규화한다. */
const formatObsltPplDate = (value?: string | null) => {
if (!value) return "";
const normalized = String(value).trim();
if (!normalized) return "";
const digits = normalized.replace(/[^0-9]/g, "");
if (digits.length === 8) {
const parsedDate = moment(digits, "YYYYMMDD", true);
return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized;
}
const parsedDate = moment(normalized);
return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized;
};
/** 목록 표시값을 공통 포맷으로 정리한다. */
const formatBusinessForDisplay = (row: ObsltPplBusiness): ObsltPplBusiness => ({
...row,
sigunNm: normalizeObsltPplSigunName(row.sigunNm) || null,
bgngYmd: formatObsltPplDate(row.bgngYmd) || null,
cmcnYmd: formatObsltPplDate(row.cmcnYmd) || null,
});
/** 현재 조회조건 기준 전체 목록을 내려받기용 행으로 변환한다. */
const toExcelRow = (row: ObsltPplTableRow) => ({
NO: row.rowNumber,
사업구분: row.bizSe ?? "",
순번: row.bizSeq ?? "",
정밀조사차수: row.prcisExmnCycl ?? "",
사업코드: row.bizCd ?? "",
시도: row.ctpvNm ?? "",
시군: row.sigunNm ?? "",
환경청: row.envoNm ?? "",
사업명: row.bizNm ?? "",
추진현황: row.prplPrst ?? "",
착수년도: row.bgngYr ?? "",
"준공(예정)년도": row.cmcnYr ?? "",
"계획연장(km)": row.planPrlg ?? "",
"상반기(예상)연장(km)": row.frhfyrExpcPrlg ?? "",
"잔여연장(km)": row.rmndPrlg ?? "",
증빙서류제출여부: row.evdncDcmntSbmsnStts ?? "",
착수일자: formatObsltPplDate(row.bgngYmd),
"준공(예정)일자": formatObsltPplDate(row.cmcnYmd),
사업추진부진사유: row.bizPrplDlyRsnCn ?? "",
"담당자 이름": row.bizPicNm ?? "",
"담당자 연락처": row.bizPicCnpl ?? "",
비고: row.rmrkCn ?? "",
공단의견: row.pbcpOpnnCn ?? "",
bgngYmd: formatDate(row.bgngYmd) || null,
cmcnYmd: formatDate(row.cmcnYmd) || null,
});
/** 노후하수관로 사업 목록, 검색, xlsx 업로드·다운로드 진입점을 제공한다. */
export function ObsltPplListPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [page, setPage] = useState(1);
const initialSearch = useMemo<ObsltPplSearch>(
() => ({
bizSe: searchParams.get("bizSe") ?? "",
ctpvNm: searchParams.get("ctpvNm") ?? "",
sigunNm: searchParams.get("sigunNm") ?? "",
bizNm: searchParams.get("bizNm") ?? "",
}),
[searchParams],
);
const [page, setPage] = useState(() =>
Math.max(Number(searchParams.get("page")) || 1, 1),
);
const [data, setData] = useState<ObsltPplPage>(EMPTY_PAGE);
const [rows, setRows] = useState<ObsltPplTableRow[]>([]);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState("");
const [resetPageNo, setResetPageNo] = useState(false);
// 선택박스의 코드와 API 검색에 사용하는 행정구역명을 함께 관리한다.
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
const [search, setSearch] = useState({ bizSe: "", ctpvNm: "", sigunNm: "", bizNm: "" });
const [appliedSearch, setAppliedSearch] = useState(search);
const [ctpvCd, setCtpvCd] = useState(() => searchParams.get("ctpvCd") ?? "0");
const [sggCd, setSggCd] = useState(() => searchParams.get("sggCd") ?? "0");
const [appliedCtpvCd, setAppliedCtpvCd] = useState(
() => searchParams.get("ctpvCd") ?? "0",
);
const [appliedSggCd, setAppliedSggCd] = useState(
() => searchParams.get("sggCd") ?? "0",
);
const [search, setSearch] = useState<ObsltPplSearch>(initialSearch);
const [appliedSearch, setAppliedSearch] =
useState<ObsltPplSearch>(initialSearch);
/** 현재 선택한 시도에 속한 시군구만 선택 후보로 제공한다. */
const availableSgg = useMemo(() => {
@@ -148,38 +163,81 @@ export function ObsltPplListPage() {
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 확정 검색조건과 페이지를 서버 요청 파라미터로 변환해 목록을 갱신한다. */
const load = useCallback(async (targetPage = page, targetSearch = appliedSearch) => {
const params = new URLSearchParams({
page: String(Math.max(targetPage - 1, 0)),
size: String(DEFAULT_LIST_SIZE),
sort: "obsltSwagePplBizSn,desc",
});
Object.entries(targetSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
const load = useCallback(
async (targetPage = page, targetSearch = appliedSearch) => {
const params = new URLSearchParams({
page: String(Math.max(targetPage - 1, 0)),
size: String(DEFAULT_LIST_SIZE),
sort: "obsltSwagePplBizSn,desc",
});
Object.entries(targetSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
setLoading(true);
try {
const result = await getObsltPplBusinesses(params);
setData(result);
setRows(result.content.map((row, index) => ({
...formatBusinessForDisplay(row),
rowNumber: result.totalElements - ((targetPage - 1) * DEFAULT_LIST_SIZE + index),
})));
} catch (error) {
setNotice(error instanceof Error ? error.message : "목록을 조회하지 못했습니다.");
setData(EMPTY_PAGE);
setRows([]);
} finally {
setLoading(false);
setResetPageNo(false);
}
}, [appliedSearch, page]);
setLoading(true);
try {
const result = await getObsltPplBusinesses(params);
setData(result);
setRows(
result.content.map((row, index) => ({
...formatBusinessForDisplay(row),
rowNumber:
result.totalElements -
((targetPage - 1) * DEFAULT_LIST_SIZE + index),
})),
);
} catch (error) {
setNotice(
error instanceof Error
? error.message
: "목록을 조회하지 못했습니다.",
);
setData(EMPTY_PAGE);
setRows([]);
} finally {
setLoading(false);
setResetPageNo(false);
}
},
[appliedSearch, page],
);
useEffect(() => {
void load();
}, [load]);
/** 팝업 등록 완료 메시지를 받으면 기존 적용 조회조건을 유지한 채 첫 페이지를 재조회한다. */
useEffect(() => {
const receivePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.CFMTN_BGT_REGISTERED)
return;
const inserted = Number(event.data?.payload?.insertedCount ?? 0);
const skipped = Number(event.data?.payload?.skippedCount ?? 0);
setNotice(
`최종확정예산사업 ${inserted.toLocaleString()}건을 등록했습니다.${skipped ? ` 이미 등록된 ${skipped.toLocaleString()}건은 제외했습니다.` : ""}`,
);
setPage(1);
setResetPageNo(true);
void load(1, appliedSearch);
};
window.addEventListener("message", receivePopupMessage);
return () => window.removeEventListener("message", receivePopupMessage);
}, [appliedSearch, load]);
/** 부모 화면의 필수 사업구분을 전달하여 최종확정예산사업 선택 팝업을 연다. */
const handleOpenCfmtnBgt = () => {
if (search.bizSe !== "정밀조사" && search.bizSe !== "정비사업") {
setNotice("사업구분에서 정밀조사 또는 정비사업을 먼저 선택해 주세요.");
return;
}
const popup = openWindowPopup("cfmtnBgt", { bizSe: search.bizSe });
if (!popup)
setNotice(
"팝업이 차단되었습니다. 브라우저의 팝업 허용 설정을 확인해 주세요.",
);
};
/** 선택한 xlsx를 브라우저에서 검증·파싱한 뒤 JSON으로 교체 업로드한다. */
const handleUpload = async (file?: File) => {
if (!file) return;
@@ -199,37 +257,29 @@ export function ObsltPplListPage() {
setPage(1);
await load(1, appliedSearch);
} catch (error) {
setNotice(error instanceof Error ? error.message : "업로드하지 못했습니다.");
setNotice(
error instanceof Error ? error.message : "업로드하지 못했습니다.",
);
} finally {
setLoading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
/** 현재 조회조건 기준 전체 목록을 화면 포맷 그대로 xlsx로 내려받는다. */
/** 적용된 조회조건 전체를 두 업무 시트의 업로드 호환 위치로 내려받는다. */
const handleDownload = async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: "0",
size: String(Math.max(data.totalElements, 1)),
sort: "obsltSwagePplBizSn,desc",
});
const params = new URLSearchParams();
Object.entries(appliedSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
const result = await getObsltPplBusinesses(params);
const exportRows = result.content.map((row, index) => ({
...formatBusinessForDisplay(row),
rowNumber: result.totalElements - index,
}));
const { utils, writeFileXLSX } = await import("xlsx");
const worksheet = utils.json_to_sheet(exportRows.map(toExcelRow));
const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, "노후하수관로");
writeFileXLSX(workbook, "노후하수관로 정밀조사 및 정비사업 현황.xlsx");
const exportRows = await getAllObsltPplBusinesses(params);
downloadObsltPplExcel(exportRows);
} catch (error) {
setNotice(error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.");
setNotice(
error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.",
);
} finally {
setLoading(false);
}
@@ -240,7 +290,13 @@ export function ObsltPplListPage() {
const nextSearch = { ...search };
setNotice("");
setAppliedSearch(nextSearch);
setAppliedCtpvCd(ctpvCd);
setAppliedSggCd(sggCd);
setResetPageNo(true);
const queryString = createListQueryString(1, nextSearch, ctpvCd, sggCd);
router.replace(`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`, {
scroll: false,
});
if (page === 1) {
await load(1, nextSearch);
return;
@@ -263,7 +319,12 @@ export function ObsltPplListPage() {
selectClass="w-[120px] rounded-lg bg-white"
selectArray={BIZ_SE_OPTIONS}
value={search.bizSe}
onSelectValue={(value) => setSearch((current) => ({ ...current, bizSe: String(value) }))}
onSelectValue={(value) =>
setSearch((current) => ({
...current,
bizSe: String(value),
}))
}
/>
</div>
<div className="flex items-center">
@@ -280,7 +341,9 @@ export function ObsltPplListPage() {
setSggCd("0");
setSearch((current) => ({
...current,
ctpvNm: ctpv.find((option) => String(option.val) === nextCtpvCd)?.text ?? "",
ctpvNm:
ctpv.find((option) => String(option.val) === nextCtpvCd)
?.text ?? "",
sigunNm: "",
}));
}}
@@ -297,7 +360,9 @@ export function ObsltPplListPage() {
onSelectValue={(value) => {
const nextSggCd = String(value);
setSggCd(nextSggCd);
const selectedOption = availableSgg.find((option) => String(option.val) === nextSggCd);
const selectedOption = availableSgg.find(
(option) => String(option.val) === nextSggCd,
);
setSearch((current) => ({
...current,
sigunNm: selectedOption?.text ?? "",
@@ -310,7 +375,12 @@ export function ObsltPplListPage() {
<Input
className="w-[220px] !text-2xl"
value={search.bizNm}
onChange={(event) => setSearch((current) => ({ ...current, bizNm: event.target.value }))}
onChange={(event) =>
setSearch((current) => ({
...current,
bizNm: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
@@ -324,12 +394,37 @@ export function ObsltPplListPage() {
</SearchFormLeft>
<SearchFormRight className="ml-auto flex shrink-0 items-center justify-end px-4">
<div className="flex items-center gap-2">
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => void handleSearch()}>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={() => void handleSearch()}
>
조회
</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => router.push("/oper/obsltPplBiz/register")}>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={() => {
const queryString = createListQueryString(
page,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.push(
`/oper/obsltPplBiz/register${queryString ? `?${queryString}` : ""}`,
);
}}
>
등록
</Button>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={handleOpenCfmtnBgt}
>
최종확정예산사업
</Button>
<input
ref={fileInputRef}
type="file"
@@ -337,7 +432,11 @@ export function ObsltPplListPage() {
className="hidden"
onChange={(event) => void handleUpload(event.target.files?.[0])}
/>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => fileInputRef.current?.click()}>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={() => fileInputRef.current?.click()}
>
업로드
</Button>
<Button
@@ -352,17 +451,22 @@ export function ObsltPplListPage() {
</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">
<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-[100%]">
<div className="w-[130%]">
<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")}건)</span>
<span className="text-base text-gray-500">
({data.totalElements.toLocaleString("ko-KR")}건)
</span>
</h3>
</div>
<UiTable<ObsltPplTableRow>
@@ -373,15 +477,41 @@ export function ObsltPplListPage() {
total={data.totalElements}
curPage={page}
widthFixed
onPageChange={setPage}
onPageChange={(nextPage) => {
setPage(nextPage);
const queryString = createListQueryString(
nextPage,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.replace(
`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`,
{ scroll: false },
);
}}
resetPageNo={resetPageNo}
cellClick={(index) => {
if (typeof index !== "number" || !rows[index]) return;
router.push(`/oper/obsltPplBiz/register/${rows[index].obsltSwagePplBizSn}`);
const queryString = createListQueryString(
page,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.push(
`/oper/obsltPplBiz/register/${rows[index].obsltSwagePplBizSn}${queryString ? `?${queryString}` : ""}`,
);
}}
renderCell={(row, headId) => {
if (!DATE_FIELD_SET.has(headId as keyof ObsltPplBusiness)) return undefined;
return formatObsltPplDate(row[headId as keyof ObsltPplTableRow] as string | null | undefined);
if (!DATE_FIELD_SET.has(headId as keyof ObsltPplBusiness))
return undefined;
return formatDate(
row[headId as keyof ObsltPplTableRow] as
| string
| null
| undefined,
);
}}
/>
</div>
@@ -1,6 +1,13 @@
import type { WindowPopupDefinition } from "./types";
export const windowPopupDefinitions = {
cfmtnBgt: {
key: "cfmtnBgt",
title: "최종확정예산사업",
width: 1500,
height: 900,
requiredParams: ["bizSe"],
},
exmnBusinessRegister: {
key: "exmnBusinessRegister",
title: "사업정보 등록",
@@ -38,6 +38,7 @@ export type OpenWindowPopupOptions = Partial<
};
export const WINDOW_POPUP_MESSAGE_TYPE = {
CFMTN_BGT_REGISTERED: "sn:popup:cfmtnBgtRegistered", // 최종확정예산사업
EXMN_BUSINESS_CREATED: "sn:popup:exmnBusinessCreated",
EXMN_BUSINESS_UPDATED: "sn:popup:exmnBusinessUpdated",
EXMN_RESULT_COMMITTED: "sn:popup:exmnResultCommitted",
@@ -35,6 +35,7 @@ import GisManagementPopupContent from "./ui/GisManagementPopupContent";
import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent";
import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent";
import WaterRiskPopupContent from "./ui/WaterRiskPopupContent";
import CfmtnBgtPopupContent from "../cfmtnBgt/ui/CfmtnBgtPopupContent";
const FloodPredictionPlaceholderPopupContent = (
_props: WindowPopupContentProps,
@@ -51,6 +52,10 @@ const GisEditPopupContent = (_props: WindowPopupContentProps) => (
);
export const windowPopupRegistry = {
cfmtnBgt: {
...windowPopupDefinitions.cfmtnBgt,
component: CfmtnBgtPopupContent,
},
exmnBusinessRegister: {
...windowPopupDefinitions.exmnBusinessRegister,
component: ExmnBusinessRegisterPopupContent,