sn: 최종확정예산사업 popup

This commit is contained in:
2026-08-06 18:40:19 +09:00
parent 1858739bb9
commit f00627270f
14 changed files with 1270 additions and 65 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>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { create } from "zustand";
import { getRawJson } from "@/components/lib/api";
import type { SelectDataType } from "@common/business_components";
type OnsemiroAddrStoreState = {
govArea: SelectDataType[];
city: SelectDataType[];
sgg: SelectDataType[];
fetchGovArea: () => Promise<void>;
fetchCity: () => Promise<void>;
fetchSgg: () => Promise<void>;
};
const CITY_SORT_ORDER = [
"서울특별시",
"부산광역시",
"대구광역시",
"인천광역시",
"광주광역시",
"대전광역시",
"울산광역시",
"세종특별자치시",
"경기도",
"강원특별자치도",
"충청북도",
"충청남도",
"전북특별자치도",
"전라남도",
"경상북도",
"경상남도",
"제주특별자치도",
];
const toResponseList = (response: unknown) => {
if (Array.isArray(response)) return response;
if (response && typeof response === "object") {
const data = (response as { data?: unknown }).data;
if (Array.isArray(data)) return data;
}
return [];
};
const toSelectOption = (item: Record<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");
};
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));
};
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;
@@ -0,0 +1,67 @@
# SN-노후하수관로정비사업 최종확정예산사업 팝업 FE 검증계획
## 1. 검증 기준
- 공통 `006` 사용자 수정본과 동기화한 FE `003` 작업계획을 기준으로 한다.
- 같은 화면 Modal이 아니라 공용 `/popup/[popupKey]` window popup 흐름을 검증한다.
## 2. 부모 화면
- `/oper/obsltPplBiz`에 `최종확정예산사업` 버튼이 표시되는지 확인
- 사업구분 미선택 시 안내만 표시되고 popup이 열리지 않는지 확인
- `정비사업`, `정밀조사` 각각에서 popup query parameter가 정확한지 확인
- popup 차단 시 사용자 안내가 표시되는지 확인
## 3. popup 등록과 초기조건
- `cfmtnBgt` popup key가 definitions와 registry에 등록되는지 확인
- popup 제목·크기·가로 스크롤 확인
- 현재연도 기준 전년도가 초기 선택되는지 확인
- 정비사업: `하수관로정비% / 하수관로정비 / 노후하수관로정비%` 초기조건 확인
- 정밀조사: `하수관로정비% / 노후하수관로정밀조사지원` 초기조건 확인
- popup 재오픈과 초기화 시 부모 조건으로 복원되는지 확인
- 초기 년도가 항상 현재년도의 전년도로 설정되는지 확인
## 4. 조회·주소·목록
- 환경청/시도/시군구/세부사업/내역사업/세부내역/년도/사업명 조건 확인
- `구분` 조건이 없고 환경청 미선택 상태에서도 시도를 선택·조회할 수 있는지 확인
- `조회 → 초기화 → 엑셀다운로드` 순서 확인
- ONSEMIRO 전용 city/sgg/govArea 상태와 raw 응답→SelectBox 매핑 확인
- 기존 operation-service `useOperStore` 주소상태 회귀 확인
- 시도 변경 시 시군구 초기화와 종속 후보 확인
- A~AG 33개 열, 다단 헤더, 금액 우측정렬, 가로 스크롤 확인
- 여러 페이지 선택 유지 및 검색·초기화 시 선택 제거 확인
## 5. 등록·부모 통신
- popup 내부에 사업구분 SelectBox가 없는지 확인
- 선택 없음에서 `등록` 버튼 비활성화 확인
- 부모에서 선택한 사업구분이 별도 변환 없이 복수 `SEQ_NO` 등록 요청에 포함되는지 확인
- 같은 원본 재등록 시 저장하지 않고 이미 등록되었다는 안내가 표시되는지 확인
- 성공 postMessage type과 등록·중복제외 payload 확인
- 성공 시 popup close, 부모 알림, 기존 조회조건 유지 첫 페이지 재조회 확인
- 실패 시 popup과 선택 유지, 부모 목록 미재조회 확인
- popup 단순 종료 시 부모 목록 미재조회 확인
## 6. Excel
- 적용 조회조건 전체를 1,000건 단위로 조회하고 파일 건수와 일치하는지 확인
- A~AG 33개 열·대표값 일치
- 날짜 `yyyy-MM-dd`, 금액/보조율 숫자, 사업코드 문자열 확인
- 파일명·시트명과 `초기화` 옆 버튼 위치 확인
## 7. 정적·회귀 검증
- 변경 TS/TSX ESLint 통과
- `features/obsltPpl`, `features/cfmtnBgt`, `features/windowPopup`, `useOperStore` 대상 TypeScript 진단 0건
- 사용자 승인 후 SN build
- 기존 조회조건 유지, 행 클릭 수정, 개별 등록, 업로드, 기존 Excel 다운로드 회귀
- 기존 popup registry 전체 key가 정상 해석되는지 확인
## 8. 증적
- 캡처와 Network 증적은 `COMMON_DOCS_ROOT/작업내용/SN-노후하수관로정비사업/검증/`에 저장한다.
- 2026-08-06 대상 TypeScript 확인: 신규 `cfmtnBgt`, 부모 목록, ONSEMIRO 주소 store, popup registry/model 오류 없음.
- SN 전체 TypeScript는 기존 타 기능 오류가 남아 있어 전체 성공으로 판정하지 않는다.
- SN 전체 build는 제한시간 안에 완료되지 않아 실 API·브라우저 검증과 함께 후속 수행한다.
@@ -0,0 +1,150 @@
# SN-노후하수관로정비사업 최종확정예산사업 팝업 FE 작업계획
## 1. 목표와 기준
- 기준 문서: `COMMON_DOCS_ROOT/작업계획/SN-노후하수관로정비사업/006-de-공통-최종확정예산사업팝업-신규기능작업계획.md`
- 대상 부모 화면: `/oper/obsltPplBiz`
- 상태: **사용자 승인 구현 완료, 실 API·브라우저 검증 대기**
- 부모 화면에서 필수 사업구분을 먼저 선택한 경우에만 공용 브라우저 popup을 연다.
- popup에서 ONSEMIRO 최종확정예산 33개 항목을 조회·다중선택하고 부모 업무로 일괄 등록한다.
- 등록 성공 메시지를 부모에 전달한 뒤 popup을 닫고 부모 목록을 기존 조회조건으로 다시 조회한다.
## 2. 공통계획 변경사항 동기화 확인
| 항목 | 이전 FE 계획 | 사용자 수정 공통계획 반영 |
| --- | --- | --- |
| 팝업 방식 | 같은 화면의 `UiModal` | `app/(popupLayout)/popup/[popupKey]` 기반 window popup |
| popup 구현 위치 | `features/obsltPpl/ui/CfmtnBgtModal.tsx` | `features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx` |
| 사업구분 선택 | popup 내부 SelectBox | 부모 화면 조회조건에서 먼저 선택, 미선택 시 알림 후 popup 미오픈 |
| 등록 버튼 | `노후하수관로사업 등록` | popup의 `등록` 버튼 |
| 검색 초기값 | 최신연도 일반 조회 | 전년도 및 부모 업무별 세부사업·내역사업·세부내역 자동조건 |
| 지역 데이터 | 신규 옵션 API 직접 사용 | `useOperStore.ts`에 ONSEMIRO 주소 옵션을 추가하여 사용 |
| 완료 처리 | Modal state close | `postMessage`로 부모 알림·재조회 요청 후 `window.close()` |
| 기능 재사용 | 노후하수관로 전용 Modal | 부모 업무정보를 query parameter로 받는 공용 popup content |
## 3. 예정 수정 파일
| 구분 | 예정 파일 | 변경 계획 |
| --- | --- | --- |
| 부모 연결 | `apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx` | 사업구분 필수검증, window popup open, 완료 메시지 수신, 기존 조건 재조회 |
| popup 본체 | `apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx` | 검색, 33개 열, 다중선택, 등록, Excel 다운로드 |
| API | `apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts` | 코드·목록·선택등록 API |
| 타입 | `apps/sn/src/features/cfmtnBgt/model/cfmtn-bgt-types.ts` | 33개 조회필드, 검색·등록·응답, 부모 context 계약 |
| Excel | `apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts` | 적용 조회조건 전체 결과 XLSX 생성 |
| popup 정의 | `apps/sn/src/features/windowPopup/model/popupDefinitions.ts` | `cfmtnBgt` key, 제목, 대형 popup 크기, 필수 query parameter 정의 |
| popup 등록 | `apps/sn/src/features/windowPopup/registry.tsx` | `CfmtnBgtPopupContent` registry 연결 |
| 완료 메시지 | `apps/sn/src/features/windowPopup/model/types.ts` | 최종확정예산 등록 완료 message type 추가 |
| popup 메시지 | 기존 `features/windowPopup/lib/popupMessage.ts` 재사용 | 등록 결과 payload 전송 후 popup 닫기 |
| 운영 store | `apps/sn/app/store/useOperStore.ts` | 기존 operation-service 주소 상태를 유지하고 ONSEMIRO 전용 주소 상태·fetch 함수 추가 |
## 4. 부모 화면 계약
- `최종확정예산사업` 버튼은 기존 목록 액션 영역에 추가한다.
- 버튼 클릭 시 현재 조회조건의 사업구분을 검사한다.
- 선택되지 않았으면 `사업구분을 먼저 선택해 주세요.` 알림을 표시하고 popup을 열지 않는다.
- 선택된 사업구분과 부모 식별정보를 `openWindowPopup("cfmtnBgt", params)`로 전달한다.
- popup 완료 메시지는 다른 popup 메시지와 구분되는 고유 type으로 수신한다.
- 성공 시 popup 응답의 등록·중복제외 건수를 부모 알림에 표시하고, 기존 적용 조회조건을 유지한 첫 페이지를 재조회한다.
- popup이 단순 종료되거나 등록에 실패하면 부모 목록은 재조회하지 않는다.
## 5. popup query parameter와 재사용 계약
제안 parameter:
| 이름 | 필수 | 설명 |
| --- | --- | --- |
| `parentType` | Y | 부모 업무 식별자, 노후하수관로는 `obsltPplBiz` |
| `bizSe` | Y | 부모가 선택한 사업구분 |
| `parentMessageType` | Y | 성공 시 부모가 수신할 popup message type |
- popup은 `parentType`과 `bizSe`로 초기 검색조건과 등록 API를 결정한다.
- 다른 화면이 재사용할 때는 popup 본체를 복사하지 않고 부모별 초기조건·등록 adapter만 추가한다.
- 필수 parameter가 없으면 조회하지 않고 누락 안내를 표시한다.
## 6. 검색 초기값
- 연도 기본값은 공통계획의 최신 명시인 **전년도**로 한다.
- 예: 현재가 2026년이면 2025년
- 부모가 `obsltPplBiz`일 때:
| 부모 사업구분 | 세부사업 초기조건 | 내역사업 초기조건 | 세부내역 초기조건 |
| --- | --- | --- | --- |
| `정비사업` | `하수관로정비%` | `하수관로정비` | `노후하수관로정비%` |
| `정밀조사` | `하수관로정비%` | `노후하수관로정밀조사지원` | 전체/미지정 |
- `%` 조건은 화면 label에 `%`를 표시한다는 뜻이 아니라 BE code/options 조회에서 prefix 일치 항목을 선택하는 계약이다.
- popup을 닫았다 다시 열면 부모가 전달한 사업구분으로 초기조건을 다시 계산한다.
- 조회조건: 환경청, 시도, 시군구, 세부사업, 내역사업, 세부내역, 년도, 사업명
- `구분` 조건은 만들지 않는다. 환경청은 선택조건이며, 환경청을 선택하지 않아도 시도를 독립적으로 선택할 수 있어야 한다.
- 버튼 순서: `조회`, `초기화`, `엑셀다운로드`
- `초기화`는 부모 업무에서 계산한 초기조건으로 되돌리고 결과와 선택을 지운다.
- 초기 년도는 현재년도 기준 전년도로 고정한다.
## 7. ONSEMIRO 주소 store 계약
- 기존 `useOperStore`의 `govArea/city/sgg`와 `/operation-service/api/v1/addr/*` 호출은 다른 화면이 사용하므로 변경하거나 대체하지 않는다.
- 같은 store에 다음과 같은 ONSEMIRO 전용 상태를 별도로 추가한다.
- `onsemiroGovArea`, `onsemiroCity`, `onsemiroSgg`
- `fetchOnsemiroGovArea`, `fetchOnsemiroCity`, `fetchOnsemiroSgg`
- 신규 API:
- `/api/v1/onsemiro/addr/city`
- `/api/v1/onsemiro/addr/sgg`
- `/api/v1/onsemiro/addr/govArea`
- 현재 신규 Addr API 응답은 원본 camelCase 필드이므로 SelectBox 변환 시 다음을 사용한다.
| 구분 | text | val |
| --- | --- | --- |
| 시도 | `sido` | `adstDstrCd` |
| 시군구 | `gugun` | `adstDstrCd` |
| 환경청/지자체 | `lclgvrnmntNm` | `lclgvrnmntCd` 또는 조회계약에서 확정한 환경청 코드 |
- 시도 변경 시 `adstDstrCd` 앞자리 또는 BE가 제공하는 관계 기준으로 시군구 후보를 제한하고 기존 시군구 선택을 초기화한다.
- `govArea` 응답은 원본 테이블이 지자체 기준이므로 popup의 실제 환경청 선택 label/code가 맞는지 BE 조회결과로 최종 확인한다.
## 8. 목록·선택·등록 계약
- 첫 열에 전체선택/행선택 checkbox를 제공한다.
- 페이지 이동 후에도 `SEQ_NO` Set을 유지한다.
- 조회·초기화로 적용조건이 바뀌면 선택 Set을 지운다.
- 33개 열은 다단 헤더와 목록 내부 가로 스크롤을 사용한다.
- popup 내부에는 사업구분 SelectBox를 두지 않는다.
- 선택행이 없으면 `등록` 버튼을 비활성화한다.
- 확인 문구는 부모가 전달한 사업구분을 사용한다.
- 등록 요청은 `SEQ_NO[]`만이 아니라 부모에서 전달한 `bizSe`를 함께 보낸다.
- 성공 시 등록·중복제외 건수를 popup message payload로 전달하고 닫는다.
- 실패 시 popup과 선택상태를 유지한다.
## 9. Excel 계약
- FE에서 적용 조회조건 전체 결과를 1,000건 단위로 반복 조회해 생성한다.
- 파일명: `최종확정예산관리_{년도}.xlsx`
- 시트명: `최종확정예산관리`
- 열 순서: 원본 Excel A~AG 33개 열
- 날짜는 `apps/sn/src/features/common/util.ts`의 `formatDate()`를 사용해 `yyyy-MM-dd`로 표시한다.
- 금액/보조율은 숫자, 사업코드는 문자열로 출력한다.
- 버튼명은 `엑셀다운로드`이며 `초기화` 바로 오른쪽에 둔다.
## 10. 검증
- 부모 사업구분 미선택 시 popup 미오픈과 알림 확인
- `popupDefinitions → registry → CfmtnBgtPopupContent` 연결 확인
- 부모별 전년도·사업구분별 초기조건 확인
- ONSEMIRO 주소 store가 기존 operation-service 상태를 변경하지 않는지 확인
- 여러 페이지 다중선택과 조건 변경 시 선택 초기화 확인
- 성공 postMessage 수신, popup close, 부모 목록 조회조건 유지 재조회 확인
- popup 재오픈 시 부모 조건으로 초기화 확인
- 33개 열 가로 스크롤, 날짜·금액 포맷, Excel 전체결과 확인
- 변경 파일 ESLint·대상 TypeScript 및 사용자 승인 후 SN build·브라우저 검증
## 11. 영향·롤백·확정 계약
- 영향: `features/obsltPpl`, 신규 `features/cfmtnBgt`, 기존 `features/windowPopup`, `app/store/useOperStore.ts`
- route 파일은 공용 `/popup/[popupKey]`를 재사용하고 신규 route를 만들지 않는다.
- 타 앱과 공통 package는 변경하지 않는다.
- 롤백: 신규 `cfmtnBgt` feature 제거, popup definition/registry/message와 부모 연결·store 전용 상태만 원복한다.
- 부모 화면에서 선택한 사업구분을 별도 명칭 변환 없이 등록 API에 전달한다.
- 같은 원본은 다시 등록하지 않고 이미 등록된 사업임을 안내한다.
- 초기 년도는 현재년도의 전년도로 설정하며 최신 보유연도 fallback은 적용하지 않는다.
- 규모·금액 15개 필드는 조회·Excel 전용으로 유지한다.
- 운영 ONSEMIRO의 `하수관로정비(312)` 코드는 현 계획대로 우선 구현하고 운영 확인 결과에 따라 후속 보정한다.
- `govArea` 응답에서 환경청 label/code로 사용할 필드는 구현 전 실제 응답으로 확인한다.
@@ -0,0 +1,21 @@
import { getData, postRawJson } from "@/components/lib/api";
import type {
CfmtnBgtCode,
CfmtnBgtPage,
CfmtnBgtRegistrationResult,
} from "../model/cfmtn-bgt-types";
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 registerCfmtnBgt = async (bizSe: string, seqNos: number[]) =>
unwrap<CfmtnBgtRegistrationResult>(
await postRawJson("v1/obsltPplBiz/onsemiro/cfmtnBgt", { bizSe, seqNos }),
);
@@ -0,0 +1,82 @@
import * as XLSX from "xlsx";
import type { CfmtnBgtRow } from "../model/cfmtn-bgt-types";
const HEADERS = [
"순번",
"권역",
"단위유역",
"중권역",
"환경청",
"시도",
"시군구",
"사업코드",
"사업명",
"사업구분",
"세부사업",
"내역사업",
"세부내역",
"사업진행상태",
"신규/계속",
"착수",
"준공",
"사업규모_하수처리장",
"사업규모_하수관로",
"보조율(%)",
"총사업비_합계",
"총사업비_국고",
"총사업비_지방비",
"총사업비_원인자부담금",
"총사업비_환특융자",
"총사업비_기타",
"전년까지사업비",
"년차사업비_합계",
"년차사업비_국고",
"년차사업비_지방비",
"년차사업비_원인자부담금",
"년차사업비_환특융자",
"년차사업비_기타",
];
const values = (r: CfmtnBgtRow) => [
r.rowNumber,
r.rvbsn,
r.lgbsn,
r.mdmbsn,
r.govArea,
r.sido,
r.gugun,
r.bizCd,
r.bizNm,
r.sourceBizSe,
r.untBzNm,
r.mevBzNm,
r.dtlBzNm,
r.bizStatus,
r.newContinuing,
r.startYear,
r.endYear,
r.treatmentPlantScale,
r.pipeScale,
r.aidRate,
r.totalBizAmount,
r.totalNational,
r.totalLocal,
r.totalBeneficiary,
r.totalLoan,
r.totalEtc,
r.previousBizAmount,
r.annualBizAmount,
r.annualNational,
r.annualLocal,
r.annualBeneficiary,
r.annualLoan,
r.annualEtc,
];
export const downloadCfmtnBgtExcel = (rows: CfmtnBgtRow[], year: string) => {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
wb,
XLSX.utils.aoa_to_sheet([HEADERS, ...rows.map(values)]),
"최종확정예산관리",
);
XLSX.writeFileXLSX(wb, `최종확정예산관리_${year}.xlsx`);
};
@@ -0,0 +1,59 @@
export type CfmtnBgtCode = {
year: string;
untBzCd: string;
untBzNm: string;
mevBzNm: string;
dtlBzCd: string;
dtlBzNm: string;
};
export type CfmtnBgtRow = {
rowNumber: number;
seqNo: number;
year: string;
rvbsn: string | null;
lgbsn: string | null;
mdmbsn: string | null;
govArea: string | null;
sido: string | null;
gugun: string | null;
bizCd: string | null;
bizNm: string;
sourceBizSe: string | null;
untBzCd: string | null;
untBzNm: string | null;
mevBzNm: string | null;
dtlBzCd: string | null;
dtlBzNm: string | null;
bizStatus: string;
newContinuing: string | null;
startYear: string | null;
endYear: string | null;
treatmentPlantScale: string | null;
pipeScale: string | null;
aidRate: number;
totalBizAmount: number;
totalNational: number;
totalLocal: number;
totalBeneficiary: number;
totalLoan: number;
totalEtc: number;
previousBizAmount: number;
annualBizAmount: number;
annualNational: number;
annualLocal: number;
annualBeneficiary: number;
annualLoan: number;
annualEtc: number;
};
export type CfmtnBgtPage = {
content: CfmtnBgtRow[];
totalElements: number;
totalPages: number;
number: number;
size: number;
};
export type CfmtnBgtRegistrationResult = {
requestedCount: number;
insertedCount: number;
skippedCount: number;
};
@@ -0,0 +1,411 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components/ui";
import { SelectBox } from "@common/business_components/ui";
import useOnsemiroAddrStore from "@/app/store/useOnsemiroAddrStore";
import type { WindowPopupContentProps } from "../../windowPopup/model/types";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types";
import { postPopupMessageAndClose } from "../../windowPopup/lib/popupMessage";
import {
getCfmtnBgtCodes,
getCfmtnBgtPage,
registerCfmtnBgt,
} from "../api/cfmtn-bgt-api";
import { downloadCfmtnBgtExcel } from "../lib/cfmtn-bgt-excel";
import type {
CfmtnBgtCode,
CfmtnBgtPage,
CfmtnBgtRow,
} from "../model/cfmtn-bgt-types";
const ALL = { text: "전체", val: "0" };
const PAGE_SIZE = 20;
const EXPORT_SIZE = 1000;
const yearDefault =
process.env.NEXT_PUBLIC_NODE_ENV === "development"
? "2024"
: String(new Date().getFullYear() - 1);
const YEAR_OPTIONS = Array.from({ length: 6 }, (_, index) => {
const year = String(Number(yearDefault) - index);
return { text: year, val: year };
});
const COLS: [keyof CfmtnBgtRow, string][] = [
["rowNumber", "순번"],
["rvbsn", "권역"],
["lgbsn", "단위유역"],
["mdmbsn", "중권역"],
["govArea", "환경청"],
["sido", "시도"],
["gugun", "시군구"],
["bizCd", "사업코드"],
["bizNm", "사업명"],
["sourceBizSe", "사업구분"],
["untBzNm", "세부사업"],
["mevBzNm", "내역사업"],
["dtlBzNm", "세부내역"],
["bizStatus", "진행상태"],
["newContinuing", "신규/계속"],
["startYear", "착수"],
["endYear", "준공"],
["treatmentPlantScale", "처리장규모"],
["pipeScale", "관로규모"],
["aidRate", "보조율"],
["totalBizAmount", "총사업비 합계"],
["totalNational", "총사업비 국고"],
["totalLocal", "총사업비 지방비"],
["totalBeneficiary", "총사업비 원인자"],
["totalLoan", "총사업비 융자"],
["totalEtc", "총사업비 기타"],
["previousBizAmount", "전년까지사업비"],
["annualBizAmount", "년차 합계"],
["annualNational", "년차 국고"],
["annualLocal", "년차 지방비"],
["annualBeneficiary", "년차 원인자"],
["annualLoan", "년차 융자"],
["annualEtc", "년차 기타"],
];
const empty: CfmtnBgtPage = {
content: [],
totalElements: 0,
totalPages: 0,
number: 0,
size: PAGE_SIZE,
};
const uniq = (items: { text: string; val: string }[]) =>
Array.from(new Map(items.map((i) => [`${i.text}|${i.val}`, i])).values());
export default function CfmtnBgtPopupContent({
searchParams,
}: WindowPopupContentProps) {
const bizSe =
typeof searchParams.bizSe === "string" ? searchParams.bizSe : "";
const { govArea, city, sgg, fetchGovArea, fetchCity, fetchSgg } =
useOnsemiroAddrStore();
const initial = () => ({
year: yearDefault,
govArea: "",
sido: "",
gugun: "",
untBzCd: "",
mevBzNm: bizSe === "정비사업" ? "하수관로정비" : "노후하수관로정밀조사지원",
dtlBzCd: "",
bizNm: "",
});
const [search, setSearch] = useState(initial);
const [codes, setCodes] = useState<CfmtnBgtCode[]>([]);
const [page, setPage] = useState(0);
const [data, setData] = useState(empty);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [notice, setNotice] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
void fetchGovArea();
void fetchCity();
void fetchSgg();
}, [fetchCity, fetchGovArea, fetchSgg]);
useEffect(() => {
void getCfmtnBgtCodes(search.year)
.then(setCodes)
.catch((e) =>
setNotice(
e instanceof Error ? e.message : "코드를 조회하지 못했습니다.",
),
);
}, [search.year]);
const untOptions = useMemo(
() =>
uniq(
codes
.filter((c) => c.untBzNm?.startsWith("하수관로정비"))
.map((c) => ({ text: c.untBzNm, val: c.untBzCd })),
),
[codes],
);
useEffect(() => {
const first = untOptions[0];
if (!search.untBzCd && first)
setSearch((s) => ({ ...s, untBzCd: first.val }));
}, [search.untBzCd, untOptions]);
const mevOptions = useMemo(
() =>
uniq(
codes
.filter((c) => !search.untBzCd || c.untBzCd === search.untBzCd)
.map((c) => ({ text: c.mevBzNm, val: c.mevBzNm }))
.filter((x) => x.text),
),
[codes, search.untBzCd],
);
const dtlOptions = useMemo(
() =>
uniq(
codes
.filter(
(c) =>
(!search.untBzCd || c.untBzCd === search.untBzCd) &&
(!search.mevBzNm || c.mevBzNm === search.mevBzNm) &&
(bizSe !== "정비사업" ||
c.dtlBzNm?.startsWith("노후하수관로정비")),
)
.map((c) => ({ text: c.dtlBzNm, val: c.dtlBzCd }))
.filter((x) => x.text),
),
[bizSe, codes, search.mevBzNm, search.untBzCd],
);
useEffect(() => {
const first = dtlOptions[0];
if (bizSe === "정비사업" && !search.dtlBzCd && first)
setSearch((s) => ({ ...s, dtlBzCd: first.val }));
}, [bizSe, dtlOptions, search.dtlBzCd]);
const cityName = city.find((x) => String(x.val) === search.sido)?.text ?? "";
const sggOptions = sgg.filter(
(x) => !search.sido || String(x.val).startsWith(search.sido.slice(0, 2)),
);
const params = (targetPage: number, size = PAGE_SIZE) => {
const p = new URLSearchParams({
page: String(targetPage),
size: String(size),
year: search.year,
});
if (search.govArea)
p.set(
"govArea",
govArea.find((x) => String(x.val) === search.govArea)?.text ??
search.govArea,
);
if (cityName) p.set("sido", cityName);
if (search.gugun)
p.set(
"gugun",
sggOptions.find((x) => String(x.val) === search.gugun)?.text ?? "",
);
["untBzCd", "mevBzNm", "dtlBzCd", "bizNm"].forEach((k) => {
const v = search[k as keyof typeof search];
if (v) p.set(k, v);
});
return p;
};
const load = async (targetPage = 0) => {
setLoading(true);
try {
setData(await getCfmtnBgtPage(params(targetPage)));
setPage(targetPage);
setNotice("");
} catch (e) {
setNotice(e instanceof Error ? e.message : "조회하지 못했습니다.");
setData(empty);
} finally {
setLoading(false);
}
};
const reset = () => {
setSearch(initial());
setData(empty);
setSelected(new Set());
setPage(0);
setNotice("");
};
const download = async () => {
setLoading(true);
try {
let p = 0,
total = 1;
const rows: CfmtnBgtRow[] = [];
while (p < total) {
const result = await getCfmtnBgtPage(params(p, EXPORT_SIZE));
rows.push(...result.content);
total = result.totalPages;
p++;
}
downloadCfmtnBgtExcel(rows, search.year);
} catch (e) {
setNotice(e instanceof Error ? e.message : "엑셀을 내려받지 못했습니다.");
} finally {
setLoading(false);
}
};
const register = async () => {
if (!selected.size) return;
if (!confirm(`선택한 ${selected.size}건을 ${bizSe}으로 등록하시겠습니까?`))
return;
setLoading(true);
try {
const r = await registerCfmtnBgt(bizSe, [...selected]);
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.CFMTN_BGT_REGISTERED, {
insertedCount: String(r.insertedCount),
skippedCount: String(r.skippedCount),
});
} catch (e) {
setNotice(e instanceof Error ? e.message : "등록하지 못했습니다.");
} finally {
setLoading(false);
}
};
return (
<div className="min-w-[1300px] p-5 text-sm">
<div className="mb-3 flex flex-wrap items-end gap-3">
<SelectBox
label="환경청"
className=""
selectClass="w-40"
selectArray={[ALL, ...govArea]}
value={search.govArea}
onSelectValue={(v) =>
setSearch((s) => ({ ...s, govArea: String(v) }))
}
/>
<SelectBox
label="시도"
className=""
selectClass="w-40"
selectArray={[...city]}
value={search.sido}
onSelectValue={(v) =>
setSearch((s) => ({ ...s, sido: String(v), gugun: "" }))
}
/>
<SelectBox
label="시군구"
className=""
selectClass="w-40"
selectArray={[ALL, ...sggOptions]}
value={search.gugun}
onSelectValue={(v) => setSearch((s) => ({ ...s, gugun: String(v) }))}
/>
<SelectBox
label="세부사업"
className=""
selectClass="w-48"
selectArray={[ALL, ...untOptions]}
value={search.untBzCd}
onSelectValue={(v) =>
setSearch((s) => ({ ...s, untBzCd: String(v), dtlBzCd: "" }))
}
/>
<SelectBox
label="내역사업"
className=""
selectClass="w-48"
selectArray={[ALL, ...mevOptions]}
value={search.mevBzNm}
onSelectValue={(v) =>
setSearch((s) => ({ ...s, mevBzNm: String(v), dtlBzCd: "" }))
}
/>
<SelectBox
label="세부내역"
className=""
selectClass="w-52"
selectArray={[ALL, ...dtlOptions]}
value={search.dtlBzCd}
onSelectValue={(v) =>
setSearch((s) => ({ ...s, dtlBzCd: String(v) }))
}
/>
<SelectBox
label="년도"
className=""
selectClass="w-24"
selectArray={YEAR_OPTIONS}
value={search.year}
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>
<Button
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)}
>
다음
</Button>
</div>
</div>
);
}
@@ -3,7 +3,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, UiTable } from "@common/business_components/ui";
import {
SearchDiv,
SearchFormLeft,
SearchFormRight,
SelectBox,
UiTable,
} from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { Button, Input, Label } from "@common/components/ui";
@@ -13,12 +19,20 @@ import {
uploadObsltPplExcel,
} from "../api/obslt-ppl-api";
import { formatDate } from "../../common/util";
import { downloadObsltPplExcel, parseObsltPplExcel } from "../lib/obslt-ppl-excel";
import { buildObsltPplRepresentativeSggOptions, normalizeObsltPplSigunName } from "../lib/obslt-ppl-region";
import {
downloadObsltPplExcel,
parseObsltPplExcel,
} from "../lib/obslt-ppl-excel";
import {
buildObsltPplRepresentativeSggOptions,
normalizeObsltPplSigunName,
} from "../lib/obslt-ppl-region";
import type { ObsltPplBusiness, ObsltPplPage } from "../model/obslt-ppl-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { openWindowPopup } from "../../windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types";
const DEFAULT_LIST_SIZE = 10;
const ACTION_BUTTON_CLASS =
@@ -33,7 +47,12 @@ const ALL_REGION_OPTION = { text: "전체", val: "0" };
type ObsltPplTableRow = ObsltPplBusiness & {
rowNumber: number;
};
type ObsltPplSearch = { bizSe: string; ctpvNm: string; sigunNm: string; bizNm: string };
type ObsltPplSearch = {
bizSe: string;
ctpvNm: string;
sigunNm: string;
bizNm: string;
};
/** 조회조건·지역 선택 코드·페이지를 목록 복귀에 사용할 URL query로 만든다. */
const createListQueryString = (
@@ -102,13 +121,18 @@ export function ObsltPplListPage() {
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const fileInputRef = useRef<HTMLInputElement>(null);
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 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);
@@ -117,10 +141,15 @@ export function ObsltPplListPage() {
// 선택박스의 코드와 API 검색에 사용하는 행정구역명을 함께 관리한다.
const [ctpvCd, setCtpvCd] = useState(() => searchParams.get("ctpvCd") ?? "0");
const [sggCd, setSggCd] = useState(() => searchParams.get("sggCd") ?? "0");
const [appliedCtpvCd, setAppliedCtpvCd] = useState(() => searchParams.get("ctpvCd") ?? "0");
const [appliedSggCd, setAppliedSggCd] = useState(() => searchParams.get("sggCd") ?? "0");
const [appliedCtpvCd, setAppliedCtpvCd] = useState(
() => searchParams.get("ctpvCd") ?? "0",
);
const [appliedSggCd, setAppliedSggCd] = useState(
() => searchParams.get("sggCd") ?? "0",
);
const [search, setSearch] = useState<ObsltPplSearch>(initialSearch);
const [appliedSearch, setAppliedSearch] = useState<ObsltPplSearch>(initialSearch);
const [appliedSearch, setAppliedSearch] =
useState<ObsltPplSearch>(initialSearch);
/** 현재 선택한 시도에 속한 시군구만 선택 후보로 제공한다. */
const availableSgg = useMemo(() => {
@@ -134,38 +163,81 @@ export function ObsltPplListPage() {
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 확정 검색조건과 페이지를 서버 요청 파라미터로 변환해 목록을 갱신한다. */
const load = useCallback(async (targetPage = page, targetSearch = appliedSearch) => {
const params = new URLSearchParams({
page: String(Math.max(targetPage - 1, 0)),
size: String(DEFAULT_LIST_SIZE),
sort: "obsltSwagePplBizSn,desc",
});
Object.entries(targetSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
const load = useCallback(
async (targetPage = page, targetSearch = appliedSearch) => {
const params = new URLSearchParams({
page: String(Math.max(targetPage - 1, 0)),
size: String(DEFAULT_LIST_SIZE),
sort: "obsltSwagePplBizSn,desc",
});
Object.entries(targetSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
setLoading(true);
try {
const result = await getObsltPplBusinesses(params);
setData(result);
setRows(result.content.map((row, index) => ({
...formatBusinessForDisplay(row),
rowNumber: result.totalElements - ((targetPage - 1) * DEFAULT_LIST_SIZE + index),
})));
} catch (error) {
setNotice(error instanceof Error ? error.message : "목록을 조회하지 못했습니다.");
setData(EMPTY_PAGE);
setRows([]);
} finally {
setLoading(false);
setResetPageNo(false);
}
}, [appliedSearch, page]);
setLoading(true);
try {
const result = await getObsltPplBusinesses(params);
setData(result);
setRows(
result.content.map((row, index) => ({
...formatBusinessForDisplay(row),
rowNumber:
result.totalElements -
((targetPage - 1) * DEFAULT_LIST_SIZE + index),
})),
);
} catch (error) {
setNotice(
error instanceof Error
? error.message
: "목록을 조회하지 못했습니다.",
);
setData(EMPTY_PAGE);
setRows([]);
} finally {
setLoading(false);
setResetPageNo(false);
}
},
[appliedSearch, page],
);
useEffect(() => {
void load();
}, [load]);
/** 팝업 등록 완료 메시지를 받으면 기존 적용 조회조건을 유지한 채 첫 페이지를 재조회한다. */
useEffect(() => {
const receivePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.CFMTN_BGT_REGISTERED)
return;
const inserted = Number(event.data?.payload?.insertedCount ?? 0);
const skipped = Number(event.data?.payload?.skippedCount ?? 0);
setNotice(
`최종확정예산사업 ${inserted.toLocaleString()}건을 등록했습니다.${skipped ? ` 이미 등록된 ${skipped.toLocaleString()}건은 제외했습니다.` : ""}`,
);
setPage(1);
setResetPageNo(true);
void load(1, appliedSearch);
};
window.addEventListener("message", receivePopupMessage);
return () => window.removeEventListener("message", receivePopupMessage);
}, [appliedSearch, load]);
/** 부모 화면의 필수 사업구분을 전달하여 최종확정예산사업 선택 팝업을 연다. */
const handleOpenCfmtnBgt = () => {
if (search.bizSe !== "정밀조사" && search.bizSe !== "정비사업") {
setNotice("사업구분에서 정밀조사 또는 정비사업을 먼저 선택해 주세요.");
return;
}
const popup = openWindowPopup("cfmtnBgt", { bizSe: search.bizSe });
if (!popup)
setNotice(
"팝업이 차단되었습니다. 브라우저의 팝업 허용 설정을 확인해 주세요.",
);
};
/** 선택한 xlsx를 브라우저에서 검증·파싱한 뒤 JSON으로 교체 업로드한다. */
const handleUpload = async (file?: File) => {
if (!file) return;
@@ -185,7 +257,9 @@ export function ObsltPplListPage() {
setPage(1);
await load(1, appliedSearch);
} catch (error) {
setNotice(error instanceof Error ? error.message : "업로드하지 못했습니다.");
setNotice(
error instanceof Error ? error.message : "업로드하지 못했습니다.",
);
} finally {
setLoading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
@@ -203,7 +277,9 @@ export function ObsltPplListPage() {
const exportRows = await getAllObsltPplBusinesses(params);
downloadObsltPplExcel(exportRows);
} catch (error) {
setNotice(error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.");
setNotice(
error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.",
);
} finally {
setLoading(false);
}
@@ -218,7 +294,9 @@ export function ObsltPplListPage() {
setAppliedSggCd(sggCd);
setResetPageNo(true);
const queryString = createListQueryString(1, nextSearch, ctpvCd, sggCd);
router.replace(`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`, { scroll: false });
router.replace(`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`, {
scroll: false,
});
if (page === 1) {
await load(1, nextSearch);
return;
@@ -241,7 +319,12 @@ export function ObsltPplListPage() {
selectClass="w-[120px] rounded-lg bg-white"
selectArray={BIZ_SE_OPTIONS}
value={search.bizSe}
onSelectValue={(value) => setSearch((current) => ({ ...current, bizSe: String(value) }))}
onSelectValue={(value) =>
setSearch((current) => ({
...current,
bizSe: String(value),
}))
}
/>
</div>
<div className="flex items-center">
@@ -258,7 +341,9 @@ export function ObsltPplListPage() {
setSggCd("0");
setSearch((current) => ({
...current,
ctpvNm: ctpv.find((option) => String(option.val) === nextCtpvCd)?.text ?? "",
ctpvNm:
ctpv.find((option) => String(option.val) === nextCtpvCd)
?.text ?? "",
sigunNm: "",
}));
}}
@@ -275,7 +360,9 @@ export function ObsltPplListPage() {
onSelectValue={(value) => {
const nextSggCd = String(value);
setSggCd(nextSggCd);
const selectedOption = availableSgg.find((option) => String(option.val) === nextSggCd);
const selectedOption = availableSgg.find(
(option) => String(option.val) === nextSggCd,
);
setSearch((current) => ({
...current,
sigunNm: selectedOption?.text ?? "",
@@ -288,7 +375,12 @@ export function ObsltPplListPage() {
<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();
@@ -302,19 +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={() => {
const queryString = createListQueryString(page, appliedSearch, appliedCtpvCd, appliedSggCd);
router.push(`/oper/obsltPplBiz/register${queryString ? `?${queryString}` : ""}`);
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"
@@ -322,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
@@ -337,7 +451,10 @@ 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>
)}
@@ -347,7 +464,9 @@ export function ObsltPplListPage() {
<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>
@@ -360,20 +479,39 @@ export function ObsltPplListPage() {
widthFixed
onPageChange={(nextPage) => {
setPage(nextPage);
const queryString = createListQueryString(nextPage, appliedSearch, appliedCtpvCd, appliedSggCd);
router.replace(`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`, { scroll: false });
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;
const queryString = createListQueryString(page, appliedSearch, appliedCtpvCd, appliedSggCd);
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 formatDate(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: "사업정보 등록",
@@ -31,6 +31,7 @@ export type OpenWindowPopupParams = Record<
>;
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",
@@ -42,8 +43,10 @@ export const WINDOW_POPUP_MESSAGE_TYPE = {
GIS_EDIT_SESSION_SYNC: "sn:popup:gisEditSessionSync",
GIS_EDIT_SESSION_SHOW_INLINE: "sn:popup:gisEditSessionShowInline",
GIS_EDIT_SESSION_FOCUS_CHANGE: "sn:popup:gisEditSessionFocusChange",
GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE: "sn:popup:gisEditSessionRemoveFocusedChange",
GIS_EDIT_SESSION_REMOVE_LAST_CHANGE: "sn:popup:gisEditSessionRemoveLastChange",
GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE:
"sn:popup:gisEditSessionRemoveFocusedChange",
GIS_EDIT_SESSION_REMOVE_LAST_CHANGE:
"sn:popup:gisEditSessionRemoveLastChange",
GIS_EDIT_SESSION_CLEAR_CHANGES: "sn:popup:gisEditSessionClearChanges",
GIS_UPLOAD_COMPLETED: "sn:popup:gisUploadCompleted",
WATER_RISK_READY: "sn:popup:waterRiskReady",
+15 -3
View File
@@ -1,4 +1,7 @@
import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions";
import {
windowPopupDefinitions,
type WindowPopupKey,
} from "./model/popupDefinitions";
import type { WindowPopupRegistryItem } from "./model/types";
import AgedPipePopupContent from "./ui/AgedPipePopupContent";
import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent";
@@ -27,18 +30,27 @@ 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) => (
const FloodPredictionPlaceholderPopupContent = (
_props: WindowPopupContentProps,
) => (
<FeaturePlaceholderPopupContent
title={windowPopupDefinitions.floodPredictionMapPopup.title}
/>
);
const GisEditPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent title={windowPopupDefinitions.gisEditPopup.title} />
<FeaturePlaceholderPopupContent
title={windowPopupDefinitions.gisEditPopup.title}
/>
);
export const windowPopupRegistry = {
cfmtnBgt: {
...windowPopupDefinitions.cfmtnBgt,
component: CfmtnBgtPopupContent,
},
exmnBusinessRegister: {
...windowPopupDefinitions.exmnBusinessRegister,
component: ExmnBusinessRegisterPopupContent,