Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev
This commit is contained in:
@@ -20,9 +20,24 @@ type FieldKey = "bizNo" | "workerNo" | "pw";
|
||||
const REGISTER_HREF = "/apply/dmdco";
|
||||
|
||||
function isBizNoValid(v: string) {
|
||||
return /^\d{10}$/.test(v);
|
||||
return /^\d{10}$/.test(v.replace(/\D/g, ""));
|
||||
}
|
||||
|
||||
const getBusinessNumberDigits = (value: string) =>
|
||||
value.replace(/\D/g, "").slice(0, 10);
|
||||
|
||||
const formatBusinessNumber = (value: string) => {
|
||||
const normalized = value.replace(/[^\d-]/g, "").slice(0, 12);
|
||||
const digits = getBusinessNumberDigits(normalized);
|
||||
|
||||
if (normalized.endsWith("-") && digits.length === 3) return `${digits}-`;
|
||||
if (normalized.endsWith("-") && digits.length === 5)
|
||||
return `${digits.slice(0, 3)}-${digits.slice(3)}-`;
|
||||
if (digits.length <= 3) return digits;
|
||||
if (digits.length <= 5) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
||||
return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5)}`;
|
||||
};
|
||||
|
||||
function inputCls(opts: { hasError?: boolean; disabled?: boolean }) {
|
||||
const base =
|
||||
"h-[48px] w-full rounded-[4px] border px-5 text-[18px] outline-none";
|
||||
@@ -102,7 +117,7 @@ export default function LoginModal({
|
||||
const validate = () => {
|
||||
const next: Partial<Record<FieldKey, string>> = {};
|
||||
|
||||
const b = bizNo.trim();
|
||||
const b = getBusinessNumberDigits(bizNo);
|
||||
const w = workerNo.trim();
|
||||
const p = pw.trim();
|
||||
|
||||
@@ -122,23 +137,6 @@ export default function LoginModal({
|
||||
return next;
|
||||
};
|
||||
|
||||
const formatBusinessNumber = (value: string) => {
|
||||
// 숫자만 추출
|
||||
const digitsOnly = value.replace(/\D/g, "");
|
||||
|
||||
// 10자리까지만 처리
|
||||
const limitedDigits = digitsOnly.slice(0, 10);
|
||||
|
||||
// 3-2-5 형식으로 포맷팅
|
||||
if (limitedDigits.length <= 3) {
|
||||
return limitedDigits;
|
||||
} else if (limitedDigits.length <= 5) {
|
||||
return `${limitedDigits.slice(0, 3)}-${limitedDigits.slice(3)}`;
|
||||
} else {
|
||||
return `${limitedDigits.slice(0, 3)}-${limitedDigits.slice(3, 5)}-${limitedDigits.slice(5)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const markTouched = (key: FieldKey) =>
|
||||
setTouched((p) => ({ ...p, [key]: true }));
|
||||
|
||||
@@ -154,8 +152,9 @@ export default function LoginModal({
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const businessNumber = getBusinessNumberDigits(bizNo);
|
||||
const param = {
|
||||
dmdInfoId: bizNo + (noWorker ? "0000" : workerNo), // 종사업자번호 해당없음 "0000"으로 채움
|
||||
dmdInfoId: businessNumber + (noWorker ? "0000" : workerNo), // 종사업자번호 해당없음 "0000"으로 채움
|
||||
dmdAplyPswd: sha512.hex(pw),
|
||||
};
|
||||
await actionLogin(param as DemandCompType);
|
||||
@@ -227,9 +226,9 @@ export default function LoginModal({
|
||||
사업자 등록번호
|
||||
</label>
|
||||
<input
|
||||
value={formatBusinessNumber(bizNo)}
|
||||
value={bizNo}
|
||||
onChange={(e) => {
|
||||
setBizNo(e.target.value.replace(/\D/g, "").slice(0, 10));
|
||||
setBizNo(formatBusinessNumber(e.target.value));
|
||||
setErrors((p) => ({ ...p, bizNo: undefined }));
|
||||
}}
|
||||
onBlur={() => {
|
||||
|
||||
+2
-1
@@ -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"
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export const getData = async (url: string): Promise<any> => {
|
||||
method: "GET",
|
||||
headers: withSiteIdHeaders(),
|
||||
});
|
||||
console.log(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorizedResponse();
|
||||
}
|
||||
|
||||
@@ -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.gugun,
|
||||
item.sggNm,
|
||||
item.sggName,
|
||||
item.lclgvrnmntNm,
|
||||
item.sido,
|
||||
item.cityNm,
|
||||
item.cityName,
|
||||
item.korNm,
|
||||
].find((value) => typeof value === "string" && value.trim());
|
||||
|
||||
const valueCandidate = [
|
||||
item.val,
|
||||
item.code,
|
||||
item.id,
|
||||
item.govAreaCd,
|
||||
item.govAreaCode,
|
||||
item.adstDstrCd,
|
||||
item.sggCd,
|
||||
item.sggCode,
|
||||
item.lclgvrnmntCd,
|
||||
item.cityCd,
|
||||
item.cityCode,
|
||||
].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;
|
||||
@@ -14,6 +14,8 @@
|
||||
4. 엑셀다운로드 후 두 시트 및 재업로드 확인
|
||||
5. 등록 저장, 행 클릭 수정, 수정 화면 삭제 확인
|
||||
6. 목록에 삭제 버튼이 없고 수정 하단 버튼 순서가 `목록`, `저장`, `삭제`인지 확인
|
||||
7. 등록·수정 화면에서 `시도,시군구 선택`에 필수 표시가 노출되고, 시도 미선택 저장 시 `시도는 필수입니다.`가 표시되는지 확인
|
||||
8. 시도만 선택하고 시군구를 선택하지 않아도 프론트 검증을 통과해 저장 요청이 전송되는지 확인
|
||||
|
||||
## 선행조건
|
||||
|
||||
|
||||
@@ -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,18 @@
|
||||
- `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는 삭제하지 않았다.
|
||||
|
||||
## 2026-08-07 등록·수정 화면 지역 필수 표시
|
||||
|
||||
- 지역 입력 행 제목을 `시군구 선택`에서 `시도,시군구 선택`으로 변경하고 필수 표시를 추가했다.
|
||||
- 저장 전 시도 선택 여부를 검증하며, 미선택 시 `시도는 필수입니다.`를 표시한다.
|
||||
- 시군구는 선택사항으로 유지하여 시도만 선택한 자료도 저장 요청할 수 있다.
|
||||
|
||||
@@ -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,135 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import type { CfmtnBgtRow } from "../model/cfmtn-bgt-types";
|
||||
|
||||
/** 화면 목록과 동일한 Excel 상위 다단 헤더다. */
|
||||
const TOP_HEADERS = [
|
||||
"순번",
|
||||
"환경청",
|
||||
"행정구역",
|
||||
"",
|
||||
"사업코드",
|
||||
"사업명",
|
||||
"사업구분",
|
||||
"세부사업",
|
||||
"내역사업",
|
||||
"세부내역",
|
||||
"사업진행상태",
|
||||
"신규/계속",
|
||||
"착수",
|
||||
"준공",
|
||||
"사업규모",
|
||||
"",
|
||||
"보조율\n(%)",
|
||||
"총사업비",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"전년까지사업비",
|
||||
"년차 사업비",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
/** 그룹별 하위 항목을 표시하는 Excel 두 번째 헤더다. */
|
||||
const SUB_HEADERS = [
|
||||
"",
|
||||
"",
|
||||
"시도",
|
||||
"시군구",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"하수처리장",
|
||||
"하수관로",
|
||||
"",
|
||||
"합계",
|
||||
"국고",
|
||||
"지방비",
|
||||
"원인자부담금",
|
||||
"환특융자",
|
||||
"기타",
|
||||
"",
|
||||
"합계",
|
||||
"국고",
|
||||
"지방비",
|
||||
"원인자부담금",
|
||||
"환특융자",
|
||||
"기타",
|
||||
];
|
||||
const MONEY_COLUMN_INDEXES = [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29];
|
||||
/** 조회 행을 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 } },
|
||||
];
|
||||
rows.forEach((_, rowIndex) => {
|
||||
MONEY_COLUMN_INDEXES.forEach((columnIndex) => {
|
||||
const cellAddress = XLSX.utils.encode_cell({
|
||||
r: rowIndex + 2,
|
||||
c: columnIndex,
|
||||
});
|
||||
const cell = ws[cellAddress];
|
||||
if (cell && typeof cell.v === "number") cell.z = "#,##0";
|
||||
});
|
||||
});
|
||||
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,568 @@
|
||||
/**
|
||||
* 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" };
|
||||
const withAllOption = (items: { text: string; val: string }[]) => [
|
||||
ALL,
|
||||
...items.filter((item) => String(item.val) !== ALL.val),
|
||||
];
|
||||
/** 화면 목록과 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 [];
|
||||
const thisYear = new Date().getFullYear();
|
||||
return Array.from({ length: 6 }, (_, index) => {
|
||||
const year = String(thisYear - 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 && search.gugun !== "0")
|
||||
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={withAllOption(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={withAllOption(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={withAllOption(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={withAllOption(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={withAllOption(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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,22 @@
|
||||
"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 {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@common/components/ui";
|
||||
|
||||
import { formatDate } from "../../common/util";
|
||||
import {
|
||||
createObsltPplBusiness,
|
||||
deleteObsltPplBusiness,
|
||||
@@ -16,42 +27,100 @@ import {
|
||||
buildObsltPplRepresentativeSggOptions,
|
||||
findObsltPplCtpvCodeByName,
|
||||
findObsltPplRepresentativeSggCode,
|
||||
isValidObsltPplEnvOfficeName,
|
||||
normalizeObsltPplSigunName,
|
||||
OBSLT_PPL_ENV_OFFICES,
|
||||
} from "../lib/obslt-ppl-region";
|
||||
import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
|
||||
import type {
|
||||
ObsltPplBusiness,
|
||||
ObsltPplBusinessSaveRequest,
|
||||
} from "../model/obslt-ppl-types";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
type FormKey = keyof ObsltPplBusinessSaveRequest;
|
||||
type FormState = Record<FormKey, string>;
|
||||
type FieldErrorKey = FormKey | "region";
|
||||
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: "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: "bizPicNm", label: "담당자 이름" }, { key: "bizPicCnpl", label: "담당자 연락처" },
|
||||
{ key: "rmrkCn", label: "비고", type: "textarea" }, { key: "pbcpOpnnCn", label: "공단의견", type: "textarea" },
|
||||
const BIZ_SE_OPTIONS = [
|
||||
{ text: "정밀조사", val: "정밀조사" },
|
||||
{ text: "정비사업", val: "정비사업" },
|
||||
];
|
||||
|
||||
const NUMBER_KEYS = new Set<FormKey>(["bizSeq", "planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]);
|
||||
/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */
|
||||
const FIELDS: Array<{
|
||||
key: FormKey;
|
||||
label: string;
|
||||
type?: "number" | "textarea";
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}> = [
|
||||
{ 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: "YYYY-MM-DD" },
|
||||
{ key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYY-MM-DD" },
|
||||
{ key: "bizPrplDlyRsnCn", label: "사업추진 부진사유" },
|
||||
{ key: "bizPicNm", label: "담당자 이름" },
|
||||
{ key: "bizPicCnpl", label: "담당자 연락처" },
|
||||
{ key: "rmrkCn", label: "비고", },
|
||||
{ key: "pbcpOpnnCn", label: "공단의견", },
|
||||
];
|
||||
|
||||
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 normalized = value.replace(/[^\d-]/g, "").slice(0, 10);
|
||||
const digits = value.replace(/\D/g, "").slice(0, 8);
|
||||
if (normalized.endsWith("-") && digits.length === 4) return `${digits}-`;
|
||||
if (normalized.endsWith("-") && digits.length === 6)
|
||||
return `${digits.slice(0, 4)}-${digits.slice(4)}-`;
|
||||
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(
|
||||
[...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => [key, key === "bizSe" ? "정밀조사" : ""]),
|
||||
) as FormState;
|
||||
const createEmptyForm = (): FormState =>
|
||||
Object.fromEntries(
|
||||
[...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => [
|
||||
key,
|
||||
key === "bizSe" ? "정밀조사" : "",
|
||||
]),
|
||||
) as FormState;
|
||||
|
||||
/** 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);
|
||||
@@ -59,27 +128,44 @@ const toForm = (business: ObsltPplBusiness): FormState => {
|
||||
};
|
||||
|
||||
/** 숫자 입력의 빈 문자열은 null로, 나머지는 trim 문자열로 저장 요청을 만든다. */
|
||||
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)];
|
||||
}),
|
||||
) as unknown as ObsltPplBusinessSaveRequest;
|
||||
const toRequest = (form: FormState): ObsltPplBusinessSaveRequest =>
|
||||
Object.fromEntries(
|
||||
[...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => {
|
||||
const value = form[key].trim();
|
||||
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);
|
||||
const [loading, setLoading] = useState(isModify);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [fieldErrors, setFieldErrors] = useState<
|
||||
Partial<Record<FieldErrorKey, string>>
|
||||
>({});
|
||||
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 ctpvOptions = useMemo(
|
||||
() => [{ text: "전체", val: "0", hidden: true }, ...ctpv],
|
||||
[ctpv],
|
||||
);
|
||||
const representativeSggOptions = useMemo(
|
||||
() => [{ text: "전체", val: "0", hidden: true }, ...buildObsltPplRepresentativeSggOptions(sgg, ctpvCd)],
|
||||
() => [
|
||||
{ text: "전체", val: "0", hidden: true },
|
||||
...buildObsltPplRepresentativeSggOptions(sgg, ctpvCd),
|
||||
],
|
||||
[ctpvCd, sgg],
|
||||
);
|
||||
|
||||
@@ -93,7 +179,12 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
|
||||
const resolvedCtpvCd = findObsltPplCtpvCodeByName(ctpv, form.ctpvNm);
|
||||
const nextCtpvCd = resolvedCtpvCd !== "0" ? resolvedCtpvCd : ctpvCd;
|
||||
const nextSggCd = findObsltPplRepresentativeSggCode(sgg, nextCtpvCd, form.sigunNm, form.sggCd);
|
||||
const nextSggCd = findObsltPplRepresentativeSggCode(
|
||||
sgg,
|
||||
nextCtpvCd,
|
||||
form.sigunNm,
|
||||
form.sggCd,
|
||||
);
|
||||
|
||||
if (nextCtpvCd !== ctpvCd) setCtpvCd(nextCtpvCd);
|
||||
if (nextSggCd !== sggCd) setSggCd(nextSggCd);
|
||||
@@ -102,61 +193,123 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getObsltPplBusiness(id).then((data) => setForm(toForm(data)))
|
||||
.catch((error) => setNotice(error instanceof Error ? error.message : "상세정보를 조회하지 못했습니다."))
|
||||
getObsltPplBusiness(id)
|
||||
.then((data) => setForm(toForm(data)))
|
||||
.catch((error) =>
|
||||
setNotice(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "상세정보를 조회하지 못했습니다.",
|
||||
),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const clearFieldError = (key: FieldErrorKey) => {
|
||||
setFieldErrors((current) => {
|
||||
if (!current[key]) return current;
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/** 필수값과 숫자 형식을 화면에서 먼저 확인한 뒤 등록 또는 수정 API를 호출한다. */
|
||||
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; }
|
||||
const nextErrors: Partial<Record<FieldErrorKey, string>> = {};
|
||||
if (!form.bizSe) nextErrors.bizSe = "사업구분은 필수입니다.";
|
||||
if (!form.bizCd.trim()) nextErrors.bizCd = "사업코드는 필수입니다.";
|
||||
if (!form.bizNm.trim()) nextErrors.bizNm = "사업명은 필수입니다.";
|
||||
if (!form.ctpvNm.trim() || ctpvCd === "0")
|
||||
nextErrors.region = "시도는 필수입니다.";
|
||||
[...NUMBER_KEYS].forEach((key) => {
|
||||
if (form[key] && !Number.isFinite(Number(form[key]))) {
|
||||
nextErrors[key] = "숫자로 입력해주세요.";
|
||||
}
|
||||
});
|
||||
[...DATE_KEYS].forEach((key) => {
|
||||
if (!isValidDateInput(form[key])) {
|
||||
nextErrors[key] = "YYYY-MM-DD 형식의 유효한 날짜로 입력해주세요.";
|
||||
}
|
||||
});
|
||||
if (!isValidObsltPplEnvOfficeName(form.envoNm)) {
|
||||
nextErrors.envoNm = `${OBSLT_PPL_ENV_OFFICES.map((office) => office.name).join(", ")} 중 하나여야 합니다.`;
|
||||
}
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setFieldErrors(nextErrors);
|
||||
setNotice("");
|
||||
return;
|
||||
}
|
||||
setFieldErrors({});
|
||||
setLoading(true);
|
||||
try {
|
||||
if (id) await updateObsltPplBusiness(id, toRequest(form));
|
||||
else await createObsltPplBusiness(toRequest(form));
|
||||
window.alert(isModify ? "수정되었습니다." : "등록되었습니다.");
|
||||
router.push("/oper/obsltPplBiz");
|
||||
} catch (error) { setNotice(error instanceof Error ? error.message : "저장하지 못했습니다."); }
|
||||
finally { setLoading(false); }
|
||||
router.push(listHref);
|
||||
} catch (error) {
|
||||
setNotice(
|
||||
error instanceof Error ? error.message : "저장하지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 사용자 확인 후 현재 사업을 논리삭제하고 목록으로 이동한다. */
|
||||
const handleDelete = async () => {
|
||||
if (!id || !window.confirm("삭제하시겠습니까?")) return;
|
||||
setLoading(true);
|
||||
try { await deleteObsltPplBusiness(id); window.alert("삭제되었습니다."); router.push("/oper/obsltPplBiz"); }
|
||||
catch (error) { setNotice(error instanceof Error ? error.message : "삭제하지 못했습니다."); }
|
||||
finally { setLoading(false); }
|
||||
try {
|
||||
await deleteObsltPplBusiness(id);
|
||||
window.alert("삭제되었습니다.");
|
||||
router.push(listHref);
|
||||
} catch (error) {
|
||||
setNotice(
|
||||
error instanceof Error ? error.message : "삭제하지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card card-border !mt-6">
|
||||
<LoadingOverlay isLoading={loading} />
|
||||
<div className="card-header"><h3 className="text-2xl font-medium text-label">노후하수관로 사업 {isModify ? "수정" : "등록"}</h3></div>
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
노후하수관로 사업 {isModify ? "수정" : "등록"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card">
|
||||
<Table variant="secondary">
|
||||
<colgroup><col width="18%" /><col width="82%" /></colgroup>
|
||||
<colgroup>
|
||||
<col width="18%" />
|
||||
<col width="82%" />
|
||||
</colgroup>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead data-required>시군구 선택</TableHead>
|
||||
<TableHead data-required>시도,시군구 선택</TableHead>
|
||||
<TableCell className="border">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SelectBox
|
||||
label="시도"
|
||||
className=""
|
||||
labelClass="w-16 whitespace-nowrap text-[1.3rem] text-gray-600"
|
||||
selectClass="w-[180px] rounded-lg bg-white"
|
||||
selectClass="h-11 w-[180px] rounded-lg bg-white"
|
||||
selectArray={ctpvOptions}
|
||||
value={ctpvCd}
|
||||
onSelectValue={(value) => {
|
||||
const nextCtpvCd = String(value);
|
||||
clearFieldError("region");
|
||||
setCtpvCd(nextCtpvCd);
|
||||
setSggCd("0");
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
ctpvNm: ctpv.find((option) => String(option.val) === nextCtpvCd)?.text ?? "",
|
||||
ctpvNm:
|
||||
ctpv.find(
|
||||
(option) => String(option.val) === nextCtpvCd,
|
||||
)?.text ?? "",
|
||||
sigunNm: "",
|
||||
sggCd: "",
|
||||
}));
|
||||
@@ -166,12 +319,15 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
label="시군구"
|
||||
className=""
|
||||
labelClass="w-20 whitespace-nowrap text-[1.3rem] text-gray-600"
|
||||
selectClass="w-[220px] rounded-lg bg-white"
|
||||
selectClass="h-11 w-[220px] rounded-lg bg-white"
|
||||
selectArray={representativeSggOptions}
|
||||
value={sggCd}
|
||||
onSelectValue={(value) => {
|
||||
const nextSggCd = String(value);
|
||||
const selectedOption = representativeSggOptions.find((option) => String(option.val) === nextSggCd);
|
||||
const selectedOption = representativeSggOptions.find(
|
||||
(option) => String(option.val) === nextSggCd,
|
||||
);
|
||||
clearFieldError("region");
|
||||
setSggCd(nextSggCd);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
@@ -181,29 +337,94 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{fieldErrors.region && (
|
||||
<p className="mt-1 text-[1.2rem] text-red-600" role="alert">
|
||||
{fieldErrors.region}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{FIELDS.map((field) => (
|
||||
<TableRow key={field.key}>
|
||||
<TableHead data-required={field.required || undefined}>{field.label}</TableHead>
|
||||
<TableHead data-required={field.required || undefined}>
|
||||
{field.label}
|
||||
</TableHead>
|
||||
<TableCell className="border">
|
||||
{field.key === "bizSe" ? (
|
||||
<select className="h-11 w-full rounded border bg-white px-3 text-[1.3rem]" value={form.bizSe} onChange={(e) => setForm({ ...form, bizSe: e.target.value })}><option value="정밀조사">정밀조사</option><option value="정비사업">정비사업</option></select>
|
||||
<SelectBox
|
||||
className=""
|
||||
selectClass="h-11 rounded-lg bg-white"
|
||||
selectArray={BIZ_SE_OPTIONS}
|
||||
value={form.bizSe}
|
||||
onSelectValue={(value) => {
|
||||
clearFieldError("bizSe");
|
||||
setForm({ ...form, bizSe: String(value) });
|
||||
}}
|
||||
/>
|
||||
) : 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 })} />
|
||||
<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) => {
|
||||
clearFieldError(field.key);
|
||||
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) => {
|
||||
clearFieldError(field.key);
|
||||
setForm({
|
||||
...form,
|
||||
[field.key]: DATE_KEYS.has(field.key)
|
||||
? formatDateInput(e.target.value)
|
||||
: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{fieldErrors[field.key] && (
|
||||
<p className="mt-1 text-[1.2rem] text-red-600" role="alert">
|
||||
{fieldErrors[field.key]}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{notice && <p className="mt-3 text-sm text-red-600" role="alert">{notice}</p>}
|
||||
{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" 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>}
|
||||
<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>
|
||||
</div>
|
||||
</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",
|
||||
|
||||
@@ -36,6 +36,7 @@ import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent";
|
||||
import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent";
|
||||
import RepairHistoryPopupContent from "./ui/RepairHistoryPopupContent";
|
||||
import WaterRiskPopupContent from "./ui/WaterRiskPopupContent";
|
||||
import CfmtnBgtPopupContent from "../cfmtnBgt/ui/CfmtnBgtPopupContent";
|
||||
|
||||
const FloodPredictionPlaceholderPopupContent = (
|
||||
_props: WindowPopupContentProps,
|
||||
@@ -52,6 +53,10 @@ const GisEditPopupContent = (_props: WindowPopupContentProps) => (
|
||||
);
|
||||
|
||||
export const windowPopupRegistry = {
|
||||
cfmtnBgt: {
|
||||
...windowPopupDefinitions.cfmtnBgt,
|
||||
component: CfmtnBgtPopupContent,
|
||||
},
|
||||
exmnBusinessRegister: {
|
||||
...windowPopupDefinitions.exmnBusinessRegister,
|
||||
component: ExmnBusinessRegisterPopupContent,
|
||||
|
||||
Reference in New Issue
Block a user