From 62c41297c4af1c56c16f5c4ef89c4cdaa8de6930 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Tue, 28 Jul 2026 13:28:15 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A1=B0=EC=82=AC=EC=82=AC=EC=97=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=A1=B0=EC=82=AC=EC=96=91?= =?UTF-8?q?=EC=8B=9D=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../002-qa-SFR-010-004~007-조사사업-통합검증결과.md | 21 ++ .../001-fe-SFR-010-004~007-조사사업-통합구현기록.md | 76 +++++- .../features/exmn/api/exmn-business-api.ts | 22 +- .../features/exmn/model/exmn-detail-table.ts | 4 +- .../exmn/ui/ExmnBusinessRegisterPopupPage.tsx | 244 ++++++++++++++---- .../features/exmn/ui/ExmnRegistrationPage.tsx | 71 ++++- .../src/features/windowPopup/model/types.ts | 1 + .../ui/ExmnBusinessRegisterPopupContent.tsx | 20 +- 8 files changed, 386 insertions(+), 73 deletions(-) diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md index c0fe7242b..7639f9bfc 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md @@ -243,3 +243,24 @@ - 공통 행정구역 컴포넌트의 `compact` 기본값이 `false`여서 기존 호출 화면에는 새 폭이 자동 적용되지 않는다. - 변경 파일을 포함한 프론트 저장소 `git diff --check`를 통과했다. - 로그인되지 않은 브라우저에서는 사업등록 경로가 로그인 화면으로 이동해 최종 팝업 시각 확인을 수행하지 못했다. 사용자 화면 확인을 최종 완료 조건으로 유지한다. + +## 17. 2026-07-28 조사사업 실제 API·양식 다운로드 검증 결과 + +| 검증 항목 | 결과 | 비고 | +| --- | --- | --- | +| 페이지 최초 조사사업 조회 | 통과 | 사용자 로그인 화면 확인 | +| 조사사업 등록 후 목록 갱신 | 통과 | 저장 결과 즉시 표시 | +| 등록 후 상세현황 자동 열림 방지 | 통과 | 목록만 갱신 | +| 기존 사업 수정 팝업 정보 복원 | 통과 | 기본정보·처리분구 복원 | +| 정밀조사연장 숫자 입력 | 통과 | 입력·저장 확인 | +| 처리분구별 조사 입력양식 다운로드 | 통과 | 다중 시트 생성 확인 | +| 본단위등급표 동적 관로번호 | 통과 | `#REF!` 없이 표시 | +| 첫 처리분구만 예시 데이터 유지 | 통과 | 이후 시트는 빈 입력 양식 | +| 이후 관로조사 글꼴 검정색 | 통과 | 사용자 최종 확인 | +| 결과 미등록 상세현황 | 통과 | 오류 배너 없이 조회 결과 없음 표시 | +| 조사보고서 양식 최종 클릭 | 확인 필요 | API와 프론트 호출 구현 완료 | + +### 남은 검증 + +- 다음 파일 업로드 단계에서 생성 양식 업로드와 Excel 파서 호환성을 확인한다. +- 지도보기는 기존 보류 범위를 유지한다. diff --git a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md index 18b397d5a..1cea607a5 100644 --- a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md @@ -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, 조사 입력·보고서 양식 다운로드, 상세현황 빈 상태에 영향을 준다. +- 조사 상세보기 표와 지도보기 보류 범위는 변경하지 않는다. diff --git a/apps/sn/src/features/exmn/api/exmn-business-api.ts b/apps/sn/src/features/exmn/api/exmn-business-api.ts index 4c9e8d90a..cdb798525 100644 --- a/apps/sn/src/features/exmn/api/exmn-business-api.ts +++ b/apps/sn/src/features/exmn/api/exmn-business-api.ts @@ -65,12 +65,18 @@ export const getExmnSectionSummaries = async ( jobType: ExmnJobType, ): Promise => { type SectionSummaryPayload = ExmnSectionSummaryRow[] | Partial>; - const response = await getData( - `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/sections?jobType=${jobType}`, - ) as SectionSummaryPayload | { data?: SectionSummaryPayload }; - const payload = unwrapData(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(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 => getBlobData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates/download`); +/** 하수관로·맨홀 현황조사 보고서 기준 XLSX를 내려받는다. */ +export const downloadExmnReportTemplate = async (): Promise => + getBlobData(`${EXMN_BUSINESS_API_PATH}/templates/report/download`); + /** 사업보고서 첨부파일 ID로 기존 첨부서비스의 원본 파일을 내려받는다. */ export const downloadExmnBusinessReport = async (attachmentId: string): Promise => getBlobData(`/attach-service/api/v1/download/${encodeURIComponent(attachmentId)}`); diff --git a/apps/sn/src/features/exmn/model/exmn-detail-table.ts b/apps/sn/src/features/exmn/model/exmn-detail-table.ts index edbd79268..5b2321694 100644 --- a/apps/sn/src/features/exmn/model/exmn-detail-table.ts +++ b/apps/sn/src/features/exmn/model/exmn-detail-table.ts @@ -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", "블록 이음부-단차"), diff --git a/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx index 2417cb17a..a12097f4b 100644 --- a/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx @@ -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(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([]); /** 등록 후보가 많을 때 처리분구명으로 화면 목록을 좁히는 검색어다. */ const [districtKeyword, setDistrictKeyword] = useState(""); @@ -65,6 +80,7 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist const [savedBusiness, setSavedBusiness] = useState(null); const [isLoading, setIsLoading] = useState(false); const [notice, setNotice] = useState(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 (
-

사업정보 등록

+

{isEditMode ? "사업정보 수정" : "사업정보 등록"}

- +
@@ -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))} /> { - 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); }} />
@@ -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)} /> km
- setBizNm(event.target.value)} /> + setBizNm(event.target.value)} />
@@ -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)} />
@@ -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))} diff --git a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx index 78f76b417..b32130073 100644 --- a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx @@ -110,6 +110,8 @@ const sortBusinessList = ( /** 사업정보 조회·등록과 선택 사업의 조사결과 등록 진입을 제공하는 SFR-010-004 메인 화면이다. */ export function ExmnRegistrationPage() { const pageRef = useRef(null); + /** React 개발 모드의 effect 재실행에서도 최초 목록 조회를 한 번만 수행한다. */ + const initialBusinessesLoadedRef = useRef(false); // 입력 중 조건과 실제 서버 조회 조건을 분리해 정렬 시 동일 조건을 재사용한다. const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS); const [appliedFilters, setAppliedFilters] = useState(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() {
+
diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts index 9a97f535a..c0527bb6e 100644 --- a/apps/sn/src/features/windowPopup/model/types.ts +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -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", diff --git a/apps/sn/src/features/windowPopup/ui/ExmnBusinessRegisterPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ExmnBusinessRegisterPopupContent.tsx index c352c919b..5b6975b29 100644 --- a/apps/sn/src/features/windowPopup/ui/ExmnBusinessRegisterPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/ExmnBusinessRegisterPopupContent.tsx @@ -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
; } - return ; + return ( + + ); }