조사사업 수정 및 조사양식 다운로드 연동

This commit is contained in:
kiminchan
2026-07-28 13:28:15 +09:00
parent 155a727a77
commit 62c41297c4
8 changed files with 386 additions and 73 deletions
@@ -243,3 +243,24 @@
- 공통 행정구역 컴포넌트의 `compact` 기본값이 `false`여서 기존 호출 화면에는 새 폭이 자동 적용되지 않는다.
- 변경 파일을 포함한 프론트 저장소 `git diff --check`를 통과했다.
- 로그인되지 않은 브라우저에서는 사업등록 경로가 로그인 화면으로 이동해 최종 팝업 시각 확인을 수행하지 못했다. 사용자 화면 확인을 최종 완료 조건으로 유지한다.
## 17. 2026-07-28 조사사업 실제 API·양식 다운로드 검증 결과
| 검증 항목 | 결과 | 비고 |
| --- | --- | --- |
| 페이지 최초 조사사업 조회 | 통과 | 사용자 로그인 화면 확인 |
| 조사사업 등록 후 목록 갱신 | 통과 | 저장 결과 즉시 표시 |
| 등록 후 상세현황 자동 열림 방지 | 통과 | 목록만 갱신 |
| 기존 사업 수정 팝업 정보 복원 | 통과 | 기본정보·처리분구 복원 |
| 정밀조사연장 숫자 입력 | 통과 | 입력·저장 확인 |
| 처리분구별 조사 입력양식 다운로드 | 통과 | 다중 시트 생성 확인 |
| 본단위등급표 동적 관로번호 | 통과 | `#REF!` 없이 표시 |
| 첫 처리분구만 예시 데이터 유지 | 통과 | 이후 시트는 빈 입력 양식 |
| 이후 관로조사 글꼴 검정색 | 통과 | 사용자 최종 확인 |
| 결과 미등록 상세현황 | 통과 | 오류 배너 없이 조회 결과 없음 표시 |
| 조사보고서 양식 최종 클릭 | 확인 필요 | API와 프론트 호출 구현 완료 |
### 남은 검증
- 다음 파일 업로드 단계에서 생성 양식 업로드와 Excel 파서 호환성을 확인한다.
- 지도보기는 기존 보류 범위를 유지한다.
@@ -189,7 +189,7 @@
- Radix Select의 서버·클라이언트 ID 불일치를 막기 위해 사업등록·결과등록 WindowPopup content를 client mount 이후 렌더링한다.
- 개발 환경의 목록 헤더에 `샘플 데이터 보기`를 제공한다. 이 버튼은 URL에 `sample=Y`를 명시해 기존 격리 원칙을 유지하며, 사업목록과 처리분구 샘플을 함께 활성화한다.
- 사용자 재확인에 따라 팝업의 `샘플 화면` 배지를 제거하고 처리분구 목록을 고정 높이 `21rem`으로 확장했다. 3열 7행으로 21개까지 한 번에 표시하고 초과 후보는 목록 내부 세로 스크롤로 확인한다.
- 정밀조사연장은 브라우저 증감 스피너가 없는 `text`+`inputMode=decimal` 입력으로 변경하고 숫자·소수점만 허용한다. 최대 10자, 입력 폭 `9rem`으로 첫 줄 안에서 표시한다.
- 정밀조사연장은 브라우저 증감 스피너가 없는 `text`+`inputMode=decimal` 입력으로 유지한다. 일반 등록 초기값은 빈 값이며 입력 중 문자를 강제 차단하지 않고 저장 시 숫자·0 이상 여부를 검증한다. 최대 10자로 첫 줄 안에서 표시한다.
- 결과등록 popup definition은 Chrome 외곽 프레임을 포함한 사용자 목표 크기 약 `980×410`에 맞춰 콘텐츠 창 설정을 `980×340`으로 확정하고 샘플 배지와 닫기 버튼을 제거했다.
- 결과등록 상단은 `사업차수 → 사업년도 → 행정구역 → 사업명` 순서로 한 줄 표시한다. 짧은 항목은 좁고 사업명은 넓은 `0.7fr/0.8fr/1.2fr/2.3fr` 비율과 `gap-8` 간격을 사용한다.
- 파일 5종 업로드 영역은 강한 셀 격자 대신 기존 화면처럼 연한 외곽선·헤더 배경·가로 행 구분선만 사용하는 4열 폼 목록으로 구성한다.
@@ -315,6 +315,7 @@
- `exmnSurveyDetail` 팝업 키와 `1500×720` definition을 추가하고 선택 사업 ID·조사유형을 필수 query로 전달한다.
- `ExmnSurveyDetailPopupContent`가 query를 `ExmnBusiness`, `PPL|MNHL`, `sample=Y` 화면 속성으로 변환하고 `ExmnSurveyDetailPopupPage`를 렌더링한다.
- `exmn-detail-table.ts`에 관로 24개, 맨홀 23개 이상항목 코드·명칭·구조/운영 구분을 화면 열 정의로 분리했다.
- 맨홀 코드 중 표면손상-외부와 파손-외부는 참조 결함코드 및 JSONB 키와 동일하게 각각 `Som`, `Bom`으로 확정했다. 코드 비교는 대소문자를 구분한다.
- 관로·맨홀 모두 보고서번호, 관로/맨홀번호, 상·하류맨홀, 배수방식, 관종, 규격, 연장, 주행거리를 공통 고정 정보 열로 사용한다.
- 사용자 제공 엑셀 구조에 맞춰 이상항목마다 `대·중·소` 3열을 만들고 그룹·항목·심각도의 다단 병합 헤더를 직접 구성했다.
- 보고서번호부터 주행거리까지는 `오름차순 → 내림차순 → 기본`으로 정렬한다. 보고서번호와 관로/맨홀번호는 가로 스크롤 고정 열이다.
@@ -421,3 +422,76 @@
- `src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx`
- `src/features/windowPopup/model/popupDefinitions.ts`
- 백엔드 API 경로·필드명·DB 구조는 변경하지 않는다.
## 15. 2026-07-27 사업등록 통합 확인 보완
- 사업등록 완료 메시지를 받은 부모 목록은 신규 사업 ID를 자동 선택하지 않고 선택 상태를 초기화한다.
- 저장된 신규 사업은 목록 재조회 결과에 표시하되 조사 상세현황은 사용자가 행을 직접 선택하기 전까지 열지 않는다.
- 정밀조사연장 일반 등록 초기값을 `0`에서 빈 값으로 변경하고 저장 시 필수·숫자·0 이상 검증을 수행한다.
- `양식 받기`는 저장된 사업 ID와 처리분구 ID 목록으로 양식 생성 API를 호출한 후 XLSX 다운로드 API를 이어서 호출한다.
- TypeScript 검사는 기존 설치 모듈 접근 `EPERM`으로 실행 결과를 확보하지 못했으므로 사용자 개발 서버에서 입력·저장 후 목록 선택 상태를 최종 확인한다.
## 16. 2026-07-27 최초 목록 조회·기존 사업 수정·보고서 양식
### 16.1 최초 조회와 수정 진입
- 조사관리 최초 진입 시 `sample=Y`가 아니면 빈 검색조건과 기존 목록 스크롤 크기로 조사사업 API를 한 번 자동 호출한다.
- React 개발 모드의 effect 재실행에서도 중복 최초 조회가 발생하지 않도록 실행 여부를 별도로 관리한다.
- 조회 버튼 옆에 `수정` 버튼을 추가했으며 사업 행을 선택하지 않으면 안내 메시지를 표시한다.
- 선택 사업이 있으면 기존 `exmnBusinessRegister` WindowPopup에 사업 ID를 전달해 같은 화면을 수정 모드로 사용한다.
### 16.2 수정 팝업
- 사업 상세 API로 기존 사업차수·연도·행정구역·사업명·정밀조사연장과 활성 처리분구를 복원한다.
- 행정구역 공통코드와 처리분구 후보 조회가 늦게 끝나도 기존 선택 ID가 유지되도록 복원 순서를 보호한다.
- React 개발 모드의 effect 재실행을 단순 1회 실행 플래그로 막지 않고 요청 순서 ID로 보호한다. 정리된 첫 요청은 무시하되 두 번째 정상 요청은 실행하고 로딩 상태를 반드시 해제한다.
- 수정 저장은 기존 저장 요청 계약으로 `PUT /api/v1/exmnBusinesses/{exmnBizId}`를 호출한다.
- 저장 완료 메시지를 받은 부모 화면은 현재 검색조건으로 목록을 다시 조회하고 선택 사업을 가능한 경우 유지한다.
- 저장하지 않은 변경사항이 있으면 이전 처리분구 기준의 조사 입력양식을 받지 못하도록 저장 안내를 표시한다.
- 저장 완료 후에는 수정된 처리분구 기준으로 `양식 받기`를 다시 실행할 수 있다.
### 16.3 조사보고서 양식
- 기존 안내 메시지 대신 `GET /api/v1/exmnBusinesses/templates/report/download`를 호출한다.
- 내려받은 원본 XLSX를 `하수관로맨홀_현황조사_보고서_양식.xlsx` 이름으로 저장한다.
- 보고서 양식은 사업별 동적 생성 대상이 아니므로 신규 등록 전과 기존 사업 수정 중에도 받을 수 있다.
### 16.4 검증과 영향
- 프론트 변경 파일 `git diff --check`는 통과했다.
- TypeScript 검사는 기존 pnpm 모듈의 compiler 파일 접근 `EPERM`으로 실행 결과를 확보하지 못했다.
- 공통 WindowPopup 크기·스타일과 조사 상세현황은 변경하지 않았다.
- 실제 서버 재기동 후 최초 목록, 행 선택·수정값 복원, 수정 저장, 입력양식 재다운로드, 조사보고서 다운로드를 통합 확인해야 한다.
### 16.5 조사결과 미등록 상세현황
- 조사 상세현황 `/sections` 요청의 404는 아직 상세 API가 없거나 선택 사업에 등록된 조사결과가 없는 정상 빈 상태로 정규화한다.
- 빈 상태에서는 오류 배너를 표시하지 않고 관로·맨홀 종합집계표 본문에 `조회된 결과가 없습니다.`만 표시한다.
- 500, 인증 오류, 통신 오류 등 404가 아닌 실패는 기존 오류 배너로 유지해 실제 장애가 숨겨지지 않게 한다.
## 17. 2026-07-28 조사사업 실제 API·양식 다운로드 연동 확인
### 17.1 목록·등록·수정
- 조사관리 페이지 진입 시 별도 조회 버튼 조작 없이 조사사업 목록을 요청한다.
- 선택한 사업을 `수정` 버튼으로 열면 서버 상세정보의 사업차수·연도·행정구역·사업명·정밀조사연장·처리분구 선택을 복원한다.
- 등록·수정 완료 후 현재 검색 조건으로 목록을 다시 조회한다.
- 신규 등록 직후 새 행을 자동 선택하지 않아 조사결과가 없는 상세현황이 임의로 열리지 않게 한다.
- 정밀조사연장은 문자열 입력을 숫자로 정규화해 소수 입력과 저장이 가능하도록 보완했다.
### 17.2 양식 다운로드
- `양식 받기`는 현재 팝업의 선택 처리분구를 전달해 서버가 생성한 XLSX를 다운로드한다.
- 사용자는 다운로드한 파일에서 처리분구별 관로·맨홀 시트, 본단위등급표 동적 관로번호, 첫 처리분구 예시 데이터, 이후 시트 검정 글꼴을 확인했다.
- `조사보고서 양식`은 공통 보고서 템플릿 다운로드 API를 호출한다.
### 17.3 빈 상세현황
- 결과가 등록되지 않은 사업의 관로·맨홀 `/sections` 404는 정상 빈 결과로 처리한다.
- 오류 배너의 `No message available`을 제거하고 집계표에 `조회된 결과가 없습니다.`만 표시한다.
- 인증·통신·서버 500 오류는 기존 오류 처리 대상으로 유지한다.
### 17.4 영향 범위
- 조사사업 목록, 사업등록/수정 WindowPopup, 조사 입력·보고서 양식 다운로드, 상세현황 빈 상태에 영향을 준다.
- 조사 상세보기 표와 지도보기 보류 범위는 변경하지 않는다.
@@ -65,12 +65,18 @@ export const getExmnSectionSummaries = async (
jobType: ExmnJobType,
): Promise<ExmnSectionSummaryRow[]> => {
type SectionSummaryPayload = ExmnSectionSummaryRow[] | Partial<ExmnPage<ExmnSectionSummaryRow>>;
const response = await getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/sections?jobType=${jobType}`,
) as SectionSummaryPayload | { data?: SectionSummaryPayload };
const payload = unwrapData<SectionSummaryPayload>(response);
if (Array.isArray(payload)) return payload;
return Array.isArray(payload?.content) ? payload.content : [];
try {
const response = await getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/sections?jobType=${jobType}`,
) as SectionSummaryPayload | { data?: SectionSummaryPayload };
const payload = unwrapData<SectionSummaryPayload>(response);
if (Array.isArray(payload)) return payload;
return Array.isArray(payload?.content) ? payload.content : [];
} catch (error) {
// 상세현황 API가 아직 없거나 선택 사업에 조사결과가 없는 404는 정상 빈 목록으로 표시한다.
if ((error as { status?: number } | null)?.status === 404) return [];
throw error;
}
};
/** 선택한 조사사업의 처리분구·첨부를 포함한 상세정보를 조회한다. */
@@ -107,6 +113,10 @@ export const createExmnTemplates = async (
export const downloadExmnTemplates = async (exmnBizId: string): Promise<Blob> =>
getBlobData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates/download`);
/** 하수관로·맨홀 현황조사 보고서 기준 XLSX를 내려받는다. */
export const downloadExmnReportTemplate = async (): Promise<Blob> =>
getBlobData(`${EXMN_BUSINESS_API_PATH}/templates/report/download`);
/** 사업보고서 첨부파일 ID로 기존 첨부서비스의 원본 파일을 내려받는다. */
export const downloadExmnBusinessReport = async (attachmentId: string): Promise<Blob> =>
getBlobData(`/attach-service/api/v1/download/${encodeURIComponent(attachmentId)}`);
@@ -104,10 +104,10 @@ export const MANHOLE_DETAIL_DEFECT_COLUMNS: ExmnDetailDefectColumn[] = [
structural("CHm", "균열-수평"),
structural("CVm", "균열-수직"),
structural("SIm", "표면손상-내부"),
structural("SOm", "표면손상-외부"),
structural("Som", "표면손상-외부"),
structural("Dm", "변형(연성계열)"),
structural("BIm", "파손-내부"),
structural("BOm", "파손-외부"),
structural("Bom", "파손-외부"),
structural("LPm", "하수관로 접속부-돌출"),
structural("LSm", "하수관로 접속부-이상"),
structural("JDm", "블록 이음부-단차"),
@@ -8,16 +8,20 @@ import { postPopupMessage } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { Button, Input, Label } from "@common/components/ui";
import { SelectBox } from "@common/business_components/ui";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
createExmnBusiness,
createExmnTemplates,
downloadExmnReportTemplate,
downloadExmnTemplates,
getExmnBusiness,
updateExmnBusiness,
} from "../api/exmn-business-api";
import { getTrtdstSettings } from "../api/trtdst-api";
import { validateExmnBusiness } from "../lib/exmn-business-validation";
import { EXMN_SAMPLE_TRTDST_SETTINGS } from "../model/exmn-sample-data";
import type {
ExmnBusiness,
ExmnBusinessDetail,
ExmnBusinessSaveRequest,
} from "../model/exmn-business-types";
@@ -30,6 +34,8 @@ type Notice = { type: "success" | "error" | "info"; text: string } | null;
interface ExmnBusinessRegisterPopupPageProps {
sampleMode: boolean;
exmnBizId?: string;
initialBusiness?: ExmnBusiness;
}
/** 처리분구 이름을 공백 제거 후 한글 가나다순으로 정렬한다. */
@@ -46,9 +52,18 @@ const saveBlob = (blob: Blob, fileName: string) => {
URL.revokeObjectURL(objectUrl);
};
/** 공통 WindowPopup 안에서 사업정보·대상 처리분구·조사양식을 등록하는 화면이다. */
export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegisterPopupPageProps) {
/** 공통 WindowPopup 안에서 사업정보·대상 처리분구·조사양식을 등록·수정하는 화면이다. */
export function ExmnBusinessRegisterPopupPage({
sampleMode,
exmnBizId,
initialBusiness,
}: ExmnBusinessRegisterPopupPageProps) {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const isEditMode = Boolean(exmnBizId);
/** 상세정보보다 처리분구 후보 조회가 늦게 끝나도 기존 선택을 복원하기 위한 대기 ID다. */
const pendingSelectedDistrictIdsRef = useRef<string[] | null>(null);
/** 개발 모드 재실행이나 사업 변경 시 이전 상세조회 응답이 현재 화면을 덮지 않게 한다. */
const businessRequestIdRef = useRef(0);
/** 사업 확장 시 등록자가 직접 지정할 수 있도록 저장 요청에 전달하는 사업차수다. */
const [bizCycl, setBizCycl] = useState(sampleMode ? "3" : "1");
const [bizYr, setBizYr] = useState(String(new Date().getFullYear()));
@@ -57,7 +72,7 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
const [sggCd, setSggCd] = useState("0");
const [ctpvNm, setCtpvNm] = useState(sampleMode ? "경기도" : "");
const [sggNm, setSggNm] = useState(sampleMode ? "양평군" : "");
const [prcisExmnPrlg, setPrcisExmnPrlg] = useState(sampleMode ? "18.42" : "0");
const [prcisExmnPrlg, setPrcisExmnPrlg] = useState(sampleMode ? "18.42" : "");
const [districts, setDistricts] = useState<TrtdstSettingDto[]>([]);
/** 등록 후보가 많을 때 처리분구명으로 화면 목록을 좁히는 검색어다. */
const [districtKeyword, setDistrictKeyword] = useState("");
@@ -65,6 +80,7 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
const [savedBusiness, setSavedBusiness] = useState<ExmnBusinessDetail | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
const formLocked = Boolean(savedBusiness) && !isEditMode;
/** 팝업 레이아웃은 BaseLayout 초기화를 거치지 않으므로 행정구역 공통코드를 직접 준비한다. */
useEffect(() => {
@@ -72,14 +88,74 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 샘플 팝업은 공통코드가 로드된 뒤 경기도·양평군을 선택 상태로 맞춘다. */
/** 공통코드가 로드되면 서버 상세의 행정구역 이름을 실제 선택 코드로 맞춘다. */
useEffect(() => {
if (!sampleMode) return;
const sampleCtpv = ctpv.find((option) => option.text === "경기도");
const sampleSgg = sgg.find((option) => option.text === "양평군");
if (sampleCtpv) setCtpvCd(String(sampleCtpv.val));
if (sampleSgg) setSggCd(String(sampleSgg.val));
}, [ctpv, sampleMode, sgg]);
const selectedCtpv = ctpv.find((option) => option.text === ctpvNm);
const selectedSgg = sgg.find((option) => option.text === sggNm);
if (selectedCtpv) setCtpvCd(String(selectedCtpv.val));
if (selectedSgg) setSggCd(String(selectedSgg.val));
}, [ctpv, ctpvNm, sgg, sggNm]);
/** 수정 모드에서는 선택 사업의 상세정보와 기존 처리분구 선택을 팝업 입력값으로 복원한다. */
useEffect(() => {
if (!exmnBizId) return;
const requestId = businessRequestIdRef.current + 1;
businessRequestIdRef.current = requestId;
setIsLoading(true);
setNotice(null);
const sampleDetail = initialBusiness
? {
...initialBusiness,
trtdstIds: EXMN_SAMPLE_TRTDST_SETTINGS
.slice(0, initialBusiness.trtdstCnt)
.map((district) => district.trtdstId),
districts: EXMN_SAMPLE_TRTDST_SETTINGS
.slice(0, initialBusiness.trtdstCnt)
.map((district) => ({
trtdstId: district.trtdstId,
trtdstNm: district.trtdstNm,
})),
} satisfies ExmnBusinessDetail
: null;
const request = sampleMode
? Promise.resolve(sampleDetail)
: getExmnBusiness(exmnBizId);
request
.then((business) => {
if (businessRequestIdRef.current !== requestId) return;
if (!business) {
setNotice({ type: "error", text: "수정할 사업정보를 찾지 못했습니다." });
return;
}
pendingSelectedDistrictIdsRef.current = [...business.trtdstIds];
setBizCycl(String(business.bizCycl ?? ""));
setBizYr(business.bizYr);
setBizNm(business.bizNm);
setCtpvNm(business.ctpvNm);
setSggNm(business.sggNm);
setPrcisExmnPrlg(String(business.prcisExmnPrlg));
setSelectedDistrictIds(business.trtdstIds);
setSavedBusiness(business);
})
.catch((error: unknown) => {
if (businessRequestIdRef.current !== requestId) return;
setNotice({
type: "error",
text: error instanceof Error ? error.message : "사업정보 조회 중 오류가 발생했습니다.",
});
})
.finally(() => {
if (businessRequestIdRef.current === requestId) setIsLoading(false);
});
return () => {
if (businessRequestIdRef.current === requestId) {
businessRequestIdRef.current += 1;
}
};
}, [exmnBizId, initialBusiness, sampleMode]);
/** 선택 행정구역의 활성 처리분구를 읽고 화면 요구사항대로 가나다순으로 제공한다. */
useEffect(() => {
@@ -91,12 +167,17 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
}
if (sampleMode) {
setDistricts(sortDistricts(EXMN_SAMPLE_TRTDST_SETTINGS.map((item) => ({
const items = sortDistricts(EXMN_SAMPLE_TRTDST_SETTINGS.map((item) => ({
...item,
ctpvNm,
sggNm,
}))));
setSelectedDistrictIds([]);
})));
setDistricts(items);
const pendingIds = pendingSelectedDistrictIdsRef.current;
setSelectedDistrictIds(pendingIds
? pendingIds.filter((id) => items.some((item) => item.trtdstId === id))
: []);
pendingSelectedDistrictIdsRef.current = null;
return;
}
@@ -105,8 +186,13 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
getTrtdstSettings({ ctpvNm, sggNm, useYn: "Y" })
.then((items) => {
if (!active) return;
setDistricts(sortDistricts(items));
setSelectedDistrictIds([]);
const sortedItems = sortDistricts(items);
setDistricts(sortedItems);
const pendingIds = pendingSelectedDistrictIdsRef.current;
setSelectedDistrictIds(pendingIds
? pendingIds.filter((id) => sortedItems.some((item) => item.trtdstId === id))
: []);
pendingSelectedDistrictIdsRef.current = null;
})
.catch((error: unknown) => {
if (!active) return;
@@ -129,6 +215,30 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
const selectedDistricts = useMemo(() => districts.filter((district) =>
selectedDistrictIds.includes(district.trtdstId)), [districts, selectedDistrictIds]);
/** 수정 후 저장하지 않은 입력이 있으면 기존 사업 기준 양식 다운로드를 차단한다. */
const hasUnsavedChanges = useMemo(() => {
if (!isEditMode || !savedBusiness) return false;
const currentDistrictIds = [...selectedDistrictIds].sort();
const savedDistrictIds = [...savedBusiness.trtdstIds].sort();
return String(savedBusiness.bizCycl ?? "") !== bizCycl.trim()
|| savedBusiness.bizYr !== bizYr.trim()
|| savedBusiness.bizNm !== bizNm.trim()
|| savedBusiness.ctpvNm !== ctpvNm
|| savedBusiness.sggNm !== sggNm
|| Number(savedBusiness.prcisExmnPrlg) !== Number(prcisExmnPrlg)
|| currentDistrictIds.join("|") !== savedDistrictIds.join("|");
}, [
bizCycl,
bizNm,
bizYr,
ctpvNm,
isEditMode,
prcisExmnPrlg,
savedBusiness,
selectedDistrictIds,
sggNm,
]);
/** 공백 차이를 무시한 처리분구명 부분일치 검색 결과다. */
const filteredDistricts = useMemo(() => {
const keyword = districtKeyword.replace(/\s+/g, "").toLocaleLowerCase("ko-KR");
@@ -164,13 +274,17 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
ctpvNm,
sggNm,
prcisExmnPrlg: Number(prcisExmnPrlg),
// 총사업비는 현재 등록 화면 입력 대상이 아니므로 신규 기본값으로 전달한다.
tpco: 0,
// 총사업비는 화면 입력 대상이 아니므로 수정 시 기존 값을 보존한다.
tpco: savedBusiness?.tpco ?? 0,
trtdstIds: selectedDistrictIds,
});
/** 실제 모드에서는 API를 호출하고 샘플 모드에서는 명시적 미리보기 사업만 부모 목록에 전달한다. */
const handleSave = async () => {
if (!prcisExmnPrlg.trim()) {
setNotice({ type: "error", text: "정밀조사 연장을 입력해주세요." });
return;
}
const request = buildRequest();
const validationMessage = validateExmnBusiness(request);
if (validationMessage) {
@@ -181,30 +295,40 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
setIsLoading(true);
setNotice(null);
try {
const created: ExmnBusinessDetail = sampleMode
const persisted: ExmnBusinessDetail = sampleMode
? {
exmnBizId: `SAMPLE-BIZ-${Date.now()}`,
exmnBizId: exmnBizId ?? `SAMPLE-BIZ-${Date.now()}`,
...request,
trtdstCnt: selectedDistrictIds.length,
reportYn: "N",
trtdstIds: [...selectedDistrictIds],
districts: selectedDistricts.map((district) => ({
trtdstId: district.trtdstId,
trtdstNm: district.trtdstNm,
})),
}
: await createExmnBusiness(request);
: isEditMode && exmnBizId
? await updateExmnBusiness(exmnBizId, request)
: await createExmnBusiness(request);
setSavedBusiness(created);
postPopupMessage(WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED, {
exmnBizId: created.exmnBizId,
sample: sampleMode ? "Y" : "N",
business: sampleMode ? JSON.stringify(created) : "",
});
setSavedBusiness(persisted);
postPopupMessage(
isEditMode
? WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_UPDATED
: WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED,
{
exmnBizId: persisted.exmnBizId,
sample: sampleMode ? "Y" : "N",
business: sampleMode ? JSON.stringify(persisted) : "",
},
);
setNotice({
type: "success",
text: sampleMode
? "샘플 사업정보를 저장했습니다. 부모 목록에서 바로 확인할 수 있습니다."
: "사업정보와 처리분구별 기본 요약행을 저장했습니다.",
text: isEditMode
? "사업정보를 수정했습니다. 변경된 처리분구 기준으로 양식을 다시 받을 수 있습니다."
: sampleMode
? "샘플 사업정보를 저장했습니다. 부모 목록에서 바로 확인할 수 있습니다."
: "사업정보와 처리분구별 기본 요약행을 저장했습니다.",
});
} catch (error) {
setNotice({
@@ -222,6 +346,10 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
setNotice({ type: "info", text: "사업정보를 먼저 저장해주세요." });
return;
}
if (hasUnsavedChanges) {
setNotice({ type: "info", text: "수정한 사업정보를 먼저 저장한 뒤 양식을 받아주세요." });
return;
}
if (sampleMode) {
setNotice({ type: "info", text: "샘플 모드에서는 실제 양식 파일을 다운로드하지 않습니다." });
return;
@@ -241,29 +369,43 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
}
};
/** 조사보고서 양식 API는 미확정 상태임을 화면에서 명시한다. */
const handleReportTemplate = () => {
setNotice({ type: "info", text: "조사보고서 양식 API가 확정되면 다운로드 기능을 연결합니다." });
/** 백엔드 classpath의 하수관로·맨홀 현황조사 보고서 기준 XLSX를 내려받는다. */
const handleReportTemplate = async () => {
setIsLoading(true);
setNotice(null);
try {
saveBlob(
await downloadExmnReportTemplate(),
"하수관로맨홀_현황조사_보고서_양식.xlsx",
);
} catch (error) {
setNotice({
type: "error",
text: error instanceof Error ? error.message : "조사보고서 양식 다운로드 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
}
};
return (
<main className="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">사업정보 등록</h1>
<h1 className="sn-popup-title">{isEditMode ? "사업정보 수정" : "사업정보 등록"}</h1>
<div className="flex items-center gap-2">
<Button
type="button"
color="white"
size="xs"
className="sn-popup-header-button"
disabled={isLoading || Boolean(savedBusiness)}
disabled={isLoading || formLocked}
onClick={() => void handleSave()}
>
저장
{isEditMode ? "수정" : "저장"}
</Button>
<Button color="green" className={DOWNLOAD_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleDownloadTemplates()}>양식 받기</Button>
<Button color="green" className={DOWNLOAD_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={handleReportTemplate}>조사보고서 양식</Button>
<Button color="green" className={DOWNLOAD_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleReportTemplate()}>조사보고서 양식</Button>
</div>
</header>
@@ -292,7 +434,7 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
inputMode="numeric"
maxLength={3}
value={bizCycl}
disabled={Boolean(savedBusiness)}
disabled={formLocked}
onChange={(event) => {
if (/^\d{0,3}$/.test(event.target.value)) setBizCycl(event.target.value);
}}
@@ -305,7 +447,7 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
selectClass="w-[6.5rem] bg-white"
selectArray={getStartYearArray({ placeholder: "선택" })}
value={bizYr}
readOnly={Boolean(savedBusiness)}
readOnly={formLocked}
onSelectValue={(value) => setBizYr(String(value))}
/>
<CtpvSggPrcsFcltSelectBox
@@ -314,19 +456,19 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(nextCtpvCd) => {
if (savedBusiness) return;
if (formLocked) return;
setCtpvCd(nextCtpvCd);
setSggCd("0");
setSggNm("");
}}
onSigunguChange={(nextSggCd) => {
if (!savedBusiness) setSggCd(nextSggCd);
if (!formLocked) setSggCd(nextSggCd);
}}
setCtpvName={(nextCtpvNm) => {
if (!savedBusiness) setCtpvNm(nextCtpvNm === "전체" ? "" : nextCtpvNm);
if (!formLocked) setCtpvNm(nextCtpvNm === "전체" ? "" : nextCtpvNm);
}}
setSggName={(nextSggNm) => {
if (!savedBusiness) setSggNm(nextSggNm === "전체" ? "" : nextSggNm);
if (!formLocked) setSggNm(nextSggNm === "전체" ? "" : nextSggNm);
}}
/>
<div className="flex items-center gap-2">
@@ -337,17 +479,15 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
inputMode="decimal"
maxLength={10}
value={prcisExmnPrlg}
disabled={Boolean(savedBusiness)}
onChange={(event) => {
if (/^\d*\.?\d*$/.test(event.target.value)) setPrcisExmnPrlg(event.target.value);
}}
disabled={formLocked}
onChange={(event) => setPrcisExmnPrlg(event.target.value)}
/>
<span className="text-sm text-gray-500">km</span>
</div>
</div>
<div className="flex items-center gap-3">
<Label className="w-20 shrink-0 text-base"><span className="text-red-500">* </span>사업명</Label>
<Input style={{ maxWidth: "480px" }} value={bizNm} maxLength={200} disabled={Boolean(savedBusiness)} onChange={(event) => setBizNm(event.target.value)} />
<Input style={{ maxWidth: "480px" }} value={bizNm} maxLength={200} disabled={formLocked} onChange={(event) => setBizNm(event.target.value)} />
</div>
</div>
</section>
@@ -361,7 +501,7 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
className="sn-popup-input-sm !h-9 w-[22rem] bg-white"
placeholder="처리분구명 검색"
aria-label="처리분구명 검색"
disabled={Boolean(savedBusiness)}
disabled={formLocked}
onChange={(event) => setDistrictKeyword(event.target.value)}
/>
<div className="ml-auto flex items-center gap-2">
@@ -370,7 +510,7 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
color="white"
size="xs"
className="sn-popup-secondary-button-sm"
disabled={Boolean(savedBusiness) || filteredDistricts.length === 0}
disabled={formLocked || filteredDistricts.length === 0}
onClick={handleToggleFilteredDistricts}
>
{allFilteredDistrictsSelected ? "검색결과 해제" : "검색결과 선택"}
@@ -380,7 +520,7 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
color="white"
size="xs"
className="sn-popup-secondary-button-sm"
disabled={Boolean(savedBusiness) || selectedDistrictIds.length === 0}
disabled={formLocked || selectedDistrictIds.length === 0}
onClick={() => setSelectedDistrictIds([])}
>
선택 초기화
@@ -401,7 +541,7 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
type="checkbox"
className="size-4 accent-blue-600"
checked={selectedDistrictIds.includes(district.trtdstId)}
disabled={Boolean(savedBusiness)}
disabled={formLocked}
onChange={(event) => setSelectedDistrictIds((current) => event.target.checked
? [...current, district.trtdstId]
: current.filter((id) => id !== district.trtdstId))}
@@ -110,6 +110,8 @@ const sortBusinessList = (
/** 사업정보 조회·등록과 선택 사업의 조사결과 등록 진입을 제공하는 SFR-010-004 메인 화면이다. */
export function ExmnRegistrationPage() {
const pageRef = useRef<HTMLDivElement>(null);
/** React 개발 모드의 effect 재실행에서도 최초 목록 조회를 한 번만 수행한다. */
const initialBusinessesLoadedRef = useRef(false);
// 입력 중 조건과 실제 서버 조회 조건을 분리해 정렬 시 동일 조건을 재사용한다.
const [draftFilters, setDraftFilters] = useState<BusinessFilters>(EMPTY_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<BusinessFilters>(EMPTY_FILTERS);
@@ -164,15 +166,6 @@ export function ExmnRegistrationPage() {
};
}, []);
/** URL에 sample=Y가 명시된 경우에만 실제 API와 분리된 화면 검증용 사업목록을 노출한다. */
useEffect(() => {
const enabled = new URLSearchParams(window.location.search).get("sample") === "Y";
setSampleMode(enabled);
if (!enabled) return;
setBusinesses(sampleBusinesses);
setTotal(sampleBusinesses.length);
}, []);
const selectedBusiness = useMemo(
() => businesses.find((business) => business.exmnBizId === selectedBusinessId) ?? null,
[businesses, selectedBusinessId],
@@ -279,6 +272,22 @@ export function ExmnRegistrationPage() {
}
};
/** URL에 sample=Y가 명시된 경우에만 실제 API와 분리된 화면 검증용 사업목록을 노출한다. */
useEffect(() => {
if (initialBusinessesLoadedRef.current) return;
initialBusinessesLoadedRef.current = true;
const enabled = new URLSearchParams(window.location.search).get("sample") === "Y";
setSampleMode(enabled);
if (enabled) {
setBusinesses(sampleBusinesses);
setTotal(sampleBusinesses.length);
return;
}
void fetchBusinesses(EMPTY_FILTERS, undefined);
// 최초 진입 시점의 빈 조회조건으로 한 번만 실행한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/** 입력 중 조건을 확정하고 첫 페이지를 조회한다. */
const handleSearch = async () => {
setAppliedFilters(draftFilters);
@@ -324,6 +333,24 @@ export function ExmnRegistrationPage() {
}
};
/** 선택 사업을 기존 등록 팝업의 수정 모드로 열어 기본정보·처리분구·양식을 다시 관리한다. */
const openBusinessUpdate = () => {
if (!selectedBusiness) {
setNotice({ type: "error", text: "사업 목록에서 수정할 사업을 먼저 선택해주세요." });
return;
}
setNotice(null);
const popup = openWindowPopup("exmnBusinessRegister", {
exmnBizId: selectedBusiness.exmnBizId,
sample: sampleMode ? "Y" : undefined,
business: sampleMode ? JSON.stringify(selectedBusiness) : undefined,
sourcePath: window.location.pathname,
});
if (!popup) {
setNotice({ type: "error", text: "사업수정 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요." });
}
};
/** 선택 사업 컨텍스트를 전달해 공통 WindowPopup 결과등록 화면을 연다. */
const openResultRegister = () => {
if (!selectedBusiness) {
@@ -362,20 +389,41 @@ export function ExmnRegistrationPage() {
setSampleBusinesses((current) => [created, ...current.filter((item) => item.exmnBizId !== created.exmnBizId)]);
setBusinesses((current) => [created, ...current.filter((item) => item.exmnBizId !== created.exmnBizId)]);
setTotal((current) => current + 1);
setSelectedBusinessId(created.exmnBizId);
setSelectedBusinessId(null);
setNotice({ type: "success", text: "샘플 조사사업이 등록되었습니다." });
} catch {
setNotice({ type: "error", text: "샘플 사업정보를 부모 목록에 반영하지 못했습니다." });
}
return;
}
setSelectedBusinessId(payload?.exmnBizId ?? null);
setSelectedBusinessId(null);
void fetchBusinesses(appliedFilters, sort).then((refreshed) => {
if (refreshed) setNotice({ type: "success", text: "조사사업이 등록되었습니다." });
});
return;
}
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_UPDATED) {
const payload = event.data?.payload as { sample?: string; business?: string } | undefined;
if (payload?.sample === "Y" && payload.business) {
try {
const updated = JSON.parse(payload.business) as ExmnBusiness;
setSampleBusinesses((current) => current.map((item) =>
item.exmnBizId === updated.exmnBizId ? updated : item));
setBusinesses((current) => current.map((item) =>
item.exmnBizId === updated.exmnBizId ? updated : item));
setNotice({ type: "success", text: "샘플 조사사업이 수정되었습니다." });
} catch {
setNotice({ type: "error", text: "수정된 샘플 사업정보를 부모 목록에 반영하지 못했습니다." });
}
return;
}
void fetchBusinesses(appliedFilters, sort).then((refreshed) => {
if (refreshed) setNotice({ type: "success", text: "조사사업이 수정되었습니다." });
});
return;
}
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED) {
const payload = event.data?.payload as { sample?: string } | undefined;
if (payload?.sample === "Y") {
@@ -438,6 +486,7 @@ export function ExmnRegistrationPage() {
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleSearch()}>조회</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={openBusinessUpdate}>수정</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={openBusinessRegister}>사업등록</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={openResultRegister}>결과등록</Button>
</div>
@@ -32,6 +32,7 @@ export type OpenWindowPopupParams = Record<
export const WINDOW_POPUP_MESSAGE_TYPE = {
EXMN_BUSINESS_CREATED: "sn:popup:exmnBusinessCreated",
EXMN_BUSINESS_UPDATED: "sn:popup:exmnBusinessUpdated",
EXMN_RESULT_COMMITTED: "sn:popup:exmnResultCommitted",
OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted",
FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted",
@@ -1,6 +1,7 @@
"use client";
import { ExmnBusinessRegisterPopupPage } from "@/features/exmn/ui/ExmnBusinessRegisterPopupPage";
import type { ExmnBusiness } from "@/features/exmn/model/exmn-business-types";
import { useEffect, useState } from "react";
import type { WindowPopupContentProps } from "../model/types";
@@ -8,6 +9,17 @@ import type { WindowPopupContentProps } from "../model/types";
const getParam = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] ?? "" : value ?? "";
/** 샘플 수정 팝업에 전달된 사업 JSON을 안전하게 복원한다. */
const getBusiness = (value: string | string[] | undefined): ExmnBusiness | undefined => {
const text = getParam(value);
if (!text) return undefined;
try {
return JSON.parse(text) as ExmnBusiness;
} catch {
return undefined;
}
};
/** 조사사업 등록 WindowPopup을 실제 업무 화면에 연결한다. */
export default function ExmnBusinessRegisterPopupContent({ searchParams }: WindowPopupContentProps) {
const [isMounted, setIsMounted] = useState(false);
@@ -20,5 +32,11 @@ export default function ExmnBusinessRegisterPopupContent({ searchParams }: Windo
return <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
}
return <ExmnBusinessRegisterPopupPage sampleMode={getParam(searchParams.sample) === "Y"} />;
return (
<ExmnBusinessRegisterPopupPage
sampleMode={getParam(searchParams.sample) === "Y"}
exmnBizId={getParam(searchParams.exmnBizId) || undefined}
initialBusiness={getBusiness(searchParams.business)}
/>
);
}